From 765d8503b55e44a5170c8f93230b298e32bb203e Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:24:27 +0200 Subject: [PATCH] Refactoring: move camerax code to a new file --- .../java/org/mydomain/myscan/view/Camera.kt | 199 +--------------- .../org/mydomain/myscan/view/CameraPreview.kt | 220 ++++++++++++++++++ 2 files changed, 222 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt diff --git a/app/src/main/java/org/mydomain/myscan/view/Camera.kt b/app/src/main/java/org/mydomain/myscan/view/Camera.kt index 959d9df..0b38d6e 100644 --- a/app/src/main/java/org/mydomain/myscan/view/Camera.kt +++ b/app/src/main/java/org/mydomain/myscan/view/Camera.kt @@ -14,25 +14,8 @@ */ package org.mydomain.myscan.view -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.graphics.Bitmap import android.util.Log -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.LinearLayout -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy -import androidx.camera.core.Preview -import androidx.camera.core.resolutionselector.AspectRatioStrategy -import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Canvas import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -54,38 +37,20 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.graphics.scale -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.common.util.concurrent.ListenableFuture import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.MainViewModel -import org.mydomain.myscan.Point -import org.mydomain.myscan.scaledTo import org.mydomain.myscan.ui.theme.MyScanTheme -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -// TODO Split this big file @Composable fun CameraScreen( @@ -99,27 +64,11 @@ fun CameraScreen( val isProcessing = rememberSaveable { mutableStateOf(false) } val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle() - val context = LocalContext.current - val requestPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (!isGranted) { - Toast.makeText(context, "Camera permission was denied", Toast.LENGTH_SHORT).show() - } - } - val captureController = remember { CameraCaptureController() } DisposableEffect(Unit) { onDispose { captureController.shutdown() } } - LaunchedEffect(Unit) { - val camera = android.Manifest.permission.CAMERA - if (ContextCompat.checkSelfPermission(context, camera) != PERMISSION_GRANTED) { - requestPermissionLauncher.launch(camera) - } - } - CameraScreenContent( modifier, cameraPreview = { @@ -245,150 +194,6 @@ private fun CameraPreviewWithOverlay( } } -@Composable -fun CameraPreview( - modifier: Modifier = Modifier, - onImageAnalyzed: (ImageProxy) -> Unit, - captureController: CameraCaptureController, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val cameraProviderFuture by remember { - mutableStateOf(ProcessCameraProvider.getInstance(context)) - } - - DisposableEffect(lifecycleOwner) { - onDispose { - cameraProviderFuture.get().unbindAll() - } - } - - AndroidView(modifier = modifier, factory = { - val previewView = PreviewView(it).apply { - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - scaleType = PreviewView.ScaleType.FIT_CENTER - } - val executor = Executors.newSingleThreadExecutor() - cameraProviderFuture.addListener({ - bindCameraUseCases( - lifecycleOwner = lifecycleOwner, - cameraProviderFuture = cameraProviderFuture, - executor = executor, - previewView = previewView, - onImageAnalyzed = onImageAnalyzed, - captureController = captureController - ) - }, ContextCompat.getMainExecutor(context)) - - previewView - }) -} - -fun bindCameraUseCases( - lifecycleOwner: LifecycleOwner, - cameraProviderFuture: ListenableFuture, - executor: ExecutorService, - previewView: PreviewView, - onImageAnalyzed: (ImageProxy) -> Unit, - captureController: CameraCaptureController, -) { - val ratio_4_3 = ResolutionSelector.Builder() - .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) - .build() - val preview: Preview = Preview.Builder().setResolutionSelector(ratio_4_3).build() - preview.surfaceProvider = previewView.surfaceProvider - - val cameraSelector: CameraSelector = - CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() - val imageAnalysis = ImageAnalysis.Builder() - .setResolutionSelector(ratio_4_3) - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build() - imageAnalysis.setAnalyzer(executor, onImageAnalyzed) - - val imageCapture = ImageCapture.Builder() - .setResolutionSelector(ratio_4_3) - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - captureController.imageCapture = imageCapture - - val cameraProvider = cameraProviderFuture.get() - cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageAnalysis, preview, imageCapture) -} - -@Composable -private fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState) { - val binaryMask = liveAnalysisState.binaryMask - if (binaryMask == null) { - return - } - val maskOverlay = replaceColor(binaryMask, Color.Black, Color.Transparent) - Canvas(modifier = Modifier.fillMaxSize()) { - drawImage( - maskOverlay.scale(size.width.toInt(), size.height.toInt()).asImageBitmap(), - colorFilter = ColorFilter.tint(Color(0x8000FF00), BlendMode.SrcIn) - ) - if (liveAnalysisState.documentQuad != null) { - val scaledQuad = liveAnalysisState.documentQuad.scaledTo( - fromWidth = binaryMask.width, - fromHeight = binaryMask.height, - toWidth = size.width.toInt(), - toHeight = size.height.toInt() - ) - scaledQuad.edges().forEach { - drawLine(Color.Green, it.from.toOffset(), it.to.toOffset(), 5.0f) - } - } - } -} - -fun replaceColor(bitmap: Bitmap, toReplace: Color, replacement: Color): Bitmap { - val width = bitmap.width - val height = bitmap.height - val result = bitmap.copy(Bitmap.Config.ARGB_8888, true) - - val pixels = IntArray(width * height) - result.getPixels(pixels, 0, width, 0, 0, width, height) - - val target = toReplace.toArgb() - val newColor = replacement.toArgb() - - for (i in pixels.indices) { - if (pixels[i] == target) { - pixels[i] = newColor - } - } - - result.setPixels(pixels, 0, width, 0, 0, width, height) - return result -} - -fun Point.toOffset() = Offset(x.toFloat(), y.toFloat()) - -class CameraCaptureController { - var imageCapture: ImageCapture? = null - private val executor = Executors.newSingleThreadExecutor() - - fun shutdown() { - executor.shutdown() - } - - fun takePicture(onImageCaptured: (ImageProxy?) -> Unit) { - imageCapture?.takePicture( - executor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(imageProxy: ImageProxy) { - onImageCaptured(imageProxy) - } - override fun onError(exception: ImageCaptureException) { - Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception) - onImageCaptured(null) - } - } - ) - } -} - @Composable fun MessageBox(inferenceTime: Long) { Text( @@ -433,7 +238,7 @@ fun CameraScreenFooter( } } -@androidx.compose.ui.tooling.preview.Preview(showBackground = true) +@Preview(showBackground = true) @Composable fun CameraScreenPreview() { MyScanTheme { diff --git a/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt b/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt new file mode 100644 index 0000000..20a4b65 --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/view/CameraPreview.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.mydomain.myscan.view + +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.graphics.Bitmap +import android.util.Log +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.common.util.concurrent.ListenableFuture +import org.mydomain.myscan.LiveAnalysisState +import org.mydomain.myscan.Point +import org.mydomain.myscan.scaledTo +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@Composable +fun CameraPreview( + modifier: Modifier = Modifier, + onImageAnalyzed: (ImageProxy) -> Unit, + captureController: CameraCaptureController, +) { + val context = LocalContext.current + val requestPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (!isGranted) { + Toast.makeText(context, "Camera permission was denied", Toast.LENGTH_SHORT).show() + } + } + + LaunchedEffect(Unit) { + val camera = android.Manifest.permission.CAMERA + if (ContextCompat.checkSelfPermission(context, camera) != PERMISSION_GRANTED) { + requestPermissionLauncher.launch(camera) + } + } + + val cameraProviderFuture by remember { + mutableStateOf(ProcessCameraProvider.getInstance(context)) + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + onDispose { + cameraProviderFuture.get().unbindAll() + } + } + + AndroidView(modifier = modifier, factory = { + val previewView = PreviewView(it).apply { + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + scaleType = PreviewView.ScaleType.FIT_CENTER + } + val executor = Executors.newSingleThreadExecutor() + cameraProviderFuture.addListener({ + bindCameraUseCases( + lifecycleOwner = lifecycleOwner, + cameraProviderFuture = cameraProviderFuture, + executor = executor, + previewView = previewView, + onImageAnalyzed = onImageAnalyzed, + captureController = captureController + ) + }, ContextCompat.getMainExecutor(context)) + + previewView + }) +} + +fun bindCameraUseCases( + lifecycleOwner: LifecycleOwner, + cameraProviderFuture: ListenableFuture, + executor: ExecutorService, + previewView: PreviewView, + onImageAnalyzed: (ImageProxy) -> Unit, + captureController: CameraCaptureController, +) { + val ratio_4_3 = ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .build() + val preview: Preview = Preview.Builder().setResolutionSelector(ratio_4_3).build() + preview.surfaceProvider = previewView.surfaceProvider + + val cameraSelector: CameraSelector = + CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() + val imageAnalysis = ImageAnalysis.Builder() + .setResolutionSelector(ratio_4_3) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build() + imageAnalysis.setAnalyzer(executor, onImageAnalyzed) + + val imageCapture = ImageCapture.Builder() + .setResolutionSelector(ratio_4_3) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + captureController.imageCapture = imageCapture + + val cameraProvider = cameraProviderFuture.get() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageAnalysis, preview, imageCapture) +} + +@Composable +fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState) { + val binaryMask = liveAnalysisState.binaryMask + if (binaryMask == null) { + return + } + val maskOverlay = replaceColor(binaryMask, Color.Black, Color.Transparent) + Canvas(modifier = Modifier.fillMaxSize()) { + drawImage( + maskOverlay.scale(size.width.toInt(), size.height.toInt()).asImageBitmap(), + colorFilter = ColorFilter.tint(Color(0x8000FF00), BlendMode.SrcIn) + ) + if (liveAnalysisState.documentQuad != null) { + val scaledQuad = liveAnalysisState.documentQuad.scaledTo( + fromWidth = binaryMask.width, + fromHeight = binaryMask.height, + toWidth = size.width.toInt(), + toHeight = size.height.toInt() + ) + scaledQuad.edges().forEach { + drawLine(Color.Green, it.from.toOffset(), it.to.toOffset(), 5.0f) + } + } + } +} + +fun replaceColor(bitmap: Bitmap, toReplace: Color, replacement: Color): Bitmap { + val width = bitmap.width + val height = bitmap.height + val result = bitmap.copy(Bitmap.Config.ARGB_8888, true) + + val pixels = IntArray(width * height) + result.getPixels(pixels, 0, width, 0, 0, width, height) + + val target = toReplace.toArgb() + val newColor = replacement.toArgb() + + for (i in pixels.indices) { + if (pixels[i] == target) { + pixels[i] = newColor + } + } + + result.setPixels(pixels, 0, width, 0, 0, width, height) + return result +} + +fun Point.toOffset() = Offset(x.toFloat(), y.toFloat()) + +class CameraCaptureController { + var imageCapture: ImageCapture? = null + private val executor = Executors.newSingleThreadExecutor() + + fun shutdown() { + executor.shutdown() + } + + fun takePicture(onImageCaptured: (ImageProxy?) -> Unit) { + imageCapture?.takePicture( + executor, + object : ImageCapture.OnImageCapturedCallback() { + override fun onCaptureSuccess(imageProxy: ImageProxy) { + onImageCaptured(imageProxy) + } + override fun onError(exception: ImageCaptureException) { + Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception) + onImageCaptured(null) + } + } + ) + } +}