Newer
Older
SmTIAS-Capture / android / app / src / main / kotlin / com / example / mini_tias / RawCapturePlugin.kt
package com.example.mini_tias

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
import android.hardware.camera2.params.TonemapCurve
import android.media.ImageReader
import android.media.MediaActionSound
import android.media.MediaScannerConnection
import android.media.RingtoneManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.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

/** フレーム取得後に処理するコールバックの型エイリアス.*/
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
        const val SATURATION_THRESHOLD = 1000
        const val UNDEREXPOSED_THRESHOLD = 100
        const val STATISTICS_HISTOGRAM_BINS = 256
        /** 10-bit RAW センサー値の範囲(0〜1023 の 1024 通り).ヒストグラム bin 変換に使用する.*/
        const val RAW_10BIT_RANGE = 1024
    }

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "captureFullResolutionPng" -> captureFullResolutionPng(result)
            "captureQuantitativeDng" -> {
                val baseName = call.argument<String>("baseName")
                val directoryPath = call.argument<String>("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<String>("path")
                if (path != null) {
                    MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf("image/png")) { _, _ ->
                        result.success(null)
                    }
                } else {
                    result.error("INVALID_PATH", "パスが指定されていません", null)
                }
            }
            "playShutterSound" -> {
                try {
                    mediaActionSound.play(MediaActionSound.SHUTTER_CLICK)
                    result.success(null)
                } catch (e: Exception) {
                    result.error("SOUND_ERROR", "シャッター音の再生に失敗: ${e.message}", null)
                }
            }
            "playSaveCompleteSound" -> {
                try {
                    val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
                    val ringtone = RingtoneManager.getRingtone(context, notification)
                    ringtone?.play()
                    result.success(null)
                } catch (e: Exception) {
                    result.error("SOUND_ERROR", "保存完了音の再生に失敗: ${e.message}", null)
                }
            }
            "getCameraDiagnostics" -> getCameraDiagnostics(result)
            else -> result.notImplemented()
        }
    }

    /// YUV_420_888 を NV21 に変換し,YuvImage で JPEG 化して返す.
    private fun convertYuvToJpeg(call: MethodCall, result: MethodChannel.Result) {
        try {
            val width = call.argument<Int>("width")!!
            val height = call.argument<Int>("height")!!
            val yPlane = call.argument<ByteArray>("yPlane")!!
            val uPlane = call.argument<ByteArray>("uPlane")!!
            val vPlane = call.argument<ByteArray>("vPlane")!!
            val yRowStride = call.argument<Int>("yRowStride")!!
            val uvRowStride = call.argument<Int>("uvRowStride")!!
            val uvPixelStride = call.argument<Int>("uvPixelStride")!!
            val rotation = call.argument<Int>("rotation") ?: 0
            val quality = call.argument<Int>("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<Boolean>("mirror") ?: false
            val jpegBytes = if (rotation != 0 || mirror) {
                val bitmap = BitmapFactory.decodeByteArray(jpegStream.toByteArray(), 0, jpegStream.size())
                val matrix = Matrix()
                if (rotation != 0) matrix.postRotate(rotation.toFloat())
                if (mirror) matrix.postScale(-1f, 1f)
                val transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
                bitmap.recycle()

                val outStream = ByteArrayOutputStream()
                transformed.compress(Bitmap.CompressFormat.JPEG, quality, outStream)
                transformed.recycle()
                outStream.toByteArray()
            } else {
                jpegStream.toByteArray()
            }

            result.success(jpegBytes)
        } catch (e: Exception) {
            result.error("CONVERT_ERROR", "YUV to JPEG 変換に失敗: ${e.message}", null)
        }
    }

    private fun captureFullResolutionPng(result: MethodChannel.Result) {
        openCameraAndCapture(result) { yBytes, uBytes, vBytes, width, height, yRowStride, uvRowStride, uvPixelStride, reader, camera, res ->
            // YUV → RGB 変換 + 90° 時計回り回転を統合
            // 元の(x,y) → 回転後の(height-1-y, x)
            // 出力サイズ: width_out=height, height_out=width
            val rotatedPixels = IntArray(width * height)
            for (y in 0 until height) {
                for (x in 0 until width) {
                    val yIndex = y * yRowStride + x
                    val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride

                    val yValue = (yBytes[yIndex].toInt() and 0xFF).toDouble()
                    val uValue = (uBytes[uvIndex].toInt() and 0xFF).toDouble()
                    val vValue = (vBytes[uvIndex].toInt() and 0xFF).toDouble()

                    val r = (yValue + 1.402 * (vValue - 128)).roundToInt().coerceIn(0, 255)
                    val g = (yValue - 0.344 * (uValue - 128) - 0.714 * (vValue - 128)).roundToInt().coerceIn(0, 255)
                    val b = (yValue + 1.772 * (uValue - 128)).roundToInt().coerceIn(0, 255)

                    rotatedPixels[x * height + (height - 1 - y)] = Color.rgb(r, g, b)
                }
            }

            val rotatedWidth = height
            val rotatedHeight = width
            val bitmap = Bitmap.createBitmap(rotatedWidth, rotatedHeight, Bitmap.Config.ARGB_8888)
            bitmap.setPixels(rotatedPixels, 0, rotatedWidth, 0, 0, rotatedWidth, rotatedHeight)

            // 非圧縮 PNG エンコード(Deflater.NO_COMPRESSION で高速化)
            val pngBytes = encodePngUncompressed(bitmap)
            bitmap.recycle()

            reader.close()
            camera.close()
            cameraDevice = null

            Handler(context.mainLooper).post {
                res.success(pngBytes)
                stopBackgroundThread()
            }
        }
    }

    /**
     * 定量モード撮影.RAW_SENSOR (DNG) で保存し,メタデータと LSC マップを返す.
     *
     * CONTROL_MODE=OFF など完全マニュアル制御で連続 3 フレームを投げ,
     * 最初の 2 フレーム(設定反映待ち)を捨てて 3 枚目を DNG 化する.
     * DNG ファイルは Kotlin 側で [directoryPath]/[baseName].dng に直接書き込む.
     */
    private fun captureQuantitativeDng(
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
    ) {
        startBackgroundThread()

        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            returnError(result, "NO_CAMERA", "フロントカメラが見つかりません")
            return
        }

        val characteristics = cameraManager.getCameraCharacteristics(cameraId)

        // RAW_SENSOR サポート確認
        val capabilities =
            characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
        val supportsRaw =
            capabilities?.contains(
                CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW
            ) == true
        if (!supportsRaw) {
            returnError(result, "NOT_SUPPORTED", "このカメラは RAW_SENSOR をサポートしていません")
            return
        }

        val imageReader = ImageReader.newInstance(
            RAW_WIDTH, RAW_HEIGHT, ImageFormat.RAW_SENSOR, 3
        )

        var resultSent = false

        try {
            cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(camera: CameraDevice) {
                    cameraDevice = camera
                    try {
                        camera.createCaptureSession(
                            listOf(imageReader.surface),
                            object : CameraCaptureSession.StateCallback() {
                                override fun onConfigured(session: CameraCaptureSession) {
                                    try {
                                        openCameraAndCaptureRaw(
                                            camera, session, imageReader, characteristics,
                                            cameraId, baseName, directoryPath,
                                            result, { sent -> resultSent = sent }
                                        )
                                    } catch (e: Exception) {
                                        if (!resultSent) {
                                            resultSent = true
                                            cleanup()
                                            imageReader.close()
                                            Handler(context.mainLooper).post {
                                                result.error(
                                                    "CAPTURE_ERROR",
                                                    "定量キャプチャに失敗: ${e.message}",
                                                    null
                                                )
                                            }
                                        }
                                    }
                                }

                                override fun onConfigureFailed(session: CameraCaptureSession) {
                                    if (!resultSent) {
                                        resultSent = true
                                        cleanup()
                                        imageReader.close()
                                        Handler(context.mainLooper).post {
                                            result.error(
                                                "SESSION_ERROR",
                                                "カメラセッションの設定に失敗",
                                                null
                                            )
                                        }
                                    }
                                }
                            },
                            backgroundHandler
                        )
                    } catch (e: Exception) {
                        if (!resultSent) {
                            resultSent = true
                            cleanup()
                            imageReader.close()
                            Handler(context.mainLooper).post {
                                result.error(
                                    "CAPTURE_ERROR",
                                    "定量キャプチャに失敗: ${e.message}",
                                    null
                                )
                            }
                        }
                    }
                }

                override fun onDisconnected(camera: CameraDevice) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    stopBackgroundThread()
                }

                override fun onError(camera: CameraDevice, error: Int) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    if (!resultSent) {
                        resultSent = true
                        Handler(context.mainLooper).post {
                            result.error("CAMERA_ERROR", "カメラエラー: $error", null)
                            stopBackgroundThread()
                        }
                    }
                }
            }, backgroundHandler)
        } catch (e: SecurityException) {
            imageReader.close()
            returnError(result, "PERMISSION_ERROR", "カメラの権限がありません")
        }
    }

    /**
     * 定量モード RAW キャプチャの実処理.
     *
     * session が onConfigured 後に呼ばれる.FRAMES_TO_DISCARD 枚を捨てた後,
     * 3 枚目の Image と TotalCaptureResult を DngCreator に渡して DNG バイト列を生成する.
     */
    private fun openCameraAndCaptureRaw(
        camera: CameraDevice,
        session: CameraCaptureSession,
        imageReader: ImageReader,
        characteristics: CameraCharacteristics,
        cameraId: String,
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
        setResultSent: (Boolean) -> Unit,
    ) {
        var capturedImage: android.media.Image? = null
        var captureResult: TotalCaptureResult? = null
        // 画像到着カウント(ImageAvailableListener 側)
        var imageArrivalCount = 0
        // CaptureResult 到着カウント(CaptureCallback 側)
        var resultArrivalCount = 0
        var resultSentFlag = false

        fun sendResultSafe(action: () -> Unit) {
            if (!resultSentFlag) {
                resultSentFlag = true
                setResultSent(true)
                action()
            }
        }

        // 完全マニュアル制御の CaptureRequest を構築
        val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
            addTarget(imageReader.surface)

            set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_OFF)
            set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF)
            set(CaptureRequest.SENSOR_EXPOSURE_TIME, DEFAULT_EXPOSURE_TIME_NS)
            set(CaptureRequest.SENSOR_SENSITIVITY, DEFAULT_SENSITIVITY)
            set(CaptureRequest.SENSOR_FRAME_DURATION, DEFAULT_FRAME_DURATION_NS)
            set(CaptureRequest.BLACK_LEVEL_LOCK, true)
            set(CaptureRequest.CONTROL_AWB_MODE, CaptureRequest.CONTROL_AWB_MODE_OFF)
            set(CaptureRequest.COLOR_CORRECTION_MODE,
                CaptureRequest.COLOR_CORRECTION_MODE_TRANSFORM_MATRIX)
            set(CaptureRequest.COLOR_CORRECTION_GAINS, RggbChannelVector(1.0f, 1.0f, 1.0f, 1.0f))
            // 単位行列(3×3,Rational 型)
            set(
                CaptureRequest.COLOR_CORRECTION_TRANSFORM,
                ColorSpaceTransform(
                    intArrayOf(
                        1, 1, 0, 1, 0, 1,
                        0, 1, 1, 1, 0, 1,
                        0, 1, 0, 1, 1, 1,
                    )
                )
            )
            set(CaptureRequest.NOISE_REDUCTION_MODE, CaptureRequest.NOISE_REDUCTION_MODE_OFF)
            set(CaptureRequest.EDGE_MODE, CaptureRequest.EDGE_MODE_OFF)
            set(CaptureRequest.TONEMAP_MODE, CaptureRequest.TONEMAP_MODE_CONTRAST_CURVE)
            // 線形トーンマップカーブ(各チャネル: (0,0)→(1,1))
            set(
                CaptureRequest.TONEMAP_CURVE,
                TonemapCurve(
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                    floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f),
                )
            )
            set(CaptureRequest.SHADING_MODE, CaptureRequest.SHADING_MODE_HIGH_QUALITY)
            set(CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE,
                CaptureRequest.STATISTICS_LENS_SHADING_MAP_MODE_ON)
            // Hot pixel map を有効化(STATISTICS_HOT_PIXEL_MAP_MODE は Boolean 型)
            set(CaptureRequest.STATISTICS_HOT_PIXEL_MAP_MODE, true)
            set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF)
            set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
            set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
                CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF)
        }.build()

        val captureCallback = object : CameraCaptureSession.CaptureCallback() {
            override fun onCaptureCompleted(
                session: CameraCaptureSession,
                request: CaptureRequest,
                totalResult: TotalCaptureResult,
            ) {
                resultArrivalCount++
                // FRAMES_TO_DISCARD + 1 枚目(最後)だけ CaptureResult を保持
                if (resultArrivalCount == FRAMES_TO_DISCARD + 1) {
                    captureResult = totalResult
                    tryProcessRaw(
                        capturedImage, captureResult, imageReader, camera,
                        characteristics, cameraId, baseName, directoryPath,
                        result, ::sendResultSafe
                    )
                }
            }

            override fun onCaptureFailed(
                session: CameraCaptureSession,
                request: CaptureRequest,
                failure: CaptureFailure,
            ) {
                sendResultSafe {
                    capturedImage?.close()
                    imageReader.close()
                    cleanup()
                    Handler(context.mainLooper).post {
                        result.error("CAPTURE_FAILED", "キャプチャに失敗しました: ${failure.reason}", null)
                    }
                }
            }
        }

        imageReader.setOnImageAvailableListener({ reader ->
            val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
            imageArrivalCount++
            // 最初の FRAMES_TO_DISCARD 枚は捨てる(設定反映待ち)
            if (imageArrivalCount <= FRAMES_TO_DISCARD) {
                image.close()
                return@setOnImageAvailableListener
            }
            // FRAMES_TO_DISCARD + 1 枚目だけ保持
            if (capturedImage == null) {
                capturedImage = image
                tryProcessRaw(
                    capturedImage, captureResult, imageReader, camera,
                    characteristics, cameraId, baseName, directoryPath,
                    result, ::sendResultSafe
                )
            } else {
                image.close()
            }
        }, backgroundHandler)

        // 連続 3 回キャプチャを投げる
        repeat(FRAMES_TO_DISCARD + 1) {
            session.capture(captureRequest, captureCallback, backgroundHandler)
        }
    }

    /**
     * 画像と CaptureResult の両方が揃ったら DNG を生成して result に返す.
     *
     * 片方がまだない場合は何もしない(もう一方の到着を待つ).
     */
    private fun tryProcessRaw(
        image: android.media.Image?,
        captureResult: TotalCaptureResult?,
        imageReader: ImageReader,
        camera: CameraDevice,
        characteristics: CameraCharacteristics,
        cameraId: String,
        baseName: String,
        directoryPath: String,
        result: MethodChannel.Result,
        sendResultSafe: (() -> Unit) -> Unit,
    ) {
        if (image == null || captureResult == null) return

        sendResultSafe {
            try {
                // DNG をファイルに直接書き込む(MethodChannel 越しの巨大バイト転送を回避)
                val dir = File(directoryPath)
                if (!dir.exists()) dir.mkdirs()
                val dngFile = File(dir, "$baseName.dng")
                FileOutputStream(dngFile).use { fos ->
                    DngCreator(characteristics, captureResult).use { dngCreator ->
                        dngCreator.writeImage(fos, image)
                    }
                }
                Log.d("RawCapturePlugin", "DNG written: ${dngFile.length()} bytes to ${dngFile.absolutePath}")

                // 画素統計を計算(DNG 書き込み後・image.close() 前)
                val imageStatistics: Map<String, Any?>? = try {
                    computeImageStatistics(image)
                } catch (e: Exception) {
                    Log.w("RawCapturePlugin", "Image statistics computation failed", e)
                    null
                }

                // Hot pixel map を CaptureResult から取得
                val hotPixelMapData: Map<String, Any?> = 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<List<Int>>()),
                    )
                } catch (e: Exception) {
                    Log.w("RawCapturePlugin", "Hot pixel map retrieval failed", e)
                    mapOf("mode" to "ON", "count" to 0,
                        "coordinates" to emptyList<List<Int>>())
                }

                Log.d("RawCapturePlugin",
                    "Image stats: $imageStatistics, hot pixels: ${hotPixelMapData["count"]}")

                // LSC マップを取得
                val lscMap =
                    captureResult.get(CaptureResult.STATISTICS_LENS_SHADING_CORRECTION_MAP)
                val lscData: List<List<Float>>? = lscMap?.let { map ->
                    val rows = map.rowCount
                    val cols = map.columnCount
                    val gainFactors = FloatArray(rows * cols * 4)
                    map.copyGainFactors(gainFactors, 0)
                    (0 until rows).map { row ->
                        gainFactors.slice(row * cols * 4 until (row + 1) * cols * 4)
                    }
                }

                // メタデータを構築
                val metadataMap = buildMetadataMap(
                    cameraId, characteristics, captureResult, image,
                    imageStatistics, hotPixelMapData,
                )

                image.close()
                imageReader.close()
                camera.close()
                cameraDevice = null

                val resultMap = mapOf<String, Any?>(
                    "dngPath" to dngFile.absolutePath,
                    "dngFileSize" to dngFile.length(),
                    "metadata" to metadataMap,
                    "lscMap" to lscData,
                    "lscMapRowCount" to lscMap?.rowCount,
                    "lscMapColumnCount" to lscMap?.columnCount,
                )

                Handler(context.mainLooper).post {
                    result.success(resultMap)
                    stopBackgroundThread()
                }
            } catch (e: Exception) {
                image.close()
                imageReader.close()
                cleanup()
                Handler(context.mainLooper).post {
                    result.error("DNG_ERROR", "DNG 生成に失敗: ${e.message}", null)
                    stopBackgroundThread()
                }
            }
        }
    }

    /**
     * RAW_SENSOR 画像から画素統計を計算する.
     *
     * Bayer 配列 (BGGR) を 2×2 ブロック単位で処理し,
     * チャネル別の平均・最大・P99 値,飽和率・低露光率,ヒストグラムを返す.
     * 計算失敗時は呼び出し元で null として扱う.
     */
    private fun computeImageStatistics(image: android.media.Image): Map<String, Any?> {
        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<String, Any?>? = null,
        hotPixelMap: Map<String, Any?>? = null,
    ): Map<String, Any?> {
        val settingsMap = mapOf<String, Any?>(
            "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<String, Any?>(
            "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<String, Any?>(
            "color_filter_arrangement" to
                characteristics.get(
                    CameraCharacteristics.SENSOR_INFO_COLOR_FILTER_ARRANGEMENT
                ),
            "black_level_pattern" to
                characteristics.get(
                    CameraCharacteristics.SENSOR_BLACK_LEVEL_PATTERN
                )?.toString(),
            "white_level" to characteristics.get(CameraCharacteristics.SENSOR_INFO_WHITE_LEVEL),
            "physical_size" to
                characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)?.let {
                    listOf(it.width, it.height)
                },
            "pixel_array_size" to
                characteristics.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)?.let {
                    listOf(it.width, it.height)
                },
            "active_array_size" to
                characteristics.get(
                    CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE
                )?.toString(),
        )

        return mapOf(
            "captureTimestampUtc" to Instant.now().toString(),
            "deviceModel" to Build.MODEL,
            "androidVersion" to Build.VERSION.RELEASE,
            "cameraId" to cameraId,
            "hardwareLevel" to
                characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL),
            "captureMode" to "quantitative",
            "imageFormat" to "RAW_SENSOR",
            "imageSize" to listOf(image.width, image.height),
            "settings" to settingsMap,
            "actual" to actualMap,
            "sensorCharacteristics" to sensorCharacteristicsMap,
            "image_statistics" to imageStatistics,
            "hot_pixel_map" to hotPixelMap,
        )
    }

    /** フロントカメラの ID を返す.見つからない場合は null を返す.*/
    private fun findFrontCameraId(cameraManager: CameraManager): String? =
        cameraManager.cameraIdList.firstOrNull { id ->
            val characteristics = cameraManager.getCameraCharacteristics(id)
            characteristics.get(CameraCharacteristics.LENS_FACING) ==
                CameraCharacteristics.LENS_FACING_FRONT
        }

    /**
     * フロントカメラを開いてフル解像度の YUV フレームを 1 枚取得し,[onFrame] を呼び出す.
     *
     * カメラのオープン・セッション作成・AE/AF 安定待機(1 秒)・本番キャプチャの
     * 共通フローを担う.フレーム取得後の処理は [onFrame] で差異化する.
     * [onFrame] 内で reader と camera のクローズおよび result への応答を行うこと.
     */
    private fun openCameraAndCapture(
        result: MethodChannel.Result,
        onFrame: FrameProcessor,
    ) {
        startBackgroundThread()

        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            returnError(result, "NO_CAMERA", "フロントカメラが見つかりません")
            return
        }

        val characteristics = cameraManager.getCameraCharacteristics(cameraId)
        val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)

        if (streamConfigMap == null) {
            returnError(result, "NO_CONFIG", "カメラの設定を取得できません")
            return
        }

        val yuvSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
        val maxSize = yuvSizes.maxByOrNull { it.width * it.height }

        if (maxSize == null) {
            returnError(result, "NO_SIZE", "YUV の解像度を取得できません")
            return
        }

        val imageReader = ImageReader.newInstance(
            maxSize.width, maxSize.height, ImageFormat.YUV_420_888, 2
        )

        var resultSent = false

        try {
            cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(camera: CameraDevice) {
                    cameraDevice = camera
                    try {
                        camera.createCaptureSession(
                            listOf(imageReader.surface),
                            object : CameraCaptureSession.StateCallback() {
                                override fun onConfigured(session: CameraCaptureSession) {
                                    try {
                                        // Phase 1: プレビューを流して AE/AF を安定させる
                                        val previewRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
                                            addTarget(imageReader.surface)
                                            set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                                            set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
                                        }
                                        session.setRepeatingRequest(previewRequest.build(), null, backgroundHandler)

                                        // Phase 2: 1 秒後にリスナーを設定してから本番キャプチャ
                                        backgroundHandler?.postDelayed({
                                            try {
                                                session.stopRepeating()

                                                // プレビュー中のバッファを捨てる
                                                while (true) {
                                                    val stale = imageReader.acquireLatestImage()
                                                    if (stale != null) {
                                                        stale.close()
                                                    } else {
                                                        break
                                                    }
                                                }

                                                // ここでリスナーを設定(本番フレームのみ受信)
                                                imageReader.setOnImageAvailableListener({ reader ->
                                                    if (resultSent) return@setOnImageAvailableListener
                                                    val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
                                                    resultSent = true

                                                    try {
                                                        val yPlane = image.planes[0]
                                                        val uPlane = image.planes[1]
                                                        val vPlane = image.planes[2]

                                                        val width = image.width
                                                        val height = image.height

                                                        val yBytes = ByteArray(yPlane.buffer.remaining())
                                                        val uBytes = ByteArray(uPlane.buffer.remaining())
                                                        val vBytes = ByteArray(vPlane.buffer.remaining())

                                                        yPlane.buffer.get(yBytes)
                                                        uPlane.buffer.get(uBytes)
                                                        vPlane.buffer.get(vBytes)

                                                        val yRowStride = yPlane.rowStride
                                                        val uvRowStride = uPlane.rowStride
                                                        val uvPixelStride = uPlane.pixelStride

                                                        image.close()

                                                        onFrame(
                                                            yBytes, uBytes, vBytes,
                                                            width, height,
                                                            yRowStride, uvRowStride, uvPixelStride,
                                                            reader, camera, result
                                                        )
                                                    } catch (e: Exception) {
                                                        image.close()
                                                        reader.close()
                                                        cleanup()
                                                        Handler(context.mainLooper).post {
                                                            result.error("PROCESS_ERROR", "画像データの処理に失敗: ${e.message}", null)
                                                        }
                                                    }
                                                }, backgroundHandler)

                                                // 本番キャプチャ実行
                                                val captureRequest = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
                                                    addTarget(imageReader.surface)
                                                    set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
                                                    set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF)
                                                }
                                                session.capture(captureRequest.build(), null, backgroundHandler)
                                            } catch (e: Exception) {
                                                cleanup()
                                                imageReader.close()
                                                Handler(context.mainLooper).post {
                                                    result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                                                }
                                            }
                                        }, 1000)
                                    } catch (e: Exception) {
                                        cleanup()
                                        imageReader.close()
                                        Handler(context.mainLooper).post {
                                            result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                                        }
                                    }
                                }

                                override fun onConfigureFailed(session: CameraCaptureSession) {
                                    cleanup()
                                    imageReader.close()
                                    Handler(context.mainLooper).post {
                                        result.error("SESSION_ERROR", "カメラセッションの設定に失敗", null)
                                    }
                                }
                            },
                            backgroundHandler
                        )
                    } catch (e: Exception) {
                        cleanup()
                        imageReader.close()
                        Handler(context.mainLooper).post {
                            result.error("CAPTURE_ERROR", "キャプチャに失敗: ${e.message}", null)
                        }
                    }
                }

                override fun onDisconnected(camera: CameraDevice) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    stopBackgroundThread()
                }

                override fun onError(camera: CameraDevice, error: Int) {
                    camera.close()
                    cameraDevice = null
                    imageReader.close()
                    Handler(context.mainLooper).post {
                        result.error("CAMERA_ERROR", "カメラエラー: $error", null)
                        stopBackgroundThread()
                    }
                }
            }, backgroundHandler)
        } catch (e: SecurityException) {
            imageReader.close()
            returnError(result, "PERMISSION_ERROR", "カメラの権限がありません")
        }
    }

    /// Bitmap を非圧縮 PNG(Deflater.NO_COMPRESSION)としてエンコードする.
    private fun encodePngUncompressed(bitmap: Bitmap): ByteArray {
        val w = bitmap.width
        val h = bitmap.height
        val out = ByteArrayOutputStream(w * h * 3 + 1024)

        // PNG シグネチャ
        out.write(byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A))

        // IHDR チャンク
        val ihdrData = ByteBuffer.allocate(13)
            .putInt(w)
            .putInt(h)
            .put(8)  // bit depth
            .put(2)  // color type: RGB
            .put(0)  // compression method
            .put(0)  // filter method
            .put(0)  // interlace method
            .array()
        writeChunk(out, "IHDR", ihdrData)

        // IDAT チャンク: 非圧縮 zlib でラップした RGB データ
        val idatStream = ByteArrayOutputStream(w * h * 3 + h + 64)
        val deflater = Deflater(Deflater.NO_COMPRESSION)
        val deflaterOut = DeflaterOutputStream(idatStream, deflater, 65536)

        val pixels = IntArray(w)
        val rowBytes = ByteArray(1 + w * 3)  // フィルタバイト(0) + RGB
        rowBytes[0] = 0  // フィルタ: None

        for (y in 0 until h) {
            bitmap.getPixels(pixels, 0, w, 0, y, w, 1)
            for (x in 0 until w) {
                val pixel = pixels[x]
                rowBytes[1 + x * 3] = ((pixel shr 16) and 0xFF).toByte()  // R
                rowBytes[1 + x * 3 + 1] = ((pixel shr 8) and 0xFF).toByte()  // G
                rowBytes[1 + x * 3 + 2] = (pixel and 0xFF).toByte()  // B
            }
            deflaterOut.write(rowBytes)
        }
        deflaterOut.finish()
        deflaterOut.close()
        deflater.end()

        writeChunk(out, "IDAT", idatStream.toByteArray())

        // IEND チャンク
        writeChunk(out, "IEND", byteArrayOf())

        return out.toByteArray()
    }

    /// PNG チャンクを書き込む(length + type + data + CRC).
    private fun writeChunk(out: ByteArrayOutputStream, type: String, data: ByteArray) {
        val typeBytes = type.toByteArray(Charsets.US_ASCII)
        // length (4 bytes, big-endian)
        out.write(ByteBuffer.allocate(4).putInt(data.size).array())
        // type (4 bytes)
        out.write(typeBytes)
        // data
        out.write(data)
        // CRC (type + data)
        val crc = CRC32()
        crc.update(typeBytes)
        crc.update(data)
        out.write(ByteBuffer.allocate(4).putInt(crc.value.toInt()).array())
    }

    /**
     * フロントカメラの Camera2 API 能力を読み出して Map で返す.
     *
     * CameraCharacteristics から各種パラメータを取得し,null セーフに処理する.
     * フロントカメラが見つからない場合は error を返す.
     */
    private fun getCameraDiagnostics(result: MethodChannel.Result) {
        val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager

        val cameraId = findFrontCameraId(cameraManager)

        if (cameraId == null) {
            result.error("NO_CAMERA", "フロントカメラが見つかりません", null)
            return
        }

        val c = cameraManager.getCameraCharacteristics(cameraId)
        val streamConfigMap = c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)

        // rawOutputSizes
        val rawOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.RAW_SENSOR)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        // yuvOutputSizes
        val yuvOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.YUV_420_888)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        // jpegOutputSizes
        val jpegOutputSizes = streamConfigMap
            ?.getOutputSizes(ImageFormat.JPEG)
            ?.map { "${it.width}x${it.height}" }
            ?: emptyList<String>()

        val diagnostics = mapOf<String, Any?>(
            "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
    }
}