blob: caf088fe219519671e780de48f79f3f6efa6105d [file] [log] [blame]
package sample_area
import (
"fmt"
"image"
"image/draw"
)
// Matcher is a non-exact image matching algorithm.
//
// Instead of comparing pixels one-by-one, areas of pixels are compared together
// and are considered identical if a certain number of pixels in that area are
// identical.
//
// The primary use case for this is large images that are susceptible to random
// noise throughout the entire image. Such actual color difference can be quite
// large and still not be noticeable to a human as long as such differences are
// not closely grouped together.
type Matcher struct {
// SampleAreaWidth allows values 1+, although 1 is the same as exact matching.
SampleAreaWidth int
// MaxDifferentPixelsPerArea allows values 0-SampleAreaWidth^2, although the
// extremes are equivalent to exact matching and a blanket pass, respectively.
MaxDifferentPixelsPerArea int
// SampleAreaChannelDeltaThreshold allows values 0-255, although 255 is
// equivalent to a blanket pass.
SampleAreaChannelDeltaThreshold int
// Debug information about the last comparison.
failedSampleArea image.Rectangle
numDifferentPixels int
sampleAreaWidthTooSmall bool
sampleAreaWidthTooLarge bool
maxDifferentPixelsPerAreaOutOfRange bool
sampleAreaChannelDeltaThresholdOutOfRange bool
}
// Match implements the imgmatching.Matcher interface.
func (m *Matcher) Match(expected, actual image.Image) bool {
// Expected image will be nil if no recent positive image is found.
if expected == nil {
return false
}
// Images must be the same size.
if !expected.Bounds().Eq(actual.Bounds()) {
return false
}
// In practice, erroneous values should be caught at argument parsing time,
// but validate here as well.
if m.SampleAreaWidth < 1 {
m.sampleAreaWidthTooSmall = true
fmt.Print("Sample area width must be > 0\n")
return false
}
if m.MaxDifferentPixelsPerArea < 0 ||
m.MaxDifferentPixelsPerArea > m.SampleAreaWidth*m.SampleAreaWidth {
m.maxDifferentPixelsPerAreaOutOfRange = true
fmt.Print("Max different pixels per area must be >= 0, <= pixels per sample\n")
return false
}
if m.SampleAreaChannelDeltaThreshold < 0 || m.SampleAreaChannelDeltaThreshold > 255 {
m.sampleAreaChannelDeltaThresholdOutOfRange = true
fmt.Print("Sample area tolerance must be >= 0, <= 255\n")
return false
}
// Check that we aren't trying to sample a larger area than the image.
bounds := expected.Bounds()
if m.SampleAreaWidth > bounds.Dx() || m.SampleAreaWidth > bounds.Dy() {
m.sampleAreaWidthTooLarge = true
fmt.Printf("Given sample area width %d is larger than the %d x %d image\n",
m.SampleAreaWidth, bounds.Dx(), bounds.Dy())
return false
}
// Convert the images to NRGBA.
expectedNRGBA := image.NewNRGBA(bounds)
actualNRGBA := image.NewNRGBA(bounds)
draw.Draw(expectedNRGBA, bounds, expected, bounds.Min, draw.Src)
draw.Draw(actualNRGBA, bounds, actual, bounds.Min, draw.Src)
// Iterate over each sample area and compare.
for x := bounds.Min.X; x <= bounds.Max.X-m.SampleAreaWidth; x++ {
for y := bounds.Min.Y; y <= bounds.Max.Y-m.SampleAreaWidth; y++ {
numDifferentPixels := 0
for xOffset := 0; xOffset < m.SampleAreaWidth; xOffset++ {
for yOffset := 0; yOffset < m.SampleAreaWidth; yOffset++ {
expectedPixel := expectedNRGBA.NRGBAAt(x+xOffset, y+yOffset)
actualPixel := actualNRGBA.NRGBAAt(x+xOffset, y+yOffset)
if expectedPixel != actualPixel {
rDiff := absDiff(expectedPixel.R, actualPixel.R)
gDiff := absDiff(expectedPixel.G, actualPixel.G)
bDiff := absDiff(expectedPixel.B, actualPixel.B)
aDiff := absDiff(expectedPixel.A, actualPixel.A)
if rDiff > m.SampleAreaChannelDeltaThreshold ||
gDiff > m.SampleAreaChannelDeltaThreshold ||
bDiff > m.SampleAreaChannelDeltaThreshold ||
aDiff > m.SampleAreaChannelDeltaThreshold {
numDifferentPixels++
}
}
}
}
if numDifferentPixels > m.MaxDifferentPixelsPerArea {
m.failedSampleArea = image.Rect(x, y, x+m.SampleAreaWidth, y+m.SampleAreaWidth)
m.numDifferentPixels = numDifferentPixels
return false
}
}
}
return true
}
// FailedSampleAreaStart returns the point corresponding the the top left corner
// of the first failed sample area.
func (m *Matcher) FailedSampleArea() image.Rectangle { return m.failedSampleArea }
// NumDifferentPixels returns the number of pixels that differed in the failed
// sample area.
func (m *Matcher) NumDifferentPixels() int { return m.numDifferentPixels }
// SampleAreaWidthTooSmall returns whether the comparison failed because the
// provided sample area is too small to be used.
func (m *Matcher) SampleAreaWidthTooSmall() bool { return m.sampleAreaWidthTooSmall }
// SampleAreaWidthTooLarge returns whether the comparison failed because the
// provided sample area is too large to be used.
func (m *Matcher) SampleAreaWidthTooLarge() bool { return m.sampleAreaWidthTooLarge }
// MaxDifferentPixelsPerAreaOutOfRange returns whether the comparison failed
// because the provided max different pixels per area is too large or small to
// be used.
func (m *Matcher) MaxDifferentPixelsPerAreaOutOfRange() bool {
return m.maxDifferentPixelsPerAreaOutOfRange
}
// SampleAreaChannelDeltaThresholdOutOfRange returns whether the comparison
// failed because the provided pixel delta threshold is too large or small to be
// used.
func (m *Matcher) SampleAreaChannelDeltaThresholdOutOfRange() bool {
return m.sampleAreaChannelDeltaThresholdOutOfRange
}
// absDiff takes two uint8 values m and n, computes |m - n|, and converts the result into an int
// suitable for addition without the risk of overflowing.
func absDiff(m, n uint8) int {
if m > n {
return int(m - n)
}
return int(n - m)
}