Introduce Jpeg class

This commit is contained in:
Pierre-Yves Nicolas
2026-03-29 11:22:08 +02:00
parent 7cd3dfd990
commit 3681d5771d
14 changed files with 119 additions and 90 deletions

View File

@@ -51,7 +51,7 @@ class DocumentDetectionTest {
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName -> listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
val inputStream = context.assets.open("uncropped/$imageFileName") val inputStream = context.assets.open("uncropped/$imageFileName")
val bitmap = BitmapFactory.decodeStream(inputStream) val bitmap = BitmapFactory.decodeStream(inputStream)
var outputJpeg: ByteArray? = null var outputJpeg: Jpeg? = null
val segmentationResult = runBlocking { val segmentationResult = runBlocking {
segmentationService.runSegmentationAndReturn(bitmap) segmentationService.runSegmentationAndReturn(bitmap)
@@ -64,7 +64,7 @@ class DocumentDetectionTest {
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height) quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask, scope).pageJpeg outputJpeg = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask, scope).pageJpeg
val file = File(context.getExternalFilesDir(null), imageFileName) val file = File(context.getExternalFilesDir(null), imageFileName)
file.writeBytes(outputJpeg) file.writeBytes(outputJpeg.bytes)
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}") Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
} }
} }

View File

