capybara-screenshot-diff 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Screenshot
5
+ module Diff
6
+ module Utils
7
+ def self.detect_available_drivers
8
+ result = []
9
+ begin
10
+ result << :vips if defined?(Vips) || require("vips")
11
+ rescue LoadError
12
+ # vips not present
13
+ end
14
+ begin
15
+ result << :chunky_png if defined?(ChunkyPNG) || require("chunky_png")
16
+ rescue LoadError
17
+ # chunky_png not present
18
+ end
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "vips"
5
+ rescue LoadError => e
6
+ warn 'Required ruby-vips gem is missing. Add `gem "ruby-vips"` to Gemfile' if e.message.include?("vips")
7
+ raise
8
+ end
9
+
10
+ require_relative "./chunky_png_driver"
11
+
12
+ module Capybara
13
+ module Screenshot
14
+ module Diff
15
+ # Compare two images and determine if they are equal, different, or within some comparison
16
+ # range considering color values and difference area size.
17
+ module Drivers
18
+ class VipsDriver
19
+ attr_reader :new_file_name, :old_file_name, :options
20
+
21
+ def initialize(new_file_name, old_file_name = nil, **options)
22
+ @new_file_name = new_file_name
23
+ @old_file_name = old_file_name || "#{new_file_name}~"
24
+
25
+ @options = options || {}
26
+
27
+ reset
28
+ end
29
+
30
+ # Resets the calculated data about the comparison with regard to the "new_image".
31
+ # Data about the original image is kept.
32
+ def reset
33
+ end
34
+
35
+ def shift_distance_equal?
36
+ warn "[capybara-screenshot-diff] Instead of shift_distance_limit " \
37
+ "please use median_filter_window_size and color_distance_limit options"
38
+ chunky_png_comparator.quick_equal?
39
+ end
40
+
41
+ def shift_distance_different?
42
+ warn "[capybara-screenshot-diff] Instead of shift_distance_limit " \
43
+ "please use median_filter_window_size and color_distance_limit options"
44
+ chunky_png_comparator.different?
45
+ end
46
+
47
+ def find_difference_region(new_image, old_image, color_distance_limit, _shift_distance_limit, _area_size_limit, fast_fail: false)
48
+ diff_mask = VipsUtil.difference_mask(color_distance_limit, old_image, new_image)
49
+ region = VipsUtil.difference_region_by(diff_mask)
50
+
51
+ [region, diff_mask]
52
+ end
53
+
54
+ def size(region)
55
+ return 0 unless region
56
+
57
+ (region[2] - region[0]) * (region[3] - region[1])
58
+ end
59
+
60
+ def adds_error_details_to(_log)
61
+ end
62
+
63
+ # old private
64
+
65
+ def inscribed?(dimensions, i)
66
+ dimension(i) == dimensions || i.width < dimensions[0] || i.height < dimensions[1]
67
+ end
68
+
69
+ def crop(dimensions, i)
70
+ i.crop(0, 0, *dimensions)
71
+ end
72
+
73
+ def filter_image_with_median(image, median_filter_window_size)
74
+ image.median(median_filter_window_size)
75
+ end
76
+
77
+ def add_black_box(memo, region)
78
+ memo.draw_rect([0, 0, 0, 0], *region, fill: true)
79
+ end
80
+
81
+ def chunky_png_comparator
82
+ @chunky_png_comparator ||= ImageCompare.new(
83
+ @new_file_name,
84
+ @old_file_name,
85
+ **@options.merge(driver: :chunky_png, tolerance: nil, median_filter_window_size: nil)
86
+ )
87
+ end
88
+
89
+ def difference_level(diff_mask, old_img, _region = nil)
90
+ VipsUtil.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
91
+ end
92
+
93
+ def image_area_size(old_img)
94
+ width_for(old_img) * height_for(old_img)
95
+ end
96
+
97
+ def height_for(image)
98
+ image.height
99
+ end
100
+
101
+ def width_for(image)
102
+ image.width
103
+ end
104
+
105
+ def save_image_to(image, filename)
106
+ image.write_to_file(filename)
107
+ end
108
+
109
+ def resize_image_to(image, new_width, new_height)
110
+ image.resize(1.* new_width / new_height)
111
+ end
112
+
113
+ def load_images(old_file_name, new_file_name, driver = self)
114
+ [driver.from_file(old_file_name), driver.from_file(new_file_name)]
115
+ end
116
+
117
+ def from_file(filename)
118
+ result = ::Vips::Image.new_from_file(filename)
119
+
120
+ result = result.colourspace("srgb") if result.bands < 3
121
+ result = result.bandjoin(255) if result.bands == 3
122
+
123
+ result
124
+ end
125
+
126
+ def dimension_changed?(org_image, new_image)
127
+ return false if dimension(org_image) == dimension(new_image)
128
+
129
+ change_msg = [org_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(" => ")
130
+ warn "Image size has changed for #{@new_file_name}: #{change_msg}"
131
+
132
+ true
133
+ end
134
+
135
+ def dimension(image)
136
+ [image.width, image.height]
137
+ end
138
+
139
+ def draw_rectangles(images, (left, top, right, bottom), rgba)
140
+ images.map do |image|
141
+ image.draw_rect(rgba, left - 1, top - 1, right - left + 2, bottom - top + 2)
142
+ end
143
+ end
144
+
145
+ class VipsUtil
146
+ def self.difference(old_image, new_image, color_distance: 0)
147
+ diff_mask = difference_mask(color_distance, new_image, old_image)
148
+ difference_region_by(diff_mask)
149
+ end
150
+
151
+ def self.difference_area(old_image, new_image, color_distance: 0)
152
+ difference_mask = difference_mask(color_distance, new_image, old_image)
153
+ difference_area_size_by(difference_mask)
154
+ end
155
+
156
+ def self.difference_area_size_by(difference_mask)
157
+ diff_mask = difference_mask == 0
158
+ diff_mask.hist_find.to_a[0][0].max
159
+ end
160
+
161
+ def self.difference_mask(color_distance, old_image, new_image)
162
+ (new_image - old_image).abs > color_distance
163
+ end
164
+
165
+ def self.difference_region_by(diff_mask)
166
+ columns, rows = diff_mask.project
167
+
168
+ left = columns.profile[1].min
169
+ right = columns.width - columns.flip("horizontal").profile[1].min
170
+ top = rows.profile[0].min
171
+ bottom = rows.height - rows.flip("vertical").profile[0].min
172
+
173
+ [left, top, right, bottom]
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -1,65 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'chunky_png'
4
-
5
3
  module Capybara
6
4
  module Screenshot
7
5
  module Diff
8
- # Compare two images and determine if they are equal, different, or within som comparison
6
+ LOADED_DRIVERS = {}
7
+
8
+ # Compare two images and determine if they are equal, different, or within some comparison
9
9
  # range considering color values and difference area size.
10
- class ImageCompare
11
- include ChunkyPNG::Color
10
+ class ImageCompare < SimpleDelegator
11
+ attr_reader :driver, :driver_options
12
12
 
13
- attr_reader :annotated_new_file_name, :annotated_old_file_name, :new_file_name, :old_file_name
13
+ attr_reader :annotated_new_file_name, :annotated_old_file_name, :area_size_limit,
14
+ :color_distance_limit, :new_file_name, :old_file_name, :shift_distance_limit,
15
+ :skip_area
14
16
 
15
- def initialize(new_file_name, old_file_name = nil, dimensions: nil, color_distance_limit: nil,
16
- area_size_limit: nil, shift_distance_limit: nil, skip_area: nil)
17
+ def initialize(new_file_name, old_file_name = nil, **driver_options)
17
18
  @new_file_name = new_file_name
18
- @color_distance_limit = color_distance_limit
19
- @area_size_limit = area_size_limit
20
- @shift_distance_limit = shift_distance_limit
21
- @dimensions = dimensions
22
- @skip_area = skip_area
23
19
  @old_file_name = old_file_name || "#{new_file_name}~"
24
- @annotated_old_file_name = "#{new_file_name.chomp('.png')}_0.png~"
25
- @annotated_new_file_name = "#{new_file_name.chomp('.png')}_1.png~"
26
- reset
27
- end
20
+ @annotated_old_file_name = "#{new_file_name.chomp(".png")}.committed.png"
21
+ @annotated_new_file_name = "#{new_file_name.chomp(".png")}.latest.png"
28
22
 
29
- # Resets the calculated data about the comparison with regard to the "new_image".
30
- # Data about the original image is kept.
31
- def reset
32
- @max_color_distance = @color_distance_limit ? 0 : nil
33
- @max_shift_distance = @shift_distance_limit ? 0 : nil
34
- @left = @top = @right = @bottom = nil
23
+ @driver_options = driver_options
24
+
25
+ @color_distance_limit = driver_options[:color_distance_limit] || 0
26
+ @area_size_limit = driver_options[:area_size_limit]
27
+ @shift_distance_limit = driver_options[:shift_distance_limit]
28
+ @dimensions = driver_options[:dimensions]
29
+ @skip_area = driver_options[:skip_area]
30
+ @tolerance = driver_options[:tolerance]
31
+ @median_filter_window_size = driver_options[:median_filter_window_size]
32
+
33
+ driver_klass = find_driver_class_for(@driver_options.fetch(:driver, :chunky_png))
34
+ @driver = driver_klass.new(@new_file_name, @old_file_name, **@driver_options)
35
+
36
+ super(@driver)
35
37
  end
36
38
 
37
39
  # Compare the two image files and return `true` or `false` as quickly as possible.
38
40
  # Return falsish if the old file does not exist or the image dimensions do not match.
39
41
  def quick_equal?
40
- return nil unless old_file_exists?
42
+ return false unless old_file_exists?
41
43
  return true if new_file_size == old_file_size
42
44
 
43
- old_bytes, new_bytes = load_image_files(@old_file_name, @new_file_name)
44
- return true if old_bytes == new_bytes
45
+ # old_bytes, new_bytes = load_image_files(@old_file_name, @new_file_name)
46
+ # return true if old_bytes == new_bytes
45
47
 
46
- images = load_images(old_bytes, new_bytes)
47
- old_bytes = new_bytes = nil # rubocop: disable Lint/UselessAssignment
48
- crop_images(images, @dimensions) if @dimensions
48
+ images = driver.load_images(@old_file_name, @new_file_name)
49
+ old_image, new_image = preprocess_images(images, driver)
49
50
 
50
- return false if sizes_changed?(*images)
51
- return true if images.first.pixels == images.last.pixels
51
+ return false if driver.dimension_changed?(old_image, new_image)
52
52
 
53
- return false unless @color_distance_limit || @shift_distance_limit
53
+ region, meta = driver.find_difference_region(
54
+ new_image,
55
+ old_image,
56
+ @color_distance_limit,
57
+ @shift_distance_limit,
58
+ @area_size_limit,
59
+ fast_fail: true
60
+ )
54
61
 
55
- @left, @top, @right, @bottom = find_top(*images)
62
+ self.difference_region = region
56
63
 
57
- return true if @top.nil?
64
+ return true if difference_region_empty?(new_image, region)
58
65
 
59
- if @area_size_limit
60
- @left, @top, @right, @bottom = find_diff_rectangle(*images)
61
- return true if size <= @area_size_limit
62
- end
66
+ return true if @area_size_limit && driver.size(region) <= @area_size_limit
67
+
68
+ return true if @tolerance && @tolerance >= driver.difference_level(meta, old_image, region)
69
+
70
+ # TODO: Remove this or find similar solution for vips
71
+ return true if @shift_distance_limit && driver.shift_distance_equal?
63
72
 
64
73
  false
65
74
  end
@@ -70,313 +79,160 @@ def quick_equal?
70
79
  def different?
71
80
  return nil unless old_file_exists?
72
81
 
73
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
82
+ images = driver.load_images(@old_file_name, @new_file_name)
74
83
 
75
- return not_different if old_file == new_file
84
+ old_image, new_image = preprocess_images(images, driver)
76
85
 
77
- images = load_images(old_file, new_file)
86
+ if driver.dimension_changed?(old_image, new_image)
87
+ save(new_image, old_image, @annotated_new_file_name, @annotated_old_file_name)
78
88
 
79
- crop_images(images, @dimensions) if @dimensions
89
+ self.difference_region = 0, 0, driver.width_for(old_image), driver.height_for(old_image)
80
90
 
81
- old_img = images.first
82
- new_img = images.last
83
-
84
- if sizes_changed?(old_img, new_img)
85
- save_images(@annotated_new_file_name, new_img, @annotated_old_file_name, old_img)
86
- @left = 0
87
- @top = 0
88
- @right = old_img.dimension.width - 1
89
- @bottom = old_img.dimension.height - 1
90
91
  return true
91
92
  end
92
93
 
93
- return not_different if old_img.pixels == new_img.pixels
94
+ region, meta = driver.find_difference_region(
95
+ new_image,
96
+ old_image,
97
+ @color_distance_limit,
98
+ @shift_distance_limit,
99
+ @area_size_limit
100
+ )
101
+ self.difference_region = region
94
102
 
95
- @left, @top, @right, @bottom = find_diff_rectangle(old_img, new_img)
103
+ return not_different if difference_region_empty?(old_image, region)
104
+ return not_different if @area_size_limit && driver.size(region) <= @area_size_limit
105
+ return not_different if @tolerance && @tolerance > driver.difference_level(meta, old_image, region)
96
106
 
97
- return not_different if @top.nil?
98
- return not_different if @area_size_limit && size <= @area_size_limit
107
+ # TODO: Remove this or find similar solution for vips
108
+ return not_different if @shift_distance_limit && !driver.shift_distance_different?
99
109
 
100
- annotated_old_img, annotated_new_img = draw_rectangles(images, @bottom, @left, @right, @top)
110
+ annotate_and_save(images, region)
101
111
 
102
- save_images(@annotated_new_file_name, annotated_new_img,
103
- @annotated_old_file_name, annotated_old_img)
104
112
  true
105
113
  end
106
114
 
107
- def old_file_exists?
108
- @old_file_name && File.exist?(@old_file_name)
109
- end
110
-
111
- def old_file_size
112
- @old_file_size ||= old_file_exists? && File.size(@old_file_name)
115
+ def clean_tmp_files
116
+ FileUtils.cp @old_file_name, @new_file_name if old_file_exists?
117
+ File.delete(@old_file_name) if old_file_exists?
118
+ File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name)
119
+ File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name)
113
120
  end
