Avoid chaining rotations (to avoid JPEG recompressions)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-12 20:58:50 +01:00
parent aa9e3893b9
commit 84df865a5d
4 changed files with 67 additions and 21 deletions

View File

@@ -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<PageV2> {
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)
if (!outputFile.exists()) {
val inputFile = File(scanDir, page.workFileName())
if (!inputFile.exists())
val inputFile = File(scanDir, "$id.jpg")
if (!inputFile.exists()) {
return
transformations.rotate(inputFile, outputFile, clockwise)
}
val outputFile = File(scanDir, workFileName(id, newManualRotation.degrees))
if (!outputFile.exists()) {
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<File>
)
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 =

View File

@@ -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)

View File

@@ -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}" }
val dst = Mat()
Core.rotate(src, dst,
if (clockwise) Core.ROTATE_90_CLOCKWISE else Core.ROTATE_90_COUNTERCLOCKWISE
)
val dst = org.fairscan.imageprocessing.rotate(src, rotationDegrees)
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()
}

View File

@@ -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()