test(thumbhash): fuzz the opaque path and drop an ill-conditioned golden

The 500-image randomized differential filled every byte randomly, so hasAlpha
(avgA < w*h) was true for all 500 images: the 7x7 no-alpha layout, terms(7,7),
nx=7 and the `if hasAlpha` false branch were never fuzzed. Force full opacity on
alternating iterations, which splits the run 250/250 with the seed and iteration
count unchanged. All 500 still match the reference port.

tiny.png is 1x1, so it has no non-zero AC content: 12 of its 37 AC coefficients
sit exactly on the round(15*f) = .5 tie and 24 are within 1e-12. It passed only
because a single pixel admits no summation reassociation. Give it the same
header-only carve-out solid.png already had, in both test files. The four
well-conditioned fixtures keep strict full byte equality.

Also document the divergence class on Encode itself rather than only in test
comments, add a sub-image regression spec, and use the max builtin over
math.Max. The toNRGBA Rect.Min gate turns out to protect nothing — SubImage
re-slices Pix so Pix[0] is the Rect.Min pixel and the loops read it correctly
either way — so its comment, which claimed the opposite, is corrected. The gate
is kept for now; removing it is a separate call.
This commit is contained in:
Deluan 2026-07-25 16:14:30 -04:00
parent 3dea79ec29
commit 4b3a861303
3 changed files with 61 additions and 24 deletions

View File

