Refactoring: move camerax code to a new file

This commit is contained in:
Pierre-Yves Nicolas
2025-06-23 13:24:27 +02:00
parent b8a8e6f2f3
commit 765d8503b5
2 changed files with 222 additions and 197 deletions

View File

@@ -14,25 +14,8 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.graphics.Bitmap
import android.util.Log 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.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.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -54,38 +37,20 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.common.util.concurrent.ListenableFuture
import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.Point
import org.mydomain.myscan.scaledTo
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
// TODO Split this big file
@Composable @Composable
fun CameraScreen( fun CameraScreen(
@@ -99,27 +64,11 @@ fun CameraScreen(
val isProcessing = rememberSaveable { mutableStateOf(false) } val isProcessing = rememberSaveable { mutableStateOf(false) }
val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle() 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() } val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { captureController.shutdown() } onDispose { captureController.shutdown() }
} }
LaunchedEffect(Unit) {
val camera = android.Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(context, camera) != PERMISSION_GRANTED) {
requestPermissionLauncher.launch(camera)
}
}
CameraScreenContent( CameraScreenContent(
modifier, modifier,
cameraPreview = { 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<ProcessCameraProvider>,
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 @Composable
fun MessageBox(inferenceTime: Long) { fun MessageBox(inferenceTime: Long) {
Text( Text(
@@ -433,7 +238,7 @@ fun CameraScreenFooter(
} }
} }
@androidx.compose.ui.tooling.preview.Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CameraScreenPreview() { fun CameraScreenPreview() {
MyScanTheme { MyScanTheme {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ProcessCameraProvider>,
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)
}
}
)
}
}