Store "source" (unprocessed captured image)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-08 20:23:45 +01:00
parent 3eb1a54457
commit 0439971e57
6 changed files with 78 additions and 26 deletions

View File

@@ -319,7 +319,7 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
cameraViewModel.events.collect { event -> cameraViewModel.events.collect { event ->
when (event) { when (event) {
is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.jpegBytes) is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page)
} }
} }
} }

View File

@@ -27,9 +27,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
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 java.io.ByteArrayOutputStream
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
@@ -67,13 +69,17 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
} }
fun movePage(id: String, newIndex: Int) { fun movePage(id: String, newIndex: Int) {
imageRepository.movePage(id, newIndex) viewModelScope.launch {
_pageIds.value = imageRepository.imageIds() imageRepository.movePage(id, newIndex)
_pageIds.value = imageRepository.imageIds()
}
} }
fun deletePage(id: String) { fun deletePage(id: String) {
imageRepository.delete(id) viewModelScope.launch {
_pageIds.value = imageRepository.imageIds() imageRepository.delete(id)
_pageIds.value = imageRepository.imageIds()
}
} }
fun startNewDocument() { fun startNewDocument() {
@@ -93,8 +99,19 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} }
fun handleImageCaptured(jpegBytes: ByteArray) { fun handleImageCaptured(capturedPage: CapturedPage) {
imageRepository.add(jpegBytes) viewModelScope.launch {
_pageIds.value = imageRepository.imageIds() imageRepository.add(
compressJpeg(capturedPage.page, 75),
compressJpeg(capturedPage.source, 90)
)
_pageIds.value = imageRepository.imageIds()
}
}
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
return outputStream.toByteArray()
} }
} }

View File

@@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
const val SOURCE_DIR_NAME = "sources"
const val SCAN_DIR_NAME = "scanned_pages" const val SCAN_DIR_NAME = "scanned_pages"
const val THUMBNAIL_DIR_NAME = "thumbnails" const val THUMBNAIL_DIR_NAME = "thumbnails"
@@ -28,6 +29,10 @@ class ImageRepository(
private val thumbnailSizePx: Int, private val thumbnailSizePx: Int,
) { ) {
private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply {
if (!exists()) mkdirs()
}
private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply { private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@@ -76,11 +81,12 @@ class ImageRepository(
fun imageIds(): ImmutableList<String> = fileNames.toImmutableList() fun imageIds(): ImmutableList<String> = fileNames.toImmutableList()
fun add(bytes: ByteArray) { fun add(pageBytes: ByteArray, sourceBytes: ByteArray? = null) {
val fileName = "${System.currentTimeMillis()}.jpg" val fileName = "${System.currentTimeMillis()}.jpg"
val file = File(scanDir, fileName) val file = File(scanDir, fileName)
file.writeBytes(bytes) file.writeBytes(pageBytes)
writeThumbnail(file) writeThumbnail(file)
sourceBytes?.let { File(sourceDir, fileName).writeBytes(sourceBytes) }
fileNames.add(fileName) fileNames.add(fileName)
saveMetadata() saveMetadata()
} }
@@ -158,6 +164,9 @@ class ImageRepository(
scanDir.listFiles()?.forEach { scanDir.listFiles()?.forEach {
file -> file.delete() file -> file.delete()
} }
sourceDir.listFiles()?.forEach {
file -> file.delete()
}
saveMetadata() // "empty" json file saveMetadata() // "empty" json file
} }
} }

View File

