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:
Deluan 2026-07-31 18:25:39 -04:00
parent b13e8d7057
commit df301ae1a8
13 changed files with 209 additions and 390 deletions

View File

@ -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
}

View File

@ -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() {

View File

@ -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)

View File

@ -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))
})
})

View File

@ -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))
})
})

View File

@ -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
}

View 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}`)

File diff suppressed because one or more lines are too long

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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)