├── LICENSE ├── README.md ├── go.mod ├── open.go ├── resample.go ├── resample_test.go ├── save.go ├── similarity.go ├── similarity_test.go └── testdata ├── distorted.jpg ├── flipped.jpg ├── large.jpg ├── nearest100x100.png ├── nearest533x400.png ├── original.png ├── proportions ├── 100x122.png ├── 100x124.png ├── 100x130.png ├── 122x100.png ├── 124x100.png ├── 130x100.png ├── 200x200.png └── 260x200.png └── small.jpg /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vitali Fedulov (fedulov.vitali@gmail.com) 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comparing images in Go ➔ [LATEST version](https://github.com/vitali-fedulov/images4) 2 | 3 | Near duplicates and resized images can be found with the module. 4 | 5 | **Demo**: [similar image search and clustering](https://vitali-fedulov.github.io/similar.pictures/) (deployed [from](https://github.com/vitali-fedulov/similar.pictures)). 6 | 7 | **Semantic versions**: 8 | - v1/v2 (/images) - this repository, 9 | - [v3](https://github.com/vitali-fedulov/images3) (/images3), 10 | - [v4](https://github.com/vitali-fedulov/images4) (/images4) - latest recommended. 11 | 12 | All versions will be kept available indefinitely. 13 | 14 | # About this repo 15 | 16 | There are no dependencies: only the Golang standard library is used. Supported image types: GIF, JPEG and PNG (golang.org/pkg/image/ as in October 2018). 17 | 18 | `Similar` function gives a verdict whether 2 images are similar or not. The library also contains wrapper functions to open/save images and basic image resampling/resizing. 19 | 20 | `SimilarCustom` function allows your own similarity metric thresholds. 21 | 22 | Documentation: [godoc](https://pkg.go.dev/github.com/vitali-fedulov/images/v2). 23 | 24 | ## Example of comparing 2 photos 25 | 26 | To test this example go-file, you need to initialize modules from command line, because v2 uses them: 27 | 28 | `go mod init foo` 29 | 30 | Here `foo` can be anything for testing purposes. Then get the required import: 31 | 32 | `go get github.com/vitali-fedulov/images/v2` 33 | 34 | Now you are ready to run or build the example. 35 | 36 | ```go 37 | package main 38 | 39 | import ( 40 | "fmt" 41 | 42 | // v2 is module-based. v1 is not. 43 | "github.com/vitali-fedulov/images/v2" 44 | 45 | // Golang explanation on module versions: 46 | // https://go.dev/blog/v2-go-modules 47 | ) 48 | 49 | func main() { 50 | 51 | // Open photos. 52 | imgA, err := images.Open("photoA.jpg") 53 | if err != nil { 54 | panic(err) 55 | } 56 | imgB, err := images.Open("photoB.jpg") 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | // Calculate hashes and image sizes. 62 | hashA, imgSizeA := images.Hash(imgA) 63 | hashB, imgSizeB := images.Hash(imgB) 64 | 65 | // Image comparison. 66 | if images.Similar(hashA, hashB, imgSizeA, imgSizeB) { 67 | fmt.Println("Images are similar.") 68 | } else { 69 | fmt.Println("Images are distinct.") 70 | } 71 | } 72 | ``` 73 | 74 | ## Algorithm for image comparison 75 | 76 | [Detailed explanation](https://vitali-fedulov.github.io/similar.pictures/algorithm-for-perceptual-image-comparison.html), also as a [PDF](https://github.com/vitali-fedulov/research/blob/main/Algorithm%20for%20perceptual%20image%20comparison%20OLD.pdf). 77 | 78 | Summary: In the algorithm images are resized to small squares of fixed size. 79 | A number of masks representing several sample pixels are run against the resized 80 | images to calculate average color values. Then the values are compared to 81 | give the similarity verdict. Also image proportions are used to avoid matching 82 | images of distinct shape. 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vitali-fedulov/images/v2 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /open.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | // Package images allows image comparison by perceptual similarity. 5 | // Supported image types are those default to the Go image package 6 | // https://golang.org/pkg/image/ (which are GIF, JPEG and PNG in October 2018). 7 | package images 8 | 9 | import ( 10 | "image" 11 | "os" 12 | ) 13 | 14 | // Open opens and decodes an image file for a given path. 15 | func Open(path string) (img image.Image, err error) { 16 | file, err := os.Open(path) 17 | if err != nil { 18 | return nil, err 19 | } 20 | defer file.Close() 21 | img, _, err = image.Decode(file) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return img, err 26 | } 27 | -------------------------------------------------------------------------------- /resample.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | package images 5 | 6 | import ( 7 | "image" 8 | "image/color" 9 | ) 10 | 11 | // ResampleByNearest resizes an image by the nearest neighbour method to the 12 | // output size outX, outY. It also returns the size inX, inY of the input image. 13 | func ResampleByNearest(inImg image.Image, outImgSize image.Point) ( 14 | outImg image.RGBA, inImgSize image.Point) { 15 | // Original image size. 16 | xMax, xMin := inImg.Bounds().Max.X, inImg.Bounds().Min.X 17 | yMax, yMin := inImg.Bounds().Max.Y, inImg.Bounds().Min.Y 18 | inImgSize.X = xMax - xMin 19 | inImgSize.Y = yMax - yMin 20 | 21 | // Destination rectangle. 22 | outRect := image.Rectangle{image.Point{0, 0}, outImgSize} 23 | // Color model of uint8 per color. 24 | outImg = *image.NewRGBA(outRect) 25 | var ( 26 | r, g, b, a uint32 27 | ) 28 | for x := 0; x < outImgSize.X; x++ { 29 | for y := 0; y < outImgSize.Y; y++ { 30 | r, g, b, a = inImg.At( 31 | x*inImgSize.X/outImgSize.X+xMin, 32 | y*inImgSize.Y/outImgSize.Y+yMin).RGBA() 33 | outImg.Set(x, y, color.RGBA{ 34 | uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}) 35 | } 36 | } 37 | return outImg, inImgSize 38 | } 39 | -------------------------------------------------------------------------------- /resample_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | package images 5 | 6 | import ( 7 | "image" 8 | "path" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestResampleByNearest(t *testing.T) { 14 | testDir := "testdata" 15 | tables := []struct { 16 | inFile string 17 | inImgSize image.Point 18 | outFile string 19 | outImgSize image.Point 20 | }{ 21 | {"original.png", image.Point{533, 400}, 22 | "nearest100x100.png", image.Point{100, 100}}, 23 | {"nearest100x100.png", image.Point{100, 100}, 24 | "nearest533x400.png", image.Point{533, 400}}, 25 | } 26 | 27 | for _, table := range tables { 28 | inImg, err := Open(path.Join(testDir, table.inFile)) 29 | if err != nil { 30 | t.Error("Cannot decode", path.Join(testDir, table.inFile)) 31 | } 32 | outImg, err := Open(path.Join(testDir, table.outFile)) 33 | if err != nil { 34 | t.Error("Cannot decode", path.Join(testDir, table.outFile)) 35 | } 36 | resampled, inImgSize := ResampleByNearest(inImg, table.outImgSize) 37 | if !reflect.DeepEqual( 38 | outImg.(*image.RGBA), &resampled) || table.inImgSize != inImgSize { 39 | t.Errorf( 40 | "Resample data do not match for %s and %s.", 41 | table.inFile, table.outFile) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /save.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | package images 5 | 6 | import ( 7 | "image" 8 | "image/gif" 9 | "image/jpeg" 10 | "image/png" 11 | "log" 12 | "os" 13 | ) 14 | 15 | // Gif saves image.RGBA to a file. 16 | func Gif(img *image.RGBA, path string) { 17 | if destFile, err := os.Create(path); err != nil { 18 | log.Println("Cannot create file: ", path, err) 19 | } else { 20 | defer destFile.Close() 21 | gif.Encode(destFile, img, &gif.Options{ 22 | NumColors: 256, Quantizer: nil, Drawer: nil}) 23 | } 24 | return 25 | } 26 | 27 | // Png saves image.RGBA to a file. 28 | func Png(img *image.RGBA, path string) { 29 | if destFile, err := os.Create(path); err != nil { 30 | log.Println("Cannot create file: ", path, err) 31 | } else { 32 | defer destFile.Close() 33 | png.Encode(destFile, img) 34 | } 35 | return 36 | } 37 | 38 | // Jpg saves image.RGBA to a file. 39 | func Jpg(img *image.RGBA, path string, quality int) { 40 | if destFile, err := os.Create(path); err != nil { 41 | log.Println("Cannot create file: ", path, err) 42 | } else { 43 | defer destFile.Close() 44 | jpeg.Encode(destFile, img, &jpeg.Options{Quality: quality}) 45 | } 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /similarity.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | package images 5 | 6 | import ( 7 | "image" 8 | ) 9 | 10 | const ( 11 | // Color similarity parameters. 12 | 13 | // Side dimension of a mask. 14 | maskSize = 24 15 | // Side dimension (in pixels) of a downsample square to reasonably well 16 | // approximate color area of a full size image. 17 | downsampleSize = 12 18 | 19 | // Cutoff value for color distance. 20 | colorDiff = 50 21 | // Cutoff coefficient for Euclidean distance (squared). 22 | euclCoeff = 0.2 23 | // Cutoff coefficient for color sign correlation. 24 | corrCoeff = 0.7 25 | 26 | // Size similarity parameter. 27 | sizeThreshold = 0.1 28 | ) 29 | 30 | // Masks generates masks, each of which will be used to calculate an image hash. 31 | // Conceptually a mask is a black square image with few white pixels used for 32 | // average color calculation. In the function output a mask is a map with keys 33 | // corresponding to white pixel coordinates only, because black pixels are 34 | // redundant. In this particular implementation white pixels form 3x3 squares. 35 | func masks() []map[image.Point]bool { 36 | ms := make([]map[image.Point]bool, 0) 37 | for x := 1; x < maskSize-1; x++ { 38 | for y := 1; y < maskSize-1; y++ { 39 | maskPixels := make(map[image.Point]bool) 40 | for dx := -1; dx < 2; dx++ { 41 | for dy := -1; dy < 2; dy++ { 42 | maskPixels[image.Point{x + dx, y + dy}] = true 43 | } 44 | } 45 | ms = append(ms, maskPixels) 46 | } 47 | } 48 | return ms 49 | } 50 | 51 | // Making masks. 52 | var ms = masks() 53 | 54 | // Number of masks. 55 | var numMasks = len(ms) 56 | 57 | // Hash calculates a slice of average color values of an image at the position 58 | // of white pixels of a mask. One average value corresponds to one mask. 59 | // The function also returns the original image width and height. 60 | func Hash(img image.Image) (h []float32, 61 | imgSize image.Point) { 62 | // Image is resampled to the mask size. Since masks are square the images 63 | // also are made square for image comparison. 64 | resImg, imgSize := ResampleByNearest(img, 65 | image.Point{maskSize * downsampleSize, maskSize * downsampleSize}) 66 | h = make([]float32, numMasks) 67 | var ( 68 | x, y int 69 | r, g, b, sum, s uint32 70 | ) 71 | // For each mask. 72 | for i := 0; i < numMasks; i++ { 73 | sum, s = 0, 0 74 | // For each white pixel of a mask. 75 | for w := range ms[i] { 76 | x, y = w.X, w.Y 77 | // For each pixel of resImg corresponding to the white mask pixel 78 | // above. 79 | for m := 0; m < downsampleSize; m++ { 80 | for n := 0; n < downsampleSize; n++ { 81 | // Alpha channel is not used for image comparison. 82 | r, g, b, _ = 83 | resImg.At(x*downsampleSize+m, y*downsampleSize+n).RGBA() 84 | // A cycle over the mask numbers to calculate average value 85 | // for different color channels. 86 | switch i % 3 { 87 | case 0: 88 | sum += r 89 | s++ 90 | case 1: 91 | sum += g 92 | s++ 93 | case 2: 94 | sum += b 95 | s++ 96 | } 97 | } 98 | } 99 | } 100 | h[i] = float32(sum) / float32(s*255) 101 | } 102 | return h, imgSize 103 | } 104 | 105 | // Euclidean distance threshold (squared). 106 | var euclDist2 = float32(numMasks) * float32(colorDiff*colorDiff) * euclCoeff 107 | 108 | // Similar function gives a verdict for image A and B based on their hashes and 109 | // sizes. The input parameters are generated with the Hash function. 110 | func Similar(hA, hB []float32, imgSizeA, imgSizeB image.Point) bool { 111 | 112 | // Filter 1 based on rescaling a narrower side of images to 1, 113 | // then cutting off at sizeThreshold of a longer image vs shorter image. 114 | xA, yA := float32(imgSizeA.X), float32(imgSizeA.Y) 115 | xB, yB := float32(imgSizeB.X), float32(imgSizeB.Y) 116 | var delta float32 117 | if xA <= yA { // x to 1. 118 | yA = yA / xA 119 | yB = yB / xB 120 | if yA > yB { 121 | delta = (yA - yB) / yA 122 | } else { 123 | delta = (yB - yA) / yB 124 | } 125 | } else { // y to 1. 126 | xA = xA / yA 127 | xB = xB / yB 128 | if xA > xB { 129 | delta = (xA - xB) / xA 130 | } else { 131 | delta = (xB - xA) / xB 132 | } 133 | } 134 | if delta > sizeThreshold { 135 | return false 136 | } 137 | 138 | // Filter 2a. Euclidean distance. 139 | var sum float32 140 | for i := 0; i < numMasks; i++ { 141 | sum += (hA[i] - hB[i]) * (hA[i] - hB[i]) 142 | } 143 | if sum > euclDist2 { 144 | return false 145 | } 146 | 147 | // Filter 3. Pixel brightness sign correlation test. 148 | sum = 0.0 149 | for i := 0; i < numMasks-1; i++ { 150 | if (hA[i] < hA[i+1]) && (hB[i] < hB[i+1]) || 151 | (hA[i] == hA[i+1]) && (hB[i] == hB[i+1]) || 152 | (hA[i] > hA[i+1]) && (hB[i] > hB[i+1]) { 153 | sum++ 154 | } 155 | } 156 | if sum < float32(numMasks)*corrCoeff { 157 | return false 158 | } 159 | 160 | // Filter 2b. Euclidean distance with normalized histogram. 161 | sum = 0.0 162 | hA, hB = normalize(hA), normalize(hB) 163 | for i := 0; i < numMasks; i++ { 164 | sum += (hA[i] - hB[i]) * (hA[i] - hB[i]) 165 | } 166 | if sum > euclDist2 { 167 | return false 168 | } 169 | 170 | return true 171 | } 172 | 173 | // normalize stretches histograms for the 3 channels of the hashes, so that 174 | // minimum and maximum values of each are 0 and 255 correspondingly. 175 | func normalize(h []float32) []float32 { 176 | normalized := make([]float32, numMasks) 177 | var rMin, gMin, bMin, rMax, gMax, bMax float32 178 | rMin, gMin, bMin = 256, 256, 256 179 | rMax, gMax, bMax = 0, 0, 0 180 | // Looking for extreme values. 181 | for n := 0; n < numMasks; n += 3 { 182 | if h[n] > rMax { 183 | rMax = h[n] 184 | } 185 | if h[n] < rMin { 186 | rMin = h[n] 187 | } 188 | } 189 | for n := 1; n < numMasks; n += 3 { 190 | if h[n] > gMax { 191 | gMax = h[n] 192 | } 193 | if h[n] < gMin { 194 | gMin = h[n] 195 | } 196 | } 197 | for n := 2; n < numMasks; n += 3 { 198 | if h[n] > bMax { 199 | bMax = h[n] 200 | } 201 | if h[n] < bMin { 202 | bMin = h[n] 203 | } 204 | } 205 | // Normalization. 206 | rMM := rMax - rMin 207 | gMM := gMax - gMin 208 | bMM := bMax - bMin 209 | for n := 0; n < numMasks; n += 3 { 210 | normalized[n] = (h[n] - rMin) * 255 / rMM 211 | } 212 | for n := 1; n < numMasks; n += 3 { 213 | normalized[n] = (h[n] - gMin) * 255 / gMM 214 | } 215 | for n := 2; n < numMasks; n += 3 { 216 | normalized[n] = (h[n] - bMin) * 255 / bMM 217 | } 218 | 219 | return normalized 220 | } 221 | 222 | // SimilarCustom function returns similarity metrics instead of 223 | // returning a verdict as the function Similar does. Then you can 224 | // decide on your own which thresholds to use for each filter, 225 | // or filter/rank by combining the values. 226 | // delta is image proportions metric (sizeThreshold in func Similar). 227 | // The larger the delta the more distinct are images by their 228 | // proportions. 229 | // euc is Euclidean metric. The larger euc is the more distinct 230 | // are images visually. 231 | // eucNorm is Euclidean metric for normalized hash values 232 | // (visially similar to autolevels in graphic editors). 233 | // The larger eucNorm is the more distinct are images visually after 234 | // autolevelling (normalization). 235 | // corr is sign correlation of color values. The larger the value 236 | // the more similar are images (correlate more to each other). 237 | // Demo returned values are used in TestSimilarCustom function. 238 | func SimilarCustom(hA, hB []float32, imgSizeA, imgSizeB image.Point) ( 239 | delta, euc, eucNorm, corr float32) { 240 | 241 | // Filter 1 based on rescaling a narrower side of images to 1, 242 | // then cutting off at sizeThreshold of a longer image vs shorter image. 243 | xA, yA := float32(imgSizeA.X), float32(imgSizeA.Y) 244 | xB, yB := float32(imgSizeB.X), float32(imgSizeB.Y) 245 | if xA <= yA { // x to 1. 246 | yA = yA / xA 247 | yB = yB / xB 248 | if yA > yB { 249 | delta = (yA - yB) / yA 250 | } else { 251 | delta = (yB - yA) / yB 252 | } 253 | } else { // y to 1. 254 | xA = xA / yA 255 | xB = xB / yB 256 | if xA > xB { 257 | delta = (xA - xB) / xA 258 | } else { 259 | delta = (xB - xA) / xB 260 | } 261 | } 262 | 263 | // Filter 2a. Euclidean distance. 264 | for i := 0; i < numMasks; i++ { 265 | euc += (hA[i] - hB[i]) * (hA[i] - hB[i]) 266 | } 267 | 268 | // Filter 2b. Euclidean distance with normalized histogram. 269 | hA, hB = normalize(hA), normalize(hB) 270 | for i := 0; i < numMasks; i++ { 271 | eucNorm += (hA[i] - hB[i]) * (hA[i] - hB[i]) 272 | } 273 | 274 | // Filter 3. Pixel brightness sign correlation test. 275 | for i := 0; i < numMasks-1; i++ { 276 | if (hA[i] < hA[i+1]) && (hB[i] < hB[i+1]) || 277 | (hA[i] == hA[i+1]) && (hB[i] == hB[i+1]) || 278 | (hA[i] > hA[i+1]) && (hB[i] > hB[i+1]) { 279 | corr++ 280 | } 281 | } 282 | 283 | return delta, euc, eucNorm, corr 284 | } 285 | -------------------------------------------------------------------------------- /similarity_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Vitali Fedulov. All rights reserved. Use of this source code 2 | // is governed by a MIT-style license that can be found in the LICENSE file. 3 | 4 | package images 5 | 6 | import ( 7 | "image" 8 | "path" 9 | "testing" 10 | ) 11 | 12 | func TestMasks(t *testing.T) { 13 | ms := masks() 14 | numMasks := (maskSize - 2) * (maskSize - 2) 15 | expectedNumMasks := len(ms) 16 | if len(ms) != numMasks { 17 | t.Errorf("Number of masks %d does not match expected value %d.", 18 | numMasks, expectedNumMasks) 19 | } 20 | for i := range ms { 21 | if len(ms[i]) > 3*3 { 22 | t.Errorf("Number of mask white pixels %d is more than 3*3.", 23 | len(ms[i])) 24 | } 25 | } 26 | } 27 | 28 | func TestHash(t *testing.T) { 29 | testDir := "testdata" 30 | testFile := "small.jpg" 31 | img, err := Open(path.Join(testDir, testFile)) 32 | if err != nil { 33 | t.Error("Error opening image:", err) 34 | return 35 | } 36 | h, imgSize := Hash(img) 37 | if len(h) == 0 { 38 | t.Errorf("Number of h %d must not be 0.", len(h)) 39 | } 40 | if imgSize != (image.Point{267, 200}) { 41 | t.Errorf( 42 | "Calculated imgSize %d is not equal to the size from image properties %d.", 43 | imgSize, image.Point{267, 200}) 44 | } 45 | var allZeroOrLessCounter int 46 | for i := range h { 47 | if h[i] > 255 { 48 | t.Errorf("h[i] %f is larger than 255.", h[i]) 49 | break 50 | } 51 | if h[i] <= 0 { 52 | allZeroOrLessCounter++ 53 | } 54 | } 55 | if allZeroOrLessCounter == len(h) { 56 | t.Error("All h[i] are 0 or less.") 57 | } 58 | } 59 | 60 | func TestSimilar(t *testing.T) { 61 | testDir := "testdata" 62 | imgFiles := []string{ 63 | "flipped.jpg", "large.jpg", "small.jpg", "distorted.jpg"} 64 | hashes := make([][]float32, len(imgFiles)) 65 | imgSizeAll := make([]image.Point, len(imgFiles)) 66 | for i := range imgFiles { 67 | img, err := Open(path.Join(testDir, imgFiles[i])) 68 | if err != nil { 69 | t.Error("Error opening image:", err) 70 | return 71 | } 72 | hashes[i], imgSizeAll[i] = Hash(img) 73 | } 74 | if !Similar(hashes[1], hashes[2], imgSizeAll[1], imgSizeAll[2]) { 75 | t.Errorf("Expected similarity between %s and %s.", 76 | imgFiles[1], imgFiles[2]) 77 | } 78 | if Similar(hashes[1], hashes[0], imgSizeAll[1], imgSizeAll[0]) { 79 | t.Errorf("Expected non-similarity between %s and %s.", 80 | imgFiles[1], imgFiles[0]) 81 | } 82 | if Similar(hashes[1], hashes[3], imgSizeAll[1], imgSizeAll[3]) { 83 | t.Errorf("Expected non-similarity between %s and %s.", 84 | imgFiles[1], imgFiles[3]) 85 | } 86 | } 87 | 88 | func testProportions(fA, fB string, isSimilar bool, 89 | t *testing.T) { 90 | p := path.Join("testdata", "proportions") 91 | imgA, err := Open(path.Join(p, fA)) 92 | if err != nil { 93 | t.Error("Error opening image:", err) 94 | } 95 | imgB, err := Open(path.Join(p, fB)) 96 | if err != nil { 97 | t.Error("Error opening image:", err) 98 | } 99 | 100 | hA, sA := Hash(imgA) 101 | hB, sB := Hash(imgB) 102 | 103 | if isSimilar == true { 104 | if !Similar(hA, hB, sA, sB) { 105 | t.Errorf("Expecting similarity of %v to %v.", fA, fB) 106 | } 107 | } 108 | if isSimilar == false { 109 | if Similar(hA, hB, sA, sB) { 110 | t.Errorf("Expecting non-similarity of %v to %v.", fA, fB) 111 | } 112 | } 113 | } 114 | 115 | func TestSimilarByProportions(t *testing.T) { 116 | testProportions("100x130.png", "100x124.png", true, t) 117 | testProportions("100x130.png", "100x122.png", true, t) 118 | testProportions("130x100.png", "260x200.png", true, t) 119 | testProportions("200x200.png", "260x200.png", false, t) 120 | testProportions("130x100.png", "124x100.png", true, t) 121 | testProportions("130x100.png", "122x100.png", true, t) 122 | testProportions("130x100.png", "130x100.png", true, t) 123 | testProportions("100x130.png", "130x100.png", false, t) 124 | testProportions("124x100.png", "260x200.png", true, t) 125 | testProportions("122x100.png", "260x200.png", true, t) 126 | testProportions("100x124.png", "100x130.png", true, t) 127 | } 128 | 129 | func TestSimilarCustom(t *testing.T) { 130 | testDir := "testdata" 131 | imgFiles := []string{ 132 | "flipped.jpg", "large.jpg", "small.jpg", "distorted.jpg"} 133 | hashes := make([][]float32, len(imgFiles)) 134 | imgSizeAll := make([]image.Point, len(imgFiles)) 135 | for i := range imgFiles { 136 | img, err := Open(path.Join(testDir, imgFiles[i])) 137 | if err != nil { 138 | t.Error("Error opening image:", err) 139 | return 140 | } 141 | hashes[i], imgSizeAll[i] = Hash(img) 142 | } 143 | 144 | delta, euc, eucNorm, corr := SimilarCustom( 145 | hashes[1], hashes[2], imgSizeAll[1], imgSizeAll[2]) 146 | 147 | // Expected similarity. 148 | if delta > 0.1 || euc > 242000 || eucNorm > 242000 || corr < 340 { 149 | t.Errorf("Expected delta, euc, eucNorm, corr got %v, %v, %v, %v", 150 | delta, euc, eucNorm, corr) 151 | } 152 | 153 | delta, euc, eucNorm, corr = SimilarCustom( 154 | hashes[1], hashes[0], imgSizeAll[1], imgSizeAll[0]) 155 | 156 | // Expected non-similarity. 157 | if !(delta > 0.1 || euc > 242000 || eucNorm > 242000 || corr < 340) { 158 | t.Errorf("Expected delta, euc, eucNorm, corr got %v, %v, %v, %v", 159 | delta, euc, eucNorm, corr) 160 | } 161 | 162 | delta, euc, eucNorm, corr = SimilarCustom( 163 | hashes[1], hashes[3], imgSizeAll[1], imgSizeAll[3]) 164 | 165 | // Expected non-similarity. 166 | if !(delta > 0.1 || euc > 242000 || eucNorm > 242000 || corr < 340) { 167 | t.Errorf("Expected delta, euc, eucNorm, corr got %v, %v, %v, %v", 168 | delta, euc, eucNorm, corr) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /testdata/distorted.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/distorted.jpg -------------------------------------------------------------------------------- /testdata/flipped.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/flipped.jpg -------------------------------------------------------------------------------- /testdata/large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/large.jpg -------------------------------------------------------------------------------- /testdata/nearest100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/nearest100x100.png -------------------------------------------------------------------------------- /testdata/nearest533x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/nearest533x400.png -------------------------------------------------------------------------------- /testdata/original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/original.png -------------------------------------------------------------------------------- /testdata/proportions/100x122.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/100x122.png -------------------------------------------------------------------------------- /testdata/proportions/100x124.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/100x124.png -------------------------------------------------------------------------------- /testdata/proportions/100x130.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/100x130.png -------------------------------------------------------------------------------- /testdata/proportions/122x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/122x100.png -------------------------------------------------------------------------------- /testdata/proportions/124x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/124x100.png -------------------------------------------------------------------------------- /testdata/proportions/130x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/130x100.png -------------------------------------------------------------------------------- /testdata/proportions/200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/200x200.png -------------------------------------------------------------------------------- /testdata/proportions/260x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/proportions/260x200.png -------------------------------------------------------------------------------- /testdata/small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitali-fedulov/images/c9363349f88147f4583b9e9b02610fae5ff1c68a/testdata/small.jpg --------------------------------------------------------------------------------