From 53b226a4654926e1d98a0e5e3fd6f5740b75f33e Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:48:04 +0200 Subject: [PATCH] Centralize image processing for 'app' module --- .../app/domain/DocumentDetectionTest.kt | 7 +- .../java/org/fairscan/app/SessionViewModel.kt | 5 +- .../org/fairscan/app/data/ImageRepository.kt | 6 +- .../fairscan/app/data/ImageTransformations.kt | 4 +- .../fairscan/app/domain/ExportPreparation.kt | 32 +--- .../fairscan/app/platform/ImageProcessor.kt | 145 ++++++++++++++++++ .../platform/OpenCvImageTransformations.kt | 93 ----------- .../app/ui/screens/camera/CameraViewModel.kt | 62 +------- .../fairscan/app/data/ImageRepositoryTest.kt | 14 +- 9 files changed, 167 insertions(+), 201 deletions(-) create mode 100644 app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt delete mode 100644 app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt diff --git a/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt b/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt index 06f2bf4..0df6ce9 100644 --- a/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt +++ b/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt @@ -24,11 +24,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking -import org.fairscan.app.ui.screens.camera.extractDocumentFromBitmap +import org.fairscan.app.platform.extractDocumentFromBitmap import org.fairscan.app.ui.screens.settings.DefaultColorMode import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.detectDocumentQuad -import org.fairscan.imageprocessing.scaledTo import org.junit.Assert.assertEquals import org.junit.Assert.fail import org.junit.Test @@ -61,10 +60,8 @@ class DocumentDetectionTest { val mask = segmentationResult.segmentation val quad = detectDocumentQuad(mask, ImageSize(bitmap.width, bitmap.height),false) if (quad != null) { - val resizedQuad = - quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height) val auto = DefaultColorMode.AUTO - val page = extractDocumentFromBitmap(bitmap, resizedQuad,0, mask, scope, auto) + val page = extractDocumentFromBitmap(bitmap, quad,0, mask, scope, auto) outputJpeg = page.pageJpeg val file = File(context.getExternalFilesDir(null), imageFileName) file.writeBytes(outputJpeg.bytes) diff --git a/app/src/main/java/org/fairscan/app/SessionViewModel.kt b/app/src/main/java/org/fairscan/app/SessionViewModel.kt index ef97eb7..514f645 100644 --- a/app/src/main/java/org/fairscan/app/SessionViewModel.kt +++ b/app/src/main/java/org/fairscan/app/SessionViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineScope import org.fairscan.app.data.ImageRepository -import org.fairscan.app.platform.OpenCvTransformations +import org.fairscan.app.platform.ImageProcessor import java.io.File import java.util.UUID @@ -66,8 +66,7 @@ class ScanSessionContainer( val imageRepository = ImageRepository( scanRootDir, - OpenCvTransformations(), - thumbnailSizePx, + ImageProcessor(thumbnailSizePx), scope, ) } 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 ab6bd62..189d56d 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -52,7 +52,6 @@ const val THUMBNAIL_DIR_NAME = "thumbnails" class ImageRepository( scanRootDir: File, val transformations: ImageTransformations, - private val thumbnailSizePx: Int, private val scope: CoroutineScope, ) { private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() } @@ -236,8 +235,7 @@ class ImageRepository( } else { transformations.rotate( baseJpeg, - key.rotation.degrees, - ExportQuality.BALANCED.jpegQuality) + key.rotation.degrees) } } @@ -245,7 +243,7 @@ class ImageRepository( withContext(Dispatchers.IO) { val processed = getOrCompute(imageCache, key, ::computeProcessedImage) ?: return@withContext null - transformations.resize(processed, thumbnailSizePx) + transformations.resizeToThumbnail(processed) } // --- Other operations --- diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index e32ede5..efced7e 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -20,9 +20,9 @@ import org.fairscan.imageprocessing.ColorMode interface ImageTransformations { - fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg + fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg - fun resize(input: Jpeg, maxSize: Int): Jpeg + fun resizeToThumbnail(input: Jpeg): Jpeg fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index a567365..45b24d5 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -15,10 +15,8 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository -import org.fairscan.imageprocessing.ColorMode -import org.fairscan.imageprocessing.extractDocument +import org.fairscan.app.platform.processedImage import org.fairscan.imageprocessing.resizeForMaxPixels -import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat fun interface JpegProvider { @@ -52,8 +50,10 @@ suspend fun jpegsForExport( val metadata = page.metadata val manualRotation = page.manualRotation val colorMode = page.colorMode - if (source != null && metadata != null && colorMode != null) - prepareJpegForHigh(source, metadata, manualRotation, colorMode, exportQuality) + if (source != null && metadata != null && colorMode != null) { + val rotation = metadata.baseRotation.add(manualRotation) + processedImage(source, metadata, rotation, colorMode, exportQuality) + } else jpeg(page, imageRepository) } @@ -83,25 +83,3 @@ private fun resizeJpegBytesForMaxPixels( resized?.release() } } - -private fun prepareJpegForHigh( - source: Jpeg, - pageMetadata: PageMetadata, - manualRotation: Rotation, - colorMode: ColorMode, - exportQuality: ExportQuality, -): Jpeg { - - var decoded: Mat? = null - var page: Mat? = null - try { - decoded = source.toMat() - val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height()) - val rotationDegrees = pageMetadata.baseRotation.add(manualRotation).degrees - page = extractDocument(decoded, quad, rotationDegrees, colorMode, exportQuality.maxPixels) - return Jpeg.fromMat(page, exportQuality.jpegQuality) - } finally { - decoded?.release() - page?.release() - } -} diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt new file mode 100644 index 0000000..193a71b --- /dev/null +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2025-2026 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.platform + +import android.graphics.Bitmap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import org.fairscan.app.data.ImageTransformations +import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.domain.ExportQuality +import org.fairscan.app.domain.Jpeg +import org.fairscan.app.domain.PageMetadata +import org.fairscan.app.domain.Rotation +import org.fairscan.app.ui.screens.settings.DefaultColorMode +import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.Mask +import org.fairscan.imageprocessing.Quad +import org.fairscan.imageprocessing.autoColorMode +import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.scaledTo +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.core.Size +import org.opencv.imgproc.Imgproc +import kotlin.math.min + +class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { + + override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg { + return transform(input, ExportQuality.BALANCED.jpegQuality) { + org.fairscan.imageprocessing.rotate(it, rotationDegrees) + } + } + + override fun resizeToThumbnail(input: Jpeg): Jpeg { + val maxSize = thumbnailSizePx.toFloat() + return transform(input, 85) { src -> + val ratio = min(maxSize / src.width(), maxSize / src.height()) + val newW = (src.width() * ratio).toDouble() + val newH = (src.height() * ratio).toDouble() + val scaled = Mat() + Imgproc.resize(src, scaled, Size(newW, newH)) + scaled + } + } + + private fun transform( + inJpeg: Jpeg, + jpegQuality: Int, + transform: (Mat) -> Mat, + ): Jpeg { + val input = inJpeg.toMat() + var output: Mat? = null + try { + output = transform.invoke(input) + return Jpeg.fromMat(output, jpegQuality) + } finally { + input.release() + output?.release() + } + } + + override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg { + return processedImage(source, metadata, metadata.baseRotation, colorMode, ExportQuality.BALANCED) + } +} + +fun processedImage( + source: Jpeg, + metadata: PageMetadata, + rotation: Rotation, + colorMode: ColorMode, + exportQuality: ExportQuality, +): Jpeg { + val rotationDegrees = rotation.degrees + var sourceMat: Mat? = null + var page: Mat? = null + try { + sourceMat = source.toMat() + val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) + page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels) + return Jpeg.fromMat(page, exportQuality.jpegQuality) + } finally { + sourceMat?.release() + page?.release() + } +} + +fun extractDocumentFromBitmap( + source: Bitmap, + quadInMask: Quad, + rotationDegrees: Int, + mask: Mask, + viewModelScope: CoroutineScope, + defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO +): CapturedPage { + val exportQuality = ExportQuality.BALANCED + val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height) + + val rgba = Mat() + Utils.bitmapToMat(source, rgba) + val bgr = Mat() + Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) + rgba.release() + val autoColorMode = autoColorMode(bgr, mask, quad) + val colorMode = defaultColorMode.colorMode ?: autoColorMode + val page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels) + val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) + bgr.release() + page.release() + + val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) + val baseRotation = Rotation.fromDegrees(rotationDegrees) + val metadata = PageMetadata(normalizedQuad, baseRotation, autoColorMode) + val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { + compressSource(source) + } + return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode) +} + +private fun compressSource(source: Bitmap): Jpeg { + val rgba = Mat() + Utils.bitmapToMat(source, rgba) + val bgr = Mat() + Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) + rgba.release() + return try { + Jpeg.fromMat(bgr, 90) + } finally { + bgr.release() + } +} diff --git a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt deleted file mode 100644 index 3ed0270..0000000 --- a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2025-2026 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.platform - -import org.fairscan.app.data.ImageTransformations -import org.fairscan.app.domain.ExportQuality -import org.fairscan.app.domain.Jpeg -import org.fairscan.app.domain.PageMetadata -import org.fairscan.imageprocessing.ColorMode -import org.fairscan.imageprocessing.extractDocument -import org.fairscan.imageprocessing.scaledTo -import org.opencv.core.Mat -import org.opencv.core.Size -import org.opencv.imgproc.Imgproc -import kotlin.math.min - -class OpenCvTransformations : ImageTransformations { - - override fun rotate( - input: Jpeg, - rotationDegrees: Int, - jpegQuality: Int - ): Jpeg { - return transform(input, jpegQuality) { - org.fairscan.imageprocessing.rotate(it, rotationDegrees) - } - } - - override fun resize(input: Jpeg, maxSize: Int): Jpeg { - return transform(input, 85) { src -> - val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height()) - val newW = (src.width() * ratio).toDouble() - val newH = (src.height() * ratio).toDouble() - val scaled = Mat() - Imgproc.resize(src, scaled, Size(newW, newH)) - scaled - } - } - - private fun transform( - inJpeg: Jpeg, - jpegQuality: Int, - transform: (Mat) -> Mat, - ): Jpeg { - val input = inJpeg.toMat() - var output: Mat? = null - try { - output = transform.invoke(input) - return Jpeg.fromMat(output, jpegQuality) - } finally { - input.release() - output?.release() - } - } - - override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg { - val exportQuality = ExportQuality.BALANCED - var sourceMat: Mat? = null - var page: Mat? = null - try { - sourceMat = source.toMat() - val quad = metadata.normalizedQuad.scaledTo( - 1, - 1, - sourceMat.width(), - sourceMat.height() - ) - page = extractDocument( - sourceMat, - quad, - metadata.baseRotation.degrees, - colorMode, - exportQuality.maxPixels - ) - return Jpeg.fromMat(page, exportQuality.jpegQuality) - } finally { - sourceMat?.release() - page?.release() - } - } -} 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 24844dd..fa548a2 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 @@ -19,9 +19,7 @@ import android.graphics.Matrix import androidx.camera.core.ImageProxy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -32,21 +30,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fairscan.app.AppContainer import org.fairscan.app.domain.CapturedPage -import org.fairscan.app.domain.ExportQuality -import org.fairscan.app.domain.Jpeg -import org.fairscan.app.domain.PageMetadata -import org.fairscan.app.domain.Rotation -import org.fairscan.app.ui.screens.settings.DefaultColorMode +import org.fairscan.app.platform.extractDocumentFromBitmap import org.fairscan.imageprocessing.ImageSize -import org.fairscan.imageprocessing.Mask -import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.detectDocumentQuad -import org.fairscan.imageprocessing.extractDocument -import org.fairscan.imageprocessing.autoColorMode -import org.fairscan.imageprocessing.scaledTo -import org.opencv.android.Utils -import org.opencv.core.Mat -import org.opencv.imgproc.Imgproc sealed interface CameraEvent { data class ImageCaptured(val page: CapturedPage) : CameraEvent @@ -167,10 +153,9 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { val originalSize = ImageSize(source.width, source.height) val quad = detectDocumentQuad(mask, originalSize, isLiveAnalysis = false) if (quad != null) { - val resizedQuad = quad.scaledTo(mask.width, mask.height, source.width, source.height) val defaultColorMode = settingsRepository.defaultColorMode.first() result = extractDocumentFromBitmap( - source, resizedQuad, rotationDegrees, mask, viewModelScope, defaultColorMode) + source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode) } } return@withContext result @@ -201,19 +186,6 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() { } } -private fun compressJpeg(bitmap: Bitmap, quality: Int): Jpeg { - val rgba = Mat() - Utils.bitmapToMat(bitmap, rgba) - val bgr = Mat() - Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) - rgba.release() - return try { - Jpeg.fromMat(bgr, quality) - } finally { - bgr.release() - } -} - sealed class CaptureState { open val frozenImage: Bitmap? = null @@ -226,36 +198,6 @@ sealed class CaptureState { ) : CaptureState() } -fun extractDocumentFromBitmap( - source: Bitmap, - quad: Quad, - rotationDegrees: Int, - mask: Mask, - viewModelScope: CoroutineScope, - defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO -): CapturedPage { - val rgba = Mat() - Utils.bitmapToMat(source, rgba) - val bgr = Mat() - Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3 - rgba.release() - val autoColorMode = autoColorMode(bgr, mask, quad) - val colorMode = defaultColorMode.colorMode ?: autoColorMode - val maxPixels = ExportQuality.BALANCED.maxPixels - val page = extractDocument(bgr, quad, rotationDegrees, colorMode, maxPixels) - val pageJpeg = Jpeg.fromMat(page, ExportQuality.BALANCED.jpegQuality) - bgr.release() - page.release() - - val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) - val baseRotation = Rotation.fromDegrees(rotationDegrees) - val metadata = PageMetadata(normalizedQuad, baseRotation, autoColorMode) - val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { - compressJpeg(source, 90) - } - return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode) -} - fun rotateBitmap(source: Bitmap, angle: Float): Bitmap { val matrix = Matrix() matrix.postRotate(angle) diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index bcc362d..6cc0063 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -60,22 +60,22 @@ class ImageRepositoryTest { } fun repo( - rotate: (Jpeg, Int, Int) -> Jpeg = { input, _, _ -> input }, - resize: (Jpeg, Int) -> Jpeg = { input, _ -> jpeg(input.bytes[0]) }, + rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input }, + resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) }, process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> throw UnsupportedOperationException() } ): ImageRepository { val transformations = object : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg = - rotate(input, rotationDegrees, jpegQuality) - override fun resize(input: Jpeg, maxSize: Int): Jpeg = - resize(input, maxSize) + override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg = + rotate(input, rotationDegrees) + override fun resizeToThumbnail(input: Jpeg): Jpeg = + resizeToThumbnail(input) override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = process(source, metadata, colorMode) } - return ImageRepository(getFilesDir(), transformations, 200, testScope) + return ImageRepository(getFilesDir(), transformations, testScope) } @Test