//! Perceptual comparison of rendered images, used by the golden-image tests //! and the cross-backend parity check. use tiny_skia::Pixmap; /// Result of comparing two images. #[derive(Debug, Clone, PartialEq)] pub struct DiffStats { /// Fraction of pixels whose maximum channel difference exceeds the /// per-pixel tolerance, in `0.0..=1.0`. pub differing_fraction: f64, /// Largest per-channel absolute difference seen anywhere. pub max_channel_diff: u8, /// Mean absolute per-channel difference across the whole image. pub mean_channel_diff: f64, /// The worst differing fraction within any single tile, and where it was. /// /// A whole-image fraction cannot see a *small object in the wrong place*: /// a scrollbar thumb drawn thirty pixels low is a fraction of a percent of /// the frame and passes any sane global threshold, while being exactly the /// kind of structural error this comparison exists to catch. Differences /// that are really anti-aliasing spread themselves over every edge in the /// picture; a displaced object piles them into one place. pub worst_tile: TileStats, } /// The worst-disagreeing tile of an image comparison. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TileStats { /// Fraction of that tile's pixels that differ, in `0.0..=1.0`. pub differing_fraction: f64, /// Its top-left corner, in pixels. pub origin: (u32, u32), } /// Tolerances for declaring two renderings "the same picture". /// /// The defaults absorb anti-aliasing and rounding differences between /// independent rasterizers while still catching anything structural: a /// misplaced shape, a wrong color, missing content. #[derive(Debug, Clone, Copy, PartialEq)] pub struct DiffTolerance { /// Per-pixel channel difference at or below which a pixel counts as /// matching. pub per_pixel: u8, /// Maximum fraction of non-matching pixels allowed. pub max_differing_fraction: f64, /// Per-pixel channel difference above which a pixel counts as *grossly* /// different, for the local check only. /// /// Deliberately far coarser than `per_pixel`, because the local check asks /// a different question: not "do these disagree" but "is something here in /// one image and missing in the other". Two rasterizers disagree along /// every anti-aliased edge by small amounts; only a displaced or absent /// object swings a pixel from background to foreground. Measured across /// the parity corpus, this is what separates the two — a 4x margin instead /// of 1.4x. pub tile_per_pixel: u8, /// Maximum fraction of grossly-differing pixels allowed within any one /// [`TILE`]-sized tile. This is what notices a small displaced object, /// which the whole-image fraction is structurally unable to see. pub max_tile_fraction: f64, } /// Side of the square tiles [`compare`] measures locally, in pixels. Big /// enough that an anti-aliased edge crossing one is still a small part of it, /// small enough that a displaced widget fills one. pub const TILE: u32 = 16; impl DiffTolerance { /// For images produced by the same renderer: everything must match /// exactly. pub const EXACT: Self = Self { per_pixel: 0, max_differing_fraction: 0.0, tile_per_pixel: 0, max_tile_fraction: 0.0, }; /// For images produced by different renderers of the same scene. /// Anti-aliased edges legitimately differ, so a small fraction of edge /// pixels may disagree by any amount, and flat areas may differ by /// rounding. pub const CROSS_RENDERER: Self = Self { per_pixel: 12, max_differing_fraction: 0.02, // Measured over the parity corpus: the worst legitimate tile is 9.4% // and the scrollbar-displacement bug this exists for is 37.5%. The // threshold sits between them with better than two-fold headroom each // way. tile_per_pixel: 40, max_tile_fraction: 0.20, }; } /// Compare two images. Returns `None` if the dimensions differ (which is /// always a failure, not a matter of degree). pub fn compare(a: &Pixmap, b: &Pixmap, tolerance: DiffTolerance) -> Option<(DiffStats, bool)> { if a.width() != b.width() || a.height() != b.height() { return None; } let (w, h) = (a.width(), b.height()); let mut differing = 0u64; let mut max_diff = 0u8; let mut total_diff = 0u64; // Per-tile counts, so a *local* pile-up of differences is visible even // when the whole-image fraction stays tiny. let tiles_across = w.div_ceil(TILE); let mut tile_hits = vec![0u32; (tiles_across * h.div_ceil(TILE)) as usize]; let (da, db) = (a.data(), b.data()); for (index, (pa, pb)) in da.chunks_exact(4).zip(db.chunks_exact(4)).enumerate() { let mut pixel_max = 0u8; for (ca, cb) in pa.iter().zip(pb.iter()) { let d = ca.abs_diff(*cb); pixel_max = pixel_max.max(d); total_diff += u64::from(d); } max_diff = max_diff.max(pixel_max); if pixel_max > tolerance.per_pixel { differing += 1; } if pixel_max > tolerance.tile_per_pixel { let (x, y) = (index as u32 % w, index as u32 / w); tile_hits[(y / TILE * tiles_across + x / TILE) as usize] += 1; } } // Each tile against its own size: the edge tiles of an image are smaller, // and judging them against a full tile's area would quietly excuse them. let mut worst = TileStats { differing_fraction: 0.0, origin: (0, 0), }; for (index, hits) in tile_hits.iter().enumerate() { let (tx, ty) = (index as u32 % tiles_across, index as u32 / tiles_across); let (ox, oy) = (tx * TILE, ty * TILE); let area = u64::from((w - ox).min(TILE)) * u64::from((h - oy).min(TILE)); if area == 0 { continue; } let fraction = f64::from(*hits) / area as f64; if fraction > worst.differing_fraction { worst = TileStats { differing_fraction: fraction, origin: (ox, oy), }; } } let pixel_count = u64::from(w) * u64::from(h); let stats = DiffStats { differing_fraction: differing as f64 / pixel_count as f64, max_channel_diff: max_diff, mean_channel_diff: total_diff as f64 / (pixel_count * 4) as f64, worst_tile: worst, }; let passes = stats.differing_fraction <= tolerance.max_differing_fraction && stats.worst_tile.differing_fraction <= tolerance.max_tile_fraction; Some((stats, passes)) } #[cfg(test)] mod tests { use super::*; fn solid(width: u32, height: u32, rgba: [u8; 4]) -> Pixmap { let mut pixmap = Pixmap::new(width, height).unwrap(); for px in pixmap.data_mut().chunks_exact_mut(4) { px.copy_from_slice(&rgba); } pixmap } #[test] fn identical_images_match_exactly() { let a = solid(8, 8, [10, 20, 30, 255]); let (stats, passes) = compare(&a, &a.clone(), DiffTolerance::EXACT).unwrap(); assert!(passes); assert_eq!(stats.max_channel_diff, 0); } /// Draw a filled rect into a white image. fn with_rect(w: u32, h: u32, rect: (u32, u32, u32, u32), rgba: [u8; 4]) -> Pixmap { let mut pixmap = solid(w, h, [255, 255, 255, 255]); let (x0, y0, rw, rh) = rect; let data = pixmap.data_mut(); for y in y0..(y0 + rh).min(h) { for x in x0..(x0 + rw).min(w) { let i = ((y * w + x) * 4) as usize; data[i..i + 4].copy_from_slice(&rgba); } } pixmap } /// The case a whole-image fraction cannot see: a small object in the /// wrong place. This is a scrollbar thumb — six pixels wide — drawn /// thirty pixels lower than it belongs, which is what a real backend bug /// did while every global threshold stayed happy. #[test] fn a_small_displaced_object_fails_even_though_the_frame_barely_differs() { let (w, h) = (420, 320); let thumb = [96, 96, 96, 255]; let a = with_rect(w, h, (410, 40, 6, 120), thumb); let b = with_rect(w, h, (410, 70, 6, 120), thumb); let (stats, passes) = compare(&a, &b, DiffTolerance::CROSS_RENDERER).unwrap(); assert!( stats.differing_fraction < 0.01, "the premise: this is a trivial fraction of the frame ({:.4}%)", stats.differing_fraction * 100.0 ); assert!( !passes, "and it must fail anyway — worst tile {:.1}% at {:?}", stats.worst_tile.differing_fraction * 100.0, stats.worst_tile.origin ); } /// The other half of the claim: differences that really are anti-aliasing /// are spread along every edge, so no one tile piles up. #[test] fn edge_noise_spread_across_the_image_still_passes() { let (w, h) = (256, 256); let mut a = solid(w, h, [255, 255, 255, 255]); let mut b = solid(w, h, [255, 255, 255, 255]); // A diagonal line, one pixel off in one image — an anti-aliased edge // disagreeing everywhere it goes. for i in 0..h { let ia = ((i * w + i) * 4) as usize; a.data_mut()[ia..ia + 4].copy_from_slice(&[0, 0, 0, 255]); let ib = ((i * w + (i + 1).min(w - 1)) * 4) as usize; b.data_mut()[ib..ib + 4].copy_from_slice(&[0, 0, 0, 255]); } let (stats, passes) = compare(&a, &b, DiffTolerance::CROSS_RENDERER).unwrap(); assert!( passes, "spread-out edge disagreement is what the tolerance is for; \ worst tile {:.1}%", stats.worst_tile.differing_fraction * 100.0 ); } #[test] fn small_rounding_passes_cross_renderer() { let a = solid(8, 8, [10, 20, 30, 255]); let b = solid(8, 8, [12, 18, 33, 255]); let (_, passes) = compare(&a, &b, DiffTolerance::CROSS_RENDERER).unwrap(); assert!(passes); let (_, exact) = compare(&a, &b, DiffTolerance::EXACT).unwrap(); assert!(!exact); } #[test] fn structural_difference_fails() { let a = solid(8, 8, [10, 20, 30, 255]); let b = solid(8, 8, [200, 20, 30, 255]); let (stats, passes) = compare(&a, &b, DiffTolerance::CROSS_RENDERER).unwrap(); assert!(!passes); assert_eq!(stats.differing_fraction, 1.0); } #[test] fn size_mismatch_is_none() { let a = solid(8, 8, [0, 0, 0, 255]); let b = solid(9, 8, [0, 0, 0, 255]); assert!(compare(&a, &b, DiffTolerance::EXACT).is_none()); } }