@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.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()
|
||||
|
||||
@@ -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="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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user