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