diff --git a/README.md b/README.md index 653ee79..ce0c1eb 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,9 @@ As you can see, the [Android decoder](https://github.com/mrousavy/react-native-b ### Asynchronous Decoding -Use `decodeAsync={true}` to decode the Blurhash on a separate background Thread instead of the main UI-Thread. This is useful when you are experiencing stutters because of the Blurhash's **decoder** - e.g.: in large Lists. Threads are re-used (iOS: `DispatchQueue`, Android: kotlinx Coroutines). +Use `decodeAsync={true}` to decode the Blurhash on a separate background Thread instead of the main UI-Thread. This is useful when you are experiencing stutters because of the Blurhash's **decoder** - e.g.: in large Lists. + +Threads are re-used (iOS: `DispatchQueue`, Android: kotlinx Coroutines). ## Resources * [this medium article.](https://teabreak.e-spres-oh.com/swift-in-react-native-the-ultimate-guide-part-2-ui-components-907767123d9e) jesus christ amen thanks for that diff --git a/android/src/main/java/com/mrousavy/blurhash/BlurhashDecoder.kt b/android/src/main/java/com/mrousavy/blurhash/BlurhashDecoder.kt index e6e2fab..8626bba 100644 --- a/android/src/main/java/com/mrousavy/blurhash/BlurhashDecoder.kt +++ b/android/src/main/java/com/mrousavy/blurhash/BlurhashDecoder.kt @@ -2,121 +2,238 @@ package com.mrousavy.blurhash import android.graphics.Bitmap import android.graphics.Color -import kotlin.math.PI +import kotlinx.coroutines.* import kotlin.math.cos import kotlin.math.pow import kotlin.math.withSign -class BlurHashDecoder { - companion object { - fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { - if (blurHash == null || blurHash.length < 6) { - return null - } - val numCompEnc = decode83(blurHash, 0, 1) - val numCompX = (numCompEnc % 9) + 1 - val numCompY = (numCompEnc / 9) + 1 - if (blurHash.length != 4 + 2 * numCompX * numCompY) { - return null - } - val maxAcEnc = decode83(blurHash, 1, 2) - val maxAc = (maxAcEnc + 1) / 166f - val colors = Array(numCompX * numCompY) { i -> - if (i == 0) { - val colorEnc = decode83(blurHash, 2, 6) - decodeDc(colorEnc) - } else { - val from = 4 + i * 2 - val colorEnc = decode83(blurHash, from, from + 2) - decodeAc(colorEnc, maxAc * punch) - } +private val COROUTINES_SCOPE_FOR_PARALLEL_TASKS = GlobalScope + +// See: https://github.com/woltapp/blurhash/pull/68/files + +object BlurHashDecoder { + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true, parallelTasks: Int = 1): Bitmap? { + + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) } - return composeBitmap(width, height, numCompX, numCompY, colors) } + return when (parallelTasks) { + 1 -> composeBitmap(width, height, numCompX, numCompY, colors, useCache) + else -> composeBitmapCoroutines(width, height, numCompX, numCompY, colors, useCache, parallelTasks) + } + } - private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { - var result = 0 - for (i in from until to) { - val index = charMap[str[i]] ?: -1 - if (index != -1) { - result = result * 83 + index - } + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index } - return result } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } - private fun decodeDc(colorEnc: Int): FloatArray { - val r = colorEnc shr 16 - val g = (colorEnc shr 8) and 255 - val b = colorEnc and 255 - return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) } + } - private fun srgbToLinear(colorEnc: Int): Float { - val v = colorEnc / 255f - return if (v <= 0.04045f) { - (v / 12.92f) - } else { - ((v + 0.055f) / 1.055f).pow(2.4f) + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) } } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } - private fun decodeAc(value: Int, maxAc: Float): FloatArray { - val r = value / (19 * 19) - val g = (value / 19) % 19 - val b = value % 19 - return floatArrayOf( - signedPow2((r - 9) / 9.0f) * maxAc, - signedPow2((g - 9) / 9.0f) * maxAc, - signedPow2((b - 9) / 9.0f) * maxAc - ) + private fun composeBitmapCoroutines( + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: Array, + useCache: Boolean, + parallelTasks: Int + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + runBlocking { + COROUTINES_SCOPE_FOR_PARALLEL_TASKS.launch { + val tasks = ArrayList>() + var step = height / parallelTasks + for (t in 0 until parallelTasks) { + val start = step * t + if (t == parallelTasks - 1 && step * parallelTasks < height) { + step += (height - step * parallelTasks) + } + tasks.add(async { + for (y in start until start + step) { + compositBitmapOnlyX(width, numCompY, numCompX, calculateCosX, cosinesX, calculateCosY, cosinesY, y, height, colors, imageArray) + } + return@async + }) + } + tasks.forEach { it.await() } + }.join() } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } - private fun signedPow2(value: Float) = value.pow(2f).withSign(value) - - private fun composeBitmap( - width: Int, height: Int, - numCompX: Int, numCompY: Int, - colors: Array - ): Bitmap { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - for (y in 0 until height) { - for (x in 0 until width) { - var r = 0f - var g = 0f - var b = 0f - for (j in 0 until numCompY) { - for (i in 0 until numCompX) { - val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat() - val color = colors[j * numCompX + i] - r += color[0] * basis - g += color[1] * basis - b += color[2] * basis - } - } - bitmap.setPixel(x, y, Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))) + private fun compositBitmapOnlyX( + width: Int, + numCompY: Int, + numCompX: Int, + calculateCosX: Boolean, + cosinesX: DoubleArray, + calculateCosY: Boolean, + cosinesY: DoubleArray, + y: Int, + height: Int, + colors: Array, + imageArray: IntArray + ) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis } } - return bitmap + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) } + } - private fun linearToSrgb(value: Float): Int { - val v = value.coerceIn(0f, 1f) - return if (v <= 0.0031308f) { - (v * 12.92f * 255f + 0.5f).toInt() - } else { - ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY[height * numCompY] = it + } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX[width * numCompX] = it } } + else -> cacheCosinesX[width * numCompX]!! + } - private val charMap = listOf( - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', - 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', - 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', - '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' - ) - .mapIndexed { i, c -> c to i } - .toMap() + private fun DoubleArray.getCos(calculate: Boolean, x: Int, numComp: Int, y: Int, size: Int): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() + } \ No newline at end of file diff --git a/android/src/main/java/com/mrousavy/blurhash/BlurhashImageView.kt b/android/src/main/java/com/mrousavy/blurhash/BlurhashImageView.kt index 71aca68..c652d87 100644 --- a/android/src/main/java/com/mrousavy/blurhash/BlurhashImageView.kt +++ b/android/src/main/java/com/mrousavy/blurhash/BlurhashImageView.kt @@ -5,7 +5,6 @@ import android.util.Log import com.facebook.drawee.controller.AbstractDraweeControllerBuilder import com.facebook.react.views.image.GlobalImageLoadListener import com.facebook.react.views.image.ReactImageView -import com.mrousavy.blurhash.BlurHashDecoder.Companion.decode import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -38,7 +37,7 @@ class BlurhashImageView(context: Context?, draweeControllerBuilder: AbstractDraw private var _cachedBlurhash: BlurhashCache? = null private var _mainThreadId = Thread.currentThread().id - fun getThreadDescriptor(): String { + private fun getThreadDescriptor(): String { return if (Thread.currentThread().id == this._mainThreadId) "main" else "separate" } @@ -47,13 +46,15 @@ class BlurhashImageView(context: Context?, draweeControllerBuilder: AbstractDraw if (decodeAsync) { GlobalScope.launch { Log.d(REACT_CLASS, "Decoding ${decodeWidth}x${decodeHeight} blurhash ($blurhash) on ${getThreadDescriptor()} Thread!") - val bitmap = decode(blurhash, decodeWidth, decodeHeight, decodePunch) + // TODO: Experiment with useCache and parallelTasks + val bitmap = BlurHashDecoder.decode(blurhash, decodeWidth, decodeHeight, decodePunch, true, 2) setImageBitmap(bitmap) // TODO: why is setImageBitmap() deprecated? https://developer.android.com/reference/android/widget/ImageView#setImageBitmap(android.graphics.Bitmap) _cachedBlurhash = BlurhashCache(blurhash, decodeWidth, decodeHeight, decodePunch) } } else { Log.d(REACT_CLASS, "Decoding ${decodeWidth}x${decodeHeight} blurhash ($blurhash) on ${getThreadDescriptor()} Thread!") - val bitmap = decode(blurhash, decodeWidth, decodeHeight, decodePunch) + // TODO: Experiment with useCache and parallelTasks + val bitmap = BlurHashDecoder.decode(blurhash, decodeWidth, decodeHeight, decodePunch, true, 1) setImageBitmap(bitmap) // TODO: why is setImageBitmap() deprecated? https://developer.android.com/reference/android/widget/ImageView#setImageBitmap(android.graphics.Bitmap) _cachedBlurhash = BlurhashCache(blurhash, decodeWidth, decodeHeight, decodePunch) }