Skip to main content

pixelcoords_core/
diff.rs

1//! Per-region pixel comparison — the logic behind `pixelcoords diff`.
2//!
3//! `assert` answers "is this point inside a region"; `find` answers
4//! "where did my region go". Neither answers "do my regions still look
5//! the same", which is visual regression testing over regions a human
6//! marked rather than over whole screenshots.
7//!
8//! The mask is not computed here. A saved crop already carries its shape
9//! in its alpha channel — `save::write_crop` runs
10//! `draw::apply_alpha_mask_outside` for every kind except an unrotated
11//! rect, which is fully opaque and therefore fully in-mask — so rotated
12//! and concave regions compare by their own silhouette for free, using
13//! the same rule [`crate::locate::Template`] matches by.
14
15use 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/// A saved crop prepared for comparison: its colour, and the shape mask
32/// its alpha channel already carries.
33///
34/// Preparing it is where a crop with nothing visible is refused, so
35/// `changed_pct` can never divide by zero.
36#[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    /// From a crop's RGBA8 bytes. Alpha is the mask, not content.
47    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    /// Pixels the shape covers — the denominator `changed_pct` uses.
67    #[must_use]
68    pub const fn masked_px(&self) -> u64 {
69        self.masked_px
70    }
71}
72
73/// How much of one region changed.
74#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
75pub struct RegionDiff {
76    /// Pixels the shape covers. Reported so the denominator below is
77    /// inspectable rather than implied.
78    pub masked_px: u64,
79    pub changed_px: u64,
80    /// `changed_px / masked_px * 100`.
81    ///
82    /// Masked pixels, not the crop's area: a circle crop is roughly a
83    /// fifth transparent, and dividing by area would make one
84    /// `--tolerance` mean a different thing for every shape kind.
85    pub changed_pct: f64,
86    /// Mean absolute channel difference over the pixels that changed;
87    /// exactly `0.0` when none did.
88    pub mean_delta: f64,
89}
90
91/// Compare a baseline against the same-sized window of `frame` at `at`,
92/// over the baseline's mask.
93///
94/// RGB only. Alpha is the mask, so comparing it would test the crop's
95/// transparency against the capture's opaque 255 and mark every masked-in
96/// pixel changed. Note the masked-*out* pixels still hold real colour —
97/// cropping copies RGB and PNG is not premultiplied — so exclusion goes
98/// through the mask, never through "the colour looks empty".
99pub 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    // Divides by the changed count, so an unchanged region would be 0/0.
145    // NaN is not serializable, and every clean run would fail to print.
146    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/// One region's comparison, with the identity every report row carries.
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct RegionReport {
162    /// Index into `session.selections` — this row's identity.
163    pub index: usize,
164    pub label: String,
165    pub monitor: usize,
166    /// Where the region sits, in the session's own physical pixels.
167    /// Provenance, not a coordinate to act on — `resolve` answers that.
168    pub region: Shape,
169    #[serde(flatten)]
170    pub diff: RegionDiff,
171}
172
173/// Every region within tolerance — the aggregate `diff` reports as `ok`.
174///
175/// `tolerance` is a percentage of masked pixels allowed to differ, and it
176/// lives here rather than inside `compare` so a stored report can be
177/// re-judged at a different bar without re-measuring.
178#[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    /// A `w`×`h` RGBA buffer of one colour, fully opaque.
188    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        // Serializing is the actual regression guard.
206        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; // pixel (1,1), red channel
215        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        // Left column transparent: outside the shape, and still carrying
224        // real colour, exactly as a shaped crop does.
225        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; // masked-out pixel changes wildly
233        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        // The crop is semi-transparent where it is in-mask; the capture
243        // is opaque. Comparing alpha would call every pixel changed.
244        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        // Paint the matching patch at (5, 3).
259        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; // 5 of 100 pixels
327        }
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        // One measurement, judged at three bars — which is why the bar
332        // lives out here and not inside `compare`.
333        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        // Flattened, not nested: a consumer reads changed_pct off the row
346        // rather than off a sub-object it has to know the name of.
347        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}