From 6ccf7081b9f4496191e1a27a5803825f763c1ed4 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:15:11 +0200 Subject: [PATCH] Display a message when no document is detected in captured image --- .../java/org/mydomain/myscan/MainViewModel.kt | 69 +++++++++++-------- .../org/mydomain/myscan/view/CameraScreen.kt | 66 ++++++++++++++---- 2 files changed, 91 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 4615a9f..ea1e2a7 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -56,7 +56,7 @@ class MainViewModel( private val _pageIds = MutableStateFlow>(imageRepository.imageIds()) val pageIds: StateFlow> = _pageIds - private val _captureState = MutableStateFlow(CaptureState()) + private val _captureState = MutableStateFlow(CaptureState.Idle) val captureState: StateFlow = _captureState init { @@ -78,28 +78,36 @@ class MainViewModel( } } - data class CaptureState(val frozenImage: Bitmap? = null, val processedImage: Bitmap? = null) { - fun isIdle(): Boolean { return frozenImage == null } - fun isProcessed(): Boolean { return processedImage != null } - fun withProcessed(processedImage: Bitmap? = null): CaptureState { - return if (processedImage == null) { - CaptureState() - } else { - CaptureState(frozenImage, processedImage) - } + sealed class CaptureState { + open val frozenImage: Bitmap? = null + + object Idle : CaptureState() + data class Capturing(override val frozenImage: Bitmap) : CaptureState() + data class CaptureError(override val frozenImage: Bitmap) : CaptureState() + data class CapturePreview( + override val frozenImage: Bitmap, + val processed: Bitmap + ) : CaptureState() + } + + + fun onCapturePressed(frozenImage: Bitmap) { + _captureState.value = CaptureState.Capturing(frozenImage) + } + + private fun onCaptureProcessed(captured: Bitmap?) { + val current = _captureState.value + _captureState.value = when { + current is CaptureState.Capturing && captured != null -> + CaptureState.CapturePreview(current.frozenImage, captured) + current is CaptureState.Capturing -> + CaptureState.CaptureError(current.frozenImage) + else -> CaptureState.Idle } } - fun onCapturePressed(frozenImage: Bitmap?) { - _captureState.value = CaptureState(frozenImage) - } - - fun onCaptureProcessed(captured: Bitmap?) { - _captureState.value = _captureState.value.withProcessed(captured) - } - fun liveAnalysis(imageProxy: ImageProxy) { - if (!_captureState.value.isIdle()) { + if (_captureState.value !is CaptureState.Idle) { imageProxy.close() return } @@ -131,7 +139,7 @@ class MainViewModel( private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) { var corrected: Bitmap? = null - var bitmap = imageProxy.toBitmap() + val bitmap = imageProxy.toBitmap() val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0) if (segmentation != null) { val mask = segmentation.segmentation.toBinaryMask() @@ -145,16 +153,19 @@ class MainViewModel( } fun addProcessedImage(quality: Int = 75) { - val bitmap = _captureState.value.processedImage - _captureState.value = CaptureState() - if (bitmap == null) { - return + val current = _captureState.value + if (current is CaptureState.CapturePreview) { + val outputStream = ByteArrayOutputStream() + current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + val jpegBytes = outputStream.toByteArray() + imageRepository.add(jpegBytes) + _pageIds.value = imageRepository.imageIds() } - val outputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) - val jpegBytes = outputStream.toByteArray() - imageRepository.add(jpegBytes) - _pageIds.value = imageRepository.imageIds() + _captureState.value = CaptureState.Idle + } + + fun afterCaptureError() { + _captureState.value = CaptureState.Idle } fun deletePage(id: String) { diff --git a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt index d9cb1e4..61e1ed3 100644 --- a/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/CameraScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -71,6 +72,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import org.mydomain.myscan.LiveAnalysisState @@ -105,13 +107,23 @@ fun CameraScreen( } val captureState by viewModel.captureState.collectAsStateWithLifecycle() - if (captureState.isProcessed()) { + if (captureState is CaptureState.CapturePreview) { LaunchedEffect(captureState) { delay(CAPTURED_IMAGE_DISPLAY_DURATION) viewModel.addProcessedImage() } } + val showDetectionError = remember { mutableStateOf(false) } + LaunchedEffect(captureState) { + if (captureState is CaptureState.CaptureError) { + showDetectionError.value = true + delay(1000) + showDetectionError.value = false + viewModel.afterCaptureError() + } + } + val listState = rememberLazyListState() LaunchedEffect(pageIds.size) { if (pageIds.isNotEmpty()) { @@ -138,14 +150,17 @@ fun CameraScreen( }, cameraUiState = CameraUiState(pageIds.size, liveAnalysisState, captureState), onCapture = { - Log.i("MyScan", "Pressed ") - viewModel.onCapturePressed(previewView?.bitmap) - captureController.takePicture( - onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) } - ) + previewView?.bitmap?.let { + Log.i("MyScan", "Pressed ") + viewModel.onCapturePressed(it) + captureController.takePicture( + onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) } + ) + } }, onFinalizePressed = onFinalizePressed, thumbnailCoords = thumbnailCoords, + showDetectionError = showDetectionError.value ) } @@ -157,6 +172,7 @@ private fun CameraScreenScaffold( onCapture: () -> Unit, onFinalizePressed: () -> Unit, thumbnailCoords: MutableState, + showDetectionError: Boolean, ) { Box { Scaffold( @@ -173,7 +189,7 @@ private fun CameraScreenScaffold( .padding(bottom = innerPadding.calculateBottomPadding()) .fillMaxSize() ) { - CameraPreviewWithOverlay(cameraPreview, cameraUiState) + CameraPreviewWithOverlay(cameraPreview, cameraUiState, showDetectionError) MessageBox(cameraUiState.liveAnalysisState.inferenceTime) CaptureButton( onClick = onCapture, @@ -183,8 +199,8 @@ private fun CameraScreenScaffold( ) } } - cameraUiState.captureState.processedImage?.let { - image -> CapturedImage(image.asImageBitmap(), thumbnailCoords) + if (cameraUiState.captureState is CaptureState.CapturePreview) { + CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords) } } } @@ -273,14 +289,16 @@ fun CaptureButton(onClick: () -> Unit, modifier: Modifier) { @Composable private fun CameraPreviewWithOverlay( cameraPreview: @Composable () -> Unit, - cameraUiState: CameraUiState + cameraUiState: CameraUiState, + showDetectionError: Boolean ) { + val captureState = cameraUiState.captureState val width = LocalConfiguration.current.screenWidthDp val height = width / 3 * 4 var showShutter by remember { mutableStateOf(false) } - LaunchedEffect(cameraUiState.captureState.frozenImage) { - if (cameraUiState.captureState.frozenImage != null) { + LaunchedEffect(captureState.frozenImage) { + if (captureState.frozenImage != null) { showShutter = true delay(200) showShutter = false @@ -294,7 +312,7 @@ private fun CameraPreviewWithOverlay( ) { cameraPreview() AnalysisOverlay(cameraUiState.liveAnalysisState) - cameraUiState.captureState.frozenImage?.let { + captureState.frozenImage?.let { Image( bitmap = it.asImageBitmap(), contentDescription = null, @@ -308,6 +326,21 @@ private fun CameraPreviewWithOverlay( .background(Color.Black.copy(alpha = 0.6f)) ) } + if (showDetectionError) { + Box( + modifier = Modifier + .align(Alignment.Center) + .background(Color.Black.copy(alpha = 0.7f), shape = RoundedCornerShape(8.dp)) + .padding(16.dp) + ) { + Text( + text = "No document detected", + color = Color.White, + fontSize = 16.sp + ) + } + } + } } @@ -359,13 +392,15 @@ fun CameraScreenFooter( @Preview(showBackground = true) @Composable fun CameraScreenPreview() { - ScreenPreview(CaptureState()) + ScreenPreview(CaptureState.Idle) } @Preview(showBackground = true, showSystemUi = true) @Composable fun CameraScreenPreviewWithProcessedImage() { - ScreenPreview(CaptureState(processedImage = debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))) + ScreenPreview(CaptureState.CapturePreview( + debugImage("uncropped/img01.jpg"), + debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))) } @Composable @@ -403,6 +438,7 @@ private fun ScreenPreview(captureState: CaptureState) { onCapture = {}, onFinalizePressed = {}, thumbnailCoords = thumbnailCoords, + showDetectionError = false, ) } }