diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index b9034887..cb1ecf28 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -210,7 +210,7 @@ class MainActivity : ComponentActivity() { onExportClick = onExportClick, onDeleteImage = { viewModel.deleteCurrentPage() }, onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) }, - onToggleColorMode = { viewModel.toggleCurrentPageColorMode() }, + onColorModeSelected = { viewModel.setCurrentPageColorMode(it) }, onCropClick = { viewModel.onClickOnCropButton() }, onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) }, onPageSelected = viewModel::onPageSelected diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index f8dc0e5d..962f84d8 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -105,7 +105,7 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM val isLoading = (it.id == loadingId) val canBeCropped = page.metadata != null val bitmap = try { - imageRepository.jpegBytes(it.key())?.toBitmap() + imageRepository.image(it.key())?.toBitmap() } catch (e: Exception) { logger.e("MainViewModel", "Failed to load image for ${it.id}", e) null @@ -179,15 +179,13 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM } } - fun toggleCurrentPageColorMode() { + fun setCurrentPageColorMode(colorMode: ColorMode) { viewModelScope.launch { val currentPage = currentPage() - currentPage.colorMode?.let { + if (currentPage.colorMode != colorMode) { _loadingPageId.value = currentPage.id - val newColorMode = - if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR val pages = withContext(Dispatchers.IO) { - imageRepository.setColorMode(currentPage.id, newColorMode) + imageRepository.setColorMode(currentPage.id, colorMode) imageRepository.pages() } _pages.value = pages @@ -233,7 +231,7 @@ class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewM val pages = withContext(Dispatchers.IO) { val sourceJpeg = capturedPage.sourceJpeg.await() imageRepository.add( - capturedPage.pageJpeg, + capturedPage.pageImage, sourceJpeg, capturedPage.metadata, capturedPage.colorMode, diff --git a/app/src/main/java/org/fairscan/app/data/FileManager.kt b/app/src/main/java/org/fairscan/app/data/FileManager.kt index e0a8a361..734ac0af 100644 --- a/app/src/main/java/org/fairscan/app/data/FileManager.kt +++ b/app/src/main/java/org/fairscan/app/data/FileManager.kt @@ -14,6 +14,7 @@ */ package org.fairscan.app.data +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.PageToExport import java.io.File import java.io.FileOutputStream @@ -27,7 +28,7 @@ data class GeneratedPdf( fun interface PdfWriter { suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, @@ -49,7 +50,7 @@ class FileManager( } suspend fun generatePdf( - pages: List, + pages: List>, disableOcr: Boolean, onProgress: (Int) -> Unit ): GeneratedPdf { diff --git a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt index 82d7d5da..bfdc9ca8 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -27,9 +27,11 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Png import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.ScanPage import org.fairscan.imageprocessing.ColorMode @@ -68,8 +70,8 @@ class ImageRepository( private var pages: PageStore = PageStore(loadPages()) private val processingJobs = synchronizedMap(mutableMapOf>()) - private val imageCache = createLruCache>(maxEntries = 50) - private val thumbnailCache = createLruCache>(maxEntries = 1000) + private val imageCache = createLruCache>(maxEntries = 50) + private val thumbnailCache = createLruCache>(maxEntries = 1000) private fun createLruCache(maxEntries: Int): MutableMap = synchronizedMap(object : LinkedHashMap(16, 0.75f, true) { @@ -82,7 +84,7 @@ class ImageRepository( thumbnailDir.deleteRecursively() // clean up dir that was used in older versions normalizeLegacyFiles() val filesOnDisk = processedDir.listFiles() - ?.filter { it.extension == "jpg" } + ?.filter { it.extension == "jpg" || it.extension == "png"} ?.map { it.name } ?.toSet() ?: emptySet() @@ -143,7 +145,7 @@ class ImageRepository( } } - suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = + suspend fun add(processed: EncodedImage, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) = mutex.withLock { val id = "${System.currentTimeMillis()}" val key = PageViewKey(id, Rotation.R0, colorMode, 0) @@ -253,20 +255,19 @@ class ImageRepository( saveMetadata() } - suspend fun jpegBytes(key: PageViewKey): Jpeg? = + suspend fun image(key: PageViewKey): EncodedImage? = getOrCompute(imageCache, key, ::computeProcessedImage) - - suspend fun getThumbnail(key: PageViewKey): Jpeg? = + suspend fun getThumbnail(key: PageViewKey): EncodedImage? = getOrCompute(thumbnailCache, key, ::computeThumbnail) // --- Cache compute functions --- private suspend fun getOrCompute( - cache: MutableMap>, + cache: MutableMap>, key: PageViewKey, - compute: suspend (PageViewKey) -> Jpeg? - ): Jpeg? { + compute: suspend (PageViewKey) -> EncodedImage? + ): EncodedImage? { val deferred = cache.computeIfAbsent(key) { k -> scope.async(Dispatchers.IO) { compute(k) } } @@ -278,21 +279,22 @@ class ImageRepository( } } - private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? = + private suspend fun computeProcessedImage(key: PageViewKey): EncodedImage? = withContext(Dispatchers.IO) { val baseFile = processedImageFile(key) if (!baseFile.exists()) return@withContext null - val baseJpeg = Jpeg(baseFile.readBytes()) + val bytes = baseFile.readBytes() + val baseImage = if (baseFile.extension == "png") Png(bytes) else Jpeg(bytes) if (key.rotation == Rotation.R0) { - baseJpeg + baseImage } else { transformations.rotate( - baseJpeg, + baseImage, key.rotation.degrees) } } - private suspend fun computeThumbnail(key: PageViewKey): Jpeg? = + private suspend fun computeThumbnail(key: PageViewKey): EncodedImage? = withContext(Dispatchers.IO) { val processed = getOrCompute(imageCache, key, ::computeProcessedImage) ?: return@withContext null @@ -313,7 +315,12 @@ class ImageRepository( sb.append(".").append(colorMode.name.lowercase()) if (quadVersion > 0) sb.append(".q").append(quadVersion) - sb.append(".jpg") + + if (colorMode == ColorMode.BLACK_AND_WHITE) + sb.append(".png") + else + sb.append(".jpg") + return sb.toString() } diff --git a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt index 12bbf380..2d7e67dd 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -14,20 +14,21 @@ */ package org.fairscan.app.data +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.imageprocessing.ColorMode interface ImageTransformations { - fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg + fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage - fun resizeToThumbnail(input: Jpeg): Jpeg + fun resizeToThumbnail(input: EncodedImage): EncodedImage fun process( source: Jpeg, metadata: PageMetadata, colorMode: ColorMode - ): Jpeg + ): EncodedImage } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt index c1676040..3aea84f6 100644 --- a/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt +++ b/app/src/main/java/org/fairscan/app/domain/CapturedPage.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.Deferred import org.fairscan.imageprocessing.ColorMode data class CapturedPage( - val pageJpeg: Jpeg, + val pageImage: EncodedImage, val sourceJpeg: Deferred, val metadata: PageMetadata, val colorMode: ColorMode, diff --git a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt index dbd68428..498c359e 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt @@ -16,19 +16,20 @@ package org.fairscan.app.domain import org.fairscan.app.data.ImageRepository import org.fairscan.app.platform.processedImage +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.estimateRealDimensions import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.scaledTo import org.opencv.core.Mat -fun interface JpegProvider { - suspend fun get(): Jpeg +fun interface ImageProvider { + suspend fun get(): T } -data class PageToExport( +data class PageToExport( val page: ScanPage, - val jpeg: JpegProvider, + val image: ImageProvider, ) { fun estimatedDimensions(): EstimatedDimensions? { val metadata = page.metadata @@ -56,19 +57,19 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime suspend fun pagesToExport( imageRepository: ImageRepository, exportQuality: ExportQuality -): List { +): List> { val pages = imageRepository.pages() return when (exportQuality) { ExportQuality.BALANCED -> pages.map { - PageToExport(it) { jpeg(it, imageRepository) } + PageToExport(it) { image(it, imageRepository) } } ExportQuality.LOW -> pages.map { page -> PageToExport(page) { - resizeJpegBytesForMaxPixels( - jpeg = jpeg(page, imageRepository), - maxPixels = exportQuality.maxPixels.toDouble(), + resizeImageForMaxPixels( + image = image(page, imageRepository), + maxPixels = exportQuality.maxPixels(page.colorMode ?: ColorMode.COLOR).toDouble(), jpegQuality = exportQuality.jpegQuality ) } @@ -84,29 +85,59 @@ suspend fun pagesToExport( processedImage(source, metadata, rotation, colorMode, exportQuality) } else - jpeg(page, imageRepository) + image(page, imageRepository) } } } } -private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg { +suspend fun jpegsToExport( + imageRepository: ImageRepository, + exportQuality: ExportQuality +): List> { + return pagesToExport(imageRepository, exportQuality).map { + PageToExport( + it.page, + { + val image = it.image.get() + when (image) { + is Jpeg -> image + is Png -> resizeToJpeg(image, exportQuality) + } + }) + } +} + +fun resizeToJpeg(png: Png, exportQuality: ExportQuality): Jpeg { + val input = png.toMat() + // For JPEG, we resize to the same size as for ColorMode.COLOR + val resized = resizeForMaxPixels(input, exportQuality.maxPixels(ColorMode.COLOR).toDouble()) + val jpeg = Jpeg.fromMat(resized, exportQuality.jpegQuality) + input.release() + resized.release() + return jpeg +} + +private suspend fun image(page: ScanPage, imageRepository: ImageRepository): EncodedImage { val key = page.key() - return imageRepository.jpegBytes(key) - ?: throw IllegalArgumentException("JPEG not found for $key") + return imageRepository.image(key) + ?: throw IllegalArgumentException("Image not found for $key") } -private fun resizeJpegBytesForMaxPixels( - jpeg: Jpeg, +private fun resizeImageForMaxPixels( + image: EncodedImage, maxPixels: Double, jpegQuality: Int -): Jpeg { +): EncodedImage { var decoded: Mat? = null var resized: Mat? = null try { - decoded = jpeg.toMat() + decoded = image.toMat() resized = resizeForMaxPixels(decoded, maxPixels) - return Jpeg.fromMat(resized, jpegQuality) + return when (image) { + is Jpeg -> Jpeg.fromMat(resized, jpegQuality) + is Png -> Png.fromMat(resized) + } } finally { decoded?.release() resized?.release() diff --git a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt index 0a6c5cdb..c11295c0 100644 --- a/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt +++ b/app/src/main/java/org/fairscan/app/domain/ExportQuality.kt @@ -15,10 +15,11 @@ package org.fairscan.app.domain import org.fairscan.app.R +import org.fairscan.imageprocessing.ColorMode enum class ExportQuality( val jpegQuality: Int, - val maxPixels: Long, + private val maxPixels: Long, val labelResource: Int ) { LOW( @@ -35,5 +36,11 @@ enum class ExportQuality( jpegQuality = 80, maxPixels = 4_000_000, R.string.export_quality_high, - ) + ); + fun maxPixels(colorMode: ColorMode) = + if (colorMode == ColorMode.BLACK_AND_WHITE) + maxPixels * 4 + else + maxPixels + } diff --git a/app/src/main/java/org/fairscan/app/domain/Image.kt b/app/src/main/java/org/fairscan/app/domain/Image.kt index 9a1bd289..d267961d 100644 --- a/app/src/main/java/org/fairscan/app/domain/Image.kt +++ b/app/src/main/java/org/fairscan/app/domain/Image.kt @@ -17,16 +17,51 @@ package org.fairscan.app.domain import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import org.fairscan.imageprocessing.decodeJpeg +import org.fairscan.imageprocessing.decodeJpegOrPng import org.fairscan.imageprocessing.encodeJpeg +import org.fairscan.imageprocessing.encodePng +import org.fairscan.imageprocessing.packBitsMsbFirst import org.opencv.core.Mat +import org.opencv.imgproc.Imgproc -class Jpeg(val bytes: ByteArray) { +sealed class EncodedImage( + open val bytes: ByteArray +) { + fun toBitmap(): Bitmap = + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + + fun toMat(): Mat = + decodeJpegOrPng(bytes) +} + +class Jpeg(override val bytes: ByteArray): EncodedImage(bytes) { companion object { fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality)) } - fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - fun toMat() : Mat = decodeJpeg(bytes) +} + +class Png(override val bytes: ByteArray): EncodedImage(bytes) { + companion object { + fun fromMat(mat: Mat): Png = Png(encodePng(mat)) + } +} + +// One bit per pixel, MSB first, rows padded to whole bytes, set bit means black. +class Bitonal(val width: Int, val height: Int, val bits: ByteArray) + +fun packBitonal(image: EncodedImage): Bitonal { + val original = image.toMat() + val gray = Mat() + Imgproc.cvtColor(original, gray, Imgproc.COLOR_BGR2GRAY) + original.release() + + val width = gray.width() + val height = gray.height() + val pixels = ByteArray(width * height) + gray.get(0, 0, pixels) + gray.release() + + return Bitonal(width, height, packBitsMsbFirst(pixels, width, height)) } interface ImageLoader { diff --git a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt index 04ab72f2..c744e7ce 100644 --- a/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt +++ b/app/src/main/java/org/fairscan/app/platform/AndroidPdfWriter.kt @@ -15,10 +15,12 @@ package org.fairscan.app.platform import android.content.res.AssetManager +import android.graphics.Bitmap import android.util.Log import com.tom_roush.pdfbox.cos.COSArray import com.tom_roush.pdfbox.cos.COSDictionary import com.tom_roush.pdfbox.cos.COSName +import com.tom_roush.pdfbox.filter.FilterFactory import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDPage import com.tom_roush.pdfbox.pdmodel.PDPageContentStream @@ -27,14 +29,22 @@ import com.tom_roush.pdfbox.pdmodel.PDResources import com.tom_roush.pdfbox.pdmodel.common.PDRectangle import com.tom_roush.pdfbox.pdmodel.common.PDStream import com.tom_roush.pdfbox.pdmodel.font.PDFontDescriptor +import com.tom_roush.pdfbox.pdmodel.graphics.color.PDDeviceGray import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory +import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImageXObject import org.fairscan.app.BuildConfig import org.fairscan.app.data.PdfWriter +import org.fairscan.app.domain.Bitonal +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.OcrService import org.fairscan.app.domain.PageToExport +import org.fairscan.app.domain.packBitonal +import org.fairscan.imageprocessing.ColorMode import org.fairscan.imageprocessing.EstimatedDimensions import org.fairscan.imageprocessing.OcrTextBox import org.fairscan.imageprocessing.PaperFormats +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.OutputStream import java.util.Calendar import java.util.Locale @@ -42,7 +52,7 @@ import java.util.Locale class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter { override suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, @@ -53,8 +63,12 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P doc.use { document -> val ocrDocument = OcrDocument(document, assets) for ((index, page) in pages.withIndex()) { - val jpeg = page.jpeg.get() - val image = JPEGFactory.createFromByteArray(document, jpeg.bytes) + val jpegOrPng = page.image.get() + val blackAndWhite = page.page.colorMode == ColorMode.BLACK_AND_WHITE + val image = if (blackAndWhite) + createCcittG4Image(document, packBitonal(jpegOrPng)) + else + JPEGFactory.createFromByteArray(document, jpegOrPng.bytes) // PDF has 72 points (units) per inch, 1 inch = 25.4 mm val pointsPerMm = 72f / 25.4f @@ -84,7 +98,10 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P if (!disableOcr) { try { - val bitmap = jpeg.toBitmap() + // Black and white pages carry four times the pixels for the PDF. Text + // recognition gets the same size as with the other modes. + val bitmap = if (blackAndWhite) halfSize(jpegOrPng.toBitmap()) + else jpegOrPng.toBitmap() val ocrTextBoxes = ocrService.runOcr(bitmap) val pdfPageDimensions = PageDimensions( bitmap.width, @@ -107,6 +124,37 @@ class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : P } } +// Same sequence of calls as PDFBox's own CCITTFactory, which is not usable here because it +// insists on an ALPHA_8 bitmap. The filter reads a set bit as black, so /BlackIs1 is left out. +private fun createCcittG4Image(document: PDDocument, bitonal: Bitonal): PDImageXObject { + val encoded = ByteArrayOutputStream() + val decodeParms = COSDictionary().apply { + setInt(COSName.COLUMNS, bitonal.width) + setInt(COSName.ROWS, bitonal.height) + } + FilterFactory.INSTANCE.getFilter(COSName.CCITTFAX_DECODE) + .encode(ByteArrayInputStream(bitonal.bits), encoded, decodeParms, 0) + + val image = PDImageXObject( + document, + ByteArrayInputStream(encoded.toByteArray()), + COSName.CCITTFAX_DECODE, + bitonal.width, + bitonal.height, + 1, + PDDeviceGray.INSTANCE, + ) + decodeParms.setInt(COSName.K, -1) + image.cosObject.setItem(COSName.DECODE_PARMS, decodeParms) + return image +} + +private fun halfSize(bitmap: Bitmap): Bitmap { + val scaled = Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true) + bitmap.recycle() + return scaled +} + fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair { val maxDim = 297.0 // A4 height val minDim = 215.9 // Letter width diff --git a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt index e96ff0ec..918db489 100644 --- a/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt +++ b/app/src/main/java/org/fairscan/app/platform/ImageProcessor.kt @@ -20,9 +20,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import org.fairscan.app.data.ImageTransformations import org.fairscan.app.domain.CapturedPage +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata +import org.fairscan.app.domain.Png import org.fairscan.app.domain.Rotation import org.fairscan.app.ui.screens.settings.DefaultColorMode import org.fairscan.imageprocessing.ColorMode @@ -46,13 +48,13 @@ import kotlin.math.min class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg { + override fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage { return transform(input, ExportQuality.BALANCED.jpegQuality) { rotate(it, rotationDegrees) } } - override fun resizeToThumbnail(input: Jpeg): Jpeg { + override fun resizeToThumbnail(input: EncodedImage): EncodedImage { val maxSize = thumbnailSizePx.toFloat() return transform(input, 85) { src -> val ratio = min(maxSize / src.width(), maxSize / src.height()) @@ -70,15 +72,18 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { } private fun transform( - inJpeg: Jpeg, + inputImage: EncodedImage, jpegQuality: Int, transform: (Mat) -> Mat, - ): Jpeg { - val input = inJpeg.toMat() + ): EncodedImage { + val input = inputImage.toMat() var output: Mat? = null try { output = transform.invoke(input) - return Jpeg.fromMat(output, jpegQuality) + return when (inputImage) { + is Jpeg -> Jpeg.fromMat(output, jpegQuality) + is Png -> Png.fromMat(output) + } } finally { input.release() output?.release() @@ -89,7 +94,7 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations { source: Jpeg, metadata: PageMetadata, colorMode: ColorMode - ): Jpeg { + ): EncodedImage { val baseRotation = metadata.baseRotation return processedImage(source, metadata, baseRotation, colorMode, ExportQuality.BALANCED) } @@ -101,16 +106,16 @@ fun processedImage( rotation: Rotation, colorMode: ColorMode, exportQuality: ExportQuality, -): Jpeg { +): EncodedImage { val rotationDegrees = rotation.degrees var sourceMat: Mat? = null var page: Mat? = null try { sourceMat = source.toMat() val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height()) - page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels(colorMode), metadata.opticalMeasures) - return Jpeg.fromMat(page, exportQuality.jpegQuality) + return encodeImage(page, colorMode, exportQuality) } finally { sourceMat?.release() page?.release() @@ -142,7 +147,7 @@ fun extractDocumentFromBitmap( if (mask == null || quadInMask == null) { // No document detected - val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble()) + val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels(ColorMode.COLOR).toDouble()) page = rotate(resized, rotationDegrees) resized.release() } else { @@ -150,11 +155,12 @@ fun extractDocumentFromBitmap( normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1) autoColorMode = autoColorMode(bgr, mask, quad) colorMode = defaultColorMode.colorMode ?: autoColorMode - page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels, + page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels(colorMode), opticalMeasures) } - val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality) + val encodedImage = encodeImage(page, colorMode, exportQuality) + bgr.release() page.release() @@ -165,7 +171,14 @@ fun extractDocumentFromBitmap( val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) { compressSource(source) } - return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode) + return CapturedPage(encodedImage, sourceJpegDeferred, metadata, colorMode) +} + +private fun encodeImage(image: Mat, colorMode: ColorMode, quality: ExportQuality): EncodedImage { + return if (colorMode == ColorMode.BLACK_AND_WHITE) + Png.fromMat(image) + else + Jpeg.fromMat(image, quality.jpegQuality) } private fun compressSource(source: Bitmap): Jpeg { diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt index 826053ec..9a997093 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraScreen.kt @@ -328,7 +328,7 @@ private fun CameraScreenScaffold( } } if (cameraUiState.captureState is CaptureState.CapturePreview) { - val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap() + val page = cameraUiState.captureState.capturedPage.pageImage.toBitmap() CapturedImage(page.asImageBitmap(), thumbnailCoords) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt index b10c2b18..788c3532 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/document/DocumentScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Contrast import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.FontDownload import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateRight @@ -63,6 +64,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -87,6 +89,7 @@ import org.fairscan.app.ui.fakeDocument import org.fairscan.app.ui.fakeImage import org.fairscan.app.ui.theme.FairScanTheme import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.ColorMode.GRAYSCALE @@ -98,7 +101,7 @@ fun DocumentScreen( onExportClick: () -> Unit, onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, onPageReorder: (String, Int) -> Unit, onPageSelected: (Int) -> Unit, @@ -132,7 +135,7 @@ fun DocumentScreen( uiState, { showDeletePageDialog.value = true }, onRotateImage, - onToggleColorMode, + onColorModeSelected, onCropClick, modifier ) @@ -151,7 +154,7 @@ private fun DocumentPreview( uiState: DocumentUiState, onDeleteImage: () -> Unit, onRotateImage: (Boolean) -> Unit, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, modifier: Modifier, ) { @@ -199,7 +202,7 @@ private fun DocumentPreview( } EditButtons( uiState, - onToggleColorMode, + onColorModeSelected, onCropClick, modifier = Modifier.align(Alignment.BottomStart) ) @@ -256,7 +259,7 @@ fun RotationButtons( @Composable fun EditButtons( uiState: DocumentUiState, - onToggleColorMode: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, onCropClick: () -> Unit, modifier: Modifier ) { @@ -264,7 +267,7 @@ fun EditButtons( uiState.currentPage?.colorMode?.let { ColorModeButton( currentColorMode = it, - onToggle = { onToggleColorMode() }, + onColorModeSelected = onColorModeSelected, ) } Spacer(Modifier.width(8.dp)) @@ -281,7 +284,7 @@ fun EditButtons( @Composable fun ColorModeButton( currentColorMode: ColorMode, - onToggle: () -> Unit, + onColorModeSelected: (ColorMode) -> Unit, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } @@ -296,36 +299,31 @@ fun ColorModeButton( expanded = expanded, onDismissRequest = { expanded = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.color_mode_color)) }, - leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) }, - onClick = { - if (currentColorMode != COLOR) onToggle() - expanded = false - }, - trailingIcon = { - if (currentColorMode == COLOR) { - Icon(Icons.Default.Check, contentDescription = null) + ColorMode.entries.forEach { colorMode -> + DropdownMenuItem( + text = { Text(stringResource(colorMode.labelResource)) }, + onClick = { + onColorModeSelected(colorMode) + expanded = false + }, + trailingIcon = { + if (currentColorMode == colorMode) { + Icon(Icons.Default.Check, contentDescription = null) + } } - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.color_mode_grayscale)) }, - leadingIcon = { Icon(Icons.Default.Contrast, contentDescription = null) }, - onClick = { - if (currentColorMode != GRAYSCALE) onToggle() - expanded = false - }, - trailingIcon = { - if (currentColorMode == GRAYSCALE) { - Icon(Icons.Default.Check, contentDescription = null) - } - } - ) + ) + } } } } +private val ColorMode.labelResource: Int + get() = when (this) { + COLOR -> R.string.color_mode_color + GRAYSCALE -> R.string.color_mode_grayscale + BLACK_AND_WHITE -> R.string.color_mode_black_and_white + } + @Composable private fun BottomBar( onExportClick: () -> Unit, @@ -378,7 +376,7 @@ fun DocumentScreenPreview() { onExportClick = {}, onDeleteImage = { }, onRotateImage = { _ -> }, - onToggleColorMode = { }, + onColorModeSelected = { }, onCropClick = { }, onPageReorder = { _,_ -> }, onPageSelected = { _ -> }, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt index 945330aa..86d1dfbf 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt @@ -300,6 +300,13 @@ private fun PdfInfos( ) } + if (uiState.blackAndWhiteAsJpeg) { + Text( + stringResource(R.string.black_and_white_jpeg_hint), + style = MaterialTheme.typography.bodySmall, + ) + } + if (uiState.isGenerating) { Text( text = stringResource(R.string.creating_export), diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt index 06897388..69ca46f4 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportUiState.kt @@ -23,6 +23,9 @@ data class ExportUiState( val isGenerating: Boolean = false, val progress: ExportProgress? = null, val ocrActivation: Boolean? = null, + // The output format is JPEG and the document has black and white pages, which are smaller + // and sharper as PDF. + val blackAndWhiteAsJpeg: Boolean = false, val isSaving: Boolean = false, val result: ExportResult? = null, val savedBundle: SavedBundle? = null, diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt index c2a5bd60..00c08cfc 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt @@ -46,9 +46,11 @@ import org.fairscan.app.data.FileManager import org.fairscan.app.data.ImageRepository import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.jpegsToExport import org.fairscan.app.domain.pagesToExport import org.fairscan.app.ui.screens.settings.ExportFormat import org.fairscan.app.ui.screens.settings.ExportFormat.PDF +import org.fairscan.imageprocessing.ColorMode import java.io.File import java.io.FileInputStream import java.io.IOException @@ -160,6 +162,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit preparationJob = launch { val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null + val blackAndWhiteAsJpeg = exportFormat == ExportFormat.JPEG && + currentPageKeys.any { it.colorMode == ColorMode.BLACK_AND_WHITE } _uiState.update { ExportUiState( filename = it.filename, @@ -167,6 +171,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit isGenerating = true, progress = ExportProgress(0, pageCount), ocrActivation = ocrActivation, + blackAndWhiteAsJpeg = blackAndWhiteAsJpeg, ) } val onProgress: (Int) -> Unit = { completedPages -> @@ -204,12 +209,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit exportQuality: ExportQuality, onProgress: (Int) -> Unit, ): ExportResult.Jpeg = withContext(Dispatchers.IO) { - val pageToExports = pagesToExport(imageRepository, exportQuality) + val jpegPages = jpegsToExport(imageRepository, exportQuality) val timestamp = System.currentTimeMillis() preparationDir.mkdirs() - val files = pageToExports.mapIndexed { index, page -> + val files = jpegPages.mapIndexed { index, page -> val file = File(preparationDir, "$timestamp-${index + 1}.jpg") - file.writeBytes(page.jpeg.get().bytes) + file.writeBytes(page.image.get().bytes) onProgress(index + 1) file }.toList() diff --git a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt index 28d7f565..9f62e7ef 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/settings/SettingsRepository.kt @@ -43,6 +43,7 @@ class SettingsRepository( "AUTO" -> DefaultColorMode.AUTO "COLOR" -> DefaultColorMode.COLOR "GRAYSCALE" -> DefaultColorMode.GRAYSCALE + "BLACK_AND_WHITE" -> DefaultColorMode.BLACK_AND_WHITE else -> DefaultColorMode.AUTO } } @@ -108,6 +109,7 @@ enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) { AUTO(null, R.string.color_mode_auto), COLOR(ColorMode.COLOR, R.string.color_mode_color), GRAYSCALE(ColorMode.GRAYSCALE, R.string.color_mode_grayscale), + BLACK_AND_WHITE(ColorMode.BLACK_AND_WHITE, R.string.color_mode_black_and_white), } enum class ExportFormat(val mimeType: String) { diff --git a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt index 13bbc604..c4480f42 100644 --- a/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/state/DocumentUiModel.kt @@ -17,7 +17,7 @@ package org.fairscan.app.ui.state import android.graphics.Bitmap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import org.fairscan.app.domain.Jpeg +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.PageViewKey data class DocumentUiModel( @@ -39,5 +39,5 @@ data class DocumentUiModel( data class PageThumbnail( val key: PageViewKey, - val thumbnail: Jpeg?, + val thumbnail: EncodedImage?, ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e84878bd..782dc70c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -4,6 +4,7 @@ تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا. تطبيق ارجع + الصفحات بالأبيض والأسود أصغر حجمًا وأكثر وضوحًا بصيغة PDF. رُفض إذن الوصول إلى الكاميرا يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي. ألغِ @@ -12,6 +13,7 @@ إغلاق فلتر تلقائي + أبيض وأسود ألوان الفلتر الافتراضي تدرج الرمادي diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b615c981..f0c5745e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -4,6 +4,7 @@ Jednoduchá a respektující aplikace pro skenování vašich dokumentů Použít Zpět + Černobílé stránky jsou v PDF menší a ostřejší. Byl odepřen přístup k fotoaparátu Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken. Zrušit @@ -12,6 +13,7 @@ Zavřít Filtr Automaticky + Černobílá Barva Výchozí filtr Odstíny šedi diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 430ad623..4af1602b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,6 +4,7 @@ Eine einfache und respektvolle App zum Scannen Ihrer Dokumente. Anwenden Zurück + Schwarzweiß-Seiten sind als PDF kleiner und schärfer. Kamerazugriff wurde verweigert Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht. Abbrechen @@ -12,6 +13,7 @@ Schließen Filter Automatisch + Schwarzweiß Farbe Standardfilter Graustufen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 841fb1a3..7cdac2a4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,6 +4,7 @@ Una aplicación sencilla y respetuosa para escanear tus documentos. Aplicar Atrás + Las páginas en blanco y negro son más pequeñas y nítidas en PDF. Permiso de cámara denegado La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual. Cancelar @@ -12,6 +13,7 @@ Cerrar Filtro Automático + Blanco y negro Color Filtro predeterminado Escala de grises diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9dc57f85..655369f7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -5,6 +5,7 @@ Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks. Rakenda Tagasi + Mustvalged leheküljed on PDF-ina väiksemad ja teravamad. Kaamera kasutamise õigused on puudu Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes. Katkesta @@ -13,6 +14,7 @@ Sulge Filtreeri Automaatne + Mustvalge Värvid Vaikimisi filter Halltoonides diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8330f8ac..b46cc05c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,6 +5,7 @@ Une application simple et respectueuse pour scanner vos documents. Appliquer Retour + Les pages en noir et blanc sont plus légères et plus nettes en PDF. L\'autorisation d\'accès à la caméra a été refusée L’application a besoin d’accéder à l’appareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours. Annuler @@ -13,6 +14,7 @@ Fermer Filtre Automatique + Noir et blanc Couleur Filtre par défaut Niveaux de gris diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 4efbec77..730be9bb 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -4,6 +4,7 @@ Unha aplicación sinxela e respectuosa para escanear os teus documentos. Aplicar Atrás + As páxinas en branco e negro son máis pequenas e nítidas en PDF. Permiso da cámara denegado A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual. Cancelar @@ -12,6 +13,7 @@ Pechar Filtro Automático + Branco e negro Cor Filtro predeterminado Escala de grises diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c105a39c..81125c9e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,6 +4,7 @@ Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás. Alkalmaz Vissza + A fekete-fehér oldalak PDF-ként kisebbek és élesebbek. Kamerahozzáférés megtagadva Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést. Mégse @@ -12,6 +13,7 @@ Bezárás Színszűrő Automatikus + Fekete-fehér Színes Alapértelmezett színszűrő Szürkeárnyalatos diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 53faef4d..c4b025c0 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -4,6 +4,7 @@ Aplikasi sederhana dan aman untuk memindai dokumen Anda Terapkan Kembali + Halaman hitam putih lebih kecil dan lebih tajam sebagai PDF. Izin penggunaan kamera ditolak Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini. Batalkan @@ -12,6 +13,7 @@ Tutup Filter Otomatis + Hitam putih Warna Filter bawaan Abu-abu diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2a865033..5b21c859 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,6 +4,7 @@ Un\'app semplice e rispettosa per scansionare i tuoi documenti. Applica Indietro + Le pagine in bianco e nero sono più piccole e nitide in PDF. Autorizzazione fotocamera negata L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente. Annulla @@ -12,6 +13,7 @@ Chiudi Filtro Automatico + Bianco e nero Colore Filtro predefinito Scala di grigi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ab5828aa..19a82176 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -4,6 +4,7 @@ シンプルで安心なドキュメントスキャナーです。 適用 戻る + 白黒ページは PDF のほうが小さく鮮明になります。 カメラの権限が拒否されました ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。 キャンセル @@ -12,6 +13,7 @@ 閉じる フィルター 自動 + 白黒 カラー デフォルトフィルター グレースケール diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 0094cb99..02dc4cbb 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1,5 +1,6 @@ + Melnbalts Sabalansēta Aizvērt Saglabāt @@ -27,6 +28,7 @@ Teksta atpazīšana: atspējota Atpakaļ + Melnbaltas lapas PDF formātā ir mazākas un asākas. Dzēst Atbalsts Versija diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 20c1dbf6..da5938eb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -4,6 +4,7 @@ Um aplicativo simples e respeitoso para digitalizar seus documentos. Aplicar Voltar + Páginas em preto e branco ficam menores e mais nítidas em PDF. Permissão da câmera negada O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual. Cancelar @@ -12,6 +13,7 @@ Fechar Filtro Automático + Preto e branco Cor Filtro padrão Escala de cinza diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e1e63f41..a9c6901e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,6 +4,7 @@ Простое и открытое приложение для сканирования Ваших документов. Применить Назад + Чёрно-белые страницы в PDF меньше и чётче. В доступе к камере отказано Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования. Отмена @@ -12,6 +13,7 @@ Закрыть Фильтр Автоматически + Чёрно-белый Цвет Фильтр по умолчанию Оттенки серого diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 0946eebe..7febaf59 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -4,6 +4,7 @@ En enkel och respektfull app för att skanna dina dokument. Använd Tillbaka + Svartvita sidor blir mindre och skarpare som PDF. Kamerabehörighet nekades Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen. Avbryt @@ -12,6 +13,7 @@ Stäng Filter Automatiskt + Svartvitt Färg Standardfilter Gråskala diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2ce5b445..4701f36e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -4,6 +4,7 @@ Belgelerinizi taramak için basit ve saygılı bir uygulama. Uygula Geri + Siyah beyaz sayfalar PDF olarak daha küçük ve daha net olur. Kamera izni reddedildi Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir. İptal @@ -12,6 +13,7 @@ Kapat Filtre Otomatik + Siyah beyaz Renkli Varsayılan filtre Gri tonlama diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 80c9d8f7..bfecff8b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,6 +4,7 @@ 一個簡單且尊重隱私的文件掃描應用程式。 套用 返回 + 黑白頁面匯出為 PDF 時更小、更清晰。 已拒絕相機權限 此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。 取消 @@ -12,6 +13,7 @@ 關閉 濾鏡 自動 + 黑白 彩色 預設濾鏡 灰階 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 56dd1c35..f5ccc091 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -4,6 +4,7 @@ 一个简单且克制的文档扫描应用 应用 返回 + 黑白页面导出为 PDF 时更小、更清晰。 相机权限被拒绝 应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。 取消 @@ -12,6 +13,7 @@ 关闭 滤镜 自动 + 黑白 彩色 默认滤镜 灰度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42c74164..07af5573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Apply Back + + Black & white pages are smaller and sharper as PDF. Camera permission was denied @@ -32,6 +34,9 @@ Filter Automatic + + Black & white Color diff --git a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt index 3443f5de..7a2c0234 100644 --- a/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt +++ b/app/src/test/java/org/fairscan/app/data/FileManagerTest.kt @@ -16,6 +16,7 @@ package org.fairscan.app.data import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageToExport import org.fairscan.app.domain.Rotation @@ -76,18 +77,18 @@ class FileManagerTest { fun generatePdf() = runTest { val fakePdfWriter = object : PdfWriter { override suspend fun writePdfFromJpegs( - pages: List, + pages: List>, outputStream: OutputStream, disableOcr: Boolean, onProgress: (Int) -> Unit, ) { val list = pages.toList() - list.forEach { page -> outputStream.write(page.jpeg.get().bytes) } + list.forEach { page -> outputStream.write(page.image.get().bytes) } } } val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val pages = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)) - .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } + .map { PageToExport(ScanPage("1", Rotation.R0, null, 1, null)) { Jpeg(it) } } val pdf = manager.generatePdf(pages, true) {} assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.sizeInBytes).isEqualTo(3) diff --git a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt index dbdef516..f7f0c2be 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -23,15 +23,18 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat +import org.fairscan.app.domain.EncodedImage import org.fairscan.app.domain.Jpeg import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey +import org.fairscan.app.domain.Png import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R180 import org.fairscan.app.domain.Rotation.R270 import org.fairscan.app.domain.Rotation.R90 import org.fairscan.imageprocessing.CameraIntrinsics import org.fairscan.imageprocessing.ColorMode +import org.fairscan.imageprocessing.ColorMode.BLACK_AND_WHITE import org.fairscan.imageprocessing.ColorMode.COLOR import org.fairscan.imageprocessing.ColorMode.GRAYSCALE import org.fairscan.imageprocessing.ImageSize @@ -65,16 +68,16 @@ class ImageRepositoryTest { } fun repo( - rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input }, - resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) }, + rotate: (EncodedImage, Int) -> EncodedImage = { input, _ -> input }, + resizeToThumbnail: (EncodedImage) -> Jpeg = { input -> jpeg(input.bytes[0]) }, process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ -> throw UnsupportedOperationException() } ): ImageRepository { val transformations = object : ImageTransformations { - override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg = + override fun rotate(input: EncodedImage, rotationDegrees: Int): EncodedImage = rotate(input, rotationDegrees) - override fun resizeToThumbnail(input: Jpeg): Jpeg = + override fun resizeToThumbnail(input: EncodedImage): Jpeg = resizeToThumbnail(input) override fun process( source: Jpeg, @@ -95,7 +98,7 @@ class ImageRepositoryTest { assertThat(repo.imageIds()).hasSize(1) val id = repo.imageIds()[0] val key = PageViewKey(id, R0, COLOR, 0) - assertThat(repo.jpegBytes(key)).isEqualTo(jpeg) + assertThat(repo.image(key)).isEqualTo(jpeg) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) val page = repo.pages().first() @@ -161,7 +164,7 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) + assertThat(repo.image(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test @@ -190,14 +193,14 @@ class ImageRepositoryTest { File(processedDir(), "1-90.jpg").writeBytes(bytes) val repo = repo() assertThat(repo.imageIds()).containsExactly("1") - assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) + assertThat(repo.image(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes) } @Test fun `should return null on invalid id`() = runTest { val repo = repo() assertThat(repo.imageIds()).isEmpty() - assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR, 0))).isNull() + assertThat(repo.image(PageViewKey("x", R0, COLOR, 0))).isNull() } @Test @@ -258,7 +261,7 @@ class ImageRepositoryTest { repo.setColorMode(id, GRAYSCALE) assertThat(repo.pages().first().colorMode).isEqualTo(GRAYSCALE) val key = PageViewKey(id, R0, GRAYSCALE, 0) - assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(41)) + assertThat(repo.image(key)?.bytes).isEqualTo(byteArrayOf(41)) } @Test @@ -278,7 +281,7 @@ class ImageRepositoryTest { launch { repo.setColorMode(id, GRAYSCALE) } } val key = PageViewKey(id, R0, GRAYSCALE, 0) - assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(1)) + assertThat(repo.image(key)?.bytes).isEqualTo(byteArrayOf(1)) assertThat(processCalls).isEqualTo(1) } @@ -365,6 +368,21 @@ class ImageRepositoryTest { assertThat(repo2.lastAddedSourceFile()).isNull() } + @Test + fun png_image() = runTest { + val repo = repo() + val png = png(101, 102, 103) + repo.add(png, jpeg(51), metadata1, ColorMode.BLACK_AND_WHITE) + assertThat(repo.imageIds()).hasSize(1) + val id = repo.imageIds()[0] + val key = PageViewKey(id, R0, BLACK_AND_WHITE, 0) + assertThat(repo.image(key)).isEqualTo(png) + assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101)) + + val repo2 = repo() + assertThat(repo2.imageIds()).containsExactly(id) + } + private fun processedDir(): File = File(getFilesDir(), PROCESSED_DIR_NAME) private fun sourceDir(): File = File(getFilesDir(), SOURCE_DIR_NAME) @@ -380,4 +398,5 @@ class ImageRepositoryTest { pages().map { it.id }.toPersistentList() private fun jpeg(vararg bytes: Byte) = Jpeg(bytes) + private fun png(vararg bytes: Byte) = Png(bytes) } diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt new file mode 100644 index 00000000..fbe4f5bd --- /dev/null +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/BitonalPacking.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.imageprocessing + +private const val BITONAL_THRESHOLD = 128 + +private fun bytesPerRow(width: Int): Int = (width + 7) / 8 + +// One bit per pixel, MSB first, each row padded to whole bytes. A set bit means black, which +// is what the CCITT fax encoder expects when /BlackIs1 is left out. +fun packBitsMsbFirst(gray: ByteArray, width: Int, height: Int): ByteArray { + val stride = bytesPerRow(width) + val packed = ByteArray(stride * height) + for (y in 0 until height) { + val sourceRow = y * width + val targetRow = y * stride + for (x in 0 until width) { + if ((gray[sourceRow + x].toInt() and 0xFF) < BITONAL_THRESHOLD) { + val index = targetRow + (x shr 3) + packed[index] = (packed[index].toInt() or (0x80 ushr (x and 7))).toByte() + } + } + } + return packed +} + +// Inverse of packBitsMsbFirst. +internal fun unpackBitsMsbFirst(packed: ByteArray, width: Int, height: Int): ByteArray { + val stride = bytesPerRow(width) + val gray = ByteArray(width * height) + for (y in 0 until height) { + val sourceRow = y * stride + val targetRow = y * width + for (x in 0 until width) { + val bit = (packed[sourceRow + (x shr 3)].toInt() shr (7 - (x and 7))) and 1 + gray[targetRow + x] = if (bit == 1) 0 else 255.toByte() + } + } + return gray +} diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt index 1a7bbaf2..9db528b1 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/DocumentDetection.kt @@ -193,7 +193,7 @@ fun extractDocument( Imgproc.warpPerspective(inputMat, warped, transform, outputSize) val resized = resizeForMaxPixels(warped, maxPixels.toDouble()) - val enhanced = enhanceCapturedImage(resized, colorMode) + val enhanced = enhanceCapturedImage(resized, colorMode, maxPixels) val rotated = rotate(enhanced, rotationDegrees) warped.release() diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt index 2e8368e4..48382b5e 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/PostProcessing.kt @@ -19,21 +19,26 @@ import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfFloat import org.opencv.core.MatOfInt +import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.core.Size import org.opencv.imgproc.Imgproc import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt enum class ColorMode { COLOR, GRAYSCALE, + BLACK_AND_WHITE, } -fun enhanceCapturedImage(img: Mat, colorMode: ColorMode): Mat { +fun enhanceCapturedImage(img: Mat, colorMode: ColorMode, maxPixels: Long = 0L): Mat { return when (colorMode) { ColorMode.COLOR -> multiScaleRetinexOnL(img) ColorMode.GRAYSCALE -> enhanceGrayscaleImage(img) + ColorMode.BLACK_AND_WHITE -> binarizeDocument(img, maxPixels) } } @@ -175,6 +180,7 @@ fun multiScaleRetinexOnL(bgr: Mat): Mat { return result } +// Expects values in [0..255] fun percentileL(l: Mat, p: Double): Double { val hist = Mat() Imgproc.calcHist( @@ -199,7 +205,33 @@ fun percentileL(l: Mat, p: Double): Double { return 255.0 } +// Estimate 2 percentiles in `src` on a subsample rather than the full image. +private fun percentiles(src: Mat, low: Double, high: Double): Pair { + val sample = resizeForMaxPixels(src, 500_000.0, Imgproc.INTER_NEAREST) + val flat = Mat() + sample.reshape(1, 1).copyTo(flat) + val sorted = Mat() + Core.sort(flat, sorted, Core.SORT_ASCENDING) + val n = sorted.cols() + val pLow = sorted.get(0, (n * low).toInt().coerceIn(0, n - 1))[0] + val pHigh = sorted.get(0, (n * high).toInt().coerceIn(0, n - 1))[0] + flat.release() + sorted.release() + sample.release() + return pLow to pHigh +} + fun enhanceGrayscaleImage(img: Mat): Mat { + val gray = flattenedGrayscale(img) + val finalBgr = Mat() + Imgproc.cvtColor(gray, finalBgr, Imgproc.COLOR_GRAY2BGR) + gray.release() + return finalBgr +} + +// Steps 1 to 5 of enhanceGrayscaleImage, without the conversion back to BGR. +// Binarization reuses it for the same illumination flattening. +private fun flattenedGrayscale(img: Mat): Mat { // -- 1. Convert to grayscale -------- val gray = Mat() @@ -247,16 +279,7 @@ fun enhanceGrayscaleImage(img: Mat): Mat { // gray areas more visible. val retinexExp = Mat() Core.exp(retinex, retinexExp) - - val flat = Mat() - retinexExp.reshape(1, 1).copyTo(flat) - val sorted = Mat() - Core.sort(flat, sorted, Core.SORT_ASCENDING) - val n = sorted.cols() - val pLow = sorted.get(0, (n * 0.004).toInt())[0] - val pHigh = sorted.get(0, (n * 0.99).toInt())[0] - flat.release(); sorted.release() - + val (pLow, pHigh) = percentiles(retinexExp, 0.004, 0.99) val normalized = Mat() Core.subtract(retinexExp, Scalar(pLow), normalized) val scale = if (pHigh > pLow) 255.0 / (pHigh - pLow) else 1.0 @@ -291,14 +314,7 @@ fun enhanceGrayscaleImage(img: Mat): Mat { if (modeVal >= 254) { val grayF = Mat() gray.convertTo(grayF, CvType.CV_32F) - val grayFlat = Mat() - grayF.reshape(1, 1).copyTo(grayFlat) - val graySorted = Mat() - Core.sort(grayFlat, graySorted, Core.SORT_ASCENDING) - val gN = graySorted.cols() - val gLow = graySorted.get(0, (gN * 0.01).toInt())[0] - val gHigh = graySorted.get(0, (gN * 0.99).toInt())[0] - grayFlat.release(); graySorted.release() + val (gLow, gHigh) = percentiles(grayF, 0.01, 0.99) Core.subtract(grayF, Scalar(gLow), grayF) Core.multiply(grayF, Scalar(255.0 / (gHigh - gLow + 1e-6)), grayF) Core.min(grayF, Scalar(255.0), grayF) @@ -320,14 +336,224 @@ fun enhanceGrayscaleImage(img: Mat): Mat { val denoised = Mat() Imgproc.bilateralFilter(stretched8u, denoised, 9, 20.0, 10.0) - val finalBgr = Mat() - Imgproc.cvtColor(denoised, finalBgr, Imgproc.COLOR_GRAY2BGR) - // -- Cleanup ----------- gray.release(); imgFloat.release(); logImg.release() blur.release(); logBlur.release(); diff.release() retinex.release(); result8u.release() - stretched8u.release(); denoised.release() + stretched8u.release() - return finalBgr + return denoised +} + +private const val SAUVOLA_K = 0.25 +private const val SAUVOLA_R = 128.0 + +// Returns a single channel containing only 0 and 255. Black and white pages are stored as +// PNG, where two more copies of that channel would only take space. The export path packs +// it into one bit per pixel. +fun binarizeDocument(img: Mat, upscaleTo: Long = 0L): Mat { + // Flatten the illumination at the captured resolution. Interpolated pixels carry no extra + // information for that step, only for where the threshold puts an edge. + val flattened = flattenedGrayscale(img) + val gray = upscaleToPixels(flattened, upscaleTo) + flattened.release() + val window = sauvolaWindow(max(gray.cols(), gray.rows())) + + val src = Mat() + gray.convertTo(src, CvType.CV_32F) + gray.release() + + val binary = sauvolaThreshold(src, window) + val fill = flatFill(src, window) + src.release() + + // A local threshold has no reference point inside a large flat fill: further than half a + // window from paper the mean is the fill itself, and the fill comes out white. + Core.subtract(binary, fill, binary) + fillHoles(binary, fill, window) + fill.release() + return binary +} + +private fun upscaleToPixels(img: Mat, targetPixels: Long): Mat { + val pixels = img.width().toLong() * img.height() + if (targetPixels <= pixels) return img.clone() + val scale = sqrt(targetPixels.toDouble() / pixels) + val out = Mat() + Imgproc.resize(img, out, Size(img.width() * scale, img.height() * scale), + 0.0, 0.0, Imgproc.INTER_CUBIC) + return out +} + +// Sauvola local thresholding: t = mean * (1 + k * (stdDev / r - 1)) +private fun sauvolaThreshold(src: Mat, window: Int): Mat { + val windowSize = Size(window.toDouble(), window.toDouble()) + + val mean = Mat() + Imgproc.boxFilter(src, mean, CvType.CV_32F, windowSize) + + val squares = Mat() + Core.multiply(src, src, squares) + val deviation = Mat() + Imgproc.boxFilter(squares, deviation, CvType.CV_32F, windowSize) + squares.release() + + val meanSquared = Mat() + Core.multiply(mean, mean, meanSquared) + Core.subtract(deviation, meanSquared, deviation) + meanSquared.release() + Core.max(deviation, Scalar(0.0), deviation) + Core.sqrt(deviation, deviation) + + Core.multiply(deviation, Scalar(SAUVOLA_K / SAUVOLA_R), deviation) + Core.add(deviation, Scalar(1.0 - SAUVOLA_K), deviation) + val threshold = Mat() + Core.multiply(mean, deviation, threshold) + mean.release(); deviation.release() + + val binary = Mat() + Core.compare(src, threshold, binary, Core.CMP_GT) + threshold.release() + + return binary +} + +internal fun sauvolaWindow(maxDim: Int): Int = (maxDim / 10).coerceIn(15, 1001) or 1 + +// Window and deviation below which an area counts as a flat fill rather than texture. +private const val FILL_WINDOW = 9.0 +private const val FILL_DEVIATION = 12.0 + +// Step 4 of the grayscale pipeline stretches the page background to white. +private const val FILL_LEVEL = 155.0 + +// Dark pixels belonging to a flat fill: a smooth dark spot seeds the fill, the seed grows over +// the area one local window covers, and the result is clipped back to the dark pixels. Texture +// produces almost no seeds, so photographs stay on the local threshold. +private fun flatFill(src: Mat, window: Int): Mat { + val dark = Mat() + Core.compare(src, Scalar(FILL_LEVEL), dark, Core.CMP_LT) + + val deviation = localDeviation(src, FILL_WINDOW) + val seeds = Mat() + Core.compare(deviation, Scalar(FILL_DEVIATION), seeds, Core.CMP_LT) + deviation.release() + Core.bitwise_and(seeds, dark, seeds) + + // Half the local window is enough: that is how far the threshold is disturbed around a + // bright feature sitting on the fill. A rectangle keeps the dilation fast at this size. + val reach = (window / 2).coerceAtLeast(3).toDouble() + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(reach, reach)) + val fill = Mat() + Imgproc.dilate(seeds, fill, kernel) + seeds.release(); kernel.release() + + Core.bitwise_and(fill, dark, fill) + dark.release() + return fill +} + +// Standard deviation of src over a square window, as CV_32F. +private fun localDeviation(src: Mat, window: Double): Mat { + val windowSize = Size(window, window) + val mean = Mat() + Imgproc.boxFilter(src, mean, CvType.CV_32F, windowSize) + val squares = Mat() + Core.multiply(src, src, squares) + val deviation = Mat() + Imgproc.boxFilter(squares, deviation, CvType.CV_32F, windowSize) + squares.release() + Core.multiply(mean, mean, mean) + Core.subtract(deviation, mean, deviation) + mean.release() + Core.max(deviation, Scalar(0.0), deviation) + Core.sqrt(deviation, deviation) + return deviation +} + +// Grows with the square of the resolution: at 300 dpi anything up to 3x3 counts as a speck. +internal fun despeckleMinArea(maxDim: Int): Int { + val scale = maxDim / 3508.0 + return max(2, (12.0 * scale * scale).roundToInt()) +} + +// How much larger than a speck a hole in a fill may be before it counts as content. +private const val FILL_HOLE_FACTOR = 5 + +// Smallest hole, in multiples of the speck size, that counts as a letter of light print. +private const val LETTER_AREA_FACTOR = 4 + +// Glare and uneven printing punch small holes into a filled area. The dot on an i in light +// print is just as small, but it sits right next to its letter, so only holes with no letter +// close by are closed. +private fun fillHoles(binary: Mat, fill: Mat, window: Int) { + val minArea = despeckleMinArea(max(binary.cols(), binary.rows())) + + val letters = binary.clone() + removeSpecks(letters, minArea * LETTER_AREA_FACTOR, ink = false) + val nearLetters = Mat() + // The dot on an i sits about its own size above the stem: reach twice that far. + val dotGap = (8 * sqrt(minArea.toDouble())).coerceAtLeast(3.0) + Imgproc.dilate(letters, nearLetters, Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(dotGap, dotGap))) + letters.release() + + val reach = (window / 4).coerceAtLeast(3).toDouble() + val inside = Mat() + Imgproc.dilate(fill, inside, Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(reach, reach))) + val lonely = Mat() + Core.bitwise_not(nearLetters, lonely) + Core.bitwise_and(inside, lonely, inside) + nearLetters.release(); lonely.release() + + removeSpecks(binary, minArea * FILL_HOLE_FACTOR, ink = false, within = inside) + inside.release() +} + +// Drops connected areas below minArea, optionally only those centred in `within`. Only the +// bounding box of each speck is touched, never the whole image. +private fun removeSpecks(binary: Mat, minArea: Int, ink: Boolean, within: Mat? = null) { + val subject = Mat() + if (ink) Core.bitwise_not(binary, subject) else binary.copyTo(subject) + + val labels = Mat() + val stats = Mat() + val centroids = Mat() + val count = Imgproc.connectedComponentsWithStats( + subject, labels, stats, centroids, 8, CvType.CV_32S) + subject.release() + + if (count > 1) { + val statsData = IntArray(count * 5) + stats.get(0, 0, statsData) + val centres = DoubleArray(count * 2) + centroids.get(0, 0, centres) + val replacement = Scalar(if (ink) 255.0 else 0.0) + + for (label in 1 until count) { + val offset = label * 5 + if (statsData[offset + Imgproc.CC_STAT_AREA] >= minArea) continue + if (within != null && !isSet(within, centres[label * 2], centres[label * 2 + 1])) + continue + val box = Rect( + statsData[offset + Imgproc.CC_STAT_LEFT], + statsData[offset + Imgproc.CC_STAT_TOP], + statsData[offset + Imgproc.CC_STAT_WIDTH], + statsData[offset + Imgproc.CC_STAT_HEIGHT], + ) + val labelBox = labels.submat(box) + val speck = Mat() + Core.compare(labelBox, Scalar(label.toDouble()), speck, Core.CMP_EQ) + val target = binary.submat(box) + target.setTo(replacement, speck) + labelBox.release(); speck.release(); target.release() + } + } + + labels.release(); stats.release(); centroids.release() +} + +private fun isSet(mask: Mat, x: Double, y: Double): Boolean { + val col = x.toInt().coerceIn(0, mask.cols() - 1) + val row = y.toInt().coerceIn(0, mask.rows() - 1) + return mask.get(row, col)[0] != 0.0 } diff --git a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt index ec0d76fd..61b08a2c 100644 --- a/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt +++ b/imageprocessing/src/main/java/org/fairscan/imageprocessing/Utils.kt @@ -23,7 +23,7 @@ import org.opencv.imgproc.Imgproc import java.io.IOException import kotlin.math.sqrt -fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { +fun resizeForMaxPixels(img: Mat, maxPixels: Double, interpolation: Int = Imgproc.INTER_AREA): Mat { val origPixels = img.width() * img.height() if (origPixels <= maxPixels) { return img.clone() @@ -31,19 +31,32 @@ fun resizeForMaxPixels(img: Mat, maxPixels: Double): Mat { 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) + Imgproc.resize(img, resizedImg, size, 0.0, 0.0, interpolation) return resizedImg } +enum class ImageEncoding(val fileNameExtension: String) { + JPEG(".jpg"), PNG(".png") +} + fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray { val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality.coerceIn(0, 100)) + return encode(mat, params, ImageEncoding.JPEG) +} + +fun encodePng(mat: Mat): ByteArray { + val params = MatOfInt(Imgcodecs.IMWRITE_PNG_COMPRESSION, 1) + return encode(mat, params, ImageEncoding.PNG) +} + +private fun encode(mat: Mat, params: MatOfInt, encoding: ImageEncoding): ByteArray { val encoded = MatOfByte() - val ok = Imgcodecs.imencode(".jpg", mat, encoded, params) + val ok = Imgcodecs.imencode(encoding.fileNameExtension, mat, encoded, params) params.release() if (!ok) { encoded.release() - throw IOException("Failed to encode JPEG") + throw IOException("Failed to encode $encoding") } val result = encoded.toArray() @@ -51,13 +64,13 @@ fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray { return result } -fun decodeJpeg(jpegBytes: ByteArray): Mat { - val src = MatOfByte(*jpegBytes) +fun decodeJpegOrPng(bytes: ByteArray): Mat { + val src = MatOfByte(*bytes) val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR) src.release() if (decoded.empty()) { decoded.release() - throw IllegalStateException("Failed to decode JPEG") + throw IllegalStateException("Failed to decode JPEG/PNG") } return decoded } diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt new file mode 100644 index 00000000..9a32cb29 --- /dev/null +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BinarizationParametersTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.imageprocessing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BinarizationParametersTest { + + @Test + fun sauvola_window_is_always_odd() { + for (maxDim in 100..6000 step 7) { + assertThat(sauvolaWindow(maxDim) % 2).isEqualTo(1) + } + } + + @Test + fun sauvola_window_is_clamped_at_both_ends() { + assertThat(sauvolaWindow(1)).isEqualTo(15) + assertThat(sauvolaWindow(100_000)).isEqualTo(1001) + } + + @Test + fun sauvola_window_grows_with_resolution() { + // A4 at roughly 150, 200 and 300 dpi + assertThat(sauvolaWindow(1189)).isEqualTo(119) + assertThat(sauvolaWindow(1682)).isEqualTo(169) + assertThat(sauvolaWindow(3508)).isEqualTo(351) + } + + @Test + fun sauvola_window_never_shrinks() { + var previous = 0 + for (maxDim in 1..8000) { + val window = sauvolaWindow(maxDim) + assertThat(window).isGreaterThanOrEqualTo(previous) + previous = window + } + } + + @Test + fun despeckle_area_grows_with_the_square_of_the_resolution() { + assertThat(despeckleMinArea(1189)).isEqualTo(2) + assertThat(despeckleMinArea(1682)).isEqualTo(3) + assertThat(despeckleMinArea(3508)).isEqualTo(12) + } + + @Test + fun despeckle_area_keeps_a_lower_bound() { + assertThat(despeckleMinArea(1)).isEqualTo(2) + } +} diff --git a/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt new file mode 100644 index 00000000..3e3c2af4 --- /dev/null +++ b/imageprocessing/src/test/java/org/fairscan/imageprocessing/BitonalPackingTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025-2026 The FairScan authors + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.fairscan.imageprocessing + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import kotlin.random.Random + +private const val BLACK: Byte = 0 +private const val WHITE = 255.toByte() + +class BitonalPackingTest { + + @Test + fun single_black_pixel() { + assertThat(packBitsMsbFirst(byteArrayOf(BLACK), 1, 1)).containsExactly(0x80.toByte()) + } + + @Test + fun single_white_pixel() { + assertThat(packBitsMsbFirst(byteArrayOf(WHITE), 1, 1)).containsExactly(0x00) + } + + @Test + fun most_significant_bit_is_the_leftmost_pixel() { + val row = byteArrayOf(BLACK, WHITE, WHITE, WHITE, WHITE, WHITE, WHITE, BLACK) + assertThat(packBitsMsbFirst(row, 8, 1)).containsExactly(0x81.toByte()) + } + + @Test + fun rows_are_padded_to_whole_bytes() { + // 9 pixels: 8 white, then one black, so the ninth bit is the MSB of the second byte + val row = ByteArray(9) { if (it == 8) BLACK else WHITE } + val packed = packBitsMsbFirst(row, 9, 1) + assertThat(packed).hasSize(2) + assertThat(packed).containsExactly(0x00, 0x80.toByte()) + } + + @Test + fun rows_do_not_bleed_into_each_other() { + val pixels = ByteArray(2 * 9) { if (it < 9) WHITE else BLACK } + val packed = packBitsMsbFirst(pixels, 9, 2) + assertThat(packed).containsExactly(0x00, 0x00, 0xFF.toByte(), 0x80.toByte()) + } + + @Test + fun packed_size_only_depends_on_dimensions() { + assertThat(packBitsMsbFirst(ByteArray(37 * 23), 37, 23)).hasSize(5 * 23) + } + + @Test + fun gray_values_are_thresholded_at_the_midpoint() { + val row = byteArrayOf(127, 128.toByte()) + assertThat(packBitsMsbFirst(row, 2, 1)).containsExactly(0x80.toByte()) + } + + @Test + fun round_trip() { + val random = Random(42) + val width = 37 + val height = 23 + val pixels = ByteArray(width * height) { if (random.nextBoolean()) BLACK else WHITE } + val packed = packBitsMsbFirst(pixels, width, height) + assertThat(unpackBitsMsbFirst(packed, width, height)).isEqualTo(pixels) + } +}