capybara-screenshot-diff 1.10.3.1 → 1.13.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/Rakefile +29 -1
  5. data/capybara-screenshot-diff.gemspec +4 -3
  6. data/docs/RELEASE_PREP.md +42 -0
  7. data/docs/UPGRADING.md +390 -0
  8. data/docs/architecture.md +270 -0
  9. data/docs/ci-integration.md +238 -0
  10. data/docs/configuration.md +395 -0
  11. data/docs/docker-testing.md +24 -0
  12. data/docs/drivers.md +102 -0
  13. data/docs/framework-setup.md +87 -0
  14. data/docs/images/snap_diff_annotated.png +0 -0
  15. data/docs/images/snap_diff_web_ui.png +0 -0
  16. data/docs/migration-guide.md +286 -0
  17. data/docs/organization.md +204 -0
  18. data/docs/reporters.md +46 -0
  19. data/docs/thread_safety.md +97 -0
  20. data/gems.rb +2 -1
  21. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  22. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  23. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  24. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  25. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  26. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  27. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  28. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  29. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  30. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  31. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  32. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
  33. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  34. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  35. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  36. data/lib/capybara/screenshot/diff/version.rb +1 -1
  37. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  38. data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
  39. data/lib/capybara_screenshot_diff/dsl.rb +123 -7
  40. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  41. data/lib/capybara_screenshot_diff/minitest.rb +10 -5
  42. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  43. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  44. data/lib/capybara_screenshot_diff/rspec.rb +20 -4
  45. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
  46. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  47. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  48. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  49. data/lib/capybara_screenshot_diff/static.rb +11 -0
  50. data/lib/capybara_screenshot_diff.rb +31 -5
  51. metadata +51 -8
  52. data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
data/docs/reporters.md ADDED
@@ -0,0 +1,46 @@
1
+ # Reporters
2
+
3
+ ## Web UI for Reviewing Screenshot Changes
4
+
5
+ Generate an interactive Web UI report of screenshot differences:
6
+
7
+ ```ruby
8
+ # Add to test_helper.rb — one line, that's it
9
+ require 'capybara_screenshot_diff/reporters/html'
10
+ ```
11
+
12
+ After running tests, open the report (generated only when there are failures):
13
+
14
+ ```bash
15
+ open doc/screenshots/snap_diff_report.html
16
+ ```
17
+
18
+ The report includes a sidebar with thumbnails, side-by-side comparison with diff toggle, search, and summary stats. No configuration needed — just require it.
19
+
20
+ **Note:** The report is not generated when all screenshots match. In parallel test environments, each worker writes to the same file — the last worker's results will be in the report.
21
+
22
+ ## Custom Reporters
23
+
24
+ Build your own reporter by implementing `record` and `finalize`:
25
+
26
+ ```ruby
27
+ class MyReporter
28
+ def record(assertions)
29
+ assertions.each do |assertion|
30
+ next unless assertion.compare&.difference&.different?
31
+ # process the failure — send to Slack, write JSON, etc.
32
+ end
33
+ end
34
+
35
+ def finalize
36
+ # called once at process exit — write summary, upload report, etc.
37
+ end
38
+ end
39
+
40
+ # Register in test_helper.rb
41
+ CapybaraScreenshotDiff.reporters << MyReporter.new
42
+ ```
43
+
44
+ Reporters are notified before assertions are cleared on each test teardown. `finalize` is called via `at_exit`.
45
+
46
+ [← Back to README](../README.md)
@@ -0,0 +1,97 @@
1
+ # Thread Safety Guide for Parallel Testing
2
+
3
+ This document explains how `snap_diff` behaves under Rails parallel tests with the `:thread` strategy.
4
+
5
+ ## Overview
6
+
7
+ `snap_diff` is thread safe for parallel test execution as long as global configuration is set before tests run. Per-thread state is isolated, and shared state is protected where it matters.
8
+
9
+ ## Architecture Summary
10
+
11
+ ### Per-thread Assertion Registry
12
+
13
+ Each thread gets its own `AssertionRegistry` stored in thread-local storage:
14
+
15
+ ```ruby
16
+ def registry
17
+ Thread.current[:capybara_screenshot_diff_registry] ||= AssertionRegistry.new
18
+ end
19
+ ```
20
+
21
+ This prevents cross-thread leakage for assertions and screenshot naming.
22
+
23
+ ### Reporters Snapshot on Notify
24
+
25
+ Reporters are notified using a snapshot protected by an eagerly initialized mutex:
26
+
27
+ ```ruby
28
+ @reporters_mutex = Mutex.new
29
+
30
+ def notify_reporters(assertions)
31
+ reporters_snapshot = reporters_mutex.synchronize { reporters.dup }
32
+ reporters_snapshot.each { |reporter| reporter.record(assertions) }
33
+ end
34
+ ```
35
+
36
+ This ensures a stable list while notifying without forcing a global lock around reporter work.
37
+
38
+ ### HTML Reporter Internal Lock
39
+
40
+ The HTML reporter protects `@failures`, `@total`, and `@finalized` with a mutex so `record` and `finalize` can run safely:
41
+
42
+ ```ruby
43
+ @mutex.synchronize do
44
+ return if @finalized
45
+ @total += total
46
+ @failures.concat(failures)
47
+ end
48
+ ```
49
+
50
+ `@finalized` is set only after `write_report` succeeds, so a failed write can be retried.
51
+
52
+ ### Screenshot Naming Isolation
53
+
54
+ Each thread gets its own `ScreenshotNamer` via the per-thread registry, so counters, sections, and groups do not collide.
55
+
56
+ ### SnapManager Per Call
57
+
58
+ `SnapManager` returns a new instance for each call, avoiding shared mutable state.
59
+
60
+ ## Global Configuration
61
+
62
+ Configuration uses `mattr_accessor` and should be set once before tests run. Do not mutate config during parallel execution.
63
+
64
+ ## Parallel Test Lifecycle
65
+
66
+ - Setup: per-thread registry is created, config is read
67
+ - Execution: assertions are added to the thread-local registry
68
+ - Teardown: `verify` and `reset` operate on the thread-local registry, reporters are notified
69
+ - Exit: reporters finalize once per process (using mutex-protected snapshot)
70
+
71
+ ## Usage Examples
72
+
73
+ ```ruby
74
+ parallelize(workers: :number_of_processors, with: :threads)
75
+
76
+ Capybara::Screenshot::Diff.configure do |screenshot, diff|
77
+ screenshot.window_size = [1280, 1024]
78
+ screenshot.save_path = "doc/screenshots"
79
+ diff.tolerance = 0.001
80
+ end
81
+ ```
82
+
83
+ ## Do and Do Not
84
+
85
+ Do:
86
+ - Set config once in test helper
87
+ - Pass per-screenshot options in the call
88
+
89
+ Do not:
90
+ - Change global config inside tests
91
+ - Manually mutate registry internals
92
+
93
+ ## File System Notes
94
+
95
+ - Paths are unique per screenshot name and counter
96
+ - `FileUtils.mv` is atomic on most file systems
97
+ - Directory creation uses `mkpath`
data/gems.rb CHANGED
@@ -15,7 +15,8 @@ gem "ruby-vips", require: false
15
15
  group :test do
