diff --git a/.gitignore b/.gitignore index 1a7a8d7..1be4d47 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ /android/app/profile /android/app/release +# ローカル専用の参考素材(Claude への状況説明用サンプル画像など.Git には含めない) +local_reference/ + # Claude Code .claude/plans/ diff --git a/CLAUDE.md b/CLAUDE.md index 945c208..0f57249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# MiniTIAS +# SmTIAS-Capture 千葉大学 TIAS(舌画像撮影・解析システム)のスマートフォン版.Flutter で開発し,初期は撮影・保存に特化する. ## 開発進捗 -最新: フェーズ A 対応(画素統計・Hot pixel map を meta.json に追加,露光 1/120s 確定) +最新: v1.1.0 リリース整理(ドキュメント再編・Dart/Kotlin リファクタ・画素統計 JUnit テスト追加・SmTIAS-Capture へリネーム) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) @@ -73,12 +73,13 @@ ### 04_SPEC(仕様・設計) -- 画面機能仕様書: docs/04_SPEC/SPEC_01_画面機能仕様書.md +- 画面機能仕様書(画面・遷移・UI・パーミッション): docs/04_SPEC/SPEC_01_画面機能仕様書.md +- カメラ撮影仕様書(カメラ制御・撮影フロー・ファイル管理): docs/04_SPEC/SPEC_02_カメラ撮影仕様書.md +- アーキテクチャと非機能(コード構成・非機能要件): docs/04_SPEC/SPEC_03_アーキテクチャと非機能.md ### 05_TECH(技術設計・外部要求仕様) -- 定量撮影モード要求仕様(SmTIAS-LightSim 側からの外部要求): docs/05_TECH/TECH_01_定量撮影モード要求仕様.md -- MiniTIAS 引き継ぎ手順(SmTIAS-LightSim 側からのハンドオフ手順): docs/05_TECH/TECH_02_MiniTIAS引き継ぎ手順.md +- 定量モード設計の経緯と合意(旧 TECH_01/03/04 の往復書簡を統合・要約.現行確定仕様は SPEC_02 参照): docs/05_TECH/TECH_01_定量モード設計の経緯と合意.md ### 06_TEST(テスト・実機検証) diff --git a/DSC_0066.JPG b/DSC_0066.JPG deleted file mode 100644 index d8a9c19..0000000 --- a/DSC_0066.JPG +++ /dev/null Binary files differ diff --git a/README.md b/README.md index ba79556..1732676 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,36 @@ -# MiniTIAS +# SmTIAS-Capture 千葉大学フロンティア医工学センターの舌画像撮影・解析システム(TIAS)のスマートフォン版。 -既存の積分球装置による撮影と画質を比較するため、まずは撮影・保存に特化したアプリとして開発。 +PC 側のシミュレーション(SmTIAS-LightSim)と実機撮影を定量比較するため、Camera2 API の +完全マニュアル制御で RAW(DNG)を撮影・保存することに特化したアプリ。 ## 主な機能 -- フロントカメラによる舌画像の撮影(PNG 無圧縮保存) -- 3 秒カウントダウンタイマー -- 画面輝度調整スライダー(LED 光源の補助) -- 撮影画像の一覧表示・拡大閲覧・削除 -- 端末を逆さに装着する専用アタッチメントに対応した UI 回転 +- **定量モード撮影(DNG)**: フロントカメラを Camera2 API でフルマニュアル制御(AE/AWB/AF/NR/EDGE OFF・線形トーンマップ・露光 1/120s・ISO 40 固定)し、10-bit BGGR Bayer の RAW を `.dng`(約 16 MB)で保存 +- **メタデータ JSON**: 撮影設定・実適用値・センサ特性・LSC マップ・画素統計・Hot pixel map を `.meta.json` に保存 +- **プレビュー JPEG**: シャッター瞬間のライブプレビュー(自動 ISP 処理)スナップショットを `.preview.jpg` として保存し、アプリ内で撮れ具合を確認 +- **一覧・閲覧・削除**: 撮影画像のサムネイル一覧、拡大表示、関連 3 ファイル連動削除 +- **カメラ診断**: 実機 Camera2 能力(HW Level・対応モード・出力サイズ等)を JSON 出力(インフラのみ・UI 非表示) +- 端末を逆さに装着する専用アタッチメントに対応した 180° 回転 UI、画面輝度調整、シャッター音 ON/OFF + +> 旧通常モードの非圧縮 PNG 撮影パス(`captureFullResolutionPng`)はフォールバック用にコードを温存。 ## 対象デバイス -- AQUOS sense3(フロントカメラ + LED ライト付きアタッチメント) +- AQUOS sense3(SH-02M)/ フロントカメラ + LED ライト付きアタッチメント +- 診断結果: HW Level 3 + RAW + MANUAL_SENSOR + MANUAL_POST_PROCESSING 対応 ## 技術スタック | 項目 | 技術 | |------|------| -| フレームワーク | Flutter 3.11 / Dart | +| フレームワーク | Flutter(stable)/ Dart | | 状態管理 | Provider | -| カメラ | camera パッケージ(Camera2 API) | -| 画像処理 | image パッケージ(YUV→PNG 変換) | +| カメラ(プレビュー) | camera パッケージ(ライブプレビュー用) | +| カメラ(撮影・変換) | Android ネイティブ Kotlin(Camera2 API / `DngCreator` / YUV→JPEG・PNG エンコード) | | 権限管理 | permission_handler | +| 設定保存 | shared_preferences(シャッター音 ON/OFF 等) | +| 画面輝度 | screen_brightness | 詳細は [docs/02_ENV/ENV_01_技術スタック.md](docs/02_ENV/ENV_01_技術スタック.md) を参照。 @@ -31,8 +38,8 @@ ```powershell # 1. リポジトリをクローン -git clone https://github.com/rintoHasegawa/MiniTias.git -cd MiniTias +git clone https://github.com/rintoHasegawa/SmTIAS-Capture.git +cd SmTIAS-Capture # 2. セットアップスクリプトを実行(Flutter・Android SDK 等を自動インストール) ./scripts/setup.ps1 @@ -54,22 +61,30 @@ flutter run # ビルド&実行 ``` +撮影データ(`.dng` / `.meta.json` / `.preview.jpg`)は端末の `Pictures/MiniTIAS/` に保存される。 +PC への転送は **adb pull** を推奨(大容量 DNG では MTP に不整合リスクあり)。 + ## プロジェクト構成 ``` lib/ -├── screens/ # 画面(撮影・一覧) -├── providers/ # 状態管理(カメラ・ギャラリー) -├── services/ # ビジネスロジック(ファイル保存・権限・RAW 変換) +├── screens/ # 画面(ホーム・撮影・一覧) +├── providers/ # 状態管理(カメラ・ギャラリー・診断) +├── services/ # ビジネスロジック(ファイル保存・権限・RAW 撮影・サウンド) ├── widgets/ # 共通ウィジェット(プレビュー・シャッターボタン・グリッド) ├── app.dart # アプリ設定 └── main.dart # エントリポイント +android/app/src/main/kotlin/com/example/mini_tias/ +└── RawCapturePlugin.kt # Camera2 撮影・DNG/PNG/JPEG 変換・診断のネイティブ実装 + docs/ ├── 01_GUIDE/ # 開発規約・ルール ├── 02_ENV/ # 環境構築 ├── 03_PLAN/ # 要件定義・開発ステップ -└── 04_SPEC/ # 画面機能仕様 +├── 04_SPEC/ # 画面・カメラ撮影・設計/非機能の各仕様 +├── 05_TECH/ # 技術設計・SmTIAS-LightSim 側との外部要求/合意 +└── 06_TEST/ # 実機検証結果(カメラ診断など) ``` ## 開発ガイド @@ -87,7 +102,11 @@ - [要件定義書](docs/03_PLAN/PLAN_01_要件定義書.md) - [開発ステップ](docs/03_PLAN/PLAN_02_開発ステップ.md) -- [画面機能仕様書](docs/04_SPEC/SPEC_01_画面機能仕様書.md) +- [画面機能仕様書](docs/04_SPEC/SPEC_01_画面機能仕様書.md) / [カメラ撮影仕様書](docs/04_SPEC/SPEC_02_カメラ撮影仕様書.md) / [アーキテクチャと非機能](docs/04_SPEC/SPEC_03_アーキテクチャと非機能.md) +- [定量モード設計の経緯と合意](docs/05_TECH/TECH_01_定量モード設計の経緯と合意.md) +- [開発進捗ログ](docs/PROGRESS.md) - [Git 運用ルール](docs/01_GUIDE/GUIDE_04_Git運用ルール.md) - [コーディング規約](docs/01_GUIDE/GUIDE_06_コーディング規約.md) - [テスト方針](docs/01_GUIDE/GUIDE_07_テスト方針.md) + + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1c87cea..50e1441 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ } android { - namespace = "com.example.mini_tias" + namespace = "com.example.smtias_capture" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.mini_tias" + applicationId = "com.example.smtias_capture" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -42,3 +42,9 @@ flutter { source = "../.." } + +dependencies { + // ImageStatistics の数理ロジック検証用(実機不要の JVM 単体テスト). + // 実行: android ディレクトリで `./gradlew test` + testImplementation("junit:junit:4.13.2") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5d084aa..4f5713b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ Unit - -/** - * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで - * フル解像度の画像を 1 フレームキャプチャする. - */ -class RawCapturePlugin(private val context: Context) : MethodChannel.MethodCallHandler { - - private var backgroundThread: HandlerThread? = null - private var backgroundHandler: Handler? = null - private var cameraDevice: CameraDevice? = null - private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } - - private companion object { - const val DEFAULT_EXPOSURE_TIME_NS = 8_333_333L // 1/120s - const val DEFAULT_SENSITIVITY = 40 - const val DEFAULT_FRAME_DURATION_NS = 33_333_333L - const val RAW_WIDTH = 3264 - const val RAW_HEIGHT = 2448 - const val FRAMES_TO_DISCARD = 2 - const val SATURATION_THRESHOLD = 1000 - const val UNDEREXPOSED_THRESHOLD = 100 - const val STATISTICS_HISTOGRAM_BINS = 256 - /** 10-bit RAW センサー値の範囲(0〜1023 の 1024 通り).ヒストグラム bin 変換に使用する.*/ - const val RAW_10BIT_RANGE = 1024 - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - 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") - if (path != null) { - MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/png")) { _, _ -> - result.success(null) - } - } else { - result.error("INVALID_PATH", "パスが指定されていません", null) - } - } - "playShutterSound" -> { - try { - mediaActionSound.play(MediaActionSound.SHUTTER_CLICK) - result.success(null) - } catch (e: Exception) { - result.error("SOUND_ERROR", "シャッター音の再生に失敗: ${e.message}", null) - } - } - "playSaveCompleteSound" -> { - try { - val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - val ringtone = RingtoneManager.getRingtone(context, notification) - ringtone?.play() - result.success(null) - } catch (e: Exception) { - result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null) - } - } - "getCameraDiagnostics" -> getCameraDiagnostics(result) - else -> result.notImplemented() - } - } - - /// YUV_420_888 を NV21 に変換し,YuvImage で JPEG 化して返す. - private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) { - try { - val width = call.argument("width")!! - val height = call.argument("height")!! - val yPlane = call.argument("yPlane")!! - val uPlane = call.argument("uPlane")!! - val vPlane = call.argument("vPlane")!! - val yRowStride = call.argument("yRowStride")!! - val uvRowStride = call.argument("uvRowStride")!! - val uvPixelStride = call.argument("uvPixelStride")!! - val rotation = call.argument("rotation") ?: 0 - val quality = call.argument("quality") ?: 85 - - // YUV_420_888 → NV21 変換 - val nv21 = ByteArray(width * height * 3 / 2) - - // Y プレーンをコピー - for (row in 0 until height) { - System.arraycopy(yPlane, row * yRowStride, nv21, row * width, width) - } - - // UV プレーンを NV21 形式(VUVU...)にインターリーブ - val uvOffset = width * height - for (row in 0 until height / 2) { - for (col in 0 until width / 2) { - val uvIndex = row * uvRowStride + col * uvPixelStride - nv21[uvOffset + row * width + col * 2] = vPlane[uvIndex] - nv21[uvOffset + row * width + col * 2 + 1] = uPlane[uvIndex] - } - } - - // NV21 → JPEG - val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null) - val jpegStream = ByteArrayOutputStream() - yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, jpegStream) - - // 回転・反転が必要な場合 - val mirror = call.argument("mirror") ?: false - val jpegBytes = if (rotation != 0 || mirror) { - val bitmap = BitmapFactory.decodeByteArray(jpegStream.toByteArray(), 0, jpegStream.size()) - val matrix = Matrix() - if (rotation != 0) matrix.postRotate(rotation.toFloat()) - if (mirror) matrix.postScale(-1f, 1f) - val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - bitmap.recycle() - - val outStream = ByteArrayOutputStream() - transformed.compress(Bitmap.CompressFormat.JPEG, quality, outStream) - transformed.recycle() - outStream.toByteArray() - } else { - jpegStream.toByteArray() - } - - result.success(jpegBytes) - } catch (e: Exception) { - result.error("CONVERT_ERROR", "YUV to JPEG 変換に失敗: ${e.message}", null) - } - } - - private fun captureFullResolutionPng(result: MethodChannel.Result) { - openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res -> - // YUV → RGB 変換 + 90° 時計回り回転を統合 - // 元の(x,y) → 回転後の(height-1-y, x) - // 出力サイズ: width_out=height, height_out=width - val rotatedPixels = IntArray(width * height) - for (y in 0 until height) { - for (x in 0 until width) { - val yIndex = y * yRowStride + x - val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride - - val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble() - val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble() - val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble() - - val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255) - val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255) - val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255) - - rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b) - } - } - - val rotatedWidth = height - val rotatedHeight = width - val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888) - bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight) - - // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化) - val pngBytes = encodePngUncompressed(bitmap) - bitmap.recycle() - - reader.close() - camera.close() - cameraDevice = null - - Handler(context.mainLooper).post { - res.success(pngBytes) - stopBackgroundThread() - } - } - } - - /** - * 定量モード撮影.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) - // Hot pixel map を有効化(STATISTICS_HOT_PIXEL_MAP_MODE は Boolean 型) - set(CaptureRequest.STATISTICS_HOT_PIXEL_MAP_MODE, true) - set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) - set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) - set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, - 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}") - - // 画素統計を計算(DNG 書き込み後・image.close() 前) - val imageStatistics: Map? = try { - computeImageStatistics(image) - } catch (e: Exception) { - Log.w("RawCapturePlugin", "Image statistics computation failed", e) - null - } - - // Hot pixel map を CaptureResult から取得 - val hotPixelMapData: Map = try { - val hotPixelArray = captureResult.get(CaptureResult.STATISTICS_HOT_PIXEL_MAP) - mapOf( - "mode" to "ON", - "count" to (hotPixelArray?.size ?: 0), - "coordinates" to (hotPixelArray?.map { listOf(it.x, it.y) } - ?: emptyList>()), - ) - } catch (e: Exception) { - Log.w("RawCapturePlugin", "Hot pixel map retrieval failed", e) - mapOf("mode" to "ON", "count" to 0, - "coordinates" to emptyList>()) - } - - Log.d("RawCapturePlugin", - "Image stats: $imageStatistics, hot pixels: ${hotPixelMapData["count"]}") - - // LSC マップを取得 - val lscMap = - captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) - 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, - imageStatistics, hotPixelMapData, - ) - - 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() - } - } - } - } - - /** - * RAW_SENSOR 画像から画素統計を計算する. - * - * Bayer 配列 (BGGR) を 2×2 ブロック単位で処理し, - * チャネル別の平均・最大・P99 値,飽和率・低露光率,ヒストグラムを返す. - * 計算失敗時は呼び出し元で null として扱う. - */ - private fun computeImageStatistics(image: android.media.Image): Map { - val plane = image.planes[0] - val buffer = plane.buffer - buffer.rewind() - buffer.order(ByteOrder.LITTLE_ENDIAN) - val shortBuf = buffer.asShortBuffer() - val rowStrideShorts = plane.rowStride / 2 - val srcW = image.width - val srcH = image.height - - // 集計用変数 - var sumR = 0.0; var sumG = 0.0; var sumB = 0.0 - var countR = 0; var countG = 0; var countB = 0 - var maxR = 0; var maxG = 0; var maxB = 0 - var saturatedCount = 0 - var underexposedCount = 0 - val histR = IntArray(STATISTICS_HISTOGRAM_BINS) - val histG = IntArray(STATISTICS_HISTOGRAM_BINS) - val histB = IntArray(STATISTICS_HISTOGRAM_BINS) - - val row1 = ShortArray(rowStrideShorts) - val row2 = ShortArray(rowStrideShorts) - - // 2 行ずつ読み,2×2 BGGR ブロックを処理 - var y = 0 - while (y < srcH - 1) { - shortBuf.position(y * rowStrideShorts) - shortBuf.get(row1, 0, rowStrideShorts) - shortBuf.position((y + 1) * rowStrideShorts) - shortBuf.get(row2, 0, rowStrideShorts) - - var x = 0 - while (x < srcW - 1) { - val b = row1[x].toInt() and 0x3FF // (0,0) B - val g1 = row1[x + 1].toInt() and 0x3FF // (0,1) G - val g2 = row2[x].toInt() and 0x3FF // (1,0) G - val r = row2[x + 1].toInt() and 0x3FF // (1,1) R - - // チャネル別集計 - sumR += r; countR++; if (r > maxR) maxR = r - sumB += b; countB++; if (b > maxB) maxB = b - val gAvg = (g1 + g2) / 2 - sumG += gAvg; countG++; if (gAvg > maxG) maxG = gAvg - - // ヒストグラム(10-bit 値 → 256 bin にマップ) - histR[ - (r * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - histG[ - (gAvg * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - histB[ - (b * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - - // 飽和率・低露光率は全 Bayer 画素を独立にカウント(4 画素分) - for (v in intArrayOf(b, g1, g2, r)) { - if (v >= SATURATION_THRESHOLD) saturatedCount++ - if (v <= UNDEREXPOSED_THRESHOLD) underexposedCount++ - } - - x += 2 - } - y += 2 - } - - val totalBayerPixels = srcW * srcH - val meanR = if (countR > 0) sumR / countR else 0.0 - val meanG = if (countG > 0) sumG / countG else 0.0 - val meanB = if (countB > 0) sumB / countB else 0.0 - - fun p99Bin(hist: IntArray, totalCount: Int): Int { - val target = (totalCount * 0.99).toInt() - var cumulative = 0 - for (bin in hist.indices) { - cumulative += hist[bin] - if (cumulative >= target) return (bin + 1) * RAW_10BIT_RANGE / STATISTICS_HISTOGRAM_BINS - 1 - } - return RAW_10BIT_RANGE - 1 - } - - val p99R = p99Bin(histR, countR) - val p99G = p99Bin(histG, countG) - val p99B = p99Bin(histB, countB) - - return mapOf( - "mean_per_channel" to mapOf("R" to meanR, "G" to meanG, "B" to meanB), - "max_per_channel" to mapOf("R" to maxR, "G" to maxG, "B" to maxB), - "p99_per_channel" to mapOf("R" to p99R, "G" to p99G, "B" to p99B), - "saturated_pixel_ratio" to (saturatedCount.toDouble() / totalBayerPixels), - "underexposed_pixel_ratio" to (underexposedCount.toDouble() / totalBayerPixels), - "thresholds" to mapOf( - "saturated" to SATURATION_THRESHOLD, - "underexposed" to UNDEREXPOSED_THRESHOLD, - ), - ) - } - - /// 撮影メタデータ Map を構築する. - private fun buildMetadataMap( - cameraId: String, - characteristics: CameraCharacteristics, - captureResult: TotalCaptureResult, - image: android.media.Image, - imageStatistics: Map? = null, - hotPixelMap: Map? = null, - ): Map { - val settingsMap = mapOf( - "control_mode" to "OFF", - "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", - "statistics_hot_pixel_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, - "image_statistics" to imageStatistics, - "hot_pixel_map" to hotPixelMap, - ) - } - - /** フロントカメラの 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 秒)・本番キャプチャの - * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する. - * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと. - */ - private fun openCameraAndCapture( - result: MethodChannel.Result, - onFrame: FrameProcessor, - ) { - 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) - val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) - - if (streamConfigMap == null) { - returnError(result, "NO_CONFIG", "カメラの設定を取得できません") - return - } - - val yuvSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888) - val maxSize = yuvSizes.maxByOrNull { it.width * it.height } - - if (maxSize == null) { - returnError(result, "NO_SIZE", "YUV の解像度を取得できません") - return - } - - val imageReader = ImageReader.newInstance( - maxSize.width, maxSize.height, ImageFormat.YUV_420_888, 2 - ) - - 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 { - // Phase 1: プレビューを流して AE/AF を安定させる - val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { - addTarget(imageReader.surface) - set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) - set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) - } - session.setRepeatingRequest(previewRequest.build(), null, backgroundHandler) - - // Phase 2: 1 秒後にリスナーを設定してから本番キャプチャ - backgroundHandler?.postDelayed({ - try { - session.stopRepeating() - - // プレビュー中のバッファを捨てる - while (true) { - val stale = imageReader.acquireLatestImage() - if (stale != null) { - stale.close() - } else { - break - } - } - - // ここでリスナーを設定(本番フレームのみ受信) - imageReader.setOnImageAvailableListener({ reader -> - if (resultSent) return@setOnImageAvailableListener - val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener - resultSent = true - - try { - val yPlane = image.planes[0] - val uPlane = image.planes[1] - val vPlane = image.planes[2] - - val width = image.width - val height = image.height - - val yBytes = ByteArray(yPlane.buffer.remaining()) - val uBytes = ByteArray(uPlane.buffer.remaining()) - val vBytes = ByteArray(vPlane.buffer.remaining()) - - yPlane.buffer.get(yBytes) - uPlane.buffer.get(uBytes) - vPlane.buffer.get(vBytes) - - val yRowStride = yPlane.rowStride - val uvRowStride = uPlane.rowStride - val uvPixelStride = uPlane.pixelStride - - image.close() - - onFrame( - yBytes, uBytes, vBytes, - width, height, - yRowStride, uvRowStride, uvPixelStride, - reader, camera, result - ) - } catch (e: Exception) { - image.close() - reader.close() - cleanup() - Handler(context.mainLooper).post { - result.error("PROCESS_ERROR", "画像データの処理に失敗: ${e.message}", null) - } - } - }, backgroundHandler) - - // 本番キャプチャ実行 - val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { - addTarget(imageReader.surface) - set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) - set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) - } - session.capture(captureRequest.build(), null, backgroundHandler) - } catch (e: Exception) { - cleanup() - imageReader.close() - Handler(context.mainLooper).post { - result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) - } - } - }, 1000) - } catch (e: Exception) { - cleanup() - imageReader.close() - Handler(context.mainLooper).post { - result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) - } - } - } - - override fun onConfigureFailed(session: CameraCaptureSession) { - cleanup() - imageReader.close() - Handler(context.mainLooper).post { - result.error("SESSION_ERROR", "カメラセッションの設定に失敗", null) - } - } - }, - backgroundHandler - ) - } catch (e: Exception) { - 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() - Handler(context.mainLooper).post { - result.error("CAMERA_ERROR", "カメラエラー: $error", null) - stopBackgroundThread() - } - } - }, backgroundHandler) - } catch (e: SecurityException) { - imageReader.close() - returnError(result, "PERMISSION_ERROR", "カメラの権限がありません") - } - } - - /// Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードする. - private fun encodePngUncompressed(bitmap: Bitmap): ByteArray { - val w = bitmap.width - val h = bitmap.height - val out = ByteArrayOutputStream(w * h * 3 + 1024) - - // PNG シグネチャ - out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) - - // IHDR チャンク - val ihdrData = ByteBuffer.allocate(13) - .putInt(w) - .putInt(h) - .put(8) // bit depth - .put(2) // color type: RGB - .put(0) // compression method - .put(0) // filter method - .put(0) // interlace method - .array() - writeChunk(out, "IHDR", ihdrData) - - // IDAT チャンク: 非圧縮 zlib でラップした RGB データ - val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64) - val deflater = Deflater(Deflater.NO_COMPRESSION) - val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536) - - val pixels = IntArray(w) - val rowBytes = ByteArray(1 + w * 3) // フィルタバイト(0) + RGB - rowBytes[0] = 0 // フィルタ: None - - for (y in 0 until h) { - bitmap.getPixels(pixels, 0, w, 0, y, w, 1) - for (x in 0 until w) { - val pixel = pixels[x] - rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte() // R - rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte() // G - rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte() // B - } - deflaterOut.write(rowBytes) - } - deflaterOut.finish() - deflaterOut.close() - deflater.end() - - writeChunk(out, "IDAT", idatStream.toByteArray()) - - // IEND チャンク - writeChunk(out, "IEND", byteArrayOf()) - - return out.toByteArray() - } - - /// PNG チャンクを書き込む(length + type + data + CRC). - private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) { - val typeBytes = type.toByteArray(Charsets.US_ASCII) - // length (4 bytes, big-endian) - out.write(ByteBuffer.allocate(4).putInt(data.size).array()) - // type (4 bytes) - out.write(typeBytes) - // data - out.write(data) - // CRC (type + data) - val crc = CRC32() - crc.update(typeBytes) - crc.update(data) - 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 - stopBackgroundThread() - } - - private fun returnError(result: MethodChannel.Result, code: String, message: String) { - result.error(code, message, null) - stopBackgroundThread() - } - - private fun startBackgroundThread() { - backgroundThread = HandlerThread("RawCaptureThread").also { it.start() } - backgroundHandler = Handler(backgroundThread!!.looper) - } - - private fun stopBackgroundThread() { - backgroundThread?.quitSafely() - try { - backgroundThread?.join(3000) - } catch (_: InterruptedException) { - } - backgroundThread = null - backgroundHandler = null - } -} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/CaptureMetadata.kt b/android/app/src/main/kotlin/com/example/smtias_capture/CaptureMetadata.kt new file mode 100644 index 0000000..de31b06 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/CaptureMetadata.kt @@ -0,0 +1,122 @@ +package com.example.smtias_capture + +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CaptureResult +import android.hardware.camera2.TotalCaptureResult +import android.media.Image +import android.os.Build +import java.time.Instant + +/** + * 定量モード撮影のメタデータ Map(meta.json の元データ)を構築するユーティリティ. + * + * 撮影設定([exposureTimeNs] 等は呼び出し元が適用した値)・実適用値(CaptureResult)・ + * センサ特性(CameraCharacteristics)・画素統計・Hot pixel map をまとめる. + */ +internal object CaptureMetadata { + + /** メタデータ Map を構築する. */ + fun build( + cameraId: String, + characteristics: CameraCharacteristics, + captureResult: TotalCaptureResult, + image: Image, + exposureTimeNs: Long, + sensitivity: Int, + frameDurationNs: Long, + imageStatistics: Map? = null, + hotPixelMap: Map? = null, + ): Map { + val settingsMap = mapOf( + "control_mode" to "OFF", + "ae_mode" to "OFF", + "exposure_time_ns" to exposureTimeNs, + "sensor_sensitivity_iso" to sensitivity, + "frame_duration_ns" to frameDurationNs, + "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", + "statistics_hot_pixel_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, + "image_statistics" to imageStatistics, + "hot_pixel_map" to hotPixelMap, + ) + } +} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/ImageStatistics.kt b/android/app/src/main/kotlin/com/example/smtias_capture/ImageStatistics.kt new file mode 100644 index 0000000..3883b57 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/ImageStatistics.kt @@ -0,0 +1,143 @@ +package com.example.smtias_capture + +import android.media.Image +import java.nio.ByteOrder +import java.nio.ShortBuffer + +/** + * RAW_SENSOR 画像から画素統計を計算するユーティリティ. + * + * Bayer 配列 (BGGR) を 2×2 ブロック単位で 1 パス走査し,チャネル別の + * 平均・最大・P99 値,飽和率・低露光率を返す.meta.json の `image_statistics` に格納する. + */ +internal object ImageStatistics { + + const val SATURATION_THRESHOLD = 1000 + const val UNDEREXPOSED_THRESHOLD = 100 + const val STATISTICS_HISTOGRAM_BINS = 256 + + /** 10-bit RAW センサー値の範囲(0〜1023 の 1024 通り).ヒストグラム bin 変換に使用する.*/ + const val RAW_10BIT_RANGE = 1024 + + /** + * [image](RAW_SENSOR)から画素統計を計算する. + * + * 計算失敗時は呼び出し元で null として扱う. + */ + fun compute(image: Image): Map { + val plane = image.planes[0] + val buffer = plane.buffer + buffer.rewind() + buffer.order(ByteOrder.LITTLE_ENDIAN) + return computeFromShortBuffer( + buffer.asShortBuffer(), + plane.rowStride / 2, + image.width, + image.height, + ) + } + + /** + * [shortBuf](10-bit RAW を short で保持)から画素統計を計算する. + * + * `android.media.Image` に依存しない純粋な数理計算のため,JVM 単体テスト(JUnit)で + * 検証できる.[compute] はバッファ読み出しのみを担い,本関数に委譲する. + */ + internal fun computeFromShortBuffer( + shortBuf: ShortBuffer, + rowStrideShorts: Int, + srcW: Int, + srcH: Int, + ): Map { + // 集計用変数 + var sumR = 0.0; var sumG = 0.0; var sumB = 0.0 + var countR = 0; var countG = 0; var countB = 0 + var maxR = 0; var maxG = 0; var maxB = 0 + var saturatedCount = 0 + var underexposedCount = 0 + val histR = IntArray(STATISTICS_HISTOGRAM_BINS) + val histG = IntArray(STATISTICS_HISTOGRAM_BINS) + val histB = IntArray(STATISTICS_HISTOGRAM_BINS) + + val row1 = ShortArray(rowStrideShorts) + val row2 = ShortArray(rowStrideShorts) + + // 2 行ずつ読み,2×2 BGGR ブロックを処理 + var y = 0 + while (y < srcH - 1) { + shortBuf.position(y * rowStrideShorts) + shortBuf.get(row1, 0, rowStrideShorts) + shortBuf.position((y + 1) * rowStrideShorts) + shortBuf.get(row2, 0, rowStrideShorts) + + var x = 0 + while (x < srcW - 1) { + val b = row1[x].toInt() and 0x3FF // (0,0) B + val g1 = row1[x + 1].toInt() and 0x3FF // (0,1) G + val g2 = row2[x].toInt() and 0x3FF // (1,0) G + val r = row2[x + 1].toInt() and 0x3FF // (1,1) R + + // チャネル別集計 + sumR += r; countR++; if (r > maxR) maxR = r + sumB += b; countB++; if (b > maxB) maxB = b + val gAvg = (g1 + g2) / 2 + sumG += gAvg; countG++; if (gAvg > maxG) maxG = gAvg + + // ヒストグラム(10-bit 値 → 256 bin にマップ) + histR[ + (r * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + histG[ + (gAvg * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + histB[ + (b * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + + // 飽和率・低露光率は全 Bayer 画素を独立にカウント(4 画素分) + for (v in intArrayOf(b, g1, g2, r)) { + if (v >= SATURATION_THRESHOLD) saturatedCount++ + if (v <= UNDEREXPOSED_THRESHOLD) underexposedCount++ + } + + x += 2 + } + y += 2 + } + + val totalBayerPixels = srcW * srcH + val meanR = if (countR > 0) sumR / countR else 0.0 + val meanG = if (countG > 0) sumG / countG else 0.0 + val meanB = if (countB > 0) sumB / countB else 0.0 + + val p99R = p99Bin(histR, countR) + val p99G = p99Bin(histG, countG) + val p99B = p99Bin(histB, countB) + + return mapOf( + "mean_per_channel" to mapOf("R" to meanR, "G" to meanG, "B" to meanB), + "max_per_channel" to mapOf("R" to maxR, "G" to maxG, "B" to maxB), + "p99_per_channel" to mapOf("R" to p99R, "G" to p99G, "B" to p99B), + "saturated_pixel_ratio" to (saturatedCount.toDouble() / totalBayerPixels), + "underexposed_pixel_ratio" to (underexposedCount.toDouble() / totalBayerPixels), + "thresholds" to mapOf( + "saturated" to SATURATION_THRESHOLD, + "underexposed" to UNDEREXPOSED_THRESHOLD, + ), + ) + } + + /** ヒストグラム [hist](総数 [totalCount])から P99 に相当する 10-bit 値を求める. */ + private fun p99Bin(hist: IntArray, totalCount: Int): Int { + val target = (totalCount * 0.99).toInt() + var cumulative = 0 + for (bin in hist.indices) { + cumulative += hist[bin] + if (cumulative >= target) return (bin + 1) * RAW_10BIT_RANGE / STATISTICS_HISTOGRAM_BINS - 1 + } + return RAW_10BIT_RANGE - 1 + } +} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/MainActivity.kt b/android/app/src/main/kotlin/com/example/smtias_capture/MainActivity.kt new file mode 100644 index 0000000..84e639a --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/MainActivity.kt @@ -0,0 +1,14 @@ +package com.example.smtias_capture + +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.smtias_capture/raw_capture") + channel.setMethodCallHandler(RawCapturePlugin(this)) + } +} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/PngEncoder.kt b/android/app/src/main/kotlin/com/example/smtias_capture/PngEncoder.kt new file mode 100644 index 0000000..5209c47 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/PngEncoder.kt @@ -0,0 +1,85 @@ +package com.example.smtias_capture + +import android.graphics.Bitmap +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.zip.CRC32 +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream + +/** + * Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードするユーティリティ. + * + * 圧縮率より速度を優先する研究用途向け.撮影フローのボトルネック解消のため + * 標準の `Bitmap.compress` を使わず自前で IDAT を非圧縮 zlib でラップする. + */ +internal object PngEncoder { + + /** [bitmap] を非圧縮 PNG のバイト列に変換して返す. */ + fun encodeUncompressed(bitmap: Bitmap): ByteArray { + val w = bitmap.width + val h = bitmap.height + val out = ByteArrayOutputStream(w * h * 3 + 1024) + + // PNG シグネチャ + out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) + + // IHDR チャンク + val ihdrData = ByteBuffer.allocate(13) + .putInt(w) + .putInt(h) + .put(8) // bit depth + .put(2) // color type: RGB + .put(0) // compression method + .put(0) // filter method + .put(0) // interlace method + .array() + writeChunk(out, "IHDR", ihdrData) + + // IDAT チャンク: 非圧縮 zlib でラップした RGB データ + val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64) + val deflater = Deflater(Deflater.NO_COMPRESSION) + val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536) + + val pixels = IntArray(w) + val rowBytes = ByteArray(1 + w * 3) // フィルタバイト(0) + RGB + rowBytes[0] = 0 // フィルタ: None + + for (y in 0 until h) { + bitmap.getPixels(pixels, 0, w, 0, y, w, 1) + for (x in 0 until w) { + val pixel = pixels[x] + rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte() // R + rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte() // G + rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte() // B + } + deflaterOut.write(rowBytes) + } + deflaterOut.finish() + deflaterOut.close() + deflater.end() + + writeChunk(out, "IDAT", idatStream.toByteArray()) + + // IEND チャンク + writeChunk(out, "IEND", byteArrayOf()) + + return out.toByteArray() + } + + /** PNG チャンクを書き込む(length + type + data + CRC). */ + private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) { + val typeBytes = type.toByteArray(Charsets.US_ASCII) + // length (4 bytes, big-endian) + out.write(ByteBuffer.allocate(4).putInt(data.size).array()) + // type (4 bytes) + out.write(typeBytes) + // data + out.write(data) + // CRC (type + data) + val crc = CRC32() + crc.update(typeBytes) + crc.update(data) + out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array()) + } +} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/smtias_capture/RawCapturePlugin.kt new file mode 100644 index 0000000..1bf9a6d --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/RawCapturePlugin.kt @@ -0,0 +1,856 @@ +package com.example.smtias_capture + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.ImageFormat +import android.hardware.camera2.* +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.Handler +import android.os.HandlerThread +import android.util.Log +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileOutputStream +import kotlin.math.roundToInt + +/** フレーム取得後に処理するコールバックの型エイリアス.*/ +private typealias FrameProcessor = ( + yBytes: ByteArray, uBytes: ByteArray, vBytes: ByteArray, + width: Int, height: Int, + yRowStride: Int, uvRowStride: Int, uvPixelStride: Int, + reader: ImageReader, camera: CameraDevice, + result: MethodChannel.Result +) -> Unit + +/** + * Camera2 API を使用してフロントカメラから YUV_420_888 フォーマットで + * フル解像度の画像を 1 フレームキャプチャする. + */ +class RawCapturePlugin(private val context: Context) : MethodChannel.MethodCallHandler { + + private var backgroundThread: HandlerThread? = null + private var backgroundHandler: Handler? = null + private var cameraDevice: CameraDevice? = null + private val mediaActionSound: MediaActionSound by lazy { MediaActionSound() } + + private companion object { + const val DEFAULT_EXPOSURE_TIME_NS = 8_333_333L // 1/120s + const val DEFAULT_SENSITIVITY = 40 + const val DEFAULT_FRAME_DURATION_NS = 33_333_333L + const val RAW_WIDTH = 3264 + const val RAW_HEIGHT = 2448 + const val FRAMES_TO_DISCARD = 2 + } + + 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") + if (path != null) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/png")) { _, _ -> + result.success(null) + } + } else { + result.error("INVALID_PATH", "パスが指定されていません", null) + } + } + "playShutterSound" -> { + try { + mediaActionSound.play(MediaActionSound.SHUTTER_CLICK) + result.success(null) + } catch (e: Exception) { + result.error("SOUND_ERROR", "シャッター音の再生に失敗: ${e.message}", null) + } + } + "playSaveCompleteSound" -> { + try { + val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val ringtone = RingtoneManager.getRingtone(context, notification) + ringtone?.play() + result.success(null) + } catch (e: Exception) { + result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null) + } + } + "getCameraDiagnostics" -> getCameraDiagnostics(result) + else -> result.notImplemented() + } + } + + /// YUV_420_888 を NV21 経由で JPEG 化して返す(実処理は [YuvJpegConverter]). + private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) { + try { + val jpegBytes = YuvJpegConverter.convert( + width = call.argument("width")!!, + height = call.argument("height")!!, + yPlane = call.argument("yPlane")!!, + uPlane = call.argument("uPlane")!!, + vPlane = call.argument("vPlane")!!, + yRowStride = call.argument("yRowStride")!!, + uvRowStride = call.argument("uvRowStride")!!, + uvPixelStride = call.argument("uvPixelStride")!!, + rotation = call.argument("rotation") ?: 0, + mirror = call.argument("mirror") ?: false, + quality = call.argument("quality") ?: 85, + ) + result.success(jpegBytes) + } catch (e: Exception) { + result.error("CONVERT_ERROR", "YUV to JPEG 変換に失敗: ${e.message}", null) + } + } + + private fun captureFullResolutionPng(result: MethodChannel.Result) { + openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res -> + // YUV → RGB 変換 + 90° 時計回り回転を統合 + // 元の(x,y) → 回転後の(height-1-y, x) + // 出力サイズ: width_out=height, height_out=width + val rotatedPixels = IntArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + val yIndex = y * yRowStride + x + val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride + + val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble() + val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble() + val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble() + + val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255) + val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255) + + rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b) + } + } + + val rotatedWidth = height + val rotatedHeight = width + val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888) + bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight) + + // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化) + val pngBytes = PngEncoder.encodeUncompressed(bitmap) + bitmap.recycle() + + reader.close() + camera.close() + cameraDevice = null + + Handler(context.mainLooper).post { + res.success(pngBytes) + stopBackgroundThread() + } + } + } + + /** + * 定量モード撮影.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) + // Hot pixel map を有効化(STATISTICS_HOT_PIXEL_MAP_MODE は Boolean 型) + set(CaptureRequest.STATISTICS_HOT_PIXEL_MAP_MODE, true) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, + 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}") + + // 画素統計を計算(DNG 書き込み後・image.close() 前) + val imageStatistics: Map? = try { + ImageStatistics.compute(image) + } catch (e: Exception) { + Log.w("RawCapturePlugin", "Image statistics computation failed", e) + null + } + + // Hot pixel map を CaptureResult から取得 + val hotPixelMapData: Map = try { + val hotPixelArray = captureResult.get(CaptureResult.STATISTICS_HOT_PIXEL_MAP) + mapOf( + "mode" to "ON", + "count" to (hotPixelArray?.size ?: 0), + "coordinates" to (hotPixelArray?.map { listOf(it.x, it.y) } + ?: emptyList>()), + ) + } catch (e: Exception) { + Log.w("RawCapturePlugin", "Hot pixel map retrieval failed", e) + mapOf("mode" to "ON", "count" to 0, + "coordinates" to emptyList>()) + } + + Log.d("RawCapturePlugin", + "Image stats: $imageStatistics, hot pixels: ${hotPixelMapData["count"]}") + + // LSC マップを取得 + val lscMap = + captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) + 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 = CaptureMetadata.build( + cameraId = cameraId, + characteristics = characteristics, + captureResult = captureResult, + image = image, + exposureTimeNs = DEFAULT_EXPOSURE_TIME_NS, + sensitivity = DEFAULT_SENSITIVITY, + frameDurationNs = DEFAULT_FRAME_DURATION_NS, + imageStatistics = imageStatistics, + hotPixelMap = hotPixelMapData, + ) + + 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() + } + } + } + } + + /** フロントカメラの 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 秒)・本番キャプチャの + * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する. + * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと. + */ + private fun openCameraAndCapture( + result: MethodChannel.Result, + onFrame: FrameProcessor, + ) { + 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) + val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + + if (streamConfigMap == null) { + returnError(result, "NO_CONFIG", "カメラの設定を取得できません") + return + } + + val yuvSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888) + val maxSize = yuvSizes.maxByOrNull { it.width * it.height } + + if (maxSize == null) { + returnError(result, "NO_SIZE", "YUV の解像度を取得できません") + return + } + + val imageReader = ImageReader.newInstance( + maxSize.width, maxSize.height, ImageFormat.YUV_420_888, 2 + ) + + 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 { + // Phase 1: プレビューを流して AE/AF を安定させる + val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { + addTarget(imageReader.surface) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + } + session.setRepeatingRequest(previewRequest.build(), null, backgroundHandler) + + // Phase 2: 1 秒後にリスナーを設定してから本番キャプチャ + backgroundHandler?.postDelayed({ + try { + session.stopRepeating() + + // プレビュー中のバッファを捨てる + while (true) { + val stale = imageReader.acquireLatestImage() + if (stale != null) { + stale.close() + } else { + break + } + } + + // ここでリスナーを設定(本番フレームのみ受信) + imageReader.setOnImageAvailableListener({ reader -> + if (resultSent) return@setOnImageAvailableListener + val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener + resultSent = true + + try { + val yPlane = image.planes[0] + val uPlane = image.planes[1] + val vPlane = image.planes[2] + + val width = image.width + val height = image.height + + val yBytes = ByteArray(yPlane.buffer.remaining()) + val uBytes = ByteArray(uPlane.buffer.remaining()) + val vBytes = ByteArray(vPlane.buffer.remaining()) + + yPlane.buffer.get(yBytes) + uPlane.buffer.get(uBytes) + vPlane.buffer.get(vBytes) + + val yRowStride = yPlane.rowStride + val uvRowStride = uPlane.rowStride + val uvPixelStride = uPlane.pixelStride + + image.close() + + onFrame( + yBytes, uBytes, vBytes, + width, height, + yRowStride, uvRowStride, uvPixelStride, + reader, camera, result + ) + } catch (e: Exception) { + image.close() + reader.close() + cleanup() + Handler(context.mainLooper).post { + result.error("PROCESS_ERROR", "画像データの処理に失敗: ${e.message}", null) + } + } + }, backgroundHandler) + + // 本番キャプチャ実行 + val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { + addTarget(imageReader.surface) + set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + } + session.capture(captureRequest.build(), null, backgroundHandler) + } catch (e: Exception) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) + } + } + }, 1000) + } catch (e: Exception) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null) + } + } + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + cleanup() + imageReader.close() + Handler(context.mainLooper).post { + result.error("SESSION_ERROR", "カメラセッションの設定に失敗", null) + } + } + }, + backgroundHandler + ) + } catch (e: Exception) { + 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() + Handler(context.mainLooper).post { + result.error("CAMERA_ERROR", "カメラエラー: $error", null) + stopBackgroundThread() + } + } + }, backgroundHandler) + } catch (e: SecurityException) { + imageReader.close() + returnError(result, "PERMISSION_ERROR", "カメラの権限がありません") + } + } + + /** + * フロントカメラの 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 + stopBackgroundThread() + } + + private fun returnError(result: MethodChannel.Result, code: String, message: String) { + result.error(code, message, null) + stopBackgroundThread() + } + + private fun startBackgroundThread() { + backgroundThread = HandlerThread("RawCaptureThread").also { it.start() } + backgroundHandler = Handler(backgroundThread!!.looper) + } + + private fun stopBackgroundThread() { + backgroundThread?.quitSafely() + try { + backgroundThread?.join(3000) + } catch (_: InterruptedException) { + } + backgroundThread = null + backgroundHandler = null + } +} diff --git a/android/app/src/main/kotlin/com/example/smtias_capture/YuvJpegConverter.kt b/android/app/src/main/kotlin/com/example/smtias_capture/YuvJpegConverter.kt new file mode 100644 index 0000000..733c2af --- /dev/null +++ b/android/app/src/main/kotlin/com/example/smtias_capture/YuvJpegConverter.kt @@ -0,0 +1,77 @@ +package com.example.smtias_capture + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageFormat +import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.YuvImage +import java.io.ByteArrayOutputStream + +/** + * YUV_420_888 のプレーンデータを NV21 経由で JPEG に変換するユーティリティ. + * + * ライブプレビューのスナップショット保存に使用する(撮影本体の RAW とは独立). + * 回転・左右反転にも対応する. + */ +internal object YuvJpegConverter { + + /** + * YUV_420_888 のプレーン群を JPEG バイト列に変換する. + * + * [rotation] は時計回りの度数,[mirror] が true なら左右反転を適用する. + */ + fun convert( + width: Int, + height: Int, + yPlane: ByteArray, + uPlane: ByteArray, + vPlane: ByteArray, + yRowStride: Int, + uvRowStride: Int, + uvPixelStride: Int, + rotation: Int, + mirror: Boolean, + quality: Int, + ): ByteArray { + // YUV_420_888 → NV21 変換 + val nv21 = ByteArray(width * height * 3 / 2) + + // Y プレーンをコピー + for (row in 0 until height) { + System.arraycopy(yPlane, row * yRowStride, nv21, row * width, width) + } + + // UV プレーンを NV21 形式(VUVU...)にインターリーブ + val uvOffset = width * height + for (row in 0 until height / 2) { + for (col in 0 until width / 2) { + val uvIndex = row * uvRowStride + col * uvPixelStride + nv21[uvOffset + row * width + col * 2] = vPlane[uvIndex] + nv21[uvOffset + row * width + col * 2 + 1] = uPlane[uvIndex] + } + } + + // NV21 → JPEG + val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null) + val jpegStream = ByteArrayOutputStream() + yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, jpegStream) + + // 回転・反転が必要な場合 + return if (rotation != 0 || mirror) { + val bitmap = BitmapFactory.decodeByteArray(jpegStream.toByteArray(), 0, jpegStream.size()) + val matrix = Matrix() + if (rotation != 0) matrix.postRotate(rotation.toFloat()) + if (mirror) matrix.postScale(-1f, 1f) + val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + bitmap.recycle() + + val outStream = ByteArrayOutputStream() + transformed.compress(Bitmap.CompressFormat.JPEG, quality, outStream) + transformed.recycle() + outStream.toByteArray() + } else { + jpegStream.toByteArray() + } + } +} diff --git a/android/app/src/test/kotlin/com/example/smtias_capture/ImageStatisticsTest.kt b/android/app/src/test/kotlin/com/example/smtias_capture/ImageStatisticsTest.kt new file mode 100644 index 0000000..f7085d5 --- /dev/null +++ b/android/app/src/test/kotlin/com/example/smtias_capture/ImageStatisticsTest.kt @@ -0,0 +1,89 @@ +package com.example.smtias_capture + +import java.nio.ShortBuffer +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [ImageStatistics] の数理ロジック(mean/max/p99/飽和率・低露光率)の単体テスト. + * + * 合成した 10-bit BGGR Bayer データを `computeFromShortBuffer` に与えて期待値を検証する. + * 実機・Android Framework に依存しない純粋な JVM テスト(実行: `./gradlew test`). + * + * BGGR 配列の 2×2 ブロック走査: + * (row, col) 偶数行: B G B G ... + * 奇数行: G R G R ... + * 1 ブロック = B=(0,0), G=(0,1), G=(1,0), R=(1,1).G はブロック内 2 サンプルの平均. + */ +class ImageStatisticsTest { + + private fun compute(values: ShortArray, width: Int, height: Int): Map = + ImageStatistics.computeFromShortBuffer( + ShortBuffer.wrap(values), + width, // rowStrideShorts(パディングなし) + width, + height, + ) + + @Test + fun `一様な値の場合 mean と max が一致し飽和・低露光は 0 になる`() { + // 4×4 すべて 500 + val values = ShortArray(16) { 500 } + val result = compute(values, 4, 4) + + val mean = result["mean_per_channel"] as Map<*, *> + assertEquals(500.0, mean["R"] as Double, 1e-9) + assertEquals(500.0, mean["G"] as Double, 1e-9) + assertEquals(500.0, mean["B"] as Double, 1e-9) + + val max = result["max_per_channel"] as Map<*, *> + assertEquals(500, max["R"] as Int) + assertEquals(500, max["G"] as Int) + assertEquals(500, max["B"] as Int) + + assertEquals(0.0, result["saturated_pixel_ratio"] as Double, 1e-9) + assertEquals(0.0, result["underexposed_pixel_ratio"] as Double, 1e-9) + + // p99: 500 は bin 125(500*256/1024)→ (125+1)*1024/256 - 1 = 503 + val p99 = result["p99_per_channel"] as Map<*, *> + assertEquals(503, p99["R"] as Int) + assertEquals(503, p99["G"] as Int) + assertEquals(503, p99["B"] as Int) + } + + @Test + fun `飽和画素と低露光画素がチャネル別 mean・max・比率に正しく反映される`() { + // 4×4.左上ブロックの B=1000(飽和), G の片側=50(低露光).他はすべて 500. + // row0: 1000 50 500 500 + // row1: 500 500 500 500 + // row2: 500 500 500 500 + // row3: 500 500 500 500 + val values = shortArrayOf( + 1000, 50, 500, 500, + 500, 500, 500, 500, + 500, 500, 500, 500, + 500, 500, 500, 500, + ) + val result = compute(values, 4, 4) + + val mean = result["mean_per_channel"] as Map<*, *> + // B: (1000+500+500+500)/4 = 625 + assertEquals(625.0, mean["B"] as Double, 1e-9) + // G: ((50+500)/2 + 500 + 500 + 500)/4 = (275+1500)/4 = 443.75 + assertEquals(443.75, mean["G"] as Double, 1e-9) + // R: すべて 500 + assertEquals(500.0, mean["R"] as Double, 1e-9) + + val max = result["max_per_channel"] as Map<*, *> + assertEquals(1000, max["B"] as Int) + assertEquals(500, max["R"] as Int) + + // 飽和は B=1000 の 1 画素,低露光は G=50 の 1 画素.総 Bayer 画素 = 16. + assertEquals(1.0 / 16.0, result["saturated_pixel_ratio"] as Double, 1e-9) + assertEquals(1.0 / 16.0, result["underexposed_pixel_ratio"] as Double, 1e-9) + + val thresholds = result["thresholds"] as Map<*, *> + assertEquals(1000, thresholds["saturated"] as Int) + assertEquals(100, thresholds["underexposed"] as Int) + } +} diff --git "a/docs/01_GUIDE/GUIDE_06_\343\202\263\343\203\274\343\203\207\343\202\243\343\203\263\343\202\260\350\246\217\347\264\204.md" "b/docs/01_GUIDE/GUIDE_06_\343\202\263\343\203\274\343\203\207\343\202\243\343\203\263\343\202\260\350\246\217\347\264\204.md" index 47a2c96..93ad50a 100644 --- "a/docs/01_GUIDE/GUIDE_06_\343\202\263\343\203\274\343\203\207\343\202\243\343\203\263\343\202\260\350\246\217\347\264\204.md" +++ "b/docs/01_GUIDE/GUIDE_06_\343\202\263\343\203\274\343\203\207\343\202\243\343\203\263\343\202\260\350\246\217\347\264\204.md" @@ -1,6 +1,6 @@ # コーディング規約 (Coding Standards) -本ドキュメントでは,MiniTIAS プロジェクトの Dart / Flutter コーディング規約を定義する. +本ドキュメントでは,SmTIAS-Capture プロジェクトの Dart / Flutter コーディング規約を定義する. 基本方針として [Effective Dart](https://dart.dev/effective-dart) に準拠し,プロジェクト固有のルールのみ本書で定める. ## フォーマット (Formatting) @@ -42,15 +42,15 @@ import 'package:provider/provider.dart'; // 4. プロジェクト内 -import 'package:mini_tias/providers/camera_provider.dart'; -import 'package:mini_tias/services/file_service.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/services/file_service.dart'; ``` ※ `dart fix --apply` および `dart format` で自動整列されるため,手動調整は不要. ## ディレクトリ構造 (Directory Structure) -[SPEC_01_画面機能仕様書](../04_SPEC/SPEC_01_画面機能仕様書.md) のアーキテクチャセクションで定義したディレクトリ構成に従う. +[SPEC_03 アーキテクチャと非機能](../04_SPEC/SPEC_03_アーキテクチャと非機能.md) のアーキテクチャセクションで定義したディレクトリ構成に従う. ```text lib/ diff --git "a/docs/01_GUIDE/GUIDE_07_\343\203\206\343\202\271\343\203\210\346\226\271\351\207\235.md" "b/docs/01_GUIDE/GUIDE_07_\343\203\206\343\202\271\343\203\210\346\226\271\351\207\235.md" index ba62567..cca870c 100644 --- "a/docs/01_GUIDE/GUIDE_07_\343\203\206\343\202\271\343\203\210\346\226\271\351\207\235.md" +++ "b/docs/01_GUIDE/GUIDE_07_\343\203\206\343\202\271\343\203\210\346\226\271\351\207\235.md" @@ -1,12 +1,13 @@ # テスト方針 (Testing Strategy) -本ドキュメントでは,MiniTIAS プロジェクトのテスト方針を定義する. +本ドキュメントでは,SmTIAS-Capture プロジェクトのテスト方針を定義する. ## 基本方針 (Principles) - 初期フェーズではカメラ・ストレージ等のハードウェア依存が大きいため,**実機での手動確認を主体** とする - ハードウェアに依存しないロジック(ファイル命名,重複回避等)には **Unit テスト** を書く - テストは `flutter test` で実行できる状態を維持する +- ネイティブ(Kotlin)コードは原則として実機手動確認に委ねるが,**研究データの正確性に直結する純粋な数理ロジック**(例: 画素統計の mean/max/p99/飽和率)は Android Framework に依存しない形に分離し,**JUnit で単体テスト**する(実機不要・`./gradlew test` で実行) ## テストの種類と対象 (Test Types) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 73163b7..986fdde 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -2,7 +2,7 @@ ## 概要 (Overview) -MiniTIAS は Flutter を用いたクロスプラットフォームモバイルアプリケーションである.初期段階では Android のみを対象とし,将来的に iOS 等への展開を視野に入れる. +SmTIAS-Capture は Flutter を用いたモバイルアプリケーションである.撮影の中核(Camera2 / `DngCreator` / 画素統計)を Android ネイティブで実装しているため,当面は **Android 専用** とする.iOS へ展開する場合は撮影パイプラインの再実装が必要になる. ## 試験端末 (Test Device) @@ -20,27 +20,36 @@ | フレームワーク | Flutter(最新 stable) | 将来的なクロスプラットフォーム対応のため | | 言語 | Dart | Flutter の標準言語 | | 対象 OS | Android | 試験端末が Android のため.初期は Android のみ | -| minSdkVersion | 21(Android 5.0) | Flutter のデフォルト.AQUOS sense3(API 28)で問題なし | +| minSdkVersion | `flutter.minSdkVersion`(`android/app/build.gradle.kts` が参照する Flutter 規定値) | AQUOS sense3(API 28)で動作確認済み | ## 主要パッケージ (Key Packages) | パッケージ | 用途 | 備考 | | --- | --- | --- | -| `camera` | インカメラのライブプレビュー・撮影 | Flutter 公式プラグイン | -| `image` | 撮影画像の PNG エンコード | YUV → RGB → PNG 変換に使用 | -| `path_provider` | アプリ内ファイルパスの取得 | 保存先ディレクトリの参照に使用 | +| `camera` | インカメラのライブプレビュー | フレーミング用.撮影本体は Camera2 ネイティブ実装 | | `permission_handler` | カメラ・ストレージの権限管理 | Android の実行時権限リクエスト | | `provider` | 状態管理 | Flutter 公式推奨.シンプルかつ拡張しやすい | | `screen_brightness` | 画面の輝度制御 | アタッチメント装着時の明るさ調整に使用 | +| `shared_preferences` | 設定の永続化 | シャッター音 ON/OFF などの保存に使用 | ※ パッケージバージョンは環境構築時に最新 stable を採用し,`pubspec.yaml` で管理する. +### ネイティブ実装(Android / Kotlin) + +撮影・画像変換・カメラ診断は Dart パッケージではなく Android ネイティブで実装する(`RawCapturePlugin.kt`,MethodChannel 経由). + +| 処理 | 実装 | +| --- | --- | +| 定量モード撮影(RAW) | Camera2 API フルマニュアル制御 + `DngCreator` で DNG 出力 | +| 画像変換 | YUV→JPEG(プレビュー保存),YUV→非圧縮 PNG(旧通常モード) | +| 画素統計・Hot pixel map | RAW から Kotlin で算出し meta.json に格納 | +| カメラ診断 | `CameraCharacteristics` を読み出して JSON 化 | + +> 当初は `image` パッケージで PNG エンコードしていたが,速度の問題からネイティブへ移行した(経緯は [docs/PROGRESS.md](../PROGRESS.md) を参照). + ### 共有ストレージ保存 (Shared Storage) -画像の保存先は `Pictures/MiniTIAS/`(Android 共有ストレージ)とする.Android 10 以降の Scoped Storage 制約により,`path_provider` では共有ストレージに直接書き込めない.保存方法は以下のいずれかを環境構築時に検証・確定する. - -- `saver_gallery` 等の既存パッケージを利用 -- Platform Channels で Android の MediaStore API を直接呼び出し +画像の保存先は `Pictures/MiniTIAS/`(Android 共有ストレージ)とする.DNG をネイティブ側で直接書き込み(`FileOutputStream`),保存後に `MediaScannerConnection.scanFile`(MethodChannel `scanFile`)で MediaStore に登録する方式を採用した.大容量 DNG の MethodChannel 転送を避けるため,ファイル書き込みは Kotlin 側で完結させ,Dart へはパスのみ返す. ### 状態管理 (State Management) @@ -55,8 +64,8 @@ | 処理 | 実行場所 | 備考 | | --- | --- | --- | | 撮影・ライブプレビュー | スマホ(Flutter アプリ) | インカメラ使用 | -| 画像保存(PNG) | スマホ(Flutter アプリ) | 共有ストレージに保存 | -| データ転送 | USB 接続 | PC からフォルダを直接参照 | +| 画像保存(DNG + meta.json + preview.jpg) | スマホ(ネイティブ Camera2) | 共有ストレージに保存 | +| データ転送 | USB 接続(adb pull 推奨) | 大容量 DNG は MTP で不整合リスクあり | | キャリブレーション | PC | カラーバーを用いた色補正 | | 色変換・解析 | PC | L\*a\*b\* 変換,色差計算等 | diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 29a8bdd..92e849d 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -28,8 +28,8 @@ ### リポジトリの取得 (Clone Repository) ```bash -git clone https://github.com//MiniTIAS.git -cd MiniTIAS +git clone https://github.com//SmTIAS-Capture.git +cd SmTIAS-Capture ``` ※ Git が未インストールの場合は `scoop install git` を先に実行する. diff --git "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 8c952f4..6e057d1 100644 --- "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -5,7 +5,7 @@ ## GitHub リポジトリの作成 (Create GitHub Repository) ```bash -gh repo create MiniTIAS --private +gh repo create SmTIAS-Capture --private ``` ※ GitHub CLI(`gh`)が未認証の場合は `gh auth login` を先に実行する. @@ -17,55 +17,21 @@ ### ディレクトリ構成 ```text -MiniTIAS/ +SmTIAS-Capture/ ├── scripts/ │ └── setup.ps1 ``` -### `scripts/setup.ps1` の内容 +### `scripts/setup.ps1` -```powershell -# MiniTIAS 開発環境セットアップスクリプト -# 使用方法: .\scripts\setup.ps1 +セットアップスクリプト本体は `scripts/setup.ps1` にコミット済み.二重管理による乖離を避けるため,本書にはコード全文を載せない(変更はスクリプト側を正とする).スクリプトは以下を自動実行する. -Write-Host "=== MiniTIAS Setup ===" -ForegroundColor Cyan +- Scoop の `extras` バケット追加 +- Flutter SDK(`scoop install flutter`) +- Android Studio(`scoop install extras/android-studio`) +- GitHub CLI(`scoop install gh`) -# Scoop の確認 -if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { - Write-Host "[ERROR] Scoop がインストールされていません." -ForegroundColor Red - Write-Host "以下を実行してください:" -ForegroundColor Yellow - Write-Host " Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser" - Write-Host " Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression" - exit 1 -} - -# extras バケットの追加 -Write-Host "[1/4] Scoop extras バケットを追加..." -ForegroundColor Green -scoop bucket add extras 2>$null - -# Flutter SDK -Write-Host "[2/4] Flutter SDK をインストール..." -ForegroundColor Green -scoop install flutter - -# Android Studio -Write-Host "[3/4] Android Studio をインストール..." -ForegroundColor Green -scoop install extras/android-studio - -# GitHub CLI -Write-Host "[4/4] GitHub CLI をインストール..." -ForegroundColor Green -scoop install gh - -Write-Host "" -Write-Host "=== インストール完了 ===" -ForegroundColor Cyan -Write-Host "" -Write-Host "次の手順:" -ForegroundColor Yellow -Write-Host " 1. Android Studio を起動してセットアップウィザードを完了する" -Write-Host " 2. flutter doctor --android-licenses を実行する" -Write-Host " 3. flutter doctor で環境を確認する" -Write-Host " 4. gh auth login で GitHub にログインする" -Write-Host "" -Write-Host "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照してください." -``` +実行後の次手順(Android Studio セットアップウィザード → `flutter doctor --android-licenses` → `flutter doctor` → `gh auth login`)はスクリプト末尾にも表示される.詳細は [ENV_02_環境構築手順.md](ENV_02_環境構築手順.md) を参照. ## VS Code 設定ファイルの作成 (Create VS Code Config) @@ -107,7 +73,7 @@ ## Flutter プロジェクトの作成 (Create Flutter Project) ```bash -flutter create --org com.example mini_tias +flutter create --org com.example smtias_capture ``` ※ パッケージ名・組織名は正式に決定してから実行する. 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 2ce1550..2e6dd8b 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" @@ -4,7 +4,7 @@ ### プロジェクト名 -MiniTIAS(ミニティアス) +SmTIAS-Capture(SmTIAS 撮影アプリ) ### 背景 (Background) @@ -12,7 +12,7 @@ ### 目的 (Objective) -TIAS のスマートフォン版(MiniTIAS)を開発し,舌画像撮影の小型化・簡便化を実現する.研究の初期段階として,既存 TIAS と MiniTIAS で同一対象を撮影し,画像品質の差を定量的に検証する.その結果をもとに差を埋める改善を行う. +TIAS のスマートフォン版(SmTIAS-Capture)を開発し,舌画像撮影の小型化・簡便化を実現する.研究の初期段階として,既存 TIAS と SmTIAS-Capture で同一対象を撮影し,画像品質の差を定量的に検証する.その結果をもとに差を埋める改善を行う. ### 対象ユーザー (Target Users) @@ -27,7 +27,7 @@ ### やること (In Scope) - スマートフォンのインカメラを用いた舌画像の撮影 -- 撮影画像の PNG(非圧縮)保存 +- 撮影画像の DNG(RAW_SENSOR 10-bit Bayer)定量モード保存(メタデータ JSON・プレビュー JPEG を併せて出力) - 撮影済み画像の一覧表示・確認・削除 - USB 経由で PC へのデータ転送を前提としたファイル管理 @@ -74,7 +74,7 @@ | 対象 OS | Android | | 開発言語 | Dart | -※ 将来的には端末に依存しないクロスプラットフォーム対応を目指す. +※ 撮影の中核を Android ネイティブ(Camera2 / `DngCreator`)で実装しているため,当面は Android 専用とする.iOS 等へ展開する場合は撮影パイプラインの再実装が必要になる. ### 画面構成 (Screen Layout) @@ -105,8 +105,16 @@ | 削除 | `.preview.jpg` 削除時は同 baseName の `.dng` / `.meta.json` も連動削除 | | 旧 PNG 形式 | `MiniTIAS_YYYYMMDD_HHmmss.png`(コードは温存,現状の `takePicture` からは呼ばれない) | +### 実装済みの主な追加機能 (Implemented Beyond Initial Scope) + +初期スコープ(撮影・保存・一覧)以降に追加された主な機能. + +- 定量モード撮影(Camera2 マニュアル制御・DNG・LSC マップ・画素統計・Hot pixel map) +- タイマー撮影(3 秒カウントダウン) +- シャッター音・保存完了音の ON/OFF 切替 +- カメラ診断(実機 Camera2 能力の取得.インフラのみ・UI 非表示) + ### 将来的な拡張 (Future Enhancements) -- タイマー撮影機能 +- 連射撮影と再現性検証(フェーズ B) - AR マーカー検出(撮影時のリアルタイムバリデーション) -- 端末非依存のクロスプラットフォーム対応 diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index 54482b7..3dad683 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -1,8 +1,15 @@ # 開発ステップ (Development Steps) -[SPEC_01_画面機能仕様書](../04_SPEC/SPEC_01_画面機能仕様書.md) の仕様をもとに,実装の順序とステップを定義する. +[04_SPEC の仕様書群(SPEC_01 画面 / SPEC_02 カメラ撮影 / SPEC_03 設計・非機能)](../04_SPEC/SPEC_01_画面機能仕様書.md) をもとに,実装の順序とステップを定義する. 各ステップは 1 つのブランチ・PR に対応させる. +> **本書は初期フェーズ(Step 1〜5)の開発計画である.** 当時は撮影フォーマットとして +> PNG(非圧縮)を前提としていた.その後,撮影高速化のネイティブ化 → カメラ診断 → +> **DNG 定量モードへの転換** → プレビュー JPEG → 画素統計,と大きく展開している. +> 定量モード転換以降の実装の経緯・設計判断は [PROGRESS.md](../PROGRESS.md) に, +> 最新の確定仕様は 04_SPEC([SPEC_01 画面](../04_SPEC/SPEC_01_画面機能仕様書.md) / [SPEC_02 カメラ撮影](../04_SPEC/SPEC_02_カメラ撮影仕様書.md) / [SPEC_03 設計・非機能](../04_SPEC/SPEC_03_アーキテクチャと非機能.md))に記録している. +> 以下の各ステップ(Step 3 の PNG 保存など)は**当時の計画**であり,現行仕様とは異なる点がある. + ## ステップ一覧 (Overview) | # | ステップ | 概要 | 依存 | @@ -104,6 +111,7 @@ - `providers/gallery_provider.dart` — 画像一覧の取得・キャッシュ・削除操作 - `widgets/image_grid.dart` — 3 列サムネイルグリッド - `widgets/image_detail_dialog.dart` — 拡大表示ダイアログ(ピンチズーム,削除ボタン) + ※ 実際には独立ダイアログではなく,一覧画面内のインライン `Stack` オーバーレイとして実装した(このファイルは存在しない.SPEC_01 参照) - `screens/gallery_screen.dart` — グリッド・ダイアログの組み込み,空状態メッセージ ### テスト 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 edaa6a8..f10978e 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" @@ -1,6 +1,9 @@ -# 画面機能仕様書 (Screen & Function Specification) +# 画面機能仕様書 (Screen Specification) -本ドキュメントでは,MiniTIAS アプリの画面設計,画面遷移,カメラ制御,ファイル管理,およびアーキテクチャを定義する. +本ドキュメントでは,SmTIAS-Capture アプリの画面設計・画面遷移・各画面の UI と動作・パーミッションを定義する. + +- カメラ制御・撮影フロー・ファイル管理は [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 +- アーキテクチャ・非機能要件は [SPEC_03 アーキテクチャと非機能](SPEC_03_アーキテクチャと非機能.md) を参照 ## 画面一覧 (Screen List) @@ -31,7 +34,7 @@ ``` - アプリ起動時のデフォルト画面は **撮影画面(S-01)** とする -- 画面切り替えには `BottomNavigationBar` を使用する(タブ 2 つ: 撮影 / 一覧) +- 画面切り替えには `home_screen.dart` の `BottomNavigationBar` を使用する(タブ 2 つ: 撮影 / 一覧) - 画像の詳細表示は一覧画面内の `Stack` オーバーレイで実装し,独立画面・ダイアログとしない ## 撮影画面 (S-01: Capture Screen) @@ -64,7 +67,8 @@ | カメラプレビュー | インカメラのライブ映像.鏡像(左右反転)+上下反転で表示.画面いっぱいに拡大し中央をクロップ | | シャッターボタン | 丸型ボタン(画面下部中央).タップで撮影実行.連続撮影可 | | タイマーボタン | 画面左下に配置.ON(黄色)/ OFF(グレー)を切り替える.ON 時はシャッター押下後 3 秒のカウントダウン表示の後に撮影 | -| 明るさスライダー | ナビバーの直下に配置.画面の輝度を手動で調整する(デフォルト: 0.8) | +| サウンドボタン | 画面右下に配置.シャッター音・保存完了音の ON(黄色)/ OFF(グレー)を切り替える.設定は次回起動時も保持(`shared_preferences`) | +| 明るさスライダー | ナビバーの直下に配置.画面の輝度を手動で調整する(デフォルト: 0.8.詳細は SPEC_03 非機能要件) | | BottomNavigationBar | 撮影タブ(アクティブ)/ 一覧タブ.背景色は黒系で統一.保存中はタップ無効 | ### 動作仕様 @@ -77,6 +81,8 @@ - **フィードバック**: 撮影成功時にスナックバーで「保存しました: [ファイル名]」を表示する - **エラー時**: カメラ初期化失敗やストレージ書き込み失敗時は,エラーメッセージをスナックバーで表示する +> 撮影の内部処理(Camera2 マニュアル制御・撮影フロー・保存ファイル)は [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照. + ## 一覧画面 (S-02: Gallery Screen) ### レイアウト @@ -123,167 +129,12 @@ ### 動作仕様 -- **画面表示時**: `Pictures/MiniTIAS/` ディレクトリ内の PNG ファイルを走査し,一覧を構築する +- **画面表示時**: `Pictures/MiniTIAS/` 内のプレビュー JPEG(`.preview.jpg`)と旧通常モードの `.png` を走査し,一覧を構築する(DNG 本体は一覧では直接表示しない) - **リフレッシュ**: 撮影画面から戻った際に一覧を再取得する - **詳細表示**: サムネイルタップで詳細表示オーバーレイを開く.× ボタンで閉じる -- **削除**: 詳細表示の削除ボタンから確認ダイアログで「削除」を選択すると,ファイルをストレージから削除し,一覧から除去する +- **削除**: 詳細表示の削除ボタンから確認ダイアログで「削除」を選択すると,ファイルをストレージから削除し,一覧から除去する(連動削除の詳細は SPEC_02 ファイル管理仕様) - **空状態**: 画像が 0 件の場合は「撮影した画像がありません」のメッセージを表示する -## カメラ制御仕様 (Camera Control) - -### プレビュー設定(フレーミング用) - -| 項目 | 値 | -| --- | --- | -| 使用カメラ | フロントカメラ(インカメラ) | -| 解像度 | `ResolutionPreset.max` | -| 制御方式 | `camera` パッケージ(オート任せ) | -| フラッシュ | OFF(LED ライトはアタッチメント側で制御) | - -プレビューは見やすさ重視.撮影時は別の Camera2 セッションが走る. - -### 撮影設定(定量モード.Camera2 API マニュアル制御) - -| 項目 | 値 | -| --- | --- | -| 使用カメラ | フロントカメラ(インカメラ) | -| 解像度 | 3264×2448(センサ最大) | -| 画像フォーマット | `RAW_SENSOR`(10-bit BGGR Bayer).`DngCreator` で DNG ファイル出力 | -| `CONTROL_MODE` | OFF(マニュアル全制御) | -| `CONTROL_AE_MODE` | OFF | -| `SENSOR_EXPOSURE_TIME` | 8,333,333 ns(1/120 s)固定(アタッチメント + LED 環境で線形領域中央狙い.TECH_04 目標 mean G = 400〜600 達成済み) | -| `SENSOR_SENSITIVITY` | 40(センサ最低 ISO)固定 | -| `SENSOR_FRAME_DURATION` | 33,333,333 ns(30fps 相当) | -| `BLACK_LEVEL_LOCK` | true | -| `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` フローからは呼ばれない.差分比較やフォールバック用に保持. - -### プレビュー表示 - -- `camera` パッケージの `CameraPreview` ウィジェットを使用する -- UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する -- カメラ映像に `Matrix4.diagonal3Values(-scale, -scale, 1.0)` を適用する - - X 軸反転: 鏡像表示(操作者が自然に見える向き) - - Y 軸反転: 端末が逆さのため上下を補正 -- プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) - -### 撮影フロー(定量モード) - -```text -シャッターボタンタップ - │ - ▼ -プレビュー停止(CameraController.dispose()) - │ - ▼ -baseName 生成(MiniTIAS_QM_YYYYMMDD_HHmmss.重複時 _1, _2 ...) - │ - ▼ -Camera2 API でフロントカメラを開く(RAW_SENSOR,3264×2448) - │ - ▼ -上記マニュアル制御パラメータを CaptureRequest に適用 - │ - ▼ -連続 3 枚キャプチャ(設定反映のため最初の 2 枚は破棄) - │ - ▼ -3 枚目の Image + TotalCaptureResult を取得 - │ - ▼ -DngCreator で DNG ファイルを直接書き込み(FileOutputStream) - → Pictures/MiniTIAS/{baseName}.dng(約 16 MB) - │ - ▼ -LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) - │ - ▼ -メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap / image_statistics / hot_pixel_map) - │ - ▼ -Dart 側で JSON 保存 - → Pictures/MiniTIAS/{baseName}.meta.json - │ - ▼ -[並行] シャッター押下時に取得済みのライブプレビュースナップショット JPEG を保存 - → Pictures/MiniTIAS/{baseName}.preview.jpg - ※ スナップショット取得失敗時は preview.jpg を保存しない(撮影全体は失敗扱いにしない) - │ - ▼ -MediaStore 通知(DNG / meta.json / preview.jpg すべて scanFile) - │ - ▼ -Camera2 を閉じ,プレビューを再開 - │ - ▼ -スナックバーで保存完了を通知 -``` - -### ライフサイクル管理 - -- `WidgetsBindingObserver` を使用し,アプリのライフサイクルを監視する -- **バックグラウンド遷移時**(`paused`): カメラリソースを解放する(`CameraController.dispose()`) -- **フォアグラウンド復帰時**(`resumed`): カメラを再初期化する -- 画面遷移でカメラを使用しない画面に移動した場合もカメラリソースを解放する - -## ファイル管理仕様 (File Management) - -### 保存先 - -- パス: `Pictures/MiniTIAS/`(Android 共有ストレージ) -- Android 10 以上では MediaStore API を使用する(`saver_gallery` パッケージで抽象化) -- ディレクトリが存在しない場合は自動作成する - -### ファイル命名規則 - -撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): - -| 種別 | ファイル名 | 概要 | -| --- | --- | --- | -| RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(10-bit BGGR Bayer,約 16 MB) | -| メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | -| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示用の縮小プレビュー.シャッター瞬間のライブプレビュー(自動 ISP 処理済み YUV→JPEG)をそのまま保存.解像度はプレビューストリーム依存(おそらく 1280×720 程度) | - -- タイムスタンプはデバイスのローカル時刻を使用する -- 旧通常モードの `MiniTIAS_YYYYMMDD_HHmmss.png` は引き続き一覧に表示可能(後方互換).新規撮影は QM 形式で行われる - -### 重複回避ロジック - -```text -ファイル名候補 = "MiniTIAS_{timestamp}.png" - -IF ファイル名候補が存在しない: - RETURN ファイル名候補 - -suffix = 1 -WHILE "MiniTIAS_{timestamp}_{suffix}.png" が存在する: - suffix += 1 - -RETURN "MiniTIAS_{timestamp}_{suffix}.png" -``` - -### 削除 - -- 一覧画面の詳細表示オーバーレイから削除を実行する -- 削除前に確認ダイアログを表示する - - `.preview.jpg` の場合:「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 - - `.png`(旧通常モード)の場合:「この画像を削除しますか?」 -- `.preview.jpg` 削除時は同 baseName の `.dng` と `.meta.json` も**連動削除**する -- ファイルシステムから物理削除する(ゴミ箱機能なし) -- 削除後,MediaStore からも当該エントリを除去する - ## パーミッション管理 (Permission Handling) ### 必要なパーミッション @@ -317,81 +168,3 @@ - ストレージ権限は撮影時・一覧表示時にそれぞれ確認する - `permission_handler` パッケージを使用する - -## アーキテクチャ (Architecture) - -### 状態管理 - -- `provider` パッケージを使用する -- 画面ごとに ChangeNotifier を作成し,ビジネスロジックを UI から分離する - -### Provider 構成 - -| Provider | 責務 | -| --- | --- | -| `CameraProvider` | カメラの初期化・プレビュー制御・撮影実行・ライフサイクル管理 | -| `GalleryProvider` | 画像一覧の取得・キャッシュ・削除操作 | - -### ディレクトリ構成 - -```text -lib/ -├── main.dart # アプリエントリポイント,Provider 登録 -├── app.dart # MaterialApp 定義,テーマ設定 -├── providers/ -│ ├── camera_provider.dart # カメラ制御の状態管理 -│ └── gallery_provider.dart # 画像一覧の状態管理 -├── screens/ -│ ├── capture_screen.dart # 撮影画面 (S-01) -│ └── gallery_screen.dart # 一覧画面 (S-02) -├── widgets/ -│ ├── camera_preview.dart # カメラプレビューウィジェット -│ ├── shutter_button.dart # シャッターボタン -│ └── image_grid.dart # サムネイルグリッド -└── services/ - ├── file_service.dart # ファイル保存・命名・削除 - └── permission_service.dart # パーミッション管理 -``` - -### レイヤー構成 - -```text -┌─────────────────────────────────┐ -│ Screens(画面) │ UI 層: ウィジェット構築のみ -├─────────────────────────────────┤ -│ Widgets(部品) │ 再利用可能な UI コンポーネント -├─────────────────────────────────┤ -│ Providers(状態管理) │ ビジネスロジック・状態保持 -├─────────────────────────────────┤ -│ Services(サービス) │ 外部リソースとのインタフェース -└─────────────────────────────────┘ -``` - -- **Screens** は Provider を `context.watch` / `context.read` で参照し,直接 Service を呼ばない -- **Providers** は Service を呼び出し,結果を状態として保持する -- **Services** はプラットフォーム API やファイルシステムとの橋渡しを行う - -## 非機能要件 (Non-functional Requirements) - -### 画面輝度 - -- アプリ起動時に画面の輝度をデフォルト値(0.8)に設定する -- ユーザーが明るさスライダーで任意に調整できる -- アプリ終了時にシステムの輝度設定に復帰する -- アタッチメント装着時に光センサーで画面が暗くなることへの対策 - -### パフォーマンス - -- 撮影から保存完了まで 2 秒以内を目標とする -- 一覧画面のサムネイル読み込みは非同期で行い,画面表示をブロックしない - -### データ保全 - -- 撮影データは端末ローカルにのみ保存する(クラウド同期なし) -- PNG(非圧縮)形式で保存し,画質劣化を防ぐ - -### 対応端末 - -- 試験端末: AQUOS sense3(SH-02M,Android 9) -- minSdkVersion: 21(Android 5.0) -- targetSdkVersion: Flutter デフォルト(最新安定版に追従) diff --git "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" new file mode 100644 index 0000000..a649254 --- /dev/null +++ "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" @@ -0,0 +1,164 @@ +# カメラ・撮影仕様書 (Camera & Capture Specification) + +本ドキュメントでは,SmTIAS-Capture のカメラ制御・撮影フロー・保存ファイルの仕様を定義する. + +- 画面・UI・パーミッションは [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 +- アーキテクチャ・非機能要件は [SPEC_03 アーキテクチャと非機能](SPEC_03_アーキテクチャと非機能.md) を参照 +- 定量モードの設計経緯・合意(SHADING_MODE 等の根拠)は [05_TECH](../05_TECH/TECH_01_定量モード設計の経緯と合意.md) を参照 + +## カメラ制御仕様 (Camera Control) + +### プレビュー設定(フレーミング用) + +| 項目 | 値 | +| --- | --- | +| 使用カメラ | フロントカメラ(インカメラ) | +| 解像度 | `ResolutionPreset.max` | +| 制御方式 | `camera` パッケージ(オート任せ) | +| フラッシュ | OFF(LED ライトはアタッチメント側で制御) | + +プレビューは見やすさ重視.撮影時は別の Camera2 セッションが走る. + +### 撮影設定(定量モード.Camera2 API マニュアル制御) + +| 項目 | 値 | +| --- | --- | +| 使用カメラ | フロントカメラ(インカメラ) | +| 解像度 | 3264×2448(センサ最大) | +| 画像フォーマット | `RAW_SENSOR`(10-bit BGGR Bayer).`DngCreator` で DNG ファイル出力 | +| `CONTROL_MODE` | OFF(マニュアル全制御) | +| `CONTROL_AE_MODE` | OFF | +| `SENSOR_EXPOSURE_TIME` | 8,333,333 ns(1/120 s)固定(アタッチメント + LED 環境で線形領域中央狙い.定量モード合意の目標 mean G = 400〜600 達成済み.05_TECH 参照) | +| `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` フローからは呼ばれない.差分比較やフォールバック用に保持. + +### プレビュー表示 + +- `camera` パッケージの `CameraPreview` ウィジェットを使用する +- UI 全体を `Transform.rotate(angle: pi)` で 180° 回転する +- カメラ映像に `Matrix4.diagonal3Values(-scale, -scale, 1.0)` を適用する + - X 軸反転: 鏡像表示(操作者が自然に見える向き) + - Y 軸反転: 端末が逆さのため上下を補正 +- プレビューは画面いっぱいに拡大し,はみ出す部分はクロップする(`ClipRect` + スケール計算) + +### 撮影フロー(定量モード) + +```text +シャッターボタンタップ + │ + ▼ +プレビュー停止(CameraController.dispose()) + │ + ▼ +baseName 生成(MiniTIAS_QM_YYYYMMDD_HHmmss.重複時 _1, _2 ...) + │ + ▼ +Camera2 API でフロントカメラを開く(RAW_SENSOR,3264×2448) + │ + ▼ +上記マニュアル制御パラメータを CaptureRequest に適用 + │ + ▼ +連続 3 枚キャプチャ(設定反映のため最初の 2 枚は破棄) + │ + ▼ +3 枚目の Image + TotalCaptureResult を取得 + │ + ▼ +DngCreator で DNG ファイルを直接書き込み(FileOutputStream) + → Pictures/MiniTIAS/{baseName}.dng(約 16 MB) + │ + ▼ +LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) + │ + ▼ +メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap / image_statistics / hot_pixel_map) + │ + ▼ +Dart 側で JSON 保存 + → Pictures/MiniTIAS/{baseName}.meta.json + │ + ▼ +[並行] シャッター押下時に取得済みのライブプレビュースナップショット JPEG を保存 + → Pictures/MiniTIAS/{baseName}.preview.jpg + ※ スナップショット取得失敗時は preview.jpg を保存しない(撮影全体は失敗扱いにしない) + │ + ▼ +MediaStore 通知(DNG / meta.json / preview.jpg すべて scanFile) + │ + ▼ +Camera2 を閉じ,プレビューを再開 + │ + ▼ +スナックバーで保存完了を通知 +``` + +### ライフサイクル管理 + +- `WidgetsBindingObserver` を使用し,アプリのライフサイクルを監視する +- **バックグラウンド遷移時**(`paused`): カメラリソースを解放する(`CameraController.dispose()`) +- **フォアグラウンド復帰時**(`resumed`): カメラを再初期化する +- 画面遷移でカメラを使用しない画面に移動した場合もカメラリソースを解放する + +## ファイル管理仕様 (File Management) + +### 保存先 + +- パス: `Pictures/MiniTIAS/`(Android 共有ストレージ) +- ネイティブ側で共有ストレージに直接書き込み,保存後に `MediaScannerConnection.scanFile`(MethodChannel `scanFile`)で MediaStore に登録する +- ディレクトリが存在しない場合は自動作成する + +### ファイル命名規則 + +撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): + +| 種別 | ファイル名 | 概要 | +| --- | --- | --- | +| RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(10-bit BGGR Bayer,約 16 MB) | +| メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | +| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示用の縮小プレビュー.シャッター瞬間のライブプレビュー(自動 ISP 処理済み YUV→JPEG)をそのまま保存.解像度はプレビューストリーム依存(おそらく 1280×720 程度) | + +- タイムスタンプはデバイスのローカル時刻を使用する +- 旧通常モードの `MiniTIAS_YYYYMMDD_HHmmss.png` は引き続き一覧に表示可能(後方互換).新規撮影は QM 形式で行われる + +### 重複回避ロジック + +```text +baseName = "MiniTIAS_QM_{timestamp}" + +IF "{baseName}.dng" が存在しない: + RETURN baseName + +suffix = 1 +WHILE "{baseName}_{suffix}.dng" が存在する: + suffix += 1 + +RETURN "{baseName}_{suffix}" +``` + +※ baseName をもとに `.dng` / `.meta.json` / `.preview.jpg` の 3 ファイルを共通名で生成する. + +### 削除 + +- 一覧画面の詳細表示オーバーレイから削除を実行する +- 削除前に確認ダイアログを表示する + - `.preview.jpg` の場合:「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 + - `.png`(旧通常モード)の場合:「この画像を削除しますか?」 +- `.preview.jpg` 削除時は同 baseName の `.dng` と `.meta.json` も**連動削除**する +- ファイルシステムから物理削除する(ゴミ箱機能なし) +- 削除後,MediaStore からも当該エントリを除去する diff --git "a/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" new file mode 100644 index 0000000..5e69362 --- /dev/null +++ "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" @@ -0,0 +1,93 @@ +# アーキテクチャ・非機能仕様書 (Architecture & Non-functional Specification) + +本ドキュメントでは,SmTIAS-Capture のアーキテクチャ(コード構成)と非機能要件を定義する. + +- 画面・UI・パーミッションは [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 +- カメラ制御・撮影フロー・ファイル管理は [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 + +## アーキテクチャ (Architecture) + +### 状態管理 + +- `provider` パッケージを使用する +- 画面ごとに ChangeNotifier を作成し,ビジネスロジックを UI から分離する + +### Provider 構成 + +| Provider | 責務 | +| --- | --- | +| `CameraProvider` | カメラの初期化・プレビュー制御・撮影実行・ライフサイクル管理 | +| `GalleryProvider` | 画像一覧の取得・キャッシュ・削除操作 | +| `DiagnosticsProvider` | カメラ診断の実行・結果保存(インフラのみ・UI 非表示) | + +### ディレクトリ構成 + +```text +lib/ +├── main.dart # アプリエントリポイント,Provider 登録 +├── app.dart # MaterialApp 定義,テーマ設定,180° 回転 +├── providers/ +│ ├── camera_provider.dart # カメラ制御の状態管理 +│ ├── gallery_provider.dart # 画像一覧の状態管理 +│ └── diagnostics_provider.dart # カメラ診断(インフラのみ) +├── screens/ +│ ├── home_screen.dart # BottomNavigationBar による画面切替 +│ ├── capture_screen.dart # 撮影画面 (S-01) +│ └── gallery_screen.dart # 一覧画面 (S-02) +├── widgets/ +│ ├── camera_preview.dart # カメラプレビューウィジェット +│ ├── shutter_button.dart # シャッターボタン +│ └── image_grid.dart # サムネイルグリッド +└── services/ + ├── file_service.dart # ファイル保存・命名・削除 + ├── permission_service.dart # パーミッション管理 + ├── raw_capture_service.dart # ネイティブ撮影(DNG/PNG)・診断の MethodChannel + └── sound_service.dart # シャッター音・保存完了音の制御 +``` + +※ 撮影・画像変換・診断の実処理は Android ネイティブ +`android/.../RawCapturePlugin.kt`(Camera2 / `DngCreator`)が担い, +`raw_capture_service.dart` が MethodChannel で橋渡しする。 + +### レイヤー構成 + +```text +┌─────────────────────────────────┐ +│ Screens(画面) │ UI 層: ウィジェット構築のみ +├─────────────────────────────────┤ +│ Widgets(部品) │ 再利用可能な UI コンポーネント +├─────────────────────────────────┤ +│ Providers(状態管理) │ ビジネスロジック・状態保持 +├─────────────────────────────────┤ +│ Services(サービス) │ 外部リソースとのインタフェース +└─────────────────────────────────┘ +``` + +- **Screens** は Provider を `context.watch` / `context.read` で参照し,直接 Service を呼ばない +- **Providers** は Service を呼び出し,結果を状態として保持する +- **Services** はプラットフォーム API やファイルシステムとの橋渡しを行う + +## 非機能要件 (Non-functional Requirements) + +### 画面輝度 + +- アプリ起動時に画面の輝度をデフォルト値(0.8)に設定する +- ユーザーが明るさスライダーで任意に調整できる +- アプリ終了時にシステムの輝度設定に復帰する +- アタッチメント装着時に光センサーで画面が暗くなることへの対策 + +### パフォーマンス + +- 撮影操作から次の撮影が可能になるまでを,研究運用上ストレスのない時間に収める(旧 PNG 経路は約 2 秒.DNG 定量モードは設定反映のための 3 枚連続キャプチャ + 約 16 MB の DNG 書き込みのぶん長くなる) +- 一覧画面のサムネイル読み込みは非同期で行い,画面表示をブロックしない + +### データ保全 + +- 撮影データは端末ローカルにのみ保存する(クラウド同期なし) +- 定量モードは RAW(DNG,10-bit BGGR Bayer)で保存し,ISP 処理による情報欠落を防ぐ(旧通常モードは非圧縮 PNG) + +### 対応端末 + +- 試験端末: AQUOS sense3(SH-02M,Android 9) +- minSdkVersion: `flutter.minSdkVersion`(`android/app/build.gradle.kts` が参照する Flutter 規定値) +- targetSdkVersion: Flutter デフォルト(最新安定版に追従) diff --git "a/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" new file mode 100644 index 0000000..b91ee8b --- /dev/null +++ "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" @@ -0,0 +1,78 @@ +# 定量モード 設計の経緯と合意 (Quantitative Mode: Rationale & Agreements) + +本書は SmTIAS-LightSim 側と SmTIAS-Capture 側の**往復書簡(旧 TECH_01 要求仕様 / TECH_03 実装報告・相談 / TECH_04 回答)を統合・要約したアーカイブ**である.定量撮影モードがなぜ・どう決まったかの記録に絞る. + +- **現行の確定仕様** → [SPEC_02 カメラ撮影仕様書](../04_SPEC/SPEC_02_カメラ撮影仕様書.md)(画面は [SPEC_01](../04_SPEC/SPEC_01_画面機能仕様書.md)、設計・非機能は [SPEC_03](../04_SPEC/SPEC_03_アーキテクチャと非機能.md)) +- **実装の経緯・設計判断・失敗パターン** → [PROGRESS.md](../PROGRESS.md) +- **実機能力の生データ** → [camera_diagnostics_20260531_161734.json](../06_TEST/camera_diagnostics_20260531_161734.json) + +> 原文の往復書簡には SmTIAS-LightSim リポジトリ側のファイル(検証履歴 TEST_01/03、評価仕様 SPEC_03/04 など。**本リポジトリの SPEC_03 とは別物**)への参照が含まれるが、それらは SmTIAS-LightSim 側に残り本リポジトリには存在しない. + +## 背景:なぜ定量モードが必要だったか + +SmTIAS-LightSim(Mitsuba 3 による光散乱シミュ)と実機撮影を定量比較する研究で、シミュ vs 実機の残差が縮まらなかった。原因は、SmTIAS-Capture の通常撮影(PNG)が **AE/AWB/NR/EDGE/TONEMAP すべて自動**で、同じシーンを撮っても値が揺らいでいたこと。つまり「比較対象の実機データ自体が定量的に不安定」だった。 + +→ Camera2 API を**完全マニュアル制御**し、ISP 介入を排した **RAW(DNG)**で撮影するモードを追加して固定化する、という方針になった。 + +## 実機能力(AQUOS sense3 / SH-02M フロントカメラ) + +実機診断(`getCameraDiagnostics`)の結果、定量モードに必要な能力をすべて満たすことを確認: + +| 項目 | 値 | +|---|---| +| ハードウェアレベル | **LEVEL_3**(最上位) | +| 能力 | MANUAL_SENSOR / MANUAL_POST_PROCESSING / **RAW** 対応 | +| RAW 出力 | 3264×2448、BGGR、10-bit(white level 1023 / black level 64) | +| 露光時間レンジ | 約 1/12530 s 〜 0.87 s | +| ISO レンジ | 40〜2506 | +| AF | **OFF のみ**(固定焦点。近接ピントはアタッチメント光学系前提) | +| LSC マップサイズ | 13×17(実キャプチャ結果から判明) | + +詳細は camera_diagnostics JSON を参照。 + +## 確定した撮影設定(要点) + +全パラメータは [SPEC_02 §撮影設定](../04_SPEC/SPEC_02_カメラ撮影仕様書.md) に記載。要点のみ: + +- `CONTROL_MODE=OFF` で AE/AWB/AF/NR/EDGE をすべて OFF、TONEMAP は線形 +- 露光 **1/120s**・ISO **40** 固定、`BLACK_LEVEL_LOCK=true` +- フォーマットは **RAW_SENSOR → `DngCreator` で DNG 出力** +- 設定反映を確実にするため **3 枚連続キャプチャして最初の 2 枚を破棄**、3 枚目を採用 +- 撮影モード切替トグル UI は採用せず、**定量モードを既定動作**とした(通常 PNG 経路 `captureFullResolutionPng` はコード温存) + +## SHADING_MODE = HIGH_QUALITY の根拠(重要な合意) + +定量モードの核心的な設計判断。観察と物理的整合性から **HIGH_QUALITY を本流で維持**することで合意した。 + +- 実装中の観察:`SHADING_MODE=OFF` だと LSC マップが**全要素 1.0**(Android 仕様)になり情報が取れない。`HIGH_QUALITY` だと ISP が LSC を適用し、**実ゲインマップ**が取得できる。 +- 物理的整合性:SmTIAS-LightSim の Mitsuba **orthographic 出力**は平行光線サンプリングで cos⁴ falloff を持たず、**「ISP が完璧に LSC 補正できた場合の理想値」相当**。これと **HIGH_QUALITY DNG** を直接比較するのが物理的に最も整合する(残差=「実機 LSC 補正の不完全度+シミュ物理モデル誤差」=まさに評価したい本質)。 +- 逆に `OFF`(生 Bayer)を使うとシミュ側に物理 cos⁴ モデル(絞り径・口径食など)を足す必要があり、**自由度が増えて overfitting リスク**が上がる。 +- 結論:**HIGH_QUALITY 維持。SmTIAS-Capture 側の実装変更は不要**。LSC マップは検証用に meta.json へ温存する。 + +### 将来 SHADING_OFF を追加検証する条件(フォールバック C1–C3) + +次のいずれかが起きたら「同一シーンを HQ と OFF で 1 セット撮影」する追加実装を検討する(現時点では未到達・実装不要): + +- **C1**: シミュ vs HQ DNG の残差が再現的に大きい(CoV/勾配残差 1.5× 以上 + 形が物理的に説明不能) +- **C2**: LSC マップが撮影ごとに大きく変動(中央値の変動 > 5%)→ ISP が AWB 連動で動的計算している証拠 +- **C3**: シミュ側モデル精度を上げても残差が物理的に説明できない + +## 露光・ISO の目安(合意値) + +10-bit DNG(黒レベル 64 除去後)で: + +- 白板中央の **mean = 400〜600**(中央値約 500) +- 飽和回避:**p99 < 900**(除去後 836) + +→ 確定値 **1/120s + ISO 40** で mean G ≈ 461・飽和率 0% を達成(基準測定値は PROGRESS.md 参照)。 + +## 作業フェーズ分け(合意) + +「再現性より先に、そもそもシミュと対応が取れるかを 1 ショットで確認する」二段階方式: + +- **フェーズ A(1 ショット対応検証)**: SmTIAS-Capture 側は ①画素統計を meta.json に追加 ②Hot pixel map 取得 ③白板 1 枚を試し撮りして adb pull 転送。SmTIAS 側が DNG/メタ読み込みを実装し 1 ショット突合 → 残差 2.1× がどう変化するか観察。**①②は実装済み**。 +- **フェーズ B(連射 V1 再現性)**: フェーズ A で対応の見通しが立った場合のみ着手。10 連射 UI(`*_burst{0..9}.dng`)+ 再現性検証(mean CoV < 1%)。 + +## データ転送 + +**`adb pull` 推奨**。MTP は禁止しないが、16 MB 級の DNG でコピー直後にサイズが正しく見えない不整合が確認されているため非推奨。 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" deleted file mode 100644 index c4c6e8d..0000000 --- "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" +++ /dev/null @@ -1,267 +0,0 @@ -# 定量撮影モード要求仕様 (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" deleted file mode 100644 index 1770af8..0000000 --- "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" +++ /dev/null @@ -1,254 +0,0 @@ -# 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/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" "b/docs/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" deleted file mode 100644 index f1559fa..0000000 --- "a/docs/05_TECH/TECH_03_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\345\256\237\350\243\205\345\240\261\345\221\212\343\201\250SHADING_MODE\347\233\270\350\253\207.md" +++ /dev/null @@ -1,336 +0,0 @@ -# 定量モード実装報告と SHADING_MODE 相談 (Implementation Report & SHADING_MODE Consultation) - -本ドキュメントは [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) を MiniTIAS リポジトリで実装した結果の **MiniTIAS 側からの報告書** であり,併せて **SmTIAS-LightSim 側との方針確認・相談事項** をまとめたものである. - -## 想定読者 (Intended Reader) - -- **SmTIAS-LightSim 側 Claude セッション**: 本ドキュメントを読んで MiniTIAS 側の実装状況を把握し,未確定事項に対する判断・指示を返す -- **人間レビュー (rinto)**: 両セッションを橋渡しし,研究方針に照らして判断する - -## 概要 (Overview) - -MiniTIAS 側で TECH_01 の要求仕様に基づいた **定量撮影モード(DNG 経路)** の実装が完了した([MiniTias リポジトリ master ブランチ](https://github.com/rintoHasegawa/MiniTIAS) に取り込み済み).本ドキュメントの目的は以下の 2 点: - -1. **実装結果の報告**:TECH_01 の各 Phase に対して何を実装したか,AQUOS sense3 (SH-02M) で何が確認できたかを共有する -2. **SHADING_MODE の扱いの相談**:定量モードのキー設定である `SHADING_MODE` を MiniTIAS 側でどう選んだか,研究上どのような含意があるか,SmTIAS-LightSim 側の解析パイプラインとの整合を確認したい - -## TECH_01 要求に対する実装状況 (Implementation Status) - -| Phase | 要求 | 実装状況 | -| --- | --- | --- | -| Phase 1 | Camera2 詳細制御の MethodChannel 実装(露光・WB 固定,LSC マップ取得) | ✅ 完了 | -| Phase 2 | LSC マップ・メタデータ保存ロジック | ✅ 完了 | -| Phase 3 | 撮影画面 UI に定量モードトグル追加 | ⚠️ 仕様変更(後述).**トグル UI は追加せず通常モードを置換** | -| Phase 4 | ハードウェアサポート判定とユーザー案内 | ✅ 診断機能を `DiagnosticsProvider` として実装(UI 非表示.裏で再利用可能) | -| Phase 5 | RAW_SENSOR (DNG) 対応(任意) | ✅ 完了.第 1 優先経路として採用 | - -### Phase 3 の変更点 - -TECH_01 では「撮影モード切替トグル」で通常モードと定量モードを並存させる想定だったが,MiniTIAS 側の運用判断として **通常モード(PNG 経路)を temporarily に無効化し,シャッターは常に定量モードを呼ぶ**構成にした.既存の `captureFullResolutionPng` コードは温存している. - -理由: - -- MiniTIAS の運用上の存在意義は SmTIAS-LightSim との定量比較のみ -- トグル UI を残すと「誤って通常モードで撮影してしまい後で気付く」事故が起きる -- 研究フェーズ完了後にトグル追加 or 通常モード復活は容易 - -## 実装した撮影設定 (Camera2 CaptureRequest) - -| キー | 設定値 | 備考 | -| --- | --- | --- | -| `CONTROL_MODE` | `OFF` | マニュアル全制御 | -| `CONTROL_AE_MODE` | `OFF` | 自動露光停止 | -| `SENSOR_EXPOSURE_TIME` | `16,666,666 ns`(= 1/60 s) | **ハードコード**.後段で半自動キャリブ機能の追加を検討中 | -| `SENSOR_SENSITIVITY` | `40`(センサ最低 ISO) | 診断で確認したセンサのレンジ最低値 | -| `SENSOR_FRAME_DURATION` | `33,333,333 ns`(30fps 相当) | 露光時間 ≤ frame duration を満たす任意値 | -| `BLACK_LEVEL_LOCK` | `true` | フレーム間で黒レベルのドリフトを抑える | -| `CONTROL_AWB_MODE` | `OFF` | 自動 WB 停止 | -| `COLOR_CORRECTION_MODE` | `TRANSFORM_MATRIX` | マニュアル制御モード | -| `COLOR_CORRECTION_GAINS` | `RggbChannelVector(1.0, 1.0, 1.0, 1.0)` | RAW 出力では Bayer 値に効かないが request 値として明示 | -| `COLOR_CORRECTION_TRANSFORM` | 単位行列(`ColorSpaceTransform`) | 同上 | -| `NOISE_REDUCTION_MODE` | `OFF` | NR による画素値の改変を防ぐ | -| `EDGE_MODE` | `OFF` | エッジ強調を防ぐ | -| `TONEMAP_MODE` | `CONTRAST_CURVE` | カーブは R/G/B すべて線形 `[(0,0),(1,1)]` | -| **`SHADING_MODE`** | **`HIGH_QUALITY`** | ★後述の相談対象.OFF も技術的に可能 | -| `STATISTICS_LENS_SHADING_MAP_MODE` | `ON` | LSC マップを CaptureResult に含める | -| `CONTROL_AF_MODE` | `OFF` | **AQUOS sense3 のフロントカメラは固定焦点で OFF のみサポート** | -| `FLASH_MODE` | `OFF` | LED はアタッチメント側で制御 | -| `CONTROL_VIDEO_STABILIZATION_MODE` | `OFF` | 手ブレ補正による画像ずれを防ぐ | - -## 撮影フロー (Capture Flow) - -```text -シャッター押下 - │ - ▼ -プレビュー停止(camera パッケージの CameraController.dispose) - │ - ▼ -baseName 生成(MiniTIAS_QM_YYYYMMDD_HHmmss.同秒重複時 _1, _2 ...) - │ - ▼ -Camera2 API でフロントカメラを開く -ImageReader(RAW_SENSOR, 3264×2448, maxImages=3) - │ - ▼ -上記マニュアル制御パラメータを CaptureRequest に適用 - │ - ▼ -連続 3 枚キャプチャ -(設定反映確実化のため最初の 2 枚は破棄.3 枚目を本番とする) - │ - ▼ -DngCreator で DNG ファイルを直接書き込み -(FileOutputStream → ${dir}/${baseName}.dng) - │ - ▼ -LSC マップ取得(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP) -メタデータ Map 構築(settings / actual / sensorCharacteristics / lscMap) - │ - ▼ -Dart 側でメタデータ JSON 保存 -(${dir}/${baseName}.meta.json) - │ - ▼ -シャッター瞬間に取得済みのライブプレビュースナップショット JPEG を保存 -(${dir}/${baseName}.preview.jpg.アプリ内目視確認用.解析対象外) - │ - ▼ -MediaStore 通知 → Camera2 解放 → プレビュー再開 -``` - -**従来の「1 秒 AE/AF 安定待ち」は省略**した.AE/AWB/AF 全部 OFF のため安定待ちの意味がないことと,「3 枚連続キャプチャの 2 枚破棄」で設定反映の不確実性を吸収する設計にした. - -## AQUOS sense3 (SH-02M) 実機検証結果 (Device Verification) - -TECH_01 の開放質問 Q1〜Q4 に対する実機での確認結果.診断機能(`getCameraDiagnostics`)の出力は MiniTIAS リポジトリの [`docs/06_TEST/camera_diagnostics_20260531_161734.json`](../06_TEST/camera_diagnostics_20260531_161734.json) に保存している. - -### 主要項目 - -| 項目 | 値 | -| --- | --- | -| `INFO_SUPPORTED_HARDWARE_LEVEL` | **`LEVEL_3`**(最上位) | -| `REQUEST_AVAILABLE_CAPABILITIES` | `BACKWARD_COMPATIBLE`, **`MANUAL_SENSOR`**, **`MANUAL_POST_PROCESSING`**, **`RAW`**, `READ_SENSOR_SETTINGS`, `BURST_CAPTURE`, `PRIVATE_REPROCESSING`, `YUV_REPROCESSING` | -| `SENSOR_INFO_EXPOSURE_TIME_RANGE` | `79,806 ns 〜 871,614,530 ns`(≈ 1/12530 s 〜 0.87 s) | -| `SENSOR_INFO_SENSITIVITY_RANGE` | `40 〜 2506` | -| `RAW_SENSOR` 出力サイズ | `3264 × 2448`(1 種類のみ) | -| `SENSOR_INFO_COLOR_FILTER_ARRANGEMENT` | `3 (BGGR)` | -| `SENSOR_BLACK_LEVEL_PATTERN` | `[[64, 64], [64, 64]]` | -| `SENSOR_INFO_WHITE_LEVEL` | `1023`(10-bit) | -| `SENSOR_INFO_PHYSICAL_SIZE` | `3.558 mm × 2.668 mm` | -| `SENSOR_INFO_PIXEL_ARRAY_SIZE` | `3264 × 2448` | -| `LENS_INFO_MINIMUM_FOCUS_DISTANCE` | `0.0`(無限遠固定) | -| `LENS_INFO_HYPERFOCAL_DISTANCE` | `0.8189`(≈ 1.22 m) | -| `STATISTICS_INFO_AVAILABLE_LENS_SHADING_MAP_MODES` | `[OFF (0), ON (1)]` | -| `CONTROL_AF_AVAILABLE_MODES` | **`[OFF (0)] のみ`**(AF 機能なし) | -| `SHADING_AVAILABLE_MODES` | `[OFF, FAST, HIGH_QUALITY]` | - -### 注目点 - -- **TECH_01 の Phase 1〜5 すべてが実装可能**な能力構成だった -- **AF は OFF のみ**.レンズは固定焦点(無限遠固定)でアタッチメント光学系で近接被写体にピントを合わせている前提 -- LSC マップサイズは実キャプチャ結果から **13 × 17** と判明(特性からは hidden API のため取得不可だった) - -## 保存ファイル仕様 (Output File Specification) - -### ファイル命名規則 - -撮影 1 回で 3 ファイルを生成.basename は共通. - -| 種別 | ファイル名 | 概要 | -| --- | --- | --- | -| RAW 画像 | `MiniTIAS_QM_YYYYMMDD_HHmmss.dng` | 解析の本体(約 16 MB) | -| メタデータ | `MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json` | 撮影設定 + 実適用値 + LSC マップ + センサ特性(約 24 KB) | -| プレビュー | `MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg` | アプリ内表示・確認用.解析対象外 | - -`QM` = Quantitative Mode.同秒重複時は `_1`, `_2` ... と連番を付与. - -### DNG (RAW_SENSOR) - -- フォーマット: TIFF/EP ベースの Adobe DNG(`DngCreator` 経由) -- データ: 10-bit BGGR Bayer,3264×2448 -- Exif メタ: DngCreator が `CameraCharacteristics` から自動で埋め込み(`ColorMatrix1/2`, `ForwardMatrix1/2`, `AsShotNeutral`, `NoiseProfile`, `BlackLevelPattern` 等) - -### meta.json の構造(実例抜粋) - -[`MiniTIAS_QM_20260531_172612.meta.json`](https://github.com/rintoHasegawa/MiniTIAS) から抜粋(白板を SHADING_MODE = HIGH_QUALITY で撮影したもの): - -```json -{ - "captureTimestampUtc": "2026-05-31T08:26:13.235Z", - "deviceModel": "SH-02M", - "androidVersion": "9", - "cameraId": "1", - "hardwareLevel": 3, - "captureMode": "quantitative", - "imageFormat": "RAW_SENSOR", - "imageSize": [3264, 2448], - - "settings": { - "control_mode": "OFF", - "ae_mode": "OFF", - "exposure_time_ns": 16666666, - "sensor_sensitivity_iso": 40, - "frame_duration_ns": 33333333, - "awb_mode": "OFF", - "color_correction_gains": [1.0, 1.0, 1.0, 1.0], - "color_correction_transform": "identity", - "noise_reduction_mode": "OFF", - "edge_mode": "OFF", - "tonemap_mode": "CONTRAST_CURVE_LINEAR", - "shading_mode": "HIGH_QUALITY", - "statistics_lens_shading_map_mode": "ON", - "af_mode": "OFF", - "black_level_lock": true - }, - - "actual": { - "exposure_time_ns": 16653365, - "sensor_sensitivity": 40, - "frame_duration_ns": 33333333, - "sensor_timestamp": 4894940190334, - "dynamic_black_level": null, - "dynamic_white_level": 1023, - "neutral_color_point": ["1/1", "1/1", "1/1"], - "color_gains": [1.0, 1.0, 1.0, 1.0], - "color_transform": [ - ["1/1", "0/1", "0/1"], - ["0/1", "1/1", "0/1"], - ["0/1", "0/1", "1/1"] - ] - }, - - "sensorCharacteristics": { - "color_filter_arrangement": 3, - "black_level_pattern": "BlackLevelPattern([64, 64], [64, 64])", - "white_level": 1023, - "physical_size": [3.557760, 2.668320], - "pixel_array_size": [3264, 2448], - "active_array_size": "Rect(0, 0 - 3264, 2448)" - }, - - "lscMap": [ - [5.105, 4.232, 4.111, 3.847, 4.982, 3.966, 3.916, 3.744, 4.459, 3.503, 3.502, 3.369, 3.860, 3.025, 2.995, 2.905, 3.367, 2.647, 2.595, 2.522, 2.995, 2.393, 2.349, 2.285, 2.765, 2.235, 2.199, 2.133, 2.613, 2.130, 2.093, 2.032, 2.577, 2.087, 2.051, 1.991, 2.610, 2.125, 2.093, 2.022, 2.728, 2.214, 2.187, 2.110, 2.950, 2.365, 2.359, 2.251, 3.294, 2.614, 2.637, 2.500, 3.846, 3.006, 3.042, 2.868, 4.426, 3.456, ...], - ...(行 1〜行 12 続く) - ], - "lscMapRowCount": 13, - "lscMapColumnCount": 17 -} -``` - -#### `actual` フィールドについての注意 - -- `actual` は CaptureResult から読み戻した**実適用値**.Request 値(`settings`)と微妙に異なる場合がある(例:露光時間 16,666,666 → 16,653,365 でセンサが量子化) -- `dynamic_black_level` は `null` で返ることがある(端末依存).代わりに `sensorCharacteristics.black_level_pattern` の静的値を参照可 -- `neutral_color_point` と `color_transform` が単位行列なのは AWB OFF + identity gains 設定の結果 - -#### LSC マップ - -- 形状: 4 ch × N rows × M cols(4 = R / G_even / G_odd / B)の各チャネルゲイン -- 本実装の保存形式: **1 行を 4 × M 個の float が並んだフラット配列**として `lscMap[row]` に格納.行数・列数は `lscMapRowCount` / `lscMapColumnCount` で別途記録 -- 値の意味: `corrected = raw × gain`.中央 ≈ 1.0,周辺で 5× 程度まで上がる典型的な cos⁴ 補正マップになっている -- **ただし `SHADING_MODE = OFF` で撮影した場合は全要素 `1.0` の単位マップが返ってくることを確認済み**(次節で詳述) - -### preview.jpg - -シャッター瞬間のライブプレビュー JPEG をそのまま保存(Camera2 ISP がフル稼働した状態の画像).**解析対象ではない**.撮影者がアプリ内で「撮れた」を判断するためだけのもの.解像度はプレビューストリーム依存(おそらく 1280×720 程度). - -## SHADING_MODE の扱い(★相談事項) (Consultation on SHADING_MODE) - -### 経緯 - -実装初期は **TECH_01 の表に明記されていた `HIGH_QUALITY` を採用**した.しかし実装途中で MiniTIAS 側は以下を観察した: - -1. `SHADING_MODE = OFF` で撮影すると **LSC マップが全要素 `1.0`** になる(Android 仕様:「補正は適用しない + 単位マップを返す」) -2. `SHADING_MODE = HIGH_QUALITY` で撮影すると LSC が **画像に適用された状態**で DNG に保存される.LSC マップは ISP が適用したゲインの実値 - -結果:**HIGH_QUALITY を採用した**現状の DNG は,ISP によって cos⁴ 由来のシェーディングが既に補正された状態のデータになっている. - -### 研究上の含意 - -SmTIAS-LightSim 側の Mitsuba 3 シミュレーションは「物理的な cos⁴ 落ち + LED 配光 + 鏡面反射」を再現するものと理解している.この物理シミュレーション結果と直接比較したいデータは: - -- (A) **LSC 適用済み画像(現状の DNG)**: ISP が「メーカー校正の LSC マップ」を掛けて平坦化したもの.物理的な cos⁴ がほぼ取り除かれている -- (B) **LSC 適用前の生 Bayer**: 物理的 cos⁴ がそのまま残っている.Mitsuba 出力と同じ前提 - -(A) と Mitsuba を直接比較すると **比較対象に LSC マップ分のずれが系統的に乗る**ことになる.これが [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で残差 2.1× が解消しなかった一因の可能性もある(推測). - -### 取りうる選択肢 - -研究の整合のために考えられる方向性を以下に列挙する.**MiniTIAS 側からの判断はせず**,SmTIAS-LightSim 側で解析パイプラインを構築する立場からの選択肢として提示する: - -| 選択肢 | MiniTIAS 側の対応 | SmTIAS-LightSim 側の対応 | データの性質 | -| --- | --- | --- | --- | -| (1) 現状維持(HQ) | 変更なし | `DNG ÷ LSC マップ` を計算して LSC 適用前相当に戻す処理を実装 | DNG は LSC 適用済み.逆適用で物理生 Bayer を再構成 | -| (2) OFF に切替 | `SHADING_MODE = OFF` に変更 | そのまま Mitsuba 出力と比較 | DNG は物理生 Bayer.LSC マップは情報なし(全 1.0) | -| (3) 両方撮影 | 1 シーンに対し HQ と OFF の 2 枚を連続撮影 | 比較・検証で使い分け | 情報量最大.ただし撮影時間と保存容量が 2 倍 | - -#### MiniTIAS 側で対応する場合のコスト感 - -- (1): コスト 0(現状).SmTIAS-LightSim 側で逆適用ロジックが必要 -- (2): Kotlin 側 CaptureRequest を 1 行変更.連動して meta.json の `settings.shading_mode` も `"OFF"` に.約 30 分 -- (3): キャプチャ処理を 2 連続化.ファイル名規則の拡張(例:`*_hq.dng` / `*_off.dng`).約 1 時間 - -### MiniTIAS 側から確認したい事項 - -- SmTIAS-LightSim 側の解析パイプラインで「LSC マップを使った逆適用」は実装済み or 実装可能か? -- 物理シミュレーション(Mitsuba)の出力は「LSC 適用前の生 Bayer 相当」で間違いないか? -- 既存 baseline `MiniTIAS_20260408_144434`(通常モード PNG)との互換性をどう扱うか?(既に方針として「定量モード以降は新規 baseline」となっていれば問題なし) - -選択肢 (1)〜(3) のうちどれを採用するか,または別案があるか,**SmTIAS-LightSim 側の判断を求めたい**.本相談への返答を受けて MiniTIAS 側で必要な実装変更を行う. - -## その他の未確認事項 (Other Open Issues) - -### 露光時間 / ISO の運用 - -現状 `1/60 s + ISO 40` ハードコードで撮影している.照明条件(アタッチメント装着時の LED 強度),被写体(白板 / 舌模型 / 実舌)によって最適値は異なるはず. - -- 白板:飽和しない範囲で最大露光(SN 比最大) -- 舌模型:白板用設定だと暗すぎる可能性 - -MiniTIAS 側で検討中の対応: - -- (a) 半自動キャリブ機能(AE を 1 回走らせて値を読み,以降固定) -- (b) 白板用・舌模型用の 2 プリセット -- (c) ハードコード変更(撮影前に手動で設定) - -SmTIAS-LightSim 側で「白板撮影時の理想的なセンサ平均値」(例:10-bit のうち 500 前後)の目安があれば共有してほしい. - -### V1 連射再現性検証の担当 - -TECH_01 検証 V1(10 連射 CoV < 1%)を実施するには: - -- MiniTIAS 側:連射 UI(10 ショットを連続保存)の実装が必要 -- 解析:DNG を読み込んで mean / std / CoV を計算する Python スクリプトが必要 - -連射機能は MiniTIAS 側で実装予定.解析スクリプトは MiniTIAS 内に `scripts/verify_repeatability.py` 等で置くか,SmTIAS-LightSim 側の sm-evaluate に統合するか方針を相談したい. - -### MTP 経由ファイル転送の注意点 - -DNG が 16 MB 級のため MTP コピー直後にファイルサイズが正しく見えないケースを実装中に確認した.**`adb pull` の使用を推奨**.SmTIAS-LightSim 側のデータ受け取り手順としてもこれを前提にすると安全. - -## MiniTIAS 側で検討中の改善案 (Planned Improvements) - -SmTIAS-LightSim 側との打ち合わせ事項と並行して,以下を MiniTIAS 側で進める予定.優先度は内部判断による: - -| 優先度 | 項目 | 概要 | -| --- | --- | --- | -| 高 | 画素統計を meta.json に追加 | 飽和・露光不足の自動判定用.`mean_per_channel`, `max_per_channel`, `p99`, `saturated_pixel_ratio` 等 | -| 高 | Hot pixel map 取得 | `STATISTICS_HOT_PIXEL_MAP_MODE = ON` で常時光る画素一覧を meta に記録.PC 側でマスク可能に | -| 高 | 連射機能(V1 検証用) | 10 連射 UI.`MiniTIAS_QM_*_burst{N}.dng` 等で連番保存 | -| 中 | 露光時間・ISO の半自動キャリブ機能 | 白板で AE 1 ショット → 値を読んで固定 | -| 中 | レンズ・センサ校正値を meta に追加 | `LENS_INTRINSIC_CALIBRATION`, `LENS_DISTORTION`, `SENSOR_REFERENCE_ILLUMINANT1/2`, `SENSOR_COLOR_TRANSFORM1/2` 等 | -| 低 | Dark frame / Flat field キャリブ機能 | `(対象 − Dark) / (Flat − Dark)` 補正のための基準フレーム撮影 | - -これらは SmTIAS-LightSim 側からの要望順位とすり合わせて優先度を確定したい. - -## 関連ドキュメント (Related Documents) - -- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — 本実装の原始要求 -- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎ時の作業ガイド -- [`docs/06_TEST/camera_diagnostics_20260531_161734.json`](../06_TEST/camera_diagnostics_20260531_161734.json) — AQUOS sense3 (SH-02M) の能力診断結果(フル) -- [SPEC_01 画面機能仕様書](../04_SPEC/SPEC_01_画面機能仕様書.md) — MiniTIAS 側の撮影仕様(定量モード反映済み) -- [PLAN_01 要件定義書](../03_PLAN/PLAN_01_要件定義書.md) — ファイル管理仕様 -- [PROGRESS.md](../PROGRESS.md) — 実装の進捗ログと設計判断・失敗パターン -- [Android Camera2 API SHADING_MODE](https://developer.android.com/reference/android/hardware/camera2/CaptureRequest#SHADING_MODE) -- [Android Camera2 API STATISTICS_LENS_SHADING_CORRECTION_MAP](https://developer.android.com/reference/android/hardware/camera2/CaptureResult#STATISTICS_LENS_SHADING_CORRECTION_MAP) diff --git "a/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" "b/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" deleted file mode 100644 index fdbfcdd..0000000 --- "a/docs/05_TECH/TECH_04_MiniTIAS\343\201\270\343\201\256\345\233\236\347\255\224.md" +++ /dev/null @@ -1,333 +0,0 @@ -# MiniTIAS への回答 (Response to MiniTIAS) - -本ドキュメントは [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) に対する **SmTIAS-LightSim 側からの回答書** である.MiniTIAS 側の実装結果評価・SHADING_MODE 判断・物理解釈の修正・運用上の合意事項を明文化する. - -## 想定読者 (Intended Reader) - -- **MiniTIAS 側 Claude セッション**: 本ドキュメントを読んで,SmTIAS-LightSim 側の判断と次フェーズの依頼内容を把握する. -- **人間レビュー (rinto)**: 両セッションの判断整合を確認する. - -## サマリ (Summary) - -| 項目 | SmTIAS-LightSim 側の判断 | -| --- | --- | -| **SHADING_MODE** | **`HIGH_QUALITY` を本流で維持**.OFF への切替は条件付き将来オプション(後述).MiniTIAS 側の実装変更は **不要** | -| Mitsuba 出力の物理解釈 | 「LSC 適用前の生 Bayer 相当」ではなく **「ISP が完璧に LSC 補正できた場合の理想値」相当**.HIGH_QUALITY DNG と直接比較するのが物理的に整合 | -| LSC 逆適用機能 | SmTIAS-LightSim 側で **検証用ツール**として将来実装.本流の比較では使わない | -| 既存 baseline 互換性 | TECH_02 既決:**新規 baseline で再キャリブ**.既存 `MiniTIAS_20260408_144434` は参考扱い | -| 露光・ISO 目安 | **10-bit のうち中央値 500 前後**(飽和回避のため p99 < 900).現状 1/60s + ISO40 で適切か実機で要確認 | -| **作業フェーズ分け** | **フェーズ A(直近): 1 ショット撮影でシミュとの対応を先に検証** → **フェーズ B(後続): 対応が取れたら連射 UI 実装と V1 再現性検証** | -| V1 連射再現性検証 | **フェーズ B に後回し**.フェーズ A で対応の見通しが立ってから着手.解析は SmTIAS-LightSim 側 sm-evaluate に統合,MiniTIAS 側は連射 UI のみ実装(フェーズ B 時点) | -| データ転送 | **`adb pull` 推奨**.MTP 経由は禁止しないが大容量 DNG で不整合リスクあり | -| 改善案優先度 | フェーズ A 用: **画素統計・Hot pixel map** を高優先で同意.連射 UI は **フェーズ B に後回し**.他はキャリブ進捗を見て判断 | - -→ **フェーズ A** で MiniTIAS 側に追加で依頼するのは **画素統計・Hot pixel map** の 2 件と **白板 1 ショット試し撮り**のみ.連射機能はフェーズ B まで保留. - -## SHADING_MODE 判断の詳細 (SHADING_MODE Decision Rationale) - -### 結論 - -**選択肢 (1) `HIGH_QUALITY` を本流で維持**.MiniTIAS 側の現状実装をそのまま継続する. - -### 物理的整合性の説明 - -TECH_03 §SHADING_MODE 経緯では「Mitsuba 出力は LSC 適用前の生 Bayer 相当か?」という確認があったが,**実はそうではない**.Mitsuba 3 の orthographic sensor は次の特性を持つ: - -| データ | 物理的意味 | cos⁴ falloff | -| --- | --- | --- | -| Mitsuba orthographic 出力 | 各画素位置の **平行光線が交差した点からの放射輝度 L (W/m²/sr)** | **無し**(全画素平行光線のため) | -| 実機センサ生 (SHADING_OFF) | レンズ通過後のセンサ受光量 | **有り**(実物理 cos⁴ で周辺暗) | -| 実機 HIGH_QUALITY DNG | センサ生 × LSC マップ(≥1.0,周辺で大きい) | **理想的にはほぼ補正済み**(残差あり) | - -つまり: - -- **Mitsuba 出力 ≒ 「ISP が完璧に LSC 補正できた場合の理想値」相当** -- これと **HIGH_QUALITY DNG** を直接比較するのが **物理的に最も整合** -- 両者の残差 = 「実機 LSC 補正の不完全度 + シミュ物理モデルの誤差」となり,**これがまさに評価したい本質** - -逆に SHADING_OFF DNG と Mitsuba 出力を直接比較するには Mitsuba 側に物理 cos⁴ falloff を別途モデル化する必要があり,新たな自由度(絞り径・焦点距離・口径食モデル)が入る.**自由度が増えれば overfitting リスクも増える**.現状の方針(HIGH_QUALITY 維持)は overfitting 回避の観点でも妥当. - -### LSC マップの位置付け - -実装した LSC マップ取得機構 (`STATISTICS_LENS_SHADING_MAP_MODE = ON`) は本流の比較では直接使わないが,**以下の検証用途で価値が高い**: - -| 用途 | 説明 | -| --- | --- | -| 個体差検証 | 実機ごと・撮影ごとに LSC マップが変動するか確認.固定なら端末校正値,変動なら AWB 連動の動的計算 | -| 補正残差の可視化 | LSC マップが実画像のずれを完全に説明するか,残差がどの程度残るかを定量化 | -| 物理 cos⁴ との比較 | LSC マップ ≒ 物理 cos⁴ の逆数か検証.モデル化検討の物理動機として使える | -| SHADING_OFF へのフォールバック | 後述「フォールバック条件」発動時に,OFF データへの逆適用ロジックの正当性検証に使える | - -→ メタデータ JSON に LSC マップを保存し続ける現実装は **そのまま維持を依頼**. - -### 将来 SHADING_OFF 切替のフォールバック条件 - -「もし問題があったら OFF にした時の差を確認する」という運用方針を踏まえ,以下のいずれかが発生した時に SHADING_OFF 撮影の追加検証を依頼する: - -| 発動条件 | 検証目的 | -| --- | --- | -| **C1**: Mitsuba シミュ vs HIGH_QUALITY DNG の残差が **同じ条件で 10 回撮っても再現的に大きい**(CoV/勾配残差 1.5× 以上 + 形が物理的に説明できない) | LSC 補正残差が大きすぎる可能性.OFF と比較して「物理 cos⁴」と「ISP 補正残差」を切り分ける | -| **C2**: LSC マップが **撮影ごとに大きく変動**する(中央値の variation > 5% 等) | ISP が AWB と連動して動的計算している証拠.補正後 DNG の解析時にこの動的性を考慮する必要 | -| **C3**: シミュ側の Mitsuba モデル精度向上後も残差が物理的に説明できない | LSC 由来でない別要因の存在.OFF 比較で物理 cos⁴ の影響度を独立に評価 | - -これらの条件が発動した時は,MiniTIAS 側に「同一シーンを HIGH_QUALITY と OFF で 1 セット連続撮影するオプション」の追加実装を依頼する.コスト見積もり:Kotlin 側 1〜2 時間,ファイル命名規則拡張(`*_hq.dng` / `*_off.dng`). - -**現時点では発動条件未到達**.実装は不要. - -## Mitsuba 出力の物理解釈の補足 (Physical Interpretation Note) - -TECH_03 §SHADING_MODE 経緯で混乱の元になった「Mitsuba 出力 = 生 Bayer 相当か?」について,SmTIAS-LightSim 側の正式な物理モデルを整理する. - -### 現状の Mitsuba シーン構成 - -- **emitter**: LED 配置から 18 個の area emitter(位置は Fusion 360 設計 STL から抽出,[Step 8-9 参照](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) -- **bsdf**: Lambertian(反射率 0.85,校正は [Step 8-6 参照](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) -- **integrator**: `path` tracer(多重反射,max_depth=8) -- **sensor**: **orthographic**(平行投影,cos⁴ falloff なし) - -→ シミュ luminance.npy は「**評価板表面の物理放射輝度 L** を orthographic 平行光線でサンプリングした値」.これは「実カメラで perspective sensor + 完璧な LSC 補正が掛かった時の出力」に最も近い. - -### 厳密に言えば足りないもの - -物理的に Mitsuba 出力と HIGH_QUALITY DNG が完全一致しない理由: - -| 要因 | 影響度 | 現状の処理 | -| --- | --- | --- | -| **実機 LSC の補正残差** | 中(残差 1.6〜1.9× の主因候補) | 未モデル化.本回答で「測定して見る」方針 | -| **実機センサのキャリブレーション残差**(ホットピクセル,黒レベル変動) | 小 | TECH_03 改善案の Hot pixel map で対応予定 | -| **Mitsuba 側 Lambertian 近似の限界** | 中(実機内壁は完全 Lambertian ではない) | [Step 8-2 反射率スイープ](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で限界確認済 | -| **LED 配光の Lambertian 近似** | 小〜中 | [Step 8-10/8-11 spot emitter 試行](../06_TEST/TEST_01_キャリブレーション検証履歴.md) でデータシート整合まで詰めた | -| **メッシュ離散化** | 小 | path bake 高サンプル化([TEST_04 Speed-2](../06_TEST/TEST_04_シミュレーション速度最適化履歴.md) )で評価可能だが現在は中断 | -| **sRGB OETF の有無** | 大(過去対応済) | sm-evaluate で順方向適用([Step 8-3](../06_TEST/TEST_01_キャリブレーション検証履歴.md)) | - -→ MiniTIAS 側 DNG (HIGH_QUALITY) は **10-bit linear** で sRGB OETF 未適用のはず.SmTIAS-LightSim 側で DNG 読み込み時に linear 域で評価し,比較時には Mitsuba 側も linear のまま使う(または両者に同じ OETF を適用する). - -## 作業フェーズ分け (Work Phase Strategy) - -本回答時点での研究上の優先順位として,**「シミュ vs 実機の対応がそもそも取れるか」を 1 ショットで先に確認してから連射に進む** 二段階方式を採用する. - -### 方針の根拠 - -- **連射再現性が確保できても,シミュとの対応自体が取れなければ意味が薄い** -- 1 ショット分析でフェーズ A の対応が取れない場合: - - 物理モデルの根本見直し(Mitsuba 側の物性・配光・反射特性) - - SHADING_MODE フォールバック条件 C1〜C3 の発動判断 - - シミュ側の自由度パラメータの再キャリブ -- これらは **どれも 1 ショット分析で判断できる** ことで,連射機能の有無に依らない -- 連射機能を先に実装すると「結局使わないかもしれない実装」のコストがかかるリスクがある - -### フェーズ A(直近・1 ショット対応検証) - -**目的**: 1 枚の定量モード DNG とシミュ Mitsuba 出力の対応そのものを評価する. - -**MiniTIAS 側の作業**: - -1. **白板で 1 ショット試し撮り**(現状実装で OK,追加実装なし) - - 1/60s + ISO 40 で mean が 10-bit 中央値 500 前後に収まるか確認 - - 範囲外なら露光時間 or ISO を 1〜2 通り試行 - - 確定した撮影条件と DNG + meta.json を SmTIAS-LightSim 側に共有 - -2. **画素統計を meta.json に追加**(高優先) - - mean_per_channel / max_per_channel / p99 / saturated_pixel_ratio - - 撮影直後に飽和判定したい.撮り直しの即時判定にも使える - -3. **Hot pixel map 取得**(高優先) - - `STATISTICS_HOT_PIXEL_MAP_MODE = ON` - - meta.json に座標リストを記録.SmTIAS-LightSim 側の解析でマスク - -**SmTIAS-LightSim 側の作業**: - -1. **DNG (RAW_SENSOR) 読み込み I/F の実装** (`evaluation/dng_loader.py` 想定) - - `rawpy` 等で 10-bit BGGR Bayer を展開 - - 黒レベル除去 + デモザイク - -2. **メタデータ JSON 読み込み I/F の実装** (`evaluation/metadata.py` 想定) - - `settings` / `actual` / `sensorCharacteristics` / `lscMap` の読み取り - - hot pixel マスク適用機能 - -3. **新規 baseline config の作成** (`configs/eval_baseline_quantitative.yaml`) - - 既存 baseline と並列保持 - -4. **1 ショット シミュ vs 実機 突合** - - sm-evaluate に DNG 入力経路を追加 - - 既存メトリクス(CoV / 勾配 / C/P / max-min)を 1 ショット定量モード DNG に適用 - - [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) に Step 8-13 として記録 - -**フェーズ A 完了の判定**: - -- 1 ショット定量モード DNG(10-bit linear,HIGH_QUALITY,hot pixel マスク済)と Mitsuba シミュ luminance.npy の評価指標を直接比較 -- 既存 baseline(通常モード PNG)での残差 2.1× が **どう変化するか** を観察 -- 結果次第で以下を判断: - - **残差が縮む(≤ 1.5×)**: 撮影モード変更が効いた.フェーズ B に進む - - **残差変わらず**: SHADING_MODE フォールバック条件 C1 の検討・物理モデル見直し - - **残差が増える**: 解析パイプラインの不整合(DNG 読み込み・黒レベル・デモザイク)を疑う - -### フェーズ B(後続・連射再現性検証) - -**フェーズ A で対応の見通しが立った場合のみ**着手.目的は TECH_01 検証 V1(10 連射 mean CoV < 1%)の達成. - -**MiniTIAS 側の作業**(フェーズ B 時点): - -- **連射 UI 実装**: ボタンタップ → 10 連射 → 全保存 - - ファイル命名: `MiniTIAS_QM_YYYYMMDD_HHmmss_burst{0..9}.dng` - - 連射間隔は端末性能依存.インターバル指定は不要 - - meta.json は撮影ごとに保存(10 個生成).プレビュー JPEG は 1 枚で十分 - -**SmTIAS-LightSim 側の作業**(フェーズ B 時点): - -- **再現性検証スクリプト** `scripts/verify_repeatability.py` - - 10 ショット間で mean / std / CoV / 形状指標を集計 - - V1 合格判定(mean CoV < 1%,形状指標 std < 0.005) - -役割分担の根拠:SmTIAS-LightSim 側に既存の解析基盤([`uniformity.py`](../../src/sm_tias_lightsim/uniformity.py), `evaluation/*.py`)があるので,再現性検証もそこに統合する方が将来のキャリブ作業との整合性が高い. - -### フェーズ B 着手の条件 - -- フェーズ A で「シミュ vs 実機」の対応が一定程度取れた(残差 ≤ 1.5× 程度) -- もしくはフェーズ A の結果から「連射で SN 比改善 / 平均値安定化が必要」と判断された -- いずれもフェーズ A 完了後に判断する - -## 露光時間・ISO 運用の目安 (Exposure & ISO Guideline) - -TECH_03 「露光時間 / ISO の運用」への回答: - -### 白板撮影の理想値 - -10-bit DNG (値域 0〜1023) で: - -| 指標 | 目安 | -| --- | --- | -| 黒レベル | 64(`SENSOR_BLACK_LEVEL_PATTERN`).差し引き後の値で評価 | -| **白板中央の mean**(黒レベル除去後) | **400〜600**(中央値約 500) | -| 飽和回避 | **p99 < 900**(黒レベル除去後 836) | -| SN 比 | 最大露光(飽和回避内)で改善.ただしホット画素も顕在化 | - -現状の **1/60s + ISO 40** が適切かは **実機で 1 度白板撮影して mean を確認**する必要がある.mean が 400 を下回るなら露光時間を倍にする(1/30s)または ISO を上げる.mean が 600 を超えるなら露光時間を半分(1/120s)に. - -### MiniTIAS 側の半自動キャリブ機能(TECH_03 「検討中の対応 (a)」)について - -提案された「AE を 1 回走らせて値を読み,以降固定」は **歓迎**.実装すれば以下の利点: - -- 環境光(部屋の明るさ)の影響を吸収して撮影条件を一定化 -- 白板用・舌模型用で個別校正値を保持できる -- meta.json の `settings.exposure_time_ns` / `settings.sensor_sensitivity_iso` に校正済み値を記録 - -ただし優先度は **中**(現状の手動ハードコードでも研究は進められる).連射 UI と画素統計が先. - -## 改善案優先度のフィードバック (Improvement Priority Feedback) - -TECH_03 「検討中の改善案」に対する SmTIAS-LightSim 側の優先度評価.本回答ではフェーズ A/B 分けにより **連射 UI を高優先 → フェーズ B(中優先)に降格**する点が当初提案からの修正. - -| MiniTIAS 側優先度 | 項目 | SmTIAS-LightSim 側評価 | フェーズ | 理由 | -| --- | --- | --- | --- | --- | -| 高 | 画素統計を meta.json に追加(mean/max/p99/saturated_ratio) | **同意,高** | **A** | 飽和判定がないと「定量データの有効性」が事後判断になる.撮影時に即判定したい.1 ショット分析でも必須 | -| 高 | Hot pixel map 取得 (`STATISTICS_HOT_PIXEL_MAP_MODE = ON`) | **同意,高** | **A** | 10-bit のホット画素は max/std 指標に大きく影響.マスク必須 | -| 高 | 連射機能(V1 検証用) | **降格.フェーズ A 結果次第で再評価** | **B(保留)** | 1 ショットで対応が取れることを先に確認すべき.対応が取れないなら連射しても無意味 | -| 中 | 露光時間・ISO の半自動キャリブ | **同意,中** | B 以降 | 手動ハードコードでも進められる.運用が定着した後でよい | -| 中 | レンズ・センサ校正値を meta に追加 | **同意,中** | B 以降 | DNG Exif に既に埋め込まれている分は重複.未埋め込み値のみ追加で十分 | -| 低 | Dark frame / Flat field キャリブ機能 | **同意,低** | C 以降 | 実物理 cos⁴ の独立測定として理想的だが,現状の HIGH_QUALITY 経路では LSC マップで代替可能 | - -→ **フェーズ A で MiniTIAS 側に依頼するのは「画素統計・Hot pixel map」の 2 件のみ**.連射 UI と他項目はフェーズ A 完了後に再評価. - -## データ受け渡し細部の追補 (Data Exchange Refinements) - -[TECH_02 データ受け渡し仕様](TECH_02_MiniTIAS引き継ぎ手順.md#データ受け渡し仕様-data-exchange-specification) を以下で補足する. - -### ファイル転送経路 - -- **推奨**: `adb pull /sdcard/Pictures/MiniTIAS/ ` -- **非推奨**: Windows エクスプローラ経由 MTP コピー(DNG サイズ 16MB で不整合報告あり) - -### 配置先(既存仕様の確認) - -```text -SmTIAS-LightSim/ -└── reference_measurement/ - └── quantitative/ - ├── MiniTIAS_QM_YYYYMMDD_HHmmss.dng - ├── MiniTIAS_QM_YYYYMMDD_HHmmss.meta.json - ├── MiniTIAS_QM_YYYYMMDD_HHmmss.preview.jpg # 解析対象外(任意配置) - └── MiniTIAS_QM_YYYYMMDD_HHmmss_burst{0..9}.dng # 連射時 -``` - -`reference_measurement/` は `.gitignore` 対象(個人ローカル).preview.jpg は解析不要なので配置任意. - -### 既存 baseline の参照 - -`MiniTIAS_20260408_144434.png`(通常モード)は SmTIAS-LightSim/`reference_measurement/MiniTIAS_20260408_144434/` 配下に保持.**新規定量モード baseline 取得後は参考扱い**で,本流キャリブから外す. - -## 次のフェーズの作業範囲 (Next Phase Scope) - -本回答に基づく次フェーズの作業を整理.**フェーズ A 完了後にフェーズ B への移行を判断**する. - -### フェーズ A(直近・1 ショット対応検証) - -#### MiniTIAS 側(実装依頼) - -1. **画素統計を meta.json に追加**(高優先・フェーズ A 必須) - - mean_per_channel / max_per_channel / p99 / saturated_pixel_ratio -2. **Hot pixel map 取得・meta.json に保存**(高優先・フェーズ A 必須) - - `STATISTICS_HOT_PIXEL_MAP_MODE = ON` -3. **白板で 1 ショット試し撮り → mean 確認**(露光/ISO の妥当性確認) - - mean が 400〜600(10-bit 黒レベル除去後)なら現状の 1/60s + ISO 40 で運用 - - 範囲外なら設定を調整 -4. 確定した撮影条件で **白板を 1 枚撮影** → `reference_measurement/quantitative/` へ adb pull 転送 - -> **連射 UI は不要**.本フェーズでは 1 ショット分析を優先. - -#### SmTIAS-LightSim 側(受け入れ実装) - -1. **DNG (RAW_SENSOR) 読み込み I/F の実装**(`evaluation/dng_loader.py` 新規想定) -2. **メタデータ JSON 読み込み I/F の実装**(`evaluation/metadata.py` 新規想定) -3. **LSC マップ読み込み I/F の実装**(検証用,本流では使わない) -4. **新規定量モード baseline 設定** `configs/eval_baseline_quantitative.yaml` の作成 -5. **1 ショット シミュ vs 実機の突合** - - 既存メトリクス(CoV / 勾配 / C/P / max-min)を 1 ショット DNG に適用 - - 既存通常モード baseline 残差 2.1× が定量モードで **どう変化するか** を観察 -6. [TEST_01](../06_TEST/TEST_01_キャリブレーション検証履歴.md) に **Step 8-13: 定量モード 1 ショット対応検証** として記録 - -### フェーズ A 完了判定とフェーズ B 移行条件 - -フェーズ A 完了後,1 ショット突合の結果から以下を判断: - -| 結果 | 判断 | 次の動き | -| --- | --- | --- | -| 残差が縮む(≤ 1.5×) | 撮影モード変更が効いた | フェーズ B(連射 V1 検証)に進む.連射 UI 実装依頼 | -| 残差変わらず(≈ 2.1×) | 撮影モード変更だけでは足りない | SHADING_MODE フォールバック条件 C1 検討.物理モデル見直し(LSC モデル化/反射特性/emitter 配光) | -| 残差が増える | 解析パイプラインに不整合 | DNG 読み込み・黒レベル・デモザイク・カラーマトリクスを疑う.デバッグ | - -### フェーズ B(後続・連射 V1 検証) - -**フェーズ A で対応の見通しが立った場合のみ**着手. - -#### MiniTIAS 側(フェーズ B 時点) - -- 10 連射 UI 実装(V1 検証前提) -- 連射機能で 10 ショット baseline 撮影 → `reference_measurement/quantitative/` へ転送 - -#### SmTIAS-LightSim 側(フェーズ B 時点) - -- **再現性検証スクリプト** `scripts/verify_repeatability.py` の実装 -- V1 検証実施 -- 必要に応じてキャリブ自由度(反射率・emitter 強度等)を再調整 - -## 未確定事項 (Pending Items) - -以下は本回答時点では判断保留.後続作業の進捗に応じて追加判断する: - -| ID | 内容 | 判断時期 | -| --- | --- | --- | -| P1 | DNG 内 Bayer データから RGB への展開方法(rawpy 等使用) | sm-evaluate DNG 読み込み実装時 | -| P2 | 黒レベル除去の有無(meta.json `sensorCharacteristics.black_level_pattern` で対応) | 同上 | -| P3 | デモザイク手法(最近傍 / バイリニア / AHD) | 同上.評価指標への影響を実測比較 | -| P4 | カラーマトリクス適用の有無 | 無彩色シーンでは影響軽微.本流は無適用で進める | -| P5 | 連射時の物理的振動・温度ドリフトの扱い | V1 結果次第.問題があれば連射間隔の延長等を検討 | - -## 関連ドキュメント (Related Documents) - -- [TECH_01 定量撮影モード要求仕様](TECH_01_定量撮影モード要求仕様.md) — MiniTIAS 側への原始要求 -- [TECH_02 MiniTIAS 引き継ぎ手順](TECH_02_MiniTIAS引き継ぎ手順.md) — 引き継ぎ作業ガイド -- [TECH_03 定量モード実装報告と SHADING_MODE 相談](TECH_03_定量モード実装報告とSHADING_MODE相談.md) — MiniTIAS 側からの実装報告(本回答の対象) -- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8 系の経緯 -- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 形のずれ議論.LSC モデル化の物理動機 -- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) — sm-evaluate パイプライン diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a8ac6de..8f43eb3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -32,7 +32,7 @@ ## 定量モード撮影機能(DNG 経路)完成 - 状況: シャッターで `MiniTIAS_QM_YYYYMMDD_HHmmss.dng`(10-bit BGGR Bayer 3264×2448,約 16MB)と `.meta.json`(settings/actual/sensorCharacteristics/lscMap)を `Pictures/MiniTIAS/` に保存.既存 PNG 経路は `captureFullResolutionPng` として温存 -- 動機: SmTIAS-LightSim 側でのシミュ vs 実機の定量比較で残差 2.1× が解消しない問題.原因は MiniTIAS 通常モードが AE/AWB/NR/EDGE/TONEMAP 全自動で撮影ごとに値が揺らいでいたこと(TECH_01 参照) +- 動機: SmTIAS-LightSim 側でのシミュ vs 実機の定量比較で残差 2.1× が解消しない問題.原因は SmTIAS-Capture 通常モードが AE/AWB/NR/EDGE/TONEMAP 全自動で撮影ごとに値が揺らいでいたこと(TECH_01 参照) - 設計判断: Camera2 マニュアル制御で `CONTROL_MODE=OFF` + AE/AWB/AF/NR/EDGE OFF + TONEMAP 線形 + 露光 1/60s 固定 + ISO 40 固定.`SHADING_MODE` は HIGH_QUALITY を採用(LSC マップ実値を取りたいため.OFF だと単位マップしか返らない).フォーマットは DNG (RAW_SENSOR) を採用(8bit PNG では舌の色再現には情報量不足) - 失敗パターン: ① MTP コピー直後の DNG が PC で 0 バイトに見える→「MethodChannel 16MB 転送が失敗」と誤診断.実際は MTP コピー遅延で,30 秒待てば全データあった.② `SHADING_MODE = OFF` 初回採用で LSC マップが全 1.0(Android 仕様)→ HIGH_QUALITY に変更で実値取得 - 後段: 露光時間・ISO のキャリブ機能(白板撮影で AE 一回走らせて値を固定する半自動モード),撮影連射再現性検証 V1(CoV < 1%),他機種展開時の `getCameraDiagnostics` 再実行 @@ -77,3 +77,14 @@ - 失敗パターン: 露光時間調整で 3 ステップ要した — ① 1/60s(初期値)→ 飽和率 21.7%(mean G 887).完全に過露光 ② 1/250s → 飽和率 0% だが mean G 207(黒レベル除去後)と暗すぎ ③ 1/120s → mean G 461(黒レベル除去後)で TECH_04 目標 400〜600 中央.飽和率 0%.**線形領域に完全に収まる確定値** - アタッチメント + LED ON 環境での測定値(基準値として記録): mean R/G/B (raw) = 349/525/325,p99 G = 879,飽和率 0%,underexposed 0.002% - 後段: フェーズ A サンプルを SmTIAS-LightSim 側 `reference_measurement/quantitative/` に adb pull 転送 → 1 ショット シミュ vs 実機の突合.残差が縮めばフェーズ B(連射 V1)へ + +## v1.1.0 リリース整理(ドキュメント再編・リファクタ・SmTIAS-Capture へリネーム) + +- 状況: v1.0.0(撮影・保存・一覧)以降に積み上げた機能群(ネイティブ高速化・カメラ診断・DNG 定量モード・プレビュー JPEG・画素統計/Hot pixel map)を一区切りとし **v1.1.0** を採番(pubspec 1.0.0+1 → 1.1.0+2).併せてリリース整理を実施 +- 動機: 開発が一区切りついたため,ドキュメントの陳腐化解消・コード品質改善・正式名称への統一を行い,新バージョンとして保存する +- ドキュメント: README/CLAUDE.md/ENV/PLAN を定量モード現状に更新.SPEC_01 を3分割(画面 / カメラ撮影 / アーキテクチャ・非機能).05_TECH の往復書簡 TECH_01/03/04 を1本(設計の経緯と合意)に統合し TECH_02(引き継ぎ手順)は役目終了で削除.参考画像 DSC_0066.JPG を `local_reference/`(gitignore)へ退避 +- リファクタリング: Dart は PreviewFrame 型導入・build のオーバーレイ抽出(テスト 79 件維持).Kotlin は肥大化した RawCapturePlugin.kt を 1184→856 行に縮小し,純粋処理を 4 object(PngEncoder/YuvJpegConverter/ImageStatistics/CaptureMetadata)へ抽出 +- テスト: ImageStatistics の数理ロジック(mean/max/p99/飽和率)を実機不要の JUnit で検証(`./gradlew test`).GUIDE_07 に方針追記 +- リネーム: 正式名称 SmTIAS に合わせ MiniTIAS → **SmTIAS-Capture**(GitHub リポジトリ名・ローカルフォルダ・Dart パッケージ `smtias_capture`・Android `com.example.smtias_capture`・全プラットフォーム製品名・ドキュメント表記) +- 設計判断: SmTIAS-LightSim とのデータ契約(保存接頭辞 `MiniTIAS_QM_`・保存先 `Pictures/MiniTIAS/`)は突合互換のため**意図的に維持** +- 検証: dart analyze 0 件 / dart format 差分なし / flutter test 79 件 / flutter build apk --debug 成功 / gradle 単体テスト通過 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 93c03c8..207ecb4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - mini_tias + smtias_capture CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/app.dart b/lib/app.dart index 35b393c..95b222d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; -import 'package:mini_tias/screens/home_screen.dart'; +import 'package:smtias_capture/screens/home_screen.dart'; -/// MiniTIAS アプリのルートウィジェット. +/// SmTIAS-Capture アプリのルートウィジェット. /// /// 端末を逆さに置いて使用するため,アプリ全体を 180° 回転する. /// これにより画面・ダイアログ・スナックバー等すべての UI が /// 操作者から見て正しい向きで表示される. -class MiniTiasApp extends StatelessWidget { - const MiniTiasApp({super.key}); +class SmtiasCaptureApp extends StatelessWidget { + const SmtiasCaptureApp({super.key}); @override Widget build(BuildContext context) { @@ -19,7 +19,7 @@ child: Transform.rotate( angle: math.pi, child: MaterialApp( - title: 'MiniTIAS', + title: 'SmTIAS-Capture', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), useMaterial3: true, diff --git a/lib/main.dart b/lib/main.dart index d024a8a..555d9b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,10 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -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'; +import 'package:smtias_capture/app.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/providers/diagnostics_provider.dart'; +import 'package:smtias_capture/providers/gallery_provider.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -18,7 +18,7 @@ ChangeNotifierProvider(create: (_) => GalleryProvider()), ChangeNotifierProvider(create: (_) => DiagnosticsProvider()), ], - child: const MiniTiasApp(), + child: const SmtiasCaptureApp(), ), ); } diff --git a/lib/models/preview_frame.dart b/lib/models/preview_frame.dart new file mode 100644 index 0000000..c66a7f4 --- /dev/null +++ b/lib/models/preview_frame.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +/// カメラのプレビューストリームから取得した YUV_420_888 フレームを保持する不変クラス. +/// +/// [_startImageStream] で生成し,[capturePreviewSnapshot] で消費する. +class PreviewFrame { + /// 画像の幅(ピクセル). + final int width; + + /// 画像の高さ(ピクセル). + final int height; + + /// Y プレーンのバイト列. + final Uint8List yPlane; + + /// U プレーンのバイト列. + final Uint8List uPlane; + + /// V プレーンのバイト列. + final Uint8List vPlane; + + /// Y プレーンの行ストライド(バイト). + final int yRowStride; + + /// UV プレーンの行ストライド(バイト). + final int uvRowStride; + + /// UV プレーンのピクセルストライド(バイト). + final int uvPixelStride; + + const PreviewFrame({ + required this.width, + required this.height, + required this.yPlane, + required this.uPlane, + required this.vPlane, + required this.yRowStride, + required this.uvRowStride, + required this.uvPixelStride, + }); +} diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 6801ad2..f90f639 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -1,10 +1,11 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; -import 'package:mini_tias/services/file_service.dart'; -import 'package:mini_tias/services/permission_service.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; -import 'package:mini_tias/services/sound_service.dart'; +import 'package:smtias_capture/models/preview_frame.dart'; +import 'package:smtias_capture/services/file_service.dart'; +import 'package:smtias_capture/services/permission_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/sound_service.dart'; /// カメラの初期化・プレビュー制御・撮影実行・ライフサイクルを管理する. class CameraProvider extends ChangeNotifier { @@ -21,7 +22,7 @@ String? _lastSavedFileName; bool _isSaving = false; bool _imageStreamActive = false; - Map? _latestFrame; + PreviewFrame? _latestFrame; CameraController? get controller => _controller; bool get isInitialized => _isInitialized; @@ -92,16 +93,16 @@ try { _controller!.startImageStream((CameraImage image) { - _latestFrame = { - 'width': image.width, - 'height': image.height, - 'yPlane': Uint8List.fromList(image.planes[0].bytes), - 'uPlane': Uint8List.fromList(image.planes[1].bytes), - 'vPlane': Uint8List.fromList(image.planes[2].bytes), - 'yRowStride': image.planes[0].bytesPerRow, - 'uvRowStride': image.planes[1].bytesPerRow, - 'uvPixelStride': image.planes[1].bytesPerPixel ?? 1, - }; + _latestFrame = PreviewFrame( + width: image.width, + height: image.height, + yPlane: Uint8List.fromList(image.planes[0].bytes), + uPlane: Uint8List.fromList(image.planes[1].bytes), + vPlane: Uint8List.fromList(image.planes[2].bytes), + yRowStride: image.planes[0].bytesPerRow, + uvRowStride: image.planes[1].bytesPerRow, + uvPixelStride: image.planes[1].bytesPerPixel ?? 1, + ); }); _imageStreamActive = true; } catch (e) { @@ -127,20 +128,20 @@ final frame = _latestFrame!; return _rawCaptureService.convertYuvToJpeg( - width: frame['width'] as int, - height: frame['height'] as int, - yPlane: frame['yPlane'] as Uint8List, - uPlane: frame['uPlane'] as Uint8List, - vPlane: frame['vPlane'] as Uint8List, - yRowStride: frame['yRowStride'] as int, - uvRowStride: frame['uvRowStride'] as int, - uvPixelStride: frame['uvPixelStride'] as int, + width: frame.width, + height: frame.height, + yPlane: frame.yPlane, + uPlane: frame.uPlane, + vPlane: frame.vPlane, + yRowStride: frame.yRowStride, + uvRowStride: frame.uvRowStride, + uvPixelStride: frame.uvPixelStride, rotation: 90, mirror: true, ); } - /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. + /// Camera2 API で定量モード撮影を行い,DNG ファイルとメタデータ JSON を保存する. /// /// [previewBytes] が指定された場合,`.preview.jpg` として保存する. /// 指定しない場合はプレビュー JPEG の保存をスキップする. diff --git a/lib/providers/diagnostics_provider.dart b/lib/providers/diagnostics_provider.dart index 05011bd..720710a 100644 --- a/lib/providers/diagnostics_provider.dart +++ b/lib/providers/diagnostics_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; -import 'package:mini_tias/services/file_service.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/file_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; /// カメラ診断機能の状態管理. /// Camera2 API の能力を読み出し JSON ファイルに保存する. diff --git a/lib/screens/capture_screen.dart b/lib/screens/capture_screen.dart index ef74d81..8957ad0 100644 --- a/lib/screens/capture_screen.dart +++ b/lib/screens/capture_screen.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:mini_tias/providers/camera_provider.dart'; -import 'package:mini_tias/widgets/camera_preview.dart'; -import 'package:mini_tias/widgets/shutter_button.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/widgets/camera_preview.dart'; +import 'package:smtias_capture/widgets/shutter_button.dart'; /// 撮影画面.カメラプレビューとシャッターボタンを表示する. class CaptureScreen extends StatefulWidget { @@ -177,47 +177,9 @@ child: CameraPreviewWidget(controller: cameraProvider.controller!), ), // 保存中インジケーター - if (cameraProvider.isSaving) - const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(color: Colors.white), - SizedBox(height: 12), - Text( - '保存中...', - style: TextStyle(color: Colors.white, fontSize: 16), - ), - ], - ), - ), + if (cameraProvider.isSaving) _buildSavingIndicator(), // カウントダウン表示 - if (_countdown != null) - Center( - child: Stack( - children: [ - Text( - '$_countdown', - style: TextStyle( - fontSize: 96, - fontWeight: FontWeight.bold, - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 6 - ..color = Colors.black, - ), - ), - Text( - '$_countdown', - style: const TextStyle( - color: Colors.white, - fontSize: 96, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), + if (_countdown != null) _buildCountdownOverlay(_countdown!), // タイマー切り替えボタン(左側) if (!cameraProvider.isSaving) Positioned( @@ -267,6 +229,51 @@ ); } + /// 保存処理中を示すローディングインジケーター(中央配置). + Widget _buildSavingIndicator() { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 12), + Text('保存中...', style: TextStyle(color: Colors.white, fontSize: 16)), + ], + ), + ); + } + + /// セルフタイマーのカウントダウン数字を中央に表示するオーバーレイ. + /// + /// 黒いアウトラインと白い塗りを重ねることで視認性を確保する. + Widget _buildCountdownOverlay(int countdown) { + return Center( + child: Stack( + children: [ + Text( + '$countdown', + style: TextStyle( + fontSize: 96, + fontWeight: FontWeight.bold, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6 + ..color = Colors.black, + ), + ), + Text( + '$countdown', + style: const TextStyle( + color: Colors.white, + fontSize: 96, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + /// アウトライン(黒・40px)とカラー(有効:黄/無効:灰・32px)を重ねたトグルアイコン. Widget _buildToggleIcon({required IconData icon, required bool enabled}) { return Stack( diff --git a/lib/screens/gallery_screen.dart b/lib/screens/gallery_screen.dart index 93aea80..5a08891 100644 --- a/lib/screens/gallery_screen.dart +++ b/lib/screens/gallery_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:mini_tias/providers/gallery_provider.dart'; -import 'package:mini_tias/widgets/image_grid.dart'; +import 'package:smtias_capture/providers/gallery_provider.dart'; +import 'package:smtias_capture/widgets/image_grid.dart'; /// 一覧画面.撮影済み画像のサムネイルグリッドを表示する. class GalleryScreen extends StatefulWidget { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7cb181d..9119031 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,10 +2,10 @@ import 'package:provider/provider.dart'; import 'package:screen_brightness/screen_brightness.dart'; -import 'package:mini_tias/providers/camera_provider.dart'; -import 'package:mini_tias/providers/gallery_provider.dart'; -import 'package:mini_tias/screens/capture_screen.dart'; -import 'package:mini_tias/screens/gallery_screen.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/providers/gallery_provider.dart'; +import 'package:smtias_capture/screens/capture_screen.dart'; +import 'package:smtias_capture/screens/gallery_screen.dart'; /// BottomNavigationBar で撮影画面と一覧画面を切り替えるホーム画面. /// diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 223eab1..0e3b8a1 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -2,7 +2,7 @@ /// Camera2 API を使用してフル解像度の画像をキャプチャするサービス. class RawCaptureService { - static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); + static const _channel = MethodChannel('com.example.smtias_capture/raw_capture'); /// フロントカメラからフル解像度の PNG バイト列をキャプチャする(ネイティブ変換). Future captureFullResolutionPng() async { diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart index f8ba387..ea1ee2b 100644 --- a/lib/services/sound_service.dart +++ b/lib/services/sound_service.dart @@ -1,6 +1,6 @@ import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; /// サウンドフィードバックの ON/OFF を管理し,再生を担うサービス. /// diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index d2057ae..269642a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,10 +4,10 @@ # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "mini_tias") +set(BINARY_NAME "smtias_capture") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.mini_tias") +set(APPLICATION_ID "com.example.smtias_capture") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 87fc2b5..c46275d 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "mini_tias"); + gtk_header_bar_set_title(header_bar, "smtias_capture"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "mini_tias"); + gtk_window_set_title(window, "smtias_capture"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index fd78e30..a163e93 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -64,7 +64,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* mini_tias.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mini_tias.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* smtias_capture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "smtias_capture.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -131,7 +131,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* mini_tias.app */, + 33CC10ED2044A3C60003C045 /* smtias_capture.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -217,7 +217,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* mini_tias.app */; + productReference = 33CC10ED2044A3C60003C045 /* smtias_capture.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -388,7 +388,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.miniTias.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mini_tias.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mini_tias"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/smtias_capture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/smtias_capture"; }; name = Debug; }; @@ -402,7 +402,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.miniTias.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mini_tias.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mini_tias"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/smtias_capture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/smtias_capture"; }; name = Release; }; @@ -416,7 +416,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.miniTias.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mini_tias.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mini_tias"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/smtias_capture.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/smtias_capture"; }; name = Profile; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 89bdfc2..ae1cc55 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 3ad4eac..84392c1 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = mini_tias +PRODUCT_NAME = smtias_capture // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.miniTias diff --git a/pubspec.yaml b/pubspec.yaml index d56b6e4..e96d400 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -name: mini_tias +name: smtias_capture description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -16,7 +16,7 @@ # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: ^3.11.4 diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index 5516a28..3419aed 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -1,7 +1,7 @@ -# MiniTIAS Development Environment Setup Script +# SmTIAS-Capture Development Environment Setup Script # Usage: .\scripts\setup.ps1 -Write-Host "=== MiniTIAS Setup ===" -ForegroundColor Cyan +Write-Host "=== SmTIAS-Capture Setup ===" -ForegroundColor Cyan # Check Scoop if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart index 68824a8..db08dca 100644 --- a/test/providers/gallery_provider_test.dart +++ b/test/providers/gallery_provider_test.dart @@ -99,7 +99,7 @@ setUp(() async { tempDir = await Directory.systemTemp.createTemp( - 'mini_tias_gallery_test_', + 'smtias_capture_gallery_test_', ); provider = _GalleryProviderTestable(tempDir.path); }); @@ -224,7 +224,7 @@ late _GalleryProviderTestable provider; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('mini_tias_del_test_'); + tempDir = await Directory.systemTemp.createTemp('smtias_capture_del_test_'); provider = _GalleryProviderTestable(tempDir.path); }); diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 7e88747..842e219 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mini_tias/services/file_service.dart'; +import 'package:smtias_capture/services/file_service.dart'; void main() { group('FileService.generateFileNameSync', () { @@ -82,7 +82,7 @@ late _FileServiceTestable service; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('mini_tias_test_'); + tempDir = await Directory.systemTemp.createTemp('smtias_capture_test_'); service = _FileServiceTestable(tempDir.path); }); @@ -173,7 +173,7 @@ setUp(() async { tempDir = await Directory.systemTemp.createTemp( - 'mini_tias_preview_test_', + 'smtias_capture_preview_test_', ); service = _FileServiceTestablePreview(tempDir.path); }); @@ -335,7 +335,7 @@ late _FileServiceTestableQuantitative service; setUp(() async { - tempDir = await Directory.systemTemp.createTemp('mini_tias_qm_test_'); + tempDir = await Directory.systemTemp.createTemp('smtias_capture_qm_test_'); service = _FileServiceTestableQuantitative(tempDir.path); }); diff --git a/test/services/raw_capture_service_test.dart b/test/services/raw_capture_service_test.dart index 68daee8..3b0c729 100644 --- a/test/services/raw_capture_service_test.dart +++ b/test/services/raw_capture_service_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; void main() { group('QuantitativeCaptureResult.fromMap', () { diff --git a/test/services/sound_service_test.dart b/test/services/sound_service_test.dart index 150706c..100e03b 100644 --- a/test/services/sound_service_test.dart +++ b/test/services/sound_service_test.dart @@ -1,8 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mini_tias/services/raw_capture_service.dart'; -import 'package:mini_tias/services/sound_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/sound_service.dart'; /// テスト用: RawCaptureService をスタブする. /// diff --git a/test/widget_test.dart b/test/widget_test.dart index cff4d50..60ed8c2 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,9 +2,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -import 'package:mini_tias/app.dart'; -import 'package:mini_tias/providers/camera_provider.dart'; -import 'package:mini_tias/providers/gallery_provider.dart'; +import 'package:smtias_capture/app.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/providers/gallery_provider.dart'; void main() { testWidgets('アプリ起動時にナビゲーションバーが表示される', (WidgetTester tester) async { @@ -14,7 +14,7 @@ ChangeNotifierProvider(create: (_) => CameraProvider()), ChangeNotifierProvider(create: (_) => GalleryProvider()), ], - child: const MiniTiasApp(), + child: const SmtiasCaptureApp(), ), ); @@ -29,7 +29,7 @@ ChangeNotifierProvider(create: (_) => CameraProvider()), ChangeNotifierProvider(create: (_) => GalleryProvider()), ], - child: const MiniTiasApp(), + child: const SmtiasCaptureApp(), ), ); diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 54a9b1c..cd759d0 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) -project(mini_tias LANGUAGES CXX) +project(smtias_capture LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "mini_tias") +set(BINARY_NAME "smtias_capture") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index c690413..fa98e62 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "mini_tias" "\0" + VALUE "FileDescription", "smtias_capture" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "mini_tias" "\0" + VALUE "InternalName", "smtias_capture" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "mini_tias.exe" "\0" - VALUE "ProductName", "mini_tias" "\0" + VALUE "OriginalFilename", "smtias_capture.exe" "\0" + VALUE "ProductName", "smtias_capture" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index e7e3aed..ff01aa9 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"mini_tias", origin, size)) { + if (!window.Create(L"smtias_capture", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);