capybara-screenshot-diff 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,66 +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
6
+ LOADED_DRIVERS = {}
7
+
8
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
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, :skip_area
14
+ :color_distance_limit, :new_file_name, :old_file_name, :shift_distance_limit,
15
+ :skip_area
15
16
 
16
- def initialize(new_file_name, old_file_name = nil, dimensions: nil, color_distance_limit: nil,
17
- area_size_limit: nil, shift_distance_limit: nil, skip_area: nil)
17
+ def initialize(new_file_name, old_file_name = nil, **driver_options)
18
18
  @new_file_name = new_file_name
19
- @color_distance_limit = color_distance_limit
20
- @area_size_limit = area_size_limit
21
- @shift_distance_limit = shift_distance_limit
22
- @dimensions = dimensions
23
- @skip_area = skip_area
24
19
  @old_file_name = old_file_name || "#{new_file_name}~"
25
- @annotated_old_file_name = "#{new_file_name.chomp('.png')}.committed.png"
26
- @annotated_new_file_name = "#{new_file_name.chomp('.png')}.latest.png"
27
- reset
28
- 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"
29
22
 
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
- @max_color_distance = @color_distance_limit ? 0 : nil
34
- @max_shift_distance = @shift_distance_limit ? 0 : nil
35
- @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)
36
37
  end
37
38
 
38
39
  # Compare the two image files and return `true` or `false` as quickly as possible.
39
40
  # Return falsish if the old file does not exist or the image dimensions do not match.
40
41
  def quick_equal?
41
- return nil unless old_file_exists?
42
+ return false unless old_file_exists?
42
43
  return true if new_file_size == old_file_size
43
44
 
44
- old_bytes, new_bytes = load_image_files(@old_file_name, @new_file_name)
45
- 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
46
47
 
47
- images = load_images(old_bytes, new_bytes)
48
- old_bytes = new_bytes = nil # rubocop: disable Lint/UselessAssignment
49
- 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)
50
50
 
51
- return false if sizes_changed?(*images)
52
- return true if images.first.pixels == images.last.pixels
51
+ return false if driver.dimension_changed?(old_image, new_image)
53
52
 
54
- 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
+ )
55
61
 
56
- @left, @top, @right, @bottom = find_top(*images)
62
+ self.difference_region = region
57
63
 
58
- return true if @top.nil?
64
+ return true if difference_region_empty?(new_image, region)
59
65
 
60
- if @area_size_limit
61
- @left, @top, @right, @bottom = find_diff_rectangle(*images)
62
- return true if size <= @area_size_limit
63
- 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?
64
72
 
65
73
  false
66
74
  end
@@ -71,319 +79,160 @@ def quick_equal?
71
79
  def different?
72
80
  return nil unless old_file_exists?
73
81
 
74
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
82
+ images = driver.load_images(@old_file_name, @new_file_name)
75
83
 
76
- return not_different if old_file == new_file
84
+ old_image, new_image = preprocess_images(images, driver)
77
85
 
78
- 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)
79
88
 
80
- crop_images(images, @dimensions) if @dimensions
89
+ self.difference_region = 0, 0, driver.width_for(old_image), driver.height_for(old_image)
81
90
 
82
- old_img = images.first
83
- new_img = images.last
84
-
85
- if sizes_changed?(old_img, new_img)
86
- save_images(@annotated_new_file_name, new_img, @annotated_old_file_name, old_img)
87
- @left = 0
88
- @top = 0
89
- @right = old_img.dimension.width - 1
90
- @bottom = old_img.dimension.height - 1
91
91
  return true
92
92
  end
93
93
 
94
- 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
95
102
 
96
- @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)
97
106
 
98
- return not_different if @top.nil?
99
- 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?
100
109
 
101
- save_annotated_images(images)
102
- true
103
- end
110
+ annotate_and_save(images, region)
104
111
 
105
- def old_file_exists?
106
- @old_file_name && File.exist?(@old_file_name)
107
- end
108
-
109
- def old_file_size
110
- @old_file_size ||= old_file_exists? && File.size(@old_file_name)
112
+ true
111
113
  end
112
114
 
113
- def new_file_size
114
- File.size(@new_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)
115
120
  end
116
121
 
117
- def dimensions
118
- return unless @left || @top || @right || @bottom
122
+ DIFF_COLOR = [255, 0, 0, 255].freeze
123
+ SKIP_COLOR = [255, 192, 0, 255].freeze
119
124
 
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_new_file_name, @annotated_old_file_name)
121
131
  end
122
132
 
