Split MainViewModel: extract CameraViewModel

This commit is contained in:
Pierre-Yves Nicolas
2025-11-20 20:37:58 +01:00
committed by pynicolas
parent 4a58a1b4e3
commit 15e3d9d917
4 changed files with 224 additions and 155 deletions

View File

@@ -33,6 +33,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat.checkSelfPermission import androidx.core.content.ContextCompat.checkSelfPermission
@@ -57,6 +58,8 @@ import org.fairscan.app.ui.screens.DocumentScreen
import org.fairscan.app.ui.screens.ExportScreenWrapper import org.fairscan.app.ui.screens.ExportScreenWrapper
import org.fairscan.app.ui.screens.HomeScreen import org.fairscan.app.ui.screens.HomeScreen
import org.fairscan.app.ui.screens.LibrariesScreen import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
private const val PDF_MIME_TYPE = "application/pdf" private const val PDF_MIME_TYPE = "application/pdf"
@@ -70,11 +73,19 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
viewModel.cleanUpOldPdfs(1000 * 3600) viewModel.cleanUpOldPdfs(1000 * 3600)
} }
val cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) }
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LaunchedEffect(Unit) {
cameraViewModel.events.collect { event ->
when (event) {
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.jpegBytes)
}
}
}
val context = LocalContext.current val context = LocalContext.current
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState() val cameraPermission = rememberCameraPermissionState()
val savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) } val savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) }
@@ -113,9 +124,10 @@ class MainActivity : ComponentActivity() {
is Screen.Main.Camera -> { is Screen.Main.Camera -> {
CameraScreen( CameraScreen(
viewModel, viewModel,
cameraViewModel,
navigation, navigation,
liveAnalysisState, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) }, onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
onFinalizePressed = navigation.toDocumentScreen, onFinalizePressed = navigation.toDocumentScreen,
cameraPermission = cameraPermission cameraPermission = cameraPermission
) )

View File

@@ -19,7 +19,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -33,7 +32,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -43,25 +41,18 @@ import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.PdfFileManager import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.domain.detectDocumentQuad
import org.fairscan.app.domain.extractDocument
import org.fairscan.app.domain.scaledTo
import org.fairscan.app.platform.AndroidPdfWriter import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.platform.OpenCvTransformations import org.fairscan.app.platform.OpenCvTransformations
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.state.RecentDocumentUiState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.LiveAnalysisState import org.fairscan.app.ui.state.PdfGenerationUiState
import java.io.ByteArrayOutputStream import org.fairscan.app.ui.state.RecentDocumentUiState
import java.io.File import java.io.File
const val THUMBNAIL_SIZE_DP = 120 const val THUMBNAIL_SIZE_DP = 120
class MainViewModel( class MainViewModel(
private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val pdfFileManager: PdfFileManager, private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore<RecentDocuments>, private val recentDocumentsDataStore: DataStore<RecentDocuments>,
@@ -74,7 +65,6 @@ class MainViewModel(
val density = context.resources.displayMetrics.density val density = context.resources.displayMetrics.density
val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt() val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
return MainViewModel( return MainViewModel(
ImageSegmentationService(context),
ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx), ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx),
PdfFileManager( PdfFileManager(
File(context.cacheDir, "pdfs"), File(context.cacheDir, "pdfs"),
@@ -87,10 +77,6 @@ class MainViewModel(
} }
} }
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null
private val _navigationState = MutableStateFlow(NavigationState.initial()) private val _navigationState = MutableStateFlow(NavigationState.initial())
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current } val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current) .stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
@@ -109,76 +95,6 @@ class MainViewModel(
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail) initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
) )
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
val captureState: StateFlow<CaptureState> = _captureState
init {
viewModelScope.launch {
imageSegmentationService.initialize()
imageSegmentationService.segmentation
.filterNotNull()
.map {
// TODO Should we really call toBinaryMask if it's used only in debug mode?
val binaryMask = it.segmentation.toBinaryMask()
LiveAnalysisState(
inferenceTime = it.inferenceTime,
binaryMask = binaryMask,
documentQuad = detectDocumentQuad(it.segmentation, isLiveAnalysis = true),
timestamp = System.currentTimeMillis(),
)
}
.collect {
_liveAnalysisState.value = it
if (it.documentQuad != null) {
lastSuccessfulLiveAnalysisState = it
}
}
}
}
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 liveAnalysis(imageProxy: ImageProxy) {
if (_captureState.value !is CaptureState.Idle) {
imageProxy.close()
return
}
viewModelScope.launch {
imageSegmentationService.runSegmentationAndEmit(
imageProxy.toBitmap(),
imageProxy.imageInfo.rotationDegrees,
)
imageProxy.close()
}
}
fun navigateTo(destination: Screen) { fun navigateTo(destination: Screen) {
_navigationState.update { it.navigateTo(destination) } _navigationState.update { it.navigateTo(destination) }
} }
@@ -187,60 +103,6 @@ class MainViewModel(
_navigationState.update { stack -> stack.navigateBack() } _navigationState.update { stack -> stack.navigateBack() }
} }
fun onImageCaptured(imageProxy: ImageProxy?) {
if (imageProxy != null) {
viewModelScope.launch {
val image = processCapturedImage(imageProxy)
imageProxy.close()
onCaptureProcessed(image)
}
} else {
onCaptureProcessed(null)
}
}
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) {
var corrected: Bitmap? = null
val bitmap = imageProxy.toBitmap()
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
if (segmentation != null) {
val mask = segmentation.segmentation
var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
if (quad == null) {
val now = System.currentTimeMillis()
lastSuccessfulLiveAnalysisState?.timestamp?.let {
val offset = now - it
Log.i("Quad", "Last successful live analysis was $offset ms ago")
}
val recentLive = lastSuccessfulLiveAnalysisState?.takeIf {
now - it.timestamp <= 1500
}
val rotations = (-imageProxy.imageInfo.rotationDegrees / 90) + 4
quad = recentLive?.documentQuad?.rotate90(rotations, mask.width, mask.height)
if (quad != null) {
Log.i("Quad", "Using quad taken in live analysis; rotations=$rotations")
}
}
if (quad != null) {
val resizedQuad = quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
corrected = extractDocument(bitmap, resizedQuad, imageProxy.imageInfo.rotationDegrees)
}
}
return@withContext corrected
}
fun addProcessedImage(quality: Int = 75) {
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()
}
_captureState.value = CaptureState.Idle
}
fun rotateImage(id: String, clockwise: Boolean) { fun rotateImage(id: String, clockwise: Boolean) {
viewModelScope.launch { viewModelScope.launch {
imageRepository.rotate(id, clockwise) imageRepository.rotate(id, clockwise)
@@ -253,10 +115,6 @@ class MainViewModel(
_pageIds.value = imageRepository.imageIds() _pageIds.value = imageRepository.imageIds()
} }
fun afterCaptureError() {
_captureState.value = CaptureState.Idle
}
fun deletePage(id: String) { fun deletePage(id: String) {
imageRepository.delete(id) imageRepository.delete(id)
_pageIds.value = imageRepository.imageIds() _pageIds.value = imageRepository.imageIds()
@@ -392,6 +250,11 @@ class MainViewModel(
} }
} }
} }
fun handleImageCaptured(jpegBytes: ByteArray) {
imageRepository.add(jpegBytes)
_pageIds.value = imageRepository.imageIds()
}
} }
// TODO Move somewhere else: ViewModel should not depend on that // TODO Move somewhere else: ViewModel should not depend on that

