From 81aac0d431b3b0e21ede2eb9978c0351e96ae8a8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 6 Jul 2024 19:25:08 +0300 Subject: [PATCH] Pages crop feature #326 #919 --- .../core/ui/image/TrimTransformation.kt | 22 +-- .../kotatsu/core/util/ext/Graphics.kt | 7 + .../kotatsu/reader/domain/EdgeDetector.kt | 150 ++++++++++++++++++ .../kotatsu/reader/domain/PageLoader.kt | 12 +- .../reader/domain/WhitespaceDetector.kt | 79 --------- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_reader.xml | 2 +- 7 files changed, 169 insertions(+), 104 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt index 88dda77b5..15695ff11 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt @@ -1,15 +1,10 @@ package org.koitharu.kotatsu.core.ui.image import android.graphics.Bitmap -import androidx.annotation.ColorInt -import androidx.core.graphics.alpha -import androidx.core.graphics.blue import androidx.core.graphics.get -import androidx.core.graphics.green -import androidx.core.graphics.red import coil.size.Size import coil.transform.Transformation -import kotlin.math.abs +import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame class TrimTransformation( private val tolerance: Int = 20, @@ -28,7 +23,7 @@ class TrimTransformation( var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isColBlank = false break } @@ -47,7 +42,7 @@ class TrimTransformation( var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isColBlank = false break } @@ -63,7 +58,7 @@ class TrimTransformation( var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isRowBlank = false break } @@ -79,7 +74,7 @@ class TrimTransformation( var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (!isColorTheSame(input[x, y], prevColor, tolerance)) { isRowBlank = false break } @@ -98,13 +93,6 @@ class TrimTransformation( } } - private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean { - return abs(a.red - b.red) <= tolerance && - abs(a.green - b.green) <= tolerance && - abs(a.blue - b.blue) <= tolerance && - abs(a.alpha - b.alpha) <= tolerance - } - override fun equals(other: Any?): Boolean { return this === other || (other is TrimTransformation && other.tolerance == tolerance) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt index 2e59b582f..2a9f0b81c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.util.ext +import android.graphics.Bitmap import android.graphics.Rect import kotlin.math.roundToInt @@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) { (height() - newHeight) / 2, ) } + +inline fun Bitmap.use(block: (Bitmap) -> R) = try { + block(this) +} finally { + recycle() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt new file mode 100644 index 000000000..c21e83300 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt @@ -0,0 +1,150 @@ +package org.koitharu.kotatsu.reader.domain + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Point +import android.graphics.Rect +import androidx.annotation.ColorInt +import androidx.core.graphics.alpha +import androidx.core.graphics.blue +import androidx.core.graphics.get +import androidx.core.graphics.green +import androidx.core.graphics.red +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder +import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.core.util.ext.use +import kotlin.math.abs + +class EdgeDetector(private val context: Context) { + + private val mutex = Mutex() + + suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock { + withContext(Dispatchers.IO) { + val decoder = SkiaPooledImageRegionDecoder(Bitmap.Config.RGB_565) + try { + val size = runInterruptible { + decoder.init(context, imageSource) + } + val edges = coroutineScope { + listOf( + async { detectLeftRightEdge(decoder, size, isLeft = true) }, + async { detectTopBottomEdge(decoder, size, isTop = true) }, + async { detectLeftRightEdge(decoder, size, isLeft = false) }, + async { detectTopBottomEdge(decoder, size, isTop = false) }, + ).awaitAll() + } + var hasEdges = false + for (edge in edges) { + if (edge > 0) { + hasEdges = true + } else if (edge < 0) { + return@withContext null + } + } + if (hasEdges) { + Rect(edges[0], edges[1], size.x - edges[2], size.y - edges[3]) + } else { + null + } + } finally { + decoder.recycle() + } + } + } + + private fun detectLeftRightEdge(decoder: ImageRegionDecoder, size: Point, isLeft: Boolean): Int { + var width = size.x + val rectCount = size.x / BLOCK_SIZE + val maxRect = rectCount / 3 + for (i in 0 until rectCount) { + if (i > maxRect) { + return -1 + } + var dd = BLOCK_SIZE + for (j in 0 until size.y / BLOCK_SIZE) { + val regionX = if (isLeft) i * BLOCK_SIZE else size.x - (i + 1) * BLOCK_SIZE + decoder.decodeRegion(region(regionX, j * BLOCK_SIZE), 1).use { bitmap -> + for (ii in 0 until minOf(BLOCK_SIZE, dd)) { + for (jj in 0 until BLOCK_SIZE) { + val bi = if (isLeft) ii else BLOCK_SIZE - ii - 1 + if (bitmap[bi, jj].isNotWhite()) { + width = minOf(width, BLOCK_SIZE * i + ii) + dd-- + break + } + } + } + } + if (dd == 0) { + break + } + } + if (dd < BLOCK_SIZE) { + break // We have already found vertical field or it is not exist + } + } + return width + } + + private fun detectTopBottomEdge(decoder: ImageRegionDecoder, size: Point, isTop: Boolean): Int { + var height = size.y + val rectCount = size.y / BLOCK_SIZE + val maxRect = rectCount / 3 + for (j in 0 until rectCount) { + if (j > maxRect) { + return -1 + } + var dd = BLOCK_SIZE + for (i in 0 until size.x / BLOCK_SIZE) { + val regionY = if (isTop) j * BLOCK_SIZE else size.y - (j + 1) * BLOCK_SIZE + decoder.decodeRegion(region(i * BLOCK_SIZE, regionY), 1).use { bitmap -> + for (jj in 0 until minOf(BLOCK_SIZE, dd)) { + for (ii in 0 until BLOCK_SIZE) { + val bj = if (isTop) jj else BLOCK_SIZE - jj - 1 + if (bitmap[ii, bj].isNotWhite()) { + height = minOf(height, BLOCK_SIZE * j + jj) + dd-- + break + } + } + } + } + if (dd == 0) { + break + } + } + if (dd < BLOCK_SIZE) { + break // We have already found vertical field or it is not exist + } + } + return height + } + + companion object { + + private const val BLOCK_SIZE = 100 + private const val COLOR_TOLERANCE = 16 + + fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int, tolerance: Int): Boolean { + return abs(a.red - b.red) <= tolerance && + abs(a.green - b.green) <= tolerance && + abs(a.blue - b.blue) <= tolerance && + abs(a.alpha - b.alpha) <= tolerance + } + + private fun Int.isNotWhite() = !isColorTheSame(this, Color.WHITE, COLOR_TOLERANCE) + + private fun region(x: Int, y: Int) = Rect(x, y, x + BLOCK_SIZE, y + BLOCK_SIZE) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 36654dd7d..08d1e537b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -47,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.ramAvailable +import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.local.data.PagesCache @@ -87,7 +88,7 @@ class PageLoader @Inject constructor( private val prefetchQueue = LinkedList() private val counter = AtomicInteger(0) private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive - private val whitespaceDetector = WhitespaceDetector(context) + private val edgeDetector = EdgeDetector(context) fun isPrefetchApplicable(): Boolean { return repository is RemoteMangaRepository @@ -147,20 +148,17 @@ class PageLoader @Inject constructor( } else { val file = uri.toFile() context.ensureRamAtLeast(file.length() * 2) - val image = runInterruptible(Dispatchers.IO) { + runInterruptible(Dispatchers.IO) { BitmapFactory.decodeFile(file.absolutePath) - } - try { + }.use { image -> image.compressToPNG(file) - } finally { - image.recycle() } uri } } suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable { - whitespaceDetector.getBounds(ImageSource.Uri(uri)) + edgeDetector.getBounds(ImageSource.Uri(uri)) }.onFailure { error -> error.printStackTraceDebug() }.getOrNull() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt deleted file mode 100644 index 06e32e7c5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Point -import android.graphics.Rect -import androidx.core.graphics.get -import com.davemorrissey.labs.subscaleview.ImageSource -import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder -import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.math.abs - -class WhitespaceDetector( - private val context: Context -) { - - private val mutex = Mutex() - - suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock { - runInterruptible(Dispatchers.IO) { - val decoder = SkiaImageRegionDecoder(Bitmap.Config.RGB_565) - try { - val size = decoder.init(context, imageSource) - detectWhitespaces(decoder, size) - } finally { - decoder.recycle() - } - } - } - - // TODO - private fun detectWhitespaces(decoder: ImageRegionDecoder, size: Point): Rect? { - val result = Rect(0, 0, size.x, size.y) - val window = Rect() - val windowSize = 200 - - var baseColor = -1 - window.set(0, 0, windowSize, windowSize) - decoder.decodeRegion(window, 1).use { bitmap -> - baseColor = bitmap[0, 0] - outerTop@ for (x in 1 until bitmap.width / 2) { - for (y in 1 until bitmap.height / 2) { - if (isSameColor(baseColor, bitmap[x, y])) { - result.left = x - result.top = y - } else { - break@outerTop - } - } - } - } - window.set(size.x - windowSize - 1, size.y - windowSize - 1, size.x - 1, size.y - 1) - decoder.decodeRegion(window, 1).use { bitmap -> - outerBottom@ for (x in (bitmap.width / 2 until bitmap.width).reversed()) { - for (y in (bitmap.height / 2 until bitmap.height).reversed()) { - if (isSameColor(baseColor, bitmap[x, y])) { - result.right = size.x - x - result.bottom = size.y - y - } else { - break@outerBottom - } - } - } - } - return result.takeUnless { it.isEmpty || (it.width() == size.x && it.height() == size.y) } - } - - private fun isSameColor(a: Int, b: Int) = abs(a - b) <= 4 // TODO - - private inline fun Bitmap.use(block: (Bitmap) -> R) = try { - block(this) - } finally { - recycle() - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f25127691..c97bb7fef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -656,4 +656,5 @@ Block when incognito mode Preferred image server %1$s: %2$s + Crop pages diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index a991989ab..6183b186a 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -92,7 +92,7 @@ android:entries="@array/reader_crop" android:entryValues="@array/values_reader_crop" android:key="reader_crop" - android:title="Crop pages (beta)" /> + android:title="@string/crop_pages" />