feat: 将实现切换至 thumbHash

Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
This commit is contained in:
chenjuntao
2024-08-12 10:02:10 +08:00
parent 239caac46a
commit 08d56959de
3 changed files with 425 additions and 53 deletions

View File

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

View File

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

View File

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