// Package diffs compares two renderings of the same screen. // // The comparison is deliberately coarse. squint renders both sides in one // run on one machine, so there is no font-hinting or architecture noise to // tolerate -- but a language model is the fine judge downstream, so this // only has to answer "did anything move", generously, and mark where. // Being generous here is what keeps a reviewer's attention on the three // screens that changed instead of the forty that did not. package diffs import ( "fmt" "image" "image/color" "image/draw" ) // Tolerance is how far one colour channel may move before a pixel counts. // Both sides come from the same browser in the same run, so this is not // for anti-aliasing; it is so a single rounding step in a gradient does // not start a conversation. const Tolerance = 16 // Threshold is the fraction of pixels that must differ before a shot is // worth a look. Generous on purpose: the model downstream decides whether // a real change matters, and a tight number here would hand it noise. const Threshold = 0.001 // Result is what comparing two renderings found. // // Measuring happens in image pixels, because that is what an image has. // Everything a reader is shown is quoted in CSS pixels -- the same units as // the viewport in the config and the size beside the name in the report. // Two different numbers for the same page is how a report starts lying. type Result struct { // Total and Changed count CSS pixels; Fraction is Changed over Total // and is the same either way. Total int `json:"total_pixels"` Changed int `json:"changed_pixels"` Fraction float64 `json:"fraction"` // Resized notes that the two renderings are different shapes, which is // a change on its own: a page that got taller moved everything below // the change. It is a note, not a refusal -- the overlay is still // drawn. Resized string `json:"resized,omitempty"` // Diff marks where. Diff *image.RGBA `json:"-"` } // Moved reports whether this shot is worth showing anyone. func (r Result) Moved() bool { return r.Resized != "" || r.Fraction > Threshold } func (r Result) String() string { pixels := fmt.Sprintf("%d of %d pixels (%.3f%%)", r.Changed, r.Total, r.Fraction*100) if r.Resized != "" { return r.Resized + "; " + pixels } return pixels } // Compare measures how far after is from before, and draws where. // // scale is image pixels per CSS pixel, used only to report in the reader's // units; the measurement itself is done on every pixel there is. // // A page that changed height is the commonest kind of visual change there // is, and an earlier version of this refused to draw anything at all when // the two renderings were different shapes -- so the one case you most // want an overlay for was the one case you never got one. Both sides are // measured against a canvas big enough for either instead. A pixel that // exists in one rendering and not the other counts as changed, which is // true: a page that grew by two lines did move everything below them. func Compare(before, after image.Image, scale float64) Result { if scale < 1 { scale = 1 } px := func(n int) int { return int(float64(n) / scale) } bb, ab := before.Bounds(), after.Bounds() res := Result{} if bb.Dx() != ab.Dx() || bb.Dy() != ab.Dy() { res.Resized = fmt.Sprintf("the page is now %dx%d, was %dx%d", px(ab.Dx()), px(ab.Dy()), px(bb.Dx()), px(bb.Dy())) } w, h := max(bb.Dx(), ab.Dx()), max(bb.Dy(), ab.Dy()) // The overlay is the new rendering faded towards white, so the page is // still readable underneath the marks. A diff nobody can read is a // diff nobody looks at. Where the new rendering does not reach -- it // is the shorter of the two -- the ground is left a flat pale grey, so // "the old page went on past here" is visible rather than implied. diff := image.NewRGBA(image.Rect(0, 0, w, h)) draw.Draw(diff, diff.Bounds(), &image.Uniform{color.RGBA{236, 236, 238, 255}}, image.Point{}, draw.Src) draw.Draw(diff, image.Rect(0, 0, ab.Dx(), ab.Dy()), after, ab.Min, draw.Src) for i := 0; i < len(diff.Pix); i += 4 { diff.Pix[i] = uint8(196 + int(diff.Pix[i])/5) diff.Pix[i+1] = uint8(196 + int(diff.Pix[i+1])/5) diff.Pix[i+2] = uint8(196 + int(diff.Pix[i+2])/5) } changed := 0 mark := color.RGBA{R: 220, G: 30, B: 30, A: 255} for y := 0; y < h; y++ { for x := 0; x < w; x++ { inB := x < bb.Dx() && y < bb.Dy() inA := x < ab.Dx() && y < ab.Dy() if inB != inA || (inB && inA && differs(before.At(bb.Min.X+x, bb.Min.Y+y), after.At(ab.Min.X+x, ab.Min.Y+y))) { changed++ diff.SetRGBA(x, y, mark) } } } // The fraction is computed on real pixels; the counts are quoted in // CSS pixels so they agree with every other number on the page. res.Fraction = float64(changed) / float64(w*h) res.Changed, res.Total = int(float64(changed)/(scale*scale)), int(float64(w*h)/(scale*scale)) res.Diff = diff return res } func differs(a, b color.Color) bool { ar, ag, ab, aa := a.RGBA() br, bg, bb, ba := b.RGBA() return delta(ar, br) > Tolerance || delta(ag, bg) > Tolerance || delta(ab, bb) > Tolerance || delta(aa, ba) > Tolerance } func delta(a, b uint32) int { d := int(a>>8) - int(b>>8) if d < 0 { return -d } return d }