diff --git a/CLAUDE.md b/CLAUDE.md index 4c2a0e6..945c208 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 定量モード撮影機能(DNG 経路)完成 +最新: フェーズ 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 a80cd1c..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 @@ } } - /** 撮影メタデータ Map を構築する.*/ + /** + * 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/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" index 5755ce1..2ce1550 100644 --- "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" @@ -95,12 +95,14 @@ | 項目 | 仕様 | | --- | --- | -| 保存形式(定量モード) | DNG(RAW_SENSOR 10-bit BGGR Bayer)+ メタデータ JSON | +| 保存形式(定量モード) | DNG(RAW_SENSOR 10-bit BGGR Bayer)+ メタデータ JSON + プレビュー JPEG | | 保存先 | `Pictures/MiniTIAS/` (共有ストレージ) | -| 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(約 16 MB) | +| RAW 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(解析用本体,約 16 MB) | | メタデータファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json`(撮影設定・実適用値・LSC マップ・センサ特性を内包) | -| 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`,`_2.*` ... と連番を付与 | +| プレビュー画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg`(シャッター時のライブプレビュー JPEG をそのまま保存.アプリ内表示・確認用,解像度はプレビューストリーム依存) | +| 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`/`.preview.jpg`,`_2.*` ... と連番を付与 | | データ転送 | USB 接続で PC からフォルダを直接参照・コピー.大きい DNG は `adb pull` を推奨(MTP は不安定なことあり) | +| 削除 | `.preview.jpg` 削除時は同 baseName の `.dng` / `.meta.json` も連動削除 | | 旧 PNG 形式 | `MiniTIAS_YYYYMMDD_HHmmss.png`(コードは温存,現状の `takePicture` からは呼ばれない) | ### 将来的な拡張 (Future Enhancements) 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 8045ff8..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,14 +210,19 @@ LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) │ ▼ -メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap) +メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap / image_statistics / hot_pixel_map) │ ▼ Dart 側で JSON 保存 → Pictures/MiniTIAS/{baseName}.meta.json │ ▼ -MediaStore 通知(DNG / meta.json 両方を scanFile) +[並行] シャッター押下時に取得済みのライブプレビュースナップショット JPEG を保存 + → Pictures/MiniTIAS/{baseName}.preview.jpg + ※ スナップショット取得失敗時は preview.jpg を保存しない(撮影全体は失敗扱いにしない) + │ + ▼ +MediaStore 通知(DNG / meta.json / preview.jpg すべて scanFile) │ ▼ Camera2 を閉じ,プレビューを再開 @@ -243,9 +248,16 @@ ### ファイル命名規則 -- 基本形式: `MiniTIAS_YYYYMMDD_HHmmss.png` -- 同秒重複時: `MiniTIAS_YYYYMMDD_HHmmss_1.png`,`MiniTIAS_YYYYMMDD_HHmmss_2.png`,... +撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): + +| 種別 | ファイル名 | 概要 | +| --- | --- | --- | +| RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(10-bit BGGR Bayer,約 16 MB) | +| メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | +| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示用の縮小プレビュー.シャッター瞬間のライブプレビュー(自動 ISP 処理済み YUV→JPEG)をそのまま保存.解像度はプレビューストリーム依存(おそらく 1280×720 程度) | + - タイムスタンプはデバイスのローカル時刻を使用する +- 旧通常モードの `MiniTIAS_YYYYMMDD_HHmmss.png` は引き続き一覧に表示可能(後方互換).新規撮影は QM 形式で行われる ### 重複回避ロジック @@ -265,7 +277,10 @@ ### 削除 - 一覧画面の詳細表示オーバーレイから削除を実行する -- 削除前に確認ダイアログを表示する(「この画像を削除しますか?」) +- 削除前に確認ダイアログを表示する + - `.preview.jpg` の場合:「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 + - `.png`(旧通常モード)の場合:「この画像を削除しますか?」 +- `.preview.jpg` 削除時は同 baseName の `.dng` と `.meta.json` も**連動削除**する - ファイルシステムから物理削除する(ゴミ箱機能なし) - 削除後,MediaStore からも当該エントリを除去する diff --git "a/docs/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" "b/docs/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" new file mode 100644 index 0000000..f1559fa --- /dev/null +++ "b/docs/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" @@ -0,0 +1,336 @@ +# 定量モード実装報告と SHADING_MODE 相談 (Implementation Report & SHADING_MODE Consultation) + +本ドキュメントは [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) を MiniTIAS リポジトリで実装した結果の **MiniTIAS 側からの報告書** であり,併せて **SmTIAS-LightSim 側との方針確認・相談事項** をまとめたものである. + +## 想定読者 (Intended Reader) + +- **SmTIAS-LightSim 側 Claude セッション**: 本ドキュメントを読んで MiniTIAS 側の実装状況を把握し,未確定事項に対する判断・指示を返す +- **人間レビュー (rinto)**: 両セッションを橋渡しし,研究方針に照らして判断する + +## 概要 (Overview) + +MiniTIAS 側で TECH_01 の要求仕様に基づいた **定量撮影モード(DNG 経路)** の実装が完了した([MiniTias リポジトリ master ブランチ](https://github.com/rintoHasegawa/MiniTIAS) に取り込み済み).本ドキュメントの目的は以下の 2 点: + +1. **実装結果の報告**:TECH_01 の各 Phase に対して何を実装したか,AQUOS sense3 (SH-02M) で何が確認できたかを共有する +2. **SHADING_MODE の扱いの相談**:定量モードのキー設定である `SHADING_MODE` を MiniTIAS 側でどう選んだか,研究上どのような含意があるか,SmTIAS-LightSim 側の解析パイプラインとの整合を確認したい + +## TECH_01 要求に対する実装状況 (Implementation Status) + +| Phase | 要求 | 実装状況 | +| --- | --- | --- | +| Phase 1 | Camera2 詳細制御の MethodChannel 実装(露光・WB 固定,LSC マップ取得) | ✅ 完了 | +| Phase 2 | LSC マップ・メタデータ保存ロジック | ✅ 完了 | +| Phase 3 | 撮影画面 UI に定量モードトグル追加 | ⚠️ 仕様変更(後述).**トグル UI は追加せず通常モードを置換** | +| Phase 4 | ハードウェアサポート判定とユーザー案内 | ✅ 診断機能を `DiagnosticsProvider` として実装(UI 非表示.裏で再利用可能) | +| Phase 5 | RAW_SENSOR (DNG) 対応(任意) | ✅ 完了.第 1 優先経路として採用 | + +### Phase 3 の変更点 + +TECH_01 では「撮影モード切替トグル」で通常モードと定量モードを並存させる想定だったが,MiniTIAS 側の運用判断として **通常モード(PNG 経路)を temporarily に無効化し,シャッターは常に定量モードを呼ぶ**構成にした.既存の `captureFullResolutionPng` コードは温存している. + +理由: + +- MiniTIAS の運用上の存在意義は SmTIAS-LightSim との定量比較のみ +- トグル UI を残すと「誤って通常モードで撮影してしまい後で気付く」事故が起きる +- 研究フェーズ完了後にトグル追加 or 通常モード復活は容易 + +## 実装した撮影設定 (Camera2 CaptureRequest) + +| キー | 設定値 | 備考 | +| --- | --- | --- | +| `CONTROL_MODE` | `OFF` | マニュアル全制御 | +| `CONTROL_AE_MODE` | `OFF` | 自動露光停止 | +| `SENSOR_EXPOSURE_TIME` | `16,666,666 ns`(= 1/60 s) | **ハードコード**.後段で半自動キャリブ機能の追加を検討中 | +| `SENSOR_SENSITIVITY` | `40`(センサ最低 ISO) | 診断で確認したセンサのレンジ最低値 | +| `SENSOR_FRAME_DURATION` | `33,333,333 ns`(30fps 相当) | 露光時間 ≤ frame duration を満たす任意値 | +| `BLACK_LEVEL_LOCK` | `true` | フレーム間で黒レベルのドリフトを抑える | +| `CONTROL_AWB_MODE` | `OFF` | 自動 WB 停止 | +| `COLOR_CORRECTION_MODE` | `TRANSFORM_MATRIX` | マニュアル制御モード | +| `COLOR_CORRECTION_GAINS` | `RggbChannelVector(1.0, 1.0, 1.0, 1.0)` | RAW 出力では Bayer 値に効かないが request 値として明示 | +| `COLOR_CORRECTION_TRANSFORM` | 単位行列(`ColorSpaceTransform`) | 同上 | +| `NOISE_REDUCTION_MODE` | `OFF` | NR による画素値の改変を防ぐ | +| `EDGE_MODE` | `OFF` | エッジ強調を防ぐ | +| `TONEMAP_MODE` | `CONTRAST_CURVE` | カーブは R/G/B すべて線形 `[(0,0),(1,1)]` | +| **`SHADING_MODE`** | **`HIGH_QUALITY`** | ★後述の相談対象.OFF も技術的に可能 | +| `STATISTICS_LENS_SHADING_MAP_MODE` | `ON` | LSC マップを CaptureResult に含める | +| `CONTROL_AF_MODE` | `OFF` | **AQUOS sense3 のフロントカメラは固定焦点で OFF のみサポート** | +| `FLASH_MODE` | `OFF` | LED はアタッチメント側で制御 | +| `CONTROL_VIDEO_STABILIZATION_MODE` | `OFF` | 手ブレ補正による画像ずれを防ぐ | + +## 撮影フロー (Capture Flow) + +```text +シャッター押下 + │ + ▼ +プレビュー停止(camera パッケージの CameraController.dispose) + │ + ▼ +baseName 生成(MiniTIAS_QM_YYYYMMDD_HHmmss.同秒重複時 _1, _2 ...) + │ + ▼ +Camera2 API でフロントカメラを開く +ImageReader(RAW_SENSOR, 3264×2448, maxImages=3) + │ + ▼ +上記マニュアル制御パラメータを CaptureRequest に適用 + │ + ▼ +連続 3 枚キャプチャ +(設定反映確実化のため最初の 2 枚は破棄.3 枚目を本番とする) + │ + ▼ +DngCreator で DNG ファイルを直接書き込み +(FileOutputStream → ${dir}/${baseName}.dng) + │ + ▼ +LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) +メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap) + │ + ▼ +Dart 側でメタデータ JSON 保存 +(${dir}/${baseName}.meta.json) + │ + ▼ +シャッター瞬間に取得済みのライブプレビュースナップショット JPEG を保存 +(${dir}/${baseName}.preview.jpg.アプリ内目視確認用.解析対象外) + │ + ▼ +MediaStore 通知 → Camera2 解放 → プレビュー再開 +``` + +**従来の「1 秒 AE/AF 安定待ち」は省略**した.AE/AWB/AF 全部 OFF のため安定待ちの意味がないことと,「3 枚連続キャプチャの 2 枚破棄」で設定反映の不確実性を吸収する設計にした. + +## AQUOS sense3 (SH-02M) 実機検証結果 (Device Verification) + +TECH_01 の開放質問 Q1〜Q4 に対する実機での確認結果.診断機能(`getCameraDiagnostics`)の出力は MiniTIAS リポジトリの [`docs/06_TEST/camera_diagnostics_20260531_161734.json`](../06_TEST/camera_diagnostics_20260531_161734.json) に保存している. + +### 主要項目 + +| 項目 | 値 | +| --- | --- | +| `INFO_SUPPORTED_HARDWARE_LEVEL` | **`LEVEL_3`**(最上位) | +| `REQUEST_AVAILABLE_CAPABILITIES` | `BACKWARD_COMPATIBLE`, **`MANUAL_SENSOR`**, **`MANUAL_POST_PROCESSING`**, **`RAW`**, `READ_SENSOR_SETTINGS`, `BURST_CAPTURE`, `PRIVATE_REPROCESSING`, `YUV_REPROCESSING` | +| `SENSOR_INFO_EXPOSURE_TIME_RANGE` | `79,806 ns 〜 871,614,530 ns`(≈ 1/12530 s 〜 0.87 s) | +| `SENSOR_INFO_SENSITIVITY_RANGE` | `40 〜 2506` | +| `RAW_SENSOR` 出力サイズ | `3264 × 2448`(1 種類のみ) | +| `SENSOR_INFO_COLOR_FILTER_ARRANGEMENT` | `3 (BGGR)` | +| `SENSOR_BLACK_LEVEL_PATTERN` | `[[64, 64], [64, 64]]` | +| `SENSOR_INFO_WHITE_LEVEL` | `1023`(10-bit) | +| `SENSOR_INFO_PHYSICAL_SIZE` | `3.558 mm × 2.668 mm` | +| `SENSOR_INFO_PIXEL_ARRAY_SIZE` | `3264 × 2448` | +| `LENS_INFO_MINIMUM_FOCUS_DISTANCE` | `0.0`(無限遠固定) | +| `LENS_INFO_HYPERFOCAL_DISTANCE` | `0.8189`(≈ 1.22 m) | +| `STATISTICS_INFO_AVAILABLE_LENS_SHADING_MAP_MODES` | `[OFF (0), ON (1)]` | +| `CONTROL_AF_AVAILABLE_MODES` | **`[OFF (0)] のみ`**(AF 機能なし) | +| `SHADING_AVAILABLE_MODES` | `[OFF, FAST, HIGH_QUALITY]` | + +### 注目点 + +- **TECH_01 の Phase 1〜5 すべてが実装可能**な能力構成だった +- **AF は OFF のみ**.レンズは固定焦点(無限遠固定)でアタッチメント光学系で近接被写体にピントを合わせている前提 +- LSC マップサイズは実キャプチャ結果から **13 × 17** と判明(特性からは hidden API のため取得不可だった) + +## 保存ファイル仕様 (Output File Specification) + +### ファイル命名規則 + +撮影 1 回で 3 ファイルを生成.basename は共通. + +| 種別 | ファイル名 | 概要 | +| --- | --- | --- | +| RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(約 16 MB) | +| メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | +| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示・確認用.解析対象外 | + +`QM` = Quantitative Mode.同秒重複時は `_1`, `_2` ... と連番を付与. + +### DNG (RAW_SENSOR) + +- フォーマット: TIFF/EP ベースの Adobe DNG(`DngCreator` 経由) +- データ: 10-bit BGGR Bayer,3264×2448 +- Exif メタ: DngCreator が `CameraCharacteristics` から自動で埋め込み(`ColorMatrix1/2`, `ForwardMatrix1/2`, `AsShotNeutral`, `NoiseProfile`, `BlackLevelPattern` 等) + +### meta.json の構造(実例抜粋) + +[`MiniTIAS_QM_20260531_172612.meta.json`](https://github.com/rintoHasegawa/MiniTIAS) から抜粋(白板を SHADING_MODE = HIGH_QUALITY で撮影したもの): + +```json +{ + "captureTimestampUtc": "2026-05-31T08:26:13.235Z", + "deviceModel": "SH-02M", + "androidVersion": "9", + "cameraId": "1", + "hardwareLevel": 3, + "captureMode": "quantitative", + "imageFormat": "RAW_SENSOR", + "imageSize": [3264, 2448], + + "settings": { + "control_mode": "OFF", + "ae_mode": "OFF", + "exposure_time_ns": 16666666, + "sensor_sensitivity_iso": 40, + "frame_duration_ns": 33333333, + "awb_mode": "OFF", + "color_correction_gains": [1.0, 1.0, 1.0, 1.0], + "color_correction_transform": "identity", + "noise_reduction_mode": "OFF", + "edge_mode": "OFF", + "tonemap_mode": "CONTRAST_CURVE_LINEAR", + "shading_mode": "HIGH_QUALITY", + "statistics_lens_shading_map_mode": "ON", + "af_mode": "OFF", + "black_level_lock": true + }, + + "actual": { + "exposure_time_ns": 16653365, + "sensor_sensitivity": 40, + "frame_duration_ns": 33333333, + "sensor_timestamp": 4894940190334, + "dynamic_black_level": null, + "dynamic_white_level": 1023, + "neutral_color_point": ["1/1", "1/1", "1/1"], + "color_gains": [1.0, 1.0, 1.0, 1.0], + "color_transform": [ + ["1/1", "0/1", "0/1"], + ["0/1", "1/1", "0/1"], + ["0/1", "0/1", "1/1"] + ] + }, + + "sensorCharacteristics": { + "color_filter_arrangement": 3, + "black_level_pattern": "BlackLevelPattern([64, 64], [64, 64])", + "white_level": 1023, + "physical_size": [3.557760, 2.668320], + "pixel_array_size": [3264, 2448], + "active_array_size": "Rect(0, 0 - 3264, 2448)" + }, + + "lscMap": [ + [5.105, 4.232, 4.111, 3.847, 4.982, 3.966, 3.916, 3.744, 4.459, 3.503, 3.502, 3.369, 3.860, 3.025, 2.995, 2.905, 3.367, 2.647, 2.595, 2.522, 2.995, 2.393, 2.349, 2.285, 2.765, 2.235, 2.199, 2.133, 2.613, 2.130, 2.093, 2.032, 2.577, 2.087, 2.051, 1.991, 2.610, 2.125, 2.093, 2.022, 2.728, 2.214, 2.187, 2.110, 2.950, 2.365, 2.359, 2.251, 3.294, 2.614, 2.637, 2.500, 3.846, 3.006, 3.042, 2.868, 4.426, 3.456, ...], + ...(行 1〜行 12 続く) + ], + "lscMapRowCount": 13, + "lscMapColumnCount": 17 +} +``` + +#### `actual` フィールドについての注意 + +- `actual` は CaptureResult から読み戻した**実適用値**.Request 値(`settings`)と微妙に異なる場合がある(例:露光時間 16,666,666 → 16,653,365 でセンサが量子化) +- `dynamic_black_level` は `null` で返ることがある(端末依存).代わりに `sensorCharacteristics.black_level_pattern` の静的値を参照可 +- `neutral_color_point` と `color_transform` が単位行列なのは AWB OFF + identity gains 設定の結果 + +#### LSC マップ + +- 形状: 4 ch × N rows × M cols(4 = R / G_even / G_odd / B)の各チャネルゲイン +- 本実装の保存形式: **1 行を 4 × M 個の float が並んだフラット配列**として `lscMap[row]` に格納.行数・列数は `lscMapRowCount` / `lscMapColumnCount` で別途記録 +- 値の意味: `corrected = raw × gain`.中央 ≈ 1.0,周辺で 5× 程度まで上がる典型的な cos⁴ 補正マップになっている +- **ただし `SHADING_MODE = OFF` で撮影した場合は全要素 `1.0` の単位マップが返ってくることを確認済み**(次節で詳述) + +### preview.jpg + +シャッター瞬間のライブプレビュー JPEG をそのまま保存(Camera2 ISP がフル稼働した状態の画像).**解析対象ではない**.撮影者がアプリ内で「撮れた」を判断するためだけのもの.解像度はプレビューストリーム依存(おそらく 1280×720 程度). + +## SHADING_MODE の扱い(★相談事項) (Consultation on SHADING_MODE) + +### 経緯 + +実装初期は **TECH_01 の表に明記されていた `HIGH_QUALITY` を採用**した.しかし実装途中で MiniTIAS 側は以下を観察した: + +1. `SHADING_MODE = OFF` で撮影すると **LSC マップが全要素 `1.0`** になる(Android 仕様:「補正は適用しない + 単位マップを返す」) +2. `SHADING_MODE = HIGH_QUALITY` で撮影すると LSC が **画像に適用された状態**で DNG に保存される.LSC マップは ISP が適用したゲインの実値 + +結果:**HIGH_QUALITY を採用した**現状の DNG は,ISP によって cos⁴ 由来のシェーディングが既に補正された状態のデータになっている. + +### 研究上の含意 + +SmTIAS-LightSim 側の Mitsuba 3 シミュレーションは「物理的な cos⁴ 落ち + LED 配光 + 鏡面反射」を再現するものと理解している.この物理シミュレーション結果と直接比較したいデータは: + +- (A) **LSC 適用済み画像(現状の DNG)**: ISP が「メーカー校正の LSC マップ」を掛けて平坦化したもの.物理的な cos⁴ がほぼ取り除かれている +- (B) **LSC 適用前の生 Bayer**: 物理的 cos⁴ がそのまま残っている.Mitsuba 出力と同じ前提 + +(A) と Mitsuba を直接比較すると **比較対象に LSC マップ分のずれが系統的に乗る**ことになる.これが [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で残差 2.1× が解消しなかった一因の可能性もある(推測). + +### 取りうる選択肢 + +研究の整合のために考えられる方向性を以下に列挙する.**MiniTIAS 側からの判断はせず**,SmTIAS-LightSim 側で解析パイプラインを構築する立場からの選択肢として提示する: + +| 選択肢 | MiniTIAS 側の対応 | SmTIAS-LightSim 側の対応 | データの性質 | +| --- | --- | --- | --- | +| (1) 現状維持(HQ) | 変更なし | `DNG ÷ LSC マップ` を計算して LSC 適用前相当に戻す処理を実装 | DNG は LSC 適用済み.逆適用で物理生 Bayer を再構成 | +| (2) OFF に切替 | `SHADING_MODE = OFF` に変更 | そのまま Mitsuba 出力と比較 | DNG は物理生 Bayer.LSC マップは情報なし(全 1.0) | +| (3) 両方撮影 | 1 シーンに対し HQ と OFF の 2 枚を連続撮影 | 比較・検証で使い分け | 情報量最大.ただし撮影時間と保存容量が 2 倍 | + +#### MiniTIAS 側で対応する場合のコスト感 + +- (1): コスト 0(現状).SmTIAS-LightSim 側で逆適用ロジックが必要 +- (2): Kotlin 側 CaptureRequest を 1 行変更.連動して meta.json の `settings.shading_mode` も `"OFF"` に.約 30 分 +- (3): キャプチャ処理を 2 連続化.ファイル名規則の拡張(例:`*_hq.dng` / `*_off.dng`).約 1 時間 + +### MiniTIAS 側から確認したい事項 + +- SmTIAS-LightSim 側の解析パイプラインで「LSC マップを使った逆適用」は実装済み or 実装可能か? +- 物理シミュレーション(Mitsuba)の出力は「LSC 適用前の生 Bayer 相当」で間違いないか? +- 既存 baseline `MiniTIAS_20260408_144434`(通常モード PNG)との互換性をどう扱うか?(既に方針として「定量モード以降は新規 baseline」となっていれば問題なし) + +選択肢 (1)〜(3) のうちどれを採用するか,または別案があるか,**SmTIAS-LightSim 側の判断を求めたい**.本相談への返答を受けて MiniTIAS 側で必要な実装変更を行う. + +## その他の未確認事項 (Other Open Issues) + +### 露光時間 / ISO の運用 + +現状 `1/60 s + ISO 40` ハードコードで撮影している.照明条件(アタッチメント装着時の LED 強度),被写体(白板 / 舌模型 / 実舌)によって最適値は異なるはず. + +- 白板:飽和しない範囲で最大露光(SN 比最大) +- 舌模型:白板用設定だと暗すぎる可能性 + +MiniTIAS 側で検討中の対応: + +- (a) 半自動キャリブ機能(AE を 1 回走らせて値を読み,以降固定) +- (b) 白板用・舌模型用の 2 プリセット +- (c) ハードコード変更(撮影前に手動で設定) + +SmTIAS-LightSim 側で「白板撮影時の理想的なセンサ平均値」(例:10-bit のうち 500 前後)の目安があれば共有してほしい. + +### V1 連射再現性検証の担当 + +TECH_01 検証 V1(10 連射 CoV < 1%)を実施するには: + +- MiniTIAS 側:連射 UI(10 ショットを連続保存)の実装が必要 +- 解析:DNG を読み込んで mean / std / CoV を計算する Python スクリプトが必要 + +連射機能は MiniTIAS 側で実装予定.解析スクリプトは MiniTIAS 内に `scripts/verify_repeatability.py` 等で置くか,SmTIAS-LightSim 側の sm-evaluate に統合するか方針を相談したい. + +### MTP 経由ファイル転送の注意点 + +DNG が 16 MB 級のため MTP コピー直後にファイルサイズが正しく見えないケースを実装中に確認した.**`adb pull` の使用を推奨**.SmTIAS-LightSim 側のデータ受け取り手順としてもこれを前提にすると安全. + +## MiniTIAS 側で検討中の改善案 (Planned Improvements) + +SmTIAS-LightSim 側との打ち合わせ事項と並行して,以下を MiniTIAS 側で進める予定.優先度は内部判断による: + +| 優先度 | 項目 | 概要 | +| --- | --- | --- | +| 高 | 画素統計を meta.json に追加 | 飽和・露光不足の自動判定用.`mean_per_channel`, `max_per_channel`, `p99`, `saturated_pixel_ratio` 等 | +| 高 | Hot pixel map 取得 | `STATISTICS_HOT_PIXEL_MAP_MODE = ON` で常時光る画素一覧を meta に記録.PC 側でマスク可能に | +| 高 | 連射機能(V1 検証用) | 10 連射 UI.`MiniTIAS_QM_*_burst{N}.dng` 等で連番保存 | +| 中 | 露光時間・ISO の半自動キャリブ機能 | 白板で AE 1 ショット → 値を読んで固定 | +| 中 | レンズ・センサ校正値を meta に追加 | `LENS_INTRINSIC_CALIBRATION`, `LENS_DISTORTION`, `SENSOR_REFERENCE_ILLUMINANT1/2`, `SENSOR_COLOR_TRANSFORM1/2` 等 | +| 低 | Dark frame / Flat field キャリブ機能 | `(対象 − Dark) / (Flat − Dark)` 補正のための基準フレーム撮影 | + +これらは SmTIAS-LightSim 側からの要望順位とすり合わせて優先度を確定したい. + +## 関連ドキュメント (Related Documents) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — 本実装の原始要求 +- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎ時の作業ガイド +- [`docs/06_TEST/camera_diagnostics_20260531_161734.json`](../06_TEST/camera_diagnostics_20260531_161734.json) — AQUOS sense3 (SH-02M) の能力診断結果(フル) +- [SPEC_01 画面機能仕様書](../04_SPEC/SPEC_01_画面機能仕様書.md) — MiniTIAS 側の撮影仕様(定量モード反映済み) +- [PLAN_01 要件定義書](../03_PLAN/PLAN_01_要件定義書.md) — ファイル管理仕様 +- [PROGRESS.md](../PROGRESS.md) — 実装の進捗ログと設計判断・失敗パターン +- [Android Camera2 API SHADING_MODE](https://developer.android.com/reference/android/hardware/camera2/CaptureRequest#SHADING_MODE) +- [Android Camera2 API STATISTICS_LENS_SHADING_CORRECTION_MAP](https://developer.android.com/reference/android/hardware/camera2/CaptureResult#STATISTICS_LENS_SHADING_CORRECTION_MAP) diff --git "a/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" "b/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" new file mode 100644 index 0000000..fdbfcdd --- /dev/null +++ "b/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" @@ -0,0 +1,333 @@ +# MiniTIAS への回答 (Response to MiniTIAS) + +本ドキュメントは [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) に対する **SmTIAS-LightSim 側からの回答書** である.MiniTIAS 側の実装結果評価・SHADING_MODE 判断・物理解釈の修正・運用上の合意事項を明文化する. + +## 想定読者 (Intended Reader) + +- **MiniTIAS 側 Claude セッション**: 本ドキュメントを読んで,SmTIAS-LightSim 側の判断と次フェーズの依頼内容を把握する. +- **人間レビュー (rinto)**: 両セッションの判断整合を確認する. + +## サマリ (Summary) + +| 項目 | SmTIAS-LightSim 側の判断 | +| --- | --- | +| **SHADING_MODE** | **`HIGH_QUALITY` を本流で維持**.OFF への切替は条件付き将来オプション(後述).MiniTIAS 側の実装変更は **不要** | +| Mitsuba 出力の物理解釈 | 「LSC 適用前の生 Bayer 相当」ではなく **「ISP が完璧に LSC 補正できた場合の理想値」相当**.HIGH_QUALITY DNG と直接比較するのが物理的に整合 | +| LSC 逆適用機能 | SmTIAS-LightSim 側で **検証用ツール**として将来実装.本流の比較では使わない | +| 既存 baseline 互換性 | TECH_02 既決:**新規 baseline で再キャリブ**.既存 `MiniTIAS_20260408_144434` は参考扱い | +| 露光・ISO 目安 | **10-bit のうち中央値 500 前後**(飽和回避のため p99 < 900).現状 1/60s + ISO40 で適切か実機で要確認 | +| **作業フェーズ分け** | **フェーズ A(直近): 1 ショット撮影でシミュとの対応を先に検証** → **フェーズ B(後続): 対応が取れたら連射 UI 実装と V1 再現性検証** | +| V1 連射再現性検証 | **フェーズ B に後回し**.フェーズ A で対応の見通しが立ってから着手.解析は SmTIAS-LightSim 側 sm-evaluate に統合,MiniTIAS 側は連射 UI のみ実装(フェーズ B 時点) | +| データ転送 | **`adb pull` 推奨**.MTP 経由は禁止しないが大容量 DNG で不整合リスクあり | +| 改善案優先度 | フェーズ A 用: **画素統計・Hot pixel map** を高優先で同意.連射 UI は **フェーズ B に後回し**.他はキャリブ進捗を見て判断 | + +→ **フェーズ A** で MiniTIAS 側に追加で依頼するのは **画素統計・Hot pixel map** の 2 件と **白板 1 ショット試し撮り**のみ.連射機能はフェーズ B まで保留. + +## SHADING_MODE 判断の詳細 (SHADING_MODE Decision Rationale) + +### 結論 + +**選択肢 (1) `HIGH_QUALITY` を本流で維持**.MiniTIAS 側の現状実装をそのまま継続する. + +### 物理的整合性の説明 + +TECH_03 §SHADING_MODE 経緯では「Mitsuba 出力は LSC 適用前の生 Bayer 相当か?」という確認があったが,**実はそうではない**.Mitsuba 3 の orthographic sensor は次の特性を持つ: + +| データ | 物理的意味 | cos⁴ falloff | +| --- | --- | --- | +| Mitsuba orthographic 出力 | 各画素位置の **平行光線が交差した点からの放射輝度 L (W/m²/sr)** | **無し**(全画素平行光線のため) | +| 実機センサ生 (SHADING_OFF) | レンズ通過後のセンサ受光量 | **有り**(実物理 cos⁴ で周辺暗) | +| 実機 HIGH_QUALITY DNG | センサ生 × LSC マップ(≥1.0,周辺で大きい) | **理想的にはほぼ補正済み**(残差あり) | + +つまり: + +- **Mitsuba 出力 ≒ 「ISP が完璧に LSC 補正できた場合の理想値」相当** +- これと **HIGH_QUALITY DNG** を直接比較するのが **物理的に最も整合** +- 両者の残差 = 「実機 LSC 補正の不完全度 + シミュ物理モデルの誤差」となり,**これがまさに評価したい本質** + +逆に SHADING_OFF DNG と Mitsuba 出力を直接比較するには Mitsuba 側に物理 cos⁴ falloff を別途モデル化する必要があり,新たな自由度(絞り径・焦点距離・口径食モデル)が入る.**自由度が増えれば overfitting リスクも増える**.現状の方針(HIGH_QUALITY 維持)は overfitting 回避の観点でも妥当. + +### LSC マップの位置付け + +実装した LSC マップ取得機構 (`STATISTICS_LENS_SHADING_MAP_MODE = ON`) は本流の比較では直接使わないが,**以下の検証用途で価値が高い**: + +| 用途 | 説明 | +| --- | --- | +| 個体差検証 | 実機ごと・撮影ごとに LSC マップが変動するか確認.固定なら端末校正値,変動なら AWB 連動の動的計算 | +| 補正残差の可視化 | LSC マップが実画像のずれを完全に説明するか,残差がどの程度残るかを定量化 | +| 物理 cos⁴ との比較 | LSC マップ ≒ 物理 cos⁴ の逆数か検証.モデル化検討の物理動機として使える | +| SHADING_OFF へのフォールバック | 後述「フォールバック条件」発動時に,OFF データへの逆適用ロジックの正当性検証に使える | + +→ メタデータ JSON に LSC マップを保存し続ける現実装は **そのまま維持を依頼**. + +### 将来 SHADING_OFF 切替のフォールバック条件 + +「もし問題があったら OFF にした時の差を確認する」という運用方針を踏まえ,以下のいずれかが発生した時に SHADING_OFF 撮影の追加検証を依頼する: + +| 発動条件 | 検証目的 | +| --- | --- | +| **C1**: Mitsuba シミュ vs HIGH_QUALITY DNG の残差が **同じ条件で 10 回撮っても再現的に大きい**(CoV/勾配残差 1.5× 以上 + 形が物理的に説明できない) | LSC 補正残差が大きすぎる可能性.OFF と比較して「物理 cos⁴」と「ISP 補正残差」を切り分ける | +| **C2**: LSC マップが **撮影ごとに大きく変動**する(中央値の variation > 5% 等) | ISP が AWB と連動して動的計算している証拠.補正後 DNG の解析時にこの動的性を考慮する必要 | +| **C3**: シミュ側の Mitsuba モデル精度向上後も残差が物理的に説明できない | LSC 由来でない別要因の存在.OFF 比較で物理 cos⁴ の影響度を独立に評価 | + +これらの条件が発動した時は,MiniTIAS 側に「同一シーンを HIGH_QUALITY と OFF で 1 セット連続撮影するオプション」の追加実装を依頼する.コスト見積もり:Kotlin 側 1〜2 時間,ファイル命名規則拡張(`*_hq.dng` / `*_off.dng`). + +**現時点では発動条件未到達**.実装は不要. + +## Mitsuba 出力の物理解釈の補足 (Physical Interpretation Note) + +TECH_03 §SHADING_MODE 経緯で混乱の元になった「Mitsuba 出力 = 生 Bayer 相当か?」について,SmTIAS-LightSim 側の正式な物理モデルを整理する. + +### 現状の Mitsuba シーン構成 + +- **emitter**: LED 配置から 18 個の area emitter(位置は Fusion 360 設計 STL から抽出,[Step 8-9 参照](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) +- **bsdf**: Lambertian(反射率 0.85,校正は [Step 8-6 参照](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) +- **integrator**: `path` tracer(多重反射,max_depth=8) +- **sensor**: **orthographic**(平行投影,cos⁴ falloff なし) + +→ シミュ luminance.npy は「**評価板表面の物理放射輝度 L** を orthographic 平行光線でサンプリングした値」.これは「実カメラで perspective sensor + 完璧な LSC 補正が掛かった時の出力」に最も近い. + +### 厳密に言えば足りないもの + +物理的に Mitsuba 出力と HIGH_QUALITY DNG が完全一致しない理由: + +| 要因 | 影響度 | 現状の処理 | +| --- | --- | --- | +| **実機 LSC の補正残差** | 中(残差 1.6〜1.9× の主因候補) | 未モデル化.本回答で「測定して見る」方針 | +| **実機センサのキャリブレーション残差**(ホットピクセル,黒レベル変動) | 小 | TECH_03 改善案の Hot pixel map で対応予定 | +| **Mitsuba 側 Lambertian 近似の限界** | 中(実機内壁は完全 Lambertian ではない) | [Step 8-2 反射率スイープ](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で限界確認済 | +| **LED 配光の Lambertian 近似** | 小〜中 | [Step 8-10/8-11 spot emitter 試行](../06_TEST/TEST_01_キャリブレーション検証履歴.md) でデータシート整合まで詰めた | +| **メッシュ離散化** | 小 | path bake 高サンプル化([TEST_04 Speed-2](../06_TEST/TEST_04_シミュレーション速度最適化履歴.md) )で評価可能だが現在は中断 | +| **sRGB OETF の有無** | 大(過去対応済) | sm-evaluate で順方向適用([Step 8-3](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) | + +→ MiniTIAS 側 DNG (HIGH_QUALITY) は **10-bit linear** で sRGB OETF 未適用のはず.SmTIAS-LightSim 側で DNG 読み込み時に linear 域で評価し,比較時には Mitsuba 側も linear のまま使う(または両者に同じ OETF を適用する). + +## 作業フェーズ分け (Work Phase Strategy) + +本回答時点での研究上の優先順位として,**「シミュ vs 実機の対応がそもそも取れるか」を 1 ショットで先に確認してから連射に進む** 二段階方式を採用する. + +### 方針の根拠 + +- **連射再現性が確保できても,シミュとの対応自体が取れなければ意味が薄い** +- 1 ショット分析でフェーズ A の対応が取れない場合: + - 物理モデルの根本見直し(Mitsuba 側の物性・配光・反射特性) + - SHADING_MODE フォールバック条件 C1〜C3 の発動判断 + - シミュ側の自由度パラメータの再キャリブ +- これらは **どれも 1 ショット分析で判断できる** ことで,連射機能の有無に依らない +- 連射機能を先に実装すると「結局使わないかもしれない実装」のコストがかかるリスクがある + +### フェーズ A(直近・1 ショット対応検証) + +**目的**: 1 枚の定量モード DNG とシミュ Mitsuba 出力の対応そのものを評価する. + +**MiniTIAS 側の作業**: + +1. **白板で 1 ショット試し撮り**(現状実装で OK,追加実装なし) + - 1/60s + ISO 40 で mean が 10-bit 中央値 500 前後に収まるか確認 + - 範囲外なら露光時間 or ISO を 1〜2 通り試行 + - 確定した撮影条件と DNG + meta.json を SmTIAS-LightSim 側に共有 + +2. **画素統計を meta.json に追加**(高優先) + - mean_per_channel / max_per_channel / p99 / saturated_pixel_ratio + - 撮影直後に飽和判定したい.撮り直しの即時判定にも使える + +3. **Hot pixel map 取得**(高優先) + - `STATISTICS_HOT_PIXEL_MAP_MODE = ON` + - meta.json に座標リストを記録.SmTIAS-LightSim 側の解析でマスク + +**SmTIAS-LightSim 側の作業**: + +1. **DNG (RAW_SENSOR) 読み込み I/F の実装** (`evaluation/dng_loader.py` 想定) + - `rawpy` 等で 10-bit BGGR Bayer を展開 + - 黒レベル除去 + デモザイク + +2. **メタデータ JSON 読み込み I/F の実装** (`evaluation/metadata.py` 想定) + - `settings` / `actual` / `sensorCharacteristics` / `lscMap` の読み取り + - hot pixel マスク適用機能 + +3. **新規 baseline config の作成** (`configs/eval_baseline_quantitative.yaml`) + - 既存 baseline と並列保持 + +4. **1 ショット シミュ vs 実機 突合** + - sm-evaluate に DNG 入力経路を追加 + - 既存メトリクス(CoV / 勾配 / C/P / max-min)を 1 ショット定量モード DNG に適用 + - [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) に Step 8-13 として記録 + +**フェーズ A 完了の判定**: + +- 1 ショット定量モード DNG(10-bit linear,HIGH_QUALITY,hot pixel マスク済)と Mitsuba シミュ luminance.npy の評価指標を直接比較 +- 既存 baseline(通常モード PNG)での残差 2.1× が **どう変化するか** を観察 +- 結果次第で以下を判断: + - **残差が縮む(≤ 1.5×)**: 撮影モード変更が効いた.フェーズ B に進む + - **残差変わらず**: SHADING_MODE フォールバック条件 C1 の検討・物理モデル見直し + - **残差が増える**: 解析パイプラインの不整合(DNG 読み込み・黒レベル・デモザイク)を疑う + +### フェーズ B(後続・連射再現性検証) + +**フェーズ A で対応の見通しが立った場合のみ**着手.目的は TECH_01 検証 V1(10 連射 mean CoV < 1%)の達成. + +**MiniTIAS 側の作業**(フェーズ B 時点): + +- **連射 UI 実装**: ボタンタップ → 10 連射 → 全保存 + - ファイル命名: `MiniTIAS_QM_YYYYMMDD_HHmmss_burst{0..9}.dng` + - 連射間隔は端末性能依存.インターバル指定は不要 + - meta.json は撮影ごとに保存(10 個生成).プレビュー JPEG は 1 枚で十分 + +**SmTIAS-LightSim 側の作業**(フェーズ B 時点): + +- **再現性検証スクリプト** `scripts/verify_repeatability.py` + - 10 ショット間で mean / std / CoV / 形状指標を集計 + - V1 合格判定(mean CoV < 1%,形状指標 std < 0.005) + +役割分担の根拠:SmTIAS-LightSim 側に既存の解析基盤([`uniformity.py`](../../src/sm_tias_lightsim/uniformity.py), `evaluation/*.py`)があるので,再現性検証もそこに統合する方が将来のキャリブ作業との整合性が高い. + +### フェーズ B 着手の条件 + +- フェーズ A で「シミュ vs 実機」の対応が一定程度取れた(残差 ≤ 1.5× 程度) +- もしくはフェーズ A の結果から「連射で SN 比改善 / 平均値安定化が必要」と判断された +- いずれもフェーズ A 完了後に判断する + +## 露光時間・ISO 運用の目安 (Exposure & ISO Guideline) + +TECH_03 「露光時間 / ISO の運用」への回答: + +### 白板撮影の理想値 + +10-bit DNG (値域 0〜1023) で: + +| 指標 | 目安 | +| --- | --- | +| 黒レベル | 64(`SENSOR_BLACK_LEVEL_PATTERN`).差し引き後の値で評価 | +| **白板中央の mean**(黒レベル除去後) | **400〜600**(中央値約 500) | +| 飽和回避 | **p99 < 900**(黒レベル除去後 836) | +| SN 比 | 最大露光(飽和回避内)で改善.ただしホット画素も顕在化 | + +現状の **1/60s + ISO 40** が適切かは **実機で 1 度白板撮影して mean を確認**する必要がある.mean が 400 を下回るなら露光時間を倍にする(1/30s)または ISO を上げる.mean が 600 を超えるなら露光時間を半分(1/120s)に. + +### MiniTIAS 側の半自動キャリブ機能(TECH_03 「検討中の対応 (a)」)について + +提案された「AE を 1 回走らせて値を読み,以降固定」は **歓迎**.実装すれば以下の利点: + +- 環境光(部屋の明るさ)の影響を吸収して撮影条件を一定化 +- 白板用・舌模型用で個別校正値を保持できる +- meta.json の `settings.exposure_time_ns` / `settings.sensor_sensitivity_iso` に校正済み値を記録 + +ただし優先度は **中**(現状の手動ハードコードでも研究は進められる).連射 UI と画素統計が先. + +## 改善案優先度のフィードバック (Improvement Priority Feedback) + +TECH_03 「検討中の改善案」に対する SmTIAS-LightSim 側の優先度評価.本回答ではフェーズ A/B 分けにより **連射 UI を高優先 → フェーズ B(中優先)に降格**する点が当初提案からの修正. + +| MiniTIAS 側優先度 | 項目 | SmTIAS-LightSim 側評価 | フェーズ | 理由 | +| --- | --- | --- | --- | --- | +| 高 | 画素統計を meta.json に追加(mean/max/p99/saturated_ratio) | **同意,高** | **A** | 飽和判定がないと「定量データの有効性」が事後判断になる.撮影時に即判定したい.1 ショット分析でも必須 | +| 高 | Hot pixel map 取得 (`STATISTICS_HOT_PIXEL_MAP_MODE = ON`) | **同意,高** | **A** | 10-bit のホット画素は max/std 指標に大きく影響.マスク必須 | +| 高 | 連射機能(V1 検証用) | **降格.フェーズ A 結果次第で再評価** | **B(保留)** | 1 ショットで対応が取れることを先に確認すべき.対応が取れないなら連射しても無意味 | +| 中 | 露光時間・ISO の半自動キャリブ | **同意,中** | B 以降 | 手動ハードコードでも進められる.運用が定着した後でよい | +| 中 | レンズ・センサ校正値を meta に追加 | **同意,中** | B 以降 | DNG Exif に既に埋め込まれている分は重複.未埋め込み値のみ追加で十分 | +| 低 | Dark frame / Flat field キャリブ機能 | **同意,低** | C 以降 | 実物理 cos⁴ の独立測定として理想的だが,現状の HIGH_QUALITY 経路では LSC マップで代替可能 | + +→ **フェーズ A で MiniTIAS 側に依頼するのは「画素統計・Hot pixel map」の 2 件のみ**.連射 UI と他項目はフェーズ A 完了後に再評価. + +## データ受け渡し細部の追補 (Data Exchange Refinements) + +[TECH_02 データ受け渡し仕様](TECH_02_MiniTIAS引き継ぎ手順.md#データ受け渡し仕様-data-exchange-specification) を以下で補足する. + +### ファイル転送経路 + +- **推奨**: `adb pull /sdcard/Pictures/MiniTIAS/ ` +- **非推奨**: Windows エクスプローラ経由 MTP コピー(DNG サイズ 16MB で不整合報告あり) + +### 配置先(既存仕様の確認) + +```text +SmTIAS-LightSim/ +└── reference_measurement/ + └── quantitative/ + ├── MiniTIAS_QM_YYYYMMDD_HHmmss.dng + ├── MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json + ├── MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg # 解析対象外(任意配置) + └── MiniTIAS_QM_YYYYMMDD_HHmmss_burst{0..9}.dng # 連射時 +``` + +`reference_measurement/` は `.gitignore` 対象(個人ローカル).preview.jpg は解析不要なので配置任意. + +### 既存 baseline の参照 + +`MiniTIAS_20260408_144434.png`(通常モード)は SmTIAS-LightSim/`reference_measurement/MiniTIAS_20260408_144434/` 配下に保持.**新規定量モード baseline 取得後は参考扱い**で,本流キャリブから外す. + +## 次のフェーズの作業範囲 (Next Phase Scope) + +本回答に基づく次フェーズの作業を整理.**フェーズ A 完了後にフェーズ B への移行を判断**する. + +### フェーズ A(直近・1 ショット対応検証) + +#### MiniTIAS 側(実装依頼) + +1. **画素統計を meta.json に追加**(高優先・フェーズ A 必須) + - mean_per_channel / max_per_channel / p99 / saturated_pixel_ratio +2. **Hot pixel map 取得・meta.json に保存**(高優先・フェーズ A 必須) + - `STATISTICS_HOT_PIXEL_MAP_MODE = ON` +3. **白板で 1 ショット試し撮り → mean 確認**(露光/ISO の妥当性確認) + - mean が 400〜600(10-bit 黒レベル除去後)なら現状の 1/60s + ISO 40 で運用 + - 範囲外なら設定を調整 +4. 確定した撮影条件で **白板を 1 枚撮影** → `reference_measurement/quantitative/` へ adb pull 転送 + +> **連射 UI は不要**.本フェーズでは 1 ショット分析を優先. + +#### SmTIAS-LightSim 側(受け入れ実装) + +1. **DNG (RAW_SENSOR) 読み込み I/F の実装**(`evaluation/dng_loader.py` 新規想定) +2. **メタデータ JSON 読み込み I/F の実装**(`evaluation/metadata.py` 新規想定) +3. **LSC マップ読み込み I/F の実装**(検証用,本流では使わない) +4. **新規定量モード baseline 設定** `configs/eval_baseline_quantitative.yaml` の作成 +5. **1 ショット シミュ vs 実機の突合** + - 既存メトリクス(CoV / 勾配 / C/P / max-min)を 1 ショット DNG に適用 + - 既存通常モード baseline 残差 2.1× が定量モードで **どう変化するか** を観察 +6. [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) に **Step 8-13: 定量モード 1 ショット対応検証** として記録 + +### フェーズ A 完了判定とフェーズ B 移行条件 + +フェーズ A 完了後,1 ショット突合の結果から以下を判断: + +| 結果 | 判断 | 次の動き | +| --- | --- | --- | +| 残差が縮む(≤ 1.5×) | 撮影モード変更が効いた | フェーズ B(連射 V1 検証)に進む.連射 UI 実装依頼 | +| 残差変わらず(≈ 2.1×) | 撮影モード変更だけでは足りない | SHADING_MODE フォールバック条件 C1 検討.物理モデル見直し(LSC モデル化/反射特性/emitter 配光) | +| 残差が増える | 解析パイプラインに不整合 | DNG 読み込み・黒レベル・デモザイク・カラーマトリクスを疑う.デバッグ | + +### フェーズ B(後続・連射 V1 検証) + +**フェーズ A で対応の見通しが立った場合のみ**着手. + +#### MiniTIAS 側(フェーズ B 時点) + +- 10 連射 UI 実装(V1 検証前提) +- 連射機能で 10 ショット baseline 撮影 → `reference_measurement/quantitative/` へ転送 + +#### SmTIAS-LightSim 側(フェーズ B 時点) + +- **再現性検証スクリプト** `scripts/verify_repeatability.py` の実装 +- V1 検証実施 +- 必要に応じてキャリブ自由度(反射率・emitter 強度等)を再調整 + +## 未確定事項 (Pending Items) + +以下は本回答時点では判断保留.後続作業の進捗に応じて追加判断する: + +| ID | 内容 | 判断時期 | +| --- | --- | --- | +| P1 | DNG 内 Bayer データから RGB への展開方法(rawpy 等使用) | sm-evaluate DNG 読み込み実装時 | +| P2 | 黒レベル除去の有無(meta.json `sensorCharacteristics.black_level_pattern` で対応) | 同上 | +| P3 | デモザイク手法(最近傍 / バイリニア / AHD) | 同上.評価指標への影響を実測比較 | +| P4 | カラーマトリクス適用の有無 | 無彩色シーンでは影響軽微.本流は無適用で進める | +| P5 | 連射時の物理的振動・温度ドリフトの扱い | V1 結果次第.問題があれば連射間隔の延長等を検討 | + +## 関連ドキュメント (Related Documents) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — MiniTIAS 側への原始要求 +- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎ作業ガイド +- [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) — MiniTIAS 側からの実装報告(本回答の対象) +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8 系の経緯 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 形のずれ議論.LSC モデル化の物理動機 +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate パイプライン diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 05b4fb1..a8ac6de 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -42,3 +42,38 @@ - 状況: programming-template から進捗記録の二段構成(CLAUDE.md 1 行 + PROGRESS.md 追記ログ)と関連スラッシュコマンド・ガイド更新を取り込み - 動機: テンプレート側で進捗記録ルールが更新された.CLAUDE.md が毎ターン読み込まれることを考えると履歴を切り離す設計は合理的 - 設計判断: `.gitignore` はテンプレート版(ミニマル)を採用せず Flutter 用エントリを温存.`CLAUDE.md` も全体上書きせず進捗欄だけ二段構成に書き換え.既存進捗エントリは本ファイルへ移植 + +## DNG プレビュー JPEG 機能を追加 + +- 状況: 撮影 1 回で `.preview.jpg`(1632×1224,100〜300KB)を `.dng` と並べて保存.一覧画面で表示・拡大できる.削除時は dng / meta.json と連動 +- 動機: DNG は Flutter `Image.file` でデコードできずアプリ内で撮影結果を確認できなかった.研究者が「ちゃんと撮れたか」を端末上で判断できるようにする +- 設計判断: 別ファイル方式(案 B)を採用.案 A(DNG 内サムネ)は Dart で TIFF パーサが必要で実装重い.案 C(マルチストリーム + JPEG)は ISP のフルパイプライン処理が混じり研究データの位置付けが曖昧になる.案 B は Kotlin で Bayer→2×2 demosaic→ガンマ 2.2 で JPEG 化するだけのシンプル実装で,DNG 本体は無変更で済む +- 失敗パターン: なし(実機で意図通り動作) +- 既知の課題: プレビューが緑がかる.原因はおそらく WB 未適用(COLOR_CORRECTION_GAINS=identity)と BGGR の G チャネルが 2 サンプル平均で他より重み付けされる効果の組み合わせ.プレビュー画質の話なので DNG 解析には影響しない +- 後段: 緑かぶり改善(グレーワールド WB or AsShotNeutral 反映).露光時間キャリブ機能.連射再現性検証 V1 + +## プレビュー JPEG をライブビュー(自動 ISP)スナップショットに切替 + +- 状況: プレビュー JPEG の生成元を「Kotlin Bayer demosaic」から「`capturePreviewSnapshot` のライブプレビュー YUV→JPEG」に切替.Kotlin プラグインから約 200 行削除.Dart で `FileService.savePreviewJpeg` を追加し,`takePicture(previewBytes:)` でシャッター瞬間のスナップショットを保存 +- 動機: 自前で Bayer→グレーワールド AWB→自動レベル→sRGB トーンカーブと再現したが,旧 PNG(フル ISP)の見た目に届かなかった.Camera2 ISP がフル稼働するライブプレビューと同じ絵を使えば一発で一致する.かつコードが大幅にシンプルになる +- 設計判断: ライブプレビューの絵を「研究データには使わない確認用」と割り切る.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)へ diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 25c21be..6801ad2 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -141,7 +141,10 @@ } /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. - Future takePicture() async { + /// + /// [previewBytes] が指定された場合,`.preview.jpg` として保存する. + /// 指定しない場合はプレビュー JPEG の保存をスキップする. + Future takePicture({Uint8List? previewBytes}) async { if (!_isInitialized || _isSaving) return; _isSaving = true; @@ -186,9 +189,24 @@ lscMapColumnCount: capture.lscMapColumnCount, ); + // プレビュー JPEG 保存(snapshot がある場合のみ) + String? previewPath; + if (previewBytes != null) { + previewPath = await _fileService.savePreviewJpeg( + baseName, + previewBytes, + ); + debugPrint( + 'Preview saved: ${previewBytes.length} bytes at $previewPath', + ); + } + // MediaStore に登録(PC から MTP で見えるようにする) await _rawCaptureService.scanFile(capture.dngPath); await _rawCaptureService.scanFile(metaPath); + if (previewPath != null) { + await _rawCaptureService.scanFile(previewPath); + } _lastSavedFileName = capture.dngPath.split('/').last; _errorMessage = null; diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart index 35fb41b..bb4e1d8 100644 --- a/lib/providers/gallery_provider.dart +++ b/lib/providers/gallery_provider.dart @@ -12,7 +12,7 @@ List get images => _images; bool get isLoading => _isLoading; - /// Pictures/MiniTIAS/ 内の PNG ファイルを取得し,新しい順に並べる. + /// Pictures/MiniTIAS/ 内の PNG ファイルと QM プレビュー JPEG を取得し,新しい順に並べる. Future loadImages() async { _isLoading = true; notifyListeners(); @@ -24,10 +24,11 @@ } else { final files = await directory .list() - .where( - (entity) => - entity is File && entity.path.toLowerCase().endsWith('.png'), - ) + .where((entity) { + final lower = entity.path.toLowerCase(); + return entity is File && + (lower.endsWith('.png') || lower.endsWith('.preview.jpg')); + }) .cast() .toList(); @@ -50,14 +51,31 @@ } /// 指定したファイルを削除し,一覧から除去する. + /// + /// QM プレビュー (*.preview.jpg) を削除する場合,同じ baseName の + /// .dng ファイルと .meta.json ファイルも連動して削除する. + /// .png ファイルは単独削除(後方互換). Future deleteImage(File file) async { + final path = file.path; + final lower = path.toLowerCase(); + + // 削除対象ファイル一覧を確定 + final filesToDelete = [file]; + if (lower.endsWith('.preview.jpg')) { + final base = path.substring(0, path.length - '.preview.jpg'.length); + filesToDelete.add(File('$base.dng')); + filesToDelete.add(File('$base.meta.json')); + } + try { - if (await file.exists()) { - await file.delete(); + for (final f in filesToDelete) { + if (await f.exists()) { + await f.delete(); + } } - // 削除成功後にのみ一覧から除去 + // 元ファイルが消えたら成功とみなす if (!await file.exists()) { - _images.remove(file); + _images.removeWhere((f) => f.path == file.path); notifyListeners(); return true; } diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index 86179fd..ef74d81 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -66,8 +66,9 @@ final cameraProvider = context.read(); // カメラの生フレームからスナップショットを取得(オーバーレイなし,瞬時) + Uint8List? bytes; try { - final bytes = await cameraProvider.capturePreviewSnapshot(); + bytes = await cameraProvider.capturePreviewSnapshot(); if (bytes != null && mounted) { setState(() => _snapshotBytes = bytes); } @@ -78,7 +79,8 @@ } if (!mounted) return; - cameraProvider.takePicture(); + // snapshot bytes を takePicture に渡し,preview.jpg として保存する + cameraProvider.takePicture(previewBytes: bytes); } void _startCountdown() { diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index f25c11f..93aea80 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -71,7 +71,7 @@ children: [ Expanded( child: Text( - _selectedFile!.path.split('/').last, + _displayName(_selectedFile!), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -114,15 +114,35 @@ ); } + /// QM プレビューファイル(*.preview.jpg)かどうかを返す. + bool _isQmPreview(File file) => + file.path.toLowerCase().endsWith('.preview.jpg'); + + /// ファイルの表示名を返す. + /// + /// QM プレビュー (.preview.jpg) は対応する DNG ファイル名で表示する + /// (DNG が「本体」であることをユーザーに伝えるため). + String _displayName(File file) { + final name = file.path.split('/').last; + if (_isQmPreview(file)) { + return '${name.substring(0, name.length - '.preview.jpg'.length)}.dng'; + } + return name; + } + void _confirmDelete(GalleryProvider galleryProvider) { final file = _selectedFile; if (file == null) return; + final message = _isQmPreview(file) + ? 'この画像を削除しますか?\n(DNG ファイルとメタデータも一緒に削除されます)' + : 'この画像を削除しますか?'; + showDialog( context: context, builder: (context) => AlertDialog( title: const Text('画像を削除'), - content: const Text('この画像を削除しますか?'), + content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 6b72dcd..b88ed48 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -9,6 +9,14 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; + /// [directoryPath] が存在しない場合,再帰的に作成する. + Future _ensureDirectory() async { + final directory = Directory(directoryPath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + } + /// `YYYYMMDD_HHmmss` 形式のタイムスタンプ文字列を生成する. static String _buildTimestamp(DateTime dt) => '${dt.year}' @@ -24,12 +32,9 @@ /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, /// そのまま書き込むため高速に動作する. Future saveImage(Uint8List pngBytes) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final fileName = await generateFileName(); - final filePath = '$_basePath/$fileName'; + final filePath = '$directoryPath/$fileName'; await File(filePath).writeAsBytes(pngBytes); return filePath; } @@ -43,12 +48,12 @@ final baseName = 'MiniTIAS_$timestamp'; final candidate = '$baseName.png'; - if (!await File('$_basePath/$candidate').exists()) { + if (!await File('$directoryPath/$candidate').exists()) { return candidate; } var suffix = 1; - while (await File('$_basePath/${baseName}_$suffix.png').exists()) { + while (await File('$directoryPath/${baseName}_$suffix.png').exists()) { suffix++; } return '${baseName}_$suffix.png'; @@ -60,14 +65,11 @@ /// 保存先: `/storage/emulated/0/Pictures/MiniTIAS` /// 戻り値は保存したファイルのフルパス. Future saveDiagnosticsJson(Map diagnostics) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final fileName = 'camera_diagnostics_${_buildTimestamp(DateTime.now())}.json'; - final filePath = '$_basePath/$fileName'; + final filePath = '$directoryPath/$fileName'; const encoder = JsonEncoder.withIndent(' '); await File(filePath).writeAsString(encoder.convert(diagnostics)); @@ -102,12 +104,12 @@ Future generateQuantitativeBaseName() async { final candidate = 'MiniTIAS_QM_${_buildTimestamp(DateTime.now())}'; - if (!await File('$_basePath/$candidate.dng').exists()) { + if (!await File('$directoryPath/$candidate.dng').exists()) { return candidate; } var suffix = 1; - while (await File('$_basePath/${candidate}_$suffix.dng').exists()) { + while (await File('$directoryPath/${candidate}_$suffix.dng').exists()) { suffix++; } return '${candidate}_$suffix'; @@ -133,6 +135,20 @@ return '${candidate}_$suffix'; } + /// ライブプレビューのスナップショット JPEG を `.preview.jpg` として保存する. + /// + /// [previewBytes] は JPEG エンコード済みのバイト列. + /// 戻り値は保存したファイルのフルパス. + Future savePreviewJpeg( + String baseName, + Uint8List previewBytes, + ) async { + await _ensureDirectory(); + final filePath = '$directoryPath/$baseName.preview.jpg'; + await File(filePath).writeAsBytes(previewBytes); + return filePath; + } + /// メタデータと LSC マップを `.meta.json` として保存する. /// /// [lscMap] が null の場合,`lscMap`/`lscMapRowCount`/`lscMapColumnCount` キーは含めない. @@ -143,10 +159,7 @@ int? lscMapRowCount, int? lscMapColumnCount, }) async { - final directory = Directory(_basePath); - if (!await directory.exists()) { - await directory.create(recursive: true); - } + await _ensureDirectory(); final Map jsonMap = Map.from(metadata); if (lscMap != null) { @@ -156,7 +169,7 @@ } const encoder = JsonEncoder.withIndent(' '); - final filePath = '$_basePath/$baseName.meta.json'; + final filePath = '$directoryPath/$baseName.meta.json'; await File(filePath).writeAsString(encoder.convert(jsonMap)); return filePath; } diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 7a655fe..223eab1 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -123,6 +123,16 @@ /// DNG ファイルのバイト数(書き込み確認用). final int dngFileSize; + /// 縮小プレビュー JPEG の絶対パス. + /// + /// Kotlin 側でプレビュー生成に失敗した場合は null. + final String? previewPath; + + /// プレビュー JPEG のバイト数. + /// + /// Kotlin 側でプレビュー生成に失敗した場合は null. + final int? previewFileSize; + /// 撮影メタデータ(設定値・実測値・センサ特性など). final Map metadata; @@ -138,6 +148,8 @@ const QuantitativeCaptureResult({ required this.dngPath, required this.dngFileSize, + this.previewPath, + this.previewFileSize, required this.metadata, this.lscMap, this.lscMapRowCount, @@ -149,6 +161,9 @@ final dngPath = map['dngPath'] as String; final dngFileSize = (map['dngFileSize'] as num).toInt(); + final previewPath = map['previewPath'] as String?; + final previewFileSize = (map['previewFileSize'] as num?)?.toInt(); + final rawMetadata = map['metadata']; final metadata = (_deepConvert(rawMetadata) as Map?) ?? {}; @@ -167,6 +182,8 @@ return QuantitativeCaptureResult( dngPath: dngPath, dngFileSize: dngFileSize, + previewPath: previewPath, + previewFileSize: previewFileSize, metadata: metadata, lscMap: lscMap, lscMapRowCount: lscMapRowCount, diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart new file mode 100644 index 0000000..68824a8 --- /dev/null +++ b/test/providers/gallery_provider_test.dart @@ -0,0 +1,341 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// テスト専用の GalleryProvider 実装. +/// +/// プロダクションの GalleryProvider はパスがハードコードされており +/// サブクラスでオーバーライドできないため,同じロジックをテスト用パスで +/// 再実装する.仕様(SPEC_01)に準拠した振る舞いをテストする. +class _GalleryProviderTestable extends ChangeNotifier { + _GalleryProviderTestable(this._basePath); + + final String _basePath; + + List _images = []; + bool _isLoading = false; + + List get images => _images; + bool get isLoading => _isLoading; + + /// Pictures/MiniTIAS/ 内の PNG と .preview.jpg を取得し,新しい順に並べる. + Future loadImages() async { + _isLoading = true; + notifyListeners(); + + try { + final directory = Directory(_basePath); + if (!await directory.exists()) { + _images = []; + } else { + final files = await directory + .list() + .where((entity) { + final lower = entity.path.toLowerCase(); + return entity is File && + (lower.endsWith('.png') || lower.endsWith('.preview.jpg')); + }) + .cast() + .toList(); + + // 更新日時の新しい順にソート + files.sort((a, b) { + final aStat = a.statSync(); + final bStat = b.statSync(); + return bStat.modified.compareTo(aStat.modified); + }); + + _images = files; + } + } catch (e) { + debugPrint('画像一覧の取得に失敗: $e'); + _images = []; + } + + _isLoading = false; + notifyListeners(); + } + + /// 指定したファイルを削除し,一覧から除去する. + /// + /// QM プレビュー (*.preview.jpg) を削除する場合,同じ baseName の + /// .dng ファイルと .meta.json ファイルも連動して削除する. + /// .png ファイルは単独削除(後方互換). + Future deleteImage(File file) async { + final path = file.path; + final lower = path.toLowerCase(); + + final filesToDelete = [file]; + if (lower.endsWith('.preview.jpg')) { + final base = path.substring(0, path.length - '.preview.jpg'.length); + filesToDelete.add(File('$base.dng')); + filesToDelete.add(File('$base.meta.json')); + } + + try { + for (final f in filesToDelete) { + if (await f.exists()) { + await f.delete(); + } + } + if (!await file.exists()) { + _images.remove(file); + notifyListeners(); + return true; + } + return false; + } catch (e) { + debugPrint('画像の削除に失敗: $e'); + return false; + } + } +} + +void main() { + group('GalleryProvider.loadImages', () { + late Directory tempDir; + late _GalleryProviderTestable provider; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'mini_tias_gallery_test_', + ); + provider = _GalleryProviderTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('.png のみある場合,全件取得する', () async { + await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create(); + await File('${tempDir.path}/MiniTIAS_20260101_120001.png').create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_20260101_120000.png')); + expect(names, contains('MiniTIAS_20260101_120001.png')); + expect(provider.images, hasLength(2)); + }); + + test('.preview.jpg のみある場合,全件取得する', () async { + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg', + ).create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_QM_20260101_120000.preview.jpg')); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(2)); + }); + + test('.png と .preview.jpg が混在する場合,両方取得する', () async { + await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, contains('MiniTIAS_20260101_120000.png')); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(2)); + }); + + test('.dng や .meta.json は取得しない', () async { + await File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng').create(); + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ).create(); + + await provider.loadImages(); + + expect(provider.images, isEmpty); + }); + + test('通常 .jpg(.preview.jpg 以外)は取得しない', () async { + // .preview.jpg のみが対象,単純な .jpg は除外される + await File('${tempDir.path}/photo.jpg').create(); + await File('${tempDir.path}/MiniTIAS_20260101_120000.jpg').create(); + // 比較用に .preview.jpg を 1 件追加 + await File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ).create(); + + await provider.loadImages(); + + final names = provider.images + .map((f) => f.path.split(RegExp(r'[\\/]')).last) + .toList(); + expect(names, isNot(contains('photo.jpg'))); + expect(names, isNot(contains('MiniTIAS_20260101_120000.jpg'))); + expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg')); + expect(provider.images, hasLength(1)); + }); + + test('ディレクトリが存在しない場合,空リストを返す', () async { + final nonExistentPath = '${tempDir.path}/nonexistent'; + final providerNonExistent = _GalleryProviderTestable(nonExistentPath); + + await providerNonExistent.loadImages(); + + expect(providerNonExistent.images, isEmpty); + }); + + test('ファイルが新しい順(更新日時降順)にソートされる', () async { + // 先に古いファイルを作成し,十分な時間差を設けてから新しいファイルを作成する + final olderPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + final newerPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg'; + await File(olderPath).create(); + // Windows NTFS の mtime 精度 (100ns) より十分大きい 1 秒以上待機 + await Future.delayed(const Duration(seconds: 2)); + await File(newerPath).create(); + + await provider.loadImages(); + + expect(provider.images, hasLength(2)); + // 新しい順なので newer が先頭 + final firstBasename = provider.images.first.path + .split(RegExp(r'[\\/]')) + .last; + expect(firstBasename, 'MiniTIAS_QM_20260101_120001.preview.jpg'); + }); + }); + + group('GalleryProvider.deleteImage', () { + late Directory tempDir; + late _GalleryProviderTestable provider; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('mini_tias_del_test_'); + provider = _GalleryProviderTestable(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('.preview.jpg を削除すると同 basename の .dng も削除される', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng'); + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + await File(previewPath).create(); + await dngFile.create(); + await metaFile.create(); + + await provider.loadImages(); + // loadImages で取得した同一インスタンスを使って削除する + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await dngFile.exists(), isFalse); + }); + + test('.preview.jpg を削除すると同 basename の .meta.json も削除される', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng'); + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + await File(previewPath).create(); + await dngFile.create(); + await metaFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await metaFile.exists(), isFalse); + }); + + test('.png を削除しても他のファイルは削除されない', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + // 同名の .dng が仮に存在しても,連動削除の対象にならない + final dngFile = File('${tempDir.path}/MiniTIAS_20260101_120000.dng'); + await File(pngPath).create(); + await dngFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + // .dng は削除されていない + expect(await dngFile.exists(), isTrue); + }); + + test('連動削除対象の .dng が最初から存在しない場合でもエラーにならず .preview.jpg の削除は成功する', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + final metaFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json', + ); + // .dng は作成しない + await File(previewPath).create(); + await metaFile.create(); + + await provider.loadImages(); + final fileToDelete = provider.images.first; + // .dng が存在しなくても例外にならない + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(await File(previewPath).exists(), isFalse); + }); + + test('削除成功後,images リストから対象ファイルが除去される(.preview.jpg)', () async { + final previewPath = + '${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg'; + await File(previewPath).create(); + + await provider.loadImages(); + expect(provider.images, hasLength(1)); + + // loadImages で保持している同一インスタンスを使って削除する + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(provider.images, isEmpty); + }); + + test('削除成功後,images リストから対象ファイルが除去される(.png)', () async { + final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png'; + await File(pngPath).create(); + + await provider.loadImages(); + expect(provider.images, hasLength(1)); + + final fileToDelete = provider.images.first; + final result = await provider.deleteImage(fileToDelete); + + expect(result, isTrue); + expect(provider.images, isEmpty); + }); + }); +} diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 285a609..7e88747 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -167,6 +167,86 @@ }); }); + group('FileService.savePreviewJpeg', () { + late Directory tempDir; + late _FileServiceTestablePreview service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'mini_tias_preview_test_', + ); + service = _FileServiceTestablePreview(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('指定した baseName で .preview.jpg ファイルが生成される', () async { + const baseName = 'MiniTIAS_QM_20260531_120000'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + await service.savePreviewJpeg(baseName, previewBytes); + + final expectedFile = File('${tempDir.path}/$baseName.preview.jpg'); + expect(await expectedFile.exists(), isTrue); + }); + + test('戻り値が保存パス(baseName.preview.jpg を含む)になる', () async { + const baseName = 'MiniTIAS_QM_20260531_120000'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + expect(path, contains('$baseName.preview.jpg')); + }); + + test('渡したバイト列がそのままファイルに書き込まれる', () async { + const baseName = 'MiniTIAS_QM_20260531_120001'; + final previewBytes = Uint8List.fromList([ + 0xFF, + 0xD8, + 0xFF, + 0xE0, + 0x00, + 0x10, + 0x4A, + 0x46, + ]); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + final written = await File(path).readAsBytes(); + expect(written, equals(previewBytes)); + }); + + test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = _FileServiceTestablePreview( + nonExistentSubDir.path, + ); + expect(await nonExistentSubDir.exists(), isFalse); + + const baseName = 'MiniTIAS_QM_20260531_120002'; + final previewBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]); + final path = await serviceWithSubDir.savePreviewJpeg( + baseName, + previewBytes, + ); + + expect(await File(path).exists(), isTrue); + }); + + test('空のバイト列でも .preview.jpg ファイルが生成される', () async { + const baseName = 'MiniTIAS_QM_20260531_120003'; + final previewBytes = Uint8List(0); + final path = await service.savePreviewJpeg(baseName, previewBytes); + + expect(await File(path).exists(), isTrue); + final written = await File(path).readAsBytes(); + expect(written.length, 0); + }); + }); + group('FileService.generateQuantitativeBaseNameSync', () { test('同秒のファイルが存在しない場合,サフィックスなしの baseName を返す', () { final result = FileService.generateQuantitativeBaseNameSync( @@ -418,6 +498,30 @@ } } +/// テスト用: savePreviewJpeg の保存先をオーバーライドする FileService サブクラス. +class _FileServiceTestablePreview extends FileService { + _FileServiceTestablePreview(this._testBasePath); + + final String _testBasePath; + + @override + String get directoryPath => _testBasePath; + + @override + Future savePreviewJpeg( + String baseName, + Uint8List previewBytes, + ) async { + final directory = Directory(_testBasePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + final filePath = '$_testBasePath/$baseName.preview.jpg'; + await File(filePath).writeAsBytes(previewBytes); + return filePath; + } +} + /// テスト用: saveQuantitativeMetadata の保存先をオーバーライドする FileService サブクラス. class _FileServiceTestableQuantitative extends FileService { _FileServiceTestableQuantitative(this._testBasePath); diff --git a/test/services/raw_capture_service_test.dart b/test/services/raw_capture_service_test.dart index a7ca24e..68daee8 100644 --- a/test/services/raw_capture_service_test.dart +++ b/test/services/raw_capture_service_test.dart @@ -138,5 +138,78 @@ expect(row[1], closeTo(2.0, 0.0001)); expect(row[2], closeTo(3.14, 0.0001)); }); + + // --- previewPath / previewFileSize 関連テスト --- + + test('previewPath と previewFileSize が両方 non-null の場合,フィールドに値がセットされる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + 'previewPath': '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', + 'previewFileSize': 204800, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect( + result.previewPath, + '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', + ); + expect(result.previewFileSize, 204800); + expect(result.previewFileSize, isA()); + }); + + test('previewPath が null の場合,previewPath は null になる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + 'previewPath': null, + 'previewFileSize': null, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.previewPath, isNull); + expect(result.previewFileSize, isNull); + }); + + test( + 'map に previewPath / previewFileSize キー自体が存在しない場合でも fromMap が成功し両フィールドが null になる', + () { + // 後方互換: プレビュー生成前の Kotlin が返す map にキーが存在しない場合 + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.previewPath, isNull); + expect(result.previewFileSize, isNull); + }, + ); + + test('previewFileSize が int 直接でも num 経由でも正しく int に変換される', () { + // int 直接のケース + final mapInt = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'previewPath': '/test.preview.jpg', + 'previewFileSize': 204800, // Dart int + }; + final resultInt = QuantitativeCaptureResult.fromMap(mapInt); + expect(resultInt.previewFileSize, 204800); + expect(resultInt.previewFileSize, isA()); + + // num (double) 経由のケース + final mapDouble = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'previewPath': '/test.preview.jpg', + 'previewFileSize': 204800.0, // double として渡される場合 + }; + final resultDouble = QuantitativeCaptureResult.fromMap(mapDouble); + expect(resultDouble.previewFileSize, 204800); + expect(resultDouble.previewFileSize, isA()); + }); }); }