|
| 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