Pages crop feature #326 #919

This commit is contained in:
Koitharu
2024-07-06 19:25:08 +03:00
parent dfb50fbddc
commit 81aac0d431
7 changed files with 169 additions and 104 deletions

View File

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

View File

@@ -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 <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}

View File

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

View File

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

View File

@@ -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 <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}
}

View File

@@ -656,4 +656,5 @@
<string name="screenshots_block_incognito">Block when incognito mode</string>
<string name="image_server">Preferred image server</string>
<string name="inline_preference_pattern" translatable="false">%1$s: %2$s</string>
<string name="crop_pages">Crop pages</string>
</resources>

View File

@@ -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" />
<SwitchPreferenceCompat
android:defaultValue="true"