New UX on capture: freeze preview, no more dialog

This commit is contained in:
Pierre-Yves Nicolas
2025-06-23 20:59:18 +02:00
parent f0b6dd400b
commit e894c14260
5 changed files with 118 additions and 157 deletions

View File

@@ -61,7 +61,7 @@ class MainActivity : ComponentActivity() {
Scaffold { innerPadding-> Scaffold { innerPadding->
CameraScreen( CameraScreen(
viewModel, liveAnalysisState, viewModel, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.segment(image) }, onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) }, onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )

View File

@@ -56,10 +56,8 @@ 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 var _pageToValidate = MutableStateFlow<Bitmap?>(null) private val _captureState = MutableStateFlow<CaptureState>(CaptureState())
val pageToValidate: StateFlow<Bitmap?> = _pageToValidate.asStateFlow() val captureState: StateFlow<CaptureState> = _captureState
var liveAnalysisEnabled = true
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -80,8 +78,28 @@ class MainViewModel(
} }
} }
fun segment(imageProxy: ImageProxy) { data class CaptureState(val frozenImage: Bitmap? = null, val processedImage: Bitmap? = null) {
if (!liveAnalysisEnabled) { 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)
}
}
}
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()) {
imageProxy.close() imageProxy.close()
return return
} }
@@ -99,11 +117,15 @@ class MainViewModel(
_currentScreen.value = screen _currentScreen.value = screen
} }
fun processCapturedImageThen(imageProxy: ImageProxy, onResult: (Bitmap?) -> Unit) { fun onImageCaptured(imageProxy: ImageProxy?) {
if (imageProxy != null) {
viewModelScope.launch { viewModelScope.launch {
_pageToValidate.value = processCapturedImage(imageProxy) val image = processCapturedImage(imageProxy)
imageProxy.close() imageProxy.close()
onResult(_pageToValidate.value) onCaptureProcessed(image)
}
} else {
onCaptureProcessed(null)
} }
} }
@@ -122,7 +144,12 @@ class MainViewModel(
return@withContext corrected return@withContext corrected
} }
fun addPage(bitmap: Bitmap, quality: Int = 75) { fun addProcessedImage(quality: Int = 75) {
val bitmap = _captureState.value.processedImage
_captureState.value = CaptureState()
if (bitmap == null) {
return
}
val resized = resizeImage(bitmap) val resized = resizeImage(bitmap)
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
resized.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) resized.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)

View File

