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.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking 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.app.ui.screens.settings.DefaultColorMode
import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.scaledTo
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
@@ -61,10 +60,8 @@ class DocumentDetectionTest {
val mask = segmentationResult.segmentation val mask = segmentationResult.segmentation
val quad = detectDocumentQuad(mask, ImageSize(bitmap.width, bitmap.height),false) val quad = detectDocumentQuad(mask, ImageSize(bitmap.width, bitmap.height),false)
if (quad != null) { if (quad != null) {
val resizedQuad =
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
val auto = DefaultColorMode.AUTO 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 outputJpeg = page.pageJpeg
val file = File(context.getExternalFilesDir(null), imageFileName) val file = File(context.getExternalFilesDir(null), imageFileName)
file.writeBytes(outputJpeg.bytes) file.writeBytes(outputJpeg.bytes)

View File

@@ -20,7 +20,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.app.platform.OpenCvTransformations import org.fairscan.app.platform.ImageProcessor
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@@ -66,8 +66,7 @@ class ScanSessionContainer(
val imageRepository = ImageRepository( val imageRepository = ImageRepository(
scanRootDir, scanRootDir,
OpenCvTransformations(), ImageProcessor(thumbnailSizePx),
thumbnailSizePx,
scope, scope,
) )
} }

View File

@@ -52,7 +52,6 @@ const val THUMBNAIL_DIR_NAME = "thumbnails"
class ImageRepository( class ImageRepository(
scanRootDir: File, scanRootDir: File,
val transformations: ImageTransformations, val transformations: ImageTransformations,
private val thumbnailSizePx: Int,
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() } private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
@@ -236,8 +235,7 @@ class ImageRepository(
} else { } else {
transformations.rotate( transformations.rotate(
baseJpeg, baseJpeg,
key.rotation.degrees, key.rotation.degrees)
ExportQuality.BALANCED.jpegQuality)
} }
} }
@@ -245,7 +243,7 @@ class ImageRepository(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val processed = getOrCompute(imageCache, key, ::computeProcessedImage) val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
?: return@withContext null ?: return@withContext null
transformations.resize(processed, thumbnailSizePx) transformations.resizeToThumbnail(processed)
} }
// --- Other operations --- // --- Other operations ---

View File

@@ -20,9 +20,9 @@ import org.fairscan.imageprocessing.ColorMode
interface ImageTransformations { 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 fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg

View File

@@ -15,10 +15,8 @@
package org.fairscan.app.domain package org.fairscan.app.domain
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.imageprocessing.ColorMode import org.fairscan.app.platform.processedImage
import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat import org.opencv.core.Mat
fun interface JpegProvider { fun interface JpegProvider {
@@ -52,8 +50,10 @@ suspend fun jpegsForExport(
val metadata = page.metadata val metadata = page.metadata
val manualRotation = page.manualRotation val manualRotation = page.manualRotation
val colorMode = page.colorMode val colorMode = page.colorMode
if (source != null && metadata != null && colorMode != null) if (source != null && metadata != null && colorMode != null) {
prepareJpegForHigh(source, metadata, manualRotation, colorMode, exportQuality) val rotation = metadata.baseRotation.add(manualRotation)
processedImage(source, metadata, rotation, colorMode, exportQuality)
}
else else
jpeg(page, imageRepository) jpeg(page, imageRepository)
} }
@@ -83,25 +83,3 @@ private fun resizeJpegBytesForMaxPixels(
resized?.release() 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.camera.core.ImageProxy
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
@@ -32,21 +30,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality import org.fairscan.app.platform.extractDocumentFromBitmap
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.ImageSize import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad 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 { sealed interface CameraEvent {
data class ImageCaptured(val page: CapturedPage) : 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 originalSize = ImageSize(source.width, source.height)
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 defaultColorMode = settingsRepository.defaultColorMode.first() val defaultColorMode = settingsRepository.defaultColorMode.first()
result = extractDocumentFromBitmap( result = extractDocumentFromBitmap(
source, resizedQuad, rotationDegrees, mask, viewModelScope, defaultColorMode) source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode)
} }
} }
return@withContext result 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 { sealed class CaptureState {
open val frozenImage: Bitmap? = null open val frozenImage: Bitmap? = null
@@ -226,36 +198,6 @@ sealed class CaptureState {
) : 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 { fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {
val matrix = Matrix() val matrix = Matrix()
matrix.postRotate(angle) matrix.postRotate(angle)

View File

@@ -60,22 +60,22 @@ class ImageRepositoryTest {
} }
fun repo( fun repo(
rotate: (Jpeg, Int, Int) -> Jpeg = { input, _, _ -> input }, rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input },
resize: (Jpeg, Int) -> Jpeg = { input, _ -> jpeg(input.bytes[0]) }, resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) },
process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ ->
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
): ImageRepository { ): ImageRepository {
val transformations = object : ImageTransformations { val transformations = object : ImageTransformations {
override fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg = override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg =
rotate(input, rotationDegrees, jpegQuality) rotate(input, rotationDegrees)
override fun resize(input: Jpeg, maxSize: Int): Jpeg = override fun resizeToThumbnail(input: Jpeg): Jpeg =
resize(input, maxSize) resizeToThumbnail(input)
override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg = override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg =
process(source, metadata, colorMode) process(source, metadata, colorMode)
} }
return ImageRepository(getFilesDir(), transformations, 200, testScope) return ImageRepository(getFilesDir(), transformations, testScope)
} }
@Test @Test