Pages crop feature #326 #919

master
Koitharu 2 years ago
parent dfb50fbddc
commit 81aac0d431
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -1,15 +1,10 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.ui.image
import android.graphics.Bitmap 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.get
import androidx.core.graphics.green
import androidx.core.graphics.red
import coil.size.Size import coil.size.Size
import coil.transform.Transformation import coil.transform.Transformation
import kotlin.math.abs import org.koitharu.kotatsu.reader.domain.EdgeDetector.Companion.isColorTheSame
class TrimTransformation( class TrimTransformation(
private val tolerance: Int = 20, private val tolerance: Int = 20,
@ -28,7 +23,7 @@ class TrimTransformation(
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor)) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false isColBlank = false
break break
} }
@ -47,7 +42,7 @@ class TrimTransformation(
var isColBlank = true var isColBlank = true
val prevColor = input[x, 0] val prevColor = input[x, 0]
for (y in 1 until input.height) { for (y in 1 until input.height) {
if (!isColorTheSame(input[x, y], prevColor)) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isColBlank = false isColBlank = false
break break
} }
@ -63,7 +58,7 @@ class TrimTransformation(
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor)) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false isRowBlank = false
break break
} }
@ -79,7 +74,7 @@ class TrimTransformation(
var isRowBlank = true var isRowBlank = true
val prevColor = input[0, y] val prevColor = input[0, y]
for (x in 1 until input.width) { for (x in 1 until input.width) {
if (!isColorTheSame(input[x, y], prevColor)) { if (!isColorTheSame(input[x, y], prevColor, tolerance)) {
isRowBlank = false isRowBlank = false
break 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 { override fun equals(other: Any?): Boolean {
return this === other || (other is TrimTransformation && other.tolerance == tolerance) return this === other || (other is TrimTransformation && other.tolerance == tolerance)
} }

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.graphics.Bitmap
import android.graphics.Rect import android.graphics.Rect
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -11,3 +12,9 @@ fun Rect.scale(factor: Double) {
(height() - newHeight) / 2, (height() - newHeight) / 2,
) )
} }
inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}

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

@ -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.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable 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.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
@ -87,7 +88,7 @@ class PageLoader @Inject constructor(
private val prefetchQueue = LinkedList<MangaPage>() private val prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0) private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val whitespaceDetector = WhitespaceDetector(context) private val edgeDetector = EdgeDetector(context)
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository return repository is RemoteMangaRepository
@ -147,20 +148,17 @@ class PageLoader @Inject constructor(
} else { } else {
val file = uri.toFile() val file = uri.toFile()
context.ensureRamAtLeast(file.length() * 2) context.ensureRamAtLeast(file.length() * 2)
val image = runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
BitmapFactory.decodeFile(file.absolutePath) BitmapFactory.decodeFile(file.absolutePath)
} }.use { image ->
try {
image.compressToPNG(file) image.compressToPNG(file)
} finally {
image.recycle()
} }
uri uri
} }
} }
suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable { suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable {
whitespaceDetector.getBounds(ImageSource.Uri(uri)) edgeDetector.getBounds(ImageSource.Uri(uri))
}.onFailure { error -> }.onFailure { error ->
error.printStackTraceDebug() error.printStackTraceDebug()
}.getOrNull() }.getOrNull()

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

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

@ -92,7 +92,7 @@
android:entries="@array/reader_crop" android:entries="@array/reader_crop"
android:entryValues="@array/values_reader_crop" android:entryValues="@array/values_reader_crop"
android:key="reader_crop" android:key="reader_crop"
android:title="Crop pages (beta)" /> android:title="@string/crop_pages" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"

Loading…
Cancel
Save