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