114
121
 
115
- def new_file_size
116
- File.size(@new_file_name)
117
- end
122
+ DIFF_COLOR = [255, 0, 0, 255].freeze
123
+ SKIP_COLOR = [255, 192, 0, 255].freeze
118
124
 
119
- def dimensions
120
- [@left, @top, @right, @bottom]
125
+ def annotate_and_save(images, region = difference_region)
126
+ annotated_images = driver.draw_rectangles(images, region, DIFF_COLOR)
127
+ @skip_area.to_a.flatten.each_slice(4) do |region|
128
+ annotated_images = driver.draw_rectangles(annotated_images, region, SKIP_COLOR)
129
+ end
130
+ save(*annotated_images, @annotated_old_file_name, @annotated_new_file_name)
121
131
  end
122
132
 
123
- def size
124
- (@right - @left + 1) * (@bottom - @top + 1)
133
+ def save(old_img, new_img, annotated_old_file_name, annotated_new_file_name)
134
+ driver.save_image_to(old_img, annotated_old_file_name)
135
+ driver.save_image_to(new_img, annotated_new_file_name)
125
136
  end
126
137
 
127
- def max_color_distance
128
- calculate_metrics unless @max_color_distance
129
- @max_color_distance
138
+ def old_file_exists?
139
+ @old_file_name && File.exist?(@old_file_name)
130
140
  end
