From df301ae1a823a1e69e87abb97857c42b68d143b6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 31 Jul 2026 18:25:39 -0400 Subject: [PATCH] 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 --- core/artwork/benchmark_helpers_test.go | 9 + core/artwork/blurhash/blurhash_test.go | 39 +-- core/artwork/hash_encoder_bench_test.go | 9 +- core/artwork/processor_internal_test.go | 47 ---- core/artwork/processor_test.go | 40 +++ core/artwork/thumbhash/reference_test.go | 232 ------------------ .../thumbhash/testdata/gen_generated.mjs | 28 +++ .../artwork/thumbhash/testdata/generated.json | 1 + core/artwork/thumbhash/thumbhash.go | 29 +-- core/artwork/thumbhash/thumbhash_test.go | 133 +++++++--- tests/images.go | 23 -- tests/mock_artwork_repo.go | 3 +- ui/src/utils/thumbhash.js | 6 +- 13 files changed, 209 insertions(+), 390 deletions(-) delete mode 100644 core/artwork/processor_internal_test.go delete mode 100644 core/artwork/thumbhash/reference_test.go create mode 100644 core/artwork/thumbhash/testdata/gen_generated.mjs create mode 100644 core/artwork/thumbhash/testdata/generated.json delete mode 100644 tests/images.go diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 0076506f3..f3201d412 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -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 +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go index 8cc100b81..91187165e 100644 --- a/core/artwork/blurhash/blurhash_test.go +++ b/core/artwork/blurhash/blurhash_test.go @@ -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() { diff --git a/core/artwork/hash_encoder_bench_test.go b/core/artwork/hash_encoder_bench_test.go index b2cda4215..2bc4b90ba 100644 --- a/core/artwork/hash_encoder_bench_test.go +++ b/core/artwork/hash_encoder_bench_test.go @@ -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) diff --git a/core/artwork/processor_internal_test.go b/core/artwork/processor_internal_test.go deleted file mode 100644 index 9165bfaf3..000000000 --- a/core/artwork/processor_internal_test.go +++ /dev/null @@ -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)) - }) -}) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 14f516d58..d7ef13bfc 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -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)) + }) +}) diff --git a/core/artwork/thumbhash/reference_test.go b/core/artwork/thumbhash/reference_test.go deleted file mode 100644 index e31c24677..000000000 --- a/core/artwork/thumbhash/reference_test.go +++ /dev/null @@ -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 -} diff --git a/core/artwork/thumbhash/testdata/gen_generated.mjs b/core/artwork/thumbhash/testdata/gen_generated.mjs new file mode 100644 index 000000000..e8eae585e --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_generated.mjs @@ -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}`) diff --git a/core/artwork/thumbhash/testdata/generated.json b/core/artwork/thumbhash/testdata/generated.json new file mode 100644 index 000000000..1b49bbb03 --- /dev/null +++ b/core/artwork/thumbhash/testdata/generated.json @@ -0,0 +1 @@ +[{"w":28,"h":79,"hash":"4AcCCgBFgGVnc6OLh2u/b7Y="},{"w":94,"h":86,"hash":"4PeBBYAIBZlXeaVKmwl6Zx+i9EeEUqdlGA=="},{"w":61,"h":88,"hash":"3/cBBQCxf6l3ioV1WadpmG+B7Pm4"},{"w":29,"h":41,"hash":"H/iBDAIHX5Chbnyqawdo8otij7N3a0qJBA=="},{"w":63,"h":74,"hash":"3wcCBgB0x884dZeDiopMhclYDUpUAm4="},{"w":29,"h":25,"hash":"HwiCBIIHF79zi6+Gg2fAhk/4fEtnapaYDQ=="},{"w":57,"h":98,"hash":"H/gBBABIejmXynwHHoVAnvdXSw=="},{"w":71,"h":69,"hash":"IPiBBYAHmD2KUW/lQQyLaLCDh0KklFn32g=="},{"w":71,"h":50,"hash":"3/cBBYBohqhLaJpwZoZUd011gBCJ"},{"w":89,"h":83,"hash":"4PeBBYAHkBp+Q/bXx0CSiZ+sT4Z4fIyYZg=="},{"w":92,"h":57,"hash":"IPgBBICgJdaPlJqBnUfQcGbwdA=="},{"w":39,"h":95,"hash":"3/eBAgAIymeYlAuWDQ9RUidjZat7jxk="},{"w":100,"h":82,"hash":"4AcCBoDiuKh2N+TrR6fh9nFm+5lp8Kc="},{"w":92,"h":93,"hash":"HwiCBQAIuI2JaEYDVRA6gU/q5PxIULPapw=="},{"w":67,"h":90,"hash":"IPgBBQC1bGaGk47Hl3+nddp6vwuY"},{"w":78,"h":71,"hash":"HwiCBYAHmZZIYn9JhXCLjZaQSWV2ZUGrfw=="},{"w":85,"h":42,"hash":"4AcCA4KeKZCAlcZzkAz7i6k="},{"w":8,"h":50,"hash":"3/eBCQAH+IVwSkj7IiZPL/h0dYpCSEg="},{"w":13,"h":78,"hash":"HwgCCQJGjqtkmUowdno/3fY="},{"w":66,"h":95,"hash":"HwiCAwAHCjuAqh2Fn/H9K5loYGiYmJU="},{"w":2,"h":84,"hash":"nvcFEQJ7c7iHmKwJj5iHCGA="},{"w":37,"h":32,"hash":"IAiCBIIIldBHeXKZ6f3EA2DV0K2GZkvDBA=="},{"w":41,"h":25,"hash":"3wcCDII7Jst5c0WFhga8cEnTBg=="},{"w":57,"h":26,"hash":"IPiBAoAIuvRneZfgjPSmAhZrl9W8SPU="},{"w":31,"h":40,"hash":"IPgBDQK6o7REKkCxJ1cXRlNqsK+B"},{"w":7,"h":69,"hash":"3veBCQIHdoKrFgYOxqU5cAVYqapmgjQ="},{"w":82,"h":59,"hash":"3/cBBYD4M6tIe6iJenZX0jv3i6QF"},{"w":85,"h":48,"hash":"IAiCA4AHyKR2lgvPG0TFBnXQWGkydzw="},{"w":53,"h":67,"hash":"HwgCBgKbVol5V393ersDOea29bSlZPk="},{"w":79,"h":26,"hash":"IAiCAoAI6QZtSGnvJmnwcJiHZquG+Hc="},{"w":14,"h":28,"hash":"3/cBDAJIhLeYi1+pu1owyIVvMg=="},{"w":85,"h":78,"hash":"4AeCBYAHXPhldaZ2ibVPVQmKh/iJlZQrig=="},{"w":26,"h":67,"hash":"H/gBCwI4T6OnR3GPhq81kGo="},{"w":51,"h":13,"hash":"IPiBCYII24eGC4VNfYAJR/S6V8WLeyg="},{"w":57,"h":81,"hash":"4AcCBQCoojSIY0qYe3f3UQGuawz+"},{"w":8,"h":72,"hash":"4PeBCQIHSUVYVn8ANrQoB3UWWYiXWbA="},{"w":89,"h":40,"hash":"IAgCA4LDe0AG5hb2aM8bcEM="},{"w":55,"h":94,"hash":"3/eBAwAHpIVmT7VvjwahW2NPhkiaDok="},{"w":59,"h":81,"hash":"H/gBDQBERoZIo4nKhqgKaJYvSkVA"},{"w":41,"h":45,"hash":"3/eBBQAHdeyYm3UGRR99adcGQ0RwRZpGmA=="},{"w":11,"h":28,"hash":"HxgCCwLHZnhGlGVggoAJ6GU="},{"w":8,"h":36,"hash":"4PeBCQIIbWHpldBF78rO/EKZaaiAqHo="},{"w":40,"h":100,"hash":"IAgCAwAIT2uoiESGs1BDrxw="},{"w":63,"h":33,"hash":"3weCA4IHmrEpmk9KVK92+LOxI+sLqfs="},{"w":72,"h":63,"hash":"HwgCBoCYlQZkaHeXaeabRJit12VwH7Y="},{"w":29,"h":90,"hash":"3/eBAgAIzqlo+KRtoAl1qh2KO7ZwWWo="},{"w":81,"h":26,"hash":"IPgBAoJVdauz6giWSnhgCYk="},{"w":92,"h":76,"hash":"IPiBBIAHrET8bFvpCZQdZQubVvO0vEV5DQ=="},{"w":85,"h":99,"hash":"3wcCBgB4L9nVxUhpWUyqb76n4LjPOfw="},{"w":79,"h":72,"hash":"4AeCBYAHWneox5/01wN7aHnvSAZscNB3qA=="},{"w":76,"h":18,"hash":"4PcBCoL6mZdmelSFz5UU9EQ="},{"w":78,"h":90,"hash":"4PeBBAAIDnhKoKRQpgBqMJy+iar8L4pZDg=="},{"w":72,"h":13,"hash":"3+cBCYKsdkJZyx6/lICO+8I="},{"w":78,"h":19,"hash":"H/iBAYAImX9hwHSeDXImENV3UIN1hMc="},{"w":29,"h":34,"hash":"IAgCDgB+WIp4KreKyhKJX6mqKKCvEI0="},{"w":63,"h":43,"hash":"H/iBA4AI++eiSIlZkLl/ZwKZhrlnSqg="},{"w":19,"h":79,"hash":"3wcCCgD1k3t0hnYqBHm6Di4="},{"w":82,"h":44,"hash":"3/eBA4AIqi2AjW6pr5YLmwF+OlNou7s="},{"w":70,"h":24,"hash":"3wcCCoAyRWCKl2Z3kHa6A1o="},{"w":28,"h":93,"hash":"4PeBCgAHwrsHVqZgqIePdy6kkJh4RRY="},{"w":44,"h":28,"hash":"H/gBBIJ2xMevRKKpZ4bnUTD6hQ=="},{"w":77,"h":9,"hash":"HwiCCYIIxixdZgmgw/m6OIcDrDM6WZ0="},{"w":59,"h":39,"hash":"H/gBBYKlW6qoh11be/NmovTQCbYc"},{"w":9,"h":93,"hash":"4PeBAQIHFVn4p5mIv/DH95KWbUXgUOk="},{"w":9,"h":96,"hash":"3wcCAQKKqluVczewLNPP0G8="},{"w":54,"h":83,"hash":"IPiBAwAIZVRaj5dvnH4NiFfGpfFPxjg="},{"w":75,"h":3,"hash":"3/cFEYR4iPWKmIiV99OsmPc="},{"w":96,"h":42,"hash":"4PeBAoAHcJqRe3qfQ6f3THWIkFi2ivY="},{"w":74,"h":8,"hash":"4PcBCYJ8JYC0NJh5+a5HnaA="},{"w":2,"h":95,"hash":"oPeBCQR3f82Im7eL3/dIdXgHiHiHiHc="},{"w":40,"h":25,"hash":"3/cBDIJGPXu1PFWvGyI2SH8JWA=="},{"w":48,"h":56,"hash":"H/iBDAAHU4OHDHmKpoT1qngAiHZWl2d4CQ=="},{"w":71,"h":18,"hash":"3/cBCoBFzceDtlBaL20Z8Ok="},{"w":60,"h":44,"hash":"3/eBBIAHqreoZmb9ePMT+YS4hIZZmncHCg=="},{"w":42,"h":25,"hash":"3/cBDIKPdrNMd9eNmFlSn7SZCw=="},{"w":64,"h":67,"hash":"IPiBBQAHbSpXaJBWxEgLybIIa6jFdgxmsw=="},{"w":74,"h":85,"hash":"4AcCBgCVe0R0W1l7eTSq0/dH+zScBSU="},{"w":42,"h":71,"hash":"4AeCAwAIZyCedbhPdZlPiaKXHQRSNeA="},{"w":61,"h":34,"hash":"H/gBBII/nL94LK58lLTVCUz9Jw=="},{"w":26,"h":66,"hash":"4AeCCgAISob0BHp4m1BvNhVnCiiFAtM="},{"w":17,"h":62,"hash":"IAgCCgI8jI9nEofooGprka8="},{"w":58,"h":90,"hash":"IAiCAwAI+Rt0bsP6o/o02aef81rcY4g="},{"w":41,"h":96,"hash":"H/gBAwDLuolgvElQHQppYPY="},{"w":57,"h":6,"hash":"IPiBCYIIh0q8dn/PhQBnd8ApWKNbdKo="},{"w":38,"h":47,"hash":"4PcBDgI6lpeRR2aABrhXvWW0UNaEIHg="},{"w":76,"h":6,"hash":"3xeCCYAISIuiIk91a6CpEKQVvUdsBYc="},{"w":85,"h":66,"hash":"H/gBBYDvamWWd5h1Vo2YlvdTB6/w"},{"w":7,"h":52,"hash":"4AeCCQII+ZuGYmj6XPaXdqa3B0a2dNA="},{"w":1,"h":4,"hash":"Hhk+UjgIh2iYh4iYGIuPh/g="},{"w":19,"h":26,"hash":"3weCDAIHY2JJumCmuqb1ToC4Yy94dxtUBw=="},{"w":63,"h":98,"hash":"3/cBBQBSUpp6Kn2Gnn+ikgXX5EBM"},{"w":36,"h":27,"hash":"IPiBBIAHh6xAemiM6Of5b5+q+XOJtpUwBg=="},{"w":69,"h":50,"hash":"IAgCBYK2ilWImIhgd8ikFPeoVvSn"},{"w":17,"h":100,"hash":"HwiCAQAI8el0tLUJG2OAFon6I0mAZ8U="},{"w":59,"h":24,"hash":"4AcCC4Inv4c4e2OIYDUGV7g="},{"w":57,"h":48,"hash":"3/eBBIAHBXh6Y4qllpB1ryKBqvqpSomhDA=="},{"w":90,"h":41,"hash":"IAgCC4CidBWGNweJm6+coBY="},{"w":68,"h":3,"hash":"oBiCCYII8hsmqlAgxcXwJHh2mAVZhWc="},{"w":77,"h":25,"hash":"4PcBCoL0iEqFdXO2zo+n9Eo="},{"w":50,"h":41,"hash":"HwiCBIAHSK9XhZbW6qiAX4j+o1Yn5JPmBA=="},{"w":95,"h":87,"hash":"3/cBBoBbqfUutpKElbmKm4B0C4I0ePA="},{"w":19,"h":7,"hash":"H/iBCoIYmUl/V1VESZ+mA6eLeHenBnY="},{"w":62,"h":28,"hash":"3wcCC4JqV4G1jAeTn3ttsvs="},{"w":80,"h":84,"hash":"HwiCBQAItaKauK+3WPxqSRf6TQlTiYgs1w=="},{"w":58,"h":11,"hash":"IPgBCYJWdVboUvKJ+nc6wI4="},{"w":62,"h":1,"hash":"oRjCKYZ3iIiIiHCXiJCLCIeIiIiAd/g="},{"w":83,"h":84,"hash":"IAgCBwCXsHOreqRrykpmV4t8lq9nsqEP"},{"w":38,"h":50,"hash":"HwiCBAAIMJSGqHiF+EsGGyZ/MLJmynBjAA=="},{"w":90,"h":25,"hash":"4AcCAoAmQEenlsuKgE4WyvY="},{"w":44,"h":34,"hash":"4AeCDIAIlUgqYFOZB1WIemC1gMKUhIq2Bg=="},{"w":44,"h":58,"hash":"4AcCDQJ1xMubx4YIEjhy2ct7H/IW"},{"w":53,"h":97,"hash":"3/eBAwAIfvkuWL5QdinGj6Tb3EAkpKk="},{"w":46,"h":78,"hash":"3wcCBAD69gtnSeOmSQvGoFC4QA=="},{"w":1,"h":91,"hash":"Xui9GQR3CIiHeIgIiXhfqAj3h4iHiHg="},{"w":62,"h":53,"hash":"HwgCBoDPnGynWGlHeHhaWbZ4ymtwP7k="},{"w":31,"h":16,"hash":"HwiCC4IHYMrnVnx9T5138ke3RXBlSHs="},{"w":79,"h":89,"hash":"3wcCBgCwS65nmVZ1Z3pFOze0G4xfP4U="},{"w":88,"h":46,"hash":"HwiCA4AIeft1qUicgPivQEqYkHp7hEY="},{"w":42,"h":42,"hash":"IAgCBwJd0ybqWZCUd6pdbkhHCZnq9ZoE"},{"w":33,"h":75,"hash":"4AeCAgAIin9mWYoCq51AulUCcmlfBB0="},{"w":74,"h":1,"hash":"3ic+AYp4h4eIiAiHD4hcjQg="},{"w":55,"h":35,"hash":"4AeCA4AIeIClN5NyENOgNZyaiIljBmw="},{"w":2,"h":11,"hash":"HlgKIQZwiHh0aZdocCUKd0w="},{"w":29,"h":33,"hash":"IPiBDAIH9lRItpqqd6CWmKAo+ad49JdoCg=="},{"w":63,"h":40,"hash":"HwgCBIBmUIs2Vol2OVgfOEvSHw=="},{"w":77,"h":90,"hash":"4PeBBAAINniEP5lGAIykwV9XCmS5VmjHCg=="},{"w":78,"h":14,"hash":"3wcCCYBGGoFHx5agj3xrR+8="},{"w":37,"h":40,"hash":"4PeBBQAHy1gEeihmdftWmwDn5DhniHAnSA=="},{"w":79,"h":27,"hash":"H/gBCoCc92ZmanaKgGcGl5k="},{"w":32,"h":86,"hash":"3weCAgAHW6NocGpT+AjOVShetbR/aso="},{"w":14,"h":30,"hash":"H/gBCwLNrIeFxaZPd6YvX1o="},{"w":33,"h":30,"hash":"oAeCBYIHarRwplZVhZj915j1dwd5Y352aQ=="},{"w":62,"h":95,"hash":"H/gBBQB5kGdZ1udkd3lJiV2PbAVc"},{"w":38,"h":93,"hash":"3weCAgAH5D7FRwOFXbAZ+fVpeYk0iHY="},{"w":47,"h":32,"hash":"3/cBDYJ0r1iqSJRkd6p3V3s/a9B/"},{"w":24,"h":9,"hash":"3weCCoIXijxlaV+Eb3RI9odfWVeFVEo="},{"w":17,"h":54,"hash":"4PcBAgSYZHWYgGhbIwMCqHU="},{"w":8,"h":78,"hash":"X/iBAQIIiFJTAjdPiZYEl2PU18bU8nQ="},{"w":8,"h":29,"hash":"HvgBCgYccKvOOXhJ20mAnyg="},{"w":90,"h":54,"hash":"H/iBA4AIb+2Md4RWn/Kfe/FJlyZ1jaQ="},{"w":88,"h":16,"hash":"H/gBCYBnc+aN1jVQYFe1Ov4="},{"w":38,"h":43,"hash":"IAiCBAAHzaaMBvyTQkB3gJ94bziEt4h4AQ=="},{"w":45,"h":33,"hash":"H/gBDYLClnt/2qJ5pLuZZpLzfW+Z"},{"w":9,"h":54,"hash":"3weCAQIIGp+aj3Wd9/GWhYjzxmlbRNg="},{"w":97,"h":47,"hash":"HwgCC4DRtDd8iLn5CHJ3Dnc="},{"w":69,"h":50,"hash":"HwiCBIAHyWepeKMEBzTqh8/5k21oaT90Bw=="},{"w":13,"h":65,"hash":"3/cBCQJpo8dpn410PLfAMvg="},{"w":25,"h":30,"hash":"IPiBBAIHqzf2RJS69XfSTG8Ld2qHiYe2Bw=="},{"w":35,"h":77,"hash":"IPgBAwC/xkaVdZYoy6K/wGU="},{"w":71,"h":99,"hash":"3/eBBAAI8jHjH4eYVgrLOHcQSAdCRrQ3Cw=="},{"w":51,"h":61,"hash":"HwgCDgB4ioRm1K+IiVmHolm68zhYANY="},{"w":99,"h":81,"hash":"4AeCBIAH/JSLl4h51voehr91sfZoWH5pBw=="},{"w":58,"h":89,"hash":"4AcCBQAN9jWNunlhuGOXV/qTrGQG"},{"w":92,"h":16,"hash":"H/iBAYAHzWadOEC11RAUXzefbZKQ2lw="},{"w":20,"h":9,"hash":"3vcBC4R6lm+5cB20+YmS1Ag="},{"w":87,"h":10,"hash":"3+eBAYII7ZVjl0/0dZfQebdwhKU8pfo="},{"w":59,"h":3,"hash":"nwcCCYIU+4fttp4sIM3/v4Y="},{"w":13,"h":39,"hash":"H/iBCgII915YU3MIrA+InAykfK6VeHk="},{"w":22,"h":87,"hash":"3/cBCgJ3MMpXK6a/3GXAf2Y="},{"w":20,"h":61,"hash":"H/iBCgIHcMhmxIC4f1uGBYYJhDl0p9c="},{"w":66,"h":33,"hash":"HwgCBIIVdWCYY5inCWn6Zwzbhg=="},{"w":24,"h":56,"hash":"HwiCAgAIV60ZefWzRA93WHxypWdW1wc="},{"w":36,"h":4,"hash":"HvgBGYaQohaH1cJad41gsFs="},{"w":93,"h":76,"hash":"IPiBBIAIcF8CZi2l97tl7aDCr7ubuXQ4Bg=="},{"w":50,"h":49,"hash":"IAgCB4K8Q4h4hHd5l/iUSzW62DWwG5AE"},{"w":89,"h":33,"hash":"H/iBAoAItPphyoZ4+FLXCCappHhPZLM="},{"w":88,"h":38,"hash":"4PcBA4Dgi0XjhoOT8obwtio="},{"w":92,"h":5,"hash":"4PeBAYII9+Mpe4oUCPQYNXqZtjd56L8="},{"w":16,"h":43,"hash":"HwgCCwLaREjvVTaWYGcna/o="},{"w":43,"h":25,"hash":"H/iBC4IIh7kDmbD1XptgRBwqiJg/xcs="},{"w":23,"h":15,"hash":"IAgCFYLE742XWmeuhhpXpJRT8NAa"},{"w":81,"h":7,"hash":"IPiBAYII/XuJaMxPhJlP16ewLUeXWQQ="},{"w":43,"h":10,"hash":"4PcBCoLS3wRpnEkUcvkwb3Q="},{"w":40,"h":31,"hash":"4PeBBIAHbvoid+O8vCBou5CEhcBWSHukBg=="},{"w":2,"h":54,"hash":"XggCGQh2P1eHhUhzil+FCIM="},{"w":81,"h":51,"hash":"HwiCA4AIXq5fhicHMAp5mmqAO6paeLQ="},{"w":66,"h":34,"hash":"3wcCBIKiaF02Rp91WXV/yMDyNw=="},{"w":82,"h":74,"hash":"4PeBBYAIuB98SKHF1VCErjT2iqiJh49Jlw=="},{"w":68,"h":37,"hash":"H/gBBICMWJmAh1d2Z4isj9tQYw=="},{"w":97,"h":1,"hash":"nxe+GYR3d3iIiIBYh8+FCId4iIiAd/g="},{"w":35,"h":74,"hash":"IAgCAwJaeHZYpGRvC0sBaJg="},{"w":84,"h":16,"hash":"4AeCAYIHtlwCmBe5QIZI/dVrfKfVQJM="},{"w":25,"h":39,"hash":"HwgCDAJR+5x4R7xqGLm9dQAwSA=="},{"w":7,"h":89,"hash":"3weCAQIH71t0jh3R8bisB2CGlqVZa/w="},{"w":11,"h":93,"hash":"4PcBEQIIiUqUVniGpkCGDmY="},{"w":28,"h":16,"hash":"3/eBC4AHZgaLiVrzW/ntHXFCuDtPelc="},{"w":47,"h":1,"hash":"4OdBGYaHiHiIiAh4+ojcfPg="},{"w":83,"h":79,"hash":"4PeBBYAHWU/JlHJmlyqL8DdUi9V3hwhJZQ=="},{"w":31,"h":58,"hash":"3/cBDACMjykH1lXXk4f1m1lXwA=="},{"w":87,"h":27,"hash":"3/eBAoAIWAuadHnSkGf6ZDCpWDbDVHk="},{"w":16,"h":25,"hash":"HxgCDALVgxjVRLTpv0gXoIL7lg=="},{"w":95,"h":6,"hash":"3/eBAYAHNhipdw3ol29wPIUrqUSnB0Y="},{"w":40,"h":79,"hash":"3/cBBACXn5bth4n5V0ifeYb4CA=="},{"w":37,"h":88,"hash":"3/eBAgAHatYMrGagPXqAUTT0aZlkV6k="},{"w":7,"h":64,"hash":"H/gBCQI1U92VyEzQtwv3UiI="},{"w":73,"h":44,"hash":"4AeCA4AI27s6T2uqZi+wyMCreGFnrNs="},{"w":13,"h":53,"hash":"H/gBCgKVPabFiVmfjzmJPgc="},{"w":26,"h":88,"hash":"4AeCAQAH+JrYmobr0Ntl9nJ0DUiHpaU="},{"w":92,"h":96,"hash":"H/gBBwDjZwxVe4qVdpSGh3maxpnPRPAB"},{"w":22,"h":93,"hash":"IPiBAQAIem4GeFLLT5awR4mPYXKA5Tc="},{"w":11,"h":63,"hash":"3/cBCQIEtyd3dmiLRl8zsW8="},{"w":1,"h":16,"hash":"IUbCgRB3CIiHeHj3iIhgOAj3homHiJg="},{"w":86,"h":54,"hash":"3wcCBIBmpUU+NtE5BapGT91XLw=="},{"w":45,"h":45,"hash":"3/eBBQAIjRSWIMBjFweKEy/f4ohbs78Dtw=="},{"w":86,"h":72,"hash":"IPgBBoDpdbynqIevhmp4hEio9NP2gBk="},{"w":56,"h":61,"hash":"HwiCBQAH/ErFesNrqQBrC8foSKc/e7px+A=="},{"w":36,"h":82,"hash":"IPgBAwDXSB9klzdqJkDSCJM="},{"w":19,"h":26,"hash":"IPiBBAIHimHC+WqsTSHQA8CFXPR1aWR7Bw=="},{"w":63,"h":30,"hash":"4AcCC4I3YoJa+ltaWY+Hed8="},{"w":22,"h":67,"hash":"H/iBCgAIdFg69+dzCKkNMJDF6aaPSyc="},{"w":7,"h":4,"hash":"XggGJIgRdYiZWVxIaK+wffh5Sg=="},{"w":65,"h":44,"hash":"H/iBA4AI2jgH3cmnUEkHdX5wtrmFaHQ="},{"w":97,"h":19,"hash":"HwgCCYBPdSVmN45n94dKaAM="},{"w":67,"h":11,"hash":"HwiCCYIImJywpza6bza/d0WndXmo+Dc="},{"w":83,"h":76,"hash":"3wcCBoAIXnqYdVekp+pHk3pn3blfP2g="},{"w":80,"h":98,"hash":"IAiCBAAI39GbiZdfdejP/6+SXIhJSPW3BA=="},{"w":72,"h":86,"hash":"3/cBBgKS+2i4h4h1a46YhnlKwb9G+qc="},{"w":78,"h":29,"hash":"3weCAoAIY2hKCQcA2gp4mpvrhouJX2c="},{"w":91,"h":1,"hash":"IfhBEYZ3iIeIiAiIB4j1i4g="},{"w":68,"h":99,"hash":"IAiCAwAIvewBFkMMqfxbLHqvcVbUZms="},{"w":79,"h":4,"hash":"4AcCCYSbW4Z9nYBL6TpgcE0="},{"w":80,"h":44,"hash":"IAiCA4IHxb+flWn5zAqJiPi0z6Vxk5g="},{"w":43,"h":36,"hash":"4PcBDoC6Qb9xjJWgeX5rc6hoklAaxH8="},{"w":61,"h":21,"hash":"4PeBAoAHoJ2KVFQu/C75JX+S/veGdqI="},{"w":23,"h":93,"hash":"4AcCCgKo/IiJRxNo2ZmfDd0="},{"w":96,"h":65,"hash":"HwiCA4AIgHXGaTw2+itpEHn4d5dxd6c="},{"w":90,"h":27,"hash":"IPgBCoBhgm/V6idYX+m39Sg="},{"w":81,"h":31,"hash":"3weCAoAHn6dXpYipr98KiLRmcGlrmIc="},{"w":73,"h":41,"hash":"3/cBBIA25bcwaFfYQdYCSqbxlg=="},{"w":91,"h":68,"hash":"3/eBBIAI0Jd2OHJIevfJgHx8NNZGpwl/CA=="},{"w":94,"h":74,"hash":"IPgBBoBvTGmIeYhay4x3Z3U3pn8gD0I="},{"w":72,"h":34,"hash":"3weCAoAHQIe83bnPpAqzRjjYpIKvlzc="},{"w":51,"h":34,"hash":"HwgCBYA6fzbdPH26bIaJuMpavwGt"},{"w":55,"h":33,"hash":"H/iBA4AHNLA1nUbPzZ4NNVOQpXiZ11k="},{"w":92,"h":20,"hash":"4PcBAoL2t02KyoishKT/vWc="},{"w":1,"h":94,"hash":"Hvi9CQKICId4iIgYj3tPOAj3h4iHiHg="},{"w":46,"h":34,"hash":"HwgCDYCYTefPhieK2bw3PMCa2Lrw"},{"w":64,"h":30,"hash":"IAiCAoAHTDx/WamEcJBD9saIKnufjmc="},{"w":36,"h":19,"hash":"HwgCDIKHuMmduHeamoBgk/PTeg=="},{"w":90,"h":94,"hash":"IAiCBQAHZy+dmah1KFYwiwVXfURlxoBptQ=="},{"w":89,"h":49,"hash":"H/gBBIBpkIl5rJU5yJrcT/x9UQ=="},{"w":34,"h":2,"hash":"Y8iBCYSIVbsJgXeT+wc2iYeHh4eHiAc="},{"w":25,"h":40,"hash":"3wcCDAKkNHd3x4wKpngw3Pa3Cw=="},{"w":47,"h":32,"hash":"IPiBA4AHl3CHY4VS/WGvOem9n4xZNiY="},{"w":53,"h":96,"hash":"4PcBBAB4zFkHVnZ5Vnb+cQoyqA=="},{"w":47,"h":93,"hash":"4PeBAwAHovPYd5nJhr9y84volUge9qQ="},{"w":86,"h":89,"hash":"4AcCBwIxnclaVpZmhwuXeIqsOUOABWQF"},{"w":40,"h":90,"hash":"IPiBAgAIzWDGoI4lL8cOkpTyeDiGmHY="},{"w":38,"h":66,"hash":"H/gBBAK5wb10WqSEx3BqgHasQA=="},{"w":12,"h":21,"hash":"4AeCAwIHU5iHp/TaqbBfi7ypznOSRIA="},{"w":54,"h":29,"hash":"3/cBBILTCdd3yod7eIVwrqfOnw=="},{"w":78,"h":77,"hash":"3weCBYAIfO9WhGEMTcBzWAd3QJc6hLVohw=="},{"w":88,"h":96,"hash":"3/cBBgCfiF9LQRun2ME2UJ5yuGC1Dgg="},{"w":49,"h":89,"hash":"4PeBAwAIKc/XsRei+QLKXFcyRHdFSvM="},{"w":19,"h":41,"hash":"3wcCCwKIj4pZgGhJXAfKXfQ="},{"w":70,"h":53,"hash":"IPiBBIII2CpoSAcGTfhW94S4b3aFdlqmBQ=="},{"w":55,"h":57,"hash":"4PcBBwAGraR0aVs6blrol9aYrApGsBgG"},{"w":54,"h":29,"hash":"3/eBC4AHyLBqhpD4VxQX9hqwzY9DuVg="},{"w":15,"h":75,"hash":"HwgCCQKJ9qeHapW2EDvPhPc="},{"w":93,"h":81,"hash":"H/iBBIAHC2WedpVnfngIVnX/YBI7RopLBw=="},{"w":64,"h":96,"hash":"4AcCBQCMiJ5GnFu6rDv1yHqfSLQP"},{"w":76,"h":44,"hash":"IPiBA4AHqQeWWI2puG+/SsVZfH+EKFc="},{"w":88,"h":33,"hash":"IAgCA4L4N5JQnp1odH8Id50="},{"w":1,"h":34,"hash":"Xue9MQaICIh3iHj3h3WfiAj3h4iId4g="},{"w":30,"h":4,"hash":"IOgFGYa2AneplIqU+YGncKc="},{"w":92,"h":76,"hash":"HwiCBIAHKrOomZBJlckKC2XFT54JnMSLCA=="},{"w":91,"h":16,"hash":"IPgBCYLldw2ryLmLYHjxVkk="},{"w":3,"h":26,"hash":"3yeGCQIYiY/WpUpL9DmJ/2WYd4anCrk="},{"w":2,"h":14,"hash":"YNcFCQ56SgeK1ld+hvp2+FY="},{"w":93,"h":90,"hash":"IPiBBYAHlR+Hm5gJyPdlAqqImTuPWXlFeA=="},{"w":47,"h":88,"hash":"3/cBBAALd4h3lHZpeIlfs6NTDQ=="},{"w":35,"h":32,"hash":"4AeCBYIHdgJPGVxze8rQrvpIvplllljXBw=="},{"w":38,"h":5,"hash":"4AcCCYRTKNxEawW3UJsl/5Y="},{"w":8,"h":78,"hash":"H/iBCQIHjIUIg8cEM2XQxWp6oKdah5A="},{"w":39,"h":36,"hash":"3/cBDoJQZreZx2RPmKWGpxpkSXA6/EM="},{"w":55,"h":13,"hash":"HwiCAYAHfuUceQxcnO/ZMFE234F3aZY="},{"w":13,"h":56,"hash":"IAgCCgJ7gviHc4ur2H4A1gI="},{"w":26,"h":22,"hash":"3weCBIIHx2iLhiCf+OrZYBToaS9I6Zd1BA=="},{"w":83,"h":19,"hash":"3wcCCoL5dJtjKLdzdZvA/G0="},{"w":7,"h":9,"hash":"IRiCDAQXc8cFtlkd8SmVQIVf+rcJWDm7BA=="},{"w":90,"h":43,"hash":"4PcBA4BWXGyC3oWPkJfDrgo="},{"w":43,"h":77,"hash":"IPiBAwAHhT8asHZ/VIr3tMogVkNnm1g="},{"w":36,"h":27,"hash":"IPgBBYKokWWJhMqLlilFBLsGpL/F"},{"w":86,"h":62,"hash":"3weCBIAHsSsGVZlmSOT8f6tyeRCPwHh6DA=="},{"w":63,"h":89,"hash":"IAgCBQC69pVJd3nafFOo1Xr893oE"},{"w":69,"h":98,"hash":"4AeCBAAHidVTQLuXiQbeVJ9H32iHhUaHAg=="},{"w":12,"h":58,"hash":"HwgCCQKZX0iHh3rGb6X1Nec="},{"w":7,"h":7,"hash":"YPeFFQYXh8baRainTwmCfET7/Km5ia0jxw=="},{"w":40,"h":12,"hash":"IPgBEoSX8Ul3WeSbcLd1Zfg="},{"w":93,"h":47,"hash":"4PeBA4AHY4N7pPu7oJO5AnmeQLYcRLk="},{"w":15,"h":42,"hash":"4AcCCwJ0D/zATmOWM/r3xXM="},{"w":67,"h":45,"hash":"3/eBA4AHVbr4h517mv+eZprFsJioaoQ="},{"w":67,"h":74,"hash":"IAgCBgBqmUc1R2d6iqtrigl4lSf/VXQ="},{"w":29,"h":67,"hash":"3weCAgAHYJRmaGqgmaOAV5q0AZR2akk="},{"w":43,"h":10,"hash":"4AcCCoL8SVDXdVsyiECVaK8="},{"w":75,"h":40,"hash":"IAiCA4AHw2mXw6/AoA5Eo8onAwY4s2o="},{"w":73,"h":91,"hash":"3wcCBgDFhsbJlnmDaYxwRZZUl5GAfzY="},{"w":21,"h":54,"hash":"4AeCAgIHWamZCKf0fNZpTyw6aHxHXzM="},{"w":98,"h":52,"hash":"H/gBBICZZsltiKuMUHewoYEI5g=="},{"w":2,"h":29,"hash":"XwiCCQR3cpuYwKqODEIHWXgHiHiIiIc="}] diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go index 53a749210..a255bec42 100644 --- a/core/artwork/thumbhash/thumbhash.go +++ b/core/artwork/thumbhash/thumbhash.go @@ -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 { diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go index ca71a5a0d..4832c25bc 100644 --- a/core/artwork/thumbhash/thumbhash_test.go +++ b/core/artwork/thumbhash/thumbhash_test.go @@ -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 { diff --git a/tests/images.go b/tests/images.go deleted file mode 100644 index 43fce969e..000000000 --- a/tests/images.go +++ /dev/null @@ -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 -} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 2688b2270..c8ebab403 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -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 } diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js index b947bac80..f4ec673d2 100644 --- a/ui/src/utils/thumbhash.js +++ b/ui/src/utils/thumbhash.js @@ -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)