From 84df865a5dfe795e52550108e7f43b2fd7b4a615 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:58:50 +0100 Subject: [PATCH] Avoid chaining rotations (to avoid JPEG recompressions) --- .../org/fairscan/app/data/ImageRepository.kt | 44 ++++++++++++++++--- .../fairscan/app/data/ImageTransformations.kt | 2 +- .../platform/OpenCvImageTransformations.kt | 28 ++++++------ .../fairscan/app/data/ImageRepositoryTest.kt | 14 +++++- 4 files changed, 67 insertions(+), 21 deletions(-) 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 292af0b..fcd4c0a 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageRepository.kt @@ -19,6 +19,7 @@ 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.ExportQuality import org.fairscan.app.domain.PageMetadata import org.fairscan.app.domain.PageViewKey import org.fairscan.app.domain.Rotation @@ -62,6 +63,7 @@ class ImageRepository( } private fun loadPages(): MutableList { + normalizeLegacyFiles() val filesOnDisk = scanDir.listFiles() ?.filter { it.extension == "jpg" } ?.map { it.name } @@ -176,13 +178,14 @@ class ImageRepository( return // no-op } - val targetFileName = workFileName(page.id, newManualRotation) - val outputFile = File(scanDir, targetFileName) + val inputFile = File(scanDir, "$id.jpg") + if (!inputFile.exists()) { + return + } + val outputFile = File(scanDir, workFileName(id, newManualRotation.degrees)) if (!outputFile.exists()) { - val inputFile = File(scanDir, page.workFileName()) - if (!inputFile.exists()) - return - transformations.rotate(inputFile, outputFile, clockwise) + val jpegQuality = ExportQuality.BALANCED.jpegQuality + transformations.rotate(inputFile, outputFile, newManualRotation.degrees, jpegQuality) } val updated = page.copy(manualRotationDegrees = newManualRotation.degrees) @@ -276,6 +279,35 @@ class ImageRepository( file -> file.delete() } } + + data class DiskPageFiles( + val base: File?, + val rotated: List + ) + + private fun normalizeLegacyFiles() { + val jpgs = scanDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty() + val byId = jpgs.groupBy { file -> + val name = file.name.removeSuffix(".jpg") + val dash = name.lastIndexOf('-') + if (dash >= 0) name.substring(0, dash) else name + } + val pages = byId.mapValues { (_, files) -> + val base = files.find { !it.name.contains('-') } + val rotated = files.filter { it.name.contains('-') } + DiskPageFiles(base, rotated) + } + pages.forEach { (id, files) -> + if (files.base == null && files.rotated.isNotEmpty()) { + val sortedRotatedFiles = files.rotated.sortedBy { it.name } + val legacyFile = sortedRotatedFiles.first() + val target = File(scanDir, "$id.jpg") + if (legacyFile.renameTo(target)) { + sortedRotatedFiles.drop(1).forEach { it.delete() } + } + } + } + } } fun Quad.toSerializable(): NormalizedQuad = 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 b7140fc..8ef54ef 100644 --- a/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/data/ImageTransformations.kt @@ -18,7 +18,7 @@ import java.io.File interface ImageTransformations { - fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) + fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) fun resize(inputFile: File, outputFile: File, maxSize: Int) diff --git a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt index ae0b1b6..5a10c52 100644 --- a/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/platform/OpenCvImageTransformations.kt @@ -16,29 +16,31 @@ package org.fairscan.app.platform import android.graphics.Bitmap import android.graphics.BitmapFactory -import org.opencv.core.Core -import org.opencv.core.Mat +import androidx.core.graphics.scale +import org.fairscan.app.data.ImageTransformations +import org.opencv.core.MatOfInt import org.opencv.imgcodecs.Imgcodecs import java.io.File import kotlin.math.min -import androidx.core.graphics.scale -import org.fairscan.app.data.ImageTransformations class OpenCvTransformations : ImageTransformations { - override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { - val src: Mat = Imgcodecs.imread(inputFile.absolutePath) + override fun rotate( + inputFile: File, + outputFile: File, + rotationDegrees: Int, + jpegQuality: Int + ) { + val src = Imgcodecs.imread(inputFile.absolutePath) + require(!src.empty()) { "Could not load image from ${inputFile.absolutePath}" } - require (!src.empty()) { "Could not load image from ${inputFile.absolutePath}" } + val dst = org.fairscan.imageprocessing.rotate(src, rotationDegrees) - val dst = Mat() - Core.rotate(src, dst, - if (clockwise) Core.ROTATE_90_CLOCKWISE else Core.ROTATE_90_COUNTERCLOCKWISE - ) - - if (!Imgcodecs.imwrite(outputFile.absolutePath, dst)) { + val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality) + if (!Imgcodecs.imwrite(outputFile.absolutePath, dst, params)) { throw RuntimeException("Could not write image to ${outputFile.absolutePath}") } + params.release() src.release() dst.release() } 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 c9c175f..35c0330 100644 --- a/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/data/ImageRepositoryTest.kt @@ -49,7 +49,7 @@ class ImageRepositoryTest { fun repo(): ImageRepository { val transformations = object : ImageTransformations { - override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { + override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) { inputFile.copyTo(outputFile) } override fun resize(inputFile: File, outputFile: File, maxSize: Int) { @@ -130,6 +130,18 @@ class ImageRepositoryTest { assertThat(repo().imageIds()).containsExactly("2") } + @Test + fun `should rename rotated files with no base file`() { + scanDir().mkdirs() + val bytes = byteArrayOf(105, 106, 107) + File(scanDir(), "123-90.jpg").writeBytes(bytes) + File(scanDir(), "123-270.jpg").writeBytes(bytes) + val repo = repo() + assertThat(repo.imageIds()).containsExactly("123") + val jpegFiles = jpegFiles(scanDir()) + assertThat(jpegFiles).hasSize(1).allMatch { it?.name == "123.jpg" } + } + @Test fun `should return null on invalid id`() { val repo = repo()