View File

@@ -82,19 +82,18 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.app.ui.state.LiveAnalysisState
import org.fairscan.app.MainViewModel import org.fairscan.app.MainViewModel
import org.fairscan.app.MainViewModel.CaptureState
import org.fairscan.app.ui.Navigation
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.app.ui.components.CommonPageListState import org.fairscan.app.ui.components.CommonPageListState
import org.fairscan.app.ui.components.MainActionButton import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.MyScaffold import org.fairscan.app.ui.components.MyScaffold
import org.fairscan.app.ui.components.pageCountText import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.state.LiveAnalysisState
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
data class CameraUiState( data class CameraUiState(
@@ -113,6 +112,7 @@ const val ANIMATION_DURATION = 200
@Composable @Composable
fun CameraScreen( fun CameraScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
cameraViewModel: CameraViewModel,
navigation: Navigation, navigation: Navigation,
liveAnalysisState: LiveAnalysisState, liveAnalysisState: LiveAnalysisState,
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
@@ -132,11 +132,11 @@ fun CameraScreen(
onDispose { captureController.shutdown() } onDispose { captureController.shutdown() }
} }
val captureState by viewModel.captureState.collectAsStateWithLifecycle() val captureState by cameraViewModel.captureState.collectAsStateWithLifecycle()
if (captureState is CaptureState.CapturePreview) { if (captureState is CaptureState.CapturePreview) {
LaunchedEffect(captureState) { LaunchedEffect(captureState) {
delay(CAPTURED_IMAGE_DISPLAY_DURATION) delay(CAPTURED_IMAGE_DISPLAY_DURATION)
viewModel.addProcessedImage() cameraViewModel.addProcessedImage()
} }
} }
@@ -146,7 +146,7 @@ fun CameraScreen(
showDetectionError = true showDetectionError = true
delay(1000) delay(1000)
showDetectionError = false showDetectionError = false
viewModel.afterCaptureError() cameraViewModel.afterCaptureError()
} }
} }
@@ -185,9 +185,9 @@ fun CameraScreen(
onCapture = { onCapture = {
previewView?.bitmap?.let { previewView?.bitmap?.let {
Log.i("FairScan", "Pressed <Capture>") Log.i("FairScan", "Pressed <Capture>")
viewModel.onCapturePressed(it) cameraViewModel.onCapturePressed(it)
captureController.takePicture( captureController.takePicture(
onImageCaptured = { imageProxy -> viewModel.onImageCaptured(imageProxy) } onImageCaptured = { imageProxy -> cameraViewModel.onImageCaptured(imageProxy) }
) )
} }
}, },

View File

@@ -0,0 +1,194 @@
/*
* 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.fairscan.app.ui.screens.camera
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.domain.detectDocumentQuad
import org.fairscan.app.domain.extractDocument
import org.fairscan.app.domain.scaledTo
import org.fairscan.app.ui.state.LiveAnalysisState
import java.io.ByteArrayOutputStream
sealed interface CameraEvent {
data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent
}
class CameraViewModel(
private val imageSegmentationService: ImageSegmentationService
): ViewModel() {
companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return CameraViewModel(ImageSegmentationService(context)) as T
}
}
}
private val _events = MutableSharedFlow<CameraEvent>()
val events = _events.asSharedFlow()
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
private var lastSuccessfulLiveAnalysisState: LiveAnalysisState? = null
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
val captureState: StateFlow<CaptureState> = _captureState
init {
viewModelScope.launch {
imageSegmentationService.initialize()
imageSegmentationService.segmentation
.filterNotNull()
.map {
// TODO Should we really call toBinaryMask if it's used only in debug mode?
val binaryMask = it.segmentation.toBinaryMask()
LiveAnalysisState(
inferenceTime = it.inferenceTime,
binaryMask = binaryMask,
documentQuad = detectDocumentQuad(it.segmentation, isLiveAnalysis = true),
timestamp = System.currentTimeMillis(),
)
}
.collect {
_liveAnalysisState.value = it
if (it.documentQuad != null) {
lastSuccessfulLiveAnalysisState = it
}
}
}
}
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 liveAnalysis(imageProxy: ImageProxy) {
if (_captureState.value !is CaptureState.Idle) {
imageProxy.close()
return
}
viewModelScope.launch {
imageSegmentationService.runSegmentationAndEmit(
imageProxy.toBitmap(),
imageProxy.imageInfo.rotationDegrees,
)
imageProxy.close()
}
}
fun onImageCaptured(imageProxy: ImageProxy?) {
if (imageProxy != null) {
viewModelScope.launch {
val image = processCapturedImage(imageProxy)
imageProxy.close()
onCaptureProcessed(image)
}
} else {
onCaptureProcessed(null)
}
}
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) {
var corrected: Bitmap? = null
val bitmap = imageProxy.toBitmap()
val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0)
if (segmentation != null) {
val mask = segmentation.segmentation
var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
if (quad == null) {
val now = System.currentTimeMillis()
lastSuccessfulLiveAnalysisState?.timestamp?.let {
val offset = now - it
Log.i("Quad", "Last successful live analysis was $offset ms ago")
}
val recentLive = lastSuccessfulLiveAnalysisState?.takeIf {
now - it.timestamp <= 1500
}
val rotations = (-imageProxy.imageInfo.rotationDegrees / 90) + 4
quad = recentLive?.documentQuad?.rotate90(rotations, mask.width, mask.height)
if (quad != null) {
Log.i("Quad", "Using quad taken in live analysis; rotations=$rotations")
}
}
if (quad != null) {
val resizedQuad = quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
corrected = extractDocument(bitmap, resizedQuad, imageProxy.imageInfo.rotationDegrees)
}
}
return@withContext corrected
}
fun addProcessedImage(quality: Int = 75) {
val current = _captureState.value
if (current is CaptureState.CapturePreview) {
val outputStream = ByteArrayOutputStream()
current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
val jpegBytes = outputStream.toByteArray()
viewModelScope.launch {
_events.emit(CameraEvent.ImageCaptured(jpegBytes))
}
}
_captureState.value = CaptureState.Idle
}
fun afterCaptureError() {
_captureState.value = CaptureState.Idle
}
}
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()
}