use serde::Serialize;
use thiserror::Error;
use crate::geometry::{Point, Shape};
use crate::locate::MASK_ALPHA_FLOOR;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DiffError {
#[error("the crop has no visible pixels to compare")]
EmptyCrop,
#[error("the crop is larger than the frame it is compared against")]
CropLargerThanFrame,
#[error("the crop sits outside the frame at ({x}, {y})")]
OutOfFrame { x: i32, y: i32 },
}
#[derive(Debug, Clone)]
pub struct Baseline {
w: usize,
h: usize,
rgba: Vec<u8>,
mask: Vec<bool>,
masked_px: u64,
}
impl Baseline {
pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Result<Self, DiffError> {
assert_eq!(rgba.len(), w * h * 4, "rgba buffer matches dimensions");
let mask: Vec<bool> = rgba
.chunks_exact(4)
.map(|p| p[3] >= MASK_ALPHA_FLOOR)
.collect();
let masked_px = mask.iter().filter(|m| **m).count() as u64;
if masked_px == 0 {
return Err(DiffError::EmptyCrop);
}
Ok(Self {
w,
h,
rgba: rgba.to_vec(),
mask,
masked_px,
})
}
#[must_use]
pub const fn masked_px(&self) -> u64 {
self.masked_px
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct RegionDiff {
pub masked_px: u64,
pub changed_px: u64,
pub changed_pct: f64,
pub mean_delta: f64,
}
pub fn compare(
baseline: &Baseline,
frame_w: usize,
frame_h: usize,
frame: &[u8],
at: Point,
) -> Result<RegionDiff, DiffError> {
assert_eq!(
frame.len(),
frame_w * frame_h * 4,
"rgba buffer matches dimensions"
);
if baseline.w > frame_w || baseline.h > frame_h {
return Err(DiffError::CropLargerThanFrame);
}
if at.x < 0
|| at.y < 0
|| at.x as usize + baseline.w > frame_w
|| at.y as usize + baseline.h > frame_h
{
return Err(DiffError::OutOfFrame { x: at.x, y: at.y });
}
let (ox, oy) = (at.x as usize, at.y as usize);
let mut changed_px = 0u64;
let mut delta_total = 0u64;
for row in 0..baseline.h {
for col in 0..baseline.w {
let index = row * baseline.w + col;
if !baseline.mask[index] {
continue;
}
let saved = &baseline.rgba[index * 4..index * 4 + 3];
let live_at = ((oy + row) * frame_w + ox + col) * 4;
let live = &frame[live_at..live_at + 3];
let delta: u32 = (0..3)
.map(|channel| u32::from(saved[channel].abs_diff(live[channel])))
.sum();
if delta > 0 {
changed_px += 1;
delta_total += u64::from(delta);
}
}
}
let mean_delta = if changed_px == 0 {
0.0
} else {
delta_total as f64 / (changed_px as f64 * 3.0)
};
Ok(RegionDiff {
masked_px: baseline.masked_px,
changed_px,
changed_pct: changed_px as f64 * 100.0 / baseline.masked_px as f64,
mean_delta,
})
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RegionReport {
pub index: usize,
pub label: String,
pub monitor: usize,
pub region: Shape,
#[serde(flatten)]
pub diff: RegionDiff,
}
#[must_use]
pub fn within(results: &[RegionReport], tolerance: f64) -> bool {
!results.is_empty() && results.iter().all(|r| r.diff.changed_pct <= tolerance)
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec<u8> {
(0..w * h)
.flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
.collect()
}
#[test]
fn an_identical_region_is_clean_and_reports_no_mean() {
let px = solid(4, 4, [10, 20, 30]);
let base = Baseline::from_rgba(4, 4, &px).unwrap();
let d = compare(&base, 4, 4, &px, Point::new(0, 0)).unwrap();
assert_eq!((d.masked_px, d.changed_px), (16, 0));
assert!(d.changed_pct.abs() < f64::EPSILON);
assert!(
d.mean_delta.abs() < f64::EPSILON,
"0/0 would be NaN, which serde_json refuses to serialize"
);
serde_json::to_value(d).expect("a clean diff must serialize");
}
#[test]
fn one_flipped_pixel_inside_the_mask_is_caught() {
let base_px = solid(4, 4, [0, 0, 0]);
let base = Baseline::from_rgba(4, 4, &base_px).unwrap();
let mut frame = base_px.clone();
frame[4 * 5] = 9; let d = compare(&base, 4, 4, &frame, Point::new(0, 0)).unwrap();
assert_eq!(d.changed_px, 1);
assert!((d.changed_pct - 6.25).abs() < 1e-9, "1 of 16 masked");
assert!((d.mean_delta - 3.0).abs() < 1e-9, "9 over three channels");
}
#[test]
fn a_change_outside_the_mask_is_ignored() {
let mut px = solid(2, 2, [5, 5, 5]);
px[3] = 0;
px[4 * 2 + 3] = 0;
let base = Baseline::from_rgba(2, 2, &px).unwrap();
assert_eq!(base.masked_px(), 2);
let mut frame = solid(2, 2, [5, 5, 5]);
frame[0] = 200; let d = compare(&base, 2, 2, &frame, Point::new(0, 0)).unwrap();
assert_eq!(
d.changed_px, 0,
"shaped regions compare by their own pixels"
);
}
#[test]
fn alpha_is_the_mask_and_never_content() {
let mut px = solid(2, 2, [7, 7, 7]);
for i in 0..4 {
px[i * 4 + 3] = 200;
}
let base = Baseline::from_rgba(2, 2, &px).unwrap();
let frame = solid(2, 2, [7, 7, 7]);
let d = compare(&base, 2, 2, &frame, Point::new(0, 0)).unwrap();
assert_eq!(d.changed_px, 0);
}
#[test]
fn the_region_is_compared_where_it_sits_in_the_frame() {
let base = Baseline::from_rgba(2, 2, &solid(2, 2, [1, 2, 3])).unwrap();
let mut frame = solid(8, 8, [0, 0, 0]);
for y in 0..2 {
for x in 0..2 {
let j = ((3 + y) * 8 + 5 + x) * 4;
frame[j..j + 3].copy_from_slice(&[1, 2, 3]);
}
}
assert_eq!(
compare(&base, 8, 8, &frame, Point::new(5, 3))
.unwrap()
.changed_px,
0
);
assert!(
compare(&base, 8, 8, &frame, Point::new(0, 0))
.unwrap()
.changed_px
> 0,
"the same crop elsewhere in the frame does not match"
);
}
#[test]
fn a_fully_transparent_crop_is_refused_rather_than_dividing_by_zero() {
let px = vec![0u8; 4 * 4 * 4];
assert_eq!(
Baseline::from_rgba(4, 4, &px).unwrap_err(),
DiffError::EmptyCrop
);
}
#[test]
fn a_crop_that_does_not_fit_is_refused_by_which_way_it_fails() {
let base = Baseline::from_rgba(4, 4, &solid(4, 4, [1, 1, 1])).unwrap();
let small = solid(2, 2, [1, 1, 1]);
assert_eq!(
compare(&base, 2, 2, &small, Point::new(0, 0)).unwrap_err(),
DiffError::CropLargerThanFrame
);
let frame = solid(8, 8, [1, 1, 1]);
assert_eq!(
compare(&base, 8, 8, &frame, Point::new(6, 0)).unwrap_err(),
DiffError::OutOfFrame { x: 6, y: 0 },
"the crop would run off the right edge"
);
assert_eq!(
compare(&base, 8, 8, &frame, Point::new(-1, 0)).unwrap_err(),
DiffError::OutOfFrame { x: -1, y: 0 }
);
}
fn row(diff: RegionDiff) -> RegionReport {
RegionReport {
index: 0,
label: "submit".into(),
monitor: 0,
region: Shape::Rect(crate::geometry::Rect::new(0, 0, 10, 10)),
diff,
}
}
#[test]
fn tolerance_is_a_bar_applied_to_results_not_baked_into_them() {
let base_px = solid(10, 10, [0, 0, 0]);
let base = Baseline::from_rgba(10, 10, &base_px).unwrap();
let mut frame = base_px.clone();
for i in 0..5 {
frame[i * 4] = 255; }
let measured = compare(&base, 10, 10, &frame, Point::new(0, 0)).unwrap();
assert!((measured.changed_pct - 5.0).abs() < 1e-9);
let rows = [row(measured)];
assert!(!within(&rows, 0.0), "the default is exact");
assert!(!within(&rows, 4.9));
assert!(within(&rows, 5.0), "the bar is inclusive");
assert!(!within(&[], 100.0), "nothing compared is not a pass");
}
#[test]
fn a_row_flattens_its_measurement_into_one_json_object() {
let base = Baseline::from_rgba(2, 2, &solid(2, 2, [0, 0, 0])).unwrap();
let measured = compare(&base, 2, 2, &solid(2, 2, [0, 0, 0]), Point::new(0, 0)).unwrap();
let json = serde_json::to_value(row(measured)).unwrap();
for key in [
"index",
"label",
"monitor",
"region",
"masked_px",
"changed_px",
"changed_pct",
"mean_delta",
] {
assert!(json.get(key).is_some(), "missing {key}");
}
}
}