diff --git a/CLAUDE.md b/CLAUDE.md index f722a1d..945c208 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: プレビュー JPEG をライブビュー(自動 ISP)スナップショットに切替 +最新: フェーズ A 対応(画素統計・Hot pixel map を meta.json に追加,露光 1/120s 確定) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt index 04c9e13..938b9f8 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -6,6 +6,7 @@ import android.graphics.Color import android.graphics.ImageFormat import android.graphics.Matrix +import android.graphics.Point import android.graphics.Rect import android.graphics.YuvImage import android.hardware.camera2.* @@ -26,6 +27,7 @@ import java.io.File import java.io.FileOutputStream import java.nio.ByteBuffer +import java.nio.ByteOrder import java.time.Instant import java.util.zip.CRC32 import java.util.zip.Deflater @@ -53,12 +55,17 @@ private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } private companion object { - const val DEFAULT_EXPOSURE_TIME_NS = 16_666_666L + const val DEFAULT_EXPOSURE_TIME_NS = 8_333_333L // 1/120s const val DEFAULT_SENSITIVITY = 40 const val DEFAULT_FRAME_DURATION_NS = 33_333_333L const val RAW_WIDTH = 3264 const val RAW_HEIGHT = 2448 const val FRAMES_TO_DISCARD = 2 + 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 } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -417,6 +424,8 @@ set(CaptureRequest.SHADING_MODE, CaptureRequest.SHADING_MODE_HIGH_QUALITY) set(CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE, CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE_ON) + // Hot pixel map を有効化(STATISTICS_HOT_PIXEL_MAP_MODE は Boolean 型) + set(CaptureRequest.STATISTICS_HOT_PIXEL_MAP_MODE, true) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, @@ -516,6 +525,32 @@ } Log.d("RawCapturePlugin", "DNG written: ${dngFile.length()} bytes to ${dngFile.absolutePath}") + // 画素統計を計算(DNG 書き込み後・image.close() 前) + val imageStatistics: Map? = try { + computeImageStatistics(image) + } catch (e: Exception) { + Log.w("RawCapturePlugin", "Image statistics computation failed", e) + null + } + + // Hot pixel map を CaptureResult から取得 + val hotPixelMapData: Map = try { + val hotPixelArray = captureResult.get(CaptureResult.STATISTICS_HOT_PIXEL_MAP) + mapOf( + "mode" to "ON", + "count" to (hotPixelArray?.size ?: 0), + "coordinates" to (hotPixelArray?.map { listOf(it.x, it.y) } + ?: emptyList>()), + ) + } catch (e: Exception) { + Log.w("RawCapturePlugin", "Hot pixel map retrieval failed", e) + mapOf("mode" to "ON", "count" to 0, + "coordinates" to emptyList>()) + } + + Log.d("RawCapturePlugin", + "Image stats: $imageStatistics, hot pixels: ${hotPixelMapData["count"]}") + // LSC マップを取得 val lscMap = captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) @@ -531,7 +566,8 @@ // メタデータを構築 val metadataMap = buildMetadataMap( - cameraId, characteristics, captureResult, image + cameraId, characteristics, captureResult, image, + imageStatistics, hotPixelMapData, ) image.close() @@ -564,12 +600,122 @@ } } + /** + * RAW_SENSOR 画像から画素統計を計算する. + * + * Bayer 配列 (BGGR) を 2×2 ブロック単位で処理し, + * チャネル別の平均・最大・P99 値,飽和率・低露光率,ヒストグラムを返す. + * 計算失敗時は呼び出し元で null として扱う. + */ + private fun computeImageStatistics(image: android.media.Image): Map { + val plane = image.planes[0] + val buffer = plane.buffer + buffer.rewind() + buffer.order(ByteOrder.LITTLE_ENDIAN) + val shortBuf = buffer.asShortBuffer() + val rowStrideShorts = plane.rowStride / 2 + val srcW = image.width + val srcH = image.height + + // 集計用変数 + 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 + + 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 + } + + 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, + ), + ) + } + /// 撮影メタデータ Map を構築する. private fun buildMetadataMap( cameraId: String, characteristics: CameraCharacteristics, captureResult: TotalCaptureResult, image: android.media.Image, + imageStatistics: Map? = null, + hotPixelMap: Map? = null, ): Map { val settingsMap = mapOf( "control_mode" to "OFF", @@ -585,6 +731,7 @@ "tonemap_mode" to "CONTRAST_CURVE_LINEAR", "shading_mode" to "HIGH_QUALITY", "statistics_lens_shading_map_mode" to "ON", + "statistics_hot_pixel_map_mode" to "ON", "af_mode" to "OFF", "black_level_lock" to true, ) @@ -658,6 +805,8 @@ "settings" to settingsMap, "actual" to actualMap, "sensorCharacteristics" to sensorCharacteristicsMap, + "image_statistics" to imageStatistics, + "hot_pixel_map" to hotPixelMap, ) } diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" index 6613cbb..edaa6a8 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" @@ -151,7 +151,7 @@ | 画像フォーマット | `RAW_SENSOR`(10-bit BGGR Bayer).`DngCreator` で DNG ファイル出力 | | `CONTROL_MODE` | OFF(マニュアル全制御) | | `CONTROL_AE_MODE` | OFF | -| `SENSOR_EXPOSURE_TIME` | 16,666,666 ns(1/60 s)固定 | +| `SENSOR_EXPOSURE_TIME` | 8,333,333 ns(1/120 s)固定(アタッチメント + LED 環境で線形領域中央狙い.TECH_04 目標 mean G = 400〜600 達成済み) | | `SENSOR_SENSITIVITY` | 40(センサ最低 ISO)固定 | | `SENSOR_FRAME_DURATION` | 33,333,333 ns(30fps 相当) | | `BLACK_LEVEL_LOCK` | true | @@ -210,7 +210,7 @@ LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) │ ▼ -メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap) +メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap / image_statistics / hot_pixel_map) │ ▼ Dart 側で JSON 保存 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 82615ad..a8ac6de 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -59,3 +59,21 @@ - 設計判断: ライブプレビューの絵を「研究データには使わない確認用」と割り切る.DNG は引き続きマニュアル制御の RAW.Preview のソースが自動 ISP かマニュアルかは独立に決められる.既存の `capturePreviewSnapshot` は保存中オーバーレイで既に使われていたので新規 API 追加なし - 失敗パターン: ① 自前 demosaic + ガンマ 2.2 → 緑かぶり ② グレーワールド AWB → 緑かぶり解消だが暗い・フラット ③ 自動レベル + sRGB トーンカーブ → さらに改善も自動 ISP に届かない → ライブビュースナップショットを直接使う方針に転換.結果コード ~200 行削減 + ライブビュー完全一致 - 後段: 解像度がプレビュー由来(おそらく 1280×720)になるが研究用途では十分.画素統計 meta 追加,hot pixel マップ,連射機能 V1 検証 + +## SmTIAS-LightSim 側との合意(TECH_03/04) + +- 状況: 実装報告 TECH_03 を SmTIAS-LightSim 側に送付 → 回答 TECH_04 受領.`docs/05_TECH/` に両方配置. +- 主要決定: + - SHADING_MODE: **HIGH_QUALITY 維持**.Mitsuba orthographic 出力は「完璧に LSC 補正できた場合の理想値」相当のため HQ DNG と直接比較が物理整合.LSC マップは検証用に温存 + - 作業分け: **フェーズ A(1 ショット対応検証)→ フェーズ B(連射 V1)** の二段階.連射 UI はフェーズ B に降格 + - 露光・ISO 目安: 白板 mean = 400〜600(10-bit 黒レベル除去後),p99 < 836 + - 転送: adb pull 推奨.MTP は禁止しないが大容量 DNG で不整合リスク + +## フェーズ A 対応(画素統計・Hot pixel map 追加,露光 1/120s 確定) + +- 状況: meta.json に `image_statistics`(mean/max/p99/saturated_pixel_ratio per channel + thresholds)と `hot_pixel_map`(mode/count/coordinates)を追加.`STATISTICS_HOT_PIXEL_MAP_MODE = true` を CaptureRequest に追加.露光時間 1/60s → 1/120s に確定 +- 動機: TECH_04 フェーズ A 要請.飽和判定の即時化と外れ値マスクのため +- 設計判断: 既存 `buildMetadataMap` のシグネチャ拡張(既定値 null で後方互換).Bayer 2×2 BGGR 走査の中で 1 パスで mean/max/ヒストグラム/飽和率/低露光率を同時計算.p99 はチャネルごとに 256bin ヒストグラム → 累積で 99% 探索.Hot pixel map は `STATISTICS_HOT_PIXEL_MAP_MODE` が Android 仕様で Boolean 型(`*_ON` 定数は存在しない).画素統計失敗時は `image_statistics: null` で続行(撮影成功は保証) +- 失敗パターン: 露光時間調整で 3 ステップ要した — ① 1/60s(初期値)→ 飽和率 21.7%(mean G 887).完全に過露光 ② 1/250s → 飽和率 0% だが mean G 207(黒レベル除去後)と暗すぎ ③ 1/120s → mean G 461(黒レベル除去後)で TECH_04 目標 400〜600 中央.飽和率 0%.**線形領域に完全に収まる確定値** +- アタッチメント + LED ON 環境での測定値(基準値として記録): mean R/G/B (raw) = 349/525/325,p99 G = 879,飽和率 0%,underexposed 0.002% +- 後段: フェーズ A サンプルを SmTIAS-LightSim 側 `reference_measurement/quantitative/` に adb pull 転送 → 1 ショット シミュ vs 実機の突合.残差が縮めばフェーズ B(連射 V1)へ