@@ -65,6 +65,7 @@ fun CameraPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
captureController: CameraCaptureController, captureController: CameraCaptureController,
onPreviewViewReady: (PreviewView) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val requestPermissionLauncher = rememberLauncherForActivityResult( val requestPermissionLauncher = rememberLauncherForActivityResult(
@@ -97,6 +98,7 @@ fun CameraPreview(
val previewView = PreviewView(it).apply { val previewView = PreviewView(it).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
scaleType = PreviewView.ScaleType.FIT_CENTER scaleType = PreviewView.ScaleType.FIT_CENTER
onPreviewViewReady(this)
} }
val executor = Executors.newSingleThreadExecutor() val executor = Executors.newSingleThreadExecutor()
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({

View File

@@ -14,8 +14,12 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Image
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
@@ -37,19 +41,24 @@ 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.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.MainViewModel.CaptureState
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
@Composable @Composable
@@ -60,64 +69,41 @@ fun CameraScreen(
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
val showPageDialog = rememberSaveable { mutableStateOf(false) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
val isProcessing = rememberSaveable { mutableStateOf(false) }
val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle()
val captureController = remember { CameraCaptureController() } val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { captureController.shutdown() } onDispose { captureController.shutdown() }
} }
val captureState by viewModel.captureState.collectAsStateWithLifecycle()
if (captureState.isProcessed()) {
LaunchedEffect(captureState) {
delay(1500)
viewModel.addProcessedImage()
}
}
CameraScreenContent( CameraScreenContent(
modifier, modifier,
cameraPreview = { cameraPreview = {
CameraPreview( CameraPreview(
onImageAnalyzed = onImageAnalyzed, onImageAnalyzed = onImageAnalyzed,
captureController = captureController captureController = captureController,
onPreviewViewReady = { view -> previewView = view }
) }, ) },
pageCount = viewModel.pageCount(), pageCount = viewModel.pageCount(),
liveAnalysisState = if (showPageDialog.value) LiveAnalysisState() else liveAnalysisState, liveAnalysisState = liveAnalysisState,
onCapture = { onCapture = {
Log.i("MyScan", "Pressed <Capture>") Log.i("MyScan", "Pressed <Capture>")
viewModel.liveAnalysisEnabled = false viewModel.onCapturePressed(previewView?.bitmap)
showPageDialog.value = true
isProcessing.value = true
captureController.takePicture( captureController.takePicture(
onImageCaptured = { imageProxy -> onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) }
if (imageProxy != null) {
viewModel.processCapturedImageThen(imageProxy) {
isProcessing.value = false
viewModel.liveAnalysisEnabled = true
Log.i("MyScan", "Capture process finished")
}
} else {
Log.e("MyScan", "Error during image capture")
isProcessing.value = false
viewModel.liveAnalysisEnabled = true
}
}
) )
}, },
onFinalizePressed = onFinalizePressed onFinalizePressed = onFinalizePressed,
captureState = captureState
) )
if (showPageDialog.value) {
PageValidationDialog(
isProcessing = isProcessing.value,
pageBitmap = pageToValidate,
onConfirm = {
pageToValidate?.let { viewModel.addPage(it) }
showPageDialog.value = false
},
onReject = {
showPageDialog.value = false
},
onDismiss = {
showPageDialog.value = false
}
)
}
} }
@Composable @Composable
@@ -127,10 +113,11 @@ private fun CameraScreenContent(
pageCount: Int, pageCount: Int,
liveAnalysisState: LiveAnalysisState, liveAnalysisState: LiveAnalysisState,
onCapture: () -> Unit, onCapture: () -> Unit,
onFinalizePressed: () -> Unit onFinalizePressed: () -> Unit,
captureState: CaptureState
) { ) {
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
CameraPreviewWithOverlay(cameraPreview, liveAnalysisState) CameraPreviewWithOverlay(cameraPreview, liveAnalysisState, captureState)
MessageBox(liveAnalysisState.inferenceTime) MessageBox(liveAnalysisState.inferenceTime)
CaptureButton( CaptureButton(
@@ -144,6 +131,17 @@ private fun CameraScreenContent(
onFinalizePressed = onFinalizePressed, onFinalizePressed = onFinalizePressed,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
captureState.processedImage?.let {
Surface (
color = Color.Black.copy(alpha = 0.3f),
modifier = Modifier.fillMaxSize())
{}
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(24.dp)
)
}
} }
} }
@@ -180,7 +178,8 @@ fun CaptureButton(onClick: () -> Unit, modifier: Modifier) {
@Composable @Composable
private fun CameraPreviewWithOverlay( private fun CameraPreviewWithOverlay(
cameraPreview: @Composable () -> Unit, cameraPreview: @Composable () -> Unit,
liveAnalysisState: LiveAnalysisState liveAnalysisState: LiveAnalysisState,
captureState: CaptureState
) { ) {
val width = LocalConfiguration.current.screenWidthDp val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4 val height = width / 3 * 4
@@ -191,6 +190,13 @@ private fun CameraPreviewWithOverlay(
) { ) {
cameraPreview() cameraPreview()
AnalysisOverlay(liveAnalysisState) AnalysisOverlay(liveAnalysisState)
captureState.frozenImage?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
)
}
} }
} }
@@ -241,6 +247,17 @@ fun CameraScreenFooter(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CameraScreenPreview() { fun CameraScreenPreview() {
ScreenPreview(CaptureState())
}
@Preview(showBackground = true)
@Composable
fun CameraScreenPreviewWithProcessedImage() {
ScreenPreview(CaptureState(processedImage = debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg")))
}
@Composable
private fun ScreenPreview(captureState: CaptureState) {
MyScanTheme { MyScanTheme {
CameraScreenContent( CameraScreenContent(
modifier = Modifier, modifier = Modifier,
@@ -249,14 +266,27 @@ fun CameraScreenPreview() {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.DarkGray), .background(Color.DarkGray),
contentAlignment = Alignment.Center contentAlignment = Alignment.TopCenter
) { ) {
Text("Camera Preview", color = Color.White) Image(
debugImage("uncropped/img01.jpg").asImageBitmap(),
contentDescription = null
)
} }
}, },
pageCount = 3, pageCount = 3,
liveAnalysisState = LiveAnalysisState(), liveAnalysisState = LiveAnalysisState(),
onCapture = {}, onCapture = {},
onFinalizePressed = {}) onFinalizePressed = {},
captureState = captureState
)
}
}
@Composable
private fun debugImage(imgName: String): Bitmap {
val context = LocalContext.current
return context.assets.open(imgName).use { input ->
BitmapFactory.decodeStream(input)
} }
} }

View File

@@ -1,98 +0,0 @@
/*
* 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.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun PageValidationDialog(
isProcessing: Boolean,
pageBitmap: Bitmap?,
onConfirm: () -> Unit,
onReject: () -> Unit,
onDismiss: () -> Unit
) {
Dialog (onDismissRequest = onDismiss) {
Surface (
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
modifier = Modifier
.fillMaxSize()
.aspectRatio(3f / 4f)
) {
if (isProcessing) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column (
Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
if (pageBitmap == null) {
Text("Failed to process image")
} else {
Image(
bitmap = pageBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = ContentScale.Fit
)
}
Row (
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton (onClick = onReject) {
Text("Reject")
}
Button (onClick = onConfirm) {
Text("OK")
}
}
}
}
}
}
}