epiphany/spikes/editor-toolkit/round2-diff/src/lib.rs

702 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The bounded visual differential (`ROUND2_TEXT_RECIPE.md` §10): pure image
//! math over two RGBA raster buffers, with no dependency on how either one
//! was produced. Ruling A permitted "geometry/scene equivalence plus a
//! bounded visual differential under a controlled backend, NOT pixel
//! equality"; this crate is where that phrase finally gets a number, fixed
//! **before** either candidate's raster exists (see the `selftest` binary,
//! which stands in for a candidate the way Round 1's oracle tranche mutated
//! its own geometry before any candidate did).
//!
//! Four rules decide, all hard (§10):
//!
//! - **D1** — outside a band around reference edges, zero pixels may differ
//! in ink class.
//! - **D2** — whole-image ink mass (Σ(255 luma)/255) agrees within 2%.
//! - **D3** — the whole-image ink centroid agrees within 0.5 device px per
//! axis, for *gross* misplacement only; its detection floor is declared on
//! [`D3_TOLERANCE_DEVICE_PX`].
//! - **D4** — **per-glyph** ink mass agrees within 2%, for every glyph.
//!
//! **D4 is the rule that catches a wrong, dropped, or re-shaped glyph, and it
//! exists because recipe revision 1 shipped without it and was wrong.** That
//! revision assigned the job to D1, and the mutation set proved D1 cannot do
//! it: D1 is structurally blind to any error confined to a stroke narrower
//! than `2 * EDGE_BAND_PX + 1 = 5` device px, because such a stroke lies
//! entirely inside the band around its own edges. Measured here — deleting a
//! 4 px stem gives `d1 = 0` differing pixels while D4 reports 100%; deleting a
//! 12 px stem, above the floor, gives `d1 = 4164`. The floor is measured from
//! both sides rather than asserted from one.
//!
//! Two more numbers are *reported inside the band but never decide anything*
//! — the max |Δluma| and the count of pixels differing by more than 16 —
//! because inside the band a difference is expected to be antialiasing, and
//! that is precisely what this differential is bounded *against* measuring.
/// Rec. 601 integer luma weights, identical to
/// `round1-candidates/harness`'s `classify_pixel` — the same convention
/// Round 1 used, so "ink" means the same thing in both rounds.
fn luma(rgba: [u8; 4]) -> u8 {
let [r, g, b, _a] = rgba;
((299 * r as u32 + 587 * g as u32 + 114 * b as u32) / 1000) as u8
}
/// A pixel is ink if its luma is strictly below this. Recipe §10: "Ink =
/// luma < 128." Not a tunable — the recipe fixes it, so it is a `const`,
/// not a parameter a caller could quietly relax.
pub const INK_LUMA_THRESHOLD: u8 = 128;
/// The band radius, in Chebyshev distance, around a reference edge pixel.
/// Recipe §10 / §11: `EDGE_BAND_PX = 2`, "the same device Round 1 used for
/// its 8 px clearance floor" — confine D1 to where the answer is geometric,
/// not a coin flip about antialiasing.
pub const EDGE_BAND_PX: i32 = 2;
/// D2's relative tolerance on ink mass: 2% (recipe §10).
pub const D2_RELATIVE_TOLERANCE: f64 = 0.02;
/// D3's per-axis tolerance on the ink centroid, in device px (recipe §10).
///
/// **D3's declared detection floor.** A whole-image centroid is one number
/// over two million pixels. Measured on this crate's own synthetic reference,
/// a legitimate antialiasing-only variant moved it 0.346 px while a true
/// 0.5 px translation moved it 0.486 px — not separable. So D3 does **not**
/// detect uniform drift below roughly 1 device px, and the recipe does not
/// claim it does; it is retained for gross misplacement, where it is decisive
/// (deleting one stem moved it 40.7 px). Sub-pixel registration is out of
/// scope for this round, declared in advance rather than inferred later from
/// a candidate's numbers.
pub const D3_TOLERANCE_DEVICE_PX: f64 = 0.5;
/// D4's relative tolerance on **per-glyph** ink mass: 2% (recipe §10).
pub const D4_RELATIVE_TOLERANCE: f64 = 0.02;
/// Each glyph region is dilated by this many device px before its mass is
/// measured, so a glyph whose ink legitimately spills a fraction of a pixel
/// past its reported bounding box (antialiasing does exactly this) is not
/// scored on a clipped footprint.
pub const D4_REGION_DILATION_PX: u32 = 3;
/// One glyph's device-space footprint, for D4. Half-open in the same sense as
/// the raster: `x0..x1`, `y0..y1`.
#[derive(Clone, Debug)]
pub struct GlyphRegion {
/// Human-readable identity for the report — a `FAIL` names the glyph, not
/// an index into a list the reader does not have.
pub label: String,
pub x0: u32,
pub y0: u32,
pub x1: u32,
pub y1: u32,
}
/// One region's D4 outcome, kept per region so a `FAIL` says *which glyph*.
#[derive(Clone, Debug)]
pub struct RegionMass {
pub label: String,
pub reference_mass: f64,
pub candidate_mass: f64,
pub relative_delta: f64,
pub pass: bool,
}
/// The in-band "reported, not deciding" delta-luma threshold (recipe §10):
/// pixels inside the band whose |Δluma| exceeds this are counted, but the
/// count never feeds a pass/fail verdict.
pub const IN_BAND_REPORT_DELTA_LUMA: u8 = 16;
fn is_ink(rgba: [u8; 4]) -> bool {
(luma(rgba) as u32) < INK_LUMA_THRESHOLD as u32
}
fn pixel_at(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
let idx = ((y as usize) * (width as usize) + (x as usize)) * 4;
[rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]
}
/// Checks `rgba` is exactly `width * height * 4` bytes and every pixel is
/// fully opaque, **before** anything reads a single sample out of it.
///
/// Both checks exist for the same reason Round 1's `evaluate_glyph` checks
/// them: a short buffer indexed with a substituted default reads back as
/// `(0,0,0,0)`, whose luma is 0 — that classifies as *ink*, so a truncated
/// or otherwise malformed readback would silently turn a broken run green.
/// This function is the single gate every other function in this crate
/// calls before it touches a pixel, so "malformed buffer" is always an
/// `Err`, never a value.
pub fn validate_rgba(rgba: &[u8], width: u32, height: u32) -> Result<(), String> {
let expected_len = (width as usize) * (height as usize) * 4;
if rgba.len() != expected_len {
return Err(format!(
"buffer is {} bytes, expected exactly {expected_len} ({width}x{height} RGBA) — a \
short or padded buffer cannot be sampled safely",
rgba.len()
));
}
for y in 0..height {
for x in 0..width {
let a = pixel_at(rgba, width, x, y)[3];
if a != 255 {
return Err(format!(
"pixel at ({x}, {y}) has alpha {a}, not 255 — the target must be fully \
opaque; a transparent pixel means the clear or the blend is wrong, not \
that the differential should guess a colour for it"
));
}
}
}
Ok(())
}
/// Total ink mass: Σ over every pixel of `(255 - luma) / 255`. Recipe §10,
/// D2's quantity. A fully black image has mass `width * height`; a fully
/// white image has mass `0`.
pub fn ink_mass(rgba: &[u8], width: u32, height: u32) -> Result<f64, String> {
validate_rgba(rgba, width, height)?;
let mut mass = 0.0f64;
for y in 0..height {
for x in 0..width {
let l = luma(pixel_at(rgba, width, x, y));
mass += (255 - l) as f64 / 255.0;
}
}
Ok(mass)
}
/// The mass-weighted ink centroid, using the same per-pixel weight as
/// [`ink_mass`]. Returns `Ok(None)` rather than dividing by zero when the
/// image carries no ink at all (mass `0`) — a blank image has no centroid
/// to report, and a `NaN` snuck into a report would be worse than an
/// explicit "undefined".
pub fn centroid(rgba: &[u8], width: u32, height: u32) -> Result<Option<(f64, f64)>, String> {
validate_rgba(rgba, width, height)?;
let mut mass = 0.0f64;
let mut sx = 0.0f64;
let mut sy = 0.0f64;
for y in 0..height {
for x in 0..width {
let l = luma(pixel_at(rgba, width, x, y));
let w = (255 - l) as f64 / 255.0;
mass += w;
sx += w * x as f64;
sy += w * y as f64;
}
}
if mass <= 0.0 {
return Ok(None);
}
Ok(Some((sx / mass, sy / mass)))
}
/// The edge band, as a `width * height` row-major mask of `true` = "inside
/// the band", computed from **`rgba` alone** — recipe §10 defines the band
/// from the *reference* pixel's own neighbourhood, never the candidate's, so
/// `diff` always calls this on the reference buffer only.
///
/// **Border handling, decided and documented (recipe §10 requires this be
/// explicit):** a pixel's 3x3 neighbourhood, and later its 5x5 dilation
/// window, is clipped to the buffer's actual extent — out-of-bounds
/// neighbours are simply absent from the neighbourhood, not synthesized as
/// either class. The alternative (treating off-image neighbours as
/// background, since the canvas is nominally an infinite white page) would
/// make every border pixel automatically non-edge whenever the interior
/// pixel at the border is uniform, which is *true* for this recipe's
/// rasters (ink never reaches the image border) but is not a property this
/// function should assume for a caller's buffer in general. Treating
/// off-image neighbours as ink would be worse — it would manufacture edges
/// along the whole border of any image whose border pixels are ink. Only
/// "absent from the vote" makes no assumption about what lies outside the
/// buffer, at the cost that a border pixel needs strictly fewer differing
/// neighbours to qualify as an edge than an interior pixel does. For this
/// recipe's rasters (ink confined well within the frame) the choice is
/// inert in practice; it is recorded here because a general-purpose
/// function must still pick something.
pub fn edge_band_mask(rgba: &[u8], width: u32, height: u32) -> Result<Vec<bool>, String> {
validate_rgba(rgba, width, height)?;
let w = width as i32;
let h = height as i32;
let len = (width as usize) * (height as usize);
let mut ink = vec![false; len];
for y in 0..height {
for x in 0..width {
ink[(y as usize) * (width as usize) + (x as usize)] =
is_ink(pixel_at(rgba, width, x, y));
}
}
let mut edge = vec![false; len];
for y in 0..h {
for x in 0..w {
let mut seen_ink = false;
let mut seen_bg = false;
for dy in -1..=1 {
for dx in -1..=1 {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w || ny >= h {
// Out-of-bounds neighbour: excluded from the vote.
// See the doc comment above for why.
continue;
}
if ink[(ny as usize) * (width as usize) + (nx as usize)] {
seen_ink = true;
} else {
seen_bg = true;
}
}
}
if seen_ink && seen_bg {
edge[(y as usize) * (width as usize) + (x as usize)] = true;
}
}
}
// Dilate every edge pixel out to Chebyshev distance EDGE_BAND_PX,
// clipped to the buffer (same border rule: no wraparound, no synthesized
// pixels beyond the edge).
let mut band = vec![false; len];
for y in 0..h {
for x in 0..w {
if !edge[(y as usize) * (width as usize) + (x as usize)] {
continue;
}
let y0 = (y - EDGE_BAND_PX).max(0);
let y1 = (y + EDGE_BAND_PX).min(h - 1);
let x0 = (x - EDGE_BAND_PX).max(0);
let x1 = (x + EDGE_BAND_PX).min(w - 1);
for by in y0..=y1 {
for bx in x0..=x1 {
band[(by as usize) * (width as usize) + (bx as usize)] = true;
}
}
}
}
Ok(band)
}
/// Every number D1/D2/D3 rest on, plus the in-band figures that are
/// reported but never decide — a `FAIL` must be diagnosable from this
/// struct alone, without re-running anything (recipe §10).
#[derive(Clone, Debug)]
pub struct DiffReport {
pub width: u32,
pub height: u32,
/// Pixel count in the edge band (computed from the reference only).
pub band_pixel_count: u64,
/// D1: pixels *outside* the band whose ink class differs between the
/// two images. Must be exactly 0 to pass.
pub d1_pixels_outside_band_differing: u64,
pub d1_pass: bool,
pub reference_ink_mass: f64,
pub candidate_ink_mass: f64,
/// D2: relative delta of ink mass, `|candidate - reference| /
/// reference`. If the reference has no ink at all (mass 0) the
/// denominator is undefined; see the field doc on how that case is
/// handled.
pub d2_relative_delta: f64,
pub d2_pass: bool,
pub reference_centroid: Option<(f64, f64)>,
pub candidate_centroid: Option<(f64, f64)>,
/// D3: `(|Δx|, |Δy|)` between the two centroids. `None` when either
/// image has zero ink mass, so no centroid exists to compare — see
/// `d3_pass`.
pub d3_delta: Option<(f64, f64)>,
/// `None` when [`DiffReport::d3_delta`] is `None` (no verdict is
/// possible, not a failed one). `Some(true)`/`Some(false)` otherwise.
/// [`DiffReport::pass`] treats `None` as non-blocking: a comparison
/// that cannot be made cannot itself fail D3, and in every case this
/// crate's mutation set produces, an undefined D3 is accompanied by a
/// D2 failure that already dooms the overall verdict (blanking the
/// candidate to zero ink is the case in point — see the `selftest`
/// binary's M8).
pub d3_pass: Option<bool>,
/// Inside the band, the largest |Δluma| observed. Reported only — this
/// number is antialiasing, which is what the band exists to stop from
/// deciding anything.
pub in_band_max_abs_delta_luma: u8,
/// Inside the band, the count of pixels whose |Δluma| exceeds
/// [`IN_BAND_REPORT_DELTA_LUMA`]. Reported only, same reason.
pub in_band_count_delta_gt_report_threshold: u64,
/// D4: per-glyph ink mass, one entry per supplied region, in the order
/// they were given. Every entry must pass.
pub d4_regions: Vec<RegionMass>,
pub d4_pass: bool,
/// The single worst region, for the one-line summary a report leads with.
pub d4_worst: Option<RegionMass>,
}
impl DiffReport {
/// The overall verdict: D1, D2 and D4 must all hold, and D3 must either
/// hold or be inapplicable (see `d3_pass`'s doc comment).
pub fn pass(&self) -> bool {
self.d1_pass && self.d2_pass && self.d4_pass && self.d3_pass.unwrap_or(true)
}
}
/// Per-glyph ink mass over one region, dilated by [`D4_REGION_DILATION_PX`]
/// and clipped to the image.
///
/// This is the rule that actually catches a wrong, dropped, or re-shaped
/// glyph, and it exists because the other three demonstrably do not:
///
/// - **D1 cannot**, because it is structurally blind to any error confined to
/// a stroke narrower than `2 * EDGE_BAND_PX + 1 = 5 device px` — such a
/// stroke lies entirely within 2 px of its own edges, so deleting it changes
/// no unbanded pixel. Measured: deleting a 4 px stem from the synthetic
/// reference gave `d1 = 0`.
/// - **Whole-image D2 cannot**, because one glyph in a 28-glyph run is a few
/// percent of the total mass, and the tolerance is 2%.
///
/// D4 has neither weakness: the denominator is one glyph's own mass, so a
/// dropped glyph is a 100% error and a missing diacritic is a large one.
fn region_mass(rgba: &[u8], width: u32, height: u32, region: &GlyphRegion) -> Result<f64, String> {
let d = D4_REGION_DILATION_PX;
let x0 = region.x0.saturating_sub(d);
let y0 = region.y0.saturating_sub(d);
let x1 = (region.x1 + d).min(width);
let y1 = (region.y1 + d).min(height);
if x0 >= x1 || y0 >= y1 {
return Err(format!(
"region {:?} is empty after clipping to {width}x{height}: x {x0}..{x1}, y {y0}..{y1} \
— an empty region would score 0 mass against 0 mass and pass vacuously",
region.label
));
}
let mut mass = 0.0f64;
for y in y0..y1 {
for x in x0..x1 {
let l = luma(pixel_at(rgba, width, x, y));
mass += (255 - l) as f64 / 255.0;
}
}
Ok(mass)
}
/// Runs the full bounded visual differential (recipe §10) between a
/// reference raster and a candidate raster of identical dimensions.
///
/// The edge band is computed from `reference_rgba` alone (see
/// [`edge_band_mask`]'s doc comment) — the recipe defines "edge pixel" in
/// terms of the reference image, never the candidate, because the band is
/// meant to bound where the reference's own antialiasing lives, not where
/// the candidate happened to draw one.
/// `regions` must be **non-empty**. An empty list is refused rather than
/// accepted, because D4 over zero regions is a rule that always passes, and a
/// caller that forgot to supply the glyph boxes would otherwise get a green
/// verdict from a differential missing its strongest rule. Refusing here means
/// "D4 was not evaluated" can never be mistaken for "D4 held".
pub fn diff(
reference_rgba: &[u8],
candidate_rgba: &[u8],
width: u32,
height: u32,
regions: &[GlyphRegion],
) -> Result<DiffReport, String> {
validate_rgba(reference_rgba, width, height)?;
validate_rgba(candidate_rgba, width, height)?;
if regions.is_empty() {
return Err(
"diff called with no glyph regions — D4 (per-glyph ink mass) would be vacuous, and a \
vacuous rule that reports `pass` is worse than an absent one. Supply one region per \
shaped glyph."
.to_string(),
);
}
let band = edge_band_mask(reference_rgba, width, height)?;
let band_pixel_count = band.iter().filter(|&&b| b).count() as u64;
let mut d1_diff = 0u64;
let mut in_band_max_delta = 0u8;
let mut in_band_gt_threshold = 0u64;
for y in 0..height {
for x in 0..width {
let idx = (y as usize) * (width as usize) + (x as usize);
let rp = pixel_at(reference_rgba, width, x, y);
let cp = pixel_at(candidate_rgba, width, x, y);
let rl = luma(rp);
let cl = luma(cp);
let delta = (rl as i32 - cl as i32).unsigned_abs() as u8;
if band[idx] {
if delta > in_band_max_delta {
in_band_max_delta = delta;
}
if delta > IN_BAND_REPORT_DELTA_LUMA {
in_band_gt_threshold += 1;
}
} else {
let r_ink = (rl as u32) < INK_LUMA_THRESHOLD as u32;
let c_ink = (cl as u32) < INK_LUMA_THRESHOLD as u32;
if r_ink != c_ink {
d1_diff += 1;
}
}
}
}
let d1_pass = d1_diff == 0;
let reference_ink_mass = ink_mass(reference_rgba, width, height)?;
let candidate_ink_mass = ink_mass(candidate_rgba, width, height)?;
// Relative to the reference mass, which is the recipe's own framing
// ("agrees within 2% relative" — relative *to the reference*, the fixed
// point of the comparison). If the reference itself carries no ink,
// "relative" has no denominator: any candidate ink at all is reported
// as an unbounded (infinite) relative delta rather than a fabricated
// number, and two blank images agree exactly (delta 0).
let d2_relative_delta = if reference_ink_mass > 0.0 {
(candidate_ink_mass - reference_ink_mass).abs() / reference_ink_mass
} else if candidate_ink_mass > 0.0 {
f64::INFINITY
} else {
0.0
};
let d2_pass = d2_relative_delta <= D2_RELATIVE_TOLERANCE;
let reference_centroid = centroid(reference_rgba, width, height)?;
let candidate_centroid = centroid(candidate_rgba, width, height)?;
let (d3_delta, d3_pass) = match (reference_centroid, candidate_centroid) {
(Some((rx, ry)), Some((cx, cy))) => {
let delta = ((cx - rx).abs(), (cy - ry).abs());
let pass = delta.0 <= D3_TOLERANCE_DEVICE_PX && delta.1 <= D3_TOLERANCE_DEVICE_PX;
(Some(delta), Some(pass))
}
_ => (None, None),
};
let mut d4_regions = Vec::with_capacity(regions.len());
for r in regions {
let reference_mass = region_mass(reference_rgba, width, height, r)?;
let candidate_mass = region_mass(candidate_rgba, width, height, r)?;
// Same convention as D2, for the same reason: a region the reference
// left blank has no denominator, so any candidate ink there is an
// unbounded error rather than a fabricated percentage.
let relative_delta = if reference_mass > 0.0 {
(candidate_mass - reference_mass).abs() / reference_mass
} else if candidate_mass > 0.0 {
f64::INFINITY
} else {
0.0
};
d4_regions.push(RegionMass {
label: r.label.clone(),
reference_mass,
candidate_mass,
relative_delta,
pass: relative_delta <= D4_RELATIVE_TOLERANCE,
});
}
let d4_pass = d4_regions.iter().all(|r| r.pass);
let d4_worst = d4_regions
.iter()
.max_by(|a, b| {
a.relative_delta
.partial_cmp(&b.relative_delta)
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned();
Ok(DiffReport {
width,
height,
band_pixel_count,
d1_pixels_outside_band_differing: d1_diff,
d1_pass,
reference_ink_mass,
candidate_ink_mass,
d2_relative_delta,
d2_pass,
reference_centroid,
candidate_centroid,
d3_delta,
d3_pass,
in_band_max_abs_delta_luma: in_band_max_delta,
in_band_count_delta_gt_report_threshold: in_band_gt_threshold,
d4_regions,
d4_pass,
d4_worst,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut buf = vec![0u8; (width as usize) * (height as usize) * 4];
for px in buf.chunks_mut(4) {
px[0] = rgb[0];
px[1] = rgb[1];
px[2] = rgb[2];
px[3] = 255;
}
buf
}
#[test]
fn validate_rejects_wrong_length() {
let buf = vec![0u8; 3];
let err = validate_rgba(&buf, 2, 2).unwrap_err();
assert!(err.contains("expected exactly 16"));
}
#[test]
fn validate_rejects_non_opaque_pixel_and_names_coordinate() {
let mut buf = solid(2, 2, [255, 255, 255]);
// Pixel (1, 0): index 1*4 = 4.
buf[4 * 1 + 3] = 254;
let err = validate_rgba(&buf, 2, 2).unwrap_err();
assert!(
err.contains("(1, 0)"),
"error should name the coordinate: {err}"
);
}
#[test]
fn short_buffer_never_silently_classifies() {
// A short buffer must error, not decode as (0,0,0,0) "ink".
let buf = vec![0u8; 4]; // one pixel's worth, for a claimed 2x2 image
assert!(ink_mass(&buf, 2, 2).is_err());
assert!(centroid(&buf, 2, 2).is_err());
assert!(edge_band_mask(&buf, 2, 2).is_err());
assert!(diff(&buf, &buf, 2, 2, &one_region("x", 0, 0, 2, 2)).is_err());
}
#[test]
fn all_white_has_zero_mass_and_no_centroid() {
let buf = solid(4, 4, [255, 255, 255]);
assert_eq!(ink_mass(&buf, 4, 4).unwrap(), 0.0);
assert_eq!(centroid(&buf, 4, 4).unwrap(), None);
}
#[test]
fn all_black_has_full_mass_and_centre_centroid() {
let buf = solid(4, 4, [0, 0, 0]);
assert_eq!(ink_mass(&buf, 4, 4).unwrap(), 16.0);
// Uniform mass over a 4x4 grid centres at (1.5, 1.5).
let (cx, cy) = centroid(&buf, 4, 4).unwrap().unwrap();
assert!((cx - 1.5).abs() < 1e-9);
assert!((cy - 1.5).abs() < 1e-9);
}
fn one_region(label: &str, x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<GlyphRegion> {
vec![GlyphRegion {
label: label.to_string(),
x0,
y0,
x1,
y1,
}]
}
#[test]
fn empty_region_list_is_refused_not_passed_vacuously() {
let buf = solid(16, 16, [255, 255, 255]);
let err = diff(&buf, &buf, 16, 16, &[]).unwrap_err();
assert!(
err.contains("no glyph regions"),
"an empty region list must be an error, not a green D4: {err}"
);
}
#[test]
fn d4_catches_a_dropped_shape_that_d1_is_blind_to() {
// A 4px-wide stem: narrower than D1's floor of 2*EDGE_BAND_PX+1 = 5,
// so every one of its pixels lies inside the band around its own
// edges and D1 cannot see it vanish. D4 must.
let mut reference = solid(64, 64, [255, 255, 255]);
for y in 10..54u32 {
for x in 30..34u32 {
let idx = ((y as usize) * 64 + x as usize) * 4;
reference[idx] = 0;
reference[idx + 1] = 0;
reference[idx + 2] = 0;
}
}
let candidate = solid(64, 64, [255, 255, 255]);
let regions = one_region("stem", 30, 10, 34, 54);
let r = diff(&reference, &candidate, 64, 64, &regions).unwrap();
assert!(
r.d1_pass,
"D1 is expected to be blind here — if it fired, the declared 5px floor is wrong and \
the recipe must change, not this assertion"
);
assert!(!r.d4_pass, "D4 must catch the dropped stem");
assert_eq!(r.d4_worst.as_ref().unwrap().label, "stem");
assert!(!r.pass());
}
#[test]
fn identical_images_pass_trivially() {
let mut buf = solid(16, 16, [255, 255, 255]);
// Paint a small black square so the band is non-degenerate.
for y in 6..10u32 {
for x in 6..10u32 {
let idx = ((y as usize) * 16 + x as usize) * 4;
buf[idx] = 0;
buf[idx + 1] = 0;
buf[idx + 2] = 0;
}
}
let report = diff(&buf, &buf, 16, 16, &one_region("square", 6, 6, 10, 10)).unwrap();
assert!(report.pass());
assert_eq!(report.d1_pixels_outside_band_differing, 0);
assert_eq!(report.d2_relative_delta, 0.0);
assert_eq!(report.d3_delta, Some((0.0, 0.0)));
assert!(
report.band_pixel_count > 0,
"the square's edge should produce a band"
);
}
#[test]
fn blanking_the_candidate_fails_d2() {
let mut reference = solid(16, 16, [255, 255, 255]);
for y in 6..10u32 {
for x in 6..10u32 {
let idx = ((y as usize) * 16 + x as usize) * 4;
reference[idx] = 0;
reference[idx + 1] = 0;
reference[idx + 2] = 0;
}
}
let candidate = solid(16, 16, [255, 255, 255]);
let report = diff(
&reference,
&candidate,
16,
16,
&one_region("square", 6, 6, 10, 10),
)
.unwrap();
assert!(!report.d2_pass);
assert!(!report.pass());
}
#[test]
fn edge_band_excludes_out_of_bounds_neighbours_rather_than_assuming_a_class() {
// A 3x3 image, all white except the centre pixel, which is black.
// The border pixels' 3x3 neighbourhoods are clipped to the 3x3
// image itself; every one of them sees the black centre, so every
// pixel in this tiny image is an edge pixel, and thus the whole
// image is banded. This confirms the border rule does not silently
// extend the image with a synthesized background that would make
// border pixels edge-blind.
let mut buf = solid(3, 3, [255, 255, 255]);
let idx = (1 * 3 + 1) * 4;
buf[idx] = 0;
buf[idx + 1] = 0;
buf[idx + 2] = 0;
let band = edge_band_mask(&buf, 3, 3).unwrap();
assert!(
band.iter().all(|&b| b),
"every pixel should fall in the band: {band:?}"
);
}
}