diff --git a/android/app/src/main/kotlin/com/example/mini_tias/CaptureMetadata.kt b/android/app/src/main/kotlin/com/example/mini_tias/CaptureMetadata.kt new file mode 100644 index 0000000..9813bdc --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mini_tias/CaptureMetadata.kt @@ -0,0 +1,122 @@ +package com.example.mini_tias + +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/mini_tias/ImageStatistics.kt b/android/app/src/main/kotlin/com/example/mini_tias/ImageStatistics.kt new file mode 100644 index 0000000..cf3afe2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mini_tias/ImageStatistics.kt @@ -0,0 +1,127 @@ +package com.example.mini_tias + +import android.media.Image +import java.nio.ByteOrder + +/** + * 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) + val shortBuf = buffer.asShortBuffer() + val rowStrideShorts = plane.rowStride / 2 + val srcW = image.width + val srcH = image.height + + // 集計用変数 + var sumR = 0.0; var sumG = 0.0; var sumB = 0.0 + var countR = 0; var countG = 0; var countB = 0 + var maxR = 0; var maxG = 0; var maxB = 0 + var saturatedCount = 0 + var underexposedCount = 0 + val histR = IntArray(STATISTICS_HISTOGRAM_BINS) + val histG = IntArray(STATISTICS_HISTOGRAM_BINS) + val histB = IntArray(STATISTICS_HISTOGRAM_BINS) + + val row1 = ShortArray(rowStrideShorts) + val row2 = ShortArray(rowStrideShorts) + + // 2 行ずつ読み,2×2 BGGR ブロックを処理 + var y = 0 + while (y < srcH - 1) { + shortBuf.position(y * rowStrideShorts) + shortBuf.get(row1, 0, rowStrideShorts) + shortBuf.position((y + 1) * rowStrideShorts) + shortBuf.get(row2, 0, rowStrideShorts) + + var x = 0 + while (x < srcW - 1) { + val b = row1[x].toInt() and 0x3FF // (0,0) B + val g1 = row1[x + 1].toInt() and 0x3FF // (0,1) G + val g2 = row2[x].toInt() and 0x3FF // (1,0) G + val r = row2[x + 1].toInt() and 0x3FF // (1,1) R + + // チャネル別集計 + sumR += r; countR++; if (r > maxR) maxR = r + sumB += b; countB++; if (b > maxB) maxB = b + val gAvg = (g1 + g2) / 2 + sumG += gAvg; countG++; if (gAvg > maxG) maxG = gAvg + + // ヒストグラム(10-bit 値 → 256 bin にマップ) + histR[ + (r * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + histG[ + (gAvg * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + histB[ + (b * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) + .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) + ]++ + + // 飽和率・低露光率は全 Bayer 画素を独立にカウント(4 画素分) + for (v in intArrayOf(b, g1, g2, r)) { + if (v >= SATURATION_THRESHOLD) saturatedCount++ + if (v <= UNDEREXPOSED_THRESHOLD) underexposedCount++ + } + + x += 2 + } + y += 2 + } + + val totalBayerPixels = srcW * srcH + val meanR = if (countR > 0) sumR / countR else 0.0 + val meanG = if (countG > 0) sumG / countG else 0.0 + val meanB = if (countB > 0) sumB / countB else 0.0 + + 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/PngEncoder.kt b/android/app/src/main/kotlin/com/example/mini_tias/PngEncoder.kt new file mode 100644 index 0000000..b0c8902 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mini_tias/PngEncoder.kt @@ -0,0 +1,85 @@ +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 index 938b9f8..1edd0e1 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -2,13 +2,8 @@ import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.graphics.Color import android.graphics.ImageFormat -import android.graphics.Matrix -import android.graphics.Point -import android.graphics.Rect -import android.graphics.YuvImage import android.hardware.camera2.* import android.hardware.camera2.params.ColorSpaceTransform import android.hardware.camera2.params.RggbChannelVector @@ -17,21 +12,13 @@ import android.media.MediaActionSound import android.media.MediaScannerConnection import android.media.RingtoneManager -import android.os.Build import android.os.Handler import android.os.HandlerThread import android.util.Log import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.time.Instant -import java.util.zip.CRC32 -import java.util.zip.Deflater -import java.util.zip.DeflaterOutputStream import kotlin.math.roundToInt /** フレーム取得後に処理するコールバックの型エイリアス.*/ @@ -61,11 +48,6 @@ const val RAW_WIDTH = 3264 const val RAW_HEIGHT = 2448 const val FRAMES_TO_DISCARD = 2 - const val SATURATION_THRESHOLD = 1000 - const val UNDEREXPOSED_THRESHOLD = 100 - const val STATISTICS_HISTOGRAM_BINS = 256 - /** 10-bit RAW センサー値の範囲(0〜1023 の 1024 通り).ヒストグラム bin 変換に使用する.*/ - const val RAW_10BIT_RANGE = 1024 } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -114,61 +96,22 @@ } } - /// YUV_420_888 を NV21 に変換し,YuvImage で JPEG 化して返す. + /// YUV_420_888 を NV21 経由で JPEG 化して返す(実処理は [YuvJpegConverter]). private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) { try { - val width = call.argument("width")!! - val height = call.argument("height")!! - val yPlane = call.argument("yPlane")!! - val uPlane = call.argument("uPlane")!! - val vPlane = call.argument("vPlane")!! - val yRowStride = call.argument("yRowStride")!! - val uvRowStride = call.argument("uvRowStride")!! - val uvPixelStride = call.argument("uvPixelStride")!! - val rotation = call.argument("rotation") ?: 0 - val quality = call.argument("quality") ?: 85 - - // YUV_420_888 → NV21 変換 - val nv21 = ByteArray(width * height * 3 / 2) - - // Y プレーンをコピー - for (row in 0 until height) { - System.arraycopy(yPlane, row * yRowStride, nv21, row * width, width) - } - - // UV プレーンを NV21 形式(VUVU...)にインターリーブ - val uvOffset = width * height - for (row in 0 until height / 2) { - for (col in 0 until width / 2) { - val uvIndex = row * uvRowStride + col * uvPixelStride - nv21[uvOffset + row * width + col * 2] = vPlane[uvIndex] - nv21[uvOffset + row * width + col * 2 + 1] = uPlane[uvIndex] - } - } - - // NV21 → JPEG - val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null) - val jpegStream = ByteArrayOutputStream() - yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, jpegStream) - - // 回転・反転が必要な場合 - val mirror = call.argument("mirror") ?: false - val jpegBytes = if (rotation != 0 || mirror) { - val bitmap = BitmapFactory.decodeByteArray(jpegStream.toByteArray(), 0, jpegStream.size()) - val matrix = Matrix() - if (rotation != 0) matrix.postRotate(rotation.toFloat()) - if (mirror) matrix.postScale(-1f, 1f) - val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - bitmap.recycle() - - val outStream = ByteArrayOutputStream() - transformed.compress(Bitmap.CompressFormat.JPEG, quality, outStream) - transformed.recycle() - outStream.toByteArray() - } else { - jpegStream.toByteArray() - } - + 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) @@ -204,7 +147,7 @@ bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight) // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化) - val pngBytes = encodePngUncompressed(bitmap) + val pngBytes = PngEncoder.encodeUncompressed(bitmap) bitmap.recycle() reader.close() @@ -527,7 +470,7 @@ // 画素統計を計算(DNG 書き込み後・image.close() 前) val imageStatistics: Map? = try { - computeImageStatistics(image) + ImageStatistics.compute(image) } catch (e: Exception) { Log.w("RawCapturePlugin", "Image statistics computation failed", e) null @@ -565,9 +508,16 @@ } // メタデータを構築 - val metadataMap = buildMetadataMap( - cameraId, characteristics, captureResult, image, - imageStatistics, hotPixelMapData, + 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() @@ -600,216 +550,6 @@ } } - /** - * RAW_SENSOR 画像から画素統計を計算する. - * - * Bayer 配列 (BGGR) を 2×2 ブロック単位で処理し, - * チャネル別の平均・最大・P99 値,飽和率・低露光率,ヒストグラムを返す. - * 計算失敗時は呼び出し元で null として扱う. - */ - private fun computeImageStatistics(image: android.media.Image): Map { - val plane = image.planes[0] - val buffer = plane.buffer - buffer.rewind() - buffer.order(ByteOrder.LITTLE_ENDIAN) - val shortBuf = buffer.asShortBuffer() - val rowStrideShorts = plane.rowStride / 2 - val srcW = image.width - val srcH = image.height - - // 集計用変数 - var sumR = 0.0; var sumG = 0.0; var sumB = 0.0 - var countR = 0; var countG = 0; var countB = 0 - var maxR = 0; var maxG = 0; var maxB = 0 - var saturatedCount = 0 - var underexposedCount = 0 - val histR = IntArray(STATISTICS_HISTOGRAM_BINS) - val histG = IntArray(STATISTICS_HISTOGRAM_BINS) - val histB = IntArray(STATISTICS_HISTOGRAM_BINS) - - val row1 = ShortArray(rowStrideShorts) - val row2 = ShortArray(rowStrideShorts) - - // 2 行ずつ読み,2×2 BGGR ブロックを処理 - var y = 0 - while (y < srcH - 1) { - shortBuf.position(y * rowStrideShorts) - shortBuf.get(row1, 0, rowStrideShorts) - shortBuf.position((y + 1) * rowStrideShorts) - shortBuf.get(row2, 0, rowStrideShorts) - - var x = 0 - while (x < srcW - 1) { - val b = row1[x].toInt() and 0x3FF // (0,0) B - val g1 = row1[x + 1].toInt() and 0x3FF // (0,1) G - val g2 = row2[x].toInt() and 0x3FF // (1,0) G - val r = row2[x + 1].toInt() and 0x3FF // (1,1) R - - // チャネル別集計 - sumR += r; countR++; if (r > maxR) maxR = r - sumB += b; countB++; if (b > maxB) maxB = b - val gAvg = (g1 + g2) / 2 - sumG += gAvg; countG++; if (gAvg > maxG) maxG = gAvg - - // ヒストグラム(10-bit 値 → 256 bin にマップ) - histR[ - (r * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - histG[ - (gAvg * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - histB[ - (b * STATISTICS_HISTOGRAM_BINS / RAW_10BIT_RANGE) - .coerceIn(0, STATISTICS_HISTOGRAM_BINS - 1) - ]++ - - // 飽和率・低露光率は全 Bayer 画素を独立にカウント(4 画素分) - for (v in intArrayOf(b, g1, g2, r)) { - if (v >= SATURATION_THRESHOLD) saturatedCount++ - if (v <= UNDEREXPOSED_THRESHOLD) underexposedCount++ - } - - x += 2 - } - y += 2 - } - - val totalBayerPixels = srcW * srcH - val meanR = if (countR > 0) sumR / countR else 0.0 - val meanG = if (countG > 0) sumG / countG else 0.0 - val meanB = if (countB > 0) sumB / countB else 0.0 - - fun p99Bin(hist: IntArray, totalCount: Int): Int { - val target = (totalCount * 0.99).toInt() - var cumulative = 0 - for (bin in hist.indices) { - cumulative += hist[bin] - if (cumulative >= target) return (bin + 1) * RAW_10BIT_RANGE / STATISTICS_HISTOGRAM_BINS - 1 - } - return RAW_10BIT_RANGE - 1 - } - - val p99R = p99Bin(histR, countR) - val p99G = p99Bin(histG, countG) - val p99B = p99Bin(histB, countB) - - return mapOf( - "mean_per_channel" to mapOf("R" to meanR, "G" to meanG, "B" to meanB), - "max_per_channel" to mapOf("R" to maxR, "G" to maxG, "B" to maxB), - "p99_per_channel" to mapOf("R" to p99R, "G" to p99G, "B" to p99B), - "saturated_pixel_ratio" to (saturatedCount.toDouble() / totalBayerPixels), - "underexposed_pixel_ratio" to (underexposedCount.toDouble() / totalBayerPixels), - "thresholds" to mapOf( - "saturated" to SATURATION_THRESHOLD, - "underexposed" to UNDEREXPOSED_THRESHOLD, - ), - ) - } - - /// 撮影メタデータ Map を構築する. - private fun buildMetadataMap( - cameraId: String, - characteristics: CameraCharacteristics, - captureResult: TotalCaptureResult, - image: android.media.Image, - imageStatistics: Map? = null, - hotPixelMap: Map? = null, - ): Map { - val settingsMap = mapOf( - "control_mode" to "OFF", - "ae_mode" to "OFF", - "exposure_time_ns" to DEFAULT_EXPOSURE_TIME_NS, - "sensor_sensitivity_iso" to DEFAULT_SENSITIVITY, - "frame_duration_ns" to DEFAULT_FRAME_DURATION_NS, - "awb_mode" to "OFF", - "color_correction_gains" to listOf(1.0, 1.0, 1.0, 1.0), - "color_correction_transform" to "identity", - "noise_reduction_mode" to "OFF", - "edge_mode" to "OFF", - "tonemap_mode" to "CONTRAST_CURVE_LINEAR", - "shading_mode" to "HIGH_QUALITY", - "statistics_lens_shading_map_mode" to "ON", - "statistics_hot_pixel_map_mode" to "ON", - "af_mode" to "OFF", - "black_level_lock" to true, - ) - - val actualMap = mapOf( - "exposure_time_ns" to captureResult.get(CaptureResult.SENSOR_EXPOSURE_TIME), - "sensor_sensitivity" to captureResult.get(CaptureResult.SENSOR_SENSITIVITY), - "frame_duration_ns" to captureResult.get(CaptureResult.SENSOR_FRAME_DURATION), - "sensor_timestamp" to captureResult.get(CaptureResult.SENSOR_TIMESTAMP), - "dynamic_black_level" to - captureResult.get(CaptureResult.SENSOR_DYNAMIC_BLACK_LEVEL)?.toList(), - "dynamic_white_level" to - captureResult.get(CaptureResult.SENSOR_DYNAMIC_WHITE_LEVEL), - "neutral_color_point" to - captureResult.get(CaptureResult.SENSOR_NEUTRAL_COLOR_POINT)?.map { - "${it.numerator}/${it.denominator}" - }, - "color_gains" to captureResult.get(CaptureResult.COLOR_CORRECTION_GAINS)?.let { - listOf( - it.red.toDouble(), - it.greenEven.toDouble(), - it.greenOdd.toDouble(), - it.blue.toDouble() - ) - }, - "color_transform" to - captureResult.get(CaptureResult.COLOR_CORRECTION_TRANSFORM)?.let { transform -> - (0 until 3).map { row -> - (0 until 3).map { col -> - val r = transform.getElement(col, row) - "${r.numerator}/${r.denominator}" - } - } - }, - ) - - val sensorCharacteristicsMap = mapOf( - "color_filter_arrangement" to - characteristics.get( - CameraCharacteristics.SENSOR_INFO_COLOR_FILTER_ARRANGEMENT - ), - "black_level_pattern" to - characteristics.get( - CameraCharacteristics.SENSOR_BLACK_LEVEL_PATTERN - )?.toString(), - "white_level" to characteristics.get(CameraCharacteristics.SENSOR_INFO_WHITE_LEVEL), - "physical_size" to - characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)?.let { - listOf(it.width, it.height) - }, - "pixel_array_size" to - characteristics.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)?.let { - listOf(it.width, it.height) - }, - "active_array_size" to - characteristics.get( - CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE - )?.toString(), - ) - - return mapOf( - "captureTimestampUtc" to Instant.now().toString(), - "deviceModel" to Build.MODEL, - "androidVersion" to Build.VERSION.RELEASE, - "cameraId" to cameraId, - "hardwareLevel" to - characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL), - "captureMode" to "quantitative", - "imageFormat" to "RAW_SENSOR", - "imageSize" to listOf(image.width, image.height), - "settings" to settingsMap, - "actual" to actualMap, - "sensorCharacteristics" to sensorCharacteristicsMap, - "image_statistics" to imageStatistics, - "hot_pixel_map" to hotPixelMap, - ) - } - /** フロントカメラの ID を返す.見つからない場合は null を返す.*/ private fun findFrontCameraId(cameraManager: CameraManager): String? = cameraManager.cameraIdList.firstOrNull { id -> @@ -1005,74 +745,6 @@ } } - /// Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードする. - private fun encodePngUncompressed(bitmap: Bitmap): ByteArray { - val w = bitmap.width - val h = bitmap.height - val out = ByteArrayOutputStream(w * h * 3 + 1024) - - // PNG シグネチャ - out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) - - // IHDR チャンク - val ihdrData = ByteBuffer.allocate(13) - .putInt(w) - .putInt(h) - .put(8) // bit depth - .put(2) // color type: RGB - .put(0) // compression method - .put(0) // filter method - .put(0) // interlace method - .array() - writeChunk(out, "IHDR", ihdrData) - - // IDAT チャンク: 非圧縮 zlib でラップした RGB データ - val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64) - val deflater = Deflater(Deflater.NO_COMPRESSION) - val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536) - - val pixels = IntArray(w) - val rowBytes = ByteArray(1 + w * 3) // フィルタバイト(0) + RGB - rowBytes[0] = 0 // フィルタ: None - - for (y in 0 until h) { - bitmap.getPixels(pixels, 0, w, 0, y, w, 1) - for (x in 0 until w) { - val pixel = pixels[x] - rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte() // R - rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte() // G - rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte() // B - } - deflaterOut.write(rowBytes) - } - deflaterOut.finish() - deflaterOut.close() - deflater.end() - - writeChunk(out, "IDAT", idatStream.toByteArray()) - - // IEND チャンク - writeChunk(out, "IEND", byteArrayOf()) - - return out.toByteArray() - } - - /// PNG チャンクを書き込む(length + type + data + CRC). - private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) { - val typeBytes = type.toByteArray(Charsets.US_ASCII) - // length (4 bytes, big-endian) - out.write(ByteBuffer.allocate(4).putInt(data.size).array()) - // type (4 bytes) - out.write(typeBytes) - // data - out.write(data) - // CRC (type + data) - val crc = CRC32() - crc.update(typeBytes) - crc.update(data) - out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array()) - } - /** * フロントカメラの Camera2 API 能力を読み出して Map で返す. * 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 new file mode 100644 index 0000000..68ee0d5 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mini_tias/YuvJpegConverter.kt @@ -0,0 +1,77 @@ +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() + } + } +}