New setting to control the quality of exported PDFs (#70)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-10 15:06:01 +01:00
parent fcdcea1891
commit 5c5b6e921e
14 changed files with 295 additions and 64 deletions

View File

@@ -253,6 +253,7 @@ class MainActivity : ComponentActivity() {
}, },
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) }, onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) }, onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
onBack = nav.back, onBack = nav.back,
) )
} }

View File

@@ -146,6 +146,11 @@ class ImageRepository(
return if (file.exists()) file else null 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? { fun getThumbnail(id: String): ByteArray? {
val thumbFile = getThumbnailFile(id) val thumbFile = getThumbnailFile(id)
if (!thumbFile.exists()) { if (!thumbFile.exists()) {

View File

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

View File

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

View File

@@ -32,11 +32,13 @@ 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.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.android.Utils import org.opencv.android.Utils
import org.opencv.core.CvType import org.opencv.core.CvType
@@ -196,13 +198,15 @@ fun extractDocumentFromBitmap(
val bgr = Mat() val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3 Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3
rgba.release() rgba.release()
val page = extractDocument(bgr, quad, rotationDegrees, mask) val isColored = isColoredDocument(bgr, mask, quad)
val outBgr = page.image val maxPixels = ExportQuality.BALANCED.maxPixels
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
val outBgr = page
bgr.release() bgr.release()
val outBitmap = toBitmap(outBgr) val outBitmap = toBitmap(outBgr)
outBgr.release() outBgr.release()
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) 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) return CapturedPage(outBitmap, source, metadata)
} }

View File

@@ -21,6 +21,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@@ -41,6 +42,8 @@ import org.fairscan.app.AppContainer
import org.fairscan.app.RecentDocument import org.fairscan.app.RecentDocument
import org.fairscan.app.data.FileManager import org.fairscan.app.data.FileManager
import org.fairscan.app.data.ImageRepository 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 org.fairscan.app.ui.screens.settings.ExportFormat
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@@ -64,16 +67,16 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
private val _events = MutableSharedFlow<ExportEvent>() private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
private suspend fun generatePdf(): ExportResult.Pdf = withContext(Dispatchers.IO) { private suspend fun generatePdf(
val imageIds = imageRepository.imageIds() exportQuality: ExportQuality
val jpegs = imageIds.asSequence() ): ExportResult.Pdf = withContext(Dispatchers.IO) {
.mapNotNull { id -> imageRepository.getContent(id) } val jpegs = jpegsForExport(imageRepository, exportQuality)
val pdf = fileManager.generatePdf(jpegs) val pdf = fileManager.generatePdf(jpegs)
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount) return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
} }
suspend fun generatePdfForExternalCall(): ExportResult.Pdf { suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
return generatePdf() return generatePdf(ExportQuality.BALANCED)
} }
private val _uiState = MutableStateFlow(ExportUiState()) private val _uiState = MutableStateFlow(ExportUiState())
@@ -91,21 +94,21 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
cancelPreparation() cancelPreparation()
preparationJob = viewModelScope.launch { preparationJob = viewModelScope.launch {
val exportQuality = settingsRepository.exportQuality.first()
exportFormat = settingsRepository.exportFormat.first() exportFormat = settingsRepository.exportFormat.first()
_uiState.update { it.copy(format = exportFormat) } _uiState.update { it.copy(format = exportFormat) }
try { try {
val t1 = System.currentTimeMillis()
val result = if (exportFormat == ExportFormat.JPEG) { val result = if (exportFormat == ExportFormat.JPEG) {
val jpegFiles = imageRepository.imageIds() generateJpegs(exportQuality)
.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)
} else { } else {
generatePdf() generatePdf(exportQuality)
} }
_uiState.update { _uiState.update {
it.copy(isGenerating = false, result = result) it.copy(isGenerating = false, result = result)
} }
val t2 = System.currentTimeMillis()
Log.i("Export", "Generation time: ${t2-t1} ms")
} catch (e: Exception) { } catch (e: Exception) {
val message = "Failed to prepare $exportFormat export" val message = "Failed to prepare $exportFormat export"
logger.e("FairScan", message, e) 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() { fun cancelPreparation() {
preparationJob?.cancel() preparationJob?.cancel()
_uiState.value = ExportUiState() _uiState.value = ExportUiState()

View File

@@ -20,6 +20,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.fairscan.app.domain.ExportQuality
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings") 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_DIR_URI = stringPreferencesKey("export_dir_uri")
private val EXPORT_FORMAT = stringPreferencesKey("export_format") private val EXPORT_FORMAT = stringPreferencesKey("export_format")
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
val exportDirUri: Flow<String?> = val exportDirUri: Flow<String?> =
context.dataStore.data.map { prefs -> context.dataStore.data.map { prefs ->
@@ -42,6 +44,16 @@ class SettingsRepository(private val context: Context) {
} }
} }
val exportQuality: Flow<ExportQuality> =
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?) { suspend fun setExportDirUri(uri: String?) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
if (uri == null) { if (uri == null) {
@@ -57,6 +69,12 @@ class SettingsRepository(private val context: Context) {
prefs[EXPORT_FORMAT] = format.name 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) { enum class ExportFormat(val mimeType: String) {

View File

@@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.ui.components.BackButton import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.app.ui.theme.FairScanTheme
@@ -60,6 +61,7 @@ fun SettingsScreen(
onChooseDirectoryClick: () -> Unit, onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit, onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit, onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
BackHandler { onBack() } BackHandler { onBack() }
@@ -76,6 +78,7 @@ fun SettingsScreen(
onChooseDirectoryClick, onChooseDirectoryClick,
onResetExportDirClick, onResetExportDirClick,
onExportFormatChanged, onExportFormatChanged,
onExportQualityChanged,
modifier = Modifier.padding(paddingValues)) modifier = Modifier.padding(paddingValues))
} }
} }
@@ -86,6 +89,7 @@ private fun SettingsContent(
onChooseDirectoryClick: () -> Unit, onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit, onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit, onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -118,6 +122,26 @@ private fun SettingsContent(
Spacer(Modifier.height(32.dp)) 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) Text("Export format", style = MaterialTheme.typography.titleLarge)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -207,6 +231,7 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
onChooseDirectoryClick = {}, onChooseDirectoryClick = {},
onResetExportDirClick = {}, onResetExportDirClick = {},
onExportFormatChanged = {}, onExportFormatChanged = {},
onExportQualityChanged = {},
onBack = {} onBack = {}
) )
} }

View File

@@ -19,10 +19,12 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.domain.ExportQuality
data class SettingsUiState( data class SettingsUiState(
val exportDirUri: String? = null, val exportDirUri: String? = null,
val exportFormat: ExportFormat = ExportFormat.PDF, val exportFormat: ExportFormat = ExportFormat.PDF,
val exportQuality: ExportQuality = ExportQuality.BALANCED,
) )
class SettingsViewModel(container: AppContainer) : ViewModel() { class SettingsViewModel(container: AppContainer) : ViewModel() {
@@ -32,10 +34,12 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
val uiState = combine( val uiState = combine(
repo.exportDirUri, repo.exportDirUri,
repo.exportFormat, repo.exportFormat,
) { dir, format -> repo.exportQuality,
) { dir, format, quality ->
SettingsUiState( SettingsUiState(
exportDirUri = dir, exportDirUri = dir,
exportFormat = format, exportFormat = format,
exportQuality = quality,
) )
}.stateIn( }.stateIn(
viewModelScope, viewModelScope,
@@ -54,4 +58,10 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
repo.setExportFormat(format) repo.setExportFormat(format)
} }
} }
fun setExportQuality(quality: ExportQuality) {
viewModelScope.launch {
repo.setExportQuality(quality)
}
}
} }

