capybara-screenshot-diff 1.2.0 → 1.4.1

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,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