facett-core/src/imgscan.rs
//! **imgscan** — the reusable IMAGE-ANALYSIS oracle (SCAN-THE-PIXELS law).
//!
//! A robot-UI / render test that claims it "sees" a frame MUST actually run image
//! analysis on the received pixels and assert the result is the EXPECTED structure.
//! "non-blank" / "has lit pixels" / "wrote a PNG" is NOT seeing — it passes on a
//! smear, a starburst, a garbage frame. (2026-06-21: the OSM-3D map smeared green
//! spokes across the whole frame in the deployed wasm demo while every render test
//! was green, because no test analyzed the image.)
//!
//! This module is **pure Rust** with no heavy deps: it operates on an RGBA byte
//! buffer (`&[u8]`, 4 bytes/pixel, row-major) + `width`/`height` — the exact shape
//! `egui_kittest`'s `harness.render()` produces (an `image::RgbaImage` derefs to
//! that slice via `.as_raw()`). Every analyzer returns a real number so a render
//! test can assert a viewport-tight, sensitivity-proven invariant ON THE PIXELS.
//!
//! The analyzers:
//! - [`spoke_score`] — thin radiating high-contrast lines (the smear signature).
//! - [`high_freq_ratio`] — high-pass vs total energy.
//! - [`coverage`] — fraction of pixels differing from the background.
//! - [`painted_centroid_and_bbox`] — centroid + bbox of the non-background mass.
//! - [`scan`] returning a [`ScanReport`] with all of the above.
/// An RGBA colour (0..=255 per channel) — the background reference for coverage /
/// centroid / bbox analysis. The alpha channel is ignored (frames are opaque).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba {
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// Opaque colour helper.
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 255 }
}
}
/// An axis-aligned bounding box in pixel coordinates (inclusive `min`, exclusive
/// `max`, i.e. `[min_x, max_x)`). Empty (no painted mass) is reported as a
/// zero-area box at the origin via [`BBox::EMPTY`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BBox {
pub min_x: u32,
pub min_y: u32,
pub max_x: u32,
pub max_y: u32,
}
impl BBox {
/// The canonical "nothing painted" box (zero width and height).
pub const EMPTY: BBox = BBox { min_x: 0, min_y: 0, max_x: 0, max_y: 0 };
pub fn width(&self) -> u32 {
self.max_x.saturating_sub(self.min_x)
}
pub fn height(&self) -> u32 {
self.max_y.saturating_sub(self.min_y)
}
pub fn is_empty(&self) -> bool {
self.width() == 0 || self.height() == 0
}
}
/// The full structural read of a frame — the one thing a render test asserts on.
#[derive(Clone, Copy, Debug)]
pub struct ScanReport {
/// Fraction of edge energy that forms LONG THIN runs (the spoke/starburst
/// signature). Clean filled render → ~0; a radiating smear → high. See
/// [`spoke_score`].
pub spoke_score: f64,
/// High-pass / total energy ratio. A few thin bright lines push this up. See
/// [`high_freq_ratio`].
pub high_freq_ratio: f64,
/// Fraction of pixels differing from the background by the threshold. See
/// [`coverage`].
pub coverage: f64,
/// Centroid `(cx, cy)` of the non-background mass, in pixels. `(0,0)` when the
/// frame is blank.
pub centroid: (f64, f64),
/// Bounding box of the non-background mass.
pub bbox: BBox,
}
// ── byte-buffer helpers ────────────────────────────────────────────────────────
#[inline]
fn idx(x: u32, y: u32, w: u32) -> usize {
(y as usize * w as usize + x as usize) * 4
}
/// Per-pixel luminance (Rec. 601) from RGB, 0.0..=255.0. Pure, so the whole oracle
/// is deterministic and snapshot-stable.
#[inline]
fn luma_at(px: &[u8], x: u32, y: u32, w: u32) -> f64 {
let i = idx(x, y, w);
0.299 * px[i] as f64 + 0.587 * px[i + 1] as f64 + 0.114 * px[i + 2] as f64
}
/// Validate the buffer is the right length for `w*h` RGBA pixels. A short buffer is
/// a programming error in the caller, so we treat "too small" as an empty image
/// rather than panic deep inside an analyzer.
#[inline]
fn ok_dims(px: &[u8], w: u32, h: u32) -> bool {
w > 0 && h > 0 && px.len() >= (w as usize * h as usize * 4)
}
/// Build the luminance plane once (reused by the edge + high-pass analyzers).
fn luma_plane(px: &[u8], w: u32, h: u32) -> Vec<f64> {
let mut out = vec![0.0; (w as usize) * (h as usize)];
for y in 0..h {
for x in 0..w {
out[(y as usize) * (w as usize) + (x as usize)] = luma_at(px, x, y, w);
}
}
out
}
// ── 1. spoke / starburst detection ──────────────────────────────────────────────
/// **Spoke / starburst score** — detect thin radiating high-contrast lines (the
/// smear signature) and return the *fraction of edge energy that forms long thin
/// runs*, in `0.0..=1.0`.
///
/// Approach (the SCAN-THE-PIXELS law's "edge filter → long thin runs"):
/// 1. Sobel gradient magnitude over the luminance plane → an edge map.
/// 2. Threshold to a binary "strong edge" mask (relative to the mean edge energy,
/// so it scales with content, not absolute brightness).
/// 3. A spoke pixel is a strong-edge pixel that is **thin** — its local
/// neighbourhood is *mostly background*, i.e. it is a 1-px line rather than the
/// boundary of a filled region. A filled rectangle's edge has fill on one side;
/// a 1-px spoke has background on BOTH sides. We measure this as: of the 8
/// neighbours, how many are themselves NOT lit (background). High → thin line.
/// 4. The score is `Σ(edge_magnitude over thin pixels) / Σ(edge_magnitude over all
/// strong-edge pixels)`. A clean filled render has thick boundaries → low score;
/// a starburst is all thin lines → high score.
///
/// A blank or fully-uniform frame has no edge energy → score `0.0`.
pub fn spoke_score(px: &[u8], w: u32, h: u32) -> f64 {
if !ok_dims(px, w, h) || w < 3 || h < 3 {
return 0.0;
}
let luma = luma_plane(px, w, h);
let at = |x: u32, y: u32| luma[(y as usize) * (w as usize) + (x as usize)];
// Sobel magnitude per interior pixel.
let mut mag = vec![0.0f64; (w as usize) * (h as usize)];
let mut sum_mag = 0.0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1))
- (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1))
- (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
let m = (gx * gx + gy * gy).sqrt();
mag[(y as usize) * (w as usize) + (x as usize)] = m;
sum_mag += m;
}
}
if sum_mag <= f64::EPSILON {
return 0.0;
}
let n_interior = ((w - 2) as f64) * ((h - 2) as f64);
let mean_mag = sum_mag / n_interior;
// Strong edge = clearly above the mean gradient. The factor is a relative gate
// (not an absolute brightness), so it tracks content; 2× mean isolates the
// genuine edges from the diffuse low-gradient field of a filled region.
let strong_gate = (mean_mag * 2.0).max(8.0);
// A "lit" pixel for the thinness test: brighter than the modal background.
// Use the global luma median as a cheap background estimate.
let bg_luma = median(&luma);
let lit = |x: u32, y: u32| (at(x, y) - bg_luma).abs() > 24.0;
let magv = |x: u32, y: u32| mag[(y as usize) * (w as usize) + (x as usize)];
let mut strong_energy = 0.0;
let mut thin_energy = 0.0;
for y in 1..h - 1 {
for x in 1..w - 1 {
let m = magv(x, y);
if m < strong_gate {
continue;
}
strong_energy += m;
// Count lit neighbours among the 8-neighbourhood. A thin line has few
// (background on both sides); a filled-region boundary has many.
let mut lit_neighbours = 0u32;
for (dx, dy) in
[(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
{
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if lit(nx as u32, ny as u32) {
lit_neighbours += 1;
}
}
// <= 2 lit neighbours ⇒ the pixel is a thin filament (a 1-px line
// continues along its axis → ~2 lit neighbours), NOT the edge of a solid
// mass (whose boundary keeps ≥3 lit neighbours on the fill side).
if lit_neighbours <= 2 {
thin_energy += m;
}
}
}
if strong_energy <= f64::EPSILON {
0.0
} else {
thin_energy / strong_energy
}
}
/// Median of a slice (used as a cheap background-luma estimate). O(n log n); the
/// planes are small (a frame), so this is fine and keeps the oracle dep-free.
fn median(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
let mut s: Vec<f64> = v.to_vec();
s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
s[s.len() / 2]
}
// ── 2. high-frequency ratio ──────────────────────────────────────────────────────
/// **High-frequency energy ratio** in `0.0..=1.0` — a cheap stand-in for the FFT
/// high-pass/low-pass split the law calls for.
///
/// We build a low-pass image by box-blurring the luminance plane (radius 2), then
/// the high-pass is `original − low_pass`. The ratio is
/// `Σ|high_pass| / (Σ|high_pass| + Σ|low_pass_variation|)` — i.e. the share of the
/// signal's total variation that lives at high frequency. A broad, smooth render
/// (filled regions) keeps this low; a few thin bright spokes inject sharp local
/// transitions and push it up.
///
/// A blank/uniform frame has no variation → `0.0`.
pub fn high_freq_ratio(px: &[u8], w: u32, h: u32) -> f64 {
if !ok_dims(px, w, h) || w < 2 || h < 2 {
return 0.0;
}
let luma = luma_plane(px, w, h);
let lp = box_blur(&luma, w, h, 2);
let mut hf = 0.0; // high-pass energy: |original - lowpass|
let mut lf = 0.0; // low-pass variation around its own mean
let mean: f64 = luma.iter().sum::<f64>() / luma.len() as f64;
for i in 0..luma.len() {
hf += (luma[i] - lp[i]).abs();
lf += (lp[i] - mean).abs();
}
let total = hf + lf;
if total <= f64::EPSILON {
0.0
} else {
hf / total
}
}
/// Separable box blur (radius `r`) over a single-channel plane. Two 1-D passes →
/// O(n·r); pure, deterministic. Edges clamp to the border.
fn box_blur(plane: &[f64], w: u32, h: u32, r: u32) -> Vec<f64> {
let (wu, hu) = (w as usize, h as usize);
let r = r as i32;
// horizontal
let mut tmp = vec![0.0f64; plane.len()];
for y in 0..hu {
for x in 0..wu {
let mut acc = 0.0;
let mut cnt = 0.0;
for dx in -r..=r {
let sx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
acc += plane[y * wu + sx];
cnt += 1.0;
}
tmp[y * wu + x] = acc / cnt;
}
}
// vertical
let mut out = vec![0.0f64; plane.len()];
for y in 0..hu {
for x in 0..wu {
let mut acc = 0.0;
let mut cnt = 0.0;
for dy in -r..=r {
let sy = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
acc += tmp[sy * wu + x];
cnt += 1.0;
}
out[y * wu + x] = acc / cnt;
}
}
out
}
// ── 3. coverage ──────────────────────────────────────────────────────────────────
/// True when pixel `(x,y)` differs from `bg` by more than the per-channel-sum
/// threshold. The threshold (`> 24` summed over RGB) ignores anti-aliasing noise
/// while catching any genuine paint.
#[inline]
fn differs(px: &[u8], x: u32, y: u32, w: u32, bg: Rgba) -> bool {
let i = idx(x, y, w);
let d = (px[i] as i32 - bg.r as i32).abs()
+ (px[i + 1] as i32 - bg.g as i32).abs()
+ (px[i + 2] as i32 - bg.b as i32).abs();
d > 24
}
/// **Coverage** — fraction of pixels (`0.0..=1.0`) that differ from the background
/// colour `bg` by the threshold. Catches a blank frame (≈0) and a frame-filling
/// smear (≈1); a sane render sits in a band between.
pub fn coverage(px: &[u8], w: u32, h: u32, bg: Rgba) -> f64 {
if !ok_dims(px, w, h) {
return 0.0;
}
let mut painted = 0u64;
for y in 0..h {
for x in 0..w {
if differs(px, x, y, w, bg) {
painted += 1;
}
}
}
painted as f64 / (w as f64 * h as f64)
}
// ── 4. centroid + bbox ───────────────────────────────────────────────────────────
/// **Centroid + bounding box of the non-background mass.** The centroid `(cx,cy)`
/// is the mean position of every painted pixel; the bbox is the tightest box that
/// contains them. Catches geometry flung to a corner (centroid far from centre) or
/// a degenerate render (empty bbox). A blank frame returns `((0,0), BBox::EMPTY)`.
pub fn painted_centroid_and_bbox(px: &[u8], w: u32, h: u32, bg: Rgba) -> ((f64, f64), BBox) {
if !ok_dims(px, w, h) {
return ((0.0, 0.0), BBox::EMPTY);
}
let mut sum_x = 0.0;
let mut sum_y = 0.0;
let mut n = 0u64;
let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
for y in 0..h {
for x in 0..w {
if differs(px, x, y, w, bg) {
sum_x += x as f64;
sum_y += y as f64;
n += 1;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
if n == 0 {
return ((0.0, 0.0), BBox::EMPTY);
}
let centroid = (sum_x / n as f64, sum_y / n as f64);
// max is inclusive above; report exclusive (+1) so width/height count pixels.
let bbox = BBox { min_x, min_y, max_x: max_x + 1, max_y: max_y + 1 };
(centroid, bbox)
}
// ── 5. one-shot scan ─────────────────────────────────────────────────────────────
/// Run every analyzer and bundle the result. This is the single entry a render /
/// robot-UI test calls — `let r = scan(pixels, w, h, bg);` then assert each field
/// against a viewport-tight, sensitivity-proven bound.
pub fn scan(px: &[u8], w: u32, h: u32, bg: Rgba) -> ScanReport {
let (centroid, bbox) = painted_centroid_and_bbox(px, w, h, bg);
ScanReport {
spoke_score: spoke_score(px, w, h),
high_freq_ratio: high_freq_ratio(px, w, h),
coverage: coverage(px, w, h, bg),
centroid,
bbox,
}
}
#[cfg(test)]
mod tests {
use super::*;
const W: u32 = 200;
const H: u32 = 200;
const BG: Rgba = Rgba::rgb(12, 12, 18); // dark background
/// Allocate a frame filled with `bg`.
fn frame(w: u32, h: u32, bg: Rgba) -> Vec<u8> {
let mut v = vec![0u8; (w as usize) * (h as usize) * 4];
for p in v.as_chunks_mut::<4>().0 {
p[0] = bg.r;
p[1] = bg.g;
p[2] = bg.b;
p[3] = bg.a;
}
v
}
fn put(px: &mut [u8], x: i32, y: i32, w: u32, h: u32, c: Rgba) {
if x < 0 || y < 0 || x as u32 >= w || y as u32 >= h {
return;
}
let i = ((y as u32 * w + x as u32) * 4) as usize;
px[i] = c.r;
px[i + 1] = c.g;
px[i + 2] = c.b;
px[i + 3] = c.a;
}
#[allow(clippy::too_many_arguments)] // pixel helper: buffer, rect, extent, colour
fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
for y in y0..(y0 + rh).min(h) {
for x in x0..(x0 + rw).min(w) {
put(px, x as i32, y as i32, w, h, c);
}
}
}
/// Bresenham line (thin, 1-px) — used to draw spokes.
#[allow(clippy::too_many_arguments)] // pixel helper: buffer, segment, extent, colour
fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
let (mut x, mut y) = (x0, y0);
loop {
put(px, x, y, w, h, c);
if x == x1 && y == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}
/// A CLEAN frame: a few solid filled rectangles on the dark bg, centred.
fn clean_frame() -> Vec<u8> {
let mut f = frame(W, H, BG);
// Solid filled regions with real area (a clean render is compact fill, not
// perimeter): boundaries are thick, interiors lit → low thin-run fraction.
fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
f
}
/// A SMEARED frame: thin bright lines radiating from the centre to the edges
/// (the green-spoke starburst the live OSM-3D demo produced).
fn smeared_frame() -> Vec<u8> {
let mut f = frame(W, H, BG);
let (cx, cy) = (W as i32 / 2, H as i32 / 2);
let spoke = Rgba::rgb(60, 230, 90); // the smear green
// 24 spokes radiating to points around the frame border.
for k in 0..24 {
let a = std::f64::consts::TAU * k as f64 / 24.0;
let ex = (cx as f64 + a.cos() * 1000.0) as i32; // shoot well past the edge
let ey = (cy as f64 + a.sin() * 1000.0) as i32;
line(&mut f, cx, cy, ex, ey, W, H, spoke);
}
f
}
// ── the SENSITIVITY proof: clean vs smeared must be clearly separable ──
#[test]
fn clean_frame_scans_low_and_centred() {
let f = clean_frame();
let r = scan(&f, W, H, BG);
eprintln!("[imgscan] CLEAN {r:?}");
// spoke + high-freq are LOW for compact filled regions.
assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
// coverage in a sane band (not blank, not frame-filling).
assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
// centroid near image centre (the rects are clustered mid-frame).
assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
}
#[test]
fn smeared_frame_scans_high() {
let f = smeared_frame();
let r = scan(&f, W, H, BG);
eprintln!("[imgscan] SMEAR {r:?}");
assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
}
/// The oracle PROVES it can go red on the bug: there is a clear separating
/// threshold between clean and smeared for BOTH spoke_score and hf_ratio. If
/// this gap ever collapses, the oracle is blind and this test fails.
#[test]
fn clean_and_smeared_are_clearly_separated() {
let clean = scan(&clean_frame(), W, H, BG);
let smear = scan(&smeared_frame(), W, H, BG);
eprintln!(
"[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
);
// spoke_score: pick a threshold strictly between the two and assert it splits.
let t_spoke = 0.25;
assert!(
clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
"spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
clean.spoke_score,
smear.spoke_score
);
// high_freq_ratio: a threshold strictly between the two.
let t_hf = 0.30;
assert!(
clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
"hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
clean.high_freq_ratio,
smear.high_freq_ratio
);
// And the gaps are comfortably wide (not a knife-edge that flips on noise).
assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
}
// ── min / mid / max boundary coverage ──
#[test]
fn blank_frame_is_all_zero() {
let f = frame(W, H, BG);
let r = scan(&f, W, H, BG);
assert_eq!(r.spoke_score, 0.0, "blank: no edges");
assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
assert!(r.bbox.is_empty(), "blank: empty bbox");
}
#[test]
fn fully_filled_frame_is_full_coverage_low_freq() {
// Every pixel a single solid non-bg colour: coverage ≈ 1, no internal
// structure → no edges, no high-freq energy.
let f = frame(W, H, Rgba::rgb(200, 30, 30));
let r = scan(&f, W, H, BG);
assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
// bbox spans the whole frame; centroid at the centre.
assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
}
#[test]
fn single_pixel_image_does_not_panic() {
// 1×1 is below every analyzer's window; they must degrade gracefully to 0,
// never index out of bounds.
let mut f = frame(1, 1, BG);
let r = scan(&f, 1, 1, BG);
assert_eq!(r.coverage, 0.0);
assert_eq!(r.spoke_score, 0.0);
// a single lit pixel ⇒ coverage 1, centroid at (0,0), 1×1 bbox.
put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
let r2 = scan(&f, 1, 1, BG);
assert!((r2.coverage - 1.0).abs() < 1e-9);
assert_eq!(r2.centroid, (0.0, 0.0));
assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
}
#[test]
fn corner_flung_geometry_moves_the_centroid() {
// Geometry in the top-left corner ⇒ centroid far from centre (the "flung to
// a corner" bug the law calls out).
let mut f = frame(W, H, BG);
fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
}
#[test]
fn coverage_counts_only_above_threshold() {
// A near-bg colour (within the AA threshold) must NOT count as painted.
let mut f = frame(W, H, BG);
fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
}
#[test]
fn bad_dims_degrade_gracefully() {
// A buffer too short for the claimed dims ⇒ analyzers return the empty read,
// never panic.
let f = vec![0u8; 16]; // claims 100×100 but is 16 bytes
assert_eq!(coverage(&f, 100, 100, BG), 0.0);
assert_eq!(spoke_score(&f, 100, 100), 0.0);
assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
assert_eq!(c, (0.0, 0.0));
assert!(b.is_empty());
}
}