EditPageScreen: apply output quad

This commit is contained in:
Pierre-Yves Nicolas
2026-05-06 20:28:32 +02:00
parent 0d83265f6b
commit fed95c99d4
12 changed files with 137 additions and 66 deletions

View File

@@ -185,7 +185,7 @@ class MainActivity : ComponentActivity() {
onLoad = { id -> viewModel.loadCropInitialState(id)},
initState = cropInitialState,
navigation = navigation,
onUpdatePageQuad = { id, quad, onComplete -> },
onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
)
}
is Screen.Main.Document -> {

View File

@@ -49,6 +49,7 @@ import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Quad
import kotlin.math.min
@OptIn(ExperimentalCoroutinesApi::class)
@@ -189,6 +190,22 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
}
}
fun setCurrentPageUserQuad(userQuad: Quad) {
viewModelScope.launch {
val currentPage = currentPage()
val totalRotation = currentPage.totalRotation()
val rotateIterations = (4 - totalRotation.degrees / 90) % 4
val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1))
_loadingPageId.value = currentPage.id
val pages = withContext(Dispatchers.IO) {
imageRepository.setUserQuad(currentPage.id, newQuad)
imageRepository.pages()
}
_pages.value = pages
_loadingPageId.value = null
}
}
private fun currentPage(): ScanPage {
val index = _currentPageIndex.value
val pages = _pages.value
@@ -234,8 +251,7 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
?: return@launch
val metadata = page.metadata
val baseRotation = metadata?.baseRotation ?: Rotation.R0
val rotation = baseRotation.add(page.manualRotation)
val rotation = page.totalRotation()
val bitmap = withContext(Dispatchers.IO) {
val source = imageRepository.source(page.id)

View File

@@ -40,6 +40,8 @@ data class PageV2(
val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null,
val quadVersion: Int = 0,
val userQuad: NormalizedQuad? = null,
val isColored: Boolean? = null,
val colorMode: ColorMode? = null,
)

View File

@@ -27,7 +27,6 @@ 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.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
@@ -91,7 +90,7 @@ class ImageRepository(
return when {
metadataPages != null ->
metadataPages
.filter { processedImageFileName(it.id, it.colorMode) in filesOnDisk }
.filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk }
.toMutableList()
else ->
filesOnDisk
@@ -135,7 +134,7 @@ class ImageRepository(
pages.pages().mapNotNull {
runCatching {
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
ScanPage(it.id, manualRotation, it.colorMode, it.toMetadata())
ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata())
}.getOrNull()
}
}
@@ -143,7 +142,7 @@ class ImageRepository(
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) =
mutex.withLock {
val id = "${System.currentTimeMillis()}"
val key = PageViewKey(id, Rotation.R0, colorMode)
val key = PageViewKey(id, Rotation.R0, colorMode, 0)
processedImageFile(key).writeBytes(processed.bytes)
sourceFile(id).writeBytes(source.bytes)
pages.addOrReplace(
@@ -162,18 +161,64 @@ class ImageRepository(
}
suspend fun setColorMode(id: String, colorMode: ColorMode) {
val key = PageViewKey(id, Rotation.R0, colorMode)
val processedFile = processedImageFile(key)
val metadata = mutex.withLock { pages.get(id)?.toMetadata() }
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(colorMode = colorMode),
normalizedQuad = metadata.normalizedQuad,
colorMode = colorMode,
)
}
}
suspend fun setUserQuad(id: String, newQuad: Quad) {
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(
quadVersion = page.quadVersion + 1,
userQuad = newQuad.toSerializable(),
),
normalizedQuad = newQuad,
colorMode = page.colorMode ?: metadata.autoColorMode,
)
}
}
private data class PageUpdate(
val updatedPage: PageV2,
val normalizedQuad: Quad,
val colorMode: ColorMode,
)
private suspend fun updatePage(
id: String,
buildUpdate: (PageV2, PageMetadata) -> PageUpdate
) {
val page = mutex.withLock { pages.get(id) }
val metadata = page?.toMetadata() ?: return
val sourceFile = sourceFile(id)
if (metadata == null || !sourceFile.exists())
if (!sourceFile.exists())
return
val update = buildUpdate(page, metadata)
val key = PageViewKey(
pageId = id,
rotation = Rotation.R0,
colorMode = update.colorMode,
quadVersion = update.updatedPage.quadVersion
)
val processedFile = processedImageFile(key)
val job = processingJobs.computeIfAbsent(key) {
scope.async(Dispatchers.IO) {
if (!processedFile.exists()) {
val sourceJpeg = Jpeg(sourceFile.readBytes())
val processedJpeg = transformations.process(sourceJpeg, metadata, colorMode)
val processedJpeg =
transformations.process(
sourceJpeg,
normalizedQuad = update.normalizedQuad,
baseRotation = metadata.baseRotation,
colorMode = update.colorMode
)
processedFile.writeBytes(processedJpeg.bytes)
}
}
@@ -185,7 +230,7 @@ class ImageRepository(
}
mutex.withLock {
pages.update(id) { it.copy(colorMode = colorMode) }
pages.update(id) { update.updatedPage }
saveMetadata()
}
}
@@ -248,14 +293,18 @@ class ImageRepository(
// --- Other operations ---
private fun processedImageFileName(id: String, colorMode: ColorMode?) : String =
if (colorMode == null)
"${id}.jpg"
else
"${id}.${colorMode.name.lowercase()}.jpg"
private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String {
val sb = StringBuilder(id)
if (colorMode != null)
sb.append(".").append(colorMode.name.lowercase())
if (quadVersion > 0)
sb.append(".q").append(quadVersion)
sb.append(".jpg")
return sb.toString()
}
private fun processedImageFile(key: PageViewKey) : File =
File(processedDir, processedImageFileName(key.pageId, key.colorMode))
File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion))
private fun sourceFile(id: String): File =
File(sourceDir, "$id.jpg")
@@ -352,7 +401,7 @@ fun NormalizedQuad.toQuad(): Quad =
fun PageV2.toMetadata(): PageMetadata? {
if (quad == null || isColored == null) return null
return PageMetadata(
quad.toQuad(),
(userQuad ?: quad).toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE
)

View File

@@ -15,8 +15,9 @@
package org.fairscan.app.data
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.Quad
interface ImageTransformations {
@@ -24,6 +25,11 @@ interface ImageTransformations {
fun resizeToThumbnail(input: Jpeg): Jpeg
fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg
fun process(
source: Jpeg,
normalizedQuad: Quad,
baseRotation: Rotation,
colorMode: ColorMode
): Jpeg
}

View File

@@ -48,11 +48,11 @@ suspend fun jpegsForExport(
JpegProvider {
val source = imageRepository.source(page.id)
val metadata = page.metadata
val manualRotation = page.manualRotation
val colorMode = page.colorMode
if (source != null && metadata != null && colorMode != null) {
val rotation = metadata.baseRotation.add(manualRotation)
processedImage(source, metadata, rotation, colorMode, exportQuality)
val rotation = page.totalRotation()
val normalizedQuad = metadata.normalizedQuad
processedImage(source, normalizedQuad, rotation, colorMode, exportQuality)
}
else
jpeg(page, imageRepository)

View File

@@ -27,19 +27,19 @@ data class ScanPage(
val id: String,
val manualRotation: Rotation,
val colorMode: ColorMode?,
val quadVersion: Int,
val metadata: PageMetadata?,
) {
fun key(): PageViewKey = PageViewKey(id, manualRotation, colorMode)
fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion)
fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0)
}
data class PageViewKey(
val pageId: String,
val rotation: Rotation,
val colorMode: ColorMode?,
) {
val saveKey: String get() = "$pageId-${rotation.degrees}-$colorMode"
}
val quadVersion: Int,
)
enum class Rotation(val degrees: Int) {
R0(0),
R90(90),

View File

@@ -73,14 +73,19 @@ class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
}
}
override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg {
return processedImage(source, metadata, metadata.baseRotation, colorMode, ExportQuality.BALANCED)
override fun process(
source: Jpeg,
normalizedQuad: Quad,
baseRotation: Rotation,
colorMode: ColorMode
): Jpeg {
return processedImage(source, normalizedQuad, baseRotation, colorMode, ExportQuality.BALANCED)
}
}
fun processedImage(
source: Jpeg,
metadata: PageMetadata,
normalizedQuad: Quad,
rotation: Rotation,
colorMode: ColorMode,
exportQuality: ExportQuality,
@@ -90,7 +95,7 @@ fun processedImage(
var page: Mat? = null
try {
sourceMat = source.toMat()
val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
val quad = normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels)
return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally {

View File

@@ -30,7 +30,7 @@ fun dummyNavigation(): Navigation {
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val pageKeys = pageIds.map {
PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR), fakeImage(it, context))
PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context))
}.toImmutableList()
return DocumentUiModel(pageKeys)
}

View File

@@ -370,7 +370,7 @@ fun DocumentScreenPreview() {
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
LocalContext.current
)
val key = PageViewKey("123", Rotation.R0, null)
val key = PageViewKey("123", Rotation.R0, null, 0)
DocumentScreen(
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document),
navigation = dummyNavigation(),

View File

@@ -69,7 +69,7 @@ fun EditPageScreen(
onLoad: (String) -> Unit,
initState: CropInitState,
navigation: Navigation,
onUpdatePageQuad: (String, Quad, onComplete: () -> Unit) -> Unit,
onUpdatePageQuad: (Quad) -> Unit,
) {
val state = remember { EditPageScreenState() }
val quadHandler = remember { QuadEditingHandler() }
@@ -125,20 +125,8 @@ fun EditPageScreen(
.padding(16.dp)
.windowInsetsPadding(WindowInsets.safeDrawing),
onConfirm = {
/*
val quad = state.editableQuad
if (quad != null) {
// Reverse the total rotation to get back to original source image coordinates
val rotateIterations = (4 - totalRotation.value.degrees / 90) % 4
val originalQuad = quad.rotate90(rotateIterations, ImageSize(1, 1))
onUpdatePageQuad(pageId, originalQuad) {
navigation.back()
}
state.setInitialQuad(quad)
} else {
navigation.back()
}
*/
state.editableQuad?.let { onUpdatePageQuad(it) }
navigation.back()
}
)
}
@@ -336,7 +324,7 @@ fun EditPageScreenPreview() {
onLoad = {},
initState = CropInitState.Ready("123",dummyImage, quad),
navigation = dummyNavigation(),
onUpdatePageQuad = { _,_,_ -> },
onUpdatePageQuad = { _ -> },
)
}
}

