diff --git a/core/artwork/thumbhash/reference_test.go b/core/artwork/thumbhash/reference_test.go index a23b95526..e31c24677 100644 --- a/core/artwork/thumbhash/reference_test.go +++ b/core/artwork/thumbhash/reference_test.go @@ -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") diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go index d52e77735..53a749210 100644 --- a/core/artwork/thumbhash/thumbhash.go +++ b/core/artwork/thumbhash/thumbhash.go @@ -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 } diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go index 804bd74bb..ca71a5a0d 100644 --- a/core/artwork/thumbhash/thumbhash_test.go +++ b/core/artwork/thumbhash/thumbhash_test.go @@ -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())