Skip to content

Commit 887040b

Browse files
committed
example
1 parent d60c30f commit 887040b

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

docs/simplest_comparator.rb

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
# Prerequisite: You must have the `ruby-vips` gem installed and libvips on your system OS.
4+
# gem install ruby-vips
5+
require 'vips'
6+
7+
module VisualRegression
8+
class Comparator
9+
# Evaluates two images for visual regressions using a high-performance hybrid approach.
10+
#
11+
# @param baseline_path [String] Path to the original (baseline) image
12+
# @param test_path [String] Path to the new image being tested
13+
# @param options [Hash] Configuration for tolerances and masking
14+
# @option options [Array<Array>] :masks A list of [x, y, w, h] coordinates to ignore (e.g., [[0,0,100,50]])
15+
# @option options [Float] :size_tolerance Allowed file size difference ratio (default 0.05 / 5%)
16+
# @option options [Float] :color_tolerance Perceptual color diff threshold DE2000 (default 2.0)
17+
# @option options [Float] :pixel_tolerance Allowed ratio of mismatched pixels (default 0.001 / 0.1%)
18+
# @return [Hash] A hash containing the :match boolean and a :reason string
19+
def self.compare(baseline_path, test_path, options = {})
20+
masks = options[:masks] || []
21+
size_tol = options.fetch(:size_tolerance, 0.05)
22+
color_tol = options.fetch(:color_tolerance, 2.0)
23+
pixel_tol = options.fetch(:pixel_tolerance, 0.001)
24+
25+
# =======================================================================
26+
# STEP 1: File Size Check (Instant Early Rejection)
27+
# =======================================================================
28+
base_size = File.size(baseline_path).to_f
29+
test_size = File.size(test_path).to_f
30+
31+
# Avoid division by zero on empty files
32+
return { match: false, reason: "Baseline file is empty." } if base_size.zero?
33+
34+
size_diff_ratio = (base_size - test_size).abs / base_size
35+
36+
# If sizes are perfectly identical, they are highly likely the exact same file/render.
37+
# However, to be completely safe against dynamic data coincidentally matching byte size,
38+
# we still proceed. We ONLY reject if the difference is drastic.
39+
if size_diff_ratio > size_tol
40+
return {
41+
match: false,
42+
reason: "Fast Rejection: File size difference (#{(size_diff_ratio * 100).round(2)}%) exceeds #{size_tol * 100}% tolerance."
43+
}
44+
end
45+
46+
# =======================================================================
47+
# STEP 2 & 3 SETUP: Load images into Libvips
48+
# `access: :sequential` tells VIPS to stream the image top-to-bottom,
49+
# which drastically reduces memory usage and speeds up processing.
50+
# =======================================================================
51+
begin
52+
base_img = Vips::Image.new_from_file(baseline_path, access: :sequential)
53+
test_img = Vips::Image.new_from_file(test_path, access: :sequential)
54+
rescue Vips::Error => e
55+
return { match: false, reason: "Image load error: #{e.message}" }
56+
end
57+
58+
if base_img.width != test_img.width || base_img.height != test_img.height
59+
return { match: false, reason: "Dimensions mismatch: #{base_img.width}x#{base_img.height} vs #{test_img.width}x#{test_img.height}" }
60+
end
61+
62+
# =======================================================================
63+
# STEP 2: Block Masking (Handle Dynamic Data)
64+
# Draw solid black rectangles over areas we know are noisy (ads, dates).
65+
# =======================================================================
66+
masks.each do |(x, y, w, h)|
67+
# format: draw_rect([R, G, B], x, y, width, height, fill: true)
68+
base_img = base_img.draw_rect([0, 0, 0], x, y, w, h, fill: true)
69+
test_img = test_img.draw_rect([0, 0, 0], x, y, w, h, fill: true)
70+
end
71+
72+
# =======================================================================
73+
# STEP 3: Color Distance & Thresholding (Handle Anti-Aliasing)
74+
# =======================================================================
75+
# Ensure both images are in the standard sRGB colourspace before diffing
76+
base_img = base_img.colourspace(:srgb) unless base_img.interpretation == :srgb
77+
test_img = test_img.colourspace(:srgb) unless test_img.interpretation == :srgb
78+
79+
# Calculate the perceptual color distance.
80+
# DE2000 (dE00) is a formula that mimics human vision. A value < 2.0 is generally
81+
# imperceptible to the human eye, neatly eating up tiny anti-aliasing artifacts.
82+
begin
83+
diff = base_img.dE00(test_img)
84+
rescue Vips::Error
85+
# Fallback to dE76 if you are using a very old version of libvips
86+
diff = base_img.dE76(test_img)
87+
end
88+
89+
# Create a boolean mask: 255 where diff > color tolerance, 0 otherwise
90+
thresholded_diff = diff > color_tol
91+
92+
# .avg returns the average pixel value (between 0 and 255).
93+
# Dividing by 255 gives us the exact ratio of pixels that failed the color check.
94+
changed_ratio = thresholded_diff.avg / 255.0
95+
96+
if changed_ratio > pixel_tol
97+
{
98+
match: false,
99+
reason: "Visual mismatch: #{(changed_ratio * 100).round(4)}% of pixels differ (Threshold: #{pixel_tol * 100}%)."
100+
}
101+
else
102+
{
103+
match: true,
104+
reason: "Match: Differences are within accepted anti-aliasing and masking thresholds."
105+
}
106+
end
107+
end
108+
end
109+
end
110+
111+
# --- Example Usage ---
112+
if __FILE__ == $PROGRAM_NAME
113+
result = VisualRegression::Comparator.compare(
114+
'baseline.png',
115+
'latest_render.png',
116+
masks: [
117+
[1500, 50, 300, 250], # Mask out an ad banner top right
118+
[10, 10, 100, 20] # Mask out a timestamp top left
119+
],
120+
size_tolerance: 0.05, # 5% file size jump allowed before instant fail
121+
color_tolerance: 2.0, # DE2000 > 2.0 triggers a mismatch
122+
pixel_tolerance: 0.001 # Allow 0.1% of the image's pixels to differ (stray noise)
123+
)
124+
125+
puts result.inspect
126+
end

0 commit comments

Comments
 (0)