131
141
 
132
- def max_shift_distance
133
- calculate_metrics unless @max_shift_distance || !@shift_distance_limit
134
- @max_shift_distance
142
+ def reset
143
+ self.difference_region = nil
144
+ driver.reset
135
145
  end
136
146
 
137
- private
147
+ def error_message
148
+ result = {
149
+ area_size: driver.size(difference_region),
150
+ region: difference_region
151
+ }
138
152
 
139
- def calculate_metrics
140
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
141
- if old_file == new_file
142
- @max_color_distance = 0
143
- @max_shift_distance = 0
144
- return
145
- end
153
+ driver.adds_error_details_to(result)
146
154
 
147
- old_image, new_image = load_images(old_file, new_file)
148
- calculate_max_color_distance(new_image, old_image)
149
- calculate_max_shift_limit(new_image, old_image)
155
+ ["(#{result.to_json})", new_file_name, annotated_old_file_name, annotated_new_file_name].join("\n")
150
156
  end
151
157
 
152
- def calculate_max_color_distance(new_image, old_image)
153
- pixel_pairs = old_image.pixels.zip(new_image.pixels)
154
- @max_color_distance = pixel_pairs.inject(0) do |max, (p1, p2)|
155
- next max unless p1 && p2
158
+ def difference_region
159
+ return nil unless @left || @top || @right || @bottom
156
160
 
