Skip to main content

facett_core/
imgscan.rs

1//! **imgscan** — the reusable IMAGE-ANALYSIS oracle (SCAN-THE-PIXELS law).
2//!
3//! A robot-UI / render test that claims it "sees" a frame MUST actually run image
4//! analysis on the received pixels and assert the result is the EXPECTED structure.
5//! "non-blank" / "has lit pixels" / "wrote a PNG" is NOT seeing — it passes on a
6//! smear, a starburst, a garbage frame. (2026-06-21: the OSM-3D map smeared green
7//! spokes across the whole frame in the deployed wasm demo while every render test
8//! was green, because no test analyzed the image.)
9//!
10//! This module is **pure Rust** with no heavy deps: it operates on an RGBA byte
11//! buffer (`&[u8]`, 4 bytes/pixel, row-major) + `width`/`height` — the exact shape
12//! `egui_kittest`'s `harness.render()` produces (an `image::RgbaImage` derefs to
13//! that slice via `.as_raw()`). Every analyzer returns a real number so a render
14//! test can assert a viewport-tight, sensitivity-proven invariant ON THE PIXELS.
15//!
16//! The analyzers:
17//! - [`spoke_score`] — thin radiating high-contrast lines (the smear signature).
18//! - [`high_freq_ratio`] — high-pass vs total energy.
19//! - [`coverage`] — fraction of pixels differing from the background.
20//! - [`painted_centroid_and_bbox`] — centroid + bbox of the non-background mass.
21//! - [`scan`] returning a [`ScanReport`] with all of the above.
22
23/// An RGBA colour (0..=255 per channel) — the background reference for coverage /
24/// centroid / bbox analysis. The alpha channel is ignored (frames are opaque).
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct Rgba {
27    pub r: u8,
28    pub g: u8,
29    pub b: u8,
30    pub a: u8,
31}
32
33impl Rgba {
34    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
35        Self { r, g, b, a }
36    }
37    /// Opaque colour helper.
38    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
39        Self { r, g, b, a: 255 }
40    }
41}
42
43/// An axis-aligned bounding box in pixel coordinates (inclusive `min`, exclusive
44/// `max`, i.e. `[min_x, max_x)`). Empty (no painted mass) is reported as a
45/// zero-area box at the origin via [`BBox::EMPTY`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct BBox {
48    pub min_x: u32,
49    pub min_y: u32,
50    pub max_x: u32,
51    pub max_y: u32,
52}
53
54impl BBox {
55    /// The canonical "nothing painted" box (zero width and height).
56    pub const EMPTY: BBox = BBox { min_x: 0, min_y: 0, max_x: 0, max_y: 0 };
57
58    pub fn width(&self) -> u32 {
59        self.max_x.saturating_sub(self.min_x)
60    }
61    pub fn height(&self) -> u32 {
62        self.max_y.saturating_sub(self.min_y)
63    }
64    pub fn is_empty(&self) -> bool {
65        self.width() == 0 || self.height() == 0
66    }
67}
68
69/// The full structural read of a frame — the one thing a render test asserts on.
70#[derive(Clone, Copy, Debug)]
71pub struct ScanReport {
72    /// Fraction of edge energy that forms LONG THIN runs (the spoke/starburst
73    /// signature). Clean filled render → ~0; a radiating smear → high. See
74    /// [`spoke_score`].
75    pub spoke_score: f64,
76    /// High-pass / total energy ratio. A few thin bright lines push this up. See
77    /// [`high_freq_ratio`].
78    pub high_freq_ratio: f64,
79    /// Fraction of pixels differing from the background by the threshold. See
80    /// [`coverage`].
81    pub coverage: f64,
82    /// Centroid `(cx, cy)` of the non-background mass, in pixels. `(0,0)` when the
83    /// frame is blank.
84    pub centroid: (f64, f64),
85    /// Bounding box of the non-background mass.
86    pub bbox: BBox,
87}
88
89// ── byte-buffer helpers ────────────────────────────────────────────────────────
90
91#[inline]
92fn idx(x: u32, y: u32, w: u32) -> usize {
93    (y as usize * w as usize + x as usize) * 4
94}
95
96/// Per-pixel luminance (Rec. 601) from RGB, 0.0..=255.0. Pure, so the whole oracle
97/// is deterministic and snapshot-stable.
98#[inline]
99fn luma_at(px: &[u8], x: u32, y: u32, w: u32) -> f64 {
100    let i = idx(x, y, w);
101    0.299 * px[i] as f64 + 0.587 * px[i + 1] as f64 + 0.114 * px[i + 2] as f64
102}
103
104/// Validate the buffer is the right length for `w*h` RGBA pixels. A short buffer is
105/// a programming error in the caller, so we treat "too small" as an empty image
106/// rather than panic deep inside an analyzer.
107#[inline]
108fn ok_dims(px: &[u8], w: u32, h: u32) -> bool {
109    w > 0 && h > 0 && px.len() >= (w as usize * h as usize * 4)
110}
111
112/// Build the luminance plane once (reused by the edge + high-pass analyzers).
113fn luma_plane(px: &[u8], w: u32, h: u32) -> Vec<f64> {
114    let mut out = vec![0.0; (w as usize) * (h as usize)];
115    for y in 0..h {
116        for x in 0..w {
117            out[(y as usize) * (w as usize) + (x as usize)] = luma_at(px, x, y, w);
118        }
119    }
120    out
121}
122
123// ── 1. spoke / starburst detection ──────────────────────────────────────────────
124
125/// **Spoke / starburst score** — detect thin radiating high-contrast lines (the
126/// smear signature) and return the *fraction of edge energy that forms long thin
127/// runs*, in `0.0..=1.0`.
128///
129/// Approach (the SCAN-THE-PIXELS law's "edge filter → long thin runs"):
130/// 1. Sobel gradient magnitude over the luminance plane → an edge map.
131/// 2. Threshold to a binary "strong edge" mask (relative to the mean edge energy,
132///    so it scales with content, not absolute brightness).
133/// 3. A spoke pixel is a strong-edge pixel that is **thin** — its local
134///    neighbourhood is *mostly background*, i.e. it is a 1-px line rather than the
135///    boundary of a filled region. A filled rectangle's edge has fill on one side;
136///    a 1-px spoke has background on BOTH sides. We measure this as: of the 8
137///    neighbours, how many are themselves NOT lit (background). High → thin line.
138/// 4. The score is `Σ(edge_magnitude over thin pixels) / Σ(edge_magnitude over all
139///    strong-edge pixels)`. A clean filled render has thick boundaries → low score;
140///    a starburst is all thin lines → high score.
141///
142/// A blank or fully-uniform frame has no edge energy → score `0.0`.
143pub fn spoke_score(px: &[u8], w: u32, h: u32) -> f64 {
144    if !ok_dims(px, w, h) || w < 3 || h < 3 {
145        return 0.0;
146    }
147    let luma = luma_plane(px, w, h);
148    let at = |x: u32, y: u32| luma[(y as usize) * (w as usize) + (x as usize)];
149
150    // Sobel magnitude per interior pixel.
151    let mut mag = vec![0.0f64; (w as usize) * (h as usize)];
152    let mut sum_mag = 0.0;
153    for y in 1..h - 1 {
154        for x in 1..w - 1 {
155            let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1))
156                - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
157            let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1))
158                - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
159            let m = (gx * gx + gy * gy).sqrt();
160            mag[(y as usize) * (w as usize) + (x as usize)] = m;
161            sum_mag += m;
162        }
163    }
164    if sum_mag <= f64::EPSILON {
165        return 0.0;
166    }
167    let n_interior = ((w - 2) as f64) * ((h - 2) as f64);
168    let mean_mag = sum_mag / n_interior;
169    // Strong edge = clearly above the mean gradient. The factor is a relative gate
170    // (not an absolute brightness), so it tracks content; 2× mean isolates the
171    // genuine edges from the diffuse low-gradient field of a filled region.
172    let strong_gate = (mean_mag * 2.0).max(8.0);
173
174    // A "lit" pixel for the thinness test: brighter than the modal background.
175    // Use the global luma median as a cheap background estimate.
176    let bg_luma = median(&luma);
177    let lit = |x: u32, y: u32| (at(x, y) - bg_luma).abs() > 24.0;
178
179    let magv = |x: u32, y: u32| mag[(y as usize) * (w as usize) + (x as usize)];
180
181    let mut strong_energy = 0.0;
182    let mut thin_energy = 0.0;
183    for y in 1..h - 1 {
184        for x in 1..w - 1 {
185            let m = magv(x, y);
186            if m < strong_gate {
187                continue;
188            }
189            strong_energy += m;
190            // Count lit neighbours among the 8-neighbourhood. A thin line has few
191            // (background on both sides); a filled-region boundary has many.
192            let mut lit_neighbours = 0u32;
193            for (dx, dy) in
194                [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
195            {
196                let nx = x as i32 + dx;
197                let ny = y as i32 + dy;
198                if lit(nx as u32, ny as u32) {
199                    lit_neighbours += 1;
200                }
201            }
202            // <= 2 lit neighbours ⇒ the pixel is a thin filament (a 1-px line
203            // continues along its axis → ~2 lit neighbours), NOT the edge of a solid
204            // mass (whose boundary keeps ≥3 lit neighbours on the fill side).
205            if lit_neighbours <= 2 {
206                thin_energy += m;
207            }
208        }
209    }
210    if strong_energy <= f64::EPSILON {
211        0.0
212    } else {
213        thin_energy / strong_energy
214    }
215}
216
217/// Median of a slice (used as a cheap background-luma estimate). O(n log n); the
218/// planes are small (a frame), so this is fine and keeps the oracle dep-free.
219fn median(v: &[f64]) -> f64 {
220    if v.is_empty() {
221        return 0.0;
222    }
223    let mut s: Vec<f64> = v.to_vec();
224    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
225    s[s.len() / 2]
226}
227
228// ── 2. high-frequency ratio ──────────────────────────────────────────────────────
229
230/// **High-frequency energy ratio** in `0.0..=1.0` — a cheap stand-in for the FFT
231/// high-pass/low-pass split the law calls for.
232///
233/// We build a low-pass image by box-blurring the luminance plane (radius 2), then
234/// the high-pass is `original − low_pass`. The ratio is
235/// `Σ|high_pass| / (Σ|high_pass| + Σ|low_pass_variation|)` — i.e. the share of the
236/// signal's total variation that lives at high frequency. A broad, smooth render
237/// (filled regions) keeps this low; a few thin bright spokes inject sharp local
238/// transitions and push it up.
239///
240/// A blank/uniform frame has no variation → `0.0`.
241pub fn high_freq_ratio(px: &[u8], w: u32, h: u32) -> f64 {
242    if !ok_dims(px, w, h) || w < 2 || h < 2 {
243        return 0.0;
244    }
245    let luma = luma_plane(px, w, h);
246    let lp = box_blur(&luma, w, h, 2);
247
248    let mut hf = 0.0; // high-pass energy: |original - lowpass|
249    let mut lf = 0.0; // low-pass variation around its own mean
250    let mean: f64 = luma.iter().sum::<f64>() / luma.len() as f64;
251    for i in 0..luma.len() {
252        hf += (luma[i] - lp[i]).abs();
253        lf += (lp[i] - mean).abs();
254    }
255    let total = hf + lf;
256    if total <= f64::EPSILON {
257        0.0
258    } else {
259        hf / total
260    }
261}
262
263/// Separable box blur (radius `r`) over a single-channel plane. Two 1-D passes →
264/// O(n·r); pure, deterministic. Edges clamp to the border.
265fn box_blur(plane: &[f64], w: u32, h: u32, r: u32) -> Vec<f64> {
266    let (wu, hu) = (w as usize, h as usize);
267    let r = r as i32;
268    // horizontal
269    let mut tmp = vec![0.0f64; plane.len()];
270    for y in 0..hu {
271        for x in 0..wu {
272            let mut acc = 0.0;
273            let mut cnt = 0.0;
274            for dx in -r..=r {
275                let sx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
276                acc += plane[y * wu + sx];
277                cnt += 1.0;
278            }
279            tmp[y * wu + x] = acc / cnt;
280        }
281    }
282    // vertical
283    let mut out = vec![0.0f64; plane.len()];
284    for y in 0..hu {
285        for x in 0..wu {
286            let mut acc = 0.0;
287            let mut cnt = 0.0;
288            for dy in -r..=r {
289                let sy = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
290                acc += tmp[sy * wu + x];
291                cnt += 1.0;
292            }
293            out[y * wu + x] = acc / cnt;
294        }
295    }
296    out
297}
298
299// ── 3. coverage ──────────────────────────────────────────────────────────────────
300
301/// True when pixel `(x,y)` differs from `bg` by more than the per-channel-sum
302/// threshold. The threshold (`> 24` summed over RGB) ignores anti-aliasing noise
303/// while catching any genuine paint.
304#[inline]
305fn differs(px: &[u8], x: u32, y: u32, w: u32, bg: Rgba) -> bool {
306    let i = idx(x, y, w);
307    let d = (px[i] as i32 - bg.r as i32).abs()
308        + (px[i + 1] as i32 - bg.g as i32).abs()
309        + (px[i + 2] as i32 - bg.b as i32).abs();
310    d > 24
311}
312
313/// **Coverage** — fraction of pixels (`0.0..=1.0`) that differ from the background
314/// colour `bg` by the threshold. Catches a blank frame (≈0) and a frame-filling
315/// smear (≈1); a sane render sits in a band between.
316pub fn coverage(px: &[u8], w: u32, h: u32, bg: Rgba) -> f64 {
317    if !ok_dims(px, w, h) {
318        return 0.0;
319    }
320    let mut painted = 0u64;
321    for y in 0..h {
322        for x in 0..w {
323            if differs(px, x, y, w, bg) {
324                painted += 1;
325            }
326        }
327    }
328    painted as f64 / (w as f64 * h as f64)
329}
330
331// ── 4. centroid + bbox ───────────────────────────────────────────────────────────
332
333/// **Centroid + bounding box of the non-background mass.** The centroid `(cx,cy)`
334/// is the mean position of every painted pixel; the bbox is the tightest box that
335/// contains them. Catches geometry flung to a corner (centroid far from centre) or
336/// a degenerate render (empty bbox). A blank frame returns `((0,0), BBox::EMPTY)`.
337pub fn painted_centroid_and_bbox(px: &[u8], w: u32, h: u32, bg: Rgba) -> ((f64, f64), BBox) {
338    if !ok_dims(px, w, h) {
339        return ((0.0, 0.0), BBox::EMPTY);
340    }
341    let mut sum_x = 0.0;
342    let mut sum_y = 0.0;
343    let mut n = 0u64;
344    let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
345    for y in 0..h {
346        for x in 0..w {
347            if differs(px, x, y, w, bg) {
348                sum_x += x as f64;
349                sum_y += y as f64;
350                n += 1;
351                min_x = min_x.min(x);
352                min_y = min_y.min(y);
353                max_x = max_x.max(x);
354                max_y = max_y.max(y);
355            }
356        }
357    }
358    if n == 0 {
359        return ((0.0, 0.0), BBox::EMPTY);
360    }
361    let centroid = (sum_x / n as f64, sum_y / n as f64);
362    // max is inclusive above; report exclusive (+1) so width/height count pixels.
363    let bbox = BBox { min_x, min_y, max_x: max_x + 1, max_y: max_y + 1 };
364    (centroid, bbox)
365}
366
367// ── 5. one-shot scan ─────────────────────────────────────────────────────────────
368
369/// Run every analyzer and bundle the result. This is the single entry a render /
370/// robot-UI test calls — `let r = scan(pixels, w, h, bg);` then assert each field
371/// against a viewport-tight, sensitivity-proven bound.
372pub fn scan(px: &[u8], w: u32, h: u32, bg: Rgba) -> ScanReport {
373    let (centroid, bbox) = painted_centroid_and_bbox(px, w, h, bg);
374    ScanReport {
375        spoke_score: spoke_score(px, w, h),
376        high_freq_ratio: high_freq_ratio(px, w, h),
377        coverage: coverage(px, w, h, bg),
378        centroid,
379        bbox,
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    const W: u32 = 200;
388    const H: u32 = 200;
389    const BG: Rgba = Rgba::rgb(12, 12, 18); // dark background
390
391    /// Allocate a frame filled with `bg`.
392    fn frame(w: u32, h: u32, bg: Rgba) -> Vec<u8> {
393        let mut v = vec![0u8; (w as usize) * (h as usize) * 4];
394        for p in v.chunks_exact_mut(4) {
395            p[0] = bg.r;
396            p[1] = bg.g;
397            p[2] = bg.b;
398            p[3] = bg.a;
399        }
400        v
401    }
402
403    fn put(px: &mut [u8], x: i32, y: i32, w: u32, h: u32, c: Rgba) {
404        if x < 0 || y < 0 || x as u32 >= w || y as u32 >= h {
405            return;
406        }
407        let i = ((y as u32 * w + x as u32) * 4) as usize;
408        px[i] = c.r;
409        px[i + 1] = c.g;
410        px[i + 2] = c.b;
411        px[i + 3] = c.a;
412    }
413
414    fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
415        for y in y0..(y0 + rh).min(h) {
416            for x in x0..(x0 + rw).min(w) {
417                put(px, x as i32, y as i32, w, h, c);
418            }
419        }
420    }
421
422    /// Bresenham line (thin, 1-px) — used to draw spokes.
423    fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
424        let dx = (x1 - x0).abs();
425        let dy = -(y1 - y0).abs();
426        let sx = if x0 < x1 { 1 } else { -1 };
427        let sy = if y0 < y1 { 1 } else { -1 };
428        let mut err = dx + dy;
429        let (mut x, mut y) = (x0, y0);
430        loop {
431            put(px, x, y, w, h, c);
432            if x == x1 && y == y1 {
433                break;
434            }
435            let e2 = 2 * err;
436            if e2 >= dy {
437                err += dy;
438                x += sx;
439            }
440            if e2 <= dx {
441                err += dx;
442                y += sy;
443            }
444        }
445    }
446
447    /// A CLEAN frame: a few solid filled rectangles on the dark bg, centred.
448    fn clean_frame() -> Vec<u8> {
449        let mut f = frame(W, H, BG);
450        // Solid filled regions with real area (a clean render is compact fill, not
451        // perimeter): boundaries are thick, interiors lit → low thin-run fraction.
452        fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
453        fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
454        fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
455        f
456    }
457
458    /// A SMEARED frame: thin bright lines radiating from the centre to the edges
459    /// (the green-spoke starburst the live OSM-3D demo produced).
460    fn smeared_frame() -> Vec<u8> {
461        let mut f = frame(W, H, BG);
462        let (cx, cy) = (W as i32 / 2, H as i32 / 2);
463        let spoke = Rgba::rgb(60, 230, 90); // the smear green
464        // 24 spokes radiating to points around the frame border.
465        for k in 0..24 {
466            let a = std::f64::consts::TAU * k as f64 / 24.0;
467            let ex = (cx as f64 + a.cos() * 1000.0) as i32; // shoot well past the edge
468            let ey = (cy as f64 + a.sin() * 1000.0) as i32;
469            line(&mut f, cx, cy, ex, ey, W, H, spoke);
470        }
471        f
472    }
473
474    // ── the SENSITIVITY proof: clean vs smeared must be clearly separable ──
475
476    #[test]
477    fn clean_frame_scans_low_and_centred() {
478        let f = clean_frame();
479        let r = scan(&f, W, H, BG);
480        eprintln!("[imgscan] CLEAN  {r:?}");
481        // spoke + high-freq are LOW for compact filled regions.
482        assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
483        assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
484        // coverage in a sane band (not blank, not frame-filling).
485        assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
486        // centroid near image centre (the rects are clustered mid-frame).
487        assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
488        assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
489        assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
490    }
491
492    #[test]
493    fn smeared_frame_scans_high() {
494        let f = smeared_frame();
495        let r = scan(&f, W, H, BG);
496        eprintln!("[imgscan] SMEAR  {r:?}");
497        assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
498        assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
499    }
500
501    /// The oracle PROVES it can go red on the bug: there is a clear separating
502    /// threshold between clean and smeared for BOTH spoke_score and hf_ratio. If
503    /// this gap ever collapses, the oracle is blind and this test fails.
504    #[test]
505    fn clean_and_smeared_are_clearly_separated() {
506        let clean = scan(&clean_frame(), W, H, BG);
507        let smear = scan(&smeared_frame(), W, H, BG);
508        eprintln!(
509            "[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
510            clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
511        );
512        // spoke_score: pick a threshold strictly between the two and assert it splits.
513        let t_spoke = 0.25;
514        assert!(
515            clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
516            "spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
517            clean.spoke_score,
518            smear.spoke_score
519        );
520        // high_freq_ratio: a threshold strictly between the two.
521        let t_hf = 0.30;
522        assert!(
523            clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
524            "hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
525            clean.high_freq_ratio,
526            smear.high_freq_ratio
527        );
528        // And the gaps are comfortably wide (not a knife-edge that flips on noise).
529        assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
530        assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
531    }
532
533    // ── min / mid / max boundary coverage ──
534
535    #[test]
536    fn blank_frame_is_all_zero() {
537        let f = frame(W, H, BG);
538        let r = scan(&f, W, H, BG);
539        assert_eq!(r.spoke_score, 0.0, "blank: no edges");
540        assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
541        assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
542        assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
543        assert!(r.bbox.is_empty(), "blank: empty bbox");
544    }
545
546    #[test]
547    fn fully_filled_frame_is_full_coverage_low_freq() {
548        // Every pixel a single solid non-bg colour: coverage ≈ 1, no internal
549        // structure → no edges, no high-freq energy.
550        let f = frame(W, H, Rgba::rgb(200, 30, 30));
551        let r = scan(&f, W, H, BG);
552        assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
553        assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
554        assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
555        // bbox spans the whole frame; centroid at the centre.
556        assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
557        assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
558        assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
559    }
560
561    #[test]
562    fn single_pixel_image_does_not_panic() {
563        // 1×1 is below every analyzer's window; they must degrade gracefully to 0,
564        // never index out of bounds.
565        let mut f = frame(1, 1, BG);
566        let r = scan(&f, 1, 1, BG);
567        assert_eq!(r.coverage, 0.0);
568        assert_eq!(r.spoke_score, 0.0);
569        // a single lit pixel ⇒ coverage 1, centroid at (0,0), 1×1 bbox.
570        put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
571        let r2 = scan(&f, 1, 1, BG);
572        assert!((r2.coverage - 1.0).abs() < 1e-9);
573        assert_eq!(r2.centroid, (0.0, 0.0));
574        assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
575    }
576
577    #[test]
578    fn corner_flung_geometry_moves_the_centroid() {
579        // Geometry in the top-left corner ⇒ centroid far from centre (the "flung to
580        // a corner" bug the law calls out).
581        let mut f = frame(W, H, BG);
582        fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
583        let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
584        assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
585    }
586
587    #[test]
588    fn coverage_counts_only_above_threshold() {
589        // A near-bg colour (within the AA threshold) must NOT count as painted.
590        let mut f = frame(W, H, BG);
591        fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
592        assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
593    }
594
595    #[test]
596    fn bad_dims_degrade_gracefully() {
597        // A buffer too short for the claimed dims ⇒ analyzers return the empty read,
598        // never panic.
599        let f = vec![0u8; 16]; // claims 100×100 but is 16 bytes
600        assert_eq!(coverage(&f, 100, 100, BG), 0.0);
601        assert_eq!(spoke_score(&f, 100, 100), 0.0);
602        assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
603        let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
604        assert_eq!(c, (0.0, 0.0));
605        assert!(b.is_empty());
606    }
607}