123
- def size
124
- (@right - @left + 1) * (@bottom - @top + 1)
133
+ def save(new_img, old_img, annotated_new_file_name, annotated_old_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 save_annotated_images(images)
140
- annotated_old_img, annotated_new_img = draw_rectangles(images, @bottom, @left, @right, @top)
153
+ driver.adds_error_details_to(result)
141
154
 
142
- save_images(@annotated_new_file_name, annotated_new_img,
143
- @annotated_old_file_name, annotated_old_img)
155
+ ["(#{result.to_json})", new_file_name, annotated_old_file_name, annotated_new_file_name].join("\n")
144
156
  end
145
157
 
146
- def calculate_metrics
147
- old_file, new_file = load_image_files(@old_file_name, @new_file_name)
148
- if old_file == new_file
149
- @max_color_distance = 0
150
- @max_shift_distance = 0
151
- return
152
- end
158
+ def difference_region
159
+ return nil unless @left || @top || @right || @bottom
153
160
 
154
- old_image, new_image = load_images(old_file, new_file)
155
- calculate_max_color_distance(new_image, old_image)
156
- calculate_max_shift_limit(new_image, old_image)
161
+ [@left, @top, @right, @bottom]
157
162
  end
158
163
 
159
- def calculate_max_color_distance(new_image, old_image)
160
- pixel_pairs = old_image.pixels.zip(new_image.pixels)
161
- @max_color_distance = pixel_pairs.inject(0) do |max, (p1, p2)|
162
- next max unless p1 && p2
163
-
164
- d = ChunkyPNG::Color.euclidean_distance_rgba(p1, p2)
165
- [max, d].max
166
- end
167
- end
164
+ private
168
165
 
169
- def calculate_max_shift_limit(new_img, old_img)
170
- (0...new_img.width).each do |x|
171
- (0...new_img.height).each do |y|
172
- shift_distance =
173
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
174
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
175
- @max_shift_distance = shift_distance
176
- return if @max_shift_distance == Float::INFINITY # rubocop: disable Lint/NonLocalExitFromIterator
177
- end
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}"
178
179
  end
179
- end
180
- end
181
-
182
- def not_different
183
- clean_tmp_files
184
- false
185
- end
186
-
187
- def save_images(new_file_name, new_img, org_file_name, org_img)
188
- org_img.save(org_file_name)
189
- new_img.save(new_file_name)
190
- end
191
-
192
- def clean_tmp_files
193
- FileUtils.cp @old_file_name, @new_file_name
194
- File.delete(@old_file_name) if File.exist?(@old_file_name)
195
- File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name)
196
- File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name)
197
- end
198
-
199
- def load_images(old_file, new_file)
200
- [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
201
180
  end
202
181
 
203
- def load_image_files(old_file_name, file_name)
204
- old_file = File.binread(old_file_name)
205
- new_file = File.binread(file_name)
206
- [old_file, new_file]
182
+ def old_file_size
183
+ @old_file_size ||= old_file_exists? && File.size(@old_file_name)
207
184
  end
208
185
 
209
- def sizes_changed?(org_image, new_image)
210
- return unless org_image.dimension != new_image.dimension
211
-
212
- change_msg = [org_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(' => ')
213
- puts "Image size has changed for #{@new_file_name}: #{change_msg}"
214
- true
186
+ def new_file_size
187
+ File.size(@new_file_name)
215
188
  end
216
189
 
217
- def crop_images(images, dimensions)
218
- images.map! do |i|
219
- if i.dimension.to_a == dimensions || i.width < dimensions[0] || i.height < dimensions[1]
220
- i
221
- else
222
- i.crop(0, 0, *dimensions)
223
- end
224
- end
190
+ def not_different
191
+ clean_tmp_files
192
+ false
225
193
  end
226
194
 
227
- def draw_rectangles(images, bottom, left, right, top)
228
- images.map do |image|
229
- new_img = image.dup
230
- new_img.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(255, 0, 0))
231
- new_img
232
- 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)]
233
197
  end
234
198
 
235
- def find_diff_rectangle(org_img, new_img)
236
- left, top, right, bottom = find_left_right_and_top(org_img, new_img)
237
- bottom = find_bottom(org_img, new_img, left, right, bottom)
238
- [left, top, right, bottom]
239
- end
199
+ def preprocess_images(images, driver = self)
200
+ old_img = preprocess_image(images.first, driver)
201
+ new_img = preprocess_image(images.last, driver)
240
202
 
241
- def find_top(old_img, new_img)
242
- old_img.height.times do |y|
243
- old_img.width.times do |x|
244
- return [x, y, x, y] unless same_color?(old_img, new_img, x, y)
245
- end
246
- end
247
- nil
203
+ [old_img, new_img]
248
204
  end
