SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/view/BlurhashView.kt

241 lines
7.5 KiB
Kotlin

package jp.juggler.subwaytooter.view
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.util.SparseIntArray
import androidx.appcompat.widget.AppCompatTextView
import jp.juggler.util.log.LogCategory
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sign
class Blurhash(blurhash: String, punch: Float = 1f) {
companion object {
// map from base83 character to index
private val base83Map = SparseIntArray().apply {
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
.forEachIndexed { index, c ->
put(c.code, index)
}
}
// convert from base83 chars(1..4 length) to integer
private fun String.decodeBase83(start: Int, length: Int): Int {
var v = 0
for (i in start until start + length) {
val ci = this[i].code
val idx = base83Map.get(ci, -1)
if (idx == -1) error("decodeBase83: incorrect char code $ci")
v = v * 83 + idx
}
return v
}
// array to convert gamma curve from sRGB(0..255) to linear(0..1f)
private val arraySRGB2Linear = FloatArray(256) { i ->
val v = i.toDouble() / 255.0
if (v <= 0.04045) {
v / 12.92
} else {
((v + 0.055) / 1.055).pow(2.4)
}
.toFloat()
}
@Suppress("SameParameterValue")
private fun clip(min: Int, max: Int, value: Int) =
if (value < min) min else if (value > max) max else value
private fun sRGBToLinear(value: Int) = arraySRGB2Linear[clip(0, 255, value)]
private fun linearTosRGB(value: Float): Int {
// binary search in arraySRGB2Linear to avoid using pow()
var start = 0
var end = 256
while (end - start > 1) {
val mid = (start + end) shr 1
val midValue = arraySRGB2Linear[mid]
when {
value < midValue -> end = mid
value > midValue -> start = mid + 1
else -> return mid
}
}
return clip(0, 255, start)
}
private fun signPow2(v: Float): Float = sign(v) * (v * v)
private fun decodeACSub(maximumValue: Float, v: Int): Float =
signPow2((v - 9).toFloat() / 9f) * maximumValue
private fun decodeAC(value: Int, maximumValue: Float) = floatArrayOf(
decodeACSub(maximumValue, value / 361), // 19*19
decodeACSub(maximumValue, ((value / 19) % 19)),
decodeACSub(maximumValue, value % 19)
)
private fun decodeDC(value: Int) = floatArrayOf(
sRGBToLinear((value ushr 16) and 255),
sRGBToLinear((value ushr 8) and 255),
sRGBToLinear((value) and 255)
)
}
private val height: Int
private val width: Int
private val colors: Array<FloatArray>
init {
if (blurhash.length < 6) {
error("blurhash: too short $blurhash")
}
val sizeFlag = blurhash.decodeBase83(0, 1)
this.height = (sizeFlag / 9) + 1
this.width = (sizeFlag % 9) + 1
val lengthExpect = 4 + 2 * width * height
if (blurhash.length != lengthExpect) {
error("'blurhash length mismatch. expect=$lengthExpect,actual=${blurhash.length}")
}
val quantisedMaximumValue = blurhash.decodeBase83(1, 1)
val maximumValue = ((quantisedMaximumValue + 1).toFloat() / 166f) * punch
this.colors = Array(width * height) { i ->
when (i) {
0 -> decodeDC(blurhash.decodeBase83(2, 4))
else -> decodeAC(blurhash.decodeBase83(4 + i * 2, 2), maximumValue)
}
}
}
// render to IntArray that can be used to Bitmap.setPixels()
fun render(pixels: IntArray, pixelWidth: Int, pixelHeight: Int) {
var pos = 0
for (y in 0 until pixelHeight) {
val ky = Math.PI * y.toDouble() / pixelHeight.toDouble()
for (x in 0 until pixelWidth) {
val kx = Math.PI * x.toDouble() / pixelWidth.toDouble()
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until height) {
for (i in 0 until width) {
val basis = (cos(kx * i) * cos(ky * j)).toFloat()
val color = colors[i + j * width]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
pixels[pos++] = Color.argb(
255,
linearTosRGB(r),
linearTosRGB(g),
linearTosRGB(b)
)
}
}
}
}
class BlurhashView : AppCompatTextView {
companion object {
val log = LogCategory("BlurhashView")
const val bitmapWidth = 16
const val bitmapHeight = 16
}
constructor(context: Context) :
super(context)
constructor(context: Context, attrs: AttributeSet) :
super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) :
super(context, attrs, defStyleAttr)
// keep bitmap and IntArray to reuse it.
private val pixels = IntArray(bitmapWidth * bitmapHeight)
private val blurhashBitmap = Bitmap.createBitmap(
bitmapWidth,
bitmapHeight,
Bitmap.Config.ARGB_8888
)
private var blurhashDecodeOk = false
private val rectSrc = Rect()
private val rectDst = Rect()
private val paint = Paint().apply {
isFilterBitmap = true
}
var errorColor: Int = 0
set(v) {
field = v
invalidate()
}
var blurhash: String? = null
set(v) {
if (v == field) return
blurhashDecodeOk = if (v?.isEmpty() != false) {
false
} else try {
Blurhash(v).render(pixels, bitmapWidth, bitmapHeight)
blurhashBitmap.setPixels(
pixels,
0,
bitmapWidth,
0,
0,
bitmapWidth,
bitmapHeight
)
true
} catch (ex: Throwable) {
log.e(ex, "blurhash decode failed.")
false
}
field = v
invalidate()
}
private fun drawBlurhash(canvas: Canvas) {
val viewW = width
val viewH = height
val b = blurhashBitmap
if (!b.isRecycled && blurhashDecodeOk) {
rectSrc.set(0, 0, b.width, b.height)
rectDst.set(0, 0, viewW, viewH)
canvas.drawBitmap(b, rectSrc, rectDst, paint)
} else {
paint.color = errorColor
rectDst.set(0, 0, viewW, viewH)
canvas.drawRect(rectDst, paint)
}
}
override fun onDraw(canvas: Canvas) {
drawBlurhash(canvas)
super.onDraw(canvas)
}
override fun isOpaque(): Boolean {
return true
}
}