Display a message when no document is detected in captured image

This commit is contained in:
Pierre-Yves Nicolas
2025-07-01 17:15:11 +02:00
parent 2dd5f8cd20
commit 6ccf7081b9
2 changed files with 91 additions and 44 deletions

View File

@@ -56,7 +56,7 @@ class MainViewModel(
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds()) private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
val pageIds: StateFlow<List<String>> = _pageIds val pageIds: StateFlow<List<String>> = _pageIds
private val _captureState = MutableStateFlow<CaptureState>(CaptureState()) private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
val captureState: StateFlow<CaptureState> = _captureState val captureState: StateFlow<CaptureState> = _captureState
init { init {
@@ -78,28 +78,36 @@ class MainViewModel(
} }
} }
data class CaptureState(val frozenImage: Bitmap? = null, val processedImage: Bitmap? = null) { sealed class CaptureState {
fun isIdle(): Boolean { return frozenImage == null } open val frozenImage: Bitmap? = null
fun isProcessed(): Boolean { return processedImage != null }
fun withProcessed(processedImage: Bitmap? = null): CaptureState { object Idle : CaptureState()
return if (processedImage == null) { data class Capturing(override val frozenImage: Bitmap) : CaptureState()
CaptureState() data class CaptureError(override val frozenImage: Bitmap) : CaptureState()
} else { data class CapturePreview(
CaptureState(frozenImage, processedImage) 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) { fun liveAnalysis(imageProxy: ImageProxy) {
if (!_captureState.value.isIdle()) { if (_captureState.value !is CaptureState.Idle) {
imageProxy.close() imageProxy.close()
return return
} }
@@ -131,7 +139,7 @@ class MainViewModel(
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) { private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) {
var corrected: Bitmap? = null var corrected: Bitmap? = null
var bitmap = imageProxy.toBitmap() val bitmap = imageProxy.toBitmap()
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0) val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
if (segmentation != null) { if (segmentation != null) {
val mask = segmentation.segmentation.toBinaryMask() val mask = segmentation.segmentation.toBinaryMask()
@@ -145,16 +153,19 @@ class MainViewModel(
} }
fun addProcessedImage(quality: Int = 75) { fun addProcessedImage(quality: Int = 75) {
val bitmap = _captureState.value.processedImage val current = _captureState.value
_captureState.value = CaptureState() if (current is CaptureState.CapturePreview) {
if (bitmap == null) { val outputStream = ByteArrayOutputStream()
return current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
val jpegBytes = outputStream.toByteArray()
imageRepository.add(jpegBytes)
_pageIds.value = imageRepository.imageIds()
} }
val outputStream = ByteArrayOutputStream() _captureState.value = CaptureState.Idle
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) }
val jpegBytes = outputStream.toByteArray()
imageRepository.add(jpegBytes) fun afterCaptureError() {
_pageIds.value = imageRepository.imageIds() _captureState.value = CaptureState.Idle
} }
fun deletePage(id: String) { fun deletePage(id: String) {

View File

@@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme 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.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.LiveAnalysisState
@@ -105,13 +107,23 @@ fun CameraScreen(
} }
val captureState by viewModel.captureState.collectAsStateWithLifecycle() val captureState by viewModel.captureState.collectAsStateWithLifecycle()
if (captureState.isProcessed()) { if (captureState is CaptureState.CapturePreview) {
LaunchedEffect(captureState) { LaunchedEffect(captureState) {
delay(CAPTURED_IMAGE_DISPLAY_DURATION) delay(CAPTURED_IMAGE_DISPLAY_DURATION)
viewModel.addProcessedImage() 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() val listState = rememberLazyListState()
LaunchedEffect(pageIds.size) { LaunchedEffect(pageIds.size) {
if (pageIds.isNotEmpty()) { if (pageIds.isNotEmpty()) {
@@ -138,14 +150,17 @@ fun CameraScreen(
}, },
cameraUiState = CameraUiState(pageIds.size, liveAnalysisState, captureState), cameraUiState = CameraUiState(pageIds.size, liveAnalysisState, captureState),
onCapture = { onCapture = {
Log.i("MyScan", "Pressed <Capture>") previewView?.bitmap?.let {
viewModel.onCapturePressed(previewView?.bitmap) Log.i("MyScan", "Pressed <Capture>")
captureController.takePicture( viewModel.onCapturePressed(it)
onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) } captureController.takePicture(
) onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) }
)
}
}, },
onFinalizePressed = onFinalizePressed, onFinalizePressed = onFinalizePressed,
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
showDetectionError = showDetectionError.value
) )
} }
@@ -157,6 +172,7 @@ private fun CameraScreenScaffold(
onCapture: () -> Unit, onCapture: () -> Unit,
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
thumbnailCoords: MutableState<Offset>, thumbnailCoords: MutableState<Offset>,
showDetectionError: Boolean,
) { ) {
Box { Box {
Scaffold( Scaffold(
@@ -173,7 +189,7 @@ private fun CameraScreenScaffold(
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
.fillMaxSize() .fillMaxSize()
) { ) {
CameraPreviewWithOverlay(cameraPreview, cameraUiState) CameraPreviewWithOverlay(cameraPreview, cameraUiState, showDetectionError)
MessageBox(cameraUiState.liveAnalysisState.inferenceTime) MessageBox(cameraUiState.liveAnalysisState.inferenceTime)
CaptureButton( CaptureButton(
onClick = onCapture, onClick = onCapture,
@@ -183,8 +199,8 @@ private fun CameraScreenScaffold(
) )
} }
} }
cameraUiState.captureState.processedImage?.let { if (cameraUiState.captureState is CaptureState.CapturePreview) {
image -> CapturedImage(image.asImageBitmap(), thumbnailCoords) CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords)
} }
} }
} }
@@ -273,14 +289,16 @@ fun CaptureButton(onClick: () -> Unit, modifier: Modifier) {
@Composable @Composable
private fun CameraPreviewWithOverlay( private fun CameraPreviewWithOverlay(
cameraPreview: @Composable () -> Unit, cameraPreview: @Composable () -> Unit,
cameraUiState: CameraUiState cameraUiState: CameraUiState,
showDetectionError: Boolean
) { ) {
val captureState = cameraUiState.captureState
val width = LocalConfiguration.current.screenWidthDp val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4 val height = width / 3 * 4
var showShutter by remember { mutableStateOf(false) } var showShutter by remember { mutableStateOf(false) }
LaunchedEffect(cameraUiState.captureState.frozenImage) { LaunchedEffect(captureState.frozenImage) {
if (cameraUiState.captureState.frozenImage != null) { if (captureState.frozenImage != null) {
showShutter = true showShutter = true
delay(200) delay(200)
showShutter = false showShutter = false
@@ -294,7 +312,7 @@ private fun CameraPreviewWithOverlay(
) { ) {
cameraPreview() cameraPreview()
AnalysisOverlay(cameraUiState.liveAnalysisState) AnalysisOverlay(cameraUiState.liveAnalysisState)
cameraUiState.captureState.frozenImage?.let { captureState.frozenImage?.let {
Image( Image(
bitmap = it.asImageBitmap(), bitmap = it.asImageBitmap(),
contentDescription = null, contentDescription = null,
@@ -308,6 +326,21 @@ private fun CameraPreviewWithOverlay(
.background(Color.Black.copy(alpha = 0.6f)) .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) @Preview(showBackground = true)
@Composable @Composable
fun CameraScreenPreview() { fun CameraScreenPreview() {
ScreenPreview(CaptureState()) ScreenPreview(CaptureState.Idle)
} }
@Preview(showBackground = true, showSystemUi = true) @Preview(showBackground = true, showSystemUi = true)
@Composable @Composable
fun CameraScreenPreviewWithProcessedImage() { 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 @Composable
@@ -403,6 +438,7 @@ private fun ScreenPreview(captureState: CaptureState) {
onCapture = {}, onCapture = {},
onFinalizePressed = {}, onFinalizePressed = {},
thumbnailCoords = thumbnailCoords, thumbnailCoords = thumbnailCoords,
showDetectionError = false,
) )
} }
} }