Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/main/java/org/fairscan/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions app/src/main/java/org/fairscan/app/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/org/fairscan/app/data/FileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,7 +28,7 @@ data class GeneratedPdf(

fun interface PdfWriter {
suspend fun writePdfFromJpegs(
pages: List<PageToExport>,
pages: List<PageToExport<EncodedImage>>,
outputStream: OutputStream,
disableOcr: Boolean,
onProgress: (Int) -> Unit,
Expand All @@ -49,7 +50,7 @@ class FileManager(
}

suspend fun generatePdf(
pages: List<PageToExport>,
pages: List<PageToExport<EncodedImage>>,
disableOcr: Boolean,
onProgress: (Int) -> Unit
): GeneratedPdf {
Expand Down
39 changes: 23 additions & 16 deletions app/src/main/java/org/fairscan/app/data/ImageRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,8 +70,8 @@ class ImageRepository(
private var pages: PageStore = PageStore(loadPages())

private val processingJobs = synchronizedMap(mutableMapOf<PageViewKey, Deferred<Unit>>())
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 1000)
private val imageCache = createLruCache<PageViewKey, Deferred<EncodedImage?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<EncodedImage?>>(maxEntries = 1000)

private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<PageViewKey, Deferred<Jpeg?>>,
cache: MutableMap<PageViewKey, Deferred<EncodedImage?>>,
key: PageViewKey,
compute: suspend (PageViewKey) -> Jpeg?
): Jpeg? {
compute: suspend (PageViewKey) -> EncodedImage?
): EncodedImage? {
val deferred = cache.computeIfAbsent(key) { k ->
scope.async(Dispatchers.IO) { compute(k) }
}
Expand All @@ -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
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
2 changes: 1 addition & 1 deletion app/src/main/java/org/fairscan/app/domain/CapturedPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Jpeg>,
val metadata: PageMetadata,
val colorMode: ColorMode,
Expand Down
67 changes: 49 additions & 18 deletions app/src/main/java/org/fairscan/app/domain/ExportPreparation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
suspend fun get(): T
}

data class PageToExport(
data class PageToExport<T>(
val page: ScanPage,
val jpeg: JpegProvider,
val image: ImageProvider<T>,
) {
fun estimatedDimensions(): EstimatedDimensions? {
val metadata = page.metadata
Expand Down Expand Up @@ -56,19 +57,19 @@ private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDime
suspend fun pagesToExport(
imageRepository: ImageRepository,
exportQuality: ExportQuality
): List<PageToExport> {
): List<PageToExport<EncodedImage>> {

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
)
}
Expand All @@ -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<PageToExport<Jpeg>> {
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()
Expand Down
11 changes: 9 additions & 2 deletions app/src/main/java/org/fairscan/app/domain/ExportQuality.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

}
Loading
Loading