From 5c5b6e921e7b3f265676de05b13fcead736fab85 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:06:01 +0100 Subject: [PATCH] New setting to control the quality of exported PDFs (#70) --- .../java/org/fairscan/app/MainActivity.kt | 1 + .../org/fairscan/app/data/ImageRepository.kt | 5 + .../fairscan/app/domain/ExportPreparation.kt | 121 ++++++++++++++++++ .../org/fairscan/app/domain/ExportQuality.kt | 33 +++++ .../app/ui/screens/camera/CameraViewModel.kt | 10 +- .../app/ui/screens/export/ExportViewModel.kt | 39 ++++-- .../ui/screens/settings/SettingsRepository.kt | 18 +++ .../app/ui/screens/settings/SettingsScreen.kt | 25 ++++ .../ui/screens/settings/SettingsViewModel.kt | 12 +- .../evaluation/ColorDetectionEvaluator.kt | 12 +- .../fairscan/evaluation/DatasetEvaluator.kt | 4 +- .../imageprocessing/ColorDetection.kt | 13 -- .../imageprocessing/DocumentDetection.kt | 34 +---- .../org/fairscan/imageprocessing/Utils.kt | 32 +++++ 14 files changed, 295 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt create mode 100644 app/src/main/java/org/fairscan/app/domain/ExportQuality.kt create mode 100644 imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index b6590ec..51e83da 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -253,6 +253,7 @@ class MainActivity : ComponentActivity() { }, onResetExportDirClick = { settingsViewModel.setExportDirUri(null) }, onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) }, + onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) }, onBack = nav.back, ) } 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 7b92d16..fc4ab42 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -146,6 +146,11 @@ class ImageRepository( return if (file.exists()) file else null } + fun getSourceFor(id: String): ByteArray? { + val file = File(sourceDir, id) + return if (file.exists()) file.readBytes() else null + } + fun getThumbnail(id: String): ByteArray? { val thumbFile = getThumbnailFile(id) if (!thumbFile.exists()) { diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt new file mode 100644 index 0000000..ac49821 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2025 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.domain + +import org.fairscan.app.data.ImageRepository +import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.resizeForMaxPixels +import org.fairscan.imageprocessing.scaledTo +import org.opencv.core.Mat +import org.opencv.core.MatOfByte +import org.opencv.core.MatOfInt +import org.opencv.imgcodecs.Imgcodecs + +fun jpegsForExport( + imageRepository: ImageRepository, + exportQuality: ExportQuality +): Sequence { + + val imageIds = imageRepository.imageIds() + val baseJpegs = imageIds.asSequence().mapNotNull { id -> imageRepository.getContent(id) } + return when (exportQuality) { + ExportQuality.BALANCED -> baseJpegs + ExportQuality.LOW -> baseJpegs.mapNotNull { jpeg -> + resizeJpegBytesForMaxPixels( + jpegBytes = jpeg, + maxPixels = exportQuality.maxPixels.toDouble(), + jpegQuality = exportQuality.jpegQuality + ) + } + ExportQuality.HIGH -> { + imageIds.asSequence().mapNotNull { id -> + val sourceJpegBytes = imageRepository.getSourceFor(id) + val pageMetadata = imageRepository.getPageMetadata(id) + if (sourceJpegBytes != null && pageMetadata != null) + prepareJpegForHigh(sourceJpegBytes, pageMetadata, ExportQuality.HIGH) + else + imageRepository.getContent(id) + } + } + } +} + +fun resizeJpegBytesForMaxPixels( + jpegBytes: ByteArray, + maxPixels: Double, + jpegQuality: Int +): ByteArray? { + val decoded = decodeJpeg(jpegBytes) + if (decoded == null) + return null + + val resized = resizeForMaxPixels(decoded, maxPixels) + val outJpegBytes = encodeJpeg(resized, jpegQuality) + + decoded.release() + resized.release() + return outJpegBytes +} + +fun prepareJpegForHigh( + sourceJpegBytes: ByteArray, + pageMetadata: PageMetadata, + exportQuality: ExportQuality, +): ByteArray? { + + val decoded = decodeJpeg(sourceJpegBytes) + if (decoded == null) + return null + + val quad = pageMetadata.normalizedQuad.scaledTo(1,1,decoded.width(), decoded.height()) + val page = extractDocument( + decoded, + quad, + pageMetadata.rotationDegrees, + pageMetadata.isColored, + exportQuality.maxPixels) + val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality) + + decoded.release() + page.release() + return outJpegBytes +} + +fun decodeJpeg(jpegBytes: ByteArray): Mat? { + val src = MatOfByte(*jpegBytes) + val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR) + src.release() + if (decoded.empty()) { + decoded.release() + return null + } + return decoded +} + +fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray? { + val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality.coerceIn(0, 100)) + val encoded = MatOfByte() + val ok = Imgcodecs.imencode(".jpg", mat, encoded, params) + params.release() + + if (!ok) { + encoded.release() + return null + } + + val result = encoded.toArray() + encoded.release() + return result +} diff --git a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt new file mode 100644 index 0000000..8b0c534 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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.domain + +enum class ExportQuality( + val jpegQuality: Int, + val maxPixels: Long +) { + LOW( + jpegQuality = 60, + maxPixels = 1_000_000 + ), + BALANCED( + jpegQuality = 75, + maxPixels = 2_000_000 + ), + HIGH( + jpegQuality = 90, + maxPixels = 5_000_000 + ) +} 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 0574ca6..41489ba 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 @@ -32,11 +32,13 @@ 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.PageMetadata import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.isColoredDocument import org.fairscan.imageprocessing.scaledTo import org.opencv.android.Utils import org.opencv.core.CvType @@ -196,13 +198,15 @@ fun extractDocumentFromBitmap( val bgr = Mat() Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3 rgba.release() - val page = extractDocument(bgr, quad, rotationDegrees, mask) - val outBgr = page.image + val isColored = isColoredDocument(bgr, mask, quad) + val maxPixels = ExportQuality.BALANCED.maxPixels + val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels) + val outBgr = page bgr.release() val outBitmap = toBitmap(outBgr) outBgr.release() val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) - val metadata = PageMetadata(normalizedQuad, rotationDegrees, page.pageAnalysis.isColored) + val metadata = PageMetadata(normalizedQuad, rotationDegrees, isColored) return CapturedPage(outBitmap, source, metadata) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index 7927df9..386c46d 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -21,6 +21,7 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -41,6 +42,8 @@ import org.fairscan.app.AppContainer import org.fairscan.app.RecentDocument import org.fairscan.app.data.FileManager import org.fairscan.app.data.ImageRepository +import org.fairscan.app.domain.ExportQuality +import org.fairscan.app.domain.jpegsForExport import org.fairscan.app.ui.screens.settings.ExportFormat import java.io.File import java.io.FileInputStream @@ -64,16 +67,16 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - private suspend fun generatePdf(): ExportResult.Pdf = withContext(Dispatchers.IO) { - val imageIds = imageRepository.imageIds() - val jpegs = imageIds.asSequence() - .mapNotNull { id -> imageRepository.getContent(id) } + private suspend fun generatePdf( + exportQuality: ExportQuality + ): ExportResult.Pdf = withContext(Dispatchers.IO) { + val jpegs = jpegsForExport(imageRepository, exportQuality) val pdf = fileManager.generatePdf(jpegs) return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount) } suspend fun generatePdfForExternalCall(): ExportResult.Pdf { - return generatePdf() + return generatePdf(ExportQuality.BALANCED) } private val _uiState = MutableStateFlow(ExportUiState()) @@ -91,21 +94,21 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit cancelPreparation() preparationJob = viewModelScope.launch { + val exportQuality = settingsRepository.exportQuality.first() exportFormat = settingsRepository.exportFormat.first() _uiState.update { it.copy(format = exportFormat) } try { + val t1 = System.currentTimeMillis() val result = if (exportFormat == ExportFormat.JPEG) { - val jpegFiles = imageRepository.imageIds() - .mapNotNull { id -> imageRepository.getFileFor(id) } - .map { f -> f.copyTo(File(preparationDir, f.name), overwrite = true) } - val sizeInBytes = jpegFiles.sumOf { it.length() } - ExportResult.Jpeg(jpegFiles, sizeInBytes) + generateJpegs(exportQuality) } else { - generatePdf() + generatePdf(exportQuality) } _uiState.update { it.copy(isGenerating = false, result = result) } + val t2 = System.currentTimeMillis() + Log.i("Export", "Generation time: ${t2-t1} ms") } catch (e: Exception) { val message = "Failed to prepare $exportFormat export" logger.e("FairScan", message, e) @@ -119,6 +122,20 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit } } + private suspend fun generateJpegs( + exportQuality: ExportQuality + ): ExportResult.Jpeg = withContext(Dispatchers.IO) { + val jpegs = jpegsForExport(imageRepository, exportQuality) + val timestamp = System.currentTimeMillis() + val files = jpegs.mapIndexed { index, bytes -> + val file = File(preparationDir, "$timestamp-${index + 1}.jpg") + file.writeBytes(bytes) + file + }.toList() + val sizeInBytes = files.sumOf { it.length() } + ExportResult.Jpeg(files, sizeInBytes) + } + fun cancelPreparation() { preparationJob?.cancel() _uiState.value = ExportUiState() diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt index 183f6f4..322d159 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt @@ -20,6 +20,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import org.fairscan.app.domain.ExportQuality private val Context.dataStore by preferencesDataStore(name = "fairscan_settings") @@ -27,6 +28,7 @@ class SettingsRepository(private val context: Context) { private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri") private val EXPORT_FORMAT = stringPreferencesKey("export_format") + private val EXPORT_QUALITY = stringPreferencesKey("export_quality") val exportDirUri: Flow = context.dataStore.data.map { prefs -> @@ -42,6 +44,16 @@ class SettingsRepository(private val context: Context) { } } + val exportQuality: Flow = + context.dataStore.data.map { prefs -> + when (prefs[EXPORT_QUALITY]) { + "LOW" -> ExportQuality.LOW + "HIGH" -> ExportQuality.HIGH + "BALANCED", null -> ExportQuality.BALANCED + else -> ExportQuality.BALANCED + } + } + suspend fun setExportDirUri(uri: String?) { context.dataStore.edit { prefs -> if (uri == null) { @@ -57,6 +69,12 @@ class SettingsRepository(private val context: Context) { prefs[EXPORT_FORMAT] = format.name } } + + suspend fun setExportQuality(quality: ExportQuality) { + context.dataStore.edit { prefs -> + prefs[EXPORT_QUALITY] = quality.name + } + } } enum class ExportFormat(val mimeType: String) { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt index 385deb0..344e972 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import org.fairscan.app.R +import org.fairscan.app.domain.ExportQuality import org.fairscan.app.ui.components.BackButton import org.fairscan.app.ui.theme.FairScanTheme @@ -60,6 +61,7 @@ fun SettingsScreen( onChooseDirectoryClick: () -> Unit, onResetExportDirClick: () -> Unit, onExportFormatChanged: (ExportFormat) -> Unit, + onExportQualityChanged: (ExportQuality) -> Unit, onBack: () -> Unit, ) { BackHandler { onBack() } @@ -76,6 +78,7 @@ fun SettingsScreen( onChooseDirectoryClick, onResetExportDirClick, onExportFormatChanged, + onExportQualityChanged, modifier = Modifier.padding(paddingValues)) } } @@ -86,6 +89,7 @@ private fun SettingsContent( onChooseDirectoryClick: () -> Unit, onResetExportDirClick: () -> Unit, onExportFormatChanged: (ExportFormat) -> Unit, + onExportQualityChanged: (ExportQuality) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -118,6 +122,26 @@ private fun SettingsContent( Spacer(Modifier.height(32.dp)) + Text("Export quality", style = MaterialTheme.typography.titleLarge) + + ExportQuality.entries.forEach { quality -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = uiState.exportQuality == quality, + onClick = { onExportQualityChanged(quality) }, + ) + Text( + when (quality) { + ExportQuality.LOW -> "Low (smaller files)" + ExportQuality.BALANCED -> "Balanced" + ExportQuality.HIGH -> "High (best quality)" + } + ) + } + } + + Spacer(Modifier.height(32.dp)) + Text("Export format", style = MaterialTheme.typography.titleLarge) Row(verticalAlignment = Alignment.CenterVertically) { @@ -207,6 +231,7 @@ fun SettingsScreenPreview(uiState: SettingsUiState) { onChooseDirectoryClick = {}, onResetExportDirClick = {}, onExportFormatChanged = {}, + onExportQualityChanged = {}, onBack = {} ) } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt index a9dbec7..5533ae8 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsViewModel.kt @@ -19,10 +19,12 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.fairscan.app.AppContainer +import org.fairscan.app.domain.ExportQuality data class SettingsUiState( val exportDirUri: String? = null, val exportFormat: ExportFormat = ExportFormat.PDF, + val exportQuality: ExportQuality = ExportQuality.BALANCED, ) class SettingsViewModel(container: AppContainer) : ViewModel() { @@ -32,10 +34,12 @@ class SettingsViewModel(container: AppContainer) : ViewModel() { val uiState = combine( repo.exportDirUri, repo.exportFormat, - ) { dir, format -> + repo.exportQuality, + ) { dir, format, quality -> SettingsUiState( exportDirUri = dir, exportFormat = format, + exportQuality = quality, ) }.stateIn( viewModelScope, @@ -54,4 +58,10 @@ class SettingsViewModel(container: AppContainer) : ViewModel() { repo.setExportFormat(format) } } + + fun setExportQuality(quality: ExportQuality) { + viewModelScope.launch { + repo.setExportQuality(quality) + } + } } diff --git a/evaluation/src/main/java/org/fairscan/evaluation/ColorDetectionEvaluator.kt b/evaluation/src/main/java/org/fairscan/evaluation/ColorDetectionEvaluator.kt index 90493cc..b94845c 100644 --- a/evaluation/src/main/java/org/fairscan/evaluation/ColorDetectionEvaluator.kt +++ b/evaluation/src/main/java/org/fairscan/evaluation/ColorDetectionEvaluator.kt @@ -14,9 +14,9 @@ */ package org.fairscan.evaluation -import org.fairscan.imageprocessing.ExtractedDocument import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.isColoredDocument import org.fairscan.imageprocessing.scaledTo import org.opencv.imgcodecs.Imgcodecs import java.io.File @@ -60,11 +60,11 @@ object ColorDetectionEvaluator { val quad = detectDocumentQuad(mask, isLiveAnalysis = false) ?.scaledTo(mask.width, mask.height, mat.width(), mat.height()) - val extracted: ExtractedDocument = if (quad != null) { - extractDocument(mat, quad, 0, mask) - } else continue + if (quad == null) continue + val isColored = isColoredDocument(mat, mask, quad) + val extracted = extractDocument(mat, quad, 0, isColored, 2_000_000) - val detected = extracted.pageAnalysis.isColored + val detected = isColored nbProcessedImages++ @@ -72,7 +72,7 @@ object ColorDetectionEvaluator { Imgcodecs.imwrite(inputOut.absolutePath, mat) val outputOut = File(outputDir, "${imgName}_output.jpg") - Imgcodecs.imwrite(outputOut.absolutePath, extracted.image) + Imgcodecs.imwrite(outputOut.absolutePath, extracted) results += ColorResult( imgName, diff --git a/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt b/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt index bff721a..a74ebfe 100644 --- a/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt +++ b/evaluation/src/main/java/org/fairscan/evaluation/DatasetEvaluator.kt @@ -17,6 +17,7 @@ package org.fairscan.evaluation import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.extractDocument +import org.fairscan.imageprocessing.isColoredDocument import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat import org.opencv.imgcodecs.Imgcodecs @@ -71,7 +72,8 @@ object DatasetEvaluator { ?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height()) val corrected: Mat? = if (quad != null) { - extractDocument(inputMat, quad = quad, rotationDegrees = 0, mask).image + val isColored = isColoredDocument(inputMat, mask, quad) + extractDocument(inputMat, quad = quad, rotationDegrees = 0, isColored, 2_000_000) } else null val inputOut = File(outputDir, "${e.name}_input.jpg") diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/ColorDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/ColorDetection.kt index 511393d..1ea50b9 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/ColorDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/ColorDetection.kt @@ -25,7 +25,6 @@ import org.opencv.core.Size import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc.fillConvexPoly import kotlin.math.roundToInt -import kotlin.math.sqrt fun isColoredDocument( img: Mat, @@ -97,18 +96,6 @@ fun isColoredDocument( return proportion > proportionThreshold } -private fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { - val origPixels = img.width() * img.height() - if (origPixels <= maxPixels) { - return img.clone() - } - val scale = sqrt(maxPixels / origPixels) - val size = Size(img.width() * scale, img.height() * scale) - val resizedImg = Mat() - Imgproc.resize(img, resizedImg, size, 0.0, 0.0, Imgproc.INTER_AREA) - return resizedImg -} - private fun chroma(a: Mat, b: Mat): Mat { val aFloat = Mat() val bFloat = Mat() diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 83f6421..ca9b755 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -32,15 +32,6 @@ interface Mask { fun toMat(): Mat } -data class PageAnalysis( - val isColored: Boolean, -) - -data class ExtractedDocument( - val image: Mat, - val pageAnalysis: PageAnalysis, -) - fun detectDocumentQuad(mask: Mask, isLiveAnalysis: Boolean, minQuadAreaRatio: Double = 0.02): Quad? { val mat = mask.toMat() val (biggest: MatOfPoint2f?, area) = biggestContour(mat) @@ -125,8 +116,9 @@ fun extractDocument( inputMat: Mat, quad: Quad, rotationDegrees: Int, - mask: Mask, -): ExtractedDocument { + isColored: Boolean, + maxPixels: Long, +): Mat { val widthTop = norm(quad.topLeft, quad.topRight) val widthBottom = norm(quad.bottomLeft, quad.bottomRight) val targetWidth = (widthTop + widthBottom) / 2 @@ -153,27 +145,11 @@ fun extractDocument( val outputSize = Size(targetWidth, targetHeight) Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize) - val resized = resize(outputMat, 1500.0) - val isColored = isColoredDocument(inputMat, mask, quad) + val resized = resizeForMaxPixels(outputMat, maxPixels.toDouble()) val enhanced = enhanceCapturedImage(resized, isColored) val rotated = rotate(enhanced, rotationDegrees) - return ExtractedDocument(rotated, PageAnalysis(isColored)) -} - -fun resize(original: Mat, targetMax: Double): Mat { - val origSize = original.size() - if (max(origSize.width, origSize.height) < targetMax) - return original; - var targetWidth = targetMax - var targetHeight = origSize.height * targetWidth / origSize.width - if (origSize.width < origSize.height) { - targetHeight = targetMax - targetWidth = origSize.width * targetHeight / origSize.height - } - val result = Mat() - Imgproc.resize(original, result, Size(targetWidth, targetHeight), 0.0, 0.0, Imgproc.INTER_AREA) - return result + return rotated } fun rotate(input: Mat, degrees: Int): Mat { diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt new file mode 100644 index 0000000..9ef6d49 --- /dev/null +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025 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.imageprocessing + +import org.opencv.core.Mat +import org.opencv.core.Size +import org.opencv.imgproc.Imgproc +import kotlin.math.sqrt + +fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { + val origPixels = img.width() * img.height() + if (origPixels <= maxPixels) { + return img.clone() + } + val scale = sqrt(maxPixels / origPixels) + val size = Size(img.width() * scale, img.height() * scale) + val resizedImg = Mat() + Imgproc.resize(img, resizedImg, size, 0.0, 0.0, Imgproc.INTER_AREA) + return resizedImg +}