From 08d56959debbfc291e59695aa45fafa71db2e560 Mon Sep 17 00:00:00 2001 From: chenjuntao Date: Mon, 12 Aug 2024 10:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E5=AE=9E=E7=8E=B0=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E8=87=B3=20thumbHash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: chenjuntao --- .../src/main/java/blurhash/RandomHashGen.kt | 53 --- .../src/main/java/thumbhash/ThumbHash.kt | 350 ++++++++++++++++++ .../main/java/thumbhash/ThumbHashDrawable.kt | 75 ++++ 3 files changed, 425 insertions(+), 53 deletions(-) delete mode 100644 module_common/src/main/java/blurhash/RandomHashGen.kt create mode 100644 module_common/src/main/java/thumbhash/ThumbHash.kt create mode 100644 module_common/src/main/java/thumbhash/ThumbHashDrawable.kt diff --git a/module_common/src/main/java/blurhash/RandomHashGen.kt b/module_common/src/main/java/blurhash/RandomHashGen.kt deleted file mode 100644 index cb9d825956..0000000000 --- a/module_common/src/main/java/blurhash/RandomHashGen.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.wolt.blurhash - -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.pow -import kotlin.random.Random - -object RandomHashGen { - - private val digitCharacters = 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', '#', '$', '%', '*', '+', ',', '-', '.', ':', ';', - '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' - ) - - private fun decode83(str: String): Int { - var value = 0 - for (c in str) { - val digit = digitCharacters.indexOf(c) - value = value * 83 + digit - } - return value - } - - private fun makeId(length: Int): Int { - val characters = "1234567890" - val charactersLength = characters.length - var result = "" - for (i in 0 until length) { - result += characters[Random.nextInt(charactersLength)] - } - return result.toInt() - } - - fun generateBlurHash(): String { - val number = ceil(Random.nextDouble() * 265).toInt() - val n = makeId(number) - - var result = "" - for (i in 1..(number * 1024)) { - val digit = (floor(n.toDouble()) / 83.0.pow((number / 128) - i.toDouble())) % 83 - result += digitCharacters[floor(digit).toInt()] - } - - val sizeFlag = decode83(result[0].toString()) - val numY = (sizeFlag / 9) + 1 - val numX = (sizeFlag % 9) + 1 - - return result.substring(0, 4 + 2 * numX * numY) - } - -} \ No newline at end of file diff --git a/module_common/src/main/java/thumbhash/ThumbHash.kt b/module_common/src/main/java/thumbhash/ThumbHash.kt new file mode 100644 index 0000000000..480328d637 --- /dev/null +++ b/module_common/src/main/java/thumbhash/ThumbHash.kt @@ -0,0 +1,350 @@ +package thumbhash + +import android.graphics.Bitmap +import android.util.Base64 +import java.nio.ByteBuffer + +object ThumbHash { + + fun getBitmapFromHash(hash: String, fullWidth: Int? = null, fullHeight: Int? = null): Bitmap { + val image = thumbHashToRGBA(Base64.decode(hash, Base64.DEFAULT)) + val bmp = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888) + bmp.copyPixelsFromBuffer(ByteBuffer.wrap(image.rgba)) + fullWidth ?: return bmp + fullHeight ?: return bmp + return Bitmap.createScaledBitmap(bmp, fullWidth, fullHeight, true) + } + + + /** + * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @return The ThumbHash as a byte array. + */ + fun rgbaToThumbHash(w: Int, h: Int, rgba: ByteArray): ByteArray { + // Encoding an image larger than 100x100 is slow with no benefit + require(!(w > 100 || h > 100)) { w.toString() + "x" + h + " doesn't fit in 100x100" } + + // Determine the average color + var avg_r = 0f + var avg_g = 0f + var avg_b = 0f + var avg_a = 0f + run { + var i = 0 + var j = 0 + while (i < w * h) { + val alpha = (rgba[j + 3].toInt() and 255) / 255.0f + avg_r += alpha / 255.0f * (rgba[j].toInt() and 255) + avg_g += alpha / 255.0f * (rgba[j + 1].toInt() and 255) + avg_b += alpha / 255.0f * (rgba[j + 2].toInt() and 255) + avg_a += alpha + i++ + j += 4 + } + } + if (avg_a > 0) { + avg_r /= avg_a + avg_g /= avg_a + avg_b /= avg_a + } + val hasAlpha = avg_a < w * h + val l_limit = if (hasAlpha) 5 else 7 // Use fewer luminance bits if there's alpha + val lx = Math.max(1, Math.round((l_limit * w).toFloat() / Math.max(w, h).toFloat())) + val ly = Math.max(1, Math.round((l_limit * h).toFloat() / Math.max(w, h).toFloat())) + val l = FloatArray(w * h) // luminance + val p = FloatArray(w * h) // yellow - blue + val q = FloatArray(w * h) // red - green + val a = FloatArray(w * h) // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color) + var i = 0 + var j = 0 + while (i < w * h) { + val alpha = (rgba[j + 3].toInt() and 255) / 255.0f + val r = avg_r * (1.0f - alpha) + alpha / 255.0f * (rgba[j].toInt() and 255) + val g = avg_g * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1].toInt() and 255) + val b = avg_b * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 2].toInt() and 255) + l[i] = (r + g + b) / 3.0f + p[i] = (r + g) / 2.0f - b + q[i] = r - g + a[i] = alpha + i++ + j += 4 + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + val l_channel = Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l) + val p_channel = Channel(3, 3).encode(w, h, p) + val q_channel = Channel(3, 3).encode(w, h, q) + val a_channel = if (hasAlpha) Channel(5, 5).encode(w, h, a) else null + + // Write the constants + val isLandscape = w > h + val header24 = (Math.round(63.0f * l_channel.dc) + or (Math.round(31.5f + 31.5f * p_channel.dc) shl 6) + or (Math.round(31.5f + 31.5f * q_channel.dc) shl 12) + or (Math.round(31.0f * l_channel.scale) shl 18) + or if (hasAlpha) 1 shl 23 else 0) + val header16 = ((if (isLandscape) ly else lx) + or (Math.round(63.0f * p_channel.scale) shl 3) + or (Math.round(63.0f * q_channel.scale) shl 9) + or if (isLandscape) 1 shl 15 else 0) + val ac_start = if (hasAlpha) 6 else 5 + val ac_count = (l_channel.ac.size + p_channel.ac.size + q_channel.ac.size + + if (hasAlpha) a_channel!!.ac.size else 0) + val hash = ByteArray(ac_start + (ac_count + 1) / 2) + hash[0] = header24.toByte() + hash[1] = (header24 shr 8).toByte() + hash[2] = (header24 shr 16).toByte() + hash[3] = header16.toByte() + hash[4] = (header16 shr 8).toByte() + if (hasAlpha) hash[5] = (Math.round(15.0f * a_channel!!.dc) + or (Math.round(15.0f * a_channel.scale) shl 4)).toByte() + + // Write the varying factors + var ac_index = 0 + ac_index = l_channel.writeTo(hash, ac_start, ac_index) + ac_index = p_channel.writeTo(hash, ac_start, ac_index) + ac_index = q_channel.writeTo(hash, ac_start, ac_index) + if (hasAlpha) a_channel!!.writeTo(hash, ac_start, ac_index) + return hash + } + + /** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @return The width, height, and pixels of the rendered placeholder image. + */ + fun thumbHashToRGBA(hash: ByteArray): Image { + // Read the constants + val header24 = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16) + val header16 = hash[3].toInt() and 255 or (hash[4].toInt() and 255 shl 8) + val l_dc = (header24 and 63).toFloat() / 63.0f + val p_dc = (header24 shr 6 and 63).toFloat() / 31.5f - 1.0f + val q_dc = (header24 shr 12 and 63).toFloat() / 31.5f - 1.0f + val l_scale = (header24 shr 18 and 31).toFloat() / 31.0f + val hasAlpha = header24 shr 23 != 0 + val p_scale = (header16 shr 3 and 63).toFloat() / 63.0f + val q_scale = (header16 shr 9 and 63).toFloat() / 63.0f + val isLandscape = header16 shr 15 != 0 + val lx = Math.max(3, if (isLandscape) if (hasAlpha) 5 else 7 else header16 and 7) + val ly = Math.max(3, if (isLandscape) header16 and 7 else if (hasAlpha) 5 else 7) + val a_dc = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f + val a_scale = (hash[5].toInt() shr 4 and 15).toFloat() / 15.0f + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + val ac_start = if (hasAlpha) 6 else 5 + var ac_index = 0 + val l_channel = Channel(lx, ly) + val p_channel = Channel(3, 3) + val q_channel = Channel(3, 3) + var a_channel: Channel? = null + ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale) + ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f) + ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f) + if (hasAlpha) { + a_channel = Channel(5, 5) + a_channel.decode(hash, ac_start, ac_index, a_scale) + } + val l_ac = l_channel.ac + val p_ac = p_channel.ac + val q_ac = q_channel.ac + val a_ac = if (hasAlpha) a_channel!!.ac else null + + // Decode using the DCT into RGB + val ratio = thumbHashToApproximateAspectRatio(hash) + val w = Math.round(if (ratio > 1.0f) 32.0f else 32.0f * ratio) + val h = Math.round(if (ratio > 1.0f) 32.0f / ratio else 32.0f) + val rgba = ByteArray(w * h * 4) + val cx_stop = Math.max(lx, if (hasAlpha) 5 else 3) + val cy_stop = Math.max(ly, if (hasAlpha) 5 else 3) + val fx = FloatArray(cx_stop) + val fy = FloatArray(cy_stop) + var y = 0 + var i = 0 + while (y < h) { + var x = 0 + while (x < w) { + var l = l_dc + var p = p_dc + var q = q_dc + var a = a_dc + + // Precompute the coefficients + for (cx in 0 until cx_stop) fx[cx] = Math.cos(Math.PI / w * (x + 0.5f) * cx).toFloat() + for (cy in 0 until cy_stop) fy[cy] = Math.cos(Math.PI / h * (y + 0.5f) * cy).toFloat() + + // Decode L + run { + var cy = 0 + var j = 0 + while (cy < ly) { + val fy2 = fy[cy] * 2.0f + var cx = if (cy > 0) 0 else 1 + while (cx * ly < lx * (ly - cy)) { + l += l_ac[j] * fx[cx] * fy2 + cx++ + j++ + } + cy++ + } + } + + // Decode P and Q + var cy = 0 + var j = 0 + while (cy < 3) { + val fy2 = fy[cy] * 2.0f + var cx = if (cy > 0) 0 else 1 + while (cx < 3 - cy) { + val f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + cx++ + j++ + } + cy++ + } + + // Decode A + if (hasAlpha) { + var cy = 0 + var j = 0 + while (cy < 5) { + val fy2 = fy[cy] * 2.0f + var cx = if (cy > 0) 0 else 1 + while (cx < 5 - cy) { + a += a_ac!![j] * fx[cx] * fy2 + cx++ + j++ + } + cy++ + } + } + + // Convert to RGB + val b = l - 2.0f / 3.0f * p + val r = (3.0f * l - b + q) / 2.0f + val g = r - q + rgba[i] = Math.max(0, Math.round(255.0f * Math.min(1f, r))).toByte() + rgba[i + 1] = Math.max(0, Math.round(255.0f * Math.min(1f, g))).toByte() + rgba[i + 2] = Math.max(0, Math.round(255.0f * Math.min(1f, b))).toByte() + rgba[i + 3] = Math.max(0, Math.round(255.0f * Math.min(1f, a))).toByte() + x++ + i += 4 + } + y++ + } + return Image(w, h, rgba) + } + + /** + * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @return The RGBA values for the average color. Each value ranges from 0 to 1. + */ + fun thumbHashToAverageRGBA(hash: ByteArray): RGBA { + val header = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16) + val l = (header and 63).toFloat() / 63.0f + val p = (header shr 6 and 63).toFloat() / 31.5f - 1.0f + val q = (header shr 12 and 63).toFloat() / 31.5f - 1.0f + val hasAlpha = header shr 23 != 0 + val a = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f + val b = l - 2.0f / 3.0f * p + val r = (3.0f * l - b + q) / 2.0f + val g = r - q + return RGBA( + Math.max(0f, Math.min(1f, r)), + Math.max(0f, Math.min(1f, g)), + Math.max(0f, Math.min(1f, b)), + a + ) + } + + /** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @return The approximate aspect ratio (i.e. width / height). + */ + fun thumbHashToApproximateAspectRatio(hash: ByteArray): Float { + val header = hash[3] + val hasAlpha = hash[2].toInt() and 0x80 != 0 + val isLandscape = hash[4].toInt() and 0x80 != 0 + val lx = if (isLandscape) if (hasAlpha) 5 else 7 else header.toInt() and 7 + val ly = if (isLandscape) header.toInt() and 7 else if (hasAlpha) 5 else 7 + return lx.toFloat() / ly.toFloat() + } + + data class Image(var width: Int, var height: Int, var rgba: ByteArray) + data class RGBA(var r: Float, var g: Float, var b: Float, var a: Float) + private class Channel internal constructor(var nx: Int, var ny: Int) { + var dc = 0f + var ac: FloatArray + var scale = 0f + + init { + var n = 0 + for (cy in 0 until ny) { + var cx = if (cy > 0) 0 else 1 + while (cx * ny < nx * (ny - cy)) { + n++ + cx++ + } + } + ac = FloatArray(n) + } + + fun encode(w: Int, h: Int, channel: FloatArray): Channel { + var n = 0 + val fx = FloatArray(w) + for (cy in 0 until ny) { + var cx = 0 + while (cx * ny < nx * (ny - cy)) { + var f = 0f + for (x in 0 until w) fx[x] = Math.cos(Math.PI / w * cx * (x + 0.5f)).toFloat() + for (y in 0 until h) { + val fy = Math.cos(Math.PI / h * cy * (y + 0.5f)).toFloat() + for (x in 0 until w) f += channel[x + y * w] * fx[x] * fy + } + f /= (w * h).toFloat() + if (cx > 0 || cy > 0) { + ac[n++] = f + scale = Math.max(scale, Math.abs(f)) + } else { + dc = f + } + cx++ + } + } + if (scale > 0) for (i in ac.indices) ac[i] = 0.5f + 0.5f / scale * ac[i] + return this + } + + fun decode(hash: ByteArray, start: Int, index: Int, scale: Float): Int { + var index = index + for (i in ac.indices) { + val data = hash[start + (index shr 1)].toInt() shr (index and 1 shl 2) + ac[i] = ((data and 15).toFloat() / 7.5f - 1.0f) * scale + index++ + } + return index + } + + fun writeTo(hash: ByteArray, start: Int, index: Int): Int { + var index = index + for (v in ac) { + hash[start + (index shr 1)] = + (hash[start + (index shr 1)].toInt() or (Math.round(15.0f * v) shl (index and 1 shl 2))).toByte() + index++ + } + return index + } + } +} \ No newline at end of file diff --git a/module_common/src/main/java/thumbhash/ThumbHashDrawable.kt b/module_common/src/main/java/thumbhash/ThumbHashDrawable.kt new file mode 100644 index 0000000000..6bdaf9a778 --- /dev/null +++ b/module_common/src/main/java/thumbhash/ThumbHashDrawable.kt @@ -0,0 +1,75 @@ +package thumbhash + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.core.graphics.withTranslation + +class ThumbHashDrawable( + private val hash: String, + private val targetWidth: Int = SIZE_UNDEFINED, // Too large a resolution will affect performance + private val targetHeight: Int = SIZE_UNDEFINED, // Too large a resolution will affect performance +) : Drawable() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var bitmap: Bitmap? = null + + override fun getIntrinsicHeight(): Int { + return bitmap?.height ?: targetHeight + } + + override fun getIntrinsicWidth(): Int { + return bitmap?.width ?: targetWidth + } + + override fun onBoundsChange(bounds: Rect) { + updateThumbHash(bounds) + } + + private fun calculateSize(boundSize: Int, targetSize: Int): Int { + return if (targetSize > SIZE_UNDEFINED) targetSize else boundSize + } + + private fun updateThumbHash(bounds: Rect) { + val width = calculateSize(bounds.width(), targetWidth) + val height = calculateSize(bounds.height(), targetHeight) + val bitmap = this.bitmap + if (bitmap != null) { + if (width == bitmap.width && height == bitmap.height) { + return + } + } + this.bitmap = ThumbHash.getBitmapFromHash(hash, width, height) + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + val bitmap = this.bitmap ?: return + val rect = bounds + canvas.withTranslation(rect.left.toFloat(), rect.top.toFloat()) { + drawBitmap(bitmap, 0f, 0f, paint) + } + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + invalidateSelf() + } + + override fun getOpacity(): Int { + return PixelFormat.OPAQUE + } + + companion object { + internal const val SIZE_UNDEFINED = -1 + } +} \ No newline at end of file