mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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.
This commit is contained in:
parent
6604f8186a
commit
3dea79ec29
270
core/artwork/thumbhash/thumbhash.go
Normal file
270
core/artwork/thumbhash/thumbhash.go
Normal file
@ -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
|
||||
}
|
||||
100
core/artwork/thumbhash/thumbhash_test.go
Normal file
100
core/artwork/thumbhash/thumbhash_test.go
Normal file
@ -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))
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user