@@ -0,0 +1,23 @@
/*
* 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.domain
import android.graphics.Bitmap
data class CapturedPage(
val page: Bitmap,
val source: Bitmap
)

View File

@@ -84,6 +84,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.fairscan.app.MainViewModel import org.fairscan.app.MainViewModel
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.ui.Navigation 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.CameraPermissionState
@@ -235,7 +236,8 @@ private fun CameraScreenScaffold(
) )
} }
if (cameraUiState.captureState is CaptureState.CapturePreview) { if (cameraUiState.captureState is CaptureState.CapturePreview) {
CapturedImage(cameraUiState.captureState.processed.asImageBitmap(), thumbnailCoords) val page = cameraUiState.captureState.capturedPage.page
CapturedImage(page.asImageBitmap(), thumbnailCoords)
} }
} }
} }
@@ -461,7 +463,9 @@ fun CameraScreenPreview() {
fun CameraScreenPreviewWithProcessedImage() { fun CameraScreenPreviewWithProcessedImage() {
ScreenPreview(CaptureState.CapturePreview( ScreenPreview(CaptureState.CapturePreview(
debugImage("uncropped/img01.jpg"), debugImage("uncropped/img01.jpg"),
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))) CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"))))
} }
@Preview(showBackground = true, widthDp = 640, heightDp = 320) @Preview(showBackground = true, widthDp = 640, heightDp = 320)

View File

@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage
import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
@@ -40,10 +41,9 @@ import org.opencv.android.Utils
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
import java.io.ByteArrayOutputStream
sealed interface CameraEvent { sealed interface CameraEvent {
data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent data class ImageCaptured(val page: CapturedPage) : CameraEvent
} }
class CameraViewModel(appContainer: AppContainer): ViewModel() { class CameraViewModel(appContainer: AppContainer): ViewModel() {
@@ -88,7 +88,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
_captureState.value = CaptureState.Capturing(frozenImage) _captureState.value = CaptureState.Capturing(frozenImage)
} }
private fun onCaptureProcessed(captured: Bitmap?) { private fun onCaptureProcessed(captured: CapturedPage?) {
val current = _captureState.value val current = _captureState.value
_captureState.value = when { _captureState.value = when {
current is CaptureState.Capturing && captured != null -> current is CaptureState.Capturing && captured != null ->
@@ -117,23 +117,25 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
fun onImageCaptured(imageProxy: ImageProxy?) { fun onImageCaptured(imageProxy: ImageProxy?) {
if (imageProxy != null) { if (imageProxy != null) {
viewModelScope.launch { viewModelScope.launch {
val image = processCapturedImage(imageProxy) val source = imageProxy.toBitmap()
val processed = processCapturedImage(source, imageProxy.imageInfo.rotationDegrees)
imageProxy.close() imageProxy.close()
onCaptureProcessed(image) onCaptureProcessed(processed?.let { CapturedPage(processed, source) })
} }
} else { } else {
onCaptureProcessed(null) onCaptureProcessed(null)
} }
} }
private suspend fun processCapturedImage(imageProxy: ImageProxy): Bitmap? = withContext(Dispatchers.IO) { private suspend fun processCapturedImage(
bitmap: Bitmap,
rotationDegrees: Int
): Bitmap? = withContext(Dispatchers.IO) {
var corrected: Bitmap? = null var corrected: Bitmap? = null
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 val mask = segmentation.segmentation
var quad = detectDocumentQuad(mask, isLiveAnalysis = false) var quad = detectDocumentQuad(mask, isLiveAnalysis = false)
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
if (quad == null) { if (quad == null) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
lastSuccessfulLiveAnalysisState?.timestamp?.let { lastSuccessfulLiveAnalysisState?.timestamp?.let {
@@ -157,14 +159,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
return@withContext corrected return@withContext corrected
} }
fun addProcessedImage(quality: Int = 75) { fun addProcessedImage() {
val current = _captureState.value val current = _captureState.value
if (current is CaptureState.CapturePreview) { if (current is CaptureState.CapturePreview) {
val outputStream = ByteArrayOutputStream()
current.processed.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
val jpegBytes = outputStream.toByteArray()
viewModelScope.launch { viewModelScope.launch {
_events.emit(CameraEvent.ImageCaptured(jpegBytes)) _events.emit(CameraEvent.ImageCaptured(current.capturedPage))
} }
} }
_captureState.value = CaptureState.Idle _captureState.value = CaptureState.Idle
@@ -184,7 +183,7 @@ sealed class CaptureState {
data class CaptureError(override val frozenImage: Bitmap) : CaptureState() data class CaptureError(override val frozenImage: Bitmap) : CaptureState()
data class CapturePreview( data class CapturePreview(
override val frozenImage: Bitmap, override val frozenImage: Bitmap,
val processed: Bitmap, val capturedPage: CapturedPage,
) : CaptureState() ) : CaptureState()
} }