lib/capybara/screenshot/diff/image_compare.rb in capybara-screenshot-diff-1.3.1 vs lib/capybara/screenshot/diff/image_compare.rb in capybara-screenshot-diff-1.4.0
- old
+ new
@@ -1,390 +1,239 @@
# frozen_string_literal: true
-require 'chunky_png'
-
module Capybara
module Screenshot
module Diff
+ LOADED_DRIVERS = {}
+
# Compare two images and determine if they are equal, different, or within some comparison
# range considering color values and difference area size.
- class ImageCompare
- include ChunkyPNG::Color
+ class ImageCompare < SimpleDelegator
+ attr_reader :driver, :driver_options
attr_reader :annotated_new_file_name, :annotated_old_file_name, :area_size_limit,
- :color_distance_limit, :new_file_name, :old_file_name, :shift_distance_limit, :skip_area
+ :color_distance_limit, :new_file_name, :old_file_name, :shift_distance_limit,
+ :skip_area
- def initialize(new_file_name, old_file_name = nil, dimensions: nil, color_distance_limit: nil,
- area_size_limit: nil, shift_distance_limit: nil, skip_area: nil)
+ def initialize(new_file_name, old_file_name = nil, **driver_options)
@new_file_name = new_file_name
- @color_distance_limit = color_distance_limit
- @area_size_limit = area_size_limit
- @shift_distance_limit = shift_distance_limit
- @dimensions = dimensions
- @skip_area = skip_area
@old_file_name = old_file_name || "#{new_file_name}~"
- @annotated_old_file_name = "#{new_file_name.chomp('.png')}.committed.png"
- @annotated_new_file_name = "#{new_file_name.chomp('.png')}.latest.png"
- reset
- end
+ @annotated_old_file_name = "#{new_file_name.chomp(".png")}.committed.png"
+ @annotated_new_file_name = "#{new_file_name.chomp(".png")}.latest.png"
- # Resets the calculated data about the comparison with regard to the "new_image".
- # Data about the original image is kept.
- def reset
- @max_color_distance = @color_distance_limit ? 0 : nil
- @max_shift_distance = @shift_distance_limit ? 0 : nil
- @left = @top = @right = @bottom = nil
+ @driver_options = driver_options
+
+ @color_distance_limit = driver_options[:color_distance_limit] || 0
+ @area_size_limit = driver_options[:area_size_limit]
+ @shift_distance_limit = driver_options[:shift_distance_limit]
+ @dimensions = driver_options[:dimensions]
+ @skip_area = driver_options[:skip_area]
+ @tolerance = driver_options[:tolerance]
+ @median_filter_window_size = driver_options[:median_filter_window_size]
+
+ driver_klass = find_driver_class_for(@driver_options.fetch(:driver, :chunky_png))
+ @driver = driver_klass.new(@new_file_name, @old_file_name, **@driver_options)
+
+ super(@driver)
end
# Compare the two image files and return `true` or `false` as quickly as possible.
# Return falsish if the old file does not exist or the image dimensions do not match.
def quick_equal?
- return nil unless old_file_exists?
+ return false unless old_file_exists?
return true if new_file_size == old_file_size
- old_bytes, new_bytes = load_image_files(@old_file_name, @new_file_name)
- return true if old_bytes == new_bytes
+ # old_bytes, new_bytes = load_image_files(@old_file_name, @new_file_name)
+ # return true if old_bytes == new_bytes
- images = load_images(old_bytes, new_bytes)
- old_bytes = new_bytes = nil # rubocop: disable Lint/UselessAssignment
- crop_images(images, @dimensions) if @dimensions
+ images = driver.load_images(@old_file_name, @new_file_name)
+ old_image, new_image = preprocess_images(images, driver)
- return false if sizes_changed?(*images)
- return true if images.first.pixels == images.last.pixels
+ return false if driver.dimension_changed?(old_image, new_image)
- return false unless @color_distance_limit || @shift_distance_limit
+ region, meta = driver.find_difference_region(
+ new_image,
+ old_image,
+ @color_distance_limit,
+ @shift_distance_limit,
+ @area_size_limit,
+ fast_fail: true
+ )
- @left, @top, @right, @bottom = find_top(*images)
+ self.difference_region = region
- return true if @top.nil?
+ return true if difference_region_empty?(new_image, region)
- if @area_size_limit
- @left, @top, @right, @bottom = find_diff_rectangle(*images)
- return true if size <= @area_size_limit
- end
+ return true if @area_size_limit && driver.size(region) <= @area_size_limit
+ return true if @tolerance && @tolerance >= driver.difference_level(meta, old_image, region)
+
+ # TODO: Remove this or find similar solution for vips
+ return true if @shift_distance_limit && driver.shift_distance_equal?
+
false
end
# Compare the two images referenced by this object, and return `true` if they are different,
# and `false` if they are the same.
# Return `nil` if the old file does not exist or if the image dimensions do not match.
def different?
return nil unless old_file_exists?
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
+ images = driver.load_images(@old_file_name, @new_file_name)
- return not_different if old_file == new_file
+ old_image, new_image = preprocess_images(images, driver)
- images = load_images(old_file, new_file)
+ if driver.dimension_changed?(old_image, new_image)
+ save(new_image, old_image, @annotated_new_file_name, @annotated_old_file_name)
- crop_images(images, @dimensions) if @dimensions
+ self.difference_region = 0, 0, driver.width_for(old_image), driver.height_for(old_image)
- old_img = images.first
- new_img = images.last
-
- if sizes_changed?(old_img, new_img)
- save_images(@annotated_new_file_name, new_img, @annotated_old_file_name, old_img)
- @left = 0
- @top = 0
- @right = old_img.dimension.width - 1
- @bottom = old_img.dimension.height - 1
return true
end
- return not_different if old_img.pixels == new_img.pixels
+ region, meta = driver.find_difference_region(
+ new_image,
+ old_image,
+ @color_distance_limit,
+ @shift_distance_limit,
+ @area_size_limit
+ )
+ self.difference_region = region
- @left, @top, @right, @bottom = find_diff_rectangle(old_img, new_img)
+ return not_different if difference_region_empty?(old_image, region)
+ return not_different if @area_size_limit && driver.size(region) <= @area_size_limit
+ return not_different if @tolerance && @tolerance > driver.difference_level(meta, old_image, region)
- return not_different if @top.nil?
- return not_different if @area_size_limit && size <= @area_size_limit
+ # TODO: Remove this or find similar solution for vips
+ return not_different if @shift_distance_limit && !driver.shift_distance_different?
- save_annotated_images(images)
+ annotate_and_save(images, region)
+
true
end
- def old_file_exists?
- @old_file_name && File.exist?(@old_file_name)
+ def clean_tmp_files
+ FileUtils.cp @old_file_name, @new_file_name if old_file_exists?
+ File.delete(@old_file_name) if old_file_exists?
+ File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name)
+ File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name)
end
- def old_file_size
- @old_file_size ||= old_file_exists? && File.size(@old_file_name)
- end
+ DIFF_COLOR = [255, 0, 0, 255].freeze
+ SKIP_COLOR = [255, 192, 0, 255].freeze
- def new_file_size
- File.size(@new_file_name)
+ def annotate_and_save(images, region = difference_region)
+ annotated_images = driver.draw_rectangles(images, region, DIFF_COLOR)
+ @skip_area.to_a.flatten.each_slice(4) do |region|
+ annotated_images = driver.draw_rectangles(annotated_images, region, SKIP_COLOR)
+ end
+ save(*annotated_images, @annotated_new_file_name, @annotated_old_file_name)
end
- def dimensions
- return unless @left || @top || @right || @bottom
-
- [@left, @top, @right, @bottom]
+ def save(new_img, old_img, annotated_new_file_name, annotated_old_file_name)
+ driver.save_image_to(old_img, annotated_old_file_name)
+ driver.save_image_to(new_img, annotated_new_file_name)
end
- def size
- (@right - @left + 1) * (@bottom - @top + 1)
+ def old_file_exists?
+ @old_file_name && File.exist?(@old_file_name)
end
- def max_color_distance
- calculate_metrics unless @max_color_distance
- @max_color_distance
+ def reset
+ self.difference_region = nil
+ driver.reset
end
- def max_shift_distance
- calculate_metrics unless @max_shift_distance || !@shift_distance_limit
- @max_shift_distance
- end
+ def error_message
+ result = {
+ area_size: driver.size(difference_region),
+ region: difference_region
+ }
- private
+ driver.adds_error_details_to(result)
- def save_annotated_images(images)
- annotated_old_img, annotated_new_img = draw_rectangles(images, @bottom, @left, @right, @top)
-
- save_images(@annotated_new_file_name, annotated_new_img,
- @annotated_old_file_name, annotated_old_img)
+ ["(#{result.to_json})", new_file_name, annotated_old_file_name, annotated_new_file_name].join("\n")
end
- def calculate_metrics
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
- if old_file == new_file
- @max_color_distance = 0
- @max_shift_distance = 0
- return
- end
+ def difference_region
+ return nil unless @left || @top || @right || @bottom
- old_image, new_image = load_images(old_file, new_file)
- calculate_max_color_distance(new_image, old_image)
- calculate_max_shift_limit(new_image, old_image)
+ [@left, @top, @right, @bottom]
end
- def calculate_max_color_distance(new_image, old_image)
- pixel_pairs = old_image.pixels.zip(new_image.pixels)
- @max_color_distance = pixel_pairs.inject(0) do |max, (p1, p2)|
- next max unless p1 && p2
+ private
- d = ChunkyPNG::Color.euclidean_distance_rgba(p1, p2)
- [max, d].max
- end
- end
+ def find_driver_class_for(driver)
+ driver = AVAILABLE_DRIVERS.first if driver == :auto
- def calculate_max_shift_limit(new_img, old_img)
- (0...new_img.width).each do |x|
- (0...new_img.height).each do |y|
- shift_distance =
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
- @max_shift_distance = shift_distance
- return if @max_shift_distance == Float::INFINITY # rubocop: disable Lint/NonLocalExitFromIterator
- end
+ LOADED_DRIVERS[driver] ||=
+ case driver
+ when :chunky_png
+ require "capybara/screenshot/diff/drivers/chunky_png_driver"
+ Drivers::ChunkyPNGDriver
+ when :vips
+ require "capybara/screenshot/diff/drivers/vips_driver"
+ Drivers::VipsDriver
+ else
+ fail "Wrong adapter #{driver.inspect}. Available adapters: #{AVAILABLE_DRIVERS.inspect}"
end
- end
end
- def not_different
- clean_tmp_files
- false
+ def old_file_size
+ @old_file_size ||= old_file_exists? && File.size(@old_file_name)
end
- def save_images(new_file_name, new_img, org_file_name, org_img)
- org_img.save(org_file_name)
- new_img.save(new_file_name)
+ def new_file_size
+ File.size(@new_file_name)
end
- def clean_tmp_files
- FileUtils.cp @old_file_name, @new_file_name
- File.delete(@old_file_name) if File.exist?(@old_file_name)
- File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name)
- File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name)
+ def not_different
+ clean_tmp_files
+ false
end
- def load_images(old_file, new_file)
- [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
+ def load_images(old_file_name, new_file_name, driver = self)
+ [driver.from_file(old_file_name), driver.from_file(new_file_name)]
end
- def load_image_files(old_file_name, file_name)
- old_file = File.binread(old_file_name)
- new_file = File.binread(file_name)
- [old_file, new_file]
- end
+ def preprocess_images(images, driver = self)
+ old_img = preprocess_image(images.first, driver)
+ new_img = preprocess_image(images.last, driver)
- def sizes_changed?(org_image, new_image)
- return unless org_image.dimension != new_image.dimension
-
- change_msg = [org_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(' => ')
- puts "Image size has changed for #{@new_file_name}: #{change_msg}"
- true
+ [old_img, new_img]
end
- def crop_images(images, dimensions)
- images.map! do |i|
- if i.dimension.to_a == dimensions || i.width < dimensions[0] || i.height < dimensions[1]
- i
- else
- i.crop(0, 0, *dimensions)
- end
- end
- end
+ def preprocess_image(image, driver = self)
+ result = image
- def draw_rectangles(images, bottom, left, right, top)
- images.map do |image|
- new_img = image.dup
- new_img.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(255, 0, 0))
- new_img
+ if @dimensions && driver.inscribed?(@dimensions, result)
+ result = driver.crop(@dimensions, result)
end
- end
- def find_diff_rectangle(org_img, new_img)
- left, top, right, bottom = find_left_right_and_top(org_img, new_img)
- bottom = find_bottom(org_img, new_img, left, right, bottom)
- [left, top, right, bottom]
- end
-
- def find_top(old_img, new_img)
- old_img.height.times do |y|
- old_img.width.times do |x|
- return [x, y, x, y] unless same_color?(old_img, new_img, x, y)
- end
+ if @median_filter_window_size
+ result = driver.filter_image_with_median(image, @median_filter_window_size)
end
- nil
- end
- def find_left_right_and_top(old_img, new_img)
- top = @top
- bottom = @bottom
- left = @left || old_img.width - 1
- right = @right || 0
- old_img.height.times do |y|
- (0...left).find do |x|
- next if same_color?(old_img, new_img, x, y)
-
- top ||= y
- bottom = y
- left = x
- right = x if x > right
- x
- end
- (old_img.width - 1).step(right + 1, -1).find do |x|
- unless same_color?(old_img, new_img, x, y)
- bottom = y
- right = x
- end
- end
+ if @skip_area
+ result = @skip_area.reduce(result) { |image, region| driver.add_black_box(image, region) }
end
- [left, top, right, bottom]
- end
- def find_bottom(old_img, new_img, left, right, bottom)
- if bottom
- (old_img.height - 1).step(bottom + 1, -1).find do |y|
- (left..right).find do |x|
- bottom = y unless same_color?(old_img, new_img, x, y)
- end
- end
- end
- bottom
+ result
end
- def same_color?(old_img, new_img, x, y)
- @skip_area&.each do |skip_start_x, skip_start_y, skip_end_x, skip_end_y|
- return true if skip_start_x <= x && x <= skip_end_x && skip_start_y <= y && y <= skip_end_y
- end
-
- color_distance =
- color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
- if !@max_color_distance || color_distance > @max_color_distance
- @max_color_distance = color_distance
- end
- color_matches = color_distance == 0 || (@color_distance_limit && @color_distance_limit > 0 &&
- color_distance <= @color_distance_limit)
- return color_matches if !@shift_distance_limit || @max_shift_distance == Float::INFINITY
-
- shift_distance = (color_matches && 0) ||
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
- @max_shift_distance = shift_distance
- end
- color_matches
+ def difference_region=(region)
+ @left, @top, @right, @bottom = region
end
- def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
- org_color = old_img[x, y]
- if shift_distance_limit
- start_x = [0, x - shift_distance_limit].max
- end_x = [x + shift_distance_limit, new_img.width - 1].min
- xs = (start_x..end_x).to_a
- start_y = [0, y - shift_distance_limit].max
- end_y = [y + shift_distance_limit, new_img.height - 1].min
- ys = (start_y..end_y).to_a
- new_pixels = xs.product(ys)
- distances = new_pixels.map do |dx, dy|
- new_color = new_img[dx, dy]
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
- end
- distances.min
- else
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
- end
- end
-
- def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
- org_color = old_img[x, y]
- shift_distance = 0
- loop do
- bounds_breached = 0
- top_row = y - shift_distance
- if top_row >= 0 # top
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
- if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
- return shift_distance
- end
- end
- else
- bounds_breached += 1
- end
- if shift_distance > 0
- if (x - shift_distance) >= 0 # left
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
- .each do |dy|
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
- return shift_distance
- end
- end
- else
- bounds_breached += 1
- end
- if (y + shift_distance) < new_img.height # bottom
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
- if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
- return shift_distance
- end
- end
- else
- bounds_breached += 1
- end
- if (x + shift_distance) < new_img.width # right
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
- .each do |dy|
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
- return shift_distance
- end
- end
- else
- bounds_breached += 1
- end
- end
- break if bounds_breached == 4
-
- shift_distance += 1
- end
- Float::INFINITY
- end
-
- def color_matches(new_img, org_color, dx, dy, color_distance_limit)
- new_color = new_img[dx, dy]
- return new_color == org_color unless color_distance_limit
-
- color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
- color_distance <= color_distance_limit
+ def difference_region_empty?(new_image, region)
+ region.nil? ||
+ (
+ region[1] == height_for(new_image) &&
+ region[0] == width_for(new_image) &&
+ region[2].zero? &&
+ region[3].zero?
+ )
end
end
end
end
end