16
16
  gem "capybara", ">= 3.26"
17
17
  gem "mutex_m" # Needed for RubyMine debugging. Try removing it.
18
- gem "minitest", require: false
18
+ gem "minitest", "< 6", require: false
19
+ gem "minitest-mock", require: false
19
20
  gem "minitest-stub-const", require: false
20
21
  gem "simplecov", require: false
21
22
  gem "rspec", require: false
@@ -48,7 +48,7 @@ module Capybara
48
48
  def build_regions_for(coordinates)
49
49
  coordinates
50
50
  .map { |entry| Region.from_edge_coordinates(*entry) }
51
- .tap { |it| it.compact! }
51
+ .tap { |region| region.compact! }
52
52
  end
53
53
  end
54
54
  end
@@ -60,6 +60,19 @@ module Capybara
60
60
  session.execute_script(HIDE_CARET_SCRIPT)
61
61
  end
62
62
 
63
+ DISABLE_ANIMATIONS_SCRIPT = <<~JS
64
+ if (!document.getElementById('csdDisableAnimationsStyle')) {
65
+ let style = document.createElement('style');
66
+ style.setAttribute('id', 'csdDisableAnimationsStyle');
67
+ style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
68
+ document.head.appendChild(style);
69
+ }
70
+ JS
71
+
72
+ def self.disable_animations
73
+ session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
74
+ end
75
+
63
76
  FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
