Centralize image processing for 'app' module
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
145
app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt
Normal file
145
app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user