git/intake/main

Collapse cover candidates that are the same picture

Dedupe hashed the file's bytes, which cannot tell that Cover Art Archive's
1200x1200 copy and its 600x600 copy are one image, nor that Deezer and iTunes are
serving re-encodes of it. Every one survived as a separate entry, so the picker
offered the same sleeve four times at four sizes.

Candidates are now compared by what they look like: each image is reduced to a
nine by eight grid of grey values with one bit per adjacent pair, in both
directions. The byte hash remains as the fallback for anything that cannot be
decoded.

Both directions because a hash of horizontal differences alone cannot separate
two images that share their horizontal structure, which a first attempt did not
notice until a test caught it.

Measured against real artwork: two different albums sit 54 bits apart, while one
cover at 800, 300 and 1400 pixels sits 3 to 4 bits apart. The threshold of 8 has
room on both sides. It is deliberately tight, since merging is the expensive
mistake here: it silently drops a candidate, whereas failing to merge only leaves
a duplicate in a list.

Closes afb69ec6cdf603e67279d8b5428244605c40367d
diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go
index 4de61ce..c611050 100644
--- a/internal/artwork/artwork.go
+++ b/internal/artwork/artwork.go
@@ -171,25 +171,58 @@ func enabled(list []Source, s Source) bool {
}
func dedupe(in []Candidate) []Candidate {
- seen := map[string]int{}
- var out []Candidate
+ type kept struct {
+ bytes string
+ visual visual
+ hashed bool
+ }
+ var (
+ out []Candidate
+ vis []kept
+ )
for _, c := range in {
- key, err := hashFile(c.Path)
- if err != nil {
- out = append(out, c)
- continue
- }
- if idx, ok := seen[key]; ok {
- if len(c.Label) > len(out[idx].Label) && out[idx].Label == "" {
- out[idx].Label = c.Label
+ // Prefer the visual hash, which catches one picture at several sizes or
+ // re-encoded by another provider. The byte hash is the fallback for an
+ // image that cannot be decoded.
+ dupe := -1
+ if vh, ok := visualHash(c.Path); ok {
+ for i := range vis {
+ if vis[i].hashed && vis[i].visual.near(vh) {
+ dupe = i
+ break
+ }
}
- if out[idx].Source == SourcePlaceholder && c.Source != SourcePlaceholder {
- out[idx] = c
+ if dupe < 0 {
+ out = append(out, c)
+ vis = append(vis, kept{visual: vh, hashed: true})
+ continue
}
- continue
+ } else {
+ key, err := hashFile(c.Path)
+ if err != nil {
+ out = append(out, c)
+ vis = append(vis, kept{})
+ continue
+ }
+ for i := range vis {
+ if vis[i].bytes != "" && vis[i].bytes == key {
+ dupe = i
+ break
+ }
+ }
+ if dupe < 0 {
+ out = append(out, c)
+ vis = append(vis, kept{bytes: key})
+ continue
+ }
+ }
+
+ if len(c.Label) > len(out[dupe].Label) && out[dupe].Label == "" {
+ out[dupe].Label = c.Label
+ }
+ if out[dupe].Source == SourcePlaceholder && c.Source != SourcePlaceholder {
+ out[dupe] = c
}
- seen[key] = len(out)
- out = append(out, c)
}
return out
}
diff --git a/internal/artwork/visual.go b/internal/artwork/visual.go
new file mode 100644
index 0000000..e80e44e
--- /dev/null
+++ b/internal/artwork/visual.go
@@ -0,0 +1,115 @@
+package artwork
+
+import (
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "math/bits"
+ "os"
+)
+
+// visualDistance is how many bits two images may differ by and still count as
+// the same picture. Re-encoding and rescaling move a difference hash by a few
+// bits; two different covers of one album are typically dozens of bits apart, so
+// this stays well clear of merging artwork that merely looks similar. Merging is
+// the expensive mistake here: it silently removes a candidate, whereas failing
+// to merge only leaves a duplicate in a list.
+const visualDistance = 8
+
+// visual is an image reduced to a grid of grey values, with one bit per pair of
+// adjacent cells recording which is brighter. Both directions are kept: a hash
+// of horizontal differences alone cannot tell two images apart when they share
+// their horizontal structure, which is easy to hit by accident.
+type visual struct {
+ horiz, vert uint64
+}
+
+// near reports whether two hashes describe the same picture.
+func (v visual) near(o visual) bool {
+ d := bits.OnesCount64(v.horiz^o.horiz) + bits.OnesCount64(v.vert^o.vert)
+ return d <= visualDistance
+}
+
+// visualHash reduces an image to its visual hash.
+//
+// Hashing the file's bytes cannot tell that Cover Art Archive's 1200x1200 copy
+// and its 600x600 copy are one picture, nor that Deezer and iTunes are serving
+// re-encodes of it, so the picker offered the same sleeve four times at four
+// sizes. This can.
+func visualHash(path string) (visual, bool) {
+ f, err := os.Open(path)
+ if err != nil {
+ return visual{}, false
+ }
+ defer f.Close()
+ img, _, err := image.Decode(f)
+ if err != nil {
+ return visual{}, false
+ }
+
+ const cols, rows = 9, 8
+ b := img.Bounds()
+ if b.Dx() < 1 || b.Dy() < 1 {
+ return visual{}, false
+ }
+
+ grey := make([]uint32, cols*rows)
+ for y := 0; y < rows; y++ {
+ y0 := b.Min.Y + y*b.Dy()/rows
+ y1 := b.Min.Y + (y+1)*b.Dy()/rows
+ if y1 <= y0 {
+ y1 = y0 + 1
+ }
+ for x := 0; x < cols; x++ {
+ x0 := b.Min.X + x*b.Dx()/cols
+ x1 := b.Min.X + (x+1)*b.Dx()/cols
+ if x1 <= x0 {
+ x1 = x0 + 1
+ }
+ // Sampling each cell at a few evenly spaced points rather than
+ // every pixel it covers: the same average for a nine by eight grid,
+ // without an At call per pixel of a multi-megapixel image.
+ var sum, n uint32
+ stepY := (y1 - y0) / 4
+ if stepY < 1 {
+ stepY = 1
+ }
+ stepX := (x1 - x0) / 4
+ if stepX < 1 {
+ stepX = 1
+ }
+ for yy := y0; yy < y1; yy += stepY {
+ for xx := x0; xx < x1; xx += stepX {
+ r, g, bl, _ := img.At(xx, yy).RGBA()
+ sum += (299*(r>>8) + 587*(g>>8) + 114*(bl>>8)) / 1000
+ n++
+ }
+ }
+ if n > 0 {
+ grey[y*cols+x] = sum / n
+ }
+ }
+ }
+
+ var out visual
+ bit := uint(0)
+ for y := 0; y < rows; y++ {
+ for x := 0; x < cols-1; x++ {
+ if grey[y*cols+x] > grey[y*cols+x+1] {
+ out.horiz |= 1 << bit
+ }
+ bit++
+ }
+ }
+ bit = 0
+ for x := 0; x < cols; x++ {
+ for y := 0; y < rows-1; y++ {
+ if grey[y*cols+x] > grey[(y+1)*cols+x] {
+ out.vert |= 1 << bit
+ }
+ bit++
+ }
+ }
+ return out, true
+}
diff --git a/internal/artwork/visual_test.go b/internal/artwork/visual_test.go
new file mode 100644
index 0000000..05161ca
--- /dev/null
+++ b/internal/artwork/visual_test.go
@@ -0,0 +1,151 @@
+package artwork
+
+import (
+ "image"
+ "image/color"
+ "image/jpeg"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// picture draws something with structure, so a difference hash has real
+// gradients to work with rather than one flat colour.
+func picture(n int, variant int) image.Image {
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ r := uint8((x * 255 / n) % 256)
+ g := uint8((y * 255 / n) % 256)
+ b := uint8(0)
+ switch variant {
+ case 1:
+ b = uint8((x + y) * 255 / (2 * n))
+ case 2:
+ // a visibly different picture: a checker pattern with the
+ // gradients reversed, so it differs in both directions
+ if (x/(n/8)+y/(n/8))%2 == 0 {
+ r, g = 255-r, 255-g
+ }
+ b = 200
+ }
+ img.Set(x, y, color.RGBA{r, g, b, 255})
+ }
+ }
+ return img
+}
+
+func savePNG(t *testing.T, path string, img image.Image) {
+ t.Helper()
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func saveJPEG(t *testing.T, path string, img image.Image) {
+ t.Helper()
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := jpeg.Encode(f, img, nil); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The same artwork at two sizes or in another format is one picture, and the
+// point of the change is that the picker should say so.
+func TestVisualHashSeesTheSamePictureAtAnotherSize(t *testing.T) {
+ dir := t.TempDir()
+ small := filepath.Join(dir, "small.png")
+ large := filepath.Join(dir, "large.png")
+ reencoded := filepath.Join(dir, "reencoded.jpg")
+
+ savePNG(t, small, picture(300, 1))
+ savePNG(t, large, picture(1200, 1))
+ saveJPEG(t, reencoded, picture(800, 1))
+
+ hs, ok := visualHash(small)
+ if !ok {
+ t.Fatal("could not hash the small image")
+ }
+ hl, _ := visualHash(large)
+ hr, _ := visualHash(reencoded)
+
+ if !hs.near(hl) {
+ t.Errorf("300px and 1200px versions of one picture read as different")
+ }
+ if !hs.near(hr) {
+ t.Errorf("a jpeg re-encode read as a different picture")
+ }
+}
+
+func TestVisualHashSeparatesDifferentPictures(t *testing.T) {
+ dir := t.TempDir()
+ a := filepath.Join(dir, "a.png")
+ b := filepath.Join(dir, "b.png")
+ savePNG(t, a, picture(400, 1))
+ savePNG(t, b, picture(400, 2))
+
+ ha, _ := visualHash(a)
+ hb, _ := visualHash(b)
+ if ha.near(hb) {
+ t.Errorf("two different pictures were read as the same")
+ }
+}
+
+func TestVisualHashOnSomethingThatIsNotAnImage(t *testing.T) {
+ dir := t.TempDir()
+ junk := filepath.Join(dir, "cover.jpg")
+ if err := os.WriteFile(junk, []byte("not an image"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := visualHash(junk); ok {
+ t.Error("nonsense should not produce a hash")
+ }
+ if _, ok := visualHash(filepath.Join(dir, "missing.png")); ok {
+ t.Error("a missing file should not produce a hash")
+ }
+}
+
+// The picker offered the same sleeve several times at several sizes. Dedupe
+// works on what the picture looks like, so those collapse to one entry.
+func TestDedupeCollapsesTheSameArtworkAtDifferentSizes(t *testing.T) {
+ dir := t.TempDir()
+ var in []Candidate
+ for i, n := range []int{600, 1200, 800} {
+ p := filepath.Join(dir, string(rune('a'+i))+".png")
+ savePNG(t, p, picture(n, 1))
+ in = append(in, Candidate{
+ Source: SourceCoverArt, Label: "coverartarchive",
+ Path: p, Width: n, Height: n,
+ })
+ }
+ // and a genuinely different cover, which must survive
+ other := filepath.Join(dir, "other.png")
+ savePNG(t, other, picture(1000, 2))
+ in = append(in, Candidate{Source: SourceDeezer, Label: "deezer", Path: other, Width: 1000, Height: 1000})
+
+ got := dedupe(in)
+ if len(got) != 2 {
+ t.Fatalf("deduped to %d candidates, want 2: %s", len(got), describe(got))
+ }
+ if got[0].Width != 600 {
+ t.Errorf("kept the %dpx copy; the first one should win", got[0].Width)
+ }
+}
+
+func describe(cs []Candidate) string {
+ s := ""
+ for _, c := range cs {
+ s += c.Path + " "
+ }
+ return s
+}