64
77
  function activeElement(){
65
78
  const ae = document.activeElement;
@@ -85,7 +98,7 @@ module Capybara
85
98
  JS
86
99
 
87
100
  def self.all_visible_regions_for(selector)
88
- BrowserHelpers.session.all(selector, visible: true).map(&method(:region_for))
101
+ BrowserHelpers.session.all(selector, visible: true).map { |el| region_for(el) }
89
102
  end
90
103
 
91
104
  def self.region_for(element)
@@ -2,5 +2,8 @@
2
2
 
3
3
  module Capybara::Screenshot::Diff
4
4
  class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
5
+ def skip_area
6
+ options[:skip_area]
7
+ end
5
8
  end
6
9
  end
@@ -5,7 +5,31 @@ require "json"
5
5
  module Capybara
6
6
  module Screenshot
7
7
  module Diff
8
- class Difference < Struct.new(:region, :meta, :comparison, :failed_by)
8
+ # Represents a difference between two images
9
+ #
10
+ # This value object encapsulates the result of an image comparison operation.
11
+ # It follows the Single Responsibility Principle by focusing solely on representing
12
+ # the difference state, including:
13
+ # - Whether images are different or equal
14
+ # - Why they differ (dimensions, pixels, etc.)
15
+ # - The specific region of difference
16
+ # - Whether differences are tolerable based on configured thresholds
17
+ #
18
+ # As part of the layered comparison architecture, this class represents the final
19
+ # output of the comparison process, containing all data needed for reporting.
20
+ # Represents a difference between two images
21
+ class Difference < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil)
22
+ def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil)
23
+ Difference.new(
24
+ nil,
25
+ {difference_level: nil, max_color_distance: 0},
26
+ comparison,
27
+ failed_by,
28
+ base_image_path,
29
+ new_image_path
30
+ ).freeze
31
+ end
32
+
9
33
  def different?
10
34
  failed? || !(blank? || tolerable?)
11
35
  end
@@ -27,7 +51,7 @@ module Capybara
27
51
  end
28
52
 
29
53
  def skip_area
30
- options[:skip_area]
54
+ comparison.skip_area
31
55
  end
32
56
 
33
57
  def area_size_limit
@@ -39,7 +63,7 @@ module Capybara
39
63
  end
40
64
 
41
65
  def region_area_size
42
- region&.size || 0
66
+ @region_area_size ||= region&.size || 0
43
67
  end
44
68
 
45
69
  def ratio
@@ -61,6 +85,19 @@ module Capybara
61
85
  def tolerable?
62
86
  !!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio))
63
87
  end
88
+
89
+ # Path accessors for backward compatibility
90
+ def new_image_path
91
+ image_path || comparison&.new_image_path
92
+ end
93
+
94
+ def original_image_path
95
+ base_image_path || comparison&.base_image_path
96
+ end
97
+
98
+ def diff_mask
99
+ meta[:diff_mask]
100
+ end
64
101
  end
65
102
  end
66
103
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara/screenshot/diff/comparison"
4
+ require "capybara/screenshot/diff/difference"
5
+
6
+ module Capybara
7
+ module Screenshot
8
+ module Diff
9
+ # Analyzes image differences with configurable tolerance levels.
10
+ #
11
+ # This class implements the core comparison logic for detecting visual differences
12
+ # between images while accounting for various tolerances and optimizations.
13
+ #
14
+ # The comparison process follows these steps:
15
+ # 1. Dimension Check (Fastest)
16
+ # - Compares image dimensions first for quick rejection
17
+ # - Different dimensions always indicate a difference
18
+ #
19
+ # 2. Pixel Equality Check (Fast)
20
+ # - Performs bitwise comparison if dimensions match
21
+ # - Returns immediately if images are exactly identical
22
+ #
23
+ # 3. Tolerant Comparison (Slower)
24
+ # - Only runs if quick checks don't determine equality
25
+ # - Respects configured tolerances for color and shift differences
26
+ # - Can ignore specific regions (skip_area)
27
+ # - Considers anti-aliasing and sub-pixel rendering differences
28
+ #
29
+ # The class is designed to be stateless and thread-safe, with all configuration
30
+ # passed in through the constructor.
31
+ class DifferenceFinder
32
+ TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
33
+
34
+ attr_reader :driver, :options
35
+
36
+ # Creates a new DifferenceFinder instance.
37
+ #
38
+ # @param driver [Drivers::Base] The image processing driver to use.
39
+ # Must implement the driver interface expected by DifferenceFinder.
40
+ # @param options [Hash] Configuration options for the comparison:
41
+ # @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0).
42
+ # @option options [Numeric] :color_distance_limit Maximum allowed color distance.
43
+ # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance.
44
+ # @option options [Numeric] :area_size_limit Maximum allowed difference area size.
45
+ # @option options [Array<Array>] :skip_area Regions to exclude from comparison.
46
+ def initialize(driver, options)
47
+ @driver = driver
48
+ @options = options
49
+ @without_tolerable_options = (options.keys & TOLERABLE_OPTIONS).empty?
50
+ end
51
+
52
+ # Analyzes the comparison and determines if images are different.
53
+ #
54
+ # @param comparison [Comparison] The comparison object containing images to analyze.
55
+ # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
56
+ # In quick mode, returns [is_equal, difference] where:
57
+ # - is_equal is true if images are considered equal
58
+ # - difference is a Difference object or nil
59
+ # When false, returns a Difference object directly.
60
+ # @return [Array, Difference] Result format depends on quick_mode parameter.
61
+ # @raise [ArgumentError] If the comparison object is invalid.
62
+ def call(comparison, quick_mode: true)
63
+ # Process the comparison and return result
64
+
65
+ # Handle dimension differences
66
+ unless driver.same_dimension?(comparison)
67
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
68
+ return quick_mode ? [false, result] : result
69
+ end
70
+
71
+ # Handle identical pixels
72
+ if driver.same_pixels?(comparison)
73
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
74
+ return quick_mode ? [true, result] : result
75
+ end
76
+
77
+ # Handle early return for non-tolerable options
78
+ if quick_mode && without_tolerable_options?
79
+ return [false, nil]
80
+ end
81
+
82
+ # Process difference region
83
+ region = driver.find_difference_region(comparison)
84
+
85
+ # Only create a proper difference object if we've completed the comparison
86
+ quick_mode ? [!region.different?, region] : region
87
+ end
88
+
89
+ private
90
+
91
+ def without_tolerable_options?
92
+ @without_tolerable_options
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -30,6 +30,10 @@ module Capybara
30
30
  def dimension(image)
