├── .gitignore
├── .rspec
├── .rubocop.yml
├── .travis.yml
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
├── console
└── setup
├── examples
└── performance.rb
├── imatcher.gemspec
├── lib
├── imatcher.rb
└── imatcher
│ ├── color_methods.rb
│ ├── image.rb
│ ├── matcher.rb
│ ├── modes.rb
│ ├── modes
│ ├── base.rb
│ ├── delta.rb
│ ├── grayscale.rb
│ └── rgb.rb
│ ├── rectangle.rb
│ ├── result.rb
│ └── version.rb
└── spec
├── fixtures
├── a.png
├── a1.png
├── b.png
├── darker.png
├── delta_diff.png
├── exclude.png
├── grayscale_diff.png
├── include.png
├── rgb_diff.png
├── small.png
└── very_small.png
├── image_spec.rb
├── imatcher_spec.rb
├── integrations
├── delta_spec.rb
├── grayscale_spec.rb
└── rgb_spec.rb
├── matcher_spec.rb
├── rectangle_spec.rb
└── spec_helper.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | # Numerous always-ignore extensions
2 | *.diff
3 | *.err
4 | *.orig
5 | *.log
6 | *.rej
7 | *.swo
8 | *.swp
9 | *.vi
10 | *~
11 | *.sass-cache
12 | *.iml
13 | .idea/
14 |
15 | # Sublime
16 | *.sublime-project
17 | *.sublime-workspace
18 |
19 | # OS or Editor folders
20 | .DS_Store
21 | .cache
22 | .project
23 | .settings
24 | .tmproj
25 | Thumbs.db
26 | *.gem
27 |
28 | /.bundle/
29 | /.yardoc
30 | /Gemfile.lock
31 | /_yardoc/
32 | /coverage/
33 | /doc/
34 | /pkg/
35 | /spec/reports/
36 | /tmp/
37 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | # Include gemspec and Rakefile
3 | Include:
4 | - 'lib/**/*.rb'
5 | - 'lib/**/*.rake'
6 | - 'spec/**/*.rb'
7 | Exclude:
8 | - 'bin/**/*'
9 | - 'spec/dummy/**/*'
10 | Rails:
11 | Enabled: false
12 | DisplayCopNames: true
13 | StyleGuideCopsOnly: false
14 |
15 | Style/AccessorMethodName:
16 | Enabled: false
17 |
18 | Style/TrivialAccessors:
19 | Enabled: false
20 |
21 | Style/Documentation:
22 | Exclude:
23 | - 'spec/**/*.rb'
24 |
25 | Style/StringLiterals:
26 | Enabled: false
27 |
28 | Style/SpaceInsideStringInterpolation:
29 | EnforcedStyle: no_space
30 |
31 | Style/BlockDelimiters:
32 | Exclude:
33 | - 'spec/**/*.rb'
34 |
35 | Style/ParallelAssignment:
36 | Enabled: false
37 |
38 | Lint/AmbiguousRegexpLiteral:
39 | Enabled: false
40 |
41 | Metrics/MethodLength:
42 | Exclude:
43 | - 'spec/**/*.rb'
44 |
45 | Metrics/LineLength:
46 | max: 100
47 | Exclude:
48 | - 'spec/**/*.rb'
49 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | cache: bundler
3 | rvm:
4 | - 2.2
5 | - ruby-head
6 | - jruby-head
7 |
8 | notifications:
9 | email: false
10 |
11 | before_install:
12 | - gem install bundler
13 |
14 | script: bundle exec rake
15 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem "rake", "~> 10.0"
4 | gem "rspec", "~> 3.0"
5 |
6 | if RUBY_PLATFORM =~ /java/
7 | gem "chunky_png", "~> 1.3.5"
8 | else
9 | gem "oily_png", "~> 1.2"
10 | end
11 |
12 | gem 'pry-byebug' if RUBY_VERSION >= "2.0.0" && RUBY_PLATFORM != 'java'
13 | local_gemfile = 'Gemfile.local'
14 |
15 | if File.exist?(local_gemfile)
16 | eval(File.read(local_gemfile)) # rubocop:disable Lint/Eval
17 | end
18 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 palkan
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://rubygems.org/gems/imatcher) [](https://travis-ci.org/teachbase/imatcher)
2 |
3 | # Imatcher
4 |
5 | Compare PNG images in pure Ruby (uses [ChunkyPNG](https://github.com/wvanbergen/chunky_png)) using different algorithms.
6 | This is an utility library for image regression testing.
7 |
8 | ## Installation
9 |
10 | Add this line to your application's Gemfile:
11 |
12 | ```ruby
13 | gem 'imatcher'
14 | ```
15 |
16 | And then execute:
17 |
18 | $ bundle
19 |
20 | Or install it yourself as:
21 |
22 | $ gem install imatcher
23 |
24 | Additionally, you may want to install [oily_png](https://github.com/wvanbergen/oily_png) to improve performance when using MRI. Just install it globally or add to your Gemfile.
25 |
26 | ## Modes
27 |
28 | Imatcher supports different ways (_modes_) of comparing images.
29 |
30 | Source images used in examples:
31 |
32 |
33 |
34 |
35 | ### Base (RGB) mode
36 |
37 | Compare pixels by values, resulting score is a ratio of unequal pixels.
38 | Resulting diff represents per-channel difference.
39 |
40 |
41 |
42 | ### Grayscale mode
43 |
44 | Compare pixels as grayscale (by brightness and alpha), resulting score is a ratio of unequal pixels (with respect to provided tolerance).
45 |
46 | Resulting diff contains grayscale version of the first image with different pixels highlighted in red and red bounding box.
47 |
48 |
49 |
50 | ### Delta
51 |
52 | Compare pixels using [Delta E](https://en.wikipedia.org/wiki/Color_difference) distance.
53 | Resulting diff contains grayscale version of the first image with different pixels highlighted in red (with respect to diff score).
54 |
55 |
56 |
57 | ## Usage
58 |
59 | ```ruby
60 | # create new matcher with default threshold equals to 0
61 | # and base (RGB) mode
62 | cmp = Imatcher::Matcher.new
63 | cmp.mode #=> Imatcher::Modes::RGB
64 |
65 | # create matcher with specific threshold
66 | cmp = Imatcher::Matcher.new threshold: 0.05
67 | cmp.threshold #=> 0.05
68 |
69 | # create zero-tolerance grayscale matcher
70 | cmp = Imatcher::Matcher.new mode: :grayscale, tolerance: 0
71 | cmp.mode #=> Imatcher::Modes::Grayscale
72 |
73 | res = cmp.compare(path_1, path_2)
74 | res #=> Imatcher::Result
75 |
76 | res.match? #=> true
77 |
78 | res.score #=> 0.0
79 |
80 | # Return diff image object
81 | res.difference_image #=> Imatcher::Image
82 |
83 | res.difference_image.save(new_path)
84 |
85 | # without explicit matcher
86 | res = Imatcher.compare(path_1, path_2, options)
87 |
88 | # equals to
89 | res = Imatcher::Matcher.new(options).compare(path_1, path_2)
90 |
91 | ```
92 |
93 | ## Excluding rectangle
94 |
95 |
96 |
97 |
98 | You can exclude rectangle from comparing by passing `:exclude_rect` to `compare`.
99 | E.g., if `path_1` and `path_2` contain images above
100 | ```ruby
101 | Imatcher.compare(path_1, path_2, exclude_rect: [200, 150, 275, 200]).match? # => true
102 | ```
103 | `[200, 150, 275, 200]` is array of two vertices of rectangle -- (200, 150) is left-top vertex and (275, 200) is right-bottom.
104 |
105 | ## Including rectangle
106 |
107 | You can set bounds of comparing by passing `:include_rect` to `compare` with array similar to previous example
108 |
109 | ## Contributing
110 |
111 | Bug reports and pull requests are welcome on GitHub at https://github.com/teachbase/imatcher.
112 |
113 | ## License
114 |
115 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
116 |
117 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rspec/core/rake_task"
3 |
4 | RSpec::Core::RakeTask.new(:spec)
5 |
6 | task :default => :spec
7 |
8 | task :console do
9 | sh 'pry -r ./lib/imatcher.rb'
10 | end
11 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "imatcher"
5 |
6 | require "pry"
7 | Pry.start
8 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
--------------------------------------------------------------------------------
/examples/performance.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2 | require 'benchmark/ips'
3 | require 'imatcher'
4 |
5 | a = Imatcher::Image.from_file(File.expand_path('../../spec/fixtures/a.png', __FILE__))
6 | b = Imatcher::Image.from_file(File.expand_path('../../spec/fixtures/a.png', __FILE__))
7 |
8 | rgb = Imatcher::Matcher.new
9 | grayscale = Imatcher::Matcher.new mode: :grayscale
10 | delta = Imatcher::Matcher.new mode: :delta
11 |
12 | Benchmark.ips do |x|
13 | x.report 'RGB' do
14 | rgb.compare(a, b)
15 | end
16 |
17 | x.report 'Grayscale' do
18 | grayscale.compare(a, b)
19 | end
20 |
21 | x.report 'Delta E' do
22 | delta.compare(a, b)
23 | end
24 |
25 | x.compare!
26 | end
27 |
28 | Benchmark.ips do |x|
29 | x.report 'RGB' do
30 | rgb.compare(a, b).difference_image
31 | end
32 |
33 | x.report 'Grayscale' do
34 | grayscale.compare(a, b).difference_image
35 | end
36 |
37 | x.report 'Delta E' do
38 | delta.compare(a, b).difference_image
39 | end
40 |
41 | x.compare!
42 | end
43 |
--------------------------------------------------------------------------------
/imatcher.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("../lib", __FILE__)
2 | require 'imatcher/version'
3 |
4 | Gem::Specification.new do |spec|
5 | spec.name = "imatcher"
6 | spec.version = Imatcher::VERSION
7 | spec.authors = ["palkan"]
8 | spec.email = ["dementiev.vm@gmail.com"]
9 | spec.summary = "Image comparison lib"
10 | spec.description = "Image comparison lib built on top of ChunkyPNG"
11 | spec.homepage = "http://github.com/teachbase/imatcher"
12 | spec.license = "MIT"
13 |
14 | spec.files = `git ls-files`.split($/)
15 | spec.require_paths = ["lib"]
16 |
17 | spec.add_dependency "chunky_png", "~> 1.3.5"
18 |
19 | spec.add_development_dependency "simplecov", ">= 0.3.8"
20 | spec.add_development_dependency "rake", "~> 10.0"
21 | spec.add_development_dependency "rspec", "~> 3.0"
22 | end
23 |
--------------------------------------------------------------------------------
/lib/imatcher.rb:
--------------------------------------------------------------------------------
1 | require "imatcher/version"
2 |
3 | # Compare PNG images using different algorithms
4 | module Imatcher
5 | class SizesMismatchError < StandardError
6 | end
7 |
8 | require 'imatcher/matcher'
9 | require 'imatcher/color_methods'
10 |
11 | def self.compare(path_a, path_b, options = {})
12 | Matcher.new(options).compare(path_a, path_b)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/imatcher/color_methods.rb:
--------------------------------------------------------------------------------
1 | require "chunky_png"
2 |
3 | begin
4 | require "oily_png" unless RUBY_PLATFORM == 'java'
5 | rescue LoadError # rubocop:disable Lint/HandleExceptions
6 | end
7 |
8 | module Imatcher
9 | module ColorMethods # :nodoc:
10 | include ChunkyPNG::Color
11 |
12 | def brightness(a)
13 | 0.3 * r(a) + 0.59 * g(a) + 0.11 * b(a)
14 | end
15 |
16 | def red
17 | rgb(255, 0, 0)
18 | end
19 |
20 | def green
21 | rgb(0, 255, 0)
22 | end
23 |
24 | def blue
25 | rgb(0, 0, 255)
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/imatcher/image.rb:
--------------------------------------------------------------------------------
1 | require 'imatcher/color_methods'
2 |
3 | module Imatcher
4 | # Extend ChunkyPNG::Image with some methods.
5 | class Image < ChunkyPNG::Image
6 | include ColorMethods
7 |
8 | def each_pixel
9 | height.times do |y|
10 | row(y).each_with_index do |pixel, x|
11 | yield(pixel, x, y)
12 | end
13 | end
14 | end
15 |
16 | def compare_each_pixel(image, area: nil)
17 | area = bounding_rect if area.nil?
18 | (area.top..area.bot).each do |y|
19 | range = (area.left..area.right)
20 | next if image.row(y).slice(range) == row(y).slice(range)
21 | (area.left..area.right).each do |x|
22 | yield(self[x, y], image[x, y], x, y)
23 | end
24 | end
25 | end
26 |
27 | def to_grayscale
28 | each_pixel do |pixel, x, y|
29 | self[x, y] = grayscale(brightness(pixel).round)
30 | end
31 | self
32 | end
33 |
34 | def with_alpha(value)
35 | each_pixel do |pixel, x, y|
36 | self[x, y] = rgba(r(pixel), g(pixel), b(pixel), value)
37 | end
38 | self
39 | end
40 |
41 | def sizes_match?(image)
42 | [width, height] == [image.width, image.height]
43 | end
44 |
45 | def inspect
46 | "Image:#{object_id}<#{width}x#{height}>"
47 | end
48 |
49 | def highlight_rectangle(rect, color = :red)
50 | fail ArgumentError, "Undefined color: #{color}" unless respond_to?(color)
51 | return self if rect.nil?
52 | rect(*rect.bounds, send(color))
53 | self
54 | end
55 |
56 | def bounding_rect
57 | Rectangle.new(0, 0, width - 1, height - 1)
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/imatcher/matcher.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | # Matcher contains information about compare mode
3 | class Matcher
4 | require 'imatcher/image'
5 | require 'imatcher/result'
6 | require 'imatcher/modes'
7 |
8 | MODES = {
9 | rgb: 'RGB',
10 | delta: 'Delta',
11 | grayscale: 'Grayscale'
12 | }.freeze
13 |
14 | attr_reader :threshold, :mode
15 |
16 | def initialize(options = {})
17 | mode_type = options.delete(:mode) || :rgb
18 | fail ArgumentError, "Undefined mode: #{ mode_type }" unless MODES.keys.include?(mode_type)
19 | @mode = Modes.const_get(MODES[mode_type]).new(options)
20 | end
21 |
22 | def compare(a, b)
23 | a = Image.from_file(a) unless a.is_a?(Image)
24 | b = Image.from_file(b) unless b.is_a?(Image)
25 |
26 | fail SizesMismatchError,
27 | "Size mismatch: first image size: " \
28 | "#{a.width}x#{a.height}, " \
29 | "second image size: " \
30 | "#{b.width}x#{b.height}" unless a.sizes_match?(b)
31 |
32 | image_area = Rectangle.new(0, 0, a.width - 1, a.height - 1)
33 |
34 | unless mode.exclude_rect.nil?
35 | fail ArgumentError,
36 | "Bounds must be in image" unless image_area.contains?(mode.exclude_rect)
37 | end
38 |
39 | unless mode.include_rect.nil?
40 | fail ArgumentError,
41 | "Bounds must be in image" unless image_area.contains?(mode.include_rect)
42 | unless mode.exclude_rect.nil?
43 | fail ArgumentError,
44 | "Included area must contain excluded" unless mode.include_rect.contains?(mode.exclude_rect)
45 | end
46 | end
47 |
48 | mode.compare(a, b)
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/imatcher/modes.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | module Modes # :nodoc:
3 | require 'imatcher/modes/rgb'
4 | require 'imatcher/modes/grayscale'
5 | require 'imatcher/modes/delta'
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/imatcher/modes/base.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | module Modes
3 | class Base # :nodoc:
4 | require 'imatcher/rectangle'
5 | include ColorMethods
6 |
7 | attr_reader :result, :threshold, :bounds, :exclude_rect, :include_rect
8 |
9 | def initialize(threshold: 0.0, exclude_rect: nil, include_rect: nil)
10 | @include_rect = Rectangle.new(*include_rect) unless include_rect.nil?
11 | @exclude_rect = Rectangle.new(*exclude_rect) unless exclude_rect.nil?
12 | @threshold = threshold
13 | @result = Result.new(self, threshold)
14 | end
15 |
16 | def compare(a, b)
17 | result.image = a
18 | @include_rect ||= a.bounding_rect
19 | @bounds = Rectangle.new(*include_rect.bounds)
20 |
21 | b.compare_each_pixel(a, area: include_rect) do |b_pixel, a_pixel, x, y|
22 | next if pixels_equal?(b_pixel, a_pixel)
23 | next if !exclude_rect.nil? && exclude_rect.contains_point?(x, y)
24 | update_result(b_pixel, a_pixel, x, y)
25 | end
26 |
27 | result.score = score
28 | result
29 | end
30 |
31 | def diff(bg, diff)
32 | diff_image = background(bg).highlight_rectangle(exclude_rect, :blue)
33 | diff.each do |pixels_pair|
34 | pixels_diff(diff_image, *pixels_pair)
35 | end
36 | create_diff_image(bg, diff_image).
37 | highlight_rectangle(bounds).
38 | highlight_rectangle(include_rect, :green)
39 | end
40 |
41 | def score
42 | result.diff.length.to_f / area
43 | end
44 |
45 | def update_result(*_args, x, y)
46 | update_bounds(x, y)
47 | end
48 |
49 | def update_bounds(x, y)
50 | bounds.left = [x, bounds.left].max
51 | bounds.top = [y, bounds.top].max
52 | bounds.right = [x, bounds.right].min
53 | bounds.bot = [y, bounds.bot].min
54 | end
55 |
56 | def area
57 | area = include_rect.area
58 | return area if exclude_rect.nil?
59 | area - exclude_rect.area
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/imatcher/modes/delta.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | module Modes # :nodoc:
3 | require 'imatcher/modes/base'
4 |
5 | # Compare pixels using Delta E distance.
6 | class Delta < Base
7 | attr_reader :tolerance
8 |
9 | def initialize(options)
10 | @tolerance = options.delete(:tolerance) || 0.01
11 | @delta_score = 0.0
12 | super(options)
13 | end
14 |
15 | private
16 |
17 | def pixels_equal?(a, b)
18 | a == b
19 | end
20 |
21 | def update_result(a, b, x, y)
22 | d = euclid(a, b) / (MAX * Math.sqrt(3))
23 | return if d <= tolerance
24 | @result.diff << [a, b, x, y, d]
25 | @delta_score += d
26 | super
27 | end
28 |
29 | def background(bg)
30 | Image.new(bg.width, bg.height, WHITE).with_alpha(0)
31 | end
32 |
33 | def euclid(a, b)
34 | Math.sqrt(
35 | (r(a) - r(b))**2 +
36 | (g(a) - g(b))**2 +
37 | (b(a) - b(b))**2
38 | )
39 | end
40 |
41 | def create_diff_image(bg, diff_image)
42 | bg.to_grayscale.compose!(diff_image, 0, 0)
43 | end
44 |
45 | def pixels_diff(d, *_args, x, y, a)
46 | d[x, y] = rgba(MAX, 0, 0, (a * MAX).round)
47 | end
48 |
49 | def score
50 | @delta_score / area
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/imatcher/modes/grayscale.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | module Modes # :nodoc:
3 | require 'imatcher/modes/base'
4 |
5 | # Compare pixels by alpha and brightness.
6 | #
7 | # Options:
8 | # - tolerance - defines the maximum allowed difference for alpha/brightness
9 | # (default value is 16)
10 | class Grayscale < Base
11 | DEFAULT_TOLERANCE = 16
12 |
13 | attr_reader :tolerance
14 |
15 | def initialize(options)
16 | @tolerance = options.delete(:tolerance) || DEFAULT_TOLERANCE
17 | super(options)
18 | end
19 |
20 | def pixels_equal?(a, b)
21 | alpha = color_similar?(a(a), a(b))
22 | brightness = color_similar?(brightness(a), brightness(b))
23 | brightness && alpha
24 | end
25 |
26 | def update_result(a, b, x, y)
27 | super
28 | @result.diff << [a, b, x, y]
29 | end
30 |
31 | def background(bg)
32 | bg.to_grayscale
33 | end
34 |
35 | def pixels_diff(d, _a, _b, x, y)
36 | d[x, y] = rgb(255, 0, 0)
37 | end
38 |
39 | def create_diff_image(_bg, diff_image)
40 | diff_image
41 | end
42 |
43 | def color_similar?(a, b)
44 | d = (a - b).abs
45 | d <= tolerance
46 | end
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/imatcher/modes/rgb.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | module Modes # :nodoc:
3 | require 'imatcher/modes/base'
4 |
5 | # Compare pixels by values.
6 | # Resulting image contains per-channel differences.
7 | class RGB < Base
8 | def pixels_equal?(a, b)
9 | a == b
10 | end
11 |
12 | def update_result(a, b, x, y)
13 | super
14 | @result.diff << [a, b, x, y]
15 | end
16 |
17 | def background(bg)
18 | Image.new(bg.width, bg.height, BLACK)
19 | end
20 |
21 | def create_diff_image(_bg, diff_image)
22 | diff_image
23 | end
24 |
25 | def pixels_diff(d, a, b, x, y)
26 | d[x, y] = rgb(
27 | (r(a) - r(b)).abs,
28 | (g(a) - g(b)).abs,
29 | (b(a) - b(b)).abs
30 | )
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/imatcher/rectangle.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | class Rectangle
3 | attr_accessor :left, :top, :right, :bot
4 |
5 | def initialize(l, t, r, b)
6 | @left = l
7 | @top = t
8 | @right = r
9 | @bot = b
10 | end
11 |
12 | def area
13 | (right - left + 1) * (bot - top + 1)
14 | end
15 |
16 | def contains?(rect)
17 | (left <= rect.left) &&
18 | (right >= rect.right) &&
19 | (top <= rect.top) &&
20 | (bot >= rect.bot)
21 | end
22 |
23 | def bounds
24 | [left, top, right, bot]
25 | end
26 |
27 | def contains_point?(x, y)
28 | x.between?(left, right) && y.between?(top, bot)
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/imatcher/result.rb:
--------------------------------------------------------------------------------
1 | module Imatcher
2 | # Object containing comparison score and diff image
3 | class Result
4 | attr_accessor :score, :image
5 | attr_reader :diff, :mode, :threshold
6 |
7 | def initialize(mode, threshold)
8 | @score = 0.0
9 | @diff = []
10 | @threshold = threshold
11 | @mode = mode
12 | end
13 |
14 | def difference_image
15 | @diff_image ||= mode.diff(image, diff)
16 | end
17 |
18 | # Returns true iff score less or equals to threshold
19 | def match?
20 | score <= threshold
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/imatcher/version.rb:
--------------------------------------------------------------------------------
1 | module Imatcher # :nodoc:
2 | VERSION = "0.1.9".freeze
3 | end
4 |
--------------------------------------------------------------------------------
/spec/fixtures/a.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/a.png
--------------------------------------------------------------------------------
/spec/fixtures/a1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/a1.png
--------------------------------------------------------------------------------
/spec/fixtures/b.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/b.png
--------------------------------------------------------------------------------
/spec/fixtures/darker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/darker.png
--------------------------------------------------------------------------------
/spec/fixtures/delta_diff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/delta_diff.png
--------------------------------------------------------------------------------
/spec/fixtures/exclude.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/exclude.png
--------------------------------------------------------------------------------
/spec/fixtures/grayscale_diff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/grayscale_diff.png
--------------------------------------------------------------------------------
/spec/fixtures/include.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/include.png
--------------------------------------------------------------------------------
/spec/fixtures/rgb_diff.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/rgb_diff.png
--------------------------------------------------------------------------------
/spec/fixtures/small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/small.png
--------------------------------------------------------------------------------
/spec/fixtures/very_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/teachbase/imatcher/7321f7e3f3be9d044a2989262f91d1181528c62b/spec/fixtures/very_small.png
--------------------------------------------------------------------------------
/spec/image_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Image do
4 | describe "highlight_rectangle" do
5 | let(:image) { described_class.new(10, 10, described_class::BLACK) }
6 | let(:rect) { Imatcher::Rectangle.new(0, 0, 1, 1) }
7 | subject { image.highlight_rectangle(rect, :deep_purple) }
8 |
9 | it { expect { subject }.to raise_error(ArgumentError) }
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/imatcher_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher do
4 | it 'has a version number' do
5 | expect(Imatcher::VERSION).not_to be nil
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/integrations/delta_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Modes::Delta do
4 | let(:path_1) { image_path "a" }
5 | let(:path_2) { image_path "darker" }
6 | subject { Imatcher.compare(path_1, path_2, options) }
7 |
8 | let(:options) { { mode: :delta } }
9 |
10 | context "with darker image" do
11 | it "score around 0.075" do
12 | expect(subject.score).to be_within(0.005).of(0.075)
13 | end
14 |
15 | context "with custom threshold" do
16 | subject { Imatcher.compare(path_1, path_2, options).match? }
17 |
18 | context "below score" do
19 | let(:options) { { mode: :delta, threshold: 0.01 } }
20 |
21 | it { expect(subject).to be_falsey }
22 | end
23 |
24 | context "above score" do
25 | let(:options) { { mode: :delta, threshold: 0.1 } }
26 |
27 | it { expect(subject).to be_truthy }
28 | end
29 | end
30 | end
31 |
32 | context "with different images" do
33 | let(:path_2) { image_path "b" }
34 |
35 | it "score around 0.0046" do
36 | expect(subject.score).to be_within(0.0001).of(0.0046)
37 | end
38 |
39 | it "creates correct difference image" do
40 | expect(subject.difference_image).to eq(Imatcher::Image.from_file(image_path("delta_diff")))
41 | end
42 |
43 | context "with high tolerance" do
44 | let(:options) { { mode: :delta, tolerance: 0.1 } }
45 |
46 | it "score around 0.0038" do
47 | expect(subject.score).to be_within(0.0001).of(0.0038)
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/spec/integrations/grayscale_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Modes::Grayscale do
4 | let(:path_1) { image_path "a" }
5 | let(:path_2) { image_path "darker" }
6 | subject { Imatcher.compare(path_1, path_2, options) }
7 |
8 | let(:options) { { mode: :grayscale } }
9 |
10 | context "darker image" do
11 | it "score around 0.95" do
12 | expect(subject.score).to be_within(0.05).of(0.95)
13 | end
14 | end
15 |
16 | context "different images" do
17 | let(:path_2) { image_path "b" }
18 |
19 | it "score around 0.005" do
20 | expect(subject.score).to be_within(0.001).of(0.005)
21 | end
22 |
23 | it "creates correct difference image" do
24 | expect(subject.difference_image).to eq(Imatcher::Image.from_file(image_path("grayscale_diff")))
25 | end
26 | end
27 |
28 | context "with zero tolerance" do
29 | let(:options) { { mode: :grayscale, tolerance: 0 } }
30 |
31 | context "darker image" do
32 | it "score equals to 1" do
33 | expect(subject.score).to eq 1.0
34 | end
35 | end
36 |
37 | context "different image" do
38 | let(:path_2) { image_path "b" }
39 |
40 | it "score around 0.016" do
41 | expect(subject.score).to be_within(0.001).of(0.016)
42 | end
43 | end
44 |
45 | context "equal image" do
46 | let(:path_2) { image_path "a" }
47 |
48 | it "score equals to 0" do
49 | expect(subject.score).to eq 0
50 | end
51 | end
52 | end
53 |
54 | context "with small tolerance" do
55 | let(:options) { { mode: :grayscale, tolerance: 8 } }
56 |
57 | context "darker image" do
58 | it "score around 0.96" do
59 | expect(subject.score).to be_within(0.005).of(0.96)
60 | end
61 | end
62 |
63 | context "different image" do
64 | let(:path_2) { image_path "b" }
65 |
66 | it "score around 0.006" do
67 | expect(subject.score).to be_within(0.0005).of(0.006)
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/spec/integrations/rgb_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Modes::RGB do
4 | let(:path_1) { image_path "a" }
5 | let(:path_2) { image_path "darker" }
6 | subject { Imatcher.compare(path_1, path_2, options) }
7 | let(:options) { {} }
8 |
9 | context "with darker" do
10 | it "score equals to 1" do
11 | expect(subject.score).to eq 1
12 | end
13 | end
14 |
15 | context "with different images" do
16 | let(:path_2) { image_path "b" }
17 |
18 | it "score around 0.016" do
19 | expect(subject.score).to be_within(0.001).of(0.016)
20 | end
21 |
22 | it "creates correct difference image" do
23 | expect(subject.difference_image).to eq(Imatcher::Image.from_file(image_path("rgb_diff")))
24 | end
25 | end
26 |
27 | context "exclude rect" do
28 | let(:options) { { exclude_rect: [200, 150, 275, 200] } }
29 | let(:path_2) { image_path "a1" }
30 | it { expect(subject.difference_image).to eq Imatcher::Image.from_file(image_path("exclude")) }
31 | it { expect(subject.score).to eq 0 }
32 |
33 | context "calculates score correctly" do
34 | let(:path_2) { image_path "darker" }
35 |
36 | it { expect(subject.score).to eq 1 }
37 | end
38 | end
39 |
40 | context "include rect" do
41 | let(:options) { { include_rect: [0, 0, 100, 100] } }
42 | let(:path_2) { image_path "a1" }
43 | it { expect(subject.difference_image).to eq Imatcher::Image.from_file(image_path("include")) }
44 | it { expect(subject.score).to eq 0 }
45 |
46 | context "calculates score correctly" do
47 | let(:path_2) { image_path "darker" }
48 | it { expect(subject.score).to eq 1 }
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/spec/matcher_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Matcher do
4 | describe "new" do
5 | subject { Imatcher::Matcher.new(options) }
6 |
7 | context "without options" do
8 | let(:options) { {} }
9 |
10 | it { expect(subject.mode.threshold).to eq 0 }
11 | it { expect(subject.mode).to be_a Imatcher::Modes::RGB }
12 | end
13 |
14 | context "with custom threshold" do
15 | let(:options) { { threshold: 0.1 } }
16 |
17 | it { expect(subject.mode.threshold).to eq 0.1 }
18 | end
19 |
20 | context "with custom options" do
21 | let(:options) { { mode: :grayscale, tolerance: 0 } }
22 |
23 | it { expect(subject.mode.tolerance).to eq 0 }
24 | end
25 |
26 | context "with custom mode" do
27 | let(:options) { { mode: :delta } }
28 |
29 | it { expect(subject.mode).to be_a Imatcher::Modes::Delta }
30 | end
31 |
32 | context "with undefined mode" do
33 | let(:options) { { mode: :gamma } }
34 |
35 | it { expect { subject }.to raise_error(ArgumentError) }
36 | end
37 | end
38 |
39 | describe "compare" do
40 | let(:path_1) { image_path "very_small" }
41 | let(:path_2) { image_path "very_small" }
42 | let(:options) { {} }
43 | subject { Imatcher.compare(path_1, path_2, options) }
44 |
45 | it { expect(subject).to be_a Imatcher::Result }
46 |
47 | context "when sizes mismatch" do
48 | let(:path_2) { image_path "small" }
49 | it { expect { subject }.to raise_error Imatcher::SizesMismatchError }
50 | end
51 |
52 | context "with negative exclude rect bounds" do
53 | let(:options) { { exclude_rect: [-1, -1, -1, -1] } }
54 | it { expect { subject }.to raise_error ArgumentError }
55 | end
56 |
57 | context "with big exclude rect bounds" do
58 | let(:options) { { exclude_rect: [100, 100, 100, 100] } }
59 | it { expect { subject }.to raise_error ArgumentError }
60 | end
61 |
62 | context "with negative include rect bounds" do
63 | let(:options) { { include_rect: [-1, -1, -1, -1] } }
64 | it { expect { subject }.to raise_error ArgumentError }
65 | end
66 |
67 | context "with big include rect bounds" do
68 | let(:options) { { include_rect: [100, 100, 100, 100] } }
69 | it { expect { subject }.to raise_error ArgumentError }
70 | end
71 |
72 | context "with wrong include and exclude rects combination" do
73 | let(:options) { { include_rect: [1, 1, 2, 2], exclude_rect: [0, 0, 1, 1] } }
74 | it { expect { subject }.to raise_error ArgumentError }
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/spec/rectangle_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Imatcher::Rectangle do
4 | let(:rect) { described_class.new(0, 0, 9, 9) }
5 |
6 | describe 'area' do
7 | subject { rect.area }
8 |
9 | it { expect(subject).to eq 100 }
10 | end
11 |
12 | describe "contains?" do
13 | let(:rect2) { described_class.new(1, 1, 8, 8) }
14 | subject { rect.contains?(rect2) }
15 |
16 | it { expect(subject).to be_truthy }
17 |
18 | context "when does not contain" do
19 | let(:rect2) { described_class.new(2, 2, 10, 10) }
20 |
21 | it { expect(subject).to be_falsey }
22 | end
23 | end
24 |
25 | describe "contains_point?" do
26 | let(:point) { [5, 5] }
27 | subject { rect.contains_point?(*point) }
28 |
29 | it { expect(subject).to be_truthy }
30 |
31 | context "when does not contain" do
32 | let(:point) { [10, 10] }
33 |
34 | it { expect(subject).to be_falsey }
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2 |
3 | require 'rspec'
4 | require 'pry-byebug' if RUBY_VERSION >= "2.0.0" && RUBY_PLATFORM != 'java'
5 |
6 | if ENV['COVER']
7 | require 'simplecov'
8 | SimpleCov.root File.join(File.dirname(__FILE__), '..')
9 | SimpleCov.start
10 | end
11 |
12 | require 'imatcher'
13 |
14 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
15 |
16 | RSpec.configure do |config|
17 | config.mock_with :rspec
18 | end
19 |
20 | def image_path(name)
21 | "#{File.dirname(__FILE__)}/fixtures/#{name}.png"
22 | end
23 |
--------------------------------------------------------------------------------