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) {
cameraViewModel.events.collect { 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.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()
}
}

View File

@@ -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<String> = 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
}
}

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 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)

View File

@@ -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()
}