@@ -145,9 +145,6 @@ class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode
} }
} }
private fun ByteArray.toBitmap() : Bitmap =
BitmapFactory.decodeByteArray(this, 0, this.size)
fun handleImageCaptured(capturedPage: CapturedPage) { fun handleImageCaptured(capturedPage: CapturedPage) {
viewModelScope.launch { viewModelScope.launch {
val pages = withContext(Dispatchers.IO) { val pages = withContext(Dispatchers.IO) {

View File

@@ -19,7 +19,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -29,6 +28,7 @@ import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation
@@ -64,8 +64,8 @@ class ImageRepository(
private val json = Json { prettyPrint = false; encodeDefaults = true } private val json = Json { prettyPrint = false; encodeDefaults = true }
private var pages: PageStore = PageStore(loadPages()) private var pages: PageStore = PageStore(loadPages())
private val imageCache = createLruCache<PageViewKey, Deferred<ByteArray?>>(maxEntries = 50) private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<ByteArray?>>(maxEntries = 200) private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 200)
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> = private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) { Collections.synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
@@ -139,12 +139,12 @@ class ImageRepository(
} }
} }
suspend fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) = suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata) =
mutex.withLock { mutex.withLock {
val id = "${System.currentTimeMillis()}" val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg" val fileName = "$id.jpg"
File(processedDir, fileName).writeBytes(pageBytes) File(processedDir, fileName).writeBytes(processed.bytes)
File(sourceDir, fileName).writeBytes(sourceBytes) File(sourceDir, fileName).writeBytes(source.bytes)
pages.addOrReplace( pages.addOrReplace(
PageV2( PageV2(
id = id, id = id,
@@ -157,7 +157,7 @@ class ImageRepository(
saveMetadata() saveMetadata()
// Pre-populate cache for R0 // Pre-populate cache for R0
val key = PageViewKey(id, Rotation.R0) val key = PageViewKey(id, Rotation.R0)
imageCache.put(key, CompletableDeferred(pageBytes)) imageCache.put(key, CompletableDeferred(processed))
} }
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock { suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
@@ -170,20 +170,20 @@ class ImageRepository(
saveMetadata() saveMetadata()
} }
suspend fun jpegBytes(key: PageViewKey): ByteArray? = suspend fun jpegBytes(key: PageViewKey): Jpeg? =
getOrCompute(imageCache, key, ::computeProcessedImage) getOrCompute(imageCache, key, ::computeProcessedImage)
suspend fun getThumbnail(key: PageViewKey): ByteArray? = suspend fun getThumbnail(key: PageViewKey): Jpeg? =
getOrCompute(thumbnailCache, key, ::computeThumbnail) getOrCompute(thumbnailCache, key, ::computeThumbnail)
// --- Cache compute functions --- // --- Cache compute functions ---
private suspend fun getOrCompute( private suspend fun getOrCompute(
cache: MutableMap<PageViewKey, Deferred<ByteArray?>>, cache: MutableMap<PageViewKey, Deferred<Jpeg?>>,
key: PageViewKey, key: PageViewKey,
compute: suspend (PageViewKey) -> ByteArray? compute: suspend (PageViewKey) -> Jpeg?
): ByteArray? { ): Jpeg? {
val deferred = cache.computeIfAbsent(key) { k -> val deferred = cache.computeIfAbsent(key) { k ->
scope.async(Dispatchers.IO) { compute(k) } scope.async(Dispatchers.IO) { compute(k) }
} }
@@ -195,32 +195,33 @@ class ImageRepository(
} }
} }
private suspend fun computeProcessedImage(key: PageViewKey): ByteArray? = private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val baseFile = File(processedDir, "${key.pageId}.jpg") val baseFile = File(processedDir, "${key.pageId}.jpg")
if (!baseFile.exists()) return@withContext null if (!baseFile.exists()) return@withContext null
val baseJpeg = Jpeg(baseFile.readBytes())
if (key.rotation == Rotation.R0) { if (key.rotation == Rotation.R0) {
baseFile.readBytes() baseJpeg
} else { } else {
transformations.rotate( transformations.rotate(
baseFile.readBytes(), baseJpeg,
key.rotation.degrees, key.rotation.degrees,
ExportQuality.BALANCED.jpegQuality) ExportQuality.BALANCED.jpegQuality)
} }
} }
private suspend fun computeThumbnail(key: PageViewKey): ByteArray? = private suspend fun computeThumbnail(key: PageViewKey): Jpeg? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val imageBytes = getOrCompute(imageCache, key, ::computeProcessedImage) val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
?: return@withContext null ?: return@withContext null
transformations.resize(imageBytes, thumbnailSizePx) transformations.resize(processed, thumbnailSizePx)
} }
// --- Other operations --- // --- Other operations ---
fun sourceJpegBytes(id: String): ByteArray? { fun source(id: String): Jpeg? {
val file = File(sourceDir, "$id.jpg") val file = File(sourceDir, "$id.jpg")
return if (file.exists()) file.readBytes() else null return if (file.exists()) Jpeg(file.readBytes()) else null
} }
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock { suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {

View File

@@ -14,10 +14,12 @@
*/ */
package org.fairscan.app.data package org.fairscan.app.data
import org.fairscan.app.domain.Jpeg
interface ImageTransformations { interface ImageTransformations {
fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg
fun resize(input: ByteArray, maxSize: Int): ByteArray fun resize(input: Jpeg, maxSize: Int): Jpeg
} }

View File

@@ -18,7 +18,7 @@ package org.fairscan.app.domain
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
data class CapturedPage( data class CapturedPage(
val pageJpeg: ByteArray, val pageJpeg: Jpeg,
val sourceJpeg: Deferred<ByteArray>, val sourceJpeg: Deferred<Jpeg>,
val metadata: PageMetadata, val metadata: PageMetadata,
) )

View File

@@ -15,15 +15,13 @@
package org.fairscan.app.domain package org.fairscan.app.domain
import org.fairscan.app.data.ImageRepository import org.fairscan.app.data.ImageRepository
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.resizeForMaxPixels import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat import org.opencv.core.Mat
fun interface JpegProvider { fun interface JpegProvider {
suspend fun get(): ByteArray suspend fun get(): Jpeg
} }
suspend fun jpegsForExport( suspend fun jpegsForExport(
@@ -34,13 +32,13 @@ suspend fun jpegsForExport(
val pages = imageRepository.pages() val pages = imageRepository.pages()
return when (exportQuality) { return when (exportQuality) {
ExportQuality.BALANCED -> pages.map { ExportQuality.BALANCED -> pages.map {
JpegProvider { jpegBytes(it, imageRepository) } JpegProvider { jpeg(it, imageRepository) }
} }
ExportQuality.LOW -> pages.map { page -> ExportQuality.LOW -> pages.map { page ->
JpegProvider { JpegProvider {
resizeJpegBytesForMaxPixels( resizeJpegBytesForMaxPixels(
jpegBytes = jpegBytes(page, imageRepository), jpeg = jpeg(page, imageRepository),
maxPixels = exportQuality.maxPixels.toDouble(), maxPixels = exportQuality.maxPixels.toDouble(),
jpegQuality = exportQuality.jpegQuality jpegQuality = exportQuality.jpegQuality
) )
@@ -49,35 +47,35 @@ suspend fun jpegsForExport(
ExportQuality.HIGH -> pages.map { page -> ExportQuality.HIGH -> pages.map { page ->
JpegProvider { JpegProvider {
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id) val source = imageRepository.source(page.id)
val pageMetadata = page.metadata val pageMetadata = page.metadata
val manualRotation = page.manualRotation val manualRotation = page.manualRotation
if (sourceJpegBytes != null && pageMetadata != null) if (source != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality) prepareJpegForHigh(source, pageMetadata, manualRotation, exportQuality)
else else
jpegBytes(page, imageRepository) jpeg(page, imageRepository)
} }
} }
} }
} }
private suspend fun jpegBytes(page: ScanPage, imageRepository: ImageRepository): ByteArray { private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg {
val key = page.key() val key = page.key()
return imageRepository.jpegBytes(key) return imageRepository.jpegBytes(key)
?: throw IllegalArgumentException("JPEG not found for $key") ?: throw IllegalArgumentException("JPEG not found for $key")
} }
private fun resizeJpegBytesForMaxPixels( private fun resizeJpegBytesForMaxPixels(
jpegBytes: ByteArray, jpeg: Jpeg,
maxPixels: Double, maxPixels: Double,
jpegQuality: Int jpegQuality: Int
): ByteArray { ): Jpeg {
var decoded: Mat? = null var decoded: Mat? = null
var resized: Mat? = null var resized: Mat? = null
try { try {
decoded = decodeJpeg(jpegBytes) decoded = jpeg.toMat()
resized = resizeForMaxPixels(decoded, maxPixels) resized = resizeForMaxPixels(decoded, maxPixels)
return encodeJpeg(resized, jpegQuality) return Jpeg.fromMat(resized, jpegQuality)
} finally { } finally {
decoded?.release() decoded?.release()
resized?.release() resized?.release()
@@ -85,16 +83,16 @@ private fun resizeJpegBytesForMaxPixels(
} }
private fun prepareJpegForHigh( private fun prepareJpegForHigh(
sourceJpegBytes: ByteArray, source: Jpeg,
pageMetadata: PageMetadata, pageMetadata: PageMetadata,
manualRotation: Rotation, manualRotation: Rotation,
exportQuality: ExportQuality, exportQuality: ExportQuality,
): ByteArray { ): Jpeg {
var decoded: Mat? = null var decoded: Mat? = null
var page: Mat? = null var page: Mat? = null
try { try {
decoded = decodeJpeg(sourceJpegBytes) decoded = source.toMat()
val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height()) val quad = pageMetadata.normalizedQuad.scaledTo(1, 1, decoded.width(), decoded.height())
page = extractDocument( page = extractDocument(
decoded, decoded,
@@ -103,7 +101,7 @@ private fun prepareJpegForHigh(
pageMetadata.isColored, pageMetadata.isColored,
exportQuality.maxPixels exportQuality.maxPixels
) )
return encodeJpeg(page, exportQuality.jpegQuality) return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally { } finally {
decoded?.release() decoded?.release()
page?.release() page?.release()

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025-2026 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 android.graphics.Bitmap
import android.graphics.BitmapFactory
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat
class Jpeg(val bytes: ByteArray) {
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)
}

View File

@@ -33,7 +33,7 @@ class AndroidPdfWriter : PdfWriter {
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}" doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
doc.use { document -> doc.use { document ->
for (jpegBytes in jpegs) { for (jpegBytes in jpegs) {
val image = JPEGFactory.createFromByteArray(document, jpegBytes.get()) val image = JPEGFactory.createFromByteArray(document, jpegBytes.get().bytes)
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat())) val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
document.addPage(page) document.addPage(page)
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)

View File

@@ -15,8 +15,7 @@
package org.fairscan.app.platform package org.fairscan.app.platform
import org.fairscan.app.data.ImageTransformations import org.fairscan.app.data.ImageTransformations
import org.fairscan.imageprocessing.decodeJpeg import org.fairscan.app.domain.Jpeg
import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.Size import org.opencv.core.Size
import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc
@@ -25,16 +24,16 @@ import kotlin.math.min
class OpenCvTransformations : ImageTransformations { class OpenCvTransformations : ImageTransformations {
override fun rotate( override fun rotate(
input: ByteArray, input: Jpeg,
rotationDegrees: Int, rotationDegrees: Int,
jpegQuality: Int jpegQuality: Int
): ByteArray { ): Jpeg {
return transform(input, jpegQuality) { return transform(input, jpegQuality) {
org.fairscan.imageprocessing.rotate(it, rotationDegrees) org.fairscan.imageprocessing.rotate(it, rotationDegrees)
} }
} }
override fun resize(input: ByteArray, maxSize: Int): ByteArray { override fun resize(input: Jpeg, maxSize: Int): Jpeg {
return transform(input, 85) { src -> return transform(input, 85) { src ->
val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height()) val ratio = min(maxSize.toFloat() / src.width(), maxSize.toFloat() / src.height())
val newW = (src.width() * ratio).toDouble() val newW = (src.width() * ratio).toDouble()
@@ -46,15 +45,15 @@ class OpenCvTransformations : ImageTransformations {
} }
private fun transform( private fun transform(
inBytes: ByteArray, inJpeg: Jpeg,
jpegQuality: Int, jpegQuality: Int,
transform: (Mat) -> Mat, transform: (Mat) -> Mat,
): ByteArray { ): Jpeg {
val input = decodeJpeg(inBytes) val input = inJpeg.toMat()
var output: Mat? = null var output: Mat? = null
try { try {
output = transform.invoke(input) output = transform.invoke(input)
return encodeJpeg(output, jpegQuality) return Jpeg.fromMat(output, jpegQuality)
} finally { } finally {
input.release() input.release()
output?.release() output?.release()

View File

@@ -91,6 +91,7 @@ import kotlinx.coroutines.delay
import org.fairscan.app.MainViewModel import org.fairscan.app.MainViewModel
import org.fairscan.app.R import org.fairscan.app.R
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.ui.Navigation import org.fairscan.app.ui.Navigation
@@ -286,7 +287,7 @@ private fun CameraScreenScaffold(
) )
} }
if (cameraUiState.captureState is CaptureState.CapturePreview) { if (cameraUiState.captureState is CaptureState.CapturePreview) {
val page = bitmap(cameraUiState.captureState.capturedPage.pageJpeg) val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
CapturedImage(page.asImageBitmap(), thumbnailCoords) CapturedImage(page.asImageBitmap(), thumbnailCoords)
} }
} }
@@ -534,10 +535,10 @@ fun CameraScreenPreviewWithProcessedImage() {
val p = Point(0 , 0) val p = Point(0 , 0)
val quad = Quad(p, p, p, p) val quad = Quad(p, p, p, p)
ScreenPreview(CaptureState.CapturePreview( ScreenPreview(CaptureState.CapturePreview(
bitmap(debugImage("uncropped/img01.jpg")), debugImage("uncropped/img01.jpg").toBitmap(),
CapturedPage( CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"), debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
CompletableDeferred(ByteArray(0)), CompletableDeferred(Jpeg(ByteArray(0))),
PageMetadata(quad, R0, false)))) PageMetadata(quad, R0, false))))
} }
@@ -560,7 +561,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
contentAlignment = Alignment.TopCenter contentAlignment = Alignment.TopCenter
) { ) {
Image( Image(
bitmap(debugImage("uncropped/img01.jpg")).asImageBitmap(), debugImage("uncropped/img01.jpg").toBitmap().asImageBitmap(),
modifier=Modifier.rotate(rotationDegrees), modifier=Modifier.rotate(rotationDegrees),
contentDescription = null contentDescription = null
) )
@@ -592,9 +593,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
} }
@Composable @Composable
private fun debugImage(imgName: String): ByteArray { private fun debugImage(imgName: String): Jpeg {
val context = LocalContext.current val context = LocalContext.current
return context.assets.open(imgName).readBytes() return Jpeg(context.assets.open(imgName).readBytes())
} }
private fun bitmap(jpeg: ByteArray): Bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size)

View File

@@ -32,13 +32,13 @@ import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.ImageSize import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mask import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.detectDocumentQuad import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.encodeJpeg
import org.fairscan.imageprocessing.extractDocument import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
@@ -197,14 +197,14 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
} }
} }
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray { private fun compressJpeg(bitmap: Bitmap, quality: Int): Jpeg {
val rgba = Mat() val rgba = Mat()
Utils.bitmapToMat(bitmap, rgba) Utils.bitmapToMat(bitmap, rgba)
val bgr = Mat() val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
rgba.release() rgba.release()
return try { return try {
encodeJpeg(bgr, quality) Jpeg.fromMat(bgr, quality)
} finally { } finally {
bgr.release() bgr.release()
} }
@@ -233,7 +233,7 @@ fun extractDocumentFromBitmap(
val isColored = isColoredDocument(bgr, mask, quad) val isColored = isColoredDocument(bgr, mask, quad)
val maxPixels = ExportQuality.BALANCED.maxPixels val maxPixels = ExportQuality.BALANCED.maxPixels
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels) val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
val pageJpeg = encodeJpeg(page, ExportQuality.BALANCED.jpegQuality) val pageJpeg = Jpeg.fromMat(page, ExportQuality.BALANCED.jpegQuality)
bgr.release() bgr.release()
page.release() page.release()

View File

@@ -176,7 +176,7 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
preparationDir.mkdirs() preparationDir.mkdirs()
val files = jpegs.mapIndexed { index, jpeg -> val files = jpegs.mapIndexed { index, jpeg ->
val file = File(preparationDir, "$timestamp-${index + 1}.jpg") val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
file.writeBytes(jpeg.get()) file.writeBytes(jpeg.get().bytes)
file file
}.toList() }.toList()
val sizeInBytes = files.sumOf { it.length() } val sizeInBytes = files.sumOf { it.length() }

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.data
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.JpegProvider import org.fairscan.app.domain.JpegProvider
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@@ -74,12 +75,12 @@ class FileManagerTest {
val fakePdfWriter = object : PdfWriter { val fakePdfWriter = object : PdfWriter {
override suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int { override suspend fun writePdfFromJpegs(jpegs: List<JpegProvider>, outputStream: OutputStream): Int {
val list = jpegs.toList() val list = jpegs.toList()
list.forEach { bytes -> outputStream.write(bytes.get()) } list.forEach { bytes -> outputStream.write(bytes.get().bytes) }
return list.size return list.size
} }
} }
val manager = FileManager(pdfDir, externalDir, fakePdfWriter) val manager = FileManager(pdfDir, externalDir, fakePdfWriter)
val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).map { JpegProvider { it } } val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).map { JpegProvider { Jpeg(it) } }
val pdf = manager.generatePdf(jpegs) val pdf = manager.generatePdf(jpegs)
assertThat(pdf.pageCount).isEqualTo(2) assertThat(pdf.pageCount).isEqualTo(2)
assertThat(pdf.sizeInBytes).isEqualTo(3) assertThat(pdf.sizeInBytes).isEqualTo(3)

View File

@@ -19,6 +19,7 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation.R0 import org.fairscan.app.domain.Rotation.R0
@@ -53,11 +54,11 @@ class ImageRepositoryTest {
fun repo(): ImageRepository { fun repo(): ImageRepository {
val transformations = object : ImageTransformations { val transformations = object : ImageTransformations {
override fun rotate(input: ByteArray, rotationDegrees: Int, jpegQuality: Int): ByteArray { override fun rotate(input: Jpeg, rotationDegrees: Int, jpegQuality: Int): Jpeg {
return input return input
} }
override fun resize(input: ByteArray, maxSize: Int): ByteArray { override fun resize(input: Jpeg, maxSize: Int): Jpeg {
return byteArrayOf(input[0]) return jpeg(input.bytes[0])
} }
} }
return ImageRepository(getFilesDir(), transformations, 200, testScope) return ImageRepository(getFilesDir(), transformations, 200, testScope)
@@ -67,13 +68,13 @@ class ImageRepositoryTest {
fun add_image() = runTest { fun add_image() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).isEmpty() assertThat(repo.imageIds()).isEmpty()
val bytes = byteArrayOf(101, 102, 103) val jpeg = jpeg(101, 102, 103)
repo.add(bytes, byteArrayOf(51), metadata1) repo.add(jpeg, jpeg(51), metadata1)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
val id = repo.imageIds()[0] val id = repo.imageIds()[0]
val key = PageViewKey(id, R0) val key = PageViewKey(id, R0)
assertThat(repo.jpegBytes(key)).isEqualTo(bytes) assertThat(repo.jpegBytes(key)).isEqualTo(jpeg)
assertThat(repo.getThumbnail(key)).isEqualTo(byteArrayOf(101)) assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101))
val page = repo.pages().first() val page = repo.pages().first()
assertThat(page.id).isEqualTo(id) assertThat(page.id).isEqualTo(id)
@@ -88,8 +89,8 @@ class ImageRepositoryTest {
@Test @Test
fun delete_image() = runTest { fun delete_image() = runTest {
val repo = repo() val repo = repo()
val bytes = byteArrayOf(101, 102, 103) val jpeg = jpeg(101, 102, 103)
repo.add(bytes, byteArrayOf(51), metadata1) repo.add(jpeg, jpeg(51), metadata1)
assertThat(jpegFiles(processedDir())).hasSize(1) assertThat(jpegFiles(processedDir())).hasSize(1)
assertThat(jpegFiles(sourceDir())).hasSize(1) assertThat(jpegFiles(sourceDir())).hasSize(1)
assertThat(repo.imageIds()).hasSize(1) assertThat(repo.imageIds()).hasSize(1)
@@ -138,7 +139,7 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes)
} }
@Test @Test
@@ -167,7 +168,7 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes) File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo() val repo = repo()
assertThat(repo.imageIds()).containsExactly("1") assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0))).isEqualTo(bytes) assertThat(repo.jpegBytes(PageViewKey("1", R0))?.bytes).isEqualTo(bytes)
} }
@Test @Test
@@ -179,9 +180,9 @@ class ImageRepositoryTest {
@Test @Test
fun `clear should delete pages`() = runTest { fun `clear should delete pages`() = runTest {
val bytes = byteArrayOf(101, 102, 103) val jpeg = jpeg(101, 102, 103)
val repo1 = repo() val repo1 = repo()
repo1.add(bytes, byteArrayOf(51), metadata1) repo1.add(jpeg, jpeg(51), metadata1)
assertThat(repo1.imageIds()).isNotEmpty() assertThat(repo1.imageIds()).isNotEmpty()
repo1.clear() repo1.clear()
assertThat(repo1.imageIds()).isEmpty() assertThat(repo1.imageIds()).isEmpty()
@@ -194,7 +195,7 @@ class ImageRepositoryTest {
@Test @Test
fun rotate() = runTest { fun rotate() = runTest {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101, 102, 103), byteArrayOf(51), metadata1) repo.add(jpeg(101, 102, 103), jpeg(51), metadata1)
assertThat(repo.pages().last().metadata).isEqualTo(metadata1) assertThat(repo.pages().last().metadata).isEqualTo(metadata1)
val id = repo.pages().last().id val id = repo.pages().last().id
repo.rotate(id, true) repo.rotate(id, true)
@@ -223,9 +224,9 @@ class ImageRepositoryTest {
@Test @Test
fun movePage() = runTest { fun movePage() = runTest {
val repo = repo() val repo = repo()
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1) repo.add(jpeg(101), jpeg(51), metadata1)
Thread.sleep(1L) // to avoid file name clashes Thread.sleep(1L) // to avoid file name clashes
repo.add(byteArrayOf(110), byteArrayOf(51), metadata1) repo.add(jpeg(110), jpeg(51), metadata1)
val id0 = repo.imageIds().first() val id0 = repo.imageIds().first()
val id1 = repo.imageIds().last() val id1 = repo.imageIds().last()
repo.movePage(id1, 0) repo.movePage(id1, 0)
@@ -275,10 +276,10 @@ class ImageRepositoryTest {
fun last_added_source_file() = runTest { fun last_added_source_file() = runTest {
val repo = repo() val repo = repo()
assertThat(repo.lastAddedSourceFile()).isNull() assertThat(repo.lastAddedSourceFile()).isNull()
repo.add(byteArrayOf(101), byteArrayOf(51), metadata1) repo.add(jpeg(101), jpeg(51), metadata1)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51)) assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(51))
Thread.sleep(1) Thread.sleep(1)
repo.add(byteArrayOf(102), byteArrayOf(52), metadata1) repo.add(jpeg(102), jpeg(52), metadata1)
assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52)) assertThat(repo.lastAddedSourceFile()).hasBinaryContent(byteArrayOf(52))
val id = repo.imageIds().last() val id = repo.imageIds().last()
@@ -307,4 +308,6 @@ class ImageRepositoryTest {
suspend fun ImageRepository.imageIds(): PersistentList<String> = suspend fun ImageRepository.imageIds(): PersistentList<String> =
pages().map { it.id }.toPersistentList() pages().map { it.id }.toPersistentList()
private fun jpeg(vararg bytes: Byte) = Jpeg(bytes)
} }