Skip to content

Commit

Permalink
cmpimg,vg/vggio: implement a YIQ-based image difference
Browse files Browse the repository at this point in the history
Relying on a plain uint8 delta difference threshold proved to be
brittle.

This CL implements a somewhat more reliable strategy relying on the NTSC
YIQ colorspace difference, as described in:

- http://www.progmat.uaem.mx:8080/artVol2Num2/Articulo3Vol2Num2.pdf
  • Loading branch information
sbinet committed Dec 18, 2020
1 parent 2894e51 commit d53aaab
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 41 deletions.
4 changes: 3 additions & 1 deletion cmpimg/checkplot.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ func CheckPlot(ExampleFunc func(), t *testing.T, filenames ...string) {
}

// CheckPlotApprox checks a generated plot against a previously created reference.
// The normalized delta parameter describes how tight the matching should be
// performed, where delta=0 expresses a perfect match, and delta=1 a very loose match.
// If GenerateTestData = true, it regenerates the reference.
// For image.Image formats, a base64 encoded png representation is output to
// the testing log when a difference is identified.
func CheckPlotApprox(ExampleFunc func(), t *testing.T, delta uint8, filenames ...string) {
func CheckPlotApprox(ExampleFunc func(), t *testing.T, delta float64, filenames ...string) {
t.Helper()

paths := make([]string, len(filenames))
Expand Down
79 changes: 53 additions & 26 deletions cmpimg/cmpimg.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,19 @@ func Equal(typ string, raw1, raw2 []byte) (bool, error) {

// EqualApprox takes the raw representation of two images, raw1 and raw2,
// together with the underlying image type ("eps", "jpeg", "jpg", "pdf", "png", "svg", "tiff"),
// a normalized delta parameter to describe how close the matching should be
// performed (delta=0: perfect match, delta=1, loose match)
// and returns whether the two images are equal or not.
//
// EqualApprox may return an error if the decoding of the raw image somehow failed.
func EqualApprox(typ string, raw1, raw2 []byte, delta uint8) (bool, error) {
func EqualApprox(typ string, raw1, raw2 []byte, delta float64) (bool, error) {
switch {
case delta < 0:
delta = 0
case delta > 1:
delta = 1
}

switch typ {
case "svg", "tex":
return bytes.Equal(raw1, raw2), nil
Expand Down Expand Up @@ -84,7 +93,7 @@ func cmpPdf(pdf1, pdf2 []byte) bool {
return gofpdf.CompareBytes(pdf1, pdf1, false) == nil
}

func cmpImg(v1, v2 image.Image, delta uint8) bool {
func cmpImg(v1, v2 image.Image, delta float64) bool {
img1, ok := v1.(*image.RGBA)
if !ok {
img1 = newRGBAFrom(v1)
Expand All @@ -99,40 +108,58 @@ func cmpImg(v1, v2 image.Image, delta uint8) bool {
return false
}

diff := func(p1, p2 uint8) bool {
if p1 > p2 {
p1, p2 = p2, p1
}
return (p2 - p1) < delta
}

equalApprox := func(c1, c2 color.RGBA) bool {
var (
r1 = c1.R
g1 = c1.G
b1 = c1.B
a1 = c1.A

r2 = c2.R
g2 = c2.G
b2 = c2.B
a2 = c2.A
)
return diff(r1, r2) && diff(g1, g2) && diff(b1, b2) && diff(a1, a2)
}

max := delta * delta
bnd := img1.Bounds()
for x := bnd.Min.X; x < bnd.Max.X; x++ {
for y := bnd.Min.Y; y < bnd.Max.Y; y++ {
c1 := img1.RGBAAt(x, y)
c2 := img2.RGBAAt(x, y)
if !equalApprox(c1, c2) {
if !yiqEqApprox(c1, c2, max) {
return false
}
}
}
return true
return ok

}

// yiqEqApprox compares the colors of 2 pixels, in the NTSC YIQ color space,
// as described in:
//
// Measuring perceived color difference using YIQ NTSC
// transmission color space in mobile applications.
// Yuriy Kotsarenko, Fernando Ramos.
//
// An electronic version is available at:
//
// - http://www.progmat.uaem.mx:8080/artVol2Num2/Articulo3Vol2Num2.pdf
func yiqEqApprox(c1, c2 color.RGBA, d2 float64) bool {
const max = 35215.0 // difference between 2 maximally different pixels.

var (
r1 = float64(c1.R)
g1 = float64(c1.G)
b1 = float64(c1.B)

r2 = float64(c2.R)
g2 = float64(c2.G)
b2 = float64(c2.B)

y1 = r1*0.29889531 + g1*0.58662247 + b1*0.11448223
i1 = r1*0.59597799 - g1*0.27417610 - b1*0.32180189
q1 = r1*0.21147017 - g1*0.52261711 + b1*0.31114694

y2 = r2*0.29889531 + g2*0.58662247 + b2*0.11448223
i2 = r2*0.59597799 - g2*0.27417610 - b2*0.32180189
q2 = r2*0.21147017 - g2*0.52261711 + b2*0.31114694

y = y1 - y2
i = i1 - i2
q = q1 - q2

diff = 0.5053*y*y + 0.299*i*i + 0.1957*q*q
)
return diff <= max*d2
}

func newRGBAFrom(src image.Image) *image.RGBA {
Expand Down
20 changes: 7 additions & 13 deletions cmpimg/cmpimg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,17 @@ func TestEqualApprox(t *testing.T) {
}

for _, tc := range []struct {
delta uint8
delta float64
ok bool
}{
{0, false},
{1, false},
{2, false},
{3, false},
{4, false},
{5, false},
{6, false},
{7, true},
{8, true},
{9, true},
{10, true},
{255, true},
{0.01, false},
{0.02, false},
{0.05, true},
{0.1, true},
{1, true},
} {
t.Run(fmt.Sprintf("delta=%d", int(tc.delta)), func(t *testing.T) {
t.Run(fmt.Sprintf("delta=%g", tc.delta), func(t *testing.T) {
ok, err := EqualApprox("png", got, want, tc.delta)
if err != nil {
t.Fatalf("could not compare images: %+v", err)
Expand Down
2 changes: 1 addition & 1 deletion vg/vggio/vggio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (
"gonum.org/v1/plot/vg/draw"
)

const deltaGio = 10 // empirical value from experimentation.
const deltaGio = 0.05 // empirical value from experimentation.

func TestCanvas(t *testing.T) {
if runtime.GOOS == "darwin" {
Expand Down

0 comments on commit d53aaab

Please sign in to comment.