├── .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 | [![Gem Version](https://badge.fury.io/rb/imatcher.svg)](https://rubygems.org/gems/imatcher) [![Build Status](https://travis-ci.org/teachbase/imatcher.svg?branch=master)](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 | --------------------------------------------------------------------------------