@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user