Split MainViewModel: extract CameraViewModel
This commit is contained in:
committed by
pynicolas
parent
4a58a1b4e3
commit
15e3d9d917
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user