1use serde::Serialize;
16use thiserror::Error;
17
18use crate::geometry::{Point, Shape};
19use crate::locate::MASK_ALPHA_FLOOR;
20
21#[derive(Debug, Error, PartialEq, Eq)]
22pub enum DiffError {
23 #[error("the crop has no visible pixels to compare")]
24 EmptyCrop,
25 #[error("the crop is larger than the frame it is compared against")]
26 CropLargerThanFrame,
27 #[error("the crop sits outside the frame at ({x}, {y})")]
28 OutOfFrame { x: i32, y: i32 },
29}
30
31#[derive(Debug, Clone)]
37pub struct Baseline {
38 w: usize,
39 h: usize,
40 rgba: Vec<u8>,
41 mask: Vec<bool>,
42 masked_px: u64,
43}
44
45impl Baseline {
46 pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Result<Self, DiffError> {
48 assert_eq!(rgba.len(), w * h * 4, "rgba buffer matches dimensions");
49 let mask: Vec<bool> = rgba
50 .chunks_exact(4)
51 .map(|p| p[3] >= MASK_ALPHA_FLOOR)
52 .collect();
53 let masked_px = mask.iter().filter(|m| **m).count() as u64;
54 if masked_px == 0 {
55 return Err(DiffError::EmptyCrop);
56 }
57 Ok(Self {
58 w,
59 h,
60 rgba: rgba.to_vec(),
61 mask,
62 masked_px,
63 })
64 }
65
66 #[must_use]
68 pub const fn masked_px(&self) -> u64 {
69 self.masked_px
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
75pub struct RegionDiff {
76 pub masked_px: u64,
79 pub changed_px: u64,
80 pub changed_pct: f64,
86 pub mean_delta: f64,
89}
90
91pub fn compare(
100 baseline: &Baseline,
101 frame_w: usize,
102 frame_h: usize,
103 frame: &[u8],
104 at: Point,
105) -> Result<RegionDiff, DiffError> {
106 assert_eq!(
107 frame.len(),
108 frame_w * frame_h * 4,
109 "rgba buffer matches dimensions"
110 );
111 if baseline.w > frame_w || baseline.h > frame_h {
112 return Err(DiffError::CropLargerThanFrame);
113 }
114 if at.x < 0
115 || at.y < 0
116 || at.x as usize + baseline.w > frame_w
117 || at.y as usize + baseline.h > frame_h
118 {
119 return Err(DiffError::OutOfFrame { x: at.x, y: at.y });
120 }
121
122 let (ox, oy) = (at.x as usize, at.y as usize);
123 let mut changed_px = 0u64;
124 let mut delta_total = 0u64;
125 for row in 0..baseline.h {
126 for col in 0..baseline.w {
127 let index = row * baseline.w + col;
128 if !baseline.mask[index] {
129 continue;
130 }
131 let saved = &baseline.rgba[index * 4..index * 4 + 3];
132 let live_at = ((oy + row) * frame_w + ox + col) * 4;
133 let live = &frame[live_at..live_at + 3];
134 let delta: u32 = (0..3)
135 .map(|channel| u32::from(saved[channel].abs_diff(live[channel])))
136 .sum();
137 if delta > 0 {
138 changed_px += 1;
139 delta_total += u64::from(delta);
140 }
141 }
142 }
143
144 let mean_delta = if changed_px == 0 {
147 0.0
148 } else {
149 delta_total as f64 / (changed_px as f64 * 3.0)
150 };
151 Ok(RegionDiff {
152 masked_px: baseline.masked_px,
153 changed_px,
154 changed_pct: changed_px as f64 * 100.0 / baseline.masked_px as f64,
155 mean_delta,
156 })
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct RegionReport {
162 pub index: usize,
164 pub label: String,
165 pub monitor: usize,
166 pub region: Shape,
169 #[serde(flatten)]
170 pub diff: RegionDiff,
171}
172
173#[must_use]
179pub fn within(results: &[RegionReport], tolerance: f64) -> bool {
180 !results.is_empty() && results.iter().all(|r| r.diff.changed_pct <= tolerance)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec<u8> {
189 (0..w * h)
190 .flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
191 .collect()
192 }
193
194 #[test]
195 fn an_identical_region_is_clean_and_reports_no_mean() {
196 let px = solid(4, 4, [10, 20, 30]);
197 let base = Baseline::from_rgba(4, 4, &px).unwrap();
198 let d = compare(&base, 4, 4, &px, Point::new(0, 0)).unwrap();
199 assert_eq!((d.masked_px, d.changed_px), (16, 0));
200 assert!(d.changed_pct.abs() < f64::EPSILON);
201 assert!(
202 d.mean_delta.abs() < f64::EPSILON,
203 "0/0 would be NaN, which serde_json refuses to serialize"
204 );
205 serde_json::to_value(d).expect("a clean diff must serialize");
207 }
208
209 #[test]
210 fn one_flipped_pixel_inside_the_mask_is_caught() {
211 let base_px = solid(4, 4, [0, 0, 0]);
212 let base = Baseline::from_rgba(4, 4, &base_px).unwrap();
213 let mut frame = base_px.clone();
214 frame[4 * 5] = 9; let d = compare(&base, 4, 4, &frame, Point::new(0, 0)).unwrap();
216 assert_eq!(d.changed_px, 1);
217 assert!((d.changed_pct - 6.25).abs() < 1e-9, "1 of 16 masked");
218 assert!((d.mean_delta - 3.0).abs() < 1e-9, "9 over three channels");
219 }
220
221 #[test]
222 fn a_change_outside_the_mask_is_ignored() {
223 let mut px = solid(2, 2, [5, 5, 5]);
226 px[3] = 0;
227 px[4 * 2 + 3] = 0;
228 let base = Baseline::from_rgba(2, 2, &px).unwrap();
229 assert_eq!(base.masked_px(), 2);
230
231 let mut frame = solid(2, 2, [5, 5, 5]);
232 frame[0] = 200; let d = compare(&base, 2, 2, &frame, Point::new(0, 0)).unwrap();
234 assert_eq!(
235 d.changed_px, 0,
236 "shaped regions compare by their own pixels"
237 );
238 }
239
240 #[test]
241 fn alpha_is_the_mask_and_never_content() {
242 let mut px = solid(2, 2, [7, 7, 7]);
245 for i in 0..4 {
246 px[i * 4 + 3] = 200;
247 }
248 let base = Baseline::from_rgba(2, 2, &px).unwrap();
249 let frame = solid(2, 2, [7, 7, 7]);
250 let d = compare(&base, 2, 2, &frame, Point::new(0, 0)).unwrap();
251 assert_eq!(d.changed_px, 0);
252 }
253
254 #[test]
255 fn the_region_is_compared_where_it_sits_in_the_frame() {
256 let base = Baseline::from_rgba(2, 2, &solid(2, 2, [1, 2, 3])).unwrap();
257 let mut frame = solid(8, 8, [0, 0, 0]);
258 for y in 0..2 {
260 for x in 0..2 {
261 let j = ((3 + y) * 8 + 5 + x) * 4;
262 frame[j..j + 3].copy_from_slice(&[1, 2, 3]);
263 }
264 }
265 assert_eq!(
266 compare(&base, 8, 8, &frame, Point::new(5, 3))
267 .unwrap()
268 .changed_px,
269 0
270 );
271 assert!(
272 compare(&base, 8, 8, &frame, Point::new(0, 0))
273 .unwrap()
274 .changed_px
275 > 0,
276 "the same crop elsewhere in the frame does not match"
277 );
278 }
279
280 #[test]
281 fn a_fully_transparent_crop_is_refused_rather_than_dividing_by_zero() {
282 let px = vec![0u8; 4 * 4 * 4];
283 assert_eq!(
284 Baseline::from_rgba(4, 4, &px).unwrap_err(),
285 DiffError::EmptyCrop
286 );
287 }
288
289 #[test]
290 fn a_crop_that_does_not_fit_is_refused_by_which_way_it_fails() {
291 let base = Baseline::from_rgba(4, 4, &solid(4, 4, [1, 1, 1])).unwrap();
292 let small = solid(2, 2, [1, 1, 1]);
293 assert_eq!(
294 compare(&base, 2, 2, &small, Point::new(0, 0)).unwrap_err(),
295 DiffError::CropLargerThanFrame
296 );
297
298 let frame = solid(8, 8, [1, 1, 1]);
299 assert_eq!(
300 compare(&base, 8, 8, &frame, Point::new(6, 0)).unwrap_err(),
301 DiffError::OutOfFrame { x: 6, y: 0 },
302 "the crop would run off the right edge"
303 );
304 assert_eq!(
305 compare(&base, 8, 8, &frame, Point::new(-1, 0)).unwrap_err(),
306 DiffError::OutOfFrame { x: -1, y: 0 }
307 );
308 }
309
310 fn row(diff: RegionDiff) -> RegionReport {
311 RegionReport {
312 index: 0,
313 label: "submit".into(),
314 monitor: 0,
315 region: Shape::Rect(crate::geometry::Rect::new(0, 0, 10, 10)),
316 diff,
317 }
318 }
319
320 #[test]
321 fn tolerance_is_a_bar_applied_to_results_not_baked_into_them() {
322 let base_px = solid(10, 10, [0, 0, 0]);
323 let base = Baseline::from_rgba(10, 10, &base_px).unwrap();
324 let mut frame = base_px.clone();
325 for i in 0..5 {
326 frame[i * 4] = 255; }
328 let measured = compare(&base, 10, 10, &frame, Point::new(0, 0)).unwrap();
329 assert!((measured.changed_pct - 5.0).abs() < 1e-9);
330
331 let rows = [row(measured)];
334 assert!(!within(&rows, 0.0), "the default is exact");
335 assert!(!within(&rows, 4.9));
336 assert!(within(&rows, 5.0), "the bar is inclusive");
337 assert!(!within(&[], 100.0), "nothing compared is not a pass");
338 }
339
340 #[test]
341 fn a_row_flattens_its_measurement_into_one_json_object() {
342 let base = Baseline::from_rgba(2, 2, &solid(2, 2, [0, 0, 0])).unwrap();
343 let measured = compare(&base, 2, 2, &solid(2, 2, [0, 0, 0]), Point::new(0, 0)).unwrap();
344 let json = serde_json::to_value(row(measured)).unwrap();
345 for key in [
348 "index",
349 "label",
350 "monitor",
351 "region",
352 "masked_px",
353 "changed_px",
354 "changed_pct",
355 "mean_delta",
356 ] {
357 assert!(json.get(key).is_some(), "missing {key}");
358 }
359 }
360}