From 3dea79ec29ada4c7cad6013d6863a1d70029b45f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 25 Jul 2026 15:56:31 -0400 Subject: [PATCH] feat(thumbhash): add a two-pass separable ThumbHash encoder Encode replaces the reference's four w*h scratch arrays (~320 KB at 100x100) and its 40 re-reads of every pixel with a single pixel pass that folds each row into per-frequency sums, then a second fold over rows. The transform drops from O(terms * pixels) to O(nx * pixels + terms * h), nx <= 7. Verified against the vendored JS goldens and, differentially, against the literal Go port on 500 randomized images. On the fixtures the two implementations' AC coefficients agree to 8e-15; the separable inner loop reassociates the additions, so exact agreement holds only where a coefficient is not sitting on a quantization tie, which is why the fixtures are now dithered. --- core/artwork/thumbhash/thumbhash.go | 270 +++++++++++++++++++++++ core/artwork/thumbhash/thumbhash_test.go | 100 +++++++++ 2 files changed, 370 insertions(+) create mode 100644 core/artwork/thumbhash/thumbhash.go create mode 100644 core/artwork/thumbhash/thumbhash_test.go diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go new file mode 100644 index 000000000..d52e77735 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash.go @@ -0,0 +1,270 @@ +// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash). +package thumbhash + +import ( + "errors" + "image" + "image/draw" + "math" + + xdraw "golang.org/x/image/draw" +) + +// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation. +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. +func Encode(img image.Image) ([]byte, error) { + rgba := toNRGBA(downscale(img)) + b := rgba.Bounds() + w, h := b.Dx(), b.Dy() + if w == 0 || h == 0 { + return nil, errors.New("thumbhash: empty image") + } + + avgR, avgG, avgB, avgA := averageColor(rgba, w, h) + hasAlpha := avgA < float64(w*h) + if avgA > 0 { + avgR /= avgA + avgG /= avgA + avgB /= avgA + } + + lLimit := 7.0 + if hasAlpha { + lLimit = 5.0 + } + maxWH := float64(max(w, h)) + lx := max(1, int(math.Round(lLimit*float64(w)/maxWH))) + ly := max(1, int(math.Round(lLimit*float64(h)/maxWH))) + + lTerms := terms(max(3, lx), max(3, ly)) + pTerms := terms(3, 3) + qTerms := terms(3, 3) + var aTerms []term + if hasAlpha { + aTerms = terms(5, 5) + } + + nx := maxCX(lTerms, pTerms, qTerms, aTerms) + 1 + cosX := cosTable(nx, w) + cosY := cosTable(maxCY(lTerms, pTerms, qTerms, aTerms)+1, h) + + lAcc := make([]float64, len(lTerms)) + pAcc := make([]float64, len(pTerms)) + qAcc := make([]float64, len(qTerms)) + aAcc := make([]float64, len(aTerms)) + rowL := make([]float64, nx) + rowP := make([]float64, nx) + rowQ := make([]float64, nx) + rowA := make([]float64, nx) + + for y := range h { + clear(rowL) + clear(rowP) + clear(rowQ) + clear(rowA) + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r := avgR*(1-alpha) + alpha/255*float64(row[j]) + g := avgG*(1-alpha) + alpha/255*float64(row[j+1]) + bl := avgB*(1-alpha) + alpha/255*float64(row[j+2]) + lv := (r + g + bl) / 3 + pv := (r+g)/2 - bl + qv := r - g + for cx := range nx { + f := cosX[cx][x] + rowL[cx] += lv * f + 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. + if hasAlpha { + for cx := range nx { + rowA[cx] += alpha * cosX[cx][x] + } + } + } + accumulate(lAcc, lTerms, rowL, cosY, y) + accumulate(pAcc, pTerms, rowP, cosY, y) + accumulate(qAcc, qTerms, rowQ, cosY, y) + accumulate(aAcc, aTerms, rowA, cosY, y) + } + + n := float64(w * h) + lDC, lAC, lScale := normalize(lAcc, n) + pDC, pAC, pScale := normalize(pAcc, n) + qDC, qAC, qScale := normalize(qAcc, n) + aDC, aAC, aScale := normalize(aAcc, n) + + return pack(w, h, hasAlpha, lx, ly, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil +} + +// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order. +func terms(nx, ny int) []term { + var ts []term + for cy := range ny { + for cx := 0; cx*ny < nx*(ny-cy); cx++ { + ts = append(ts, term{cx, cy}) + } + } + return ts +} + +func maxCX(groups ...[]term) int { + m := 0 + for _, g := range groups { + for _, t := range g { + m = max(m, t.cx) + } + } + return m +} + +func maxCY(groups ...[]term) int { + m := 0 + for _, g := range groups { + for _, t := range g { + m = max(m, t.cy) + } + } + return m +} + +// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the +// table values are bit-identical to recomputing them per coefficient. +func cosTable(n, size int) [][]float64 { + t := make([][]float64, n) + for c := range n { + t[c] = make([]float64, size) + for i := range size { + t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5)) + } + } + return t +} + +func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) { + for y := range h { + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r += alpha / 255 * float64(row[j]) + g += alpha / 255 * float64(row[j+1]) + b += alpha / 255 * float64(row[j+2]) + a += alpha + } + } + return r, g, b, a +} + +// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs +// nx multiplies per pixel instead of one per coefficient. +func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) { + for k, t := range ts { + acc[k] += row[t.cx] * cosY[t.cy][y] + } +} + +// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant +// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint. +func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) { + if len(acc) == 0 { + return 0, nil, 0 + } + dc = acc[0] / n + ac = make([]float64, len(acc)-1) + for i, v := range acc[1:] { + ac[i] = v / n + scale = math.Max(scale, math.Abs(ac[i])) + } + if scale > 0 { + for i := range ac { + ac[i] = 0.5 + 0.5/scale*ac[i] + } + } + return dc, ac, scale +} + +func pack(w, h int, hasAlpha bool, lx, ly int, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64, + lAC, pAC, qAC, aAC []float64, +) []byte { + isLandscape := 0 + if w > h { + isLandscape = 1 + } + alphaBit := 0 + if hasAlpha { + alphaBit = 1 + } + header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 | + int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23 + lead := lx + if isLandscape == 1 { + lead = ly + } + header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15 + + acs := [][]float64{lAC, pAC, qAC} + acStart := 5 + if hasAlpha { + acs = append(acs, aAC) + acStart = 6 + } + acCount := 0 + for _, ac := range acs { + acCount += len(ac) + } + + hash := make([]byte, acStart+(acCount+1)/2) + hash[0] = byte(header24 & 255) + hash[1] = byte((header24 >> 8) & 255) + hash[2] = byte(header24 >> 16) + hash[3] = byte(header16 & 255) + hash[4] = byte(header16 >> 8) + if hasAlpha { + hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4) + } + acIndex := 0 + for _, ac := range acs { + for _, f := range ac { + hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2)) + acIndex++ + } + } + return hash +} + +// 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. + if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) { + return nrgba + } + b := img.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go new file mode 100644 index 000000000..804bd74bb --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_test.go @@ -0,0 +1,100 @@ +package thumbhash_test + +import ( + "encoding/base64" + "image" + "image/color" + "math/rand/v2" + + "github.com/navidrome/navidrome/core/artwork/thumbhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fixtureImage rebuilds a testdata PNG as an image.Image for the Encode API. NRGBA, not RGBA: +// the pixels are non-premultiplied and must stay that way. +func fixtureImage(name string) image.Image { + GinkgoHelper() + w, h, pix := loadFixture(name) + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + copy(img.Pix[y*img.Stride:], pix[y*w*4:(y+1)*w*4]) + } + return img +} + +var _ = Describe("Encode", func() { + It("matches every golden vector", func() { + for name, want := range loadGoldens() { + if name == "solid.png" { + continue // see the dedicated header-only spec below + } + got, err := thumbhash.Encode(fixtureImage(name)) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name) + } + }) + + // 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("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 { + 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. + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + + pix := make([]byte, 0, w*h*4) + for y := range h { + pix = append(pix, img.Pix[y*img.Stride:y*img.Stride+w*4]...) + } + Expect(got).To(Equal(referenceEncode(w, h, pix)), "%dx%d", w, h) + } + }) + + It("downscales an oversized image rather than failing", func() { + img := image.NewNRGBA(image.Rect(0, 0, 500, 300)) + for i := range img.Pix { + img.Pix[i] = byte(i) + } + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(len(got)).To(BeNumerically(">=", 5)) + }) + + It("encodes a 1x1 image", func() { + img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255}) + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(got).ToNot(BeEmpty()) + }) + + It("rejects an empty image", func() { + _, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0))) + Expect(err).To(HaveOccurred()) + }) + + It("is deterministic", func() { + img := fixtureImage("square.png") + first, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + second, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(Equal(second)) + }) +})