Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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());
    }
}