Newer
Older
SmTIAS-Capture / android / app / src / main / kotlin / com / example / mini_tias / ImageStatistics.kt
package com.example.mini_tias

import android.media.Image
import java.nio.ByteOrder
import java.nio.ShortBuffer

/**
 * RAW_SENSOR 画像から画素統計を計算するユーティリティ.
 *
 * Bayer 配列 (BGGR) を 2×2 ブロック単位で 1 パス走査し,チャネル別の
 * 平均・最大・P99 値,飽和率・低露光率を返す.meta.json の `image_statistics` に格納する.
 */
internal object ImageStatistics {

    const val SATURATION_THRESHOLD = 1000
    const val UNDEREXPOSED_THRESHOLD = 100
    const val STATISTICS_HISTOGRAM_BINS = 256

    /** 10-bit RAW センサー値の範囲(0〜1023 の 1024 通り).ヒストグラム bin 変換に使用する.*/
    const val RAW_10BIT_RANGE = 1024

    /**
     * [image](RAW_SENSOR)から画素統計を計算する.
     *
     * 計算失敗時は呼び出し元で null として扱う.
     */
    fun compute(image: Image): Map<String, Any?> {
        val plane = image.planes[0]
        val buffer = plane.buffer
        buffer.rewind()
        buffer.order(ByteOrder.LITTLE_ENDIAN)
        return computeFromShortBuffer(
            buffer.asShortBuffer(),
            plane.rowStride / 2,
            image.width,
            image.height,
        )
    }

    /**
     * [shortBuf](10-bit RAW を short で保持)から画素統計を計算する.
     *
     * `android.media.Image` に依存しない純粋な数理計算のため,JVM 単体テスト(JUnit)で
     * 検証できる.[compute] はバッファ読み出しのみを担い,本関数に委譲する.
     */
    internal fun computeFromShortBuffer(
        shortBuf: ShortBuffer,
        rowStrideShorts: Int,
        srcW: Int,
        srcH: Int,
    ): Map<String, Any?> {
        // 集計用変数
        var sumR = 0.0; var sumG = 0.0; var sumB = 0.0
        var countR = 0; var countG = 0; var countB = 0
        var maxR = 0; var maxG = 0; var maxB = 0
        var saturatedCount = 0
        var underexposedCount = 0
        val histR = IntArray(STATISTICS_HISTOGRAM_BINS)
        val histG = IntArray(STATISTICS_HISTOGRAM_BINS)
        val histB = IntArray(STATISTICS_HISTOGRAM_BINS)

        val row1 = ShortArray(rowStrideShorts)
        val row2 = ShortArray(rowStrideShorts)

        // 2 行ずつ読み,2×2 BGGR ブロックを処理
        var y = 0
        while (y < srcH - 1) {
            shortBuf.position(y * rowStrideShorts)
            shortBuf.get(row1, 0, rowStrideShorts)
            shortBuf.position((y + 1) * rowStrideShorts)
            shortBuf.get(row2, 0, rowStrideShorts)

            var x = 0
            while (x < srcW - 1) {
                val b = row1[x].toInt() and 0x3FF       // (0,0) B
                val g1 = row1[x + 1].toInt() and 0x3FF  // (0,1) G
                val g2 = row2[x].toInt() and 0x3FF      // (1,0) G
                val r = row2[x + 1].toInt() and 0x3FF   // (1,1) R

                // チャネル別集計
                sumR += r; countR++; if (r > maxR) maxR = r
                sumB += b; countB++; if (b > maxB) maxB = b
                val gAvg = (g1 + g2) / 2
                sumG += gAvg; countG++; if (gAvg > maxG) maxG = gAvg

                // ヒストグラム(10-bit 値 → 256 bin にマップ)
                histR[
                    (r * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE)
                        .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1)
                ]++
                histG[
                    (gAvg * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE)
                        .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1)
                ]++
                histB[
                    (b * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE)
                        .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1)
                ]++

                // 飽和率・低露光率は全 Bayer 画素を独立にカウント(4 画素分)
                for (v in intArrayOf(b, g1, g2, r)) {
                    if (v >= SATURATION_THRESHOLD) saturatedCount++
                    if (v <= UNDEREXPOSED_THRESHOLD) underexposedCount++
                }

                x += 2
            }
            y += 2
        }

        val totalBayerPixels = srcW * srcH
        val meanR = if (countR > 0) sumR / countR else 0.0
        val meanG = if (countG > 0) sumG / countG else 0.0
        val meanB = if (countB > 0) sumB / countB else 0.0

        val p99R = p99Bin(histR, countR)
        val p99G = p99Bin(histG, countG)
        val p99B = p99Bin(histB, countB)

        return mapOf(
            "mean_per_channel" to mapOf("R" to meanR, "G" to meanG, "B" to meanB),
            "max_per_channel" to mapOf("R" to maxR, "G" to maxG, "B" to maxB),
            "p99_per_channel" to mapOf("R" to p99R, "G" to p99G, "B" to p99B),
            "saturated_pixel_ratio" to (saturatedCount.toDouble() / totalBayerPixels),
            "underexposed_pixel_ratio" to (underexposedCount.toDouble() / totalBayerPixels),
            "thresholds" to mapOf(
                "saturated" to SATURATION_THRESHOLD,
                "underexposed" to UNDEREXPOSED_THRESHOLD,
            ),
        )
    }

    /** ヒストグラム [hist](総数 [totalCount])から P99 に相当する 10-bit 値を求める. */
    private fun p99Bin(hist: IntArray, totalCount: Int): Int {
        val target = (totalCount * 0.99).toInt()
        var cumulative = 0
        for (bin in hist.indices) {
            cumulative += hist[bin]
            if (cumulative >= target) return (bin + 1) * RAW_10BIT_RANGE / STATISTICS_HISTOGRAM_BINS - 1
        }
        return RAW_10BIT_RANGE - 1
    }
}