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.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.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
@@ -62,6 +63,7 @@ class ImageRepository(
} }
private fun loadPages(): MutableList<PageV2> { private fun loadPages(): MutableList<PageV2> {
normalizeLegacyFiles()
val filesOnDisk = scanDir.listFiles() val filesOnDisk = scanDir.listFiles()
?.filter { it.extension == "jpg" } ?.filter { it.extension == "jpg" }
?.map { it.name } ?.map { it.name }
@@ -176,13 +178,14 @@ class ImageRepository(
return // no-op return // no-op
} }
val targetFileName = workFileName(page.id, newManualRotation) val inputFile = File(scanDir, "$id.jpg")
val outputFile = File(scanDir, targetFileName) if (!inputFile.exists()) {
if (!outputFile.exists()) {
val inputFile = File(scanDir, page.workFileName())
if (!inputFile.exists())
return 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) val updated = page.copy(manualRotationDegrees = newManualRotation.degrees)
@@ -276,6 +279,35 @@ class ImageRepository(
file -> file.delete() 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 = fun Quad.toSerializable(): NormalizedQuad =

View File

@@ -18,7 +18,7 @@ import java.io.File
interface ImageTransformations { 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) 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.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import org.opencv.core.Core import androidx.core.graphics.scale
import org.opencv.core.Mat import org.fairscan.app.data.ImageTransformations
import org.opencv.core.MatOfInt
import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgcodecs.Imgcodecs
import java.io.File import java.io.File
import kotlin.math.min import kotlin.math.min
import androidx.core.graphics.scale
import org.fairscan.app.data.ImageTransformations
class OpenCvTransformations : ImageTransformations { class OpenCvTransformations : ImageTransformations {
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { override fun rotate(
val src: Mat = Imgcodecs.imread(inputFile.absolutePath) 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 = Mat() val dst = org.fairscan.imageprocessing.rotate(src, rotationDegrees)
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}") throw RuntimeException("Could not write image to ${outputFile.absolutePath}")
} }
params.release()
src.release() src.release()
dst.release() dst.release()
} }

View File

@@ -49,7 +49,7 @@ class ImageRepositoryTest {
fun repo(): ImageRepository { fun repo(): ImageRepository {
val transformations = object : ImageTransformations { 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) inputFile.copyTo(outputFile)
} }
override fun resize(inputFile: File, outputFile: File, maxSize: Int) { override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
@@ -130,6 +130,18 @@ class ImageRepositoryTest {
assertThat(repo().imageIds()).containsExactly("2") 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 @Test
fun `should return null on invalid id`() { fun `should return null on invalid id`() {
val repo = repo() val repo = repo()