Skip to main content

rustial_engine/
image_compare.rs

1// ---------------------------------------------------------------------------
2//! Image comparison utilities for cross-renderer parity tests.
3//!
4//! Provides lightweight pixel-level comparison metrics without pulling in
5//! external image-comparison crates.  Used by the headless comparison test
6//! harness to assert that WGPU and Bevy renderers produce structurally
7//! equivalent output for the same `FrameOutput`.
8// ---------------------------------------------------------------------------
9
10/// Per-pixel RMSE (Root Mean Square Error) between two RGBA8 images.
11///
12/// Both buffers must have the same length (`width * height * 4`).
13/// Returns a value in the range `[0.0, 255.0]`:
14///
15/// - `0.0` -- images are byte-identical.
16/// - `< 5.0` -- visually indistinguishable (acceptance threshold).
17/// - `> 50.0` -- significant structural difference.
18///
19/// # Panics
20///
21/// Panics if `a.len() != b.len()` or if the length is zero.
22pub fn compute_rmse(a: &[u8], b: &[u8]) -> f64 {
23    assert_eq!(a.len(), b.len(), "image buffers must have the same length");
24    assert!(!a.is_empty(), "image buffers must not be empty");
25
26    let sum_sq: f64 = a
27        .iter()
28        .zip(b.iter())
29        .map(|(&va, &vb)| {
30            let diff = va as f64 - vb as f64;
31            diff * diff
32        })
33        .sum();
34
35    (sum_sq / a.len() as f64).sqrt()
36}
37
38/// Count the number of pixels where any RGBA channel differs by more than
39/// `threshold` (0-255).
40///
41/// Both buffers must have the same length (`width * height * 4`).
42///
43/// # Panics
44///
45/// Panics if `a.len() != b.len()` or if the length is not a multiple of 4.
46pub fn count_differing_pixels(a: &[u8], b: &[u8], threshold: u8) -> usize {
47    assert_eq!(a.len(), b.len(), "image buffers must have the same length");
48    assert_eq!(a.len() % 4, 0, "image buffer length must be a multiple of 4");
49
50    a.chunks_exact(4)
51        .zip(b.chunks_exact(4))
52        .filter(|(pa, pb)| {
53            pa.iter()
54                .zip(pb.iter())
55                .any(|(&ca, &cb)| (ca as i16 - cb as i16).unsigned_abs() > threshold as u16)
56        })
57        .count()
58}
59
60/// Fraction of pixels that differ (0.0 to 1.0).
61///
62/// Convenience wrapper around [`count_differing_pixels`].
63pub fn differing_pixel_fraction(a: &[u8], b: &[u8], threshold: u8) -> f64 {
64    let total_pixels = a.len() / 4;
65    if total_pixels == 0 {
66        return 0.0;
67    }
68    count_differing_pixels(a, b, threshold) as f64 / total_pixels as f64
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn identical_images_have_zero_rmse() {
77        let img = vec![128u8; 64 * 4];
78        assert!((compute_rmse(&img, &img) - 0.0).abs() < f64::EPSILON);
79    }
80
81    #[test]
82    fn different_images_have_positive_rmse() {
83        let a = vec![0u8; 64 * 4];
84        let b = vec![255u8; 64 * 4];
85        let rmse = compute_rmse(&a, &b);
86        assert!(rmse > 200.0, "rmse was {rmse}");
87    }
88
89    #[test]
90    fn count_differing_pixels_exact_threshold() {
91        let a = vec![100u8; 4 * 4];
92        let mut b = a.clone();
93        // Change one pixel by exactly threshold+1.
94        b[0] = 110;
95        assert_eq!(count_differing_pixels(&a, &b, 9), 1);
96        assert_eq!(count_differing_pixels(&a, &b, 10), 0);
97    }
98
99    #[test]
100    fn differing_fraction_returns_correct_ratio() {
101        let a = vec![0u8; 8 * 4]; // 8 pixels
102        let mut b = a.clone();
103        b[0] = 255; // pixel 0 differs
104        b[4] = 255; // pixel 1 differs
105        let frac = differing_pixel_fraction(&a, &b, 0);
106        assert!((frac - 0.25).abs() < 1e-9, "frac was {frac}");
107    }
108}