31
31
  [width_for(image), height_for(image)]
32
32
  end
33
+
34
+ def supports?(feature)
35
+ respond_to?(feature)
36
+ end
33
37
  end
34
38
  end
35
39
  end
@@ -24,10 +24,6 @@ module Capybara
24
24
  _load_images(old_bytes, new_bytes)
25
25
  end
26
26
 
27
- def filter_image_with_median(_image)
28
- raise NotImplementedError
29
- end
30
-
31
27
  def add_black_box(image, _region)
32
28
  image
33
29
  end
@@ -217,22 +213,24 @@ module Capybara
217
213
 
218
214
  def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
219
215
  org_color = old_img[x, y]
220
- if shift_distance_limit
221
- start_x = [0, x - shift_distance_limit].max
222
- end_x = [x + shift_distance_limit, new_img.width - 1].min
223
- xs = (start_x..end_x).to_a
224
- start_y = [0, y - shift_distance_limit].max
225
- end_y = [y + shift_distance_limit, new_img.height - 1].min
226
- ys = (start_y..end_y).to_a
227
- new_pixels = xs.product(ys)
228
-
229
- distances = new_pixels.map do |dx, dy|
230
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
216
+ unless shift_distance_limit
217
+ return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
218
+ end
219
+
220
+ start_x = [0, x - shift_distance_limit].max
221
+ end_x = [x + shift_distance_limit, new_img.width - 1].min
222
+ start_y = [0, y - shift_distance_limit].max
223
+ end_y = [y + shift_distance_limit, new_img.height - 1].min
224
+
225
+ min_distance = Float::INFINITY
226
+ (start_y..end_y).each do |dy|
227
+ (start_x..end_x).each do |dx|
228
+ distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
229
+ return 0 if distance == 0
230
+ min_distance = distance if distance < min_distance
231
231
  end
232
- distances.min
233
- else
234
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
235
232
  end
233
+ min_distance
236
234
  end
237
235
 
238
236
  def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
@@ -254,10 +252,10 @@ module Capybara
254
252
  if (x - shift_distance) >= 0 # left
255
253
  ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
256
254
  .each do |dy|
257
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
258
- return shift_distance
255
+ if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
256
+ return shift_distance
257
+ end
259
258
  end
260
- end
261
259
  else
262
260
  bounds_breached += 1
263
261
  end
@@ -273,10 +271,10 @@ module Capybara
273
271
  if (x + shift_distance) < new_img.width # right
274
272
  ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
275
273
  .each do |dy|
276
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
277
- return shift_distance
274
+ if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
275
+ return shift_distance
276
+ end
278
277
  end
279
- end
280
278
  else
281
279
  bounds_breached += 1
282
280
  end
@@ -15,15 +15,18 @@ module Capybara
15
15
  # Compare two images and determine if they are equal, different, or within some comparison
