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!(
49        a.len() % 4,
50        0,
51        "image buffer length must be a multiple of 4"
52    );
53
54    a.chunks_exact(4)
55        .zip(b.chunks_exact(4))
56        .filter(|(pa, pb)| {
57            pa.iter()
58                .zip(pb.iter())
59                .any(|(&ca, &cb)| (ca as i16 - cb as i16).unsigned_abs() > threshold as u16)
60        })
61        .count()
62}
63
64/// Fraction of pixels that differ (0.0 to 1.0).
65///
66/// Convenience wrapper around [`count_differing_pixels`].
67pub fn differing_pixel_fraction(a: &[u8], b: &[u8], threshold: u8) -> f64 {
68    let total_pixels = a.len() / 4;
69    if total_pixels == 0 {
70        return 0.0;
71    }
72    count_differing_pixels(a, b, threshold) as f64 / total_pixels as f64
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn identical_images_have_zero_rmse() {
81        let img = vec![128u8; 64 * 4];
82        assert!((compute_rmse(&img, &img) - 0.0).abs() < f64::EPSILON);
83    }
84
85    #[test]
86    fn different_images_have_positive_rmse() {
87        let a = vec![0u8; 64 * 4];
88        let b = vec![255u8; 64 * 4];
89        let rmse = compute_rmse(&a, &b);
90        assert!(rmse > 200.0, "rmse was {rmse}");
91    }
92
93    #[test]
94    fn count_differing_pixels_exact_threshold() {
95        let a = vec![100u8; 4 * 4];
96        let mut b = a.clone();
97        // Change one pixel by exactly threshold+1.
98        b[0] = 110;
99        assert_eq!(count_differing_pixels(&a, &b, 9), 1);
100        assert_eq!(count_differing_pixels(&a, &b, 10), 0);
101    }
102
103    #[test]
104    fn differing_fraction_returns_correct_ratio() {
105        let a = vec![0u8; 8 * 4]; // 8 pixels
106        let mut b = a.clone();
107        b[0] = 255; // pixel 0 differs
108        b[4] = 255; // pixel 1 differs
109        let frac = differing_pixel_fraction(&a, &b, 0);
110        assert!((frac - 0.25).abs() < 1e-9, "frac was {frac}");
111    }
112}