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 index 51d481c..3b19bf4 100644 --- a/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/mini_tias/MainActivity.kt @@ -1,5 +1,14 @@ 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() +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/RawCapturePlugin.kt b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt new file mode 100644 index 0000000..e6f8962 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/mini_tias/RawCapturePlugin.kt @@ -0,0 +1,247 @@ +package com.example.mini_tias + +import android.content.Context +import android.graphics.ImageFormat +import android.hardware.camera2.* +import android.media.ImageReader +import android.media.MediaScannerConnection +import android.os.Handler +import android.os.HandlerThread +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * 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 + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "captureFullResolutionYuv" -> captureFullResolutionYuv(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) + } + } + else -> result.notImplemented() + } + } + + private fun captureFullResolutionYuv(result: MethodChannel.Result) { + startBackgroundThread() + + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + val cameraId = cameraManager.cameraIdList.firstOrNull { id -> + val characteristics = cameraManager.getCameraCharacteristics(id) + characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT + } + + 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 + ) + + 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 -> + val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener + + try { + val yPlane = image.planes[0] + val uPlane = image.planes[1] + val vPlane = image.planes[2] + + 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 data = mapOf( + "width" to image.width, + "height" to image.height, + "yPlane" to yBytes, + "uPlane" to uBytes, + "vPlane" to vBytes, + "yRowStride" to yPlane.rowStride, + "uvRowStride" to uPlane.rowStride, + "uvPixelStride" to uPlane.pixelStride + ) + + image.close() + reader.close() + camera.close() + cameraDevice = null + + Handler(context.mainLooper).post { + result.success(data) + stopBackgroundThread() + } + } 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", "カメラの権限がありません") + } + } + + 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/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 0b0e3d8..db5e749 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -3,11 +3,13 @@ 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'; /// カメラの初期化・プレビュー制御・撮影実行・ライフサイクルを管理する. class CameraProvider extends ChangeNotifier { final PermissionService _permissionService = PermissionService(); final FileService _fileService = FileService(); + final RawCaptureService _rawCaptureService = RawCaptureService(); CameraController? _controller; bool _isInitialized = false; @@ -15,7 +17,6 @@ bool _permissionDenied = false; bool _permissionPermanentlyDenied = false; String? _lastSavedFileName; - CameraImage? _latestFrame; bool _isSaving = false; CameraController? get controller => _controller; @@ -26,7 +27,7 @@ String? get lastSavedFileName => _lastSavedFileName; bool get isSaving => _isSaving; - /// カメラを初期化し,画像ストリームを開始する. + /// カメラを初期化する. Future initialize() async { final granted = await _permissionService.requestCamera(); if (!granted) { @@ -51,17 +52,10 @@ frontCamera, ResolutionPreset.max, enableAudio: false, - imageFormatGroup: ImageFormatGroup.yuv420, ); await _controller!.initialize(); await _controller!.setFlashMode(FlashMode.off); - - // 画像ストリームを開始し,最新フレームを保持する - await _controller!.startImageStream((image) { - _latestFrame = image; - }); - _isInitialized = true; _errorMessage = null; } catch (e) { @@ -72,11 +66,11 @@ notifyListeners(); } - /// 最新フレームを PNG 形式で保存する. + /// Camera2 API でフル解像度 YUV キャプチャし,PNG で保存する. /// - /// YUV 生データから直接 PNG に変換するため画質劣化がない. + /// プレビューを一時停止 → Camera2 でキャプチャ → プレビュー再開. Future takePicture() async { - if (_latestFrame == null || _isSaving) return; + if (!_isInitialized || _isSaving) return; _isSaving = true; notifyListeners(); @@ -91,7 +85,22 @@ return; } - final savedPath = await _fileService.saveImageFromYuv(_latestFrame!); + // プレビューを停止してカメラを解放 + await _controller?.dispose(); + _controller = null; + _isInitialized = false; + + // カメラが完全に解放されるまで待つ + await Future.delayed(const Duration(milliseconds: 500)); + + // Camera2 API でフル解像度 YUV キャプチャ + final yuvData = await _rawCaptureService.captureFullResolutionYuv(); + + // PNG に変換して保存 + final savedPath = await _fileService.saveImageFromYuvData(yuvData); + + // MediaStore に登録(PC から MTP で見えるようにする) + await _rawCaptureService.scanFile(savedPath); _lastSavedFileName = savedPath.split('/').last; _errorMessage = null; @@ -102,11 +111,13 @@ _isSaving = false; notifyListeners(); + + // プレビューを再開 + await initialize(); } /// カメラリソースを解放する. void disposeCamera() { - _latestFrame = null; _controller?.dispose(); _controller = null; _isInitialized = false; diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index e782ac1..8495733 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:image/image.dart' as img; @@ -11,33 +10,70 @@ /// 保存先ディレクトリのパスを返す. String get directoryPath => _basePath; - /// CameraImage(YUV420)を PNG に変換して保存する. + /// Platform Channel から受け取った YUV データを PNG に変換して保存する. /// - /// JPEG を経由せず,生データから直接 PNG に変換するため画質劣化がない. - /// 保存したファイルのパスを返す. - Future saveImageFromYuv(CameraImage cameraImage) async { + /// Camera2 API でフル解像度キャプチャした生データを直接変換するため, + /// JPEG 圧縮を経由せず画質劣化がない. + Future saveImageFromYuvData(Map yuvData) async { final directory = Directory(_basePath); if (!await directory.exists()) { await directory.create(recursive: true); } - // YUV → PNG 変換(重い処理なので isolate で実行) + final yRaw = yuvData['yPlane']; + final uRaw = yuvData['uPlane']; + final vRaw = yuvData['vPlane']; + + final Uint8List yPlane; + final Uint8List uPlane; + final Uint8List vPlane; + + if (yRaw is Uint8List) { + yPlane = yRaw; + uPlane = uRaw as Uint8List; + vPlane = vRaw as Uint8List; + } else { + yPlane = Uint8List.fromList((yRaw as List).cast()); + uPlane = Uint8List.fromList((uRaw as List).cast()); + vPlane = Uint8List.fromList((vRaw as List).cast()); + } + + debugPrint( + 'YUV data: ${yuvData['width']}x${yuvData['height']}, ' + 'yPlane: ${yPlane.length} bytes, ' + 'uPlane: ${uPlane.length} bytes, ' + 'vPlane: ${vPlane.length} bytes', + ); + + if (yPlane.isEmpty) { + throw Exception('YUV データが空です'); + } + final params = { - 'width': cameraImage.width, - 'height': cameraImage.height, - 'yPlane': Uint8List.fromList(cameraImage.planes[0].bytes), - 'uPlane': Uint8List.fromList(cameraImage.planes[1].bytes), - 'vPlane': Uint8List.fromList(cameraImage.planes[2].bytes), - 'yRowStride': cameraImage.planes[0].bytesPerRow, - 'uvRowStride': cameraImage.planes[1].bytesPerRow, - 'uvPixelStride': cameraImage.planes[1].bytesPerPixel ?? 1, + 'width': yuvData['width'] as int, + 'height': yuvData['height'] as int, + 'yPlane': yPlane, + 'uPlane': uPlane, + 'vPlane': vPlane, + 'yRowStride': yuvData['yRowStride'] as int, + 'uvRowStride': yuvData['uvRowStride'] as int, + 'uvPixelStride': yuvData['uvPixelStride'] as int, }; + + debugPrint('PNG 変換開始...'); + final stopwatch = Stopwatch()..start(); final pngBytes = await compute(_convertYuvToPng, params); + stopwatch.stop(); + debugPrint( + 'PNG 変換完了: ${pngBytes.length} bytes, ' + '${stopwatch.elapsedMilliseconds}ms', + ); final fileName = await generateFileName(); final filePath = '$_basePath/$fileName'; final file = File(filePath); await file.writeAsBytes(pngBytes); + debugPrint('ファイル保存完了: $filePath'); return filePath; } diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart new file mode 100644 index 0000000..7e520a9 --- /dev/null +++ b/lib/services/raw_capture_service.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; + +/// Camera2 API を使用してフル解像度の YUV 画像をキャプチャするサービス. +class RawCaptureService { + static const _channel = MethodChannel('com.example.mini_tias/raw_capture'); + + /// フロントカメラからフル解像度の YUV データをキャプチャする. + /// + /// 返り値は YUV データを含む Map: + /// - width, height: 画像サイズ + /// - yPlane, uPlane, vPlane: YUV プレーンのバイトデータ + /// - yRowStride, uvRowStride, uvPixelStride: ストライド情報 + Future> captureFullResolutionYuv() async { + final result = await _channel.invokeMethod('captureFullResolutionYuv'); + return Map.from(result as Map); + } + + /// MediaStore にファイルを登録し,PC から MTP で見えるようにする. + Future scanFile(String path) async { + await _channel.invokeMethod('scanFile', {'path': path}); + } +}