Centralize image processing for 'app' module

This commit is contained in:
Pierre-Yves Nicolas
2026-04-04 18:48:04 +02:00
parent 953d0e4a42
commit 53b226a465
9 changed files with 167 additions and 201 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}

View File

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

View File

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