157
- d = ChunkyPNG::Color.euclidean_distance_rgba(p1, p2)
158
- [max, d].max
159
- end
160
- end
161
-
162
- def calculate_max_shift_limit(new_img, old_img)
163
- (0...new_img.width).each do |x|
164
- (0...new_img.height).each do |y|
165
- shift_distance =
166
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
167
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
168
- @max_shift_distance = shift_distance
169
- return if @max_shift_distance == Float::INFINITY # rubocop: disable Lint/NonLocalExitFromIterator
170
- end
171
- end
172
- end
173
- end
174
-
175
- def not_different
176
- clean_tmp_files
177
- false
178
- end
179
-
180
- def save_images(new_file_name, new_img, org_file_name, org_img)
181
- org_img.save(org_file_name)
182
- new_img.save(new_file_name)
161
+ [@left, @top, @right, @bottom]
183
162
  end
184
163
 
185
- def clean_tmp_files
186
- FileUtils.cp @old_file_name, @new_file_name
187
- File.delete(@old_file_name) if File.exist?(@old_file_name)
188
- File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name)
189
- File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name)
190
- end
164
+ private
191
165
 
192
- def load_images(old_file, new_file)
193
- [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
166
+ def find_driver_class_for(driver)
167
+ driver = AVAILABLE_DRIVERS.first if driver == :auto
168
+
169
+ LOADED_DRIVERS[driver] ||=
170
+ case driver
171
+ when :chunky_png
172
+ require "capybara/screenshot/diff/drivers/chunky_png_driver"
173
+ Drivers::ChunkyPNGDriver
174
+ when :vips
175
+ require "capybara/screenshot/diff/drivers/vips_driver"
176
+ Drivers::VipsDriver
177
+ else
178
+ fail "Wrong adapter #{driver.inspect}. Available adapters: #{AVAILABLE_DRIVERS.inspect}"
179
+ end
194
180
  end
195
181
 
196
- def load_image_files(old_file_name, file_name)
197
- old_file = File.binread(old_file_name)
198
- new_file = File.binread(file_name)
199
- [old_file, new_file]
182
+ def old_file_size
183
+ @old_file_size ||= old_file_exists? && File.size(@old_file_name)
200
184
  end
201
185
 
202
- def sizes_changed?(org_image, new_image)
203
- return unless org_image.dimension != new_image.dimension
204
-
205
- change_msg = [org_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(' => ')
206
- puts "Image size has changed for #{@new_file_name}: #{change_msg}"
207
- true
186
+ def new_file_size
187
+ File.size(@new_file_name)
208
188
  end
209
189
 
210
- def crop_images(images, dimensions)
211
- images.map! do |i|
212
- if i.dimension.to_a == dimensions || i.width < dimensions[0] || i.height < dimensions[1]
213
- i
214
- else
215
- i.crop(0, 0, *dimensions)
216
- end
217
- end
190
+ def not_different
191
+ clean_tmp_files
192
+ false
218
193
  end
219
194
 
220
- def draw_rectangles(images, bottom, left, right, top)
221
- images.map do |image|
222
- new_img = image.dup
223
- new_img.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(255, 0, 0))
224
- new_img
225
- end
195
+ def load_images(old_file_name, new_file_name, driver = self)
196
+ [driver.from_file(old_file_name), driver.from_file(new_file_name)]
226
197
  end
227
198
 
228
- def find_diff_rectangle(org_img, new_img)
229
- left, top, right, bottom = find_left_right_and_top(org_img, new_img)
230
- bottom = find_bottom(org_img, new_img, left, right, bottom)
231
- [left, top, right, bottom]
232
- end
199
+ def preprocess_images(images, driver = self)
200
+ old_img = preprocess_image(images.first, driver)
201
+ new_img = preprocess_image(images.last, driver)
233
202
 
234
- def find_top(old_img, new_img)
235
- old_img.height.times do |y|
236
- old_img.width.times do |x|
237
- return [x, y, x, y] unless same_color?(old_img, new_img, x, y)
238
- end
239
- end
240
- nil
203
+ [old_img, new_img]
241
204
  end
242
205
 
243
- def find_left_right_and_top(old_img, new_img)
244
- top = @top
245
- bottom = @bottom
246
- left = @left || old_img.width - 1
247
- right = @right || 0
248
- old_img.height.times do |y|
249
- (0...left).find do |x|
250
- next if same_color?(old_img, new_img, x, y)
251
-
252
- top ||= y
253
- bottom = y
254
- left = x
255
- right = x if x > right
256
- x
257
- end
258
- (old_img.width - 1).step(right + 1, -1).find do |x|
259
- unless same_color?(old_img, new_img, x, y)
260
- bottom = y
261
- right = x
262
- end
263
- end
264
- end
265
- [left, top, right, bottom]
266
- end
206
+ def preprocess_image(image, driver = self)
207
+ result = image
267
208
 
268
- def find_bottom(old_img, new_img, left, right, bottom)
269
- if bottom
270
- (old_img.height - 1).step(bottom + 1, -1).find do |y|
271
- (left..right).find do |x|
272
- bottom = y unless same_color?(old_img, new_img, x, y)
273
- end
274
- end
209
+ if @dimensions && driver.inscribed?(@dimensions, result)
210
+ result = driver.crop(@dimensions, result)
275
211
  end
276
- bottom
277
- end
278
212
 
279
- def same_color?(old_img, new_img, x, y)
280
- @skip_area&.each do |skip_start_x, skip_start_y, skip_end_x, skip_end_y|
281
- return true if skip_start_x <= x && x <= skip_end_x && skip_start_y <= y && y <= skip_end_y
213
+ if @median_filter_window_size
214
+ result = driver.filter_image_with_median(image, @median_filter_window_size)
282
215
  end
283
216
 
284
- color_distance =
285
- color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
286
- if !@max_color_distance || color_distance > @max_color_distance
287
- @max_color_distance = color_distance
217
+ if @skip_area
218
+ result = @skip_area.reduce(result) { |image, region| driver.add_black_box(image, region) }
288
219
  end
289
- color_matches = color_distance == 0 || (@color_distance_limit && @color_distance_limit > 0 &&
290
- color_distance <= @color_distance_limit)
291
- return color_matches if !@shift_distance_limit || @max_shift_distance == Float::INFINITY
292
-
293
- shift_distance = (color_matches && 0) ||
294
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
295
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
296
- @max_shift_distance = shift_distance
297
- end
298
- color_matches
299
- end
300
220
 
301
- def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
302
- org_color = old_img[x, y]
303
- if shift_distance_limit
304
- start_x = [0, x - shift_distance_limit].max
305
- end_x = [x + shift_distance_limit, new_img.width - 1].min
306
- xs = (start_x..end_x).to_a
307
- start_y = [0, y - shift_distance_limit].max
308
- end_y = [y + shift_distance_limit, new_img.height - 1].min
309
- ys = (start_y..end_y).to_a
310
- new_pixels = xs.product(ys)
311
- distances = new_pixels.map do |dx, dy|
312
- new_color = new_img[dx, dy]
313
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
314
- end
315
- distances.min
316
- else
317
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
318
- end
221
+ result
319
222
  end
320
223
 
321
- def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
322
- org_color = old_img[x, y]
323
- shift_distance = 0
324
- loop do
325
- bounds_breached = 0
326
- top_row = y - shift_distance
327
- if top_row >= 0 # top
328
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
329
- if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
330
- return shift_distance
331
- end
332
- end
333
- else
334
- bounds_breached += 1
335
- end
336
- if shift_distance > 0
337
- if (x - shift_distance) >= 0 # left
338
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
339
- .each do |dy|
340
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
341
- return shift_distance
342
- end
343
- end
344
- else
345
- bounds_breached += 1
346
- end
347
- if (y + shift_distance) < new_img.height # bottom
348
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
349
- if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
350
- return shift_distance
351
- end
352
- end
353
- else
354
- bounds_breached += 1
355
- end
356
- if (x + shift_distance) < new_img.width # right
357
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
358
- .each do |dy|
359
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
360
- return shift_distance
361
- end
362
- end
363
- else
364
- bounds_breached += 1
365
- end
366
- end
367
- break if bounds_breached == 4
368
-
369
- shift_distance += 1
370
- end
371
- Float::INFINITY
224
+ def difference_region=(region)
225
+ @left, @top, @right, @bottom = region
372
226
  end
373
227
 
374
- def color_matches(new_img, org_color, dx, dy, color_distance_limit)
375
- new_color = new_img[dx, dy]
376
- return new_color == org_color unless color_distance_limit
377
-
378
- color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
379
- color_distance <= color_distance_limit
228
+ def difference_region_empty?(new_image, region)
229
+ region.nil? ||
230
+ (
231
+ region[1] == height_for(new_image) &&
232
+ region[0] == width_for(new_image) &&
233
+ region[2].zero? &&
234
+ region[3].zero?
235
+ )
380
236
  end
381
237
  end
382
238
  end