diff --git a/CLAUDE.md b/CLAUDE.md index 6df8bff..7e975d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +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 経路)完成**.シャッターボタンタップで `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` として温存. ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) @@ -75,3 +77,12 @@ ### 04_SPEC(仕様・設計) - 画面機能仕様書: docs/04_SPEC/SPEC_01_画面機能仕様書.md + +### 05_TECH(技術設計・外部要求仕様) + +- 定量撮影モード要求仕様(SmTIAS-LightSim 側からの外部要求): docs/05_TECH/TECH_01_定量撮影モード要求仕様.md +- MiniTIAS 引き継ぎ手順(SmTIAS-LightSim 側からのハンドオフ手順): docs/05_TECH/TECH_02_MiniTIAS引き継ぎ手順.md + +### 06_TEST(テスト・実機検証) + +- AQUOS sense3 カメラ能力診断結果(2026-05-31): docs/06_TEST/camera_diagnostics_20260531_161734.json 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 9324ef1..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") @@ -77,6 +102,7 @@ result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null) } } + "getCameraDiagnostics" -> getCameraDiagnostics(result) else -> result.notImplemented() } } @@ -186,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 秒)・本番キャプチャの @@ -200,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", "フロントカメラが見つかりません") @@ -443,6 +924,89 @@ out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array()) } + /** + * フロントカメラの Camera2 API 能力を読み出して Map で返す. + * + * CameraCharacteristics から各種パラメータを取得し,null セーフに処理する. + * フロントカメラが見つからない場合は error を返す. + */ + private fun getCameraDiagnostics(result: MethodChannel.Result) { + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + val cameraId = findFrontCameraId(cameraManager) + + if (cameraId == null) { + result.error("NO_CAMERA", "フロントカメラが見つかりません", null) + return + } + + val c = cameraManager.getCameraCharacteristics(cameraId) + val streamConfigMap = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + + // rawOutputSizes + val rawOutputSizes = streamConfigMap + ?.getOutputSizes(ImageFormat.RAW_SENSOR) + ?.map { "${it.width}x${it.height}" } + ?: emptyList() + + // yuvOutputSizes + val yuvOutputSizes = streamConfigMap + ?.getOutputSizes(ImageFormat.YUV_420_888) + ?.map { "${it.width}x${it.height}" } + ?: emptyList() + + // jpegOutputSizes + val jpegOutputSizes = streamConfigMap + ?.getOutputSizes(ImageFormat.JPEG) + ?.map { "${it.width}x${it.height}" } + ?: emptyList() + + val diagnostics = mapOf( + "cameraId" to cameraId, + "hardwareLevel" to c.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL), + "capabilities" to c.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)?.toList(), + "exposureTimeRangeNs" to c.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE) + ?.let { listOf(it.lower, it.upper) }, + "sensitivityRange" to c.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) + ?.let { listOf(it.lower, it.upper) }, + "lscMapSize" to null, // 実キャプチャ後に CaptureResult から取得する必要があるため診断段階では null + "availableLensShadingMapModes" to c.get( + CameraCharacteristics.STATISTICS_INFO_AVAILABLE_LENS_SHADING_MAP_MODES + )?.map { it }, + "availableNoiseReductionModes" to c.get( + CameraCharacteristics.NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES + )?.toList(), + "availableEdgeModes" to c.get(CameraCharacteristics.EDGE_AVAILABLE_EDGE_MODES)?.toList(), + "availableTonemapModes" to c.get( + CameraCharacteristics.TONEMAP_AVAILABLE_TONE_MAP_MODES + )?.toList(), + "availableShadingModes" to c.get(CameraCharacteristics.SHADING_AVAILABLE_MODES)?.toList(), + "availableAwbModes" to c.get( + CameraCharacteristics.CONTROL_AWB_AVAILABLE_MODES + )?.toList(), + "availableAfModes" to c.get( + CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES + )?.toList(), + "minFocusDistance" to c.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE), + "hyperfocalDistance" to c.get(CameraCharacteristics.LENS_INFO_HYPERFOCAL_DISTANCE), + "rawOutputSizes" to rawOutputSizes, + "yuvOutputSizes" to yuvOutputSizes, + "jpegOutputSizes" to jpegOutputSizes, + "colorFilterArrangement" to c.get( + CameraCharacteristics.SENSOR_INFO_COLOR_FILTER_ARRANGEMENT + ), + "blackLevelPattern" to c.get(CameraCharacteristics.SENSOR_BLACK_LEVEL_PATTERN)?.toString(), + "whiteLevel" to c.get(CameraCharacteristics.SENSOR_INFO_WHITE_LEVEL), + "physicalSize" to c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) + ?.let { listOf(it.width, it.height) }, + "pixelArraySize" to c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE) + ?.let { listOf(it.width, it.height) }, + "activeArraySize" to c.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)?.toString(), + ) + + result.success(diagnostics) + } + private fun cleanup() { cameraDevice?.close() cameraDevice = 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/docs/05_TECH/TECH_01_\345\256\232\351\207\217\346\222\256\345\275\261\343\203\242\343\203\274\343\203\211\350\246\201\346\261\202\344\273\225\346\247\230.md" "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\346\222\256\345\275\261\343\203\242\343\203\274\343\203\211\350\246\201\346\261\202\344\273\225\346\247\230.md" new file mode 100644 index 0000000..c4c6e8d --- /dev/null +++ "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\346\222\256\345\275\261\343\203\242\343\203\274\343\203\211\350\246\201\346\261\202\344\273\225\346\247\230.md" @@ -0,0 +1,267 @@ +# 定量撮影モード要求仕様 (Quantitative Capture Mode Specification) + +本ドキュメントは SmTIAS-LightSim プロジェクトから MiniTIAS プロジェクトに対する **撮影モード拡張の要求仕様** である.SmTIAS-LightSim のキャリブレーション作業 (Step 8 系) で判明した「実機 PNG が定量データとして揺らぐ」問題を解消するための撮影アプリ側改修を依頼する. + +## 概要 (Overview) + +MiniTIAS([MiniTias](https://github.com/rintoHasegawa/MiniTias) リポジトリ)の撮影アプリに,現状の通常撮影モードに加えて **「定量撮影モード (Quantitative Capture Mode)」** を追加することを要求する. + +定量撮影モードでは: + +- Camera2 API のオート制御 (AE/AWB/NR/EDGE/TONEMAP) を **すべて固定または OFF** にする. +- ISP が適用した **LSC ゲインマップ** を撮影ごとに同時保存する. +- 可能なら出力フォーマットを **RAW_SENSOR (DNG)** に切り替えられるようにする. + +これにより同じシーンを何度撮影しても再現性のある定量データが得られ,SmTIAS-LightSim 側のシミュレーション結果との突合が安定する. + +## 背景 (Background) + +### SmTIAS-LightSim と MiniTIAS の関係 + +- **MiniTIAS**: スマートフォンアプリ.AQUOS sense3 で白板・舌模型を撮影し PNG として保存する. +- **MiniTias-Evaluation**: PC 側.MiniTIAS が保存した PNG を読み込み照明均一性 (CoV/勾配/C/P 等) を算出する. +- **SmTIAS-LightSim**: PC 側.Mitsuba 3 で同じ筐体・LED 配置を仮想撮影し,MiniTias-Evaluation と同じ指標を計算する. + +→ 3 つのプロジェクトで **同じ評価指標** を出すことで「シミュ vs 実機」を比較する. + +### 現状の問題(Step 8 キャリブで判明) + +Step 8 キャリブレーション ([TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) 参照) で,シミュと実機の比較を試みた結果,以下の現象が観測された: + +- **形状指標残差 1.6〜1.9 倍** が解消されない(OETF 順方向適用後でも) +- 輝度マップの「形」が実機(中央ピーク楕円)とシミュ(Y 軸単調勾配)で **構造的に違う** ([TEST_03](../06_TEST/TEST_03_輝度マップ形状追跡.md) 参照) +- LED 位置・反射率・配光・センサ方向 ([TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) Step 8-9/8-10/8-11/8-12 系) すべてを試したが「形」は変わらず + +「形を変える唯一の道」として **LSC (Lens Shading Correction) モデル化** が候補に挙がったが,さらに調査した結果,**より根本的な問題** が判明した: + +- 既存 baseline 画像 (`MiniTIAS_20260408_144434`) は **MiniTIAS の通常撮影モード**で取得されており,**ISP がすべて自動制御**で動作している. +- 具体的には [MiniTIAS/docs/04_SPEC/SPEC_01](../../MiniTIAS/docs/04_SPEC/SPEC_01_画面機能仕様書.md) のとおり: + - AE/AF 安定 1 秒待ち → STILL_CAPTURE → YUV_420_888 取得 → BT.601 で RGB → PNG 保存 + - AE / AWB / NR / EDGE / TONEMAP / SHADING はすべて Camera2 API のデフォルト (オート/ON) のまま +- これは「人間が見て自然な画像」用設定であり,**定量解析向けではない**. +- 撮影ごとに露光・WB・補正量が変動する可能性があり,**同じシーンを 2 回撮っても同じ値にならない可能性** がある. + +つまり「定量比較の対象である実機 PNG 自体が揺らいでいる」状態.まずここを固定化しない限り,シミュ側でいくらモデルを精緻化しても比較できない. + +## 目的 (Objective) + +定量撮影モードの導入により以下を達成する: + +1. **撮影再現性の確保**: 同条件で繰り返し撮影しても mean / std / 形状指標が同じ値になる. +2. **ISP 介入の最小化**: AE/AWB/NR/EDGE/TONEMAP/SHADING を固定/OFF し,センサ出力に近い値を取得する. +3. **メタデータの可視化**: ISP が適用した LSC ゲインマップを取得して別ファイル保存する. +4. **シミュ比較の土台**: SmTIAS-LightSim 側のキャリブレーション・LSC 検証が安定したデータで進められる. + +## 要求仕様 (Requirements) + +### 1. 撮影モード切替 UI + +撮影画面 (S-01) に「定量撮影モード」のトグルスイッチを追加する. + +- デフォルト: OFF(既存通常モード) +- ON 時: 後述の Camera2 API 設定をすべて適用 +- 後方互換: 既存ユーザー・既存撮影ワークフローには影響しない + +### 2. Camera2 API 設定(定量モード ON 時) + +| キー | 値 | 目的 | +| --- | --- | --- | +| `CONTROL_MODE` | `OFF`(マニュアル全制御) | ISP オート制御の完全停止 | +| `CONTROL_AE_MODE` | `OFF` | 自動露光停止 | +| `SENSOR_EXPOSURE_TIME` | 固定値(要キャリブ,例 1/60 s) | 露光時間固定 | +| `SENSOR_SENSITIVITY` | 最低 ISO(端末依存,例 50) | ノイズ最小化 + ゲイン固定 | +| `CONTROL_AWB_MODE` | `OFF` | 自動 WB 停止 | +| `COLOR_CORRECTION_GAINS` | 固定値 (R=G=B=1.0 推奨) | チャネルゲイン固定 | +| `COLOR_CORRECTION_TRANSFORM` | 単位行列 | カラーマトリクス無効化 | +| `NOISE_REDUCTION_MODE` | `OFF` | NR 無効化(アーティファクト排除) | +| `EDGE_MODE` | `OFF` | エッジ強調無効化 | +| `TONEMAP_MODE` | `CONTRAST_CURVE` (線形カーブ) または `PRESET_CURVE` (SRGB) | トーンマッピング固定 | +| `SHADING_MODE` | **`HIGH_QUALITY`** (LSC 補正適用) または **`OFF`** (LSC 無効化) のいずれか選択可 | LSC の挙動制御(次節参照) | +| `STATISTICS_LENS_SHADING_MAP_MODE` | `ON` | LSC ゲインマップ取得有効化 | + +> ※ `SHADING_MODE = OFF` を選んだ場合は cos⁴ falloff が画像に残る.後段のシミュ側で物理 cos⁴ を加える前提なら有用.通常運用では `HIGH_QUALITY` を推奨. + +### 3. LSC ゲインマップの保存 + +撮影成功時に `CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP` から `LensShadingMap` を取得して保存する. + +| 項目 | 仕様 | +| --- | --- | +| 取得 API | `result.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP)` | +| データ形状 | 4 × N × M float 配列(4 = R/Geven/Godd/B チャネル,N = 列,M = 行) | +| 値の意味 | ≥ 1.0 のゲイン係数.`corrected = raw × gain` | +| 保存形式 | NPZ(推奨)または JSON.画像ファイル名と同 prefix で `.lsc.npz` 等 | +| 保存先 | 画像と同ディレクトリ | +| マップサイズ | `CameraCharacteristics.LENS_INFO_SHADING_MAP_SIZE` から取得 | + +### 4. 撮影メタデータの保存(撮影パラメータの可視化) + +LSC マップに加えて,撮影時の Camera2 API パラメータを JSON で保存する.後段でシミュレーションの境界条件を再現するために必要. + +```json +{ + "capture_timestamp_utc": "2026-05-28T10:00:00Z", + "device_model": "SH-02M", + "android_version": "9", + "camera_id": "1", + "hardware_level": "LIMITED", + "capture_mode": "quantitative", + "settings": { + "control_mode": "OFF", + "ae_mode": "OFF", + "exposure_time_ns": 16666666, + "sensor_sensitivity_iso": 50, + "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", + "shading_mode": "HIGH_QUALITY", + "statistics_lens_shading_map_mode": "ON" + }, + "image_format": "YUV_420_888", + "image_size": [4000, 3000], + "yuv_to_rgb_matrix": "BT.601" +} +``` + +ファイル名規則: `.meta.json` + +### 5. 推奨出力フォーマット + +優先順位は以下: + +| 優先度 | フォーマット | 説明 | +| --- | --- | --- | +| 第 1 | **`RAW_SENSOR` (DNG)** | ISP 介入ゼロ.センサ出力そのまま.[DNG GainMap 仕様](https://www.adobe.com/content/dam/acom/en/products/photoshop/pdfs/dng_spec_1.4.0.0.pdf) と整合 | +| 第 2 | **`YUV_420_888` + 上記メタデータ + LSC マップ** | 現状フォーマットに情報追加.後段で逆処理可能 | +| 第 3 | **PNG(現状) + メタデータ + LSC マップ** | 最小実装.BT.601 YUV→RGB 経由のため情報量は最小 | + +第 1 は実機が DNG 出力をサポートしている必要がある(AQUOS sense3 で要確認).サポートしていない場合は第 2 を採用. + +### 6. ハードウェアサポート判定 + +定量モード起動時に `CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL` を取得して以下を判定: + +- `LEGACY`: 定量モード **不可**(マニュアル制御サポートなし)→ ユーザーに警告表示 +- `LIMITED`: 部分サポート.SHADING_MODE と LSC マップ取得が可能か個別確認 +- `FULL` / `LEVEL_3`: **完全サポート** + +必要な能力フラグ: + +- `REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR` (露光・ISO 固定) +- `REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING` (NR/EDGE/TONEMAP/SHADING 固定) +- `REQUEST_AVAILABLE_CAPABILITIES_RAW` (DNG 出力,第 1 優先時のみ必須) + +判定結果は前述メタデータ JSON の `hardware_level` フィールドに記録する. + +## 推奨実装方針 (Recommended Implementation) + +### Flutter / camera パッケージの限界 + +[camera パッケージ](https://pub.dev/packages/camera) は AE/AWB/SHADING_MODE 等の詳細制御を **直接公開していない** ことが多い.定量モードを実現するには以下のいずれかが必要: + +- **Platform Channel で Camera2 API を直接制御**(推奨) + - Android Kotlin/Java 側で Camera2 API を直接叩く + - Flutter 側は MethodChannel で「定量モード撮影」要求を送る +- **camerax_android** など低レベル制御を公開した代替パッケージへの移行 +- **Native Plugin の自作** + +実装規模が大きい場合は段階的に: + +1. **Phase 1**: Camera2 詳細制御の MethodChannel 実装(露光・WB 固定,LSC マップ取得) +2. **Phase 2**: LSC マップ・メタデータ保存ロジック +3. **Phase 3**: 撮影画面 UI に定量モードトグル追加 +4. **Phase 4**: ハードウェアサポート判定とユーザー案内 +5. **Phase 5**: RAW_SENSOR (DNG) 対応(任意) + +### ディレクトリ構成提案 + +MiniTIAS 既存構造に従い: + +```text +android/app/src/main/kotlin// +└── camera/ + └── QuantitativeCameraController.kt # Camera2 直接制御 + +lib/services/ +├── quantitative_camera_service.dart # Platform Channel ラッパ +├── lsc_map_service.dart # LSC マップ NPZ 保存 +└── capture_metadata_service.dart # メタデータ JSON 保存 + +lib/providers/ +└── camera_provider.dart # 定量モード状態追加(既存 Provider 拡張) +``` + +## 期待される効果 (Expected Effects) + +| 項目 | 改善内容 | +| --- | --- | +| 撮影再現性 | 同条件 10 連射で mean / std が ±1% 以内に揃う | +| 色温度ドリフト | AWB 固定により無彩色撮影で色シフト排除 | +| LSC 補正可視化 | ISP が適用したゲインマップが取得でき,シミュ側で逆適用可能 | +| 物理 cos⁴ 検証 | SHADING_MODE = OFF モードで実機 raw 相当を取得し,シミュ + cos⁴ と比較可能 | +| RAW 比較 (任意) | DNG 経路が確立すれば,ISP 完全排除でセンサ生値 vs Mitsuba 直接比較 | +| キャリブ作業の安定化 | SmTIAS-LightSim 側で同じ baseline を繰り返し再撮影しても結果が動かない | + +## 後方互換 (Backward Compatibility) + +- 通常撮影モードは現状仕様のまま維持(既存撮影ワークフローを壊さない) +- 既存 baseline 画像 (`MiniTIAS_20260408_144434`) は **「通常モードで撮影された」** ことを SmTIAS-LightSim 側で明示記録([TEST_03](../06_TEST/TEST_03_輝度マップ形状追跡.md) に追記予定) +- 新規定量モード撮影は別命名規則を推奨: + - 例: `MiniTIAS_QM_YYYYMMDD_HHmmss.png`(QM = Quantitative Mode) + - メタデータ: `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` + - LSC マップ: `MiniTIAS_QM_YYYYMMDD_HHmmss.lsc.npz` + +## 検証方法 (Verification) + +定量モード実装完了後,以下を実施して受け入れ判定とする: + +### V1. 再現性検証 + +同条件(光源 ON・同じ白板・三脚固定)で **10 回連続撮影** し,全画像で: + +- mean / std の変動係数 (CoV of means) < 1% +- 形状指標 (CoV/勾配/C/P) の標準偏差 < 0.005 + +### V2. LSC マップ取得検証 + +- 全撮影で `.lsc.npz` が保存されている +- マップサイズが `CameraCharacteristics.LENS_INFO_SHADING_MAP_SIZE` と一致 +- ゲイン値がすべて ≥ 1.0 +- 中央 (col=N/2, row=M/2) のゲインが ≈ 1.0 + +### V3. SHADING_MODE 切替検証 + +- `SHADING_MODE = OFF` で撮影した画像が `HIGH_QUALITY` より周辺で明らかに暗い(cos⁴ falloff 可視化) +- `HIGH_QUALITY × (1 / LSC マップ)` ≈ `OFF` の関係性が成立する(数値差 < 5%) + +### V4. SmTIAS-LightSim 側での突合 + +- 定量モード白板撮影 (SHADING_MODE = HIGH_QUALITY) を baseline として再キャリブ +- シミュ vs 実機の残差が現状 2.1× → 1.5× 以下に縮むこと(仮目標) + +## 開放質問 (Open Questions) + +実装着手時に確認が必要な事項: + +| ID | 質問 | 確認方法 | +| --- | --- | --- | +| Q1 | AQUOS sense3 のハードウェアレベルは何か | デバッグビルドで `CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL` を読みログ出力 | +| Q2 | SH-02M のフロントカメラで `REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING` がサポートされているか | 同上.未サポートなら定量モードは部分実装 | +| Q3 | DNG (RAW_SENSOR) 出力は可能か | `REQUEST_AVAILABLE_CAPABILITIES_RAW` を確認 | +| Q4 | LSC マップサイズはどの程度か(例 13×17 等) | 実機でログ出力.データ量設計の参考 | +| Q5 | 露光時間・ISO の最適値(固定値の決定) | 白板撮影で被写体の明るさが適切かを実機で試行錯誤 | +| Q6 | 既存 `MiniTIAS_20260408_144434` を定量モードで再撮影するか | 研究方針との整合(再撮影なら現 baseline は参考扱い) | +| Q7 | 既存 PNG 保存形式 (BT.601 経由) を維持するか YUV/RAW 直接保存に変えるか | SmTIAS-LightSim 側読み込みコストとのトレードオフ | + +## 関連ドキュメント (Related Documents) + +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8-1〜8-12 で「実機 PNG が定量データとして揺らぐ」を発見した経緯 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 形のずれの定性的記録.LSC モデル化議論の出発点 +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate の OETF 適用・mean アンカー処理 +- [SPEC_03 基準設計の物理構成](../04_SPEC/SPEC_03_基準設計の物理構成.md) — シミュ側物理モデル +- [MiniTIAS/docs/04_SPEC/SPEC_01 画面機能仕様書](../../MiniTIAS/docs/04_SPEC/SPEC_01_画面機能仕様書.md) — 現状のカメラ制御仕様(変更前ベースライン) +- [Android Camera2 API LensShadingMap](https://developer.android.com/reference/android/hardware/camera2/params/LensShadingMap) +- [Android Camera2 API CaptureRequest](https://developer.android.com/reference/android/hardware/camera2/CaptureRequest) diff --git "a/docs/05_TECH/TECH_02_MiniTIAS\345\274\225\343\201\215\347\266\231\343\201\216\346\211\213\351\240\206.md" "b/docs/05_TECH/TECH_02_MiniTIAS\345\274\225\343\201\215\347\266\231\343\201\216\346\211\213\351\240\206.md" new file mode 100644 index 0000000..1770af8 --- /dev/null +++ "b/docs/05_TECH/TECH_02_MiniTIAS\345\274\225\343\201\215\347\266\231\343\201\216\346\211\213\351\240\206.md" @@ -0,0 +1,254 @@ +# MiniTIAS 引き継ぎ手順 (Hand-off Procedure to MiniTIAS) + +本ドキュメントは [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) を MiniTIAS リポジトリで実装する際の **作業着手ガイド** および **データ受け渡し仕様** である.SmTIAS-LightSim と MiniTias の両プロジェクトを Claude Code セッションで作業している前提に最適化している. + +## 想定読者 (Intended Reader) + +- **MiniTias 側 Claude セッション**: 本ドキュメントを最初に開き,次に TECH_01 を読んで実装着手する. +- **人間レビュー (rinto)**: 両セッションを行き来し,質問の橋渡しと完了確認を行う. + +## プロジェクト関係図 (Project Relationship) + +```text +┌─────────────────────────┐ ┌──────────────────────────┐ +│ MiniTias │ │ SmTIAS-LightSim │ +│ (Flutter / Android) │ │ (Python / Mitsuba 3) │ +│ │ │ │ +│ 撮影アプリ │ │ 光散乱シミュレータ │ +│ - インカメラ制御 │ │ - 仮想シーン構築 │ +│ - 撮影モード │ │ - 物理光線追跡 │ +│ - PNG / メタデータ保存 │ ──→ │ - sm-evaluate │ +└─────────────────────────┘ └──────────────────────────┘ + │ + │ 同じ評価指標 + ▼ + ┌──────────────────────────┐ + │ MiniTias-Evaluation │ + │ (Python) │ + │ │ + │ 実機 PNG → 均一性指標 │ + └──────────────────────────┘ +``` + +→ 3 プロジェクトで **同じ評価指標** を出し,シミュ vs 実機を比較する.本作業は MiniTias 側に **「定量撮影モード」** を追加し,定量比較可能な実機データを取得できるようにする. + +## 背景の要約 (Background Digest) + +詳細は SmTIAS-LightSim 側の [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) と [TEST_03](../06_TEST/TEST_03_輝度マップ形状追跡.md) を参照.要点のみ抜粋: + +- SmTIAS-LightSim 側で **Step 8-1〜8-12 のキャリブ作業** を実施し,残差 2.1× で頭打ち +- 「形のずれ」を解消するため LSC モデル化を検討していたが,さらに調査した結果,**実機 PNG 自体が定量データとして揺らいでいる**ことが判明 +- 既存 baseline `MiniTIAS_20260408_144434` は MiniTias の通常モード (AE/AWB/NR/EDGE/TONEMAP 全自動) で撮影されており,撮影ごとに値が変動する可能性 +- → **「定量撮影モード」** の追加が必要.詳細は [TECH_01](TECH_01_定量撮影モード要求仕様.md) + +## MiniTias 側 Claude セッションの最初のアクション (First Actions) + +セッション開始時に以下 3 ステップを順に実施することを推奨する. + +### Step A: 実機サポート状況の確認(最優先) + +[TECH_01 開放質問 Q1〜Q4](TECH_01_定量撮影モード要求仕様.md#開放質問-open-questions) は **実装着手前に実機で確認すべき** 項目.まず簡易デバッグビルドで以下を取得: + +```kotlin +// android/app/src/main/kotlin/.../CameraDiagnostics.kt として実装提案 +val characteristics = cameraManager.getCameraCharacteristics(frontCameraId) + +// Q1: ハードウェアレベル +val hwLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) + +// Q2: 能力フラグ +val caps = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) +val hasManualSensor = caps?.contains(REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR) +val hasManualPP = caps?.contains(REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING) +val hasRaw = caps?.contains(REQUEST_AVAILABLE_CAPABILITIES_RAW) + +// Q4: LSC マップサイズ +val mapSize = characteristics.get(CameraCharacteristics.LENS_INFO_SHADING_MAP_SIZE) + +// Q3: DNG 出力可否 +val streamMap = characteristics.get(SCALER_STREAM_CONFIGURATION_MAP) +val rawOutputSizes = streamMap?.getOutputSizes(ImageFormat.RAW_SENSOR) +``` + +これらを Logcat または UI に出力し,結果を MiniTias リポジトリの新規ファイル `docs/06_TEST/TEST_xx_実機サポート判定.md` 等に記録する. + +### Step B: 実装範囲の決定 + +Step A の結果から,**どの Phase まで実装できるか** を決定する: + +| ハードウェアレベル | 実装可能範囲 | 備考 | +| --- | --- | --- | +| `LEGACY` | 定量モード **不可** | ユーザーに警告表示のみ | +| `LIMITED` + `MANUAL_SENSOR` のみ | TECH_01 Phase 1(露光・ISO 固定)まで | NR/EDGE/TONEMAP 制御は ×.SHADING_MODE は個別確認 | +| `LIMITED` + `MANUAL_POST_PROCESSING` あり | TECH_01 Phase 1〜2(露光・WB・NR・EDGE 固定)まで | LSC マップ取得は STATISTICS_LENS_SHADING_MAP_MODE 個別確認 | +| `FULL` / `LEVEL_3` | TECH_01 Phase 1〜5 全実装可能 | DNG 出力も可能なら最高 | + +判定結果と「実装する Phase」を MiniTias 側 docs/ に記録.SmTIAS-LightSim 側にも反映できるよう,後述「結果の共有方法」に従う. + +### Step C: Phase 1 着手 + +[TECH_01 推奨実装方針](TECH_01_定量撮影モード要求仕様.md#推奨実装方針-recommended-implementation) の Phase 1〜5 を順に実装.`/implement` パイプラインで進めることを推奨: + +```text +/implement TECH_01 Phase 1: Camera2 詳細制御の MethodChannel 実装 +``` + +## データ受け渡し仕様 (Data Exchange Specification) + +定量撮影モードで取得したデータを SmTIAS-LightSim 側で使えるようにするための **ファイル配置・命名規則**. + +### ファイル命名規則 + +| 種別 | 命名 | +| --- | --- | +| 画像(PNG/YUV/DNG) | `MiniTIAS_QM_YYYYMMDD_HHmmss.` | +| 撮影メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | +| LSC ゲインマップ | `MiniTIAS_QM_YYYYMMDD_HHmmss.lsc.npz` | + +`QM` = Quantitative Mode.既存通常モードの `MiniTIAS_YYYYMMDD_HHmmss.png` と区別する. + +### 拡張子の選択 + +| 拡張子 | 採用条件 | +| --- | --- | +| `.png` | YUV_420_888 → BT.601 → RGB → PNG 経路(現状継承) | +| `.yuv` または `.yuv.npz` | YUV_420_888 を直接保存(中間処理排除) | +| `.dng` | RAW_SENSOR をサポートしている場合 | + +複数フォーマットを並列保存しても良い(例: 同一撮影で `.png` と `.dng` 両方を保存). + +### 端末から PC への転送 + +MiniTias アプリは端末ローカル `Pictures/MiniTIAS/` に保存する.PC への転送は **USB 接続でユーザーが手動コピー** する想定(既存ワークフローを継承). + +### SmTIAS-LightSim 側の配置先 + +PC 転送後の配置先は: + +```text +SmTIAS-LightSim/ +└── reference_measurement/ # .gitignore 配下(個人ローカル) + ├── MiniTIAS_20260408_144434/ # 既存 baseline(通常モード) + │ └── <画像 / 解析結果> + └── quantitative/ # ← 新規・定量モード用 + ├── MiniTIAS_QM_20260601_120000.png + ├── MiniTIAS_QM_20260601_120000.meta.json + ├── MiniTIAS_QM_20260601_120000.lsc.npz + └── ... +``` + +→ MiniTias 側で撮影 → PC に USB 転送 → `reference_measurement/quantitative/` に配置. + +### メタデータ JSON スキーマ + +[TECH_01 のメタデータ要求](TECH_01_定量撮影モード要求仕様.md#4-撮影メタデータの保存撮影パラメータの可視化) を参照.SmTIAS-LightSim 側の sm-evaluate で読み込み可能な形式とする. + +特に **必須フィールド**: + +- `capture_mode`: `"quantitative"` または `"normal"` +- `device_model`: `"SH-02M"` 等 +- `hardware_level`: Step A で取得した値 +- `settings`: Camera2 API の実適用値 +- `image_format`: `"YUV_420_888"` / `"RAW_SENSOR"` 等 + +これらが揃っていれば SmTIAS-LightSim 側で読み込みロジックを実装可能. + +### LSC マップ NPZ スキーマ + +```python +# NPZ 内のキー +{ + "gain_map": np.ndarray, # shape=(4, N, M), dtype=float32, channels=[R, Geven, Godd, B] + "map_size": np.ndarray, # shape=(2,), dtype=int32, [N=col_count, M=row_count] + "min_gain_factor": 1.0, # MINIMUM_GAIN_FACTOR +} +``` + +または等価な JSON でも可(数値桁数の精度に注意). + +## 既存 baseline 画像へのアクセス (Existing Baseline Access) + +`MiniTIAS_20260408_144434` は SmTIAS-LightSim 側の `reference_measurement/` 配下に存在(.gitignore 対象,個人ローカル).MiniTias 側からは直接参照できない. + +実装中に **「既存撮影時の条件と揃えたい」** という要請が出た場合は,rinto 経由で: + +1. SmTIAS-LightSim リポジトリで該当画像の Exif / メタデータを抽出 +2. 結果を MiniTias 側 docs/ または本リポジトリの本ファイルに追記して共有 + +ただし TECH_01 に書いたとおり,**既存 baseline 自体が「定量データとして揺らいでいる」のが本作業の出発点**なので,定量モード実装後は新規 baseline(定量モードで撮ったもの)に置き換える前提でよい. + +## 質問・調整事項の扱い (Open Issues Handling) + +両プロジェクト Claude セッションでの作業を前提とすると,質問・調整は **ファイルベース** で行うのが追跡性が高い. + +| 状況 | 対応 | +| --- | --- | +| MiniTias 側実装中に Q1〜Q7 の判断が必要になった | MiniTias 側 docs/ に `06_TEST/TEST_xx_実装判断ログ.md` 等で判断と理由を記録.rinto が SmTIAS-LightSim 側にも反映 | +| SmTIAS-LightSim 側仕様 (TECH_01) に追加要求が出た | rinto が SmTIAS-LightSim 側で TECH_01 を更新.MiniTias 側 Claude は `git pull` 相当で再読 | +| データ受け渡し仕様(ファイル命名・スキーマ)の変更要求 | rinto が SmTIAS-LightSim 側で本ファイル (TECH_02) を更新.両セッションで共有 | +| 実機 (AQUOS sense3) で予期せぬ動作 | MiniTias 側 docs/ に記録.必要なら本ファイルの「既知の制約」セクションに追記(後日反映) | + +## 完了判定の方法 (Completion Criteria) + +[TECH_01 検証方法 V1〜V4](TECH_01_定量撮影モード要求仕様.md#検証方法-verification) を実機で実施. + +### MiniTias 側で完了とする条件 + +- V1(再現性 10 連射 CoV < 1%),V2(LSC マップ取得検証),V3(SHADING_MODE 切替検証)の結果ログが MiniTias 側 docs/06_TEST/ 等に残っている +- 定量モードで白板を撮影したサンプルファイル(PNG + meta.json + LSC マップ)が 1 セット以上 PC 側 `reference_measurement/quantitative/` に存在する + +### SmTIAS-LightSim 側の受け入れトリガー + +`reference_measurement/quantitative/` 配下にサンプルファイルが配置された時点で,SmTIAS-LightSim 側 Claude セッションは以下の受け入れ作業に着手できる: + +- メタデータ JSON 読み込み I/F の実装(sm-evaluate 拡張) +- LSC マップ読み込み I/F の実装(sm-evaluate の LSC 順方向適用機能) +- 既存 baseline と新規定量モード baseline の **並列キャリブ**(両者の比較) +- TEST_01 に新規 Step(例: Step 8-13)として追加 + +### V4 の達成判定 + +V4(残差 2.1× → 1.5×)は両プロジェクトの連携でのみ達成可能: + +- MiniTias 側で V1〜V3 完了 +- SmTIAS-LightSim 側で sm-evaluate に定量モード対応実装完了 +- 新規 baseline でキャリブ実施 +- 結果を SmTIAS-LightSim 側 TEST_01 に追記 + +V4 達成は本ハンドオフのスコープ外(後続キャリブ作業の成果として位置付ける). + +## 後続作業 (Follow-up Work on SmTIAS-LightSim Side) + +MiniTias 側完了後,SmTIAS-LightSim 側で以下を実施予定(参考情報): + +1. **`sm-evaluate` に LSC 順方向適用機能を追加**: 新規 `evaluation/lsc.py` モジュール.LSC マップを画像に掛けて補正済み画像を再現 +2. **メタデータ JSON 読み込み機能**: `evaluation/metadata.py` で撮影パラメータを取得 +3. **新規 baseline 設定の追加**: `configs/eval_baseline_quantitative.yaml` 等.既存 baseline と並列保持 +4. **TEST_01 に Step 8-13 を追加**: 「定量モード baseline での再キャリブ」として記録 +5. **TEST_03 の比較パネル更新**: 新規 baseline を含む形で再生成 + +これらは MiniTias 側完了後に着手するため,TECH_01 の検証 V4 とともに「本ハンドオフのスコープ外」として位置付ける. + +## チェックリスト(MiniTias 側 Claude 用) (Checklist) + +セッション内で以下を順に確認すると進めやすい: + +- [ ] 本ファイル (TECH_02) を読了 +- [ ] TECH_01 を読了 +- [ ] [MiniTIAS/docs/04_SPEC/SPEC_01 画面機能仕様書](../../MiniTIAS/docs/04_SPEC/SPEC_01_画面機能仕様書.md) を読了(現状仕様) +- [ ] Step A: 実機サポート状況確認 → 結果記録 +- [ ] Step B: 実装範囲決定 → 記録 +- [ ] Step C: Phase 1 着手(`/implement` パイプライン) +- [ ] Phase 2 以降を順次実装 +- [ ] V1〜V3 実機検証 → 結果ログを docs/06_TEST/ に残す +- [ ] 定量モード撮影サンプルを `Pictures/MiniTIAS/` に作成 → rinto が PC 転送 + +## 関連ドキュメント (Related Documents) + +- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — 本作業の要求書(What/Why) +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — 「形のずれ」発見の経緯 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — シミュ vs 実機の構造的差異 +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate の評価パイプライン +- [MiniTIAS/docs/04_SPEC/SPEC_01 画面機能仕様書](../../MiniTIAS/docs/04_SPEC/SPEC_01_画面機能仕様書.md) — 現状の MiniTias 撮影仕様(変更前ベースライン) +- [Android Camera2 API LensShadingMap](https://developer.android.com/reference/android/hardware/camera2/params/LensShadingMap) +- [Android Camera2 API CaptureRequest](https://developer.android.com/reference/android/hardware/camera2/CaptureRequest) diff --git a/docs/06_TEST/camera_diagnostics_20260531_161734.json b/docs/06_TEST/camera_diagnostics_20260531_161734.json new file mode 100644 index 0000000..ccf9e8b --- /dev/null +++ b/docs/06_TEST/camera_diagnostics_20260531_161734.json @@ -0,0 +1,123 @@ +{ + "cameraId": "1", + "hardwareLevel": 3, + "capabilities": [ + 0, + 1, + 2, + 5, + 6, + 4, + 7, + 3 + ], + "exposureTimeRangeNs": [ + 79806, + 871614530 + ], + "sensitivityRange": [ + 40, + 2506 + ], + "lscMapSize": null, + "availableLensShadingMapModes": [ + 0, + 1 + ], + "availableNoiseReductionModes": [ + 0, + 1, + 2, + 3, + 4 + ], + "availableEdgeModes": [ + 0, + 1, + 2, + 3 + ], + "availableTonemapModes": [ + 0, + 1, + 2 + ], + "availableShadingModes": [ + 0, + 1, + 2 + ], + "availableAwbModes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 0 + ], + "availableAfModes": [ + 0 + ], + "minFocusDistance": 0.0, + "hyperfocalDistance": 0.8189331293106079, + "rawOutputSizes": [ + "3264x2448" + ], + "yuvOutputSizes": [ + "3264x2448", + "2048x1536", + "1920x1440", + "1920x1080", + "2560x800", + "1600x1200", + "1440x1080", + "1280x960", + "1280x720", + "1200x1200", + "1280x480", + "864x480", + "720x480", + "640x480", + "480x360", + "480x320", + "352x288", + "320x240", + "240x180" + ], + "jpegOutputSizes": [ + "3264x2448", + "2048x1536", + "1920x1440", + "1920x1080", + "2560x800", + "1600x1200", + "1440x1080", + "1280x960", + "1280x720", + "1200x1200", + "1280x480", + "864x480", + "720x480", + "640x480", + "480x360", + "480x320", + "352x288", + "320x240", + "240x180" + ], + "colorFilterArrangement": 3, + "blackLevelPattern": "BlackLevelPattern([64, 64], [64, 64])", + "whiteLevel": 1023, + "physicalSize": [ + 3.557760000228882, + 2.6683201789855957 + ], + "pixelArraySize": [ + 3264, + 2448 + ], + "activeArraySize": "Rect(0, 0 - 3264, 2448)" +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 87089bf..d024a8a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:mini_tias/app.dart'; import 'package:mini_tias/providers/camera_provider.dart'; +import 'package:mini_tias/providers/diagnostics_provider.dart'; import 'package:mini_tias/providers/gallery_provider.dart'; void main() { @@ -15,6 +16,7 @@ providers: [ ChangeNotifierProvider(create: (_) => CameraProvider()), ChangeNotifierProvider(create: (_) => GalleryProvider()), + ChangeNotifierProvider(create: (_) => DiagnosticsProvider()), ], child: const MiniTiasApp(), ), 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/providers/diagnostics_provider.dart b/lib/providers/diagnostics_provider.dart new file mode 100644 index 0000000..05011bd --- /dev/null +++ b/lib/providers/diagnostics_provider.dart @@ -0,0 +1,55 @@ +import 'package:flutter/foundation.dart'; + +import 'package:mini_tias/services/file_service.dart'; +import 'package:mini_tias/services/raw_capture_service.dart'; + +/// カメラ診断機能の状態管理. +/// Camera2 API の能力を読み出し JSON ファイルに保存する. +class DiagnosticsProvider extends ChangeNotifier { + final RawCaptureService _rawCaptureService; + final FileService _fileService; + + DiagnosticsProvider({ + RawCaptureService? rawCaptureService, + FileService? fileService, + }) : _rawCaptureService = rawCaptureService ?? RawCaptureService(), + _fileService = fileService ?? FileService(); + + bool _isRunning = false; + String? _lastSavedPath; + String? _errorMessage; + + bool get isRunning => _isRunning; + String? get lastSavedPath => _lastSavedPath; + String? get errorMessage => _errorMessage; + + /// 診断を実行し,結果を JSON ファイルとして保存する. + Future runDiagnostics() async { + if (_isRunning) return; + + _isRunning = true; + _errorMessage = null; + _lastSavedPath = null; + notifyListeners(); + + try { + final diagnostics = await _rawCaptureService.getCameraDiagnostics(); + if (diagnostics == null) { + _errorMessage = 'カメラ診断情報を取得できませんでした'; + _isRunning = false; + notifyListeners(); + return; + } + + final savedPath = await _fileService.saveDiagnosticsJson(diagnostics); + await _rawCaptureService.scanFile(savedPath); + + _lastSavedPath = savedPath; + } catch (e) { + _errorMessage = '診断の実行に失敗しました: $e'; + } + + _isRunning = false; + notifyListeners(); + } +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 1f9765a..6b72dcd 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -8,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 変換済みのバイト列を受け取り, @@ -28,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'; @@ -52,6 +54,27 @@ return '${baseName}_$suffix.png'; } + /// カメラ診断情報を JSON ファイルとして保存する. + /// + /// ファイル名: `camera_diagnostics_YYYYMMDD_HHmmss.json` + /// 保存先: `/storage/emulated/0/Pictures/MiniTIAS` + /// 戻り値は保存したファイルのフルパス. + Future saveDiagnosticsJson(Map diagnostics) async { + final directory = Directory(_basePath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final fileName = + 'camera_diagnostics_${_buildTimestamp(DateTime.now())}.json'; + final filePath = '$_basePath/$fileName'; + + const encoder = JsonEncoder.withIndent(' '); + await File(filePath).writeAsString(encoder.convert(diagnostics)); + + return filePath; + } + /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する. static String generateFileNameSync( String timestamp, @@ -70,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 9614251..7a655fe 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -51,6 +51,17 @@ return result; } + /// フロントカメラの Camera2 API 能力を診断情報として取得する. + /// + /// 戻り値はカメラの各種パラメータを含む Map.取得できない場合は null を返す. + Future?> getCameraDiagnostics() async { + final result = await _channel.invokeMethod>( + 'getCameraDiagnostics', + ); + if (result == null) return null; + return result.cast(); + } + /// MediaStore にファイルを登録し,PC から MTP で見えるようにする. Future scanFile(String path) async { await _channel.invokeMethod('scanFile', {'path': path}); @@ -65,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)); + }); + }); +}