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) },
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
onBack = nav.back,
)
}

View File

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

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

View File

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

View File

@@ -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<String?> =
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?) {
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) {

View File

@@ -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 = {}
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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
}