perf(thumbhash): cache cosine tables and flatten hot loop in decoder

The reference port recomputed the inverse DCT cosine tables once per output
pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change
lifts the tables out of the inner loop and caches them across decodes
keyed by (size, componentCount) so subsequent placeholders at the same
target size skip cosine evaluation entirely.

Additional wins:
- AC coefficients unpack into pre-sized DoubleArrays (no ArrayList<Double>
  boxing, no post-hoc copy)
- truncated hashes are rejected up-front via a single length check instead
  of mid-stream
- LPQA -> sRGB uses an inline branch clamp instead of a
  min/max/round/coerceIn chain

Tests: 12 unit tests cover determinism across repeated decodes, cache
clearing, alpha preservation, aspect-ratio preservation both directions,
average-color drift bounds, truncated input rejection, and round-tripping
base64 vs. raw bytes. All green.

Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode,
warm- and cold-cache decode, aspect-ratio probe, and the full
decodeKeepAspectRatio pipeline so the perf delta is visible on device.
This commit is contained in:
Claude
2026-04-20 00:33:26 +00:00
parent b2f297b3bb
commit 63da850e3b
3 changed files with 557 additions and 92 deletions
@@ -24,8 +24,10 @@ import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
class ThumbHashTest {
@Test
@@ -84,8 +86,166 @@ class ThumbHashTest {
@Test
fun `decoding a malformed hash returns null`() {
assertEquals(null, ThumbHashDecoder.decode(ByteArray(3)))
assertEquals(null, ThumbHashDecoder.decode(null as String?))
assertEquals(null, ThumbHashDecoder.decode(""))
assertNull(ThumbHashDecoder.decode(ByteArray(3)))
assertNull(ThumbHashDecoder.decode(null as String?))
assertNull(ThumbHashDecoder.decode(""))
}
@Test
fun `decoded opaque image has fully opaque alpha`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFF8080FF.toInt() } // opaque cornflower-ish
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
for (p in decoded.pixels) {
val a = (p ushr 24) and 0xff
assertEquals("alpha should be 255 for opaque encode", 255, a)
}
}
@Test
fun `decoded transparent image preserves alpha channel`() {
val w = 32
val h = 32
// Fully transparent pixels everywhere.
val pixels = IntArray(w * h) { 0x00000000 }
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
// The average alpha is 0, so every decoded alpha should be at or near 0.
var maxAlpha = 0
for (p in decoded.pixels) {
val a = (p ushr 24) and 0xff
if (a > maxAlpha) maxAlpha = a
}
assertTrue("max alpha of all-transparent decode should be low; got $maxAlpha", maxAlpha <= 16)
}
@Test
fun `decoded average color is close to input average`() {
val w = 48
val h = 32
val target = intArrayOf(200, 120, 60) // warm orange
val pixels =
IntArray(w * h) {
(0xFF shl 24) or (target[0] shl 16) or (target[1] shl 8) or target[2]
}
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
var sumR = 0
var sumG = 0
var sumB = 0
for (p in decoded.pixels) {
sumR += (p shr 16) and 0xff
sumG += (p shr 8) and 0xff
sumB += p and 0xff
}
val count = decoded.pixels.size
val avgR = sumR / count
val avgG = sumG / count
val avgB = sumB / count
// ThumbHash quantisation allows a handful of codepoints of drift.
assertTrue("avg R drift: expected ${target[0]}, got $avgR", abs(avgR - target[0]) < 8)
assertTrue("avg G drift: expected ${target[1]}, got $avgG", abs(avgG - target[1]) < 8)
assertTrue("avg B drift: expected ${target[2]}, got $avgB", abs(avgB - target[2]) < 8)
}
@Test
fun `aspect ratio matches landscape input`() {
val w = 60
val h = 30
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val ratio = ThumbHashDecoder.aspectRatio(hash)
assertNotNull(ratio)
assertTrue("landscape ratio should be > 1, got $ratio", ratio!! > 1f)
}
@Test
fun `aspect ratio matches portrait input`() {
val w = 30
val h = 60
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val ratio = ThumbHashDecoder.aspectRatio(hash)
assertNotNull(ratio)
assertTrue("portrait ratio should be < 1, got $ratio", ratio!! < 1f)
}
@Test
fun `repeated decodes produce identical output (cosine cache determinism)`() {
val w = 40
val h = 30
val pixels =
IntArray(w * h) { i ->
val x = i % w
(0xFF shl 24) or (x * 6 shl 16) or ((i % 255) shl 8) or ((i * 3) and 0xff)
}
val hash = ThumbHashEncoder.encode(pixels, w, h)
val first = ThumbHashDecoder.decode(hash)
val second = ThumbHashDecoder.decode(hash)
val third = ThumbHashDecoder.decode(hash)
assertNotNull(first)
assertNotNull(second)
assertNotNull(third)
// Bit-exact: the cached cosine tables must produce identical output.
assertEquals(first, second)
assertEquals(first, third)
}
@Test
fun `clearCache does not affect correctness of subsequent decodes`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFFAABBCC.toInt() }
val hash = ThumbHashEncoder.encode(pixels, w, h)
val before = ThumbHashDecoder.decode(hash)
ThumbHashDecoder.clearCache()
val after = ThumbHashDecoder.decode(hash)
assertEquals(before, after)
}
@Test
fun `truncated AC payload returns null`() {
val w = 32
val h = 32
val pixels = IntArray(w * h) { 0xFF336699.toInt() }
val fullHash = ThumbHashEncoder.encode(pixels, w, h)
// Chop off half the AC payload.
val truncated = fullHash.copyOfRange(0, 5 + (fullHash.size - 5) / 4)
assertNull(
"hash with insufficient AC bytes should be rejected",
ThumbHashDecoder.decode(truncated),
)
}
@Test
fun `decoded output size stays within 32 x 32 bounds`() {
val w = 50
val h = 40
val pixels =
IntArray(w * h) { i ->
(0xFF shl 24) or ((i and 0xff) shl 16) or (((i * 2) and 0xff) shl 8) or ((i * 3) and 0xff)
}
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
assertNotNull(decoded)
decoded!!
assertTrue(
"expected output to fit in 32x32, got ${decoded.width}x${decoded.height}",
decoded.width in 1..32 && decoded.height in 1..32,
)
assertEquals(
"pixel buffer size must match dimensions",
decoded.width * decoded.height,
decoded.pixels.size,
)
}
}