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