From aa53d49e1f4b94ee31102a8a296ee2897584e366 Mon Sep 17 00:00:00 2001 From: n7olkachev Date: Sat, 12 Dec 2020 17:26:24 +0300 Subject: [PATCH] init --- cmd/main.go | 102 +++++++++++++++++++++++++++++++++++++++++ go.mod | 8 ++++ go.sum | 9 ++++ pkg/imgdiff/imgdiff.go | 76 ++++++++++++++++++++++++++++++ pkg/yiq/delta.go | 38 +++++++++++++++ readme.md | 47 +++++++++++++++++++ scripts/build.sh | 22 +++++++++ 7 files changed, 302 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/imgdiff/imgdiff.go create mode 100644 pkg/yiq/delta.go create mode 100644 readme.md create mode 100755 scripts/build.sh diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..fee5161 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "bufio" + "fmt" + "image" + _ "image/jpeg" + "image/png" + _ "image/png" + "imgdiff/pkg/imgdiff" + "log" + "os" + "sync" + + "github.com/alexflint/go-arg" + . "github.com/logrusorgru/aurora" +) + +func loadImages(filePathes ...string) []image.Image { + images := make([]image.Image, len(filePathes)) + + wg := sync.WaitGroup{} + + for i, path := range filePathes { + wg.Add(1) + + go func(i int, path string) { + file, err := os.Open(path) + + defer file.Close() + + if err != nil { + log.Fatalf("can't open image %s %s", path, err.Error()) + } + + image, _, err := image.Decode(file) + + if err != nil { + log.Fatalf("can't decode image %s %s", path, err.Error()) + } + + images[i] = image + + wg.Done() + }(i, path) + } + + wg.Wait() + + return images +} + +func main() { + var args struct { + Threshold float64 `arg:"-t,--threshold" help:"Color difference threshold (from 0 to 1). Less more precise." default:"0.1"` + DiffImage bool `arg:"--diff-image" help:"Render image to the diff output instead of transparent background." default:"false"` + FailOnLayout bool `arg:"--fail-on-layout" help:"Do not compare images and produce output if images layout is different." default:"false"` + Base string `arg:"positional" help:"Base image."` + Compare string `arg:"positional" help:"Image to compare with."` + Output string `arg:"positional" help:"Output image path."` + } + + arg.MustParse(&args) + + images := loadImages(args.Base, args.Compare) + + image1, image2 := images[0], images[1] + + if args.FailOnLayout && !image1.Bounds().Eq(image2.Bounds()) { + fmt.Println(Red("Failure!").Bold(), "Images have different layout.") + + os.Exit(2) + } + + result := imgdiff.Diff(image1, image2, &imgdiff.Options{ + Threshold: args.Threshold, + DiffImage: args.DiffImage, + }) + + if result.Equal { + fmt.Println(Green("Success!").Bold(), "Images are equal.") + return + } + + enc := &png.Encoder{ + CompressionLevel: png.BestSpeed, + } + + f, _ := os.Create(args.Output) + defer f.Close() + + writer := bufio.NewWriter(f) + defer writer.Flush() + + enc.Encode(writer, result.Image) + + fmt.Println(Red("Failure!").Bold(), "Images are different.") + + fmt.Printf("Different pixels: %d\n", Red(result.DiffPixelsCount).Bold()) + + os.Exit(1) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d35485b --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module imgdiff + +go 1.15 + +require ( + github.com/alexflint/go-arg v1.3.0 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d1cd553 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/alexflint/go-arg v1.3.0 h1:UfldqSdFWeLtoOuVRosqofU4nmhI1pYEbT4ZFS34Bdo= +github.com/alexflint/go-arg v1.3.0/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= +github.com/alexflint/go-scalar v1.0.0 h1:NGupf1XV/Xb04wXskDFzS0KWOLH632W/EO4fAFi+A70= +github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/pkg/imgdiff/imgdiff.go b/pkg/imgdiff/imgdiff.go new file mode 100644 index 0000000..3af22c0 --- /dev/null +++ b/pkg/imgdiff/imgdiff.go @@ -0,0 +1,76 @@ +package imgdiff + +import ( + "image" + "image/color" + "imgdiff/pkg/yiq" + "runtime" + "sync" + "sync/atomic" +) + +// Options struct. +type Options struct { + Threshold float64 + DiffImage bool +} + +// Result struct. +type Result struct { + Equal bool + Image image.Image + DiffPixelsCount uint64 +} + +// Diff between two images. +func Diff(image1 image.Image, image2 image.Image, options *Options) *Result { + diffPixelsCount := uint64(0) + + maxDelta := yiq.MaxDelta * options.Threshold * options.Threshold + + diff := image.NewNRGBA(image1.Bounds()) + + wg := sync.WaitGroup{} + + cpus := runtime.NumCPU() + + for i := 0; i < cpus; i++ { + wg.Add(1) + + go func(i int) { + diffPixelsCounter := 0 + + for y := i; y <= image1.Bounds().Max.Y; y += cpus { + for x := 0; x <= image1.Bounds().Max.X; x++ { + pixel1, pixel2 := image1.At(x, y), image2.At(x, y) + + if pixel1 != pixel2 { + delta := yiq.Delta(pixel1, pixel2) + + if delta > maxDelta { + diff.SetNRGBA(x, y, color.NRGBA{R: 255, G: 0, B: 0, A: 255}) + + diffPixelsCounter++ + } + } else if options.DiffImage { + diff.Set(x, y, pixel1) + } + } + } + + if diffPixelsCounter > 0 { + atomic.AddUint64(&diffPixelsCount, uint64(diffPixelsCounter)) + } + + wg.Done() + }(i) + } + + wg.Wait() + + return &Result{ + Equal: diffPixelsCount == 0, + DiffPixelsCount: diffPixelsCount, + Image: diff, + } +} diff --git a/pkg/yiq/delta.go b/pkg/yiq/delta.go new file mode 100644 index 0000000..f444b1f --- /dev/null +++ b/pkg/yiq/delta.go @@ -0,0 +1,38 @@ +package yiq + +import ( + "image/color" +) + +func normalize(rgba color.Color) (uint8, uint8, uint8, uint8) { + r, g, b, a := rgba.RGBA() + + return uint8(r), uint8(g), uint8(b), uint8(a) +} + +func rgb2y(r, g, b uint8) float64 { + return float64(r)*0.29889531 + float64(g)*0.58662247 + float64(b)*0.11448223 +} + +func rgb2i(r, g, b uint8) float64 { + return float64(r)*0.59597799 - float64(g)*0.27417610 - float64(b)*0.32180189 +} + +func rgb2q(r, g, b uint8) float64 { + return float64(r)*0.21147017 - float64(g)*0.52261711 + float64(b)*0.31114694 +} + +// Delta between two pixels. +func Delta(pixelA, pixelB color.Color) float64 { + r1, g1, b1, _ := normalize(pixelA) + r2, g2, b2, _ := normalize(pixelB) + + y := rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2) + i := rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2) + q := rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2) + + return 0.5053*y*y + 0.299*i*i + 0.1957*q*q +} + +// MaxDelta is a max value of Delta func. +var MaxDelta = 35215.0 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e4cad2e --- /dev/null +++ b/readme.md @@ -0,0 +1,47 @@ +# imgdiff + +Faster than [the fastest in the world pixel-by-pixel image difference tool](https://github.com/dmtrKovalenko/odiff). + +## Why? + +imgdiff isn't as fast as a tool like this should be and I'm not proud of it, but it is 3X faster than +[the fastest in the world pixel-by-pixel image difference tool](https://github.com/dmtrKovalenko/odiff), +so maybe you'll find it useful. + +## Features + +It can do everything [odiff](https://github.com/dmtrKovalenko/odiff) can. Faster. + +## Benchmarks + +[Cypress image](https://github.com/dmtrKovalenko/odiff/blob/main/images/www.cypress.io.png) 3446 x 10728 + +| Command | Mean [s] | Min [s] | Max [s] | Relative | +| :------------------------------------------------------------- | ------------: | ------: | ------: | -------: | +| `imgdiff images/cypress-1.png images/cypress-2.png output.png` | 1.442 ± 0.012 | 1.420 | 1.462 | 1.00 | +| `odiff images/cypress-1.png images/cypress-2.png output.png` | 6.475 ± 0.092 | 6.300 | 6.583 | 4.49 | + +[Water image](https://github.com/dmtrKovalenko/odiff/blob/main/images/water-4k.png) 8400 x 4725 + +| Command | Mean [s] | Min [s] | Max [s] | Relative | +| :--------------------------------------------------------- | -------------: | ------: | ------: | -------: | +| `imgdiff images/water-1.png images/water-2.png output.png` | 1.908 ± 0.0058 | 1.841 | 2.002 | 1.00 | +| `odiff images/water-1.png images/water-2.png output.png` | 6.016 ± 0.415 | 5.643 | 7.140 | 3.15 | + +## Usage + +``` +Usage: imgdiff [--threshold THRESHOLD] [--diff-image] [--fail-on-layout] BASE COMPARE OUTPUT + +Positional arguments: + BASE Base image. + COMPARE Image to compare with. + OUTPUT Output image path. + +Options: + --threshold THRESHOLD, -t THRESHOLD + Color difference threshold (from 0 to 1). Less more precise. [default: 0.1] + --diff-image Render image to the diff output instead of transparent background. [default: false] + --fail-on-layout Do not compare images and produce output if images layout is different. [default: false] + --help, -h display this help and exit +``` diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..58ed96e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +package_name=imgdiff + +platforms=("windows/amd64" "windows/386" "linux/386" "linux/amd64" "darwin/amd64") + +for platform in "${platforms[@]}" +do + platform_split=(${platform//\// }) + GOOS=${platform_split[0]} + GOARCH=${platform_split[1]} + output_name=$package_name'-'$GOOS'-'$GOARCH + if [ $GOOS = "windows" ]; then + output_name+='.exe' + fi + + env GOOS=$GOOS GOARCH=$GOARCH go build -o $output_name cmd/main.go + if [ $? -ne 0 ]; then + echo 'An error has occurred! Aborting the script execution...' + exit 1 + fi +done