From 0439971e576aad092a7bb4710ae6badc1a1324dc Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Thu, 8 Jan 2026 20:23:45 +0100 Subject: [PATCH] Store "source" (unprocessed captured image) --- .../java/org/fairscan/app/MainActivity.kt | 2 +- .../java/org/fairscan/app/MainViewModel.kt | 31 ++++++++++++++----- .../org/fairscan/app/data/ImageRepository.kt | 13 ++++++-- .../org/fairscan/app/domain/CapturedPage.kt | 23 ++++++++++++++ .../app/ui/screens/camera/CameraScreen.kt | 8 +++-- .../app/ui/screens/camera/CameraViewModel.kt | 27 ++++++++-------- 6 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/fairscan/app/domain/CapturedPage.kt diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 57314df..b6590ec 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -319,7 +319,7 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { cameraViewModel.events.collect { event -> when (event) { - is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.jpegBytes) + is CameraEvent.ImageCaptured -> viewModel.handleImageCaptured(event.page) } } } diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 750fac7..0a636c8 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -27,9 +27,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.fairscan.app.data.ImageRepository +import org.fairscan.app.domain.CapturedPage import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.Screen import org.fairscan.app.ui.state.DocumentUiModel +import java.io.ByteArrayOutputStream 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) { - imageRepository.movePage(id, newIndex) - _pageIds.value = imageRepository.imageIds() + viewModelScope.launch { + imageRepository.movePage(id, newIndex) + _pageIds.value = imageRepository.imageIds() + } } fun deletePage(id: String) { - imageRepository.delete(id) - _pageIds.value = imageRepository.imageIds() + viewModelScope.launch { + imageRepository.delete(id) + _pageIds.value = imageRepository.imageIds() + } } fun startNewDocument() { @@ -93,8 +99,19 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } } - fun handleImageCaptured(jpegBytes: ByteArray) { - imageRepository.add(jpegBytes) - _pageIds.value = imageRepository.imageIds() + fun handleImageCaptured(capturedPage: CapturedPage) { + viewModelScope.launch { + 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() } } diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 101c0ff..7e31976 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.json.Json import java.io.File +const val SOURCE_DIR_NAME = "sources" const val SCAN_DIR_NAME = "scanned_pages" const val THUMBNAIL_DIR_NAME = "thumbnails" @@ -28,6 +29,10 @@ class ImageRepository( 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 { if (!exists()) mkdirs() } @@ -76,11 +81,12 @@ class ImageRepository( fun imageIds(): ImmutableList = fileNames.toImmutableList() - fun add(bytes: ByteArray) { + fun add(pageBytes: ByteArray, sourceBytes: ByteArray? = null) { val fileName = "${System.currentTimeMillis()}.jpg" val file = File(scanDir, fileName) - file.writeBytes(bytes) + file.writeBytes(pageBytes) writeThumbnail(file) + sourceBytes?.let { File(sourceDir, fileName).writeBytes(sourceBytes) } fileNames.add(fileName) saveMetadata() } @@ -158,6 +164,9 @@ class ImageRepository( scanDir.listFiles()?.forEach { file -> file.delete() } + sourceDir.listFiles()?.forEach { + file -> file.delete() + } saveMetadata() // "empty" json file } } diff --git a/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt new file mode 100644 index 0000000..352dcf5 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt @@ -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 . + */ + +package org.fairscan.app.domain + +import android.graphics.Bitmap + +data class CapturedPage( + val page: Bitmap, + val source: Bitmap +) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 42c7302..5c1d7b0 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -84,6 +84,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import org.fairscan.app.MainViewModel import org.fairscan.app.R +import org.fairscan.app.domain.CapturedPage import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Screen import org.fairscan.app.ui.components.CameraPermissionState @@ -235,7 +236,8 @@ private fun CameraScreenScaffold( ) } 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() { ScreenPreview(CaptureState.CapturePreview( 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) diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index cd9f9c5..cf75845 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.AppContainer +import org.fairscan.app.domain.CapturedPage import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.detectDocumentQuad @@ -40,10 +41,9 @@ import org.opencv.android.Utils import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.imgproc.Imgproc -import java.io.ByteArrayOutputStream sealed interface CameraEvent { - data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent + data class ImageCaptured(val page: CapturedPage) : CameraEvent } class CameraViewModel(appContainer: AppContainer): ViewModel() { @@ -88,7 +88,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { _captureState.value = CaptureState.Capturing(frozenImage) } - private fun onCaptureProcessed(captured: Bitmap?) { + private fun onCaptureProcessed(captured: CapturedPage?) { val current = _captureState.value _captureState.value = when { current is CaptureState.Capturing && captured != null -> @@ -117,23 +117,25 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { fun onImageCaptured(imageProxy: ImageProxy?) { if (imageProxy != null) { viewModelScope.launch { - val image = processCapturedImage(imageProxy) + val source = imageProxy.toBitmap() + val processed = processCapturedImage(source, imageProxy.imageInfo.rotationDegrees) imageProxy.close() - onCaptureProcessed(image) + onCaptureProcessed(processed?.let { CapturedPage(processed, source) }) } } else { 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 - val bitmap = imageProxy.toBitmap() val segmentation = imageSegmentationService.runSegmentationAndReturn(bitmap, 0) if (segmentation != null) { val mask = segmentation.segmentation var quad = detectDocumentQuad(mask, isLiveAnalysis = false) - val rotationDegrees = imageProxy.imageInfo.rotationDegrees if (quad == null) { val now = System.currentTimeMillis() lastSuccessfulLiveAnalysisState?.timestamp?.let { @@ -157,14 +159,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { return@withContext corrected } - fun addProcessedImage(quality: Int = 75) { + fun addProcessedImage() { 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)) + _events.emit(CameraEvent.ImageCaptured(current.capturedPage)) } } _captureState.value = CaptureState.Idle @@ -184,7 +183,7 @@ sealed class CaptureState { data class CaptureError(override val frozenImage: Bitmap) : CaptureState() data class CapturePreview( override val frozenImage: Bitmap, - val processed: Bitmap, + val capturedPage: CapturedPage, ) : CaptureState() }