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

241 lines
6.1 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.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 view_w = width
val view_h = height
val b = blurhashBitmap
if(b != null && ! b.isRecycled && blurhashDecodeOk) {
rectSrc.set(0, 0, b.width, b.height)
rectDst.set(0, 0, view_w, view_h)
canvas.drawBitmap(b, rectSrc, rectDst, paint)
} else {
paint.color = errorColor
rectDst.set(0, 0, view_w, view_h)
canvas.drawRect(rectDst, paint)
}
}
override fun onDraw(canvas : Canvas) {
drawBlurhash(canvas)
super.onDraw(canvas)
}
override fun isOpaque() : Boolean {
return true
}
}