New setting to control the quality of exported PDFs (#70)
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
121
app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt
Normal file
121
app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt
Normal 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
|
||||||
|
}
|
||||||
33
app/src/main/java/org/fairscan/app/domain/ExportQuality.kt
Normal file
33
app/src/main/java/org/fairscan/app/domain/ExportQuality.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user