diff --git a/CLAUDE.md b/CLAUDE.md index 0c614c3..6f96b8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# MiniTIAS +# SmTIAS-Capture 千葉大学 TIAS(舌画像撮影・解析システム)のスマートフォン版.Flutter で開発し,初期は撮影・保存に特化する. diff --git a/README.md b/README.md index 1ea374e..1732676 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MiniTIAS +# SmTIAS-Capture 千葉大学フロンティア医工学センターの舌画像撮影・解析システム(TIAS)のスマートフォン版。 PC 側のシミュレーション(SmTIAS-LightSim)と実機撮影を定量比較するため、Camera2 API の @@ -38,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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 285b4e2..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 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 @@ ? = 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/mini_tias/ImageStatistics.kt b/android/app/src/main/kotlin/com/example/mini_tias/ImageStatistics.kt deleted file mode 100644 index b3ab4bc..0000000 --- a/android/app/src/main/kotlin/com/example/mini_tias/ImageStatistics.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.example.mini_tias - -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/mini_tias/MainActivity.kt b/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt deleted file mode 100644 index 3b19bf4..0000000 --- a/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.mini_tias - -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.mini_tias/raw_capture") - channel.setMethodCallHandler(RawCapturePlugin(this)) - } -} diff --git a/android/app/src/main/kotlin/com/example/mini_tias/PngEncoder.kt b/android/app/src/main/kotlin/com/example/mini_tias/PngEncoder.kt deleted file mode 100644 index b0c8902..0000000 --- a/android/app/src/main/kotlin/com/example/mini_tias/PngEncoder.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.example.mini_tias - -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/mini_tias/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt deleted file mode 100644 index 1edd0e1..0000000 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ /dev/null @@ -1,856 +0,0 @@ -package com.example.mini_tias - -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/mini_tias/YuvJpegConverter.kt b/android/app/src/main/kotlin/com/example/mini_tias/YuvJpegConverter.kt deleted file mode 100644 index 68ee0d5..0000000 --- a/android/app/src/main/kotlin/com/example/mini_tias/YuvJpegConverter.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.mini_tias - -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/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/mini_tias/ImageStatisticsTest.kt b/android/app/src/test/kotlin/com/example/mini_tias/ImageStatisticsTest.kt deleted file mode 100644 index 67967f8..0000000 --- a/android/app/src/test/kotlin/com/example/mini_tias/ImageStatisticsTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.example.mini_tias - -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/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 93fddf0..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,8 +42,8 @@ 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` で自動整列されるため,手動調整は不要. 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 a1d40e1..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,6 +1,6 @@ # テスト方針 (Testing Strategy) -本ドキュメントでは,MiniTIAS プロジェクトのテスト方針を定義する. +本ドキュメントでは,SmTIAS-Capture プロジェクトのテスト方針を定義する. ## 基本方針 (Principles) 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 5273dfc..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 を用いたモバイルアプリケーションである.撮影の中核(Camera2 / `DngCreator` / 画素統計)を Android ネイティブで実装しているため,当面は **Android 専用** とする.iOS へ展開する場合は撮影パイプラインの再実装が必要になる. +SmTIAS-Capture は Flutter を用いたモバイルアプリケーションである.撮影の中核(Camera2 / `DngCreator` / 画素統計)を Android ネイティブで実装しているため,当面は **Android 専用** とする.iOS へ展開する場合は撮影パイプラインの再実装が必要になる. ## 試験端末 (Test Device) 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 e8887f2..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,7 +17,7 @@ ### ディレクトリ構成 ```text -MiniTIAS/ +SmTIAS-Capture/ ├── scripts/ │ └── setup.ps1 ``` @@ -73,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 73ffc2f..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) 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 ab5a31a..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,6 @@ # 画面機能仕様書 (Screen Specification) -本ドキュメントでは,MiniTIAS アプリの画面設計・画面遷移・各画面の UI と動作・パーミッションを定義する. +本ドキュメントでは,SmTIAS-Capture アプリの画面設計・画面遷移・各画面の UI と動作・パーミッションを定義する. - カメラ制御・撮影フロー・ファイル管理は [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 - アーキテクチャ・非機能要件は [SPEC_03 アーキテクチャと非機能](SPEC_03_アーキテクチャと非機能.md) を参照 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" index 888e9e8..a649254 100644 --- "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" @@ -1,6 +1,6 @@ # カメラ・撮影仕様書 (Camera & Capture Specification) -本ドキュメントでは,MiniTIAS のカメラ制御・撮影フロー・保存ファイルの仕様を定義する. +本ドキュメントでは,SmTIAS-Capture のカメラ制御・撮影フロー・保存ファイルの仕様を定義する. - 画面・UI・パーミッションは [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 - アーキテクチャ・非機能要件は [SPEC_03 アーキテクチャと非機能](SPEC_03_アーキテクチャと非機能.md) を参照 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" index 724b121..5e69362 100644 --- "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" @@ -1,6 +1,6 @@ # アーキテクチャ・非機能仕様書 (Architecture & Non-functional Specification) -本ドキュメントでは,MiniTIAS のアーキテクチャ(コード構成)と非機能要件を定義する. +本ドキュメントでは,SmTIAS-Capture のアーキテクチャ(コード構成)と非機能要件を定義する. - 画面・UI・パーミッションは [SPEC_01 画面機能仕様書](SPEC_01_画面機能仕様書.md) を参照 - カメラ制御・撮影フロー・ファイル管理は [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 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" index 035d393..b91ee8b 100644 --- "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" @@ -1,6 +1,6 @@ # 定量モード 設計の経緯と合意 (Quantitative Mode: Rationale & Agreements) -本書は SmTIAS-LightSim 側と MiniTIAS 側の**往復書簡(旧 TECH_01 要求仕様 / TECH_03 実装報告・相談 / TECH_04 回答)を統合・要約したアーカイブ**である.定量撮影モードがなぜ・どう決まったかの記録に絞る. +本書は 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) @@ -10,7 +10,7 @@ ## 背景:なぜ定量モードが必要だったか -SmTIAS-LightSim(Mitsuba 3 による光散乱シミュ)と実機撮影を定量比較する研究で、シミュ vs 実機の残差が縮まらなかった。原因は、MiniTIAS の通常撮影(PNG)が **AE/AWB/NR/EDGE/TONEMAP すべて自動**で、同じシーンを撮っても値が揺らいでいたこと。つまり「比較対象の実機データ自体が定量的に不安定」だった。 +SmTIAS-LightSim(Mitsuba 3 による光散乱シミュ)と実機撮影を定量比較する研究で、シミュ vs 実機の残差が縮まらなかった。原因は、SmTIAS-Capture の通常撮影(PNG)が **AE/AWB/NR/EDGE/TONEMAP すべて自動**で、同じシーンを撮っても値が揺らいでいたこと。つまり「比較対象の実機データ自体が定量的に不安定」だった。 → Camera2 API を**完全マニュアル制御**し、ISP 介入を排した **RAW(DNG)**で撮影するモードを追加して固定化する、という方針になった。 @@ -47,7 +47,7 @@ - 実装中の観察:`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 維持。MiniTIAS 側の実装変更は不要**。LSC マップは検証用に meta.json へ温存する。 +- 結論:**HIGH_QUALITY 維持。SmTIAS-Capture 側の実装変更は不要**。LSC マップは検証用に meta.json へ温存する。 ### 将来 SHADING_OFF を追加検証する条件(フォールバック C1–C3) @@ -70,7 +70,7 @@ 「再現性より先に、そもそもシミュと対応が取れるかを 1 ショットで確認する」二段階方式: -- **フェーズ A(1 ショット対応検証)**: MiniTIAS 側は ①画素統計を meta.json に追加 ②Hot pixel map 取得 ③白板 1 枚を試し撮りして adb pull 転送。SmTIAS 側が DNG/メタ読み込みを実装し 1 ショット突合 → 残差 2.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%)。 ## データ転送 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a8ac6de..e4e4419 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` 再実行 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/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 5d18d5e..f90f639 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -1,11 +1,11 @@ import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; -import 'package:mini_tias/models/preview_frame.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 { 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 16499a0..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 { 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/pubspec.yaml b/pubspec.yaml index d56b6e4..cfe0796 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. 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(), ), );