Compress source JPEG during capture preview rather than later

This commit is contained in:
Pierre-Yves Nicolas
2026-03-24 16:45:44 +01:00
parent fa619da867
commit 90287b3389
5 changed files with 36 additions and 29 deletions

View File

@@ -15,12 +15,14 @@
package org.fairscan.app.domain package org.fairscan.app.domain
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.fairscan.app.ui.screens.camera.extractDocumentFromBitmap import org.fairscan.app.ui.screens.camera.extractDocumentFromBitmap
import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.ImageSize
@@ -32,7 +34,6 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
import java.io.File import java.io.File
import java.io.FileOutputStream
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DocumentDetectionTest { class DocumentDetectionTest {
@@ -45,6 +46,7 @@ class DocumentDetectionTest {
val segmentationService = ImageSegmentationService(context) { _, _, _ -> } val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
segmentationService.initialize() segmentationService.initialize()
OpenCVLoader.initLocal() OpenCVLoader.initLocal()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName -> listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
val inputStream = context.assets.open("uncropped/$imageFileName") val inputStream = context.assets.open("uncropped/$imageFileName")
@@ -60,7 +62,7 @@ class DocumentDetectionTest {
if (quad != null) { if (quad != null) {
val resizedQuad = val resizedQuad =
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height) quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask).pageJpeg outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask, scope).pageJpeg
val file = File(context.getExternalFilesDir(null), imageFileName) val file = File(context.getExternalFilesDir(null), imageFileName)
file.writeBytes(outputJpeg) file.writeBytes(outputJpeg)
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}") Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")

View File

@@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -35,10 +36,6 @@ import org.fairscan.app.domain.PageViewKey
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 org.fairscan.imageprocessing.encodeJpeg
import org.opencv.android.Utils
import org.opencv.core.Mat
import org.opencv.imgproc.Imgproc
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() { class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
@@ -125,10 +122,13 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
fun handleImageCaptured(capturedPage: CapturedPage) { fun handleImageCaptured(capturedPage: CapturedPage) {
viewModelScope.launch { viewModelScope.launch {
val sourceJpeg = withContext(Dispatchers.IO) {
capturedPage.sourceJpeg.await()
}
val pages = withContext(repositoryDispatcher) { val pages = withContext(repositoryDispatcher) {
imageRepository.add( imageRepository.add(
capturedPage.pageJpeg, capturedPage.pageJpeg,
compressJpeg(capturedPage.source, 90), sourceJpeg,
capturedPage.metadata, capturedPage.metadata,
) )
imageRepository.pages() imageRepository.pages()
@@ -136,17 +136,4 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
_pages.value = pages _pages.value = pages
} }
} }
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
val rgba = Mat()
Utils.bitmapToMat(bitmap, rgba)
val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
rgba.release()
return try {
encodeJpeg(bgr, quality)
} finally {
bgr.release()
}
}
} }

View File

@@ -15,10 +15,10 @@
package org.fairscan.app.domain package org.fairscan.app.domain
import android.graphics.Bitmap import kotlinx.coroutines.Deferred
data class CapturedPage( data class CapturedPage(
val pageJpeg: ByteArray, val pageJpeg: ByteArray,
val source: Bitmap, val sourceJpeg: Deferred<ByteArray>,
val metadata: PageMetadata, val metadata: PageMetadata,
) )

View File

@@ -86,6 +86,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.CompletableDeferred
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
@@ -536,7 +537,7 @@ fun CameraScreenPreviewWithProcessedImage() {
bitmap(debugImage("uncropped/img01.jpg")), bitmap(debugImage("uncropped/img01.jpg")),
CapturedPage( CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"), debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
bitmap(debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg")), CompletableDeferred(ByteArray(0)),
PageMetadata(quad, R0, false)))) PageMetadata(quad, R0, false))))
} }

View File

@@ -17,10 +17,11 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix import android.graphics.Matrix
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.graphics.createBitmap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -42,7 +43,6 @@ import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.android.Utils import org.opencv.android.Utils
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
@@ -165,7 +165,8 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false)
if (quad != null) { if (quad != null) {
val resizedQuad = quad.scaledTo(mask.width, mask.height, source.width, source.height) val resizedQuad = quad.scaledTo(mask.width, mask.height, source.width, source.height)
result = extractDocumentFromBitmap(source, resizedQuad, rotationDegrees, mask) result = extractDocumentFromBitmap(
source, resizedQuad, rotationDegrees, mask, viewModelScope)
} }
} }
return@withContext result return@withContext result
@@ -196,6 +197,19 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
} }
} }
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
val rgba = Mat()
Utils.bitmapToMat(bitmap, rgba)
val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
rgba.release()
return try {
encodeJpeg(bgr, quality)
} finally {
bgr.release()
}
}
sealed class CaptureState { sealed class CaptureState {
open val frozenImage: Bitmap? = null open val frozenImage: Bitmap? = null
@@ -209,7 +223,7 @@ sealed class CaptureState {
} }
fun extractDocumentFromBitmap( fun extractDocumentFromBitmap(
source: Bitmap, quad: Quad, rotationDegrees: Int, mask: Mask source: Bitmap, quad: Quad, rotationDegrees: Int, mask: Mask, viewModelScope: CoroutineScope
): CapturedPage { ): CapturedPage {
val rgba = Mat() val rgba = Mat()
Utils.bitmapToMat(source, rgba) Utils.bitmapToMat(source, rgba)
@@ -226,7 +240,10 @@ fun extractDocumentFromBitmap(
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
val baseRotation = Rotation.fromDegrees(rotationDegrees) val baseRotation = Rotation.fromDegrees(rotationDegrees)
val metadata = PageMetadata(normalizedQuad, baseRotation, isColored) val metadata = PageMetadata(normalizedQuad, baseRotation, isColored)
return CapturedPage(pageJpeg, source, metadata) val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) {
compressJpeg(source, 90)
}
return CapturedPage(pageJpeg, sourceJpegDeferred, metadata)
} }
fun rotateBitmap(source: Bitmap, angle: Float): Bitmap { fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {