capybara-screenshot-diff 1.3.1 → 1.4.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,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