package com.example.smtias_capture
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
}
}