diff --git a/CLAUDE.md b/CLAUDE.md index 5e113c0..8875cac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,8 @@ 現在の進捗: Step 1〜5 完了(撮影・保存・一覧・削除の実装完了).初期バージョンのアプリが動作する状態. ※ 撮影→保存の高速化完了(YUV→PNG 変換を Android ネイティブに移行,非圧縮 PNG + 回転統合により 15 秒 → 約 2 秒に短縮) -※ カメラ診断機能のインフラ追加完了(`DiagnosticsProvider` / `RawCaptureService.getCameraDiagnostics` / `FileService.saveDiagnosticsJson`).UI は非表示.AQUOS sense3 のフロントカメラは **HW Level 3 + RAW (DNG) + MANUAL_SENSOR + MANUAL_POST_PROCESSING** 対応を確認.次は定量モード撮影機能(DNG 経路)の実装. +※ カメラ診断機能のインフラ追加完了(`DiagnosticsProvider` / `RawCaptureService.getCameraDiagnostics` / `FileService.saveDiagnosticsJson`).UI は非表示.AQUOS sense3 のフロントカメラは **HW Level 3 + RAW (DNG) + MANUAL_SENSOR + MANUAL_POST_PROCESSING** 対応を確認. +※ **定量モード撮影機能(DNG 経路)完成**.シャッターボタンタップで `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(10-bit BGGR Bayer 3264×2448,約 16MB)+ `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json`(撮影設定・実適用値・LSC マップ・センサ特性)を `Pictures/MiniTIAS/` に保存.Camera2 API でマニュアル制御(露光 1/60s 固定,ISO 40 固定,AE/AWB/NR/EDGE OFF,TONEMAP 線形,SHADING_MODE HIGH_QUALITY,AF OFF).既存 PNG 経路は `captureFullResolutionPng` として温存. ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) 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 589a200..a80cd1c 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 @@ -9,17 +9,24 @@ import android.graphics.Rect import android.graphics.YuvImage import android.hardware.camera2.* -import android.media.AudioAttributes +import android.hardware.camera2.params.ColorSpaceTransform +import android.hardware.camera2.params.RggbChannelVector +import android.hardware.camera2.params.TonemapCurve import android.media.ImageReader import android.media.MediaActionSound import android.media.MediaScannerConnection import android.media.RingtoneManager +import android.os.Build import android.os.Handler import android.os.HandlerThread +import android.util.Log import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream import java.nio.ByteBuffer +import java.time.Instant import java.util.zip.CRC32 import java.util.zip.Deflater import java.util.zip.DeflaterOutputStream @@ -45,9 +52,27 @@ private var cameraDevice: CameraDevice? = null private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } + private companion object { + const val DEFAULT_EXPOSURE_TIME_NS = 16_666_666L + 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 + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "captureFullResolutionPng" -> captureFullResolutionPng(result) + "captureQuantitativeDng" -> { + val baseName = call.argument("baseName") + val directoryPath = call.argument("directoryPath") + if (baseName == null || directoryPath == null) { + result.error("INVALID_ARG", "baseName と directoryPath が必要です", null) + } else { + captureQuantitativeDng(baseName, directoryPath, result) + } + } "convertYuvToJpeg" -> convertYuvToJpeg(call, result) "scanFile" -> { val path = call.argument("path") @@ -187,6 +212,464 @@ } /** + * 定量モード撮影.RAW_SENSOR (DNG) で保存し,メタデータと LSC マップを返す. + * + * CONTROL_MODE=OFF など完全マニュアル制御で連続 3 フレームを投げ, + * 最初の 2 フレーム(設定反映待ち)を捨てて 3 枚目を DNG 化する. + * DNG ファイルは Kotlin 側で [directoryPath]/[baseName].dng に直接書き込む. + */ + private fun captureQuantitativeDng( + baseName: String, + directoryPath: String, + result: MethodChannel.Result, + ) { + startBackgroundThread() + + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + val cameraId = findFrontCameraId(cameraManager) + + if (cameraId == null) { + returnError(result, "NO_CAMERA", "フロントカメラが見つかりません") + return + } + + val characteristics = cameraManager.getCameraCharacteristics(cameraId) + + // RAW_SENSOR サポート確認 + val capabilities = + characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + val supportsRaw = + capabilities?.contains( + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW + ) == true + if (!supportsRaw) { + returnError(result, "NOT_SUPPORTED", "このカメラは RAW_SENSOR をサポートしていません") + return + } + + val imageReader = ImageReader.newInstance( + RAW_WIDTH, RAW_HEIGHT, ImageFormat.RAW_SENSOR, 3 + ) + + var resultSent = false + + try { + cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + cameraDevice = camera + try { + camera.createCaptureSession( + listOf(imageReader.surface), + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + try { + openCameraAndCaptureRaw( + camera, session, imageReader, characteristics, + cameraId, baseName, directoryPath, + result, { sent -> resultSent = sent } + ) + } catch (e: Exception) { + if (!resultSent) { + resultSent = true + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error( + "CAPTURE_ERROR", + "定量キャプチャに失敗: ${e.message}", + null + ) + } + } + } + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + if (!resultSent) { + resultSent = true + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error( + "SESSION_ERROR", + "カメラセッションの設定に失敗", + null + ) + } + } + } + }, + backgroundHandler + ) + } catch (e: Exception) { + if (!resultSent) { + resultSent = true + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error( + "CAPTURE_ERROR", + "定量キャプチャに失敗: ${e.message}", + null + ) + } + } + } + } + + override fun onDisconnected(camera: CameraDevice) { + camera.close() + cameraDevice = null + imageReader.close() + stopBackgroundThread() + } + + override fun onError(camera: CameraDevice, error: Int) { + camera.close() + cameraDevice = null + imageReader.close() + if (!resultSent) { + resultSent = true + Handler(context.mainLooper).post { + result.error("CAMERA_ERROR", "カメラエラー: $error", null) + stopBackgroundThread() + } + } + } + }, backgroundHandler) + } catch (e: SecurityException) { + imageReader.close() + returnError(result, "PERMISSION_ERROR", "カメラの権限がありません") + } + } + + /** + * 定量モード RAW キャプチャの実処理. + * + * session が onConfigured 後に呼ばれる.FRAMES_TO_DISCARD 枚を捨てた後, + * 3 枚目の Image と TotalCaptureResult を DngCreator に渡して DNG バイト列を生成する. + */ + private fun openCameraAndCaptureRaw( + camera: CameraDevice, + session: CameraCaptureSession, + imageReader: ImageReader, + characteristics: CameraCharacteristics, + cameraId: String, + baseName: String, + directoryPath: String, + result: MethodChannel.Result, + setResultSent: (Boolean) -> Unit, + ) { + var capturedImage: android.media.Image? = null + var captureResult: TotalCaptureResult? = null + // 画像到着カウント(ImageAvailableListener 側) + var imageArrivalCount = 0 + // CaptureResult 到着カウント(CaptureCallback 側) + var resultArrivalCount = 0 + var resultSentFlag = false + + fun sendResultSafe(action: () -> Unit) { + if (!resultSentFlag) { + resultSentFlag = true + setResultSent(true) + action() + } + } + + // 完全マニュアル制御の CaptureRequest を構築 + val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { + addTarget(imageReader.surface) + + set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_OFF) + set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF) + set(CaptureRequest.SENSOR_EXPOSURE_TIME, DEFAULT_EXPOSURE_TIME_NS) + set(CaptureRequest.SENSOR_SENSITIVITY, DEFAULT_SENSITIVITY) + set(CaptureRequest.SENSOR_FRAME_DURATION, DEFAULT_FRAME_DURATION_NS) + set(CaptureRequest.BLACK_LEVEL_LOCK, true) + set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_OFF) + set(CaptureRequest.COLOR_CORRECTION_MODE, + CaptureRequest.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX) + set(CaptureRequest.COLOR_CORRECTION_GAINS, RggbChannelVector(1.0f, 1.0f, 1.0f, 1.0f)) + // 単位行列(3×3,Rational 型) + set( + CaptureRequest.COLOR_CORRECTION_TRANSFORM, + ColorSpaceTransform( + intArrayOf( + 1, 1, 0, 1, 0, 1, + 0, 1, 1, 1, 0, 1, + 0, 1, 0, 1, 1, 1, + ) + ) + ) + set(CaptureRequest.NOISE_REDUCTION_MODE, CaptureRequest.NOISE_REDUCTION_MODE_OFF) + set(CaptureRequest.EDGE_MODE, CaptureRequest.EDGE_MODE_OFF) + set(CaptureRequest.TONEMAP_MODE, CaptureRequest.TONEMAP_MODE_CONTRAST_CURVE) + // 線形トーンマップカーブ(各チャネル: (0,0)→(1,1)) + set( + CaptureRequest.TONEMAP_CURVE, + TonemapCurve( + floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f), + floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f), + floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f), + ) + ) + set(CaptureRequest.SHADING_MODE, CaptureRequest.SHADING_MODE_HIGH_QUALITY) + set(CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE, + CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE_ON) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) + }.build() + + val captureCallback = object : CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted( + session: CameraCaptureSession, + request: CaptureRequest, + totalResult: TotalCaptureResult, + ) { + resultArrivalCount++ + // FRAMES_TO_DISCARD + 1 枚目(最後)だけ CaptureResult を保持 + if (resultArrivalCount == FRAMES_TO_DISCARD + 1) { + captureResult = totalResult + tryProcessRaw( + capturedImage, captureResult, imageReader, camera, + characteristics, cameraId, baseName, directoryPath, + result, ::sendResultSafe + ) + } + } + + override fun onCaptureFailed( + session: CameraCaptureSession, + request: CaptureRequest, + failure: CaptureFailure, + ) { + sendResultSafe { + capturedImage?.close() + imageReader.close() + cleanup() + Handler(context.mainLooper).post { + result.error("CAPTURE_FAILED", "キャプチャに失敗しました: ${failure.reason}", null) + } + } + } + } + + imageReader.setOnImageAvailableListener({ reader -> + val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener + imageArrivalCount++ + // 最初の FRAMES_TO_DISCARD 枚は捨てる(設定反映待ち) + if (imageArrivalCount <= FRAMES_TO_DISCARD) { + image.close() + return@setOnImageAvailableListener + } + // FRAMES_TO_DISCARD + 1 枚目だけ保持 + if (capturedImage == null) { + capturedImage = image + tryProcessRaw( + capturedImage, captureResult, imageReader, camera, + characteristics, cameraId, baseName, directoryPath, + result, ::sendResultSafe + ) + } else { + image.close() + } + }, backgroundHandler) + + // 連続 3 回キャプチャを投げる + repeat(FRAMES_TO_DISCARD + 1) { + session.capture(captureRequest, captureCallback, backgroundHandler) + } + } + + /** + * 画像と CaptureResult の両方が揃ったら DNG を生成して result に返す. + * + * 片方がまだない場合は何もしない(もう一方の到着を待つ). + */ + private fun tryProcessRaw( + image: android.media.Image?, + captureResult: TotalCaptureResult?, + imageReader: ImageReader, + camera: CameraDevice, + characteristics: CameraCharacteristics, + cameraId: String, + baseName: String, + directoryPath: String, + result: MethodChannel.Result, + sendResultSafe: (() -> Unit) -> Unit, + ) { + if (image == null || captureResult == null) return + + sendResultSafe { + try { + // DNG をファイルに直接書き込む(MethodChannel 越しの巨大バイト転送を回避) + val dir = File(directoryPath) + if (!dir.exists()) dir.mkdirs() + val dngFile = File(dir, "$baseName.dng") + FileOutputStream(dngFile).use { fos -> + DngCreator(characteristics, captureResult).use { dngCreator -> + dngCreator.writeImage(fos, image) + } + } + Log.d("RawCapturePlugin", "DNG written: ${dngFile.length()} bytes to ${dngFile.absolutePath}") + + // LSC マップを取得 + val lscMap = + captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) + val lscData: List>? = lscMap?.let { map -> + val rows = map.rowCount + val cols = map.columnCount + val gainFactors = FloatArray(rows * cols * 4) + map.copyGainFactors(gainFactors, 0) + (0 until rows).map { row -> + gainFactors.slice(row * cols * 4 until (row + 1) * cols * 4) + } + } + + // メタデータを構築 + val metadataMap = buildMetadataMap( + cameraId, characteristics, captureResult, image + ) + + image.close() + imageReader.close() + camera.close() + cameraDevice = null + + val resultMap = mapOf( + "dngPath" to dngFile.absolutePath, + "dngFileSize" to dngFile.length(), + "metadata" to metadataMap, + "lscMap" to lscData, + "lscMapRowCount" to lscMap?.rowCount, + "lscMapColumnCount" to lscMap?.columnCount, + ) + + Handler(context.mainLooper).post { + result.success(resultMap) + stopBackgroundThread() + } + } catch (e: Exception) { + image.close() + imageReader.close() + cleanup() + Handler(context.mainLooper).post { + result.error("DNG_ERROR", "DNG 生成に失敗: ${e.message}", null) + stopBackgroundThread() + } + } + } + } + + /** 撮影メタデータ Map を構築する.*/ + private fun buildMetadataMap( + cameraId: String, + characteristics: CameraCharacteristics, + captureResult: TotalCaptureResult, + image: android.media.Image, + ): Map { + val settingsMap = mapOf( + "control_mode" to "OFF", + "ae_mode" to "OFF", + "exposure_time_ns" to DEFAULT_EXPOSURE_TIME_NS, + "sensor_sensitivity_iso" to DEFAULT_SENSITIVITY, + "frame_duration_ns" to DEFAULT_FRAME_DURATION_NS, + "awb_mode" to "OFF", + "color_correction_gains" to listOf(1.0, 1.0, 1.0, 1.0), + "color_correction_transform" to "identity", + "noise_reduction_mode" to "OFF", + "edge_mode" to "OFF", + "tonemap_mode" to "CONTRAST_CURVE_LINEAR", + "shading_mode" to "HIGH_QUALITY", + "statistics_lens_shading_map_mode" to "ON", + "af_mode" to "OFF", + "black_level_lock" to true, + ) + + val actualMap = mapOf( + "exposure_time_ns" to captureResult.get(CaptureResult.SENSOR_EXPOSURE_TIME), + "sensor_sensitivity" to captureResult.get(CaptureResult.SENSOR_SENSITIVITY), + "frame_duration_ns" to captureResult.get(CaptureResult.SENSOR_FRAME_DURATION), + "sensor_timestamp" to captureResult.get(CaptureResult.SENSOR_TIMESTAMP), + "dynamic_black_level" to + captureResult.get(CaptureResult.SENSOR_DYNAMIC_BLACK_LEVEL)?.toList(), + "dynamic_white_level" to + captureResult.get(CaptureResult.SENSOR_DYNAMIC_WHITE_LEVEL), + "neutral_color_point" to + captureResult.get(CaptureResult.SENSOR_NEUTRAL_COLOR_POINT)?.map { + "${it.numerator}/${it.denominator}" + }, + "color_gains" to captureResult.get(CaptureResult.COLOR_CORRECTION_GAINS)?.let { + listOf( + it.red.toDouble(), + it.greenEven.toDouble(), + it.greenOdd.toDouble(), + it.blue.toDouble() + ) + }, + "color_transform" to + captureResult.get(CaptureResult.COLOR_CORRECTION_TRANSFORM)?.let { transform -> + (0 until 3).map { row -> + (0 until 3).map { col -> + val r = transform.getElement(col, row) + "${r.numerator}/${r.denominator}" + } + } + }, + ) + + val sensorCharacteristicsMap = mapOf( + "color_filter_arrangement" to + characteristics.get( + CameraCharacteristics.SENSOR_INFO_COLOR_FILTER_ARRANGEMENT + ), + "black_level_pattern" to + characteristics.get( + CameraCharacteristics.SENSOR_BLACK_LEVEL_PATTERN + )?.toString(), + "white_level" to characteristics.get(CameraCharacteristics.SENSOR_INFO_WHITE_LEVEL), + "physical_size" to + characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)?.let { + listOf(it.width, it.height) + }, + "pixel_array_size" to + characteristics.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)?.let { + listOf(it.width, it.height) + }, + "active_array_size" to + characteristics.get( + CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE + )?.toString(), + ) + + return mapOf( + "captureTimestampUtc" to Instant.now().toString(), + "deviceModel" to Build.MODEL, + "androidVersion" to Build.VERSION.RELEASE, + "cameraId" to cameraId, + "hardwareLevel" to + characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL), + "captureMode" to "quantitative", + "imageFormat" to "RAW_SENSOR", + "imageSize" to listOf(image.width, image.height), + "settings" to settingsMap, + "actual" to actualMap, + "sensorCharacteristics" to sensorCharacteristicsMap, + ) + } + + /** フロントカメラの ID を返す.見つからない場合は null を返す.*/ + private fun findFrontCameraId(cameraManager: CameraManager): String? = + cameraManager.cameraIdList.firstOrNull { id -> + val characteristics = cameraManager.getCameraCharacteristics(id) + characteristics.get(CameraCharacteristics.LENS_FACING) == + CameraCharacteristics.LENS_FACING_FRONT + } + + /** * フロントカメラを開いてフル解像度の YUV フレームを 1 枚取得し,[onFrame] を呼び出す. * * カメラのオープン・セッション作成・AE/AF 安定待機(1 秒)・本番キャプチャの @@ -201,10 +684,7 @@ val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - val cameraId = cameraManager.cameraIdList.firstOrNull { id -> - val characteristics = cameraManager.getCameraCharacteristics(id) - characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT - } + val cameraId = findFrontCameraId(cameraManager) if (cameraId == null) { returnError(result, "NO_CAMERA", "フロントカメラが見つかりません") @@ -453,10 +933,7 @@ private fun getCameraDiagnostics(result: MethodChannel.Result) { val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - val cameraId = cameraManager.cameraIdList.firstOrNull { id -> - val characteristics = cameraManager.getCameraCharacteristics(id) - characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT - } + val cameraId = findFrontCameraId(cameraManager) if (cameraId == null) { result.error("NO_CAMERA", "フロントカメラが見つかりません", null) 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 eff0204..5755ce1 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,11 +95,13 @@ | 項目 | 仕様 | | --- | --- | -| 保存形式 | PNG(非圧縮) | +| 保存形式(定量モード) | DNG(RAW_SENSOR 10-bit BGGR Bayer)+ メタデータ JSON | | 保存先 | `Pictures/MiniTIAS/` (共有ストレージ) | -| ファイル名形式 | `MiniTIAS_YYYYMMDD_HHmmss.png` | -| 同秒重複時 | `MiniTIAS_YYYYMMDD_HHmmss_1.png`,`_2.png` ... と連番を付与 | -| データ転送 | USB 接続で PC からフォルダを直接参照・コピー | +| 画像ファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(約 16 MB) | +| メタデータファイル名 | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json`(撮影設定・実適用値・LSC マップ・センサ特性を内包) | +| 同秒重複時 | `MiniTIAS_QM_YYYYMMDD_HHmmss_1.dng`/`.meta.json`,`_2.*` ... と連番を付与 | +| データ転送 | USB 接続で PC からフォルダを直接参照・コピー.大きい DNG は `adb pull` を推奨(MTP は不安定なことあり) | +| 旧 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 4e1d4b9..8045ff8 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" @@ -131,15 +131,44 @@ ## カメラ制御仕様 (Camera Control) -### カメラ設定 +### プレビュー設定(フレーミング用) | 項目 | 値 | | --- | --- | | 使用カメラ | フロントカメラ(インカメラ) | -| 解像度 | `ResolutionPreset.max`(カメラが対応する最大解像度) | -| フォーカス | オートフォーカス(デフォルト動作) | +| 解像度 | `ResolutionPreset.max` | +| 制御方式 | `camera` パッケージ(オート任せ) | | フラッシュ | OFF(LED ライトはアタッチメント側で制御) | -| 画像フォーマット | Camera2 API で YUV_420_888 生データを取得し,PNG に直接変換して保存(JPEG 非経由) | + +プレビューは見やすさ重視.撮影時は別の Camera2 セッションが走る. + +### 撮影設定(定量モード.Camera2 API マニュアル制御) + +| 項目 | 値 | +| --- | --- | +| 使用カメラ | フロントカメラ(インカメラ) | +| 解像度 | 3264×2448(センサ最大) | +| 画像フォーマット | `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_SENSITIVITY` | 40(センサ最低 ISO)固定 | +| `SENSOR_FRAME_DURATION` | 33,333,333 ns(30fps 相当) | +| `BLACK_LEVEL_LOCK` | true | +| `CONTROL_AWB_MODE` | OFF | +| `COLOR_CORRECTION_GAINS` | (1.0, 1.0, 1.0, 1.0) | +| `COLOR_CORRECTION_TRANSFORM` | 単位行列 | +| `NOISE_REDUCTION_MODE` | OFF | +| `EDGE_MODE` | OFF | +| `TONEMAP_MODE` | `CONTRAST_CURVE` 線形 `[(0,0),(1,1)]` | +| `SHADING_MODE` | HIGH_QUALITY(LSC マップ実値取得のため.画像は LSC 補正済み.PC 側で逆適用可) | +| `STATISTICS_LENS_SHADING_MAP_MODE` | ON | +| `CONTROL_AF_MODE` | OFF(AQUOS sense3 は固定焦点) | +| `FLASH_MODE` | OFF | + +### 既存 PNG 撮影パス(無効化済み・コードは温存) + +旧 YUV→BT.601→PNG 経路は `RawCapturePlugin.captureFullResolutionPng` として残存.現状の `takePicture` フローからは呼ばれない.差分比較やフォールバック用に保持. ### プレビュー表示 @@ -150,7 +179,7 @@ - Y 軸反転: 端末が逆さのため上下を補正 - プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) -### 撮影フロー +### 撮影フロー(定量モード) ```text シャッターボタンタップ @@ -159,31 +188,36 @@ プレビュー停止(CameraController.dispose()) │ ▼ -Camera2 API でフロントカメラを開く(YUV_420_888,フル解像度) +baseName 生成(MiniTIAS_QM_YYYYMMDD_HHmmss.重複時 _1, _2 ...) │ ▼ -AE/AF 安定のため 1 秒間プレビューフレームを流す +Camera2 API でフロントカメラを開く(RAW_SENSOR,3264×2448) │ ▼ -STILL_CAPTURE リクエストで YUV フレームを 1 枚取得 +上記マニュアル制御パラメータを CaptureRequest に適用 │ ▼ -YUV → RGB 変換(BT.601 係数,isolate で実行) +連続 3 枚キャプチャ(設定反映のため最初の 2 枚は破棄) │ ▼ -90° 時計回り回転(センサー向き補正) +3 枚目の Image + TotalCaptureResult を取得 │ ▼ -PNG エンコード(image パッケージ,ロスレス) +DngCreator で DNG ファイルを直接書き込み(FileOutputStream) + → Pictures/MiniTIAS/{baseName}.dng(約 16 MB) │ ▼ -ファイル名生成(命名規則に従う) +LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) │ ▼ -Pictures/MiniTIAS/ に保存 +メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap) │ ▼ -MediaStore 通知(MediaScannerConnection でスキャン) +Dart 側で JSON 保存 + → Pictures/MiniTIAS/{baseName}.meta.json + │ + ▼ +MediaStore 通知(DNG / meta.json 両方を scanFile) │ ▼ Camera2 を閉じ,プレビューを再開 diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index fba41bc..25c21be 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -166,16 +166,31 @@ // カメラが完全に解放されるまで待つ await Future.delayed(const Duration(milliseconds: 500)); - // Camera2 API でフル解像度キャプチャ(ネイティブで YUV→PNG 変換) - final pngBytes = await _rawCaptureService.captureFullResolutionPng(); + // 定量モード撮影(DNG 経路) + // baseName を先に確定し,Kotlin 側で DNG を直接書き込む + final baseName = await _fileService.generateQuantitativeBaseName(); + final capture = await _rawCaptureService.captureQuantitativeDng( + baseName: baseName, + directoryPath: _fileService.directoryPath, + ); + debugPrint( + 'DNG saved: ${capture.dngFileSize} bytes at ${capture.dngPath}', + ); - // PNG バイト列をファイルに保存 - final savedPath = await _fileService.saveImage(pngBytes); + // メタデータ JSON は引き続き Dart 側で保存 + final metaPath = await _fileService.saveQuantitativeMetadata( + baseName, + capture.metadata, + lscMap: capture.lscMap, + lscMapRowCount: capture.lscMapRowCount, + lscMapColumnCount: capture.lscMapColumnCount, + ); // MediaStore に登録(PC から MTP で見えるようにする) - await _rawCaptureService.scanFile(savedPath); + await _rawCaptureService.scanFile(capture.dngPath); + await _rawCaptureService.scanFile(metaPath); - _lastSavedFileName = savedPath.split('/').last; + _lastSavedFileName = capture.dngPath.split('/').last; _errorMessage = null; // 保存完了音を再生(エラーで撮影フロー全体は止めない) diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 37b0d9f..6b72dcd 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -9,6 +9,16 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; + /// `YYYYMMDD_HHmmss` 形式のタイムスタンプ文字列を生成する. + static String _buildTimestamp(DateTime dt) => + '${dt.year}' + '${dt.month.toString().padLeft(2, '0')}' + '${dt.day.toString().padLeft(2, '0')}' + '_' + '${dt.hour.toString().padLeft(2, '0')}' + '${dt.minute.toString().padLeft(2, '0')}' + '${dt.second.toString().padLeft(2, '0')}'; + /// PNG バイト列をファイルに書き込んで保存する. /// /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, @@ -29,16 +39,7 @@ /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png /// 同秒の重複がある場合は _1, _2, ... と連番を付与する. Future generateFileName() async { - final now = DateTime.now(); - final timestamp = - '${now.year}' - '${now.month.toString().padLeft(2, '0')}' - '${now.day.toString().padLeft(2, '0')}' - '_' - '${now.hour.toString().padLeft(2, '0')}' - '${now.minute.toString().padLeft(2, '0')}' - '${now.second.toString().padLeft(2, '0')}'; - + final timestamp = _buildTimestamp(DateTime.now()); final baseName = 'MiniTIAS_$timestamp'; final candidate = '$baseName.png'; @@ -64,17 +65,8 @@ await directory.create(recursive: true); } - final now = DateTime.now(); - final timestamp = - '${now.year}' - '${now.month.toString().padLeft(2, '0')}' - '${now.day.toString().padLeft(2, '0')}' - '_' - '${now.hour.toString().padLeft(2, '0')}' - '${now.minute.toString().padLeft(2, '0')}' - '${now.second.toString().padLeft(2, '0')}'; - - final fileName = 'camera_diagnostics_$timestamp.json'; + final fileName = + 'camera_diagnostics_${_buildTimestamp(DateTime.now())}.json'; final filePath = '$_basePath/$fileName'; const encoder = JsonEncoder.withIndent(' '); @@ -101,4 +93,71 @@ } return '${baseName}_$suffix.png'; } + + /// 定量モード撮影用のファイル名 prefix を生成する. + /// + /// 形式: `MiniTIAS_QM_YYYYMMDD_HHmmss` + /// 同秒の重複がある場合は `_1`, `_2` ...と連番を付与する. + /// 重複判定は `.dng` の存在チェックで行う. + Future generateQuantitativeBaseName() async { + final candidate = 'MiniTIAS_QM_${_buildTimestamp(DateTime.now())}'; + + if (!await File('$_basePath/$candidate.dng').exists()) { + return candidate; + } + + var suffix = 1; + while (await File('$_basePath/${candidate}_$suffix.dng').exists()) { + suffix++; + } + return '${candidate}_$suffix'; + } + + /// テスト用同期版.既存ファイル名のリストとタイムスタンプを受け取って baseName を返す. + /// + /// 候補 `MiniTIAS_QM_{timestamp}` に対して `.dng` の存在チェックを行う. + static String generateQuantitativeBaseNameSync( + String timestamp, + List existingFiles, + ) { + final candidate = 'MiniTIAS_QM_$timestamp'; + + if (!existingFiles.contains('$candidate.dng')) { + return candidate; + } + + var suffix = 1; + while (existingFiles.contains('${candidate}_$suffix.dng')) { + suffix++; + } + return '${candidate}_$suffix'; + } + + /// メタデータと LSC マップを `.meta.json` として保存する. + /// + /// [lscMap] が null の場合,`lscMap`/`lscMapRowCount`/`lscMapColumnCount` キーは含めない. + Future saveQuantitativeMetadata( + String baseName, + Map metadata, { + List>? lscMap, + int? lscMapRowCount, + int? lscMapColumnCount, + }) async { + final directory = Directory(_basePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final Map jsonMap = Map.from(metadata); + if (lscMap != null) { + jsonMap['lscMap'] = lscMap; + jsonMap['lscMapRowCount'] = lscMapRowCount; + jsonMap['lscMapColumnCount'] = lscMapColumnCount; + } + + const encoder = JsonEncoder.withIndent(' '); + final filePath = '$_basePath/$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 e19e1cf..7a655fe 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -76,4 +76,101 @@ Future playSaveCompleteSound() async { await _channel.invokeMethod('playSaveCompleteSound'); } + + /// 定量モード DNG キャプチャを行う. + /// + /// [baseName] / [directoryPath] を Kotlin 側に渡し,Kotlin 側で + /// `${directoryPath}/${baseName}.dng` として直接保存する. + /// タイムアウトは 30 秒. + Future captureQuantitativeDng({ + required String baseName, + required String directoryPath, + }) async { + final result = await _channel + .invokeMethod>('captureQuantitativeDng', { + 'baseName': baseName, + 'directoryPath': directoryPath, + }) + .timeout( + const Duration(seconds: 30), + onTimeout: () => throw Exception('定量モード撮影がタイムアウトしました'), + ); + if (result == null) { + throw Exception('定量モード撮影に失敗しました'); + } + return QuantitativeCaptureResult.fromMap(result); + } +} + +/// `_deepConvert` の実装:ネストした Map / List を Dart 型に再帰変換する. +dynamic _deepConvert(dynamic value) { + if (value is Map) { + return value.map( + (key, v) => MapEntry(key.toString(), _deepConvert(v)), + ); + } + if (value is List) { + return value.map(_deepConvert).toList(); + } + return value; +} + +/// 定量モード撮影の結果を保持する値クラス. +class QuantitativeCaptureResult { + /// Kotlin 側が書き込んだ DNG ファイルの絶対パス. + final String dngPath; + + /// DNG ファイルのバイト数(書き込み確認用). + final int dngFileSize; + + /// 撮影メタデータ(設定値・実測値・センサ特性など). + final Map metadata; + + /// LSC ゲインマップ(行ごとに 4ch 分のゲイン値を格納).null の場合は取得不可. + final List>? lscMap; + + /// LSC マップの行数. + final int? lscMapRowCount; + + /// LSC マップの列数. + final int? lscMapColumnCount; + + const QuantitativeCaptureResult({ + required this.dngPath, + required this.dngFileSize, + required this.metadata, + this.lscMap, + this.lscMapRowCount, + this.lscMapColumnCount, + }); + + /// Kotlin から渡された Map を Dart 型に変換する. + factory QuantitativeCaptureResult.fromMap(Map map) { + final dngPath = map['dngPath'] as String; + final dngFileSize = (map['dngFileSize'] as num).toInt(); + + final rawMetadata = map['metadata']; + final metadata = (_deepConvert(rawMetadata) as Map?) ?? {}; + + final rawLscMap = map['lscMap']; + final lscMap = rawLscMap == null + ? null + : (rawLscMap as List).map>((row) { + return (row as List) + .map((e) => (e as num).toDouble()) + .toList(); + }).toList(); + + final lscMapRowCount = map['lscMapRowCount'] as int?; + final lscMapColumnCount = map['lscMapColumnCount'] as int?; + + return QuantitativeCaptureResult( + dngPath: dngPath, + dngFileSize: dngFileSize, + metadata: metadata, + lscMap: lscMap, + lscMapRowCount: lscMapRowCount, + lscMapColumnCount: lscMapColumnCount, + ); + } } diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 7bd9b54..285a609 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -78,7 +79,7 @@ group('FileService.saveImage', () { late Directory tempDir; - late FileService service; + late _FileServiceTestable service; setUp(() async { tempDir = await Directory.systemTemp.createTemp('mini_tias_test_'); @@ -165,6 +166,209 @@ expect(service.directoryPath, '/storage/emulated/0/Pictures/MiniTIAS'); }); }); + + group('FileService.generateQuantitativeBaseNameSync', () { + test('同秒のファイルが存在しない場合,サフィックスなしの baseName を返す', () { + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + [], + ); + expect(result, 'MiniTIAS_QM_20260404_120000'); + }); + + test('同秒の .dng が存在する場合,_1 サフィックスを付与する', () { + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + ['MiniTIAS_QM_20260404_120000.dng'], + ); + expect(result, 'MiniTIAS_QM_20260404_120000_1'); + }); + + test('_1.dng も存在する場合,_2 サフィックスを付与する', () { + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + [ + 'MiniTIAS_QM_20260404_120000.dng', + 'MiniTIAS_QM_20260404_120000_1.dng', + ], + ); + expect(result, 'MiniTIAS_QM_20260404_120000_2'); + }); + + test('異なるタイムスタンプの .dng が存在しても影響しない', () { + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + ['MiniTIAS_QM_20260404_120001.dng'], + ); + expect(result, 'MiniTIAS_QM_20260404_120000'); + }); + + test('返り値が MiniTIAS_QM_ プレフィックスで始まる', () { + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + [], + ); + expect(result, startsWith('MiniTIAS_QM_')); + }); + + test('連番に欠番がある場合(_1.dng が欠落),_2 ではなく _1 を返す', () { + // 仕様: suffix=1 から順に探すため _1 が存在しなければ _1 を返す + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + [ + 'MiniTIAS_QM_20260404_120000.dng', + 'MiniTIAS_QM_20260404_120000_2.dng', // _1 が存在しない状態 + ], + ); + expect(result, 'MiniTIAS_QM_20260404_120000_1'); + }); + + test('連番が多数存在する場合(_1〜_5.dng),_6 を付与する', () { + final existing = [ + 'MiniTIAS_QM_20260404_120000.dng', + 'MiniTIAS_QM_20260404_120000_1.dng', + 'MiniTIAS_QM_20260404_120000_2.dng', + 'MiniTIAS_QM_20260404_120000_3.dng', + 'MiniTIAS_QM_20260404_120000_4.dng', + 'MiniTIAS_QM_20260404_120000_5.dng', + ]; + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + existing, + ); + expect(result, 'MiniTIAS_QM_20260404_120000_6'); + }); + + test('候補 .meta.json のみが存在し .dng がない場合,サフィックスなしを返す', () { + // 重複判定は .dng の存在チェックのみであるため, + // .meta.json のみ存在しても重複とみなさない + final result = FileService.generateQuantitativeBaseNameSync( + '20260404_120000', + ['MiniTIAS_QM_20260404_120000.meta.json'], + ); + expect(result, 'MiniTIAS_QM_20260404_120000'); + }); + }); + + group('FileService.saveQuantitativeMetadata', () { + late Directory tempDir; + late _FileServiceTestableQuantitative service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('mini_tias_qm_test_'); + service = _FileServiceTestableQuantitative(tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('LSC マップなしで呼び出すと .meta.json 拡張子のファイルが生成される', () async { + final metadata = {'settings': {}}; + final path = await service.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + ); + expect(path, endsWith('.meta.json')); + expect(await File(path).exists(), isTrue); + }); + + test( + 'LSC マップなしの JSON には lscMap / lscMapRowCount / lscMapColumnCount キーが含まれない', + () async { + final metadata = {'settings': {}}; + final path = await service.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + ); + final jsonStr = await File(path).readAsString(); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded.containsKey('lscMap'), isFalse); + expect(decoded.containsKey('lscMapRowCount'), isFalse); + expect(decoded.containsKey('lscMapColumnCount'), isFalse); + }, + ); + + test('LSC マップありで呼び出すと JSON 内に lscMap の二重配列が含まれる', () async { + final metadata = {'settings': {}}; + final lscMap = [ + [1.0, 2.0], + [3.0, 4.0], + ]; + final path = await service.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + lscMap: lscMap, + lscMapRowCount: 2, + lscMapColumnCount: 2, + ); + final jsonStr = await File(path).readAsString(); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded.containsKey('lscMap'), isTrue); + final storedLscMap = decoded['lscMap'] as List; + expect(storedLscMap, hasLength(2)); + expect(storedLscMap[0], isA>()); + }); + + test( + 'LSC マップありの JSON で lscMapRowCount と lscMapColumnCount が指定値で保存される', + () async { + final metadata = {'settings': {}}; + final lscMap = [ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ]; + final path = await service.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + lscMap: lscMap, + lscMapRowCount: 3, + lscMapColumnCount: 3, + ); + final jsonStr = await File(path).readAsString(); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded['lscMapRowCount'], 3); + expect(decoded['lscMapColumnCount'], 3); + }, + ); + + test('メタデータの中身が JSON 内に保持される', () async { + final metadata = { + 'settings': {'exposure_time_ns': 10000000, 'iso': 400}, + 'actual': {'frame_duration_ns': 33333333}, + 'sensorCharacteristics': {'make': 'TestMake'}, + }; + final path = await service.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + ); + final jsonStr = await File(path).readAsString(); + final decoded = jsonDecode(jsonStr) as Map; + expect(decoded.containsKey('settings'), isTrue); + expect(decoded.containsKey('actual'), isTrue); + expect(decoded.containsKey('sensorCharacteristics'), isTrue); + final settings = decoded['settings'] as Map; + expect(settings['iso'], 400); + }); + + test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { + final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); + final serviceWithSubDir = _FileServiceTestableQuantitative( + nonExistentSubDir.path, + ); + expect(await nonExistentSubDir.exists(), isFalse); + + final metadata = {'settings': {}}; + final path = await serviceWithSubDir.saveQuantitativeMetadata( + 'MiniTIAS_QM_20260404_120000', + metadata, + ); + expect(await File(path).exists(), isTrue); + }); + }); } /// テスト用: 保存先ディレクトリをオーバーライドできる FileService サブクラス. @@ -213,3 +417,39 @@ return '${baseName}_$suffix.png'; } } + +/// テスト用: saveQuantitativeMetadata の保存先をオーバーライドする FileService サブクラス. +class _FileServiceTestableQuantitative extends FileService { + _FileServiceTestableQuantitative(this._testBasePath); + + final String _testBasePath; + + @override + String get directoryPath => _testBasePath; + + @override + Future saveQuantitativeMetadata( + String baseName, + Map metadata, { + List>? lscMap, + int? lscMapRowCount, + int? lscMapColumnCount, + }) async { + final directory = Directory(_testBasePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final Map jsonMap = Map.from(metadata); + if (lscMap != null) { + jsonMap['lscMap'] = lscMap; + jsonMap['lscMapRowCount'] = lscMapRowCount; + jsonMap['lscMapColumnCount'] = lscMapColumnCount; + } + + const encoder = JsonEncoder.withIndent(' '); + final filePath = '$_testBasePath/$baseName.meta.json'; + await File(filePath).writeAsString(encoder.convert(jsonMap)); + return filePath; + } +} diff --git a/test/services/raw_capture_service_test.dart b/test/services/raw_capture_service_test.dart new file mode 100644 index 0000000..a7ca24e --- /dev/null +++ b/test/services/raw_capture_service_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mini_tias/services/raw_capture_service.dart'; + +void main() { + group('QuantitativeCaptureResult.fromMap', () { + test('最小限のマップから正しくインスタンス化できる', () { + final map = { + 'dngPath': '/storage/emulated/0/Pictures/MiniTIAS/test.dng', + 'dngFileSize': 16000000, + 'metadata': {}, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.dngPath, '/storage/emulated/0/Pictures/MiniTIAS/test.dng'); + expect(result.dngFileSize, 16000000); + expect(result.metadata, isEmpty); + expect(result.lscMap, isNull); + expect(result.lscMapRowCount, isNull); + expect(result.lscMapColumnCount, isNull); + }); + + test('dngFileSize が int として渡された場合でも正しく変換される', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 12345678, // Dart の int + 'metadata': {}, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.dngFileSize, 12345678); + expect(result.dngFileSize, isA()); + }); + + test('dngFileSize が long 経由の num として渡された場合でも正しく int 変換される', () { + // Kotlin の Long は Dart で num として渡される + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 16777216.0, // num (double として渡される場合) + 'metadata': {}, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.dngFileSize, 16777216); + expect(result.dngFileSize, isA()); + }); + + test('metadata がネスト構造を含む場合,全階層が Map 型になる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': { + 'settings': { + 'exposure_time_ns': 10000000, + 'iso': 400, + }, + 'actual': {'frame_duration_ns': 33333333}, + }, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.metadata, isA>()); + expect(result.metadata['settings'], isA>()); + expect(result.metadata['actual'], isA>()); + final settings = result.metadata['settings'] as Map; + expect(settings['exposure_time_ns'], 10000000); + expect(settings['iso'], 400); + }); + + test('metadata 内のリスト要素も再帰変換される', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': { + 'colorMatrix': [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], + 'nested': { + 'tags': ['tag1', 'tag2'], + }, + }, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.metadata['colorMatrix'], isA>()); + final nested = result.metadata['nested'] as Map; + expect(nested['tags'], isA>()); + }); + + test('lscMap が null の場合,フィールド lscMap も null になる', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'lscMap': null, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.lscMap, isNull); + }); + + test('lscMap が List> (List 経由) の場合,正しく変換される', () { + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'lscMap': [ + [1.1, 1.2, 1.3], + [1.4, 1.5, 1.6], + ], + 'lscMapRowCount': 2, + 'lscMapColumnCount': 3, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.lscMap, isNotNull); + expect(result.lscMap, hasLength(2)); + expect(result.lscMap![0], hasLength(3)); + expect(result.lscMap![0][0], closeTo(1.1, 0.0001)); + expect(result.lscMap![1][2], closeTo(1.6, 0.0001)); + expect(result.lscMapRowCount, 2); + expect(result.lscMapColumnCount, 3); + }); + + test('lscMap に Float (num) の値が含まれる場合でも double に正しくキャストされる', () { + // Kotlin の Float は Dart で num 経由で渡される + final map = { + 'dngPath': '/test.dng', + 'dngFileSize': 100, + 'metadata': {}, + 'lscMap': [ + [ + 1.0, // double + 2, // int も num として変換される + 3.14, // 精度のある double + ], + ], + 'lscMapRowCount': 1, + 'lscMapColumnCount': 3, + }; + final result = QuantitativeCaptureResult.fromMap(map); + expect(result.lscMap, isNotNull); + final row = result.lscMap![0]; + expect(row[0], isA()); + expect(row[1], isA()); + expect(row[2], isA()); + expect(row[1], closeTo(2.0, 0.0001)); + expect(row[2], closeTo(3.14, 0.0001)); + }); + }); +}