mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
test(thumbhash): assert against reference goldens, drop the Go port
The reference port existed to be a differential oracle, but its packing half was a verbatim copy of production pack(), so it was not as independent as it looked. Only the 500-image randomised spec needed it; the six PNG fixtures cannot reach random sizes, aspects, or both coefficient layouts. gen_generated.mjs now emits 300 vectors from evanw's actual JS. Their pixels are a pure function of their index, so Go rebuilds them byte-for-byte and only the hashes are committed (16KB). The oracle is now the reference itself rather than a hand transcription of it. Also from the cleanup pass: - maxCX/maxCY scanned every term to recover a bound that is just the widest coefficient region - tests.GradientImage duplicated generateGradientImage three files away - BenchmarkHashEncodersAtInputSize was also BenchmarkHashEncoders/*/100x100 - processor had two internal test files with no rule for which gets a new spec - three blurhash specs differing only by alpha became a DescribeTable - the shared mock's GetInfoForItems projection had drifted from the real query
This commit is contained in:
parent
b13e8d7057
commit
df301ae1a8
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
@ -45,3 +46,11 @@ func generateGradientImage(width, height int) *image.RGBA {
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders.
|
||||
func gradientNRGBA(size int) *image.NRGBA {
|
||||
src := generateGradientImage(size, size)
|
||||
dst := image.NewNRGBA(src.Bounds())
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
@ -60,32 +60,19 @@ var _ = Describe("Encode input types", func() {
|
||||
return nrgba, rgba
|
||||
}
|
||||
|
||||
It("gives an opaque NRGBA the same hash as the equivalent RGBA", func() {
|
||||
nrgba, rgba := buildPair(255)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
})
|
||||
|
||||
It("premultiplies a partly transparent NRGBA, matching the RGBA it replaces", func() {
|
||||
nrgba, rgba := buildPair(128)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
})
|
||||
|
||||
It("treats a fully transparent NRGBA as black, as premultiplication does", func() {
|
||||
nrgba, rgba := buildPair(0)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
})
|
||||
DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces",
|
||||
func(alpha uint8) {
|
||||
nrgba, rgba := buildPair(alpha)
|
||||
fromNRGBA, err := blurhash.Encode(nrgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fromRGBA, err := blurhash.Encode(rgba)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fromNRGBA).To(Equal(fromRGBA))
|
||||
},
|
||||
Entry("opaque", uint8(255)),
|
||||
Entry("partly transparent", uint8(128)),
|
||||
Entry("fully transparent, which premultiplication crushes to black", uint8(0)),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Encode", func() {
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork/blurhash"
|
||||
"github.com/navidrome/navidrome/core/artwork/thumbhash"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail.
|
||||
@ -32,17 +31,17 @@ func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image)
|
||||
// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image
|
||||
// makeThumbnail produces, so neither is measured with a conversion the other avoids.
|
||||
func BenchmarkHashEncodersAtInputSize(b *testing.B) {
|
||||
img := tests.GradientImage(thumbnailSize)
|
||||
img := gradientNRGBA(thumbnailSize)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) })
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive
|
||||
// downscale starts to dominate.
|
||||
// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above.
|
||||
func BenchmarkHashEncoders(b *testing.B) {
|
||||
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
|
||||
img := tests.GradientImage(size)
|
||||
for _, size := range []int{300, 600, 900, 1200, 1500} {
|
||||
img := gradientNRGBA(size)
|
||||
for _, e := range hashEncoders {
|
||||
b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) {
|
||||
benchEncoder(b, e.encode, img)
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("makeThumbnail", func() {
|
||||
// Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is
|
||||
// what keeps either of them from allocating a converted copy of the thumbnail.
|
||||
It("emits NRGBA when it downscales", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{}))
|
||||
})
|
||||
|
||||
It("fits the longest side to maxSize and keeps the aspect ratio", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
b := makeThumbnail(src, 100).Bounds()
|
||||
Expect(b.Dx()).To(Equal(100))
|
||||
Expect(b.Dy()).To(Equal(75))
|
||||
})
|
||||
|
||||
It("never upscales an image already within bounds", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 40, 30))
|
||||
Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds()))
|
||||
})
|
||||
|
||||
// A fully transparent pixel's colour cannot survive any resample, since the scaler works in
|
||||
// premultiplied space and multiplying by zero is not invertible. Partial alpha is the case
|
||||
// that distinguishes straight storage from premultiplied.
|
||||
It("keeps partly transparent colour straight rather than premultiplied", func() {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 400, 400))
|
||||
for y := range 400 {
|
||||
for x := range 400 {
|
||||
src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128})
|
||||
}
|
||||
}
|
||||
thumb, ok := makeThumbnail(src, 100).(*image.NRGBA)
|
||||
Expect(ok).To(BeTrue())
|
||||
// Premultiplied storage would have halved this to ~100.
|
||||
Expect(thumb.Pix[0]).To(BeNumerically("==", 200))
|
||||
Expect(thumb.Pix[3]).To(BeNumerically("==", 128))
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@ -449,3 +451,41 @@ type countingLocker struct {
|
||||
func (l *countingLocker) Lock() { l.locks++ }
|
||||
func (l *countingLocker) Unlock() { l.unlocks++ }
|
||||
func (l *countingLocker) held() bool { return l.locks != l.unlocks }
|
||||
|
||||
var _ = Describe("makeThumbnail", func() {
|
||||
// Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is
|
||||
// what keeps either of them from allocating a converted copy of the thumbnail.
|
||||
It("emits NRGBA when it downscales", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{}))
|
||||
})
|
||||
|
||||
It("fits the longest side to maxSize and keeps the aspect ratio", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 400, 300))
|
||||
b := makeThumbnail(src, 100).Bounds()
|
||||
Expect(b.Dx()).To(Equal(100))
|
||||
Expect(b.Dy()).To(Equal(75))
|
||||
})
|
||||
|
||||
It("never upscales an image already within bounds", func() {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 40, 30))
|
||||
Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds()))
|
||||
})
|
||||
|
||||
// A fully transparent pixel's colour cannot survive any resample, since the scaler works in
|
||||
// premultiplied space and multiplying by zero is not invertible. Partial alpha is the case
|
||||
// that distinguishes straight storage from premultiplied.
|
||||
It("keeps partly transparent colour straight rather than premultiplied", func() {
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 400, 400))
|
||||
for y := range 400 {
|
||||
for x := range 400 {
|
||||
src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128})
|
||||
}
|
||||
}
|
||||
thumb, ok := makeThumbnail(src, 100).(*image.NRGBA)
|
||||
Expect(ok).To(BeTrue())
|
||||
// Premultiplied storage would have halved this to ~100.
|
||||
Expect(thumb.Pix[0]).To(BeNumerically("==", 200))
|
||||
Expect(thumb.Pix[3]).To(BeNumerically("==", 128))
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,232 +0,0 @@
|
||||
package thumbhash_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/draw"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs
|
||||
// the process to the repo root, which would break a plain relative "testdata" path.
|
||||
var testdataDir = func() string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "testdata")
|
||||
}()
|
||||
|
||||
// loadFixture returns a testdata PNG as tightly-packed NON-premultiplied RGBA, which is what the
|
||||
// reference JS sees when it reads the raw PNG bytes. image.NRGBA is the non-premultiplied type;
|
||||
// image.RGBA would silently premultiply and break every fixture that has alpha.
|
||||
func loadFixture(name string) (int, int, []byte) {
|
||||
GinkgoHelper()
|
||||
f, err := os.Open(filepath.Join(testdataDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
src, _, err := image.Decode(f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b := src.Bounds()
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
|
||||
pix := make([]byte, 0, b.Dx()*b.Dy()*4)
|
||||
for y := range b.Dy() {
|
||||
pix = append(pix, dst.Pix[y*dst.Stride:y*dst.Stride+b.Dx()*4]...)
|
||||
}
|
||||
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"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var golden map[string]string
|
||||
Expect(json.Unmarshal(data, &golden)).To(Succeed())
|
||||
Expect(golden).ToNot(BeEmpty())
|
||||
return golden
|
||||
}
|
||||
|
||||
var _ = Describe("reference port", func() {
|
||||
It("reproduces every golden vector", func() {
|
||||
for name, want := range loadGoldens() {
|
||||
if isHeaderOnly(name) {
|
||||
continue // see the dedicated header-only spec below
|
||||
}
|
||||
w, h, rgba := loadFixture(name)
|
||||
got := base64.StdEncoding.EncodeToString(referenceEncode(w, h, rgba))
|
||||
Expect(got).To(Equal(want), "fixture %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
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")
|
||||
Expect((header16>>3)&63).To(Equal(0), "pScale")
|
||||
Expect((header16>>9)&63).To(Equal(0), "qScale")
|
||||
})
|
||||
|
||||
It("produces 24 bytes for a square opaque image", func() {
|
||||
w, h, rgba := loadFixture("square.png")
|
||||
Expect(referenceEncode(w, h, rgba)).To(HaveLen(24))
|
||||
})
|
||||
|
||||
It("produces 25 bytes when the image has alpha", func() {
|
||||
w, h, rgba := loadFixture("alpha.png")
|
||||
Expect(referenceEncode(w, h, rgba)).To(HaveLen(25))
|
||||
})
|
||||
})
|
||||
|
||||
// referenceEncode is a literal port of evanw/thumbhash's rgbaToThumbHash (testdata/thumbhash.js).
|
||||
// It is the differential oracle and the naive benchmark baseline; the shipped encoder is Encode.
|
||||
func referenceEncode(w, h int, rgba []byte) []byte {
|
||||
var avgR, avgG, avgB, avgA float64
|
||||
for i, j := 0, 0; i < w*h; i, j = i+1, j+4 {
|
||||
alpha := float64(rgba[j+3]) / 255
|
||||
avgR += alpha / 255 * float64(rgba[j])
|
||||
avgG += alpha / 255 * float64(rgba[j+1])
|
||||
avgB += alpha / 255 * float64(rgba[j+2])
|
||||
avgA += alpha
|
||||
}
|
||||
if avgA > 0 {
|
||||
avgR /= avgA
|
||||
avgG /= avgA
|
||||
avgB /= avgA
|
||||
}
|
||||
|
||||
hasAlpha := avgA < float64(w*h)
|
||||
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)))
|
||||
|
||||
l := make([]float64, w*h)
|
||||
p := make([]float64, w*h)
|
||||
q := make([]float64, w*h)
|
||||
a := make([]float64, w*h)
|
||||
for i, j := 0, 0; i < w*h; i, j = i+1, j+4 {
|
||||
alpha := float64(rgba[j+3]) / 255
|
||||
r := avgR*(1-alpha) + alpha/255*float64(rgba[j])
|
||||
g := avgG*(1-alpha) + alpha/255*float64(rgba[j+1])
|
||||
b := avgB*(1-alpha) + alpha/255*float64(rgba[j+2])
|
||||
l[i] = (r + g + b) / 3
|
||||
p[i] = (r+g)/2 - b
|
||||
q[i] = r - g
|
||||
a[i] = alpha
|
||||
}
|
||||
|
||||
encodeChannel := func(channel []float64, nx, ny int) (dc float64, ac []float64, scale float64) {
|
||||
fx := make([]float64, w)
|
||||
for cy := range ny {
|
||||
for cx := 0; cx*ny < nx*(ny-cy); cx++ {
|
||||
f := 0.0
|
||||
for x := range w {
|
||||
fx[x] = math.Cos(math.Pi / float64(w) * float64(cx) * (float64(x) + 0.5))
|
||||
}
|
||||
for y := range h {
|
||||
fy := math.Cos(math.Pi / float64(h) * float64(cy) * (float64(y) + 0.5))
|
||||
for x := range w {
|
||||
f += channel[x+y*w] * fx[x] * fy
|
||||
}
|
||||
}
|
||||
f /= float64(w * h)
|
||||
if cx > 0 || cy > 0 {
|
||||
ac = append(ac, f)
|
||||
scale = math.Max(scale, math.Abs(f))
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
}
|
||||
}
|
||||
// A constant image leaves scale at 0; the reference then skips normalization entirely.
|
||||
if scale > 0 {
|
||||
for i := range ac {
|
||||
ac[i] = 0.5 + 0.5/scale*ac[i]
|
||||
}
|
||||
}
|
||||
return dc, ac, scale
|
||||
}
|
||||
|
||||
lDC, lAC, lScale := encodeChannel(l, max(3, lx), max(3, ly))
|
||||
pDC, pAC, pScale := encodeChannel(p, 3, 3)
|
||||
qDC, qAC, qScale := encodeChannel(q, 3, 3)
|
||||
var aDC, aScale float64
|
||||
var aAC []float64
|
||||
if hasAlpha {
|
||||
aDC, aAC, aScale = encodeChannel(a, 5, 5)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
28
core/artwork/thumbhash/testdata/gen_generated.mjs
vendored
Normal file
28
core/artwork/thumbhash/testdata/gen_generated.mjs
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference.
|
||||
// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same
|
||||
// formula and only the hashes need committing. Run: node gen_generated.mjs
|
||||
import { writeFileSync } from 'fs'
|
||||
import { rgbaToThumbHash } from './thumbhash.js'
|
||||
|
||||
const COUNT = 300
|
||||
|
||||
// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly.
|
||||
const mix = (n) => {
|
||||
n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0
|
||||
n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0
|
||||
return (n ^ (n >>> 16)) >>> 0
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const w = 1 + (mix(i * 3 + 1) % 100)
|
||||
const h = 1 + (mix(i * 3 + 2) % 100)
|
||||
const rgba = new Uint8Array(w * h * 4)
|
||||
for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255
|
||||
// Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7
|
||||
// no-alpha layout as often as the 5x5-plus-alpha one.
|
||||
if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255
|
||||
out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') })
|
||||
}
|
||||
writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n')
|
||||
console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`)
|
||||
1
core/artwork/thumbhash/testdata/generated.json
vendored
Normal file
1
core/artwork/thumbhash/testdata/generated.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -50,9 +50,14 @@ func Encode(img image.Image) ([]byte, error) {
|
||||
aTerms = terms(5, 5)
|
||||
}
|
||||
|
||||
nx := maxCX(lTerms, pTerms, qTerms, aTerms) + 1
|
||||
// The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly.
|
||||
chan5 := 3
|
||||
if hasAlpha {
|
||||
chan5 = 5
|
||||
}
|
||||
nx := max(max(3, lx), chan5)
|
||||
cosX := cosTable(nx, w)
|
||||
cosY := cosTable(maxCY(lTerms, pTerms, qTerms, aTerms)+1, h)
|
||||
cosY := cosTable(max(max(3, ly), chan5), h)
|
||||
|
||||
lAcc := make([]float64, len(lTerms))
|
||||
pAcc := make([]float64, len(pTerms))
|
||||
@ -119,26 +124,6 @@ func terms(nx, ny int) []term {
|
||||
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 {
|
||||
|
||||
@ -2,24 +2,82 @@ package thumbhash_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"math/rand/v2"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"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 {
|
||||
// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs
|
||||
// the process to the repo root, which would break a plain relative "testdata" path.
|
||||
var testdataDir = func() string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "testdata")
|
||||
}()
|
||||
|
||||
// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels,
|
||||
// and RGBA would silently premultiply every fixture that has alpha.
|
||||
func fixtureImage(name string) *image.NRGBA {
|
||||
GinkgoHelper()
|
||||
w, h, pix := loadFixture(name)
|
||||
f, err := os.Open(filepath.Join(testdataDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer f.Close()
|
||||
src, _, err := image.Decode(f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b := src.Bounds()
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// 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 loadJSON[T any](name string) T {
|
||||
GinkgoHelper()
|
||||
data, err := os.ReadFile(filepath.Join(testdataDir, name))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var out T
|
||||
Expect(json.Unmarshal(data, &out)).To(Succeed())
|
||||
return out
|
||||
}
|
||||
|
||||
func loadGoldens() map[string]string {
|
||||
GinkgoHelper()
|
||||
golden := loadJSON[map[string]string]("golden.json")
|
||||
Expect(golden).ToNot(BeEmpty())
|
||||
return golden
|
||||
}
|
||||
|
||||
// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the
|
||||
// same synthetic pixels and only the reference's hashes need committing.
|
||||
func mix(n uint32) uint32 {
|
||||
n = (n ^ (n >> 16)) * 2246822507
|
||||
n = (n ^ (n >> 13)) * 3266489909
|
||||
return n ^ (n >> 16)
|
||||
}
|
||||
|
||||
func generatedImage(i int) *image.NRGBA {
|
||||
w := 1 + int(mix(uint32(i)*3+1)%100)
|
||||
h := 1 + int(mix(uint32(i)*3+2)%100)
|
||||
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])
|
||||
for k := range img.Pix {
|
||||
img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k)))
|
||||
}
|
||||
if i%2 == 0 {
|
||||
for k := 3; k < len(img.Pix); k += 4 {
|
||||
img.Pix[k] = 255
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@ -27,7 +85,7 @@ func fixtureImage(name string) image.Image {
|
||||
var _ = Describe("Encode", func() {
|
||||
It("matches every golden vector", func() {
|
||||
for name, want := range loadGoldens() {
|
||||
if isHeaderOnly(name) {
|
||||
if slices.Contains(headerOnlyFixtures, name) {
|
||||
continue // see the dedicated header-only spec below
|
||||
}
|
||||
got, err := thumbhash.Encode(fixtureImage(name))
|
||||
@ -46,33 +104,46 @@ var _ = Describe("Encode", func() {
|
||||
}
|
||||
})
|
||||
|
||||
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 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))
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the
|
||||
// 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference.
|
||||
It("matches the reference on 300 generated images", func() {
|
||||
vectors := loadJSON[[]struct {
|
||||
W, H int
|
||||
Hash string
|
||||
}]("generated.json")
|
||||
Expect(vectors).ToNot(BeEmpty())
|
||||
for i, want := range vectors {
|
||||
img := generatedImage(i)
|
||||
Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i)
|
||||
Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i)
|
||||
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)
|
||||
Expect(err).ToNot(HaveOccurred(), "vector %d", i)
|
||||
Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H)
|
||||
}
|
||||
})
|
||||
|
||||
It("quantizes a uniform image's scales to zero", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("solid.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
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")
|
||||
Expect((header16>>3)&63).To(Equal(0), "pScale")
|
||||
Expect((header16>>9)&63).To(Equal(0), "qScale")
|
||||
})
|
||||
|
||||
It("produces 24 bytes for a square opaque image", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("square.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(HaveLen(24))
|
||||
})
|
||||
|
||||
It("produces 25 bytes when the image has alpha", func() {
|
||||
got, err := thumbhash.Encode(fixtureImage("alpha.png"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(HaveLen(25))
|
||||
})
|
||||
|
||||
It("downscales an oversized image rather than failing", func() {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 500, 300))
|
||||
for i := range img.Pix {
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// GradientImage builds a deterministic square gradient, so benchmark runs are comparable across
|
||||
// revisions. NRGBA is the type the artwork pipeline's makeThumbnail hands its hash encoders.
|
||||
func GradientImage(size int) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||
for y := range size {
|
||||
for x := range size {
|
||||
img.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8(255 * x / size),
|
||||
G: uint8(255 * y / size),
|
||||
B: uint8((x + y) * 255 / (2 * size)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@ -200,7 +200,8 @@ func (m *MockArtworkRepo) GetInfoForItems(kind model.Kind, ids []string) (map[st
|
||||
if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok {
|
||||
info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash}
|
||||
if a, ok := m.Data[ia.Hash]; ok {
|
||||
info.BlurHash = a.BlurHash
|
||||
info.BlurHash, info.ThumbHash = a.BlurHash, a.ThumbHash
|
||||
info.Width, info.Height = a.Width, a.Height
|
||||
}
|
||||
res[id] = info
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ const header = (bytes) => {
|
||||
lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7),
|
||||
ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit),
|
||||
aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1,
|
||||
aScale: hasAlpha ? bytes[5] >> 4 : 0,
|
||||
aScale: hasAlpha ? (bytes[5] >> 4) / 15 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ const cosTable = (n, size) => {
|
||||
const table = new Float64Array(n * size)
|
||||
for (let c = 0; c < n; c++) {
|
||||
for (let i = 0; i < size; i++) {
|
||||
table[c * size + i] = Math.cos(((Math.PI / size) * (i + 0.5) * c))
|
||||
table[c * size + i] = Math.cos((Math.PI / size) * (i + 0.5) * c)
|
||||
}
|
||||
}
|
||||
return table
|
||||
@ -84,7 +84,7 @@ export const decode = (hash, width, height) => {
|
||||
const lAC = channel(h.lx, h.ly, h.lScale)
|
||||
const pAC = channel(3, 3, h.pScale * 1.25)
|
||||
const qAC = channel(3, 3, h.qScale * 1.25)
|
||||
const aAC = h.hasAlpha ? channel(5, 5, h.aScale / 15) : []
|
||||
const aAC = h.hasAlpha ? channel(5, 5, h.aScale) : []
|
||||
|
||||
const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3)
|
||||
const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user