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->
CameraScreen(
viewModel, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.segment(image) },
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.FinalizeDocument) },
modifier = Modifier.padding(innerPadding)
)

View File

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

View File

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

View File

@@ -14,8 +14,12 @@
*/
package org.mydomain.myscan.view
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.camera.core.ImageProxy
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -37,19 +41,24 @@ 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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
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.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.MainViewModel.CaptureState
import org.mydomain.myscan.ui.theme.MyScanTheme
@Composable
@@ -60,64 +69,41 @@ fun CameraScreen(
onFinalizePressed: () -> Unit,
modifier: Modifier,
) {
val showPageDialog = rememberSaveable { mutableStateOf(false) }
val isProcessing = rememberSaveable { mutableStateOf(false) }
val pageToValidate by viewModel.pageToValidate.collectAsStateWithLifecycle()
var previewView by remember { mutableStateOf<PreviewView?>(null) }
val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) {
onDispose { captureController.shutdown() }
}
val captureState by viewModel.captureState.collectAsStateWithLifecycle()
if (captureState.isProcessed()) {
LaunchedEffect(captureState) {
delay(1500)
viewModel.addProcessedImage()
}
}
CameraScreenContent(
modifier,
cameraPreview = {
CameraPreview(
onImageAnalyzed = onImageAnalyzed,
captureController = captureController
captureController = captureController,
onPreviewViewReady = { view -> previewView = view }
) },
pageCount = viewModel.pageCount(),
liveAnalysisState = if (showPageDialog.value) LiveAnalysisState() else liveAnalysisState,
liveAnalysisState = liveAnalysisState,
onCapture = {
Log.i("MyScan", "Pressed <Capture>")
viewModel.liveAnalysisEnabled = false
showPageDialog.value = true
isProcessing.value = true
viewModel.onCapturePressed(previewView?.bitmap)
captureController.takePicture(
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
}
}
onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) }
)
},
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
@@ -127,10 +113,11 @@ private fun CameraScreenContent(
pageCount: Int,
liveAnalysisState: LiveAnalysisState,
onCapture: () -> Unit,
onFinalizePressed: () -> Unit
onFinalizePressed: () -> Unit,
captureState: CaptureState
) {
Box(modifier = modifier.fillMaxSize()) {
CameraPreviewWithOverlay(cameraPreview, liveAnalysisState)
CameraPreviewWithOverlay(cameraPreview, liveAnalysisState, captureState)
MessageBox(liveAnalysisState.inferenceTime)
CaptureButton(
@@ -144,6 +131,17 @@ private fun CameraScreenContent(
onFinalizePressed = onFinalizePressed,
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
private fun CameraPreviewWithOverlay(
cameraPreview: @Composable () -> Unit,
liveAnalysisState: LiveAnalysisState
liveAnalysisState: LiveAnalysisState,
captureState: CaptureState
) {
val width = LocalConfiguration.current.screenWidthDp
val height = width / 3 * 4
@@ -191,6 +190,13 @@ private fun CameraPreviewWithOverlay(
) {
cameraPreview()
AnalysisOverlay(liveAnalysisState)
captureState.frozenImage?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
)
}
}
}
@@ -241,6 +247,17 @@ fun CameraScreenFooter(
@Preview(showBackground = true)
@Composable
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 {
CameraScreenContent(
modifier = Modifier,
@@ -249,14 +266,27 @@ fun CameraScreenPreview() {
modifier = Modifier
.fillMaxSize()
.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,
liveAnalysisState = LiveAnalysisState(),
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")
}
}
}
}
}
}
}