|
| 1 | +//! Compares a render to its reference pixel by pixel. |
| 2 | +
|
| 3 | +use crate::corpus::Tolerance; |
| 4 | +use image::RgbaImage; |
| 5 | + |
| 6 | +pub(crate) enum Comparison { |
| 7 | + Matches, |
| 8 | + SizeChanged { |
| 9 | + reference: (u32, u32), |
| 10 | + actual: (u32, u32), |
| 11 | + }, |
| 12 | + /// More pixels than the tolerance allows differ, highlighted in `diff`. |
| 13 | + Differs { |
| 14 | + differing: usize, |
| 15 | + total: usize, |
| 16 | + diff: RgbaImage, |
| 17 | + }, |
| 18 | +} |
| 19 | + |
| 20 | +/// O(pixels). |
| 21 | +pub(crate) fn compare(reference: &RgbaImage, actual: &RgbaImage, tolerance: Tolerance) -> Comparison { |
| 22 | + if reference.dimensions() != actual.dimensions() { |
| 23 | + return Comparison::SizeChanged { |
| 24 | + reference: reference.dimensions(), |
| 25 | + actual: actual.dimensions(), |
| 26 | + }; |
| 27 | + } |
| 28 | + |
| 29 | + // Differing pixels show red over a faded copy of the reference, so they stand out in context |
| 30 | + let mut diff = RgbaImage::new(actual.width(), actual.height()); |
| 31 | + let mut differing = 0; |
| 32 | + for ((expected, rendered), marked) in reference.pixels().zip(actual.pixels()).zip(diff.pixels_mut()) { |
| 33 | + let largest_difference = expected.0.iter().zip(rendered.0).map(|(a, b)| a.abs_diff(b)).max().unwrap_or_default(); |
| 34 | + if largest_difference > tolerance.channel { |
| 35 | + differing += 1; |
| 36 | + *marked = image::Rgba([255, 0, 0, 255]); |
| 37 | + } else { |
| 38 | + let [red, green, blue, alpha] = expected.0; |
| 39 | + let luminance = ((red as u32 * 3 + green as u32 * 6 + blue as u32) / 10) as u8; |
| 40 | + *marked = image::Rgba([luminance, luminance, luminance, alpha / 4]); |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + let total = (actual.width() * actual.height()) as usize; |
| 45 | + if differing as f64 > tolerance.pixels * total as f64 { |
| 46 | + Comparison::Differs { differing, total, diff } |
| 47 | + } else { |
| 48 | + Comparison::Matches |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +#[cfg(test)] |
| 53 | +mod tests { |
| 54 | + use super::*; |
| 55 | + |
| 56 | + fn image_of(pixels: &[[u8; 4]]) -> RgbaImage { |
| 57 | + RgbaImage::from_raw(pixels.len() as u32, 1, pixels.concat()).unwrap() |
| 58 | + } |
| 59 | + |
| 60 | + #[test] |
| 61 | + fn small_differences_and_few_pixels_are_tolerated() { |
| 62 | + let reference = image_of(&[[10, 10, 10, 255]; 4]); |
| 63 | + let close = image_of(&[[12, 9, 10, 255]; 4]); |
| 64 | + assert!(matches!(compare(&reference, &close, Tolerance::default()), Comparison::Matches)); |
| 65 | + |
| 66 | + let one_off = image_of(&[[10, 10, 10, 255], [10, 10, 10, 255], [10, 10, 10, 255], [200, 10, 10, 255]]); |
| 67 | + let lenient = Tolerance { channel: 2, pixels: 0.25 }; |
| 68 | + assert!(matches!(compare(&reference, &one_off, lenient), Comparison::Matches)); |
| 69 | + |
| 70 | + let Comparison::Differs { differing, total, diff } = compare(&reference, &one_off, Tolerance::default()) else { |
| 71 | + panic!("one pixel in four exceeds the default tolerance"); |
| 72 | + }; |
| 73 | + assert_eq!((differing, total), (1, 4)); |
| 74 | + assert_eq!(diff.get_pixel(3, 0).0, [255, 0, 0, 255]); |
| 75 | + } |
| 76 | + |
| 77 | + #[test] |
| 78 | + fn a_size_change_never_matches() { |
| 79 | + let comparison = compare(&image_of(&[[0; 4]; 2]), &image_of(&[[0; 4]; 3]), Tolerance::default()); |
| 80 | + assert!(matches!(comparison, Comparison::SizeChanged { reference: (2, 1), actual: (3, 1) })); |
| 81 | + } |
| 82 | +} |
0 commit comments