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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user