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.as_chunks_mut::<4>().0 {
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    #[allow(clippy::too_many_arguments)] // pixel helper: buffer, rect, extent, colour
415    fn fill_rect(px: &mut [u8], x0: u32, y0: u32, rw: u32, rh: u32, w: u32, h: u32, c: Rgba) {
416        for y in y0..(y0 + rh).min(h) {
417            for x in x0..(x0 + rw).min(w) {
418                put(px, x as i32, y as i32, w, h, c);
419            }
420        }
421    }
422
423    /// Bresenham line (thin, 1-px) — used to draw spokes.
424    #[allow(clippy::too_many_arguments)] // pixel helper: buffer, segment, extent, colour
425    fn line(px: &mut [u8], x0: i32, y0: i32, x1: i32, y1: i32, w: u32, h: u32, c: Rgba) {
426        let dx = (x1 - x0).abs();
427        let dy = -(y1 - y0).abs();
428        let sx = if x0 < x1 { 1 } else { -1 };
429        let sy = if y0 < y1 { 1 } else { -1 };
430        let mut err = dx + dy;
431        let (mut x, mut y) = (x0, y0);
432        loop {
433            put(px, x, y, w, h, c);
434            if x == x1 && y == y1 {
435                break;
436            }
437            let e2 = 2 * err;
438            if e2 >= dy {
439                err += dy;
440                x += sx;
441            }
442            if e2 <= dx {
443                err += dx;
444                y += sy;
445            }
446        }
447    }
448
449    /// A CLEAN frame: a few solid filled rectangles on the dark bg, centred.
450    fn clean_frame() -> Vec<u8> {
451        let mut f = frame(W, H, BG);
452        // Solid filled regions with real area (a clean render is compact fill, not
453        // perimeter): boundaries are thick, interiors lit → low thin-run fraction.
454        fill_rect(&mut f, 40, 40, 70, 70, W, H, Rgba::rgb(220, 80, 80));
455        fill_rect(&mut f, 100, 50, 60, 90, W, H, Rgba::rgb(80, 200, 120));
456        fill_rect(&mut f, 60, 110, 90, 60, W, H, Rgba::rgb(90, 140, 230));
457        f
458    }
459
460    /// A SMEARED frame: thin bright lines radiating from the centre to the edges
461    /// (the green-spoke starburst the live OSM-3D demo produced).
462    fn smeared_frame() -> Vec<u8> {
463        let mut f = frame(W, H, BG);
464        let (cx, cy) = (W as i32 / 2, H as i32 / 2);
465        let spoke = Rgba::rgb(60, 230, 90); // the smear green
466        // 24 spokes radiating to points around the frame border.
467        for k in 0..24 {
468            let a = std::f64::consts::TAU * k as f64 / 24.0;
469            let ex = (cx as f64 + a.cos() * 1000.0) as i32; // shoot well past the edge
470            let ey = (cy as f64 + a.sin() * 1000.0) as i32;
471            line(&mut f, cx, cy, ex, ey, W, H, spoke);
472        }
473        f
474    }
475
476    // ── the SENSITIVITY proof: clean vs smeared must be clearly separable ──
477
478    #[test]
479    fn clean_frame_scans_low_and_centred() {
480        let f = clean_frame();
481        let r = scan(&f, W, H, BG);
482        eprintln!("[imgscan] CLEAN  {r:?}");
483        // spoke + high-freq are LOW for compact filled regions.
484        assert!(r.spoke_score < 0.30, "clean spoke_score should be low: {}", r.spoke_score);
485        assert!(r.high_freq_ratio < 0.30, "clean hf_ratio should be low: {}", r.high_freq_ratio);
486        // coverage in a sane band (not blank, not frame-filling).
487        assert!(r.coverage > 0.02 && r.coverage < 0.45, "clean coverage band: {}", r.coverage);
488        // centroid near image centre (the rects are clustered mid-frame).
489        assert!((r.centroid.0 - W as f64 / 2.0).abs() < W as f64 * 0.25, "cx near centre: {:?}", r.centroid);
490        assert!((r.centroid.1 - H as f64 / 2.0).abs() < H as f64 * 0.25, "cy near centre: {:?}", r.centroid);
491        assert!(!r.bbox.is_empty(), "clean frame has a real bbox");
492    }
493
494    #[test]
495    fn smeared_frame_scans_high() {
496        let f = smeared_frame();
497        let r = scan(&f, W, H, BG);
498        eprintln!("[imgscan] SMEAR  {r:?}");
499        assert!(r.spoke_score > 0.45, "smear spoke_score should be high: {}", r.spoke_score);
500        assert!(r.high_freq_ratio > 0.30, "smear hf_ratio should be high: {}", r.high_freq_ratio);
501    }
502
503    /// The oracle PROVES it can go red on the bug: there is a clear separating
504    /// threshold between clean and smeared for BOTH spoke_score and hf_ratio. If
505    /// this gap ever collapses, the oracle is blind and this test fails.
506    #[test]
507    fn clean_and_smeared_are_clearly_separated() {
508        let clean = scan(&clean_frame(), W, H, BG);
509        let smear = scan(&smeared_frame(), W, H, BG);
510        eprintln!(
511            "[imgscan] SEPARATION spoke: clean={:.3} smear={:.3} | hf: clean={:.3} smear={:.3}",
512            clean.spoke_score, smear.spoke_score, clean.high_freq_ratio, smear.high_freq_ratio
513        );
514        // spoke_score: pick a threshold strictly between the two and assert it splits.
515        let t_spoke = 0.25;
516        assert!(
517            clean.spoke_score < t_spoke && t_spoke < smear.spoke_score,
518            "spoke_score must split at {t_spoke}: clean={} < {t_spoke} < smear={}",
519            clean.spoke_score,
520            smear.spoke_score
521        );
522        // high_freq_ratio: a threshold strictly between the two.
523        let t_hf = 0.30;
524        assert!(
525            clean.high_freq_ratio < t_hf && t_hf < smear.high_freq_ratio,
526            "hf_ratio must split at {t_hf}: clean={} < {t_hf} < smear={}",
527            clean.high_freq_ratio,
528            smear.high_freq_ratio
529        );
530        // And the gaps are comfortably wide (not a knife-edge that flips on noise).
531        assert!(smear.spoke_score - clean.spoke_score > 0.25, "spoke gap wide enough");
532        assert!(smear.high_freq_ratio - clean.high_freq_ratio > 0.10, "hf gap wide enough");
533    }
534
535    // ── min / mid / max boundary coverage ──
536
537    #[test]
538    fn blank_frame_is_all_zero() {
539        let f = frame(W, H, BG);
540        let r = scan(&f, W, H, BG);
541        assert_eq!(r.spoke_score, 0.0, "blank: no edges");
542        assert_eq!(r.high_freq_ratio, 0.0, "blank: no variation");
543        assert_eq!(r.coverage, 0.0, "blank: nothing differs from bg");
544        assert_eq!(r.centroid, (0.0, 0.0), "blank: no centroid");
545        assert!(r.bbox.is_empty(), "blank: empty bbox");
546    }
547
548    #[test]
549    fn fully_filled_frame_is_full_coverage_low_freq() {
550        // Every pixel a single solid non-bg colour: coverage ≈ 1, no internal
551        // structure → no edges, no high-freq energy.
552        let f = frame(W, H, Rgba::rgb(200, 30, 30));
553        let r = scan(&f, W, H, BG);
554        assert!((r.coverage - 1.0).abs() < 1e-9, "fully filled: coverage == 1: {}", r.coverage);
555        assert_eq!(r.spoke_score, 0.0, "uniform fill: no thin lines");
556        assert_eq!(r.high_freq_ratio, 0.0, "uniform fill: no high-freq energy");
557        // bbox spans the whole frame; centroid at the centre.
558        assert_eq!(r.bbox, BBox { min_x: 0, min_y: 0, max_x: W, max_y: H });
559        assert!((r.centroid.0 - (W as f64 - 1.0) / 2.0).abs() < 1.0);
560        assert!((r.centroid.1 - (H as f64 - 1.0) / 2.0).abs() < 1.0);
561    }
562
563    #[test]
564    fn single_pixel_image_does_not_panic() {
565        // 1×1 is below every analyzer's window; they must degrade gracefully to 0,
566        // never index out of bounds.
567        let mut f = frame(1, 1, BG);
568        let r = scan(&f, 1, 1, BG);
569        assert_eq!(r.coverage, 0.0);
570        assert_eq!(r.spoke_score, 0.0);
571        // a single lit pixel ⇒ coverage 1, centroid at (0,0), 1×1 bbox.
572        put(&mut f, 0, 0, 1, 1, Rgba::rgb(255, 255, 255));
573        let r2 = scan(&f, 1, 1, BG);
574        assert!((r2.coverage - 1.0).abs() < 1e-9);
575        assert_eq!(r2.centroid, (0.0, 0.0));
576        assert_eq!(r2.bbox, BBox { min_x: 0, min_y: 0, max_x: 1, max_y: 1 });
577    }
578
579    #[test]
580    fn corner_flung_geometry_moves_the_centroid() {
581        // Geometry in the top-left corner ⇒ centroid far from centre (the "flung to
582        // a corner" bug the law calls out).
583        let mut f = frame(W, H, BG);
584        fill_rect(&mut f, 0, 0, 20, 20, W, H, Rgba::rgb(240, 240, 240));
585        let (cx, cy) = painted_centroid_and_bbox(&f, W, H, BG).0;
586        assert!(cx < W as f64 * 0.2 && cy < H as f64 * 0.2, "centroid in the corner: ({cx},{cy})");
587    }
588
589    #[test]
590    fn coverage_counts_only_above_threshold() {
591        // A near-bg colour (within the AA threshold) must NOT count as painted.
592        let mut f = frame(W, H, BG);
593        fill_rect(&mut f, 10, 10, 50, 50, W, H, Rgba::rgb(BG.r + 5, BG.g + 5, BG.b + 5));
594        assert_eq!(coverage(&f, W, H, BG), 0.0, "sub-threshold delta is not coverage");
595    }
596
597    #[test]
598    fn bad_dims_degrade_gracefully() {
599        // A buffer too short for the claimed dims ⇒ analyzers return the empty read,
600        // never panic.
601        let f = vec![0u8; 16]; // claims 100×100 but is 16 bytes
602        assert_eq!(coverage(&f, 100, 100, BG), 0.0);
603        assert_eq!(spoke_score(&f, 100, 100), 0.0);
604        assert_eq!(high_freq_ratio(&f, 100, 100), 0.0);
605        let (c, b) = painted_centroid_and_bbox(&f, 100, 100, BG);
606        assert_eq!(c, (0.0, 0.0));
607        assert!(b.is_empty());
608    }
609}