Another project
1use bone_types::Tolerance;
2
3use crate::camera::ViewportExtent;
4use crate::snapshot::SnapshotFrame;
5
6#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
7pub struct PixelDiffThreshold(f64);
8
9impl PixelDiffThreshold {
10 pub const EXACT: Self = Self(0.0);
11
12 #[must_use]
13 pub fn new(channel_fraction: f64) -> Self {
14 Self(channel_fraction.clamp(0.0, 1.0))
15 }
16
17 #[must_use]
18 pub const fn value(self) -> f64 {
19 self.0
20 }
21
22 #[must_use]
23 #[allow(
24 clippy::cast_possible_truncation,
25 clippy::cast_sign_loss,
26 reason = "value is clamped to [0, 1] before scaling to u8"
27 )]
28 pub fn as_u8(self) -> u8 {
29 (self.0.clamp(0.0, 1.0) * 255.0).round() as u8
30 }
31}
32
33impl From<Tolerance> for PixelDiffThreshold {
34 fn from(t: Tolerance) -> Self {
35 Self::new(t.value())
36 }
37}
38
39#[derive(Copy, Clone, Debug, PartialEq, Eq)]
40pub struct PixelMismatch {
41 x: u32,
42 y: u32,
43 channel_delta: [u8; 4],
44}
45
46impl PixelMismatch {
47 #[must_use]
48 pub const fn x(self) -> u32 {
49 self.x
50 }
51
52 #[must_use]
53 pub const fn y(self) -> u32 {
54 self.y
55 }
56
57 #[must_use]
58 pub const fn channel_delta(self) -> [u8; 4] {
59 self.channel_delta
60 }
61
62 #[must_use]
63 pub fn max_delta(self) -> u8 {
64 self.channel_delta.into_iter().max().unwrap_or(0)
65 }
66}
67
68#[derive(Clone, Debug, Default)]
69pub struct PixelDiffReport {
70 worst: Option<PixelMismatch>,
71 over_threshold: u32,
72}
73
74impl PixelDiffReport {
75 #[must_use]
76 pub const fn worst(&self) -> Option<PixelMismatch> {
77 self.worst
78 }
79
80 #[must_use]
81 pub const fn over_threshold(&self) -> u32 {
82 self.over_threshold
83 }
84
85 #[must_use]
86 pub const fn is_clean(&self) -> bool {
87 self.over_threshold == 0
88 }
89}
90
91#[derive(Debug, thiserror::Error)]
92pub enum PixelDiffError {
93 #[error(
94 "pixel buffer length mismatch: actual={actual}, expected={expected}, required={required}"
95 )]
96 ShapeMismatch {
97 actual: usize,
98 expected: usize,
99 required: usize,
100 },
101 #[error("viewport dimension is zero")]
102 ZeroExtent,
103 #[error("pixel count {pixels} exceeds u32::MAX")]
104 ExtentOverflow { pixels: u64 },
105}
106
107pub struct PixelDiff;
108
109impl PixelDiff {
110 pub fn compare_bytes(
111 extent: ViewportExtent,
112 actual: &[u8],
113 expected: &[u8],
114 threshold: PixelDiffThreshold,
115 ) -> core::result::Result<PixelDiffReport, PixelDiffError> {
116 let width = extent.width().value();
117 let height = extent.height().value();
118 if width == 0 || height == 0 {
119 return Err(PixelDiffError::ZeroExtent);
120 }
121 let pixel_count = extent.pixel_count();
122 if pixel_count > u64::from(u32::MAX) {
123 return Err(PixelDiffError::ExtentOverflow {
124 pixels: pixel_count,
125 });
126 }
127 let Ok(required) = usize::try_from(pixel_count * 4) else {
128 return Err(PixelDiffError::ExtentOverflow {
129 pixels: pixel_count,
130 });
131 };
132 if actual.len() != required || expected.len() != required {
133 return Err(PixelDiffError::ShapeMismatch {
134 actual: actual.len(),
135 expected: expected.len(),
136 required,
137 });
138 }
139 let limit = threshold.as_u8();
140 let report = actual
141 .chunks_exact(4)
142 .zip(expected.chunks_exact(4))
143 .zip(0_u32..)
144 .filter_map(|((a, e), idx)| {
145 let delta: [u8; 4] = [
146 a[0].abs_diff(e[0]),
147 a[1].abs_diff(e[1]),
148 a[2].abs_diff(e[2]),
149 a[3].abs_diff(e[3]),
150 ];
151 let max = delta[0].max(delta[1]).max(delta[2]).max(delta[3]);
152 (max > limit).then_some((idx, delta))
153 })
154 .fold(PixelDiffReport::default(), |acc, (idx, delta)| {
155 let m = PixelMismatch {
156 x: idx % width,
157 y: idx / width,
158 channel_delta: delta,
159 };
160 let worst = match acc.worst {
161 Some(prev) if prev.max_delta() >= m.max_delta() => Some(prev),
162 _ => Some(m),
163 };
164 PixelDiffReport {
165 worst,
166 over_threshold: acc.over_threshold + 1,
167 }
168 });
169 Ok(report)
170 }
171
172 pub fn compare(
173 actual: &SnapshotFrame,
174 expected: &[u8],
175 threshold: PixelDiffThreshold,
176 ) -> core::result::Result<PixelDiffReport, PixelDiffError> {
177 Self::compare_bytes(actual.extent(), actual.rgba(), expected, threshold)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::camera::{ViewportExtent, ViewportPx};
185
186 fn extent(w: u32, h: u32) -> ViewportExtent {
187 ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h))
188 }
189
190 fn run(
191 extent: ViewportExtent,
192 actual: &[u8],
193 expected: &[u8],
194 threshold: PixelDiffThreshold,
195 ) -> PixelDiffReport {
196 let Ok(report) = PixelDiff::compare_bytes(extent, actual, expected, threshold) else {
197 panic!("compare_bytes rejected inputs");
198 };
199 report
200 }
201
202 #[test]
203 fn exact_match_is_clean() {
204 let buf = vec![10_u8, 20, 30, 255, 10, 20, 30, 255];
205 let report = run(extent(2, 1), &buf, &buf, PixelDiffThreshold::EXACT);
206 assert!(report.is_clean());
207 assert!(report.worst().is_none());
208 }
209
210 #[test]
211 fn single_channel_delta_under_threshold_is_clean() {
212 let a = vec![10_u8, 20, 30, 255];
213 let b = vec![12_u8, 20, 30, 255];
214 let report = run(extent(1, 1), &a, &b, PixelDiffThreshold::new(4.0 / 255.0));
215 assert!(report.is_clean());
216 }
217
218 #[test]
219 fn over_threshold_reports_worst_pixel_coords() {
220 let a = vec![0_u8, 0, 0, 255, 200, 0, 0, 255];
221 let b = vec![0_u8, 0, 0, 255, 50, 0, 0, 255];
222 let report = run(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT);
223 let Some(worst) = report.worst() else {
224 panic!("worst pixel present");
225 };
226 assert_eq!(worst.x(), 1);
227 assert_eq!(worst.y(), 0);
228 assert_eq!(worst.max_delta(), 150);
229 assert_eq!(report.over_threshold(), 1);
230 }
231
232 #[test]
233 fn tolerance_maps_to_threshold() {
234 let t = Tolerance::new(8.0 / 255.0);
235 let th: PixelDiffThreshold = t.into();
236 assert_eq!(th.as_u8(), 8);
237 }
238
239 #[test]
240 fn coords_index_by_row_major() {
241 let a = vec![0_u8; 32];
242 let mut b = a.clone();
243 let pixel = 5_usize;
244 b[pixel * 4] = 200;
245 let report = run(extent(4, 2), &a, &b, PixelDiffThreshold::EXACT);
246 let Some(worst) = report.worst() else {
247 panic!("worst present");
248 };
249 assert_eq!(worst.x(), 1);
250 assert_eq!(worst.y(), 1);
251 }
252
253 #[test]
254 fn shape_mismatch_is_rejected() {
255 let a = vec![0_u8; 12];
256 let b = vec![0_u8; 8];
257 let Err(err) = PixelDiff::compare_bytes(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT)
258 else {
259 panic!("expected ShapeMismatch");
260 };
261 let PixelDiffError::ShapeMismatch {
262 actual,
263 expected,
264 required,
265 } = err
266 else {
267 panic!("wrong error variant: {err:?}");
268 };
269 assert_eq!(actual, 12);
270 assert_eq!(expected, 8);
271 assert_eq!(required, 8);
272 }
273
274 #[test]
275 fn zero_extent_is_rejected() {
276 let buf: Vec<u8> = Vec::new();
277 let Err(err) =
278 PixelDiff::compare_bytes(extent(0, 4), &buf, &buf, PixelDiffThreshold::EXACT)
279 else {
280 panic!("expected ZeroExtent");
281 };
282 assert!(matches!(err, PixelDiffError::ZeroExtent));
283 }
284}