Skip to content

Commit ad4ec0b

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 ad4ec0b

4 files changed

Lines changed: 119 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: 26 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,27 @@ 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+
base_lab = base_rgb.colourspace(:lab)
142+
new_lab = new_rgb.colourspace(:lab)
143+
perceptual_diff = begin
144+
base_lab.dE00(new_lab)
145+
rescue Vips::Error
146+
base_lab.dE76(new_lab)
147+
end
148+
color_diff = perceptual_diff > threshold
149+
150+
# Compare alpha channel separately to catch transparency changes
151+
if base_image.bands > 3 && new_image.bands > 3
152+
alpha_diff = (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
153+
color_diff | alpha_diff
154+
else
155+
color_diff
156+
end
157+
end
158+
134159
def difference_region_by(diff_mask)
135160
columns, rows = diff_mask.bandor.project
136161

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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ class VipsDriverTest < ActiveSupport::TestCase
148148
assert comp.different?
149149
end
150150

151+
test "#different? uses perceptual_threshold when set" do
152+
comp = make_comparison(:a, :b, perceptual_threshold: 2.0)
153+
assert comp.different?
154+
end
155+
156+
test "#different? with perceptual_threshold returns equal for identical images" do
157+
comp = make_comparison(:a, :a, perceptual_threshold: 2.0)
158+
assert_not comp.different?
159+
end
160+
151161
# Test Interface Contracts
152162

153163
test "#from_file successfully loads an image from the specified path" do
@@ -205,6 +215,56 @@ class VipsDriverClassMethodsTest < ActiveSupport::TestCase
205215
assert_equal 8, VipsDriver.difference_area(old_image, new_image, color_distance: 10)
206216
end
207217

218+
test "VipsDriver.perceptual_difference_mask returns nil region for identical images" do
219+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
220+
same_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
221+
222+
diff_mask = VipsDriver.perceptual_difference_mask(old_image, same_image)
223+
region = VipsDriver.difference_region_by(diff_mask)
224+
225+
assert_nil region
226+
end
227+
228+
test "VipsDriver.perceptual_difference_mask detects differences between images" 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+
diff_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 2.0)
233+
region = VipsDriver.difference_region_by(diff_mask)
234+
235+
assert_not_nil region
236+
end
237+
238+
test "VipsDriver.perceptual_difference_mask detects alpha channel differences" do
239+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
240+
# Make a copy with different alpha
241+
transparent = old_image.extract_band(0, n: 3).bandjoin(128)
242+
243+
diff_mask = VipsDriver.perceptual_difference_mask(old_image, transparent)
244+
region = VipsDriver.difference_region_by(diff_mask)
245+
246+
assert_not_nil region, "Should detect alpha channel difference"
247+
end
248+
249+
test "VipsDriver.perceptual_difference_mask respects threshold" do
250+
old_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/a.png")
251+
new_image = Vips::Image.new_from_file("#{TEST_IMAGES_DIR}/b.png")
252+
253+
# High threshold — fewer differences detected
254+
high_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 50.0)
255+
high_region = VipsDriver.difference_region_by(high_mask)
256+
257+
# Low threshold — more differences detected
258+
low_mask = VipsDriver.perceptual_difference_mask(old_image, new_image, 2.0)
259+
low_region = VipsDriver.difference_region_by(low_mask)
260+
261+
assert_not_nil low_region
262+
# High threshold region should be smaller or nil compared to low threshold
263+
if high_region
264+
assert high_region.size <= low_region.size
265+
end
266+
end
267+
208268
private
209269

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

0 commit comments

Comments
 (0)