View File

@@ -14,9 +14,9 @@
*/ */
package org.fairscan.evaluation package org.fairscan.evaluation
import org.fairscan.imageprocessing.ExtractedDocument
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgcodecs.Imgcodecs
import java.io.File import java.io.File
@@ -60,11 +60,11 @@ object ColorDetectionEvaluator {
val quad = detectDocumentQuad(mask, isLiveAnalysis = false) val quad = detectDocumentQuad(mask, isLiveAnalysis = false)
?.scaledTo(mask.width, mask.height, mat.width(), mat.height()) ?.scaledTo(mask.width, mask.height, mat.width(), mat.height())
val extracted: ExtractedDocument = if (quad != null) { if (quad == null) continue
extractDocument(mat, quad, 0, mask) val isColored = isColoredDocument(mat, mask, quad)
} else continue val extracted = extractDocument(mat, quad, 0, isColored, 2_000_000)
val detected = extracted.pageAnalysis.isColored val detected = isColored
nbProcessedImages++ nbProcessedImages++
@@ -72,7 +72,7 @@ object ColorDetectionEvaluator {
Imgcodecs.imwrite(inputOut.absolutePath, mat) Imgcodecs.imwrite(inputOut.absolutePath, mat)
val outputOut = File(outputDir, "${imgName}_output.jpg") val outputOut = File(outputDir, "${imgName}_output.jpg")
Imgcodecs.imwrite(outputOut.absolutePath, extracted.image) Imgcodecs.imwrite(outputOut.absolutePath, extracted)
results += ColorResult( results += ColorResult(
imgName, imgName,

View File

@@ -17,6 +17,7 @@ package org.fairscan.evaluation
import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgcodecs.Imgcodecs
@@ -71,7 +72,8 @@ object DatasetEvaluator {
?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height()) ?.scaledTo(mask.width, mask.height, inputMat.width(), inputMat.height())
val corrected: Mat? = if (quad != null) { 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 } else null
val inputOut = File(outputDir, "${e.name}_input.jpg") val inputOut = File(outputDir, "${e.name}_input.jpg")

View File

@@ -25,7 +25,6 @@ import org.opencv.core.Size
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
import org.opencv.imgproc.Imgproc.fillConvexPoly import org.opencv.imgproc.Imgproc.fillConvexPoly
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt
fun isColoredDocument( fun isColoredDocument(
img: Mat, img: Mat,
@@ -97,18 +96,6 @@ fun isColoredDocument(
return proportion > proportionThreshold 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 { private fun chroma(a: Mat, b: Mat): Mat {
val aFloat = Mat() val aFloat = Mat()
val bFloat = Mat() val bFloat = Mat()

View File

@@ -32,15 +32,6 @@ interface Mask {
fun toMat(): Mat 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? { fun detectDocumentQuad(mask: Mask, isLiveAnalysis: Boolean, minQuadAreaRatio: Double = 0.02): Quad? {
val mat = mask.toMat() val mat = mask.toMat()
val (biggest: MatOfPoint2f?, area) = biggestContour(mat) val (biggest: MatOfPoint2f?, area) = biggestContour(mat)
@@ -125,8 +116,9 @@ fun extractDocument(
inputMat: Mat, inputMat: Mat,
quad: Quad, quad: Quad,
rotationDegrees: Int, rotationDegrees: Int,
mask: Mask, isColored: Boolean,
): ExtractedDocument { maxPixels: Long,
): Mat {
val widthTop = norm(quad.topLeft, quad.topRight) val widthTop = norm(quad.topLeft, quad.topRight)
val widthBottom = norm(quad.bottomLeft, quad.bottomRight) val widthBottom = norm(quad.bottomLeft, quad.bottomRight)
val targetWidth = (widthTop + widthBottom) / 2 val targetWidth = (widthTop + widthBottom) / 2
@@ -153,27 +145,11 @@ fun extractDocument(
val outputSize = Size(targetWidth, targetHeight) val outputSize = Size(targetWidth, targetHeight)
Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize) Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize)
val resized = resize(outputMat, 1500.0) val resized = resizeForMaxPixels(outputMat, maxPixels.toDouble())
val isColored = isColoredDocument(inputMat, mask, quad)
val enhanced = enhanceCapturedImage(resized, isColored) val enhanced = enhanceCapturedImage(resized, isColored)
val rotated = rotate(enhanced, rotationDegrees) val rotated = rotate(enhanced, rotationDegrees)
return ExtractedDocument(rotated, PageAnalysis(isColored)) return rotated
}
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
} }
fun rotate(input: Mat, degrees: Int): Mat { fun rotate(input: Mat, degrees: Int): Mat {

View File

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