View File

@@ -26,6 +26,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.domain.Rotation.R180
import org.fairscan.app.domain.Rotation.R270
@@ -62,7 +63,7 @@ class ImageRepositoryTest {
fun repo(
rotate: (Jpeg, Int) -> Jpeg = { input, _ -> input },
resizeToThumbnail: (Jpeg) -> Jpeg = { input -> jpeg(input.bytes[0]) },
process: (Jpeg, PageMetadata, ColorMode) -> Jpeg = { _, _, _ ->
process: (Jpeg, Quad, Rotation, ColorMode) -> Jpeg = { _, _, _, _ ->
throw UnsupportedOperationException()
}
): ImageRepository {
@@ -71,8 +72,12 @@ class ImageRepositoryTest {
rotate(input, rotationDegrees)
override fun resizeToThumbnail(input: Jpeg): Jpeg =
resizeToThumbnail(input)
override fun process(source: Jpeg, metadata: PageMetadata, colorMode: ColorMode): Jpeg =
process(source, metadata, colorMode)
override fun process(
source: Jpeg,
normalizedQuad: Quad,
baseRotation: Rotation,
colorMode: ColorMode
): Jpeg = process(source, normalizedQuad, baseRotation, colorMode)
}
return ImageRepository(getFilesDir(), transformations, testScope)
@@ -86,7 +91,7 @@ class ImageRepositoryTest {
repo.add(jpeg, jpeg(51), metadata1, COLOR)
assertThat(repo.imageIds()).hasSize(1)
val id = repo.imageIds()[0]
val key = PageViewKey(id, R0, COLOR)
val key = PageViewKey(id, R0, COLOR, 0)
assertThat(repo.jpegBytes(key)).isEqualTo(jpeg)
assertThat(repo.getThumbnail(key)?.bytes).isEqualTo(byteArrayOf(101))
@@ -153,7 +158,7 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo()
assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes)
assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes)
}
@Test
@@ -182,14 +187,14 @@ class ImageRepositoryTest {
File(processedDir(), "1-90.jpg").writeBytes(bytes)
val repo = repo()
assertThat(repo.imageIds()).containsExactly("1")
assertThat(repo.jpegBytes(PageViewKey("1", R0, null))?.bytes).isEqualTo(bytes)
assertThat(repo.jpegBytes(PageViewKey("1", R0, null, 0))?.bytes).isEqualTo(bytes)
}
@Test
fun `should return null on invalid id`() = runTest {
val repo = repo()
assertThat(repo.imageIds()).isEmpty()
assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR))).isNull()
assertThat(repo.jpegBytes(PageViewKey("x", R0, COLOR, 0))).isNull()
}
@Test
@@ -239,7 +244,7 @@ class ImageRepositoryTest {
fun setColorMode_should_process_and_update_metadata() = runTest {
val jpeg1 = jpeg(10)
val repo = repo(
process = { jpeg ,meta, mode ->
process = { _, _ , _, mode ->
assertThat(mode).isEqualTo(GRAYSCALE)
jpeg(41)
}
@@ -249,7 +254,7 @@ class ImageRepositoryTest {
val id = repo.pages().first().id
repo.setColorMode(id, GRAYSCALE)
assertThat(repo.pages().first().colorMode).isEqualTo(GRAYSCALE)
val key = PageViewKey(id, R0, GRAYSCALE)
val key = PageViewKey(id, R0, GRAYSCALE, 0)
assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(41))
}
@@ -257,7 +262,7 @@ class ImageRepositoryTest {
fun setColorMode_should_not_run_twice_in_parallel() = runTest {
var processCalls = 0
val repo = repo(
process = { _, _, _ ->
process = { _, _, _, _ ->
processCalls++
runBlocking { delay(10) }
jpeg(1)
@@ -269,7 +274,7 @@ class ImageRepositoryTest {
launch { repo.setColorMode(id, GRAYSCALE) }
launch { repo.setColorMode(id, GRAYSCALE) }
}
val key = PageViewKey(id, R0, GRAYSCALE)
val key = PageViewKey(id, R0, GRAYSCALE, 0)
assertThat(repo.jpegBytes(key)?.bytes).isEqualTo(byteArrayOf(1))
assertThat(processCalls).isEqualTo(1)
}
@@ -307,11 +312,11 @@ class ImageRepositoryTest {
fun metadata() {
val quad = quad1.toSerializable()
assertThat(PageV2("1", 0, 0, null,true).toMetadata()).isNull()
assertThat(PageV2("1", 0, 0, quad, null).toMetadata()).isNull()
assertThat(PageV2("1", 0, 0, quad = null, isColored = true).toMetadata()).isNull()
assertThat(PageV2("1", 0, 0, quad).toMetadata()).isNull()
listOf(true, false).forEach { isColored ->
val metadata = PageV2("1", 0, 0, quad, isColored).toMetadata()
val metadata = PageV2("1", 0, 0, quad, isColored = isColored).toMetadata()
assertThat(metadata).isNotNull()
assertThat(metadata!!.autoColorMode).isEqualTo(
if (isColored) COLOR else GRAYSCALE