mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
3dea79ec29
commit
4b3a861303
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user