249
205
 
250
- def find_left_right_and_top(old_img, new_img)
251
- top = @top
252
- bottom = @bottom
253
- left = @left || old_img.width - 1
254
- right = @right || 0
255
- old_img.height.times do |y|
256
- (0...left).find do |x|
257
- next if same_color?(old_img, new_img, x, y)
258
-
259
- top ||= y
260
- bottom = y
261
- left = x
262
- right = x if x > right
263
- x
264
- end
265
- (old_img.width - 1).step(right + 1, -1).find do |x|
266
- unless same_color?(old_img, new_img, x, y)
267
- bottom = y
268
- right = x
269
- end
270
- end
271
- end
272
- [left, top, right, bottom]
273
- end
206
+ def preprocess_image(image, driver = self)
207
+ result = image
274
208
 
275
- def find_bottom(old_img, new_img, left, right, bottom)
276
- if bottom
277
- (old_img.height - 1).step(bottom + 1, -1).find do |y|
278
- (left..right).find do |x|
279
- bottom = y unless same_color?(old_img, new_img, x, y)
280
- end
281
- end
209
+ if @dimensions && driver.inscribed?(@dimensions, result)
210
+ result = driver.crop(@dimensions, result)
282
211
  end
283
- bottom
284
- end
285
212
 
286
- def same_color?(old_img, new_img, x, y)
287
- @skip_area&.each do |skip_start_x, skip_start_y, skip_end_x, skip_end_y|
288
- 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)
289
215
  end
290
216
 
291
- color_distance =
292
- color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
293
- if !@max_color_distance || color_distance > @max_color_distance
294
- @max_color_distance = color_distance
217
+ if @skip_area
218
+ result = @skip_area.reduce(result) { |image, region| driver.add_black_box(image, region) }
295
219
  end
296
- color_matches = color_distance == 0 || (@color_distance_limit && @color_distance_limit > 0 &&
297
- color_distance <= @color_distance_limit)
298
- return color_matches if !@shift_distance_limit || @max_shift_distance == Float::INFINITY
299
-
300
- shift_distance = (color_matches && 0) ||
301
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
302
- if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
303
- @max_shift_distance = shift_distance
304
- end
305
- color_matches
306
- end
307
220
 
308
- def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
309
- org_color = old_img[x, y]
310
- if shift_distance_limit
311
- start_x = [0, x - shift_distance_limit].max
312
- end_x = [x + shift_distance_limit, new_img.width - 1].min
313
- xs = (start_x..end_x).to_a
314
- start_y = [0, y - shift_distance_limit].max
315
- end_y = [y + shift_distance_limit, new_img.height - 1].min
316
- ys = (start_y..end_y).to_a
317
- new_pixels = xs.product(ys)
318
- distances = new_pixels.map do |dx, dy|
319
- new_color = new_img[dx, dy]
320
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
321
- end
322
- distances.min
323
- else
324
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
325
- end
221
+ result
326
222
  end
327
223
 
328
- def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
329
- org_color = old_img[x, y]
330
- shift_distance = 0
331
- loop do
332
- bounds_breached = 0
333
- top_row = y - shift_distance
334
- if top_row >= 0 # top
335
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
336
- if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
337
- return shift_distance
338
- end
339
- end
340
- else
341
- bounds_breached += 1
342
- end
343
- if shift_distance > 0
344
- if (x - shift_distance) >= 0 # left
345
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
346
- .each do |dy|
347
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
348
- return shift_distance
349
- end
350
- end
351
- else
352
- bounds_breached += 1
353
- end
354
- if (y + shift_distance) < new_img.height # bottom
355
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
356
- if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
357
- return shift_distance
358
- end
359
- end
360
- else
361
- bounds_breached += 1
362
- end
363
- if (x + shift_distance) < new_img.width # right
364
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
365
- .each do |dy|
366
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
367
- return shift_distance
368
- end
369
- end
370
- else
371
- bounds_breached += 1
372
- end
373
- end
374
- break if bounds_breached == 4
375
-
376
- shift_distance += 1
377
- end
378
- Float::INFINITY
224
+ def difference_region=(region)
225
+ @left, @top, @right, @bottom = region
379
226
  end
380
227
 
381
- def color_matches(new_img, org_color, dx, dy, color_distance_limit)
382
- new_color = new_img[dx, dy]
383
- return new_color == org_color unless color_distance_limit
384
-
385
- color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
386
- 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
+ )
387
236
  end
388
237
  end
389
238
  end