//! 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 { 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, 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, 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, /// 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, pub d4_pass: bool, /// The single worst region, for the one-line summary a report leads with. pub d4_worst: Option, } 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 { 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 { 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 { 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 { 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, ®ions).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:?}" ); } }