├── Dockerfile ├── go.mod ├── scripts └── build.sh ├── go.sum ├── pkg ├── yiq │ └── delta.go └── imgdiff │ └── imgdiff.go ├── LICENSE ├── readme.md └── cmd └── main.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 2 | 3 | WORKDIR "/app" 4 | 5 | CMD ["go", "build", "-o", "/build/imgdiff", "cmd/main.go"] 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/n7olkachev/imgdiff 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.3.0 // indirect 7 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | package_name=imgdiff 4 | 5 | platforms=("windows/amd64" "windows/386" "linux/386" "linux/amd64" "darwin/amd64" "darwin/386") 6 | 7 | for platform in "${platforms[@]}" 8 | do 9 | platform_split=(${platform//\// }) 10 | GOOS=${platform_split[0]} 11 | GOARCH=${platform_split[1]} 12 | output_name=$package_name'-'$GOOS'-'$GOARCH 13 | if [ $GOOS = "windows" ]; then 14 | output_name+='.exe' 15 | fi 16 | 17 | env GOOS=$GOOS GOARCH=$GOARCH go build -o $output_name cmd/main.go 18 | if [ $? -ne 0 ]; then 19 | echo 'An error has occurred! Aborting the script execution...' 20 | exit 1 21 | fi 22 | done 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alexflint/go-arg v1.3.0 h1:UfldqSdFWeLtoOuVRosqofU4nmhI1pYEbT4ZFS34Bdo= 2 | github.com/alexflint/go-arg v1.3.0/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM= 3 | github.com/alexflint/go-scalar v1.0.0 h1:NGupf1XV/Xb04wXskDFzS0KWOLH632W/EO4fAFi+A70= 4 | github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 7 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 10 | -------------------------------------------------------------------------------- /pkg/yiq/delta.go: -------------------------------------------------------------------------------- 1 | package yiq 2 | 3 | import ( 4 | "image/color" 5 | ) 6 | 7 | func normalize(rgba color.Color) (uint8, uint8, uint8, uint8) { 8 | r, g, b, a := rgba.RGBA() 9 | 10 | return uint8(r), uint8(g), uint8(b), uint8(a) 11 | } 12 | 13 | func rgb2y(r, g, b uint8) float64 { 14 | return float64(r)*0.29889531 + float64(g)*0.58662247 + float64(b)*0.11448223 15 | } 16 | 17 | func rgb2i(r, g, b uint8) float64 { 18 | return float64(r)*0.59597799 - float64(g)*0.27417610 - float64(b)*0.32180189 19 | } 20 | 21 | func rgb2q(r, g, b uint8) float64 { 22 | return float64(r)*0.21147017 - float64(g)*0.52261711 + float64(b)*0.31114694 23 | } 24 | 25 | // Delta between two pixels. 26 | func Delta(pixelA, pixelB color.Color) float64 { 27 | r1, g1, b1, _ := normalize(pixelA) 28 | r2, g2, b2, _ := normalize(pixelB) 29 | 30 | y := rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2) 31 | i := rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2) 32 | q := rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2) 33 | 34 | return 0.5053*y*y + 0.299*i*i + 0.1957*q*q 35 | } 36 | 37 | // MaxDelta is a max value of Delta func. 38 | var MaxDelta = 35215.0 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nikita Tolkachev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/imgdiff/imgdiff.go: -------------------------------------------------------------------------------- 1 | package imgdiff 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "runtime" 7 | "sync" 8 | "sync/atomic" 9 | 10 | "github.com/n7olkachev/imgdiff/pkg/yiq" 11 | ) 12 | 13 | // Options struct. 14 | type Options struct { 15 | Threshold float64 16 | DiffImage bool 17 | } 18 | 19 | // Result struct. 20 | type Result struct { 21 | Equal bool 22 | Image image.Image 23 | DiffPixelsCount uint64 24 | } 25 | 26 | // Diff between two images. 27 | func Diff(image1 image.Image, image2 image.Image, options *Options) *Result { 28 | diffPixelsCount := uint64(0) 29 | 30 | maxDelta := yiq.MaxDelta * options.Threshold * options.Threshold 31 | 32 | diff := image.NewNRGBA(image1.Bounds()) 33 | 34 | wg := sync.WaitGroup{} 35 | 36 | cpus := runtime.NumCPU() 37 | 38 | for i := 0; i < cpus; i++ { 39 | wg.Add(1) 40 | 41 | go func(i int) { 42 | diffPixelsCounter := 0 43 | 44 | for y := i; y <= image1.Bounds().Max.Y; y += cpus { 45 | for x := 0; x <= image1.Bounds().Max.X; x++ { 46 | pixel1, pixel2 := image1.At(x, y), image2.At(x, y) 47 | 48 | if pixel1 != pixel2 { 49 | delta := yiq.Delta(pixel1, pixel2) 50 | 51 | if delta > maxDelta { 52 | diff.SetNRGBA(x, y, color.NRGBA{R: 255, G: 0, B: 0, A: 255}) 53 | 54 | diffPixelsCounter++ 55 | } 56 | } else if options.DiffImage { 57 | diff.Set(x, y, pixel1) 58 | } 59 | } 60 | } 61 | 62 | if diffPixelsCounter > 0 { 63 | atomic.AddUint64(&diffPixelsCount, uint64(diffPixelsCounter)) 64 | } 65 | 66 | wg.Done() 67 | }(i) 68 | } 69 | 70 | wg.Wait() 71 | 72 | return &Result{ 73 | Equal: diffPixelsCount == 0, 74 | DiffPixelsCount: diffPixelsCount, 75 | Image: diff, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # imgdiff 2 | 3 | Faster than [the fastest in the world pixel-by-pixel image difference tool](https://github.com/dmtrKovalenko/odiff). 4 | 5 | ## Why? 6 | 7 | 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 8 | [the fastest in the world pixel-by-pixel image difference tool](https://github.com/dmtrKovalenko/odiff), 9 | so maybe you'll find it useful. 10 | 11 | ## Features 12 | 13 | It can do everything [odiff](https://github.com/dmtrKovalenko/odiff) can. Faster. 14 | 15 | ## Benchmarks 16 | 17 | I've tested it on Linux, Intel(R) Core(TM) i7-4700HQ CPU @ 2.40GHz, 8 cores. 18 | 19 | [Cypress image](https://github.com/dmtrKovalenko/odiff/blob/main/images/www.cypress.io.png) 3446 x 10728 20 | 21 | | Command | Mean [s] | Min [s] | Max [s] | Relative | 22 | | :------------------------------------------------------------- | ------------: | ------: | ------: | -------: | 23 | | `imgdiff images/cypress-1.png images/cypress-2.png output.png` | 1.442 ± 0.012 | 1.420 | 1.462 | 1.00 | 24 | | `odiff images/cypress-1.png images/cypress-2.png output.png` | 6.475 ± 0.092 | 6.300 | 6.583 | 4.49 | 25 | 26 | [Water image](https://github.com/dmtrKovalenko/odiff/blob/main/images/water-4k.png) 8400 x 4725 27 | 28 | | Command | Mean [s] | Min [s] | Max [s] | Relative | 29 | | :--------------------------------------------------------- | -------------: | ------: | ------: | -------: | 30 | | `imgdiff images/water-1.png images/water-2.png output.png` | 1.908 ± 0.0058 | 1.841 | 2.002 | 1.00 | 31 | | `odiff images/water-1.png images/water-2.png output.png` | 6.016 ± 0.415 | 5.643 | 7.140 | 3.15 | 32 | 33 | ## Usage 34 | 35 | ``` 36 | Usage: imgdiff [--threshold THRESHOLD] [--diff-image] [--fail-on-layout] BASE COMPARE OUTPUT 37 | 38 | Positional arguments: 39 | BASE Base image. 40 | COMPARE Image to compare with. 41 | OUTPUT Output image path. 42 | 43 | Options: 44 | --threshold THRESHOLD, -t THRESHOLD 45 | Color difference threshold (from 0 to 1). Less more precise. [default: 0.1] 46 | --diff-image Render image to the diff output instead of transparent background. [default: false] 47 | --fail-on-layout Do not compare images and produce output if images layout is different. [default: false] 48 | --help, -h display this help and exit 49 | ``` 50 | 51 | ## Download 52 | 53 | You can find pre-built binaries [here](https://github.com/n7olkachev/imgdiff/releases/tag/v1.0.0). 54 | imgdiff is written in Go, so there shouldn't be any troubles to compile it for the most of popular platforms. 55 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "image" 7 | _ "image/jpeg" 8 | "image/png" 9 | _ "image/png" 10 | "log" 11 | "os" 12 | "sync" 13 | 14 | "github.com/n7olkachev/imgdiff/pkg/imgdiff" 15 | 16 | "github.com/alexflint/go-arg" 17 | . "github.com/logrusorgru/aurora" 18 | ) 19 | 20 | func loadImages(filePathes ...string) []image.Image { 21 | images := make([]image.Image, len(filePathes)) 22 | 23 | wg := sync.WaitGroup{} 24 | 25 | for i, path := range filePathes { 26 | wg.Add(1) 27 | 28 | go func(i int, path string) { 29 | file, err := os.Open(path) 30 | 31 | defer file.Close() 32 | 33 | if err != nil { 34 | log.Fatalf("can't open image %s %s", path, err.Error()) 35 | } 36 | 37 | image, _, err := image.Decode(file) 38 | 39 | if err != nil { 40 | log.Fatalf("can't decode image %s %s", path, err.Error()) 41 | } 42 | 43 | images[i] = image 44 | 45 | wg.Done() 46 | }(i, path) 47 | } 48 | 49 | wg.Wait() 50 | 51 | return images 52 | } 53 | 54 | func main() { 55 | var args struct { 56 | Threshold float64 `arg:"-t,--threshold" help:"Color difference threshold (from 0 to 1). Less more precise." default:"0.1"` 57 | DiffImage bool `arg:"--diff-image" help:"Render image to the diff output instead of transparent background." default:"false"` 58 | FailOnLayout bool `arg:"--fail-on-layout" help:"Do not compare images and produce output if images layout is different." default:"false"` 59 | Base string `arg:"positional" help:"Base image."` 60 | Compare string `arg:"positional" help:"Image to compare with."` 61 | Output string `arg:"positional" help:"Output image path."` 62 | } 63 | 64 | arg.MustParse(&args) 65 | 66 | images := loadImages(args.Base, args.Compare) 67 | 68 | image1, image2 := images[0], images[1] 69 | 70 | if args.FailOnLayout && !image1.Bounds().Eq(image2.Bounds()) { 71 | fmt.Println(Red("Failure!").Bold(), "Images have different layout.") 72 | 73 | os.Exit(2) 74 | } 75 | 76 | result := imgdiff.Diff(image1, image2, &imgdiff.Options{ 77 | Threshold: args.Threshold, 78 | DiffImage: args.DiffImage, 79 | }) 80 | 81 | if result.Equal { 82 | fmt.Println(Green("Success!").Bold(), "Images are equal.") 83 | return 84 | } 85 | 86 | enc := &png.Encoder{ 87 | CompressionLevel: png.BestSpeed, 88 | } 89 | 90 | f, _ := os.Create(args.Output) 91 | 92 | writer := bufio.NewWriter(f) 93 | 94 | enc.Encode(writer, result.Image) 95 | 96 | fmt.Println(Red("Failure!").Bold(), "Images are different.") 97 | 98 | fmt.Printf("Different pixels: %d\n", Red(result.DiffPixelsCount).Bold()) 99 | 100 | writer.Flush() 101 | 102 | f.Close() 103 | 104 | os.Exit(1) 105 | } 106 | --------------------------------------------------------------------------------