Skip to content

Commit ef607e2

Browse files
pftgclaude
andcommitted
feat: add perceptual color distance (dE00) support to VipsDriver
New `perceptual: true` option uses CIE dE00 formula for color comparison instead of raw RGB channel distance. dE00 measures perceptual color difference — values <2.0 are imperceptible to human eyes, making it ideal for handling anti-aliasing artifacts. Usage: screenshot 'page', perceptual: true, color_distance_limit: 2.0 Falls back to existing RGB distance when perceptual is not set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 43d14f5 commit ef607e2

4 files changed

Lines changed: 84 additions & 1 deletion

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,37 @@ end
155155
| Standard Rails apps | 0.0005 | 15 | 1s |
156156
| Pixel-perfect design tests | 0.0001 | 5 | 1s |
157157

158+
### Perceptual color comparison (VIPS only)
159+
160+
By default, color differences are measured using raw RGB channel distance. This can produce
161+
false positives from anti-aliasing and sub-pixel font rendering — the same page rendered on
162+
different OS versions or browsers will have slightly different pixel values at text edges.
163+
164+
The `perceptual_threshold` option uses the CIE dE00 formula instead, which measures color
165+
difference the way human eyes perceive it. Anti-aliasing artifacts typically score below 2.0
166+
on the dE00 scale and are automatically ignored.
167+
168+
```ruby
169+
# Per-screenshot: ignore anti-aliasing, catch real visual changes
170+
screenshot 'dashboard', perceptual_threshold: 2.0
171+
172+
# Global: apply to all screenshots
173+
Capybara::Screenshot::Diff.perceptual_threshold = 2.0
174+
175+
# dE00 scale reference:
176+
# < 1.0 — not perceptible by human eyes
177+
# 1-2 — perceptible through close observation (anti-aliasing, font hinting)
178+
# 2-10 — perceptible at a glance (color shifts, layout changes)
179+
# > 10 — clearly different colors
180+
```
181+
182+
Use `perceptual_threshold` when you see false positives from font rendering differences across
183+
CI environments, or when `color_distance_limit` with raw RGB requires frequent tuning.
184+
185+
**Note:** `perceptual_threshold` and `color_distance_limit` are independent options on different
186+
scales. `perceptual_threshold` uses dE00 (0-100+), `color_distance_limit` uses Euclidean RGB
187+
distance (0-441). Set one or the other, not both.
188+
158189
### Taking screenshots
159190

160191
Add `screenshot '<my_feature>'` to your tests. The screenshot will be saved in

lib/capybara/screenshot/diff/drivers/vips_driver.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ class VipsDriver < BaseDriver
1919
def find_difference_region(comparison)
2020
new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
2121

22-
diff_mask = self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
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
2327
region = self.class.difference_region_by(diff_mask)
2428
# TODO: schedule research when we got this case for VIPs
2529
# region = nil if region && region_covers_entire_image?(region, base_image)
@@ -131,6 +135,13 @@ def difference_mask(base_image, new_image, color_distance = nil)
131135
color_distance ? result > color_distance : result
132136
end
133137

138+
def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
139+
base_rgb = base_image.bands > 3 ? base_image.extract_band(0, n: 3) : base_image
140+
new_rgb = new_image.bands > 3 ? new_image.extract_band(0, n: 3) : new_image
141+
diff = base_rgb.colourspace(:lab).dE00(new_rgb.colourspace(:lab))
142+
diff > threshold
143+
end
144+
134145
def difference_region_by(diff_mask)
135146
columns, rows = diff_mask.bandor.project
136147

lib/capybara_screenshot_diff.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ module Diff
7171
mattr_accessor :skip_area
7272
mattr_accessor(:driver) { :auto }
7373
mattr_accessor :tolerance
74+
mattr_accessor :perceptual_threshold
7475

7576
mattr_accessor(:screenshoter) { Screenshoter }
7677
mattr_accessor(:manager) { CapybaraScreenshotDiff::SnapManager }
@@ -96,6 +97,7 @@ def self.default_options
9697
driver: driver,
9798
screenshot_format: Screenshot.screenshot_format,
9899
capybara_screenshot_options: Screenshot.capybara_screenshot_options,
100+
perceptual_threshold: perceptual_threshold,
99101
shift_distance_limit: shift_distance_limit,
100102
skip_area: skip_area,
101103
stability_time_limit: Screenshot.stability_time_limit,

test/unit/drivers/vips_driver_test.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,45 @@ class VipsDriverClassMethodsTest < ActiveSupport::TestCase
205205
assert_equal 8, VipsDriver.difference_area(old_image, new_image, color_distance: 10)
206206
end
207207

208+
test "VipsDriver.perceptual_difference_mask returns nil region for identical images" do
209+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
210+
same_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
211+
212+
diff_mask = VipsDriver.perceptual_difference_mask(old_image, same_image)
213+
region = VipsDriver.difference_region_by(diff_mask)
214+
215+
assert_nil region
216+
end
217+
218+
test "VipsDriver.perceptual_difference_mask detects differences between images" do
219+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
220+
new_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/b.png")
221+
222+
diff_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 2.0)
223+
region = VipsDriver.difference_region_by(diff_mask)
224+
225+
assert_not_nil region
226+
end
227+
228+
test "VipsDriver.perceptual_difference_mask respects threshold" do
229+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
230+
new_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/b.png")
231+
232+
# High threshold — fewer differences detected
233+
high_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 50.0)
234+
high_region = VipsDriver.difference_region_by(high_mask)
235+
236+
# Low threshold — more differences detected
237+
low_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 2.0)
238+
low_region = VipsDriver.difference_region_by(low_mask)
239+
240+
assert_not_nil low_region
241+
# High threshold region should be smaller or nil compared to low threshold
242+
if high_region
243+
assert high_region.size <= low_region.size
244+
end
245+
end
246+
208247
private
209248

210249
def difference(old_image, new_image, color_distance: nil)

0 commit comments

Comments
 (0)