test(thumbhash): dither the fixtures so no DCT coefficient is degenerate

The gradient fixtures are smooth analytic ramps, and alpha.png's alpha channel varied
along x only. Both make whole families of DCT coefficients mathematically zero: 13 of
alpha.png's 38 AC nibbles, and at least one in every other ramp fixture.

A zero coefficient normalizes to exactly the 0.5 midpoint, i.e. 15*f = 7.5, so which of
nibble 7 or 8 it quantizes to is decided by ~1e-16 of float rounding noise. Those nibbles
are therefore unstable across any two summation orders -- the vendored JS and the Go port
already disagreed on solid.png for this reason -- and they carry no signal, so a bug that
transposed two of them would be invisible.

A deterministic +/-4 dither, plus an alpha ramp that varies in x and y, leaves every
coefficient at least 4.8e-5 from the tie boundary: ~1e9 times the observed inter-
implementation noise. solid.png and tiny.png regenerate byte-identical and keep their
existing carve-outs.
This commit is contained in:
Deluan 2026-07-25 15:56:02 -04:00
parent 02d253b2b0
commit 6604f8186a
6 changed files with 24 additions and 11 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -39,7 +39,20 @@ const png = (w, h, rgba) => {
])
}
// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float
// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal.
let seed = 0
const dither = () => {
seed ^= seed << 13
seed ^= seed >>> 17
seed ^= seed << 5
seed >>>= 0
return (seed % 9) - 4
}
const shade = (v) => Math.max(0, Math.min(255, v + dither()))
const make = (w, h, fn) => {
seed = 0x9e3779b9
const rgba = new Uint8Array(w * h * 4)
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h)
@ -47,9 +60,9 @@ const make = (w, h, fn) => {
}
const gradient = (rgba, i, x, y, w, h) => {
rgba[i] = Math.floor((255 * x) / w)
rgba[i + 1] = Math.floor((255 * y) / h)
rgba[i + 2] = Math.floor((255 * (x + y)) / (w + h))
rgba[i] = shade(Math.floor((255 * x) / w))
rgba[i + 1] = shade(Math.floor((255 * y) / h))
rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h)))
rgba[i + 3] = 255
}
const solid = (rgba, i) => {
@ -61,10 +74,10 @@ const solid = (rgba, i) => {
// RGB must vary with position too: a constant color composited over its own average cancels to a
// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp.
const alphaRamp = (rgba, i, x, y, w, h) => {
rgba[i] = Math.floor((255 * x) / w)
rgba[i + 1] = Math.floor((255 * y) / h)
rgba[i + 2] = Math.floor((255 * (x + y)) / (w + h))
rgba[i + 3] = Math.floor((255 * x) / w)
rgba[i] = shade(Math.floor((255 * x) / w))
rgba[i + 1] = shade(Math.floor((255 * y) / h))
rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h)))
rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h)))
}
const out = new URL('.', import.meta.url).pathname

View File

@ -1,8 +1,8 @@
{
"alpha.png": "JFiKBQw3s4ewiId3eBtPOfuEgHd4iIh4eA==",
"landscape.png": "3wcOFJpwd3dxd3eHh3ePgAj4hw==",
"portrait.png": "3/cNFBpxB4d3d3d4d3eAjwj3eA==",
"alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==",
"landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==",
"portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==",
"solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA",
"square.png": "HwgOBxpwd4dwd3h3h3eHd3d3+PiIgI8H",
"square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I",
"tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB