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