@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -42,6 +43,14 @@ func loadFixture(name string) (int, int, []byte) {
return b.Dx(), b.Dy(), pix
}
// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding
// noise sitting on a quantization tie; only the header bytes carry signal.
var headerOnlyFixtures = []string{"solid.png", "tiny.png"}
func isHeaderOnly(name string) bool {
return slices.Contains(headerOnlyFixtures, name)
}
func loadGoldens() map[string]string {
GinkgoHelper()
data, err := os.ReadFile(filepath.Join(testdataDir, "golden.json"))
@ -55,7 +64,7 @@ func loadGoldens() map[string]string {
var _ = Describe("reference port", func() {
It("reproduces every golden vector", func() {
for name, want := range loadGoldens() {
if name == "solid.png" {
if isHeaderOnly(name) {
continue // see the dedicated header-only spec below
}
w, h, rgba := loadFixture(name)
@ -64,15 +73,18 @@ var _ = Describe("reference port", func() {
}
})
// solid.png is uniform, so its true AC term is 0 and the golden's AC nibbles are just float
// rounding noise from cos(); only the header (DC terms + scales, which quantize to 0) is well-defined.
It("reproduces the well-conditioned header of a uniform image", func() {
want, err := base64.StdEncoding.DecodeString(loadGoldens()["solid.png"])
Expect(err).ToNot(HaveOccurred())
It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() {
for _, name := range headerOnlyFixtures {
want, err := base64.StdEncoding.DecodeString(loadGoldens()[name])
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
w, h, rgba := loadFixture(name)
Expect(referenceEncode(w, h, rgba)[:5]).To(Equal(want[:5]), "fixture %s header bytes", name)
}
})
It("quantizes a uniform image's scales to zero", func() {
w, h, rgba := loadFixture("solid.png")
got := referenceEncode(w, h, rgba)
Expect(got[:5]).To(Equal(want[:5]), "header bytes")
header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16
header16 := int(got[3]) | int(got[4])<<8
Expect((header24>>18)&31).To(Equal(0), "lScale")

View File

@ -16,7 +16,8 @@ const maxInputSize = 100
// term is one DCT coefficient's frequency pair, in the reference's triangular scan order.
type term struct{ cx, cy int }
// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha.
// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches
// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1.
func Encode(img image.Image) ([]byte, error) {
rgba := toNRGBA(downscale(img))
b := rgba.Bounds()
@ -83,8 +84,8 @@ func Encode(img image.Image) ([]byte, error) {
rowP[cx] += pv * f
rowQ[cx] += qv * f
}
// hasAlpha is loop-invariant, so this costs a predicted branch rather than a
// quarter of the inner loop on the opaque images that covers almost always are.
// hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter
// of the inner loop that opaque images never need.
if hasAlpha {
for cx := range nx {
rowA[cx] += alpha * cosX[cx][x]
@ -184,7 +185,7 @@ func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float6
ac = make([]float64, len(acc)-1)
for i, v := range acc[1:] {
ac[i] = v / n
scale = math.Max(scale, math.Abs(ac[i]))
scale = max(scale, math.Abs(ac[i]))
}
if scale > 0 {
for i := range ac {
@ -247,7 +248,8 @@ func pack(w, h int, hasAlpha bool, lx, ly int,
// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a
// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in.
func toNRGBA(img image.Image) *image.NRGBA {
// The pixel loops index Pix from its start, so only an origin-anchored image can be used as-is.
// Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the
// copy costs nothing on the origin-anchored images the pipeline actually produces.
if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) {
return nrgba
}

View File

@ -4,6 +4,7 @@ import (
"encoding/base64"
"image"
"image/color"
"image/draw"
"math/rand/v2"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
@ -26,7 +27,7 @@ func fixtureImage(name string) image.Image {
var _ = Describe("Encode", func() {
It("matches every golden vector", func() {
for name, want := range loadGoldens() {
if name == "solid.png" {
if isHeaderOnly(name) {
continue // see the dedicated header-only spec below
}
got, err := thumbhash.Encode(fixtureImage(name))
@ -35,26 +36,32 @@ var _ = Describe("Encode", func() {
}
})
// A uniform image has mathematically-zero AC terms, so its AC nibbles are rounding noise
// normalized by a scale that is itself noise; only the header is well-defined.
It("reproduces the well-conditioned header of a uniform image", func() {
want, err := base64.StdEncoding.DecodeString(loadGoldens()["solid.png"])
Expect(err).ToNot(HaveOccurred())
got, err := thumbhash.Encode(fixtureImage("solid.png"))
Expect(err).ToNot(HaveOccurred())
Expect(got[:5]).To(Equal(want[:5]), "header bytes")
It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() {
for _, name := range headerOnlyFixtures {
want, err := base64.StdEncoding.DecodeString(loadGoldens()[name])
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
got, err := thumbhash.Encode(fixtureImage(name))
Expect(err).ToNot(HaveOccurred(), "fixture %s", name)
Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name)
}
})
It("agrees with the reference port on randomized images", func() {
rng := rand.New(rand.NewPCG(1, 2)) //nolint:gosec // a fixed seed is the point: the run must be reproducible
for range 500 {
for iter := range 500 {
w := 1 + rng.IntN(100)
h := 1 + rng.IntN(100)
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for i := range img.Pix {
img.Pix[i] = byte(rng.IntN(256))
}
// Alpha is randomized too, so the 5x5-plus-alpha layout is exercised as often as 7x7.
// Random alpha is opaque essentially never, so half the runs are forced opaque to
// fuzz the 7x7 no-alpha layout as well as the 5x5-plus-alpha one.
if iter%2 == 0 {
for i := 3; i < len(img.Pix); i += 4 {
img.Pix[i] = 255
}
}
got, err := thumbhash.Encode(img)
Expect(err).ToNot(HaveOccurred())
@ -84,6 +91,22 @@ var _ = Describe("Encode", func() {
Expect(got).ToNot(BeEmpty())
})
It("encodes a sub-image like an origin-anchored copy of the same region", func() {
parent := image.NewNRGBA(image.Rect(0, 0, 60, 50))
for i := range parent.Pix {
parent.Pix[i] = byte(i * 7 % 251)
}
region := image.Rect(10, 7, 40, 30)
cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy()))
draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src)
got, err := thumbhash.Encode(parent.SubImage(region))
Expect(err).ToNot(HaveOccurred())
want, err := thumbhash.Encode(cropped)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
})
It("rejects an empty image", func() {
_, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0)))
Expect(err).To(HaveOccurred())