16
16
  # range considering color values and difference area size.
17
17
  module Drivers
18
- DEFAULT_HIGHLIGHT_COLOR = [255, 0, 0, 255].freeze
19
-
20
18
  class VipsDriver < BaseDriver
21
19
  def find_difference_region(comparison)
22
20
  new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
23
21
 
24
- diff_mask = VipsUtil.difference_mask(base_image, new_image, options[:color_distance_limit])
25
- region = VipsUtil.difference_region_by(diff_mask)
26
- region = nil if region && same_as?(region, base_image)
22
+ diff_mask = if options[:perceptual_threshold]
23
+ self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
24
+ else
25
+ self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
26
+ end
27
+ region = self.class.difference_region_by(diff_mask)
28
+ # TODO: schedule research when we got this case for VIPs
29
+ # region = nil if region && region_covers_entire_image?(region, base_image)
27
30
 
28
31
  result = Difference.new(region, {}, comparison)
29
32
 
@@ -40,7 +43,7 @@ module Capybara
40
43
  rescue Vips::Error => e
41
44
  warn(
42
45
  "[capybara-screenshot-diff] Crop has been failed for " \
43
- "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
46
+ "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
44
47
  )
45
48
  raise e
46
49
  end
@@ -56,7 +59,7 @@ module Capybara
56
59
  end
57
60
 
58
61
  def difference_level(diff_mask, old_img, _region = nil)
59
- VipsUtil.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
62
+ self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
60
63
  end
61
64
 
62
65
  MAX_FILENAME_LENGTH = 200
@@ -89,10 +92,6 @@ module Capybara
89
92
  result
90
93
  end
91
94
 
92
- def dimension(image)
93
- [width_for(image), height_for(image)]
94
- end
95
-
96
95
  def draw_rectangles(images, region, rgba, offset: 0)
97
96
  images.map do |image|
98
97
  image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
@@ -107,37 +106,51 @@ module Capybara
107
106
  base_image.composite2(new_image, :over)
108
107
  end
109
108
 
110
- def highlight_mask(diff_mask, merged_image, color: DEFAULT_HIGHLIGHT_COLOR)
109
+ def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
111
110
  diff_mask.ifthenelse(color, merged_image * 0.75)
112
111
  end
113
112
 
114
113
  private
115
114
 
116
- def same_as?(region, base_image)
117
- region.x.zero? &&
118
- region.y.zero? &&
119
- region.height == height_for(base_image) &&
120
- region.width == width_for(base_image)
121
- end
122
-
123
- class VipsUtil
124
- def self.difference_area(old_image, new_image, color_distance: 0)
125
- difference_mask = difference_mask(new_image, old_image, color_distance)
126
- difference_area_size_by(difference_mask)
115
+ class << self
116
+ def difference_area(old_image, new_image, color_distance: 0)
117
+ mask = difference_mask(new_image, old_image, color_distance)
118
+ difference_area_size_by(mask)
127
119
  end
128
120
 
129
- def self.difference_area_size_by(difference_mask)
121
+ def difference_area_size_by(difference_mask)
130
122
  diff_mask = difference_mask == 0
131
123
  diff_mask.hist_find.to_a[0][0].max
132
124
  end
133
125
 
134
- def self.difference_mask(base_image, new_image, color_distance = nil)
126
+ def difference_mask(base_image, new_image, color_distance = nil)
135
127
  result = (new_image - base_image).abs
136
-
137
128
  color_distance ? result > color_distance : result
138
129
  end
139
130
 
140
- def self.difference_region_by(diff_mask)
131
+ def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
132
+ color_diff = perceptual_color_diff(base_image, new_image) > threshold
133
+ alpha_diff = alpha_channel_diff(base_image, new_image)
134
+ alpha_diff ? (color_diff | alpha_diff) : color_diff
135
+ end
136
+
137
+ def perceptual_color_diff(base_image, new_image)
138
+ base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
139
+ new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
140
+ base_lab = base_rgb.colourspace(:lab)
141
+ new_lab = new_rgb.colourspace(:lab)
142
+ base_lab.dE00(new_lab)
143
+ rescue Vips::Error
144
+ base_lab.dE76(new_lab)
145
+ end
146
+
147
+ def alpha_channel_diff(base_image, new_image)
148
+ return unless base_image.bands > 3 && new_image.bands > 3
149
+
150
+ (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
151
+ end
152
+
153
+ def difference_region_by(diff_mask)
141
154
  columns, rows = diff_mask.bandor.project
142
155
 
143
156
  left = columns.profile[1].min