Skip to main content

long_screen/kernels/reveal/
model.rs

1//! Overlay objects of one layer, learned from its [`LayerStats`].
2//!
3//! Classification runs on 3×3-cell pooled sums so a cell is judged by its neighbourhood (overlays are coherent
4//! regions; single cells over rarely-textured page are noisy). A cell is opaque when it disagrees with the page
5//! in most comparisons or when the page-to-observation slope is confidently ≈ 0; it is a veil when the slope is
6//! confidently below 1. Connected opaque cells (closed, then grown by one cell to take their anti-aliased edge)
7//! form objects that are tracked per frame; connected veil cells form objects that are not tracked (a veil has
8//! no stable appearance of its own) and only ever lower the tier of what is seen through them.
9
10use super::stats::LayerStats;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Kind {
14    Opaque,
15    Veil,
16}
17
18#[derive(Clone, Debug)]
19pub struct Object {
20    pub kind: Kind,
21    /// Bounding box in cells, relative to the layer's cell grid.
22    pub bx: usize,
23    pub by: usize,
24    pub bw: usize,
25    pub bh: usize,
26    /// Cells of the object inside its box.
27    pub mask: Vec<bool>,
28    /// Template: mean appearance of each cell when it disagreed with the page.
29    pub tpl: Vec<[u8; 3]>,
30    /// Cells whose appearance is stable (low spread when present); matching uses only these.
31    pub stable: Vec<u32>,
32    /// A thinned subset of `stable` for coarse searches.
33    pub coarse: Vec<u32>,
34    /// Enough stable edges for a shifted match to be meaningful (a flat template would match any flat area).
35    pub textured: bool,
36    /// Offset (cells) where the object was last found.
37    pub last: (i32, i32),
38    /// A uniformly coloured object (a scrollbar thumb, a flat bar): its colour. Its learned mask is smeared over
39    /// everywhere it travelled, so it is found per frame by colour inside its zone instead of by template.
40    pub flat: Option<[u8; 3]>,
41}
42
43impl Object {
44    pub fn area(&self) -> usize {
45        self.mask.iter().filter(|&&m| m).count()
46    }
47
48    /// The axis an elongated object may travel along (a scrollbar thumb's track), if any.
49    pub fn track(&self) -> Track {
50        if self.bh >= 3 * self.bw {
51            Track::Vertical
52        } else if self.bw >= 3 * self.bh {
53            Track::Horizontal
54        } else {
55            Track::None
56        }
57    }
58
59    /// Cells where this object may appear in a frame: its box, run along the long axis to the layer edge when
60    /// it is elongated (a scrollbar thumb travels its whole track). `(x0, x1, y0, y1)`, half-open, in cells.
61    pub fn zone(&self, cw: usize, ch: usize) -> (usize, usize, usize, usize) {
62        let (bx0, bx1) = (self.bx.saturating_sub(1), (self.bx + self.bw + 1).min(cw));
63        let (by0, by1) = (self.by.saturating_sub(1), (self.by + self.bh + 1).min(ch));
64        match self.track() {
65            Track::Vertical => (bx0, bx1, 0, ch),
66            Track::Horizontal => (0, cw, by0, by1),
67            Track::None => (bx0, bx1, by0, by1),
68        }
69    }
70}
71
72/// See [`Object::track`].
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum Track {
75    None,
76    Vertical,
77    Horizontal,
78}
79
80impl LayerModel {
81    /// Cells inside any opaque object's zone: the only place an overlay can fade or move, so the only place a
82    /// still-changing pixel at the end of a static run is suspect (elsewhere it is page content still loading).
83    pub fn overlay_zone(&self) -> Vec<bool> {
84        let mut z = vec![false; self.cw * self.ch];
85        for o in self.objects.iter().filter(|o| o.kind == Kind::Opaque) {
86            let (x0, x1, y0, y1) = o.zone(self.cw, self.ch);
87            for y in y0..y1 {
88                for x in x0..x1 {
89                    z[y * self.cw + x] = true;
90                }
91            }
92        }
93        z
94    }
95}
96
97#[derive(Clone, Debug)]
98pub struct LayerModel {
99    pub x: i64,
100    pub y: i64,
101    pub cw: usize,
102    pub ch: usize,
103    pub objects: Vec<Object>,
104}
105
106/// Minimum comparisons for a cell to be classified at all.
107const ENOUGH: u16 = 8;
108const POOL: usize = 1; // radius in cells: 3×3
109
110fn pooled(v: &[u32], cw: usize, ch: usize) -> Vec<u64> {
111    // Separable box sum with radius POOL, zero outside.
112    let mut rows = vec![0u64; cw * ch];
113    for y in 0..ch {
114        for x in 0..cw {
115            let (a, b) = (x.saturating_sub(POOL), (x + POOL).min(cw - 1));
116            rows[y * cw + x] = (a..=b).map(|i| v[y * cw + i] as u64).sum();
117        }
118    }
119    let mut out = vec![0u64; cw * ch];
120    for y in 0..ch {
121        let (a, b) = (y.saturating_sub(POOL), (y + POOL).min(ch - 1));
122        for x in 0..cw {
123            out[y * cw + x] = (a..=b).map(|j| rows[j * cw + x]).sum();
124        }
125    }
126    out
127}
128
129fn dilate(m: &[bool], cw: usize, ch: usize) -> Vec<bool> {
130    let mut out = vec![false; m.len()];
131    for y in 0..ch {
132        for x in 0..cw {
133            if !m[y * cw + x] {
134                continue;
135            }
136            for yy in y.saturating_sub(1)..=(y + 1).min(ch - 1) {
137                for xx in x.saturating_sub(1)..=(x + 1).min(cw - 1) {
138                    out[yy * cw + xx] = true;
139                }
140            }
141        }
142    }
143    out
144}
145
146fn erode(m: &[bool], cw: usize, ch: usize) -> Vec<bool> {
147    let inv: Vec<bool> = m.iter().map(|&b| !b).collect();
148    dilate(&inv, cw, ch).iter().map(|&b| !b).collect()
149}
150
151/// 8-connected components; returns (label per cell, 0 = none) and component count.
152fn components(m: &[bool], cw: usize, ch: usize) -> (Vec<u32>, u32) {
153    let mut label = vec![0u32; m.len()];
154    let mut next = 0;
155    let mut stack = Vec::new();
156    for start in 0..m.len() {
157        if !m[start] || label[start] != 0 {
158            continue;
159        }
160        next += 1;
161        label[start] = next;
162        stack.push(start);
163        while let Some(i) = stack.pop() {
164            let (x, y) = (i % cw, i / cw);
165            for yy in y.saturating_sub(1)..=(y + 1).min(ch - 1) {
166                for xx in x.saturating_sub(1)..=(x + 1).min(cw - 1) {
167                    let j = yy * cw + xx;
168                    if m[j] && label[j] == 0 {
169                        label[j] = next;
170                        stack.push(j);
171                    }
172                }
173            }
174        }
175    }
176    (label, next)
177}
178
179/// Builds the layer's overlay objects. `tau` is the per-channel disagreement threshold.
180pub fn build(stats: &LayerStats, tau: i32) -> LayerModel {
181    let (cw, ch) = (stats.cw, stats.ch);
182    let len = cw * ch;
183    let n = pooled(&stats.field(|a| a.n as u32), cw, ch);
184    let bad = pooled(&stats.field(|a| a.bad as u32), cw, ch);
185    let sr = pooled(&stats.field(|a| a.sr), cw, ch);
186    let so = pooled(&stats.field(|a| a.so), cw, ch);
187    let srr = pooled(&stats.field(|a| a.srr), cw, ch);
188    let sro = pooled(&stats.field(|a| a.sro), cw, ch);
189    let soo = pooled(&stats.field(|a| a.soo), cw, ch);
190    let mut opaque = vec![false; len];
191    let mut veil = vec![false; len];
192    for i in 0..len {
193        if stats.cells[i].n < ENOUGH {
194            continue;
195        }
196        let nn = n[i].max(1) as f64;
197        let frac = bad[i] as f64 / nn;
198        let (mr, mo) = (sr[i] as f64 / nn, so[i] as f64 / nn);
199        let vr = srr[i] as f64 / nn - mr * mr;
200        let cov = sro[i] as f64 / nn - mr * mo;
201        let vo = soo[i] as f64 / nn - mo * mo;
202        // A slope is meaningful only when reference contrast exceeds the same noise floor
203        // used for agreeing source views. Otherwise quantization or burned-in faint ghosts
204        // on a flat reference would make a clean, flat observation look opaque.
205        let noise = (tau / 4).max(2) as f64;
206        let hi = if vr > noise * noise {
207            let slope = cov / vr;
208            let resid = (vo - cov * cov / vr).max(0.0);
209            let se = ((resid + 4.0) / (nn * vr)).sqrt();
210            slope + 2.0 * se
211        } else {
212            f64::INFINITY
213        };
214        // A misplaced or differently sampled reference can lose covariance without losing contrast.
215        // A low slope needs independent contrast or brightness evidence to establish an overlay.
216        let opaque_gain = 0.15;
217        let veil_gain = 0.85;
218        // A moving/animated opaque replacement can add its own variance. Repeated visible disagreements
219        // support that candidate even without contrast loss; sub-noise decorrelation alone does not.
220        let repeated_disagreement = stats.cells[i].bad >= 4;
221        if frac > 0.3
222            || (hi < opaque_gain && (vo < vr * opaque_gain * opaque_gain || repeated_disagreement))
223        {
224            opaque[i] = true;
225        } else if hi < veil_gain {
226            // Switching a translucent control on and off can increase total variance. Its
227            // mean brightness shift is independent evidence; registration phase alone has
228            // no consistent signed shift. Use the local view count, not pooled pixels,
229            // when bounding uncertainty: neighbouring samples are not independent views.
230            let difference_variance = (vr + vo - 2.0 * cov).max(0.0);
231            let mean_error = (difference_variance / stats.cells[i].n as f64).sqrt();
232            let shifted = repeated_disagreement && (mo - mr).abs() > noise + 2.0 * mean_error;
233            if vo < vr * veil_gain * veil_gain || shifted {
234                veil[i] = true;
235            }
236        }
237    }
238    let enough: Vec<bool> = stats.cells.iter().map(|a| a.n >= ENOUGH).collect();
239    let core: Vec<bool> = erode(&dilate(&opaque, cw, ch), cw, ch)
240        .iter()
241        .zip(&enough)
242        .map(|(&a, &b)| a && b)
243        .collect();
244    let grown: Vec<bool> = dilate(&core, cw, ch)
245        .iter()
246        .zip(&enough)
247        .map(|(&a, &b)| a && b)
248        .collect();
249    let veil: Vec<bool> = erode(&dilate(&veil, cw, ch), cw, ch)
250        .iter()
251        .zip(&enough)
252        .zip(&grown)
253        .map(|((&a, &b), &g)| a && b && !g)
254        .collect();
255    let mut objects = Vec::new();
256    for (kind, mask) in [(Kind::Opaque, &grown), (Kind::Veil, &veil)] {
257        let (label, count) = components(mask, cw, ch);
258        let mut boxes = vec![(usize::MAX, usize::MAX, 0usize, 0usize, 0usize); count as usize];
259        for (i, &l) in label.iter().enumerate() {
260            if l == 0 {
261                continue;
262            }
263            let b = &mut boxes[l as usize - 1];
264            let (x, y) = (i % cw, i / cw);
265            b.0 = b.0.min(x);
266            b.1 = b.1.min(y);
267            b.2 = b.2.max(x);
268            b.3 = b.3.max(y);
269            b.4 += 1;
270        }
271        for (k, &(x0, y0, x1, y1, area)) in boxes.iter().enumerate() {
272            // Three cells (12 native pixels) is below any overlay worth tracking and mostly codec speckle.
273            if area < 3 {
274                continue;
275            }
276            let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1);
277            let mut m = vec![false; bw * bh];
278            let mut tpl = vec![[0u8; 3]; bw * bh];
279            let mut stable = Vec::new();
280            for yy in 0..bh {
281                for xx in 0..bw {
282                    let i = (y0 + yy) * cw + x0 + xx;
283                    if label[i] != k as u32 + 1 {
284                        continue;
285                    }
286                    let j = yy * bw + xx;
287                    m[j] = true;
288                    let a = stats.cells[i];
289                    let nb = a.bad as u32;
290                    if nb == 0 {
291                        continue;
292                    }
293                    tpl[j] = [
294                        ((a.br + nb / 2) / nb) as u8,
295                        ((a.bg + nb / 2) / nb) as u8,
296                        ((a.bb + nb / 2) / nb) as u8,
297                    ];
298                    let ml = a.bl as f64 / nb as f64;
299                    let spread = (a.bll as f64 / nb as f64 - ml * ml).max(0.0).sqrt();
300                    // Present often enough to know its look, and the look is consistent.
301                    if kind == Kind::Opaque && core[i] && nb >= 4 && spread <= tau as f64 / 2.0 {
302                        stable.push(j as u32);
303                    }
304                }
305            }
306            let edges = stable
307                .iter()
308                .filter(|&&j| {
309                    let j = j as usize;
310                    let (xx, yy) = (j % bw, j / bw);
311                    let l = |a: usize| {
312                        let t = tpl[a];
313                        super::luma(t[0], t[1], t[2]) as i32
314                    };
315                    let c = l(j);
316                    (xx > 0 && (c - l(j - 1)).abs() > 16) || (yy > 0 && (c - l(j - bw)).abs() > 16)
317                })
318                .count();
319            let step = (stable.len() / 2000).max(1);
320            let coarse = stable.iter().step_by(step).copied().collect();
321            // Dominant uniform colour: most stable cells within tolerance of their median colour.
322            let flat = (stable.len() >= 4)
323                .then(|| {
324                    let mut ls: Vec<(u32, [u8; 3])> = stable
325                        .iter()
326                        .map(|&j| {
327                            let t = tpl[j as usize];
328                            (super::luma(t[0], t[1], t[2]), t)
329                        })
330                        .collect();
331                    ls.sort_by_key(|p| p.0);
332                    let med = ls[ls.len() / 2].1;
333                    let near = ls
334                        .iter()
335                        .filter(|(_, t)| (0..3).all(|k| (t[k] as i32 - med[k] as i32).abs() <= tau))
336                        .count();
337                    (near * 10 >= ls.len() * 6).then_some(med)
338                })
339                .flatten();
340            objects.push(Object {
341                kind,
342                bx: x0,
343                by: y0,
344                bw,
345                bh,
346                mask: m,
347                tpl,
348                textured: edges >= 10,
349                stable,
350                coarse,
351                last: (0, 0),
352                flat,
353            });
354        }
355    }
356    LayerModel {
357        x: stats.x,
358        y: stats.y,
359        cw,
360        ch,
361        objects,
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::kernels::reveal::locate::tiers;
369    use crate::kernels::reveal::stats::CellRef;
370    use crate::kernels::reveal::{Footprint, Frame, TIER_CLEAN};
371
372    struct Page(u8);
373    impl CellRef for Page {
374        fn cell(&self, _: i64, _: i64) -> Option<[u8; 3]> {
375            Some([self.0; 3])
376        }
377        fn cells(&self) -> (i64, i64, i64, i64) {
378            (0, 0, 48, 32)
379        }
380    }
381
382    fn button(page: u8, ink: u8) -> Vec<u8> {
383        let mut rgba = vec![255; 96 * 64 * 4];
384        for y in 0..64 {
385            for x in 0..96 {
386                let v = if (16..80).contains(&x) && (16..48).contains(&y) {
387                    if (32..64).contains(&x) && (24..40).contains(&y) {
388                        ink
389                    } else {
390                        240
391                    }
392                } else {
393                    page
394                };
395                let i = (y * 96 + x) * 4;
396                rgba[i..i + 3].fill(v);
397            }
398        }
399        rgba
400    }
401
402    // A button has a mostly uniform background but also its own visible label. If that label changes, the
403    // old template no longer locates it; matching only its background must not certify the new label as page.
404    #[test]
405    fn a_changed_label_on_an_opaque_button_is_not_clean_page_content() {
406        let mut stats = LayerStats::new(0, 0, 96, 64);
407        let mask = vec![1; 96 * 64];
408        let footprint = Footprint {
409            x: 0,
410            y: 0,
411            width: 96,
412            height: 64,
413            mask: &mask,
414        };
415        for background in (0..=240).step_by(16) {
416            let rgba = button(background, 0);
417            stats.accumulate_cells(
418                &Frame {
419                    rgba: &rgba,
420                    width: 96,
421                    height: 64,
422                },
423                &footprint,
424                &Page(background),
425                (0, 0),
426                24,
427            );
428        }
429        let mut model = build(&stats, 24);
430        let rgba = button(128, 80);
431        let tier = tiers(
432            &Frame {
433                rgba: &rgba,
434                width: 96,
435                height: 64,
436            },
437            &mut model,
438            24,
439        );
440        for y in 10..22 {
441            for x in 10..38 {
442                assert!(
443                    tier[y * 48 + x] < TIER_CLEAN,
444                    "button cell ({x}, {y}) treated as page"
445                );
446            }
447        }
448        assert_eq!(tier[0], TIER_CLEAN);
449    }
450    #[test]
451    fn decorrelation_without_contrast_loss_is_not_attenuation() {
452        struct Pattern;
453        const VALUES: [u8; 6] = [90, 99, 99, 90, 81, 81];
454        impl CellRef for Pattern {
455            fn cell(&self, x: i64, _y: i64) -> Option<[u8; 3]> {
456                Some([VALUES[x.rem_euclid(6) as usize]; 3])
457            }
458            fn cells(&self) -> (i64, i64, i64, i64) {
459                (-1000, -1000, 10000, 10000)
460            }
461        }
462        let (width, height) = (96, 64);
463        let mask = vec![1; width * height];
464        let fp = Footprint {
465            x: 0,
466            y: 0,
467            width,
468            height,
469            mask: &mask,
470        };
471        let model_for = |phase: usize, attenuate: bool, opaque: bool| {
472            let mut stats = LayerStats::new(0, 0, width, height);
473            for step in 0..48 {
474                let mut rgba = vec![255; width * height * 4];
475                for y in 0..height {
476                    for x in 0..width {
477                        let value = VALUES[(x / 2 + step + phase) % 6];
478                        let value = if opaque {
479                            90
480                        } else if attenuate {
481                            ((value as u16 + 90) / 2) as u8
482                        } else {
483                            value
484                        };
485                        rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(value);
486                    }
487                }
488                let frame = Frame {
489                    rgba: &rgba,
490                    width,
491                    height,
492                };
493                stats.accumulate_cells(&frame, &fp, &Pattern, ((step * 2) as i64, 0), 24);
494            }
495            build(&stats, 24)
496        };
497        // A translated copy has identical contrast but lower covariance. It is registration uncertainty,
498        // not a screen-fixed veil or opaque object; every difference stays below decode tolerance.
499        for phase in [1, 2] {
500            assert!(
501                model_for(phase, false, false).objects.is_empty(),
502                "a shifted same-contrast pattern cannot establish an overlay"
503            );
504        }
505        assert!(
506            model_for(0, true, false)
507                .objects
508                .iter()
509                .any(|o| o.kind == Kind::Veil),
510            "true attenuation remains detectable"
511        );
512        assert!(
513            model_for(0, false, true)
514                .objects
515                .iter()
516                .any(|o| o.kind == Kind::Opaque),
517            "a truly fixed replacement remains detectable"
518        );
519    }
520    #[test]
521    fn a_moving_replacement_can_keep_or_raise_the_observed_variance() {
522        // A rendered reference can already contain sparse burned-in copies of a moving thumb.
523        // The real thumb has repeated above-noise appearances but is not an attenuated copy of that reference.
524        struct BurnedReference;
525        impl CellRef for BurnedReference {
526            fn cell(&self, x: i64, _y: i64) -> Option<[u8; 3]> {
527                Some([if x.rem_euclid(20) == 0 { 170 } else { 90 }; 3])
528            }
529            fn cells(&self) -> (i64, i64, i64, i64) {
530                (-1000, -1000, 10000, 10000)
531            }
532        }
533        let (width, height) = (96, 64);
534        let mask = vec![1; width * height];
535        let fp = Footprint {
536            x: 0,
537            y: 0,
538            width,
539            height,
540            mask: &mask,
541        };
542        let mut stats = LayerStats::new(0, 0, width, height);
543        for step in 0..60 {
544            let mut rgba = vec![255; width * height * 4];
545            for y in 0..height {
546                for x in 0..width {
547                    let phase = (x / 2 + step) % 20;
548                    let v = if phase == 5 || phase == 6 { 170 } else { 90 };
549                    rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(v);
550                }
551            }
552            stats.accumulate_cells(
553                &Frame {
554                    rgba: &rgba,
555                    width,
556                    height,
557                },
558                &fp,
559                &BurnedReference,
560                ((step * 2) as i64, 0),
561                24,
562            );
563        }
564        let model = build(&stats, 24);
565        assert!(
566            model.objects.iter().any(|o| o.kind == Kind::Opaque),
567            "replacement appearance is not required to lose variance"
568        );
569    }
570    #[test]
571    fn an_intermittent_veil_can_raise_variance_without_being_an_opaque_replacement() {
572        // Native sampling phase changes contrast locally. A pale control is present in one fifth
573        // of observations: switching it on and off increases total variance, despite transmission loss.
574        const VALUES: [u8; 6] = [30, 40, 60, 70, 60, 40];
575        struct Pattern;
576        impl CellRef for Pattern {
577            fn cell(&self, x: i64, _y: i64) -> Option<[u8; 3]> {
578                Some([VALUES[x.rem_euclid(6) as usize]; 3])
579            }
580            fn cells(&self) -> (i64, i64, i64, i64) {
581                (-1000, -1000, 10000, 10000)
582            }
583        }
584        let (width, height) = (48, 32);
585        let mask = vec![1; width * height];
586        let footprint = Footprint {
587            x: 0,
588            y: 0,
589            width,
590            height,
591            mask: &mask,
592        };
593        for control in [false, true] {
594            let mut stats = LayerStats::new(0, 0, width, height);
595            for step in 0..120 {
596                let mut rgba = vec![255; width * height * 4];
597                for y in 0..height {
598                    for x in 0..width {
599                        let background = VALUES[(x / 2 + step + 1) % VALUES.len()];
600                        let value = if control && step % 5 == 0 {
601                            ((background as u16 + 230) / 2) as u8
602                        } else {
603                            background
604                        };
605                        rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(value);
606                    }
607                }
608                stats.accumulate_cells(
609                    &Frame {
610                        rgba: &rgba,
611                        width,
612                        height,
613                    },
614                    &footprint,
615                    &Pattern,
616                    ((step * 2) as i64, 0),
617                    24,
618                );
619            }
620            let model = build(&stats, 24);
621            if control {
622                assert!(
623                    model.objects.iter().any(|o| o.kind == Kind::Veil),
624                    "a repeatedly observed translucent control must remain suspect"
625                );
626                assert!(
627                    !model.objects.iter().any(|o| o.kind == Kind::Opaque),
628                    "intermittent transmission is not fixed opaque replacement"
629                );
630            } else {
631                assert!(
632                    model.objects.is_empty(),
633                    "phase noise alone does not establish an overlay"
634                );
635            }
636        }
637    }
638
639    #[test]
640    fn a_flat_page_is_not_an_overlay_of_subnoise_reference_variation() {
641        // The rendered reference can contain a few quantization levels of variation on a
642        // flat page margin. Zero observed variance is not evidence of an opaque control
643        // when the reference contrast itself lies inside the measured noise tolerance.
644        struct NoisyWhite;
645        impl CellRef for NoisyWhite {
646            fn cell(&self, x: i64, _y: i64) -> Option<[u8; 3]> {
647                const VALUES: [u8; 6] = [250, 253, 255, 253, 250, 247];
648                Some([VALUES[x.rem_euclid(6) as usize]; 3])
649            }
650            fn cells(&self) -> (i64, i64, i64, i64) {
651                (-1000, -1000, 10000, 10000)
652            }
653        }
654        let (width, height) = (48, 32);
655        let mask = vec![1; width * height];
656        let footprint = Footprint {
657            x: 0,
658            y: 0,
659            width,
660            height,
661            mask: &mask,
662        };
663        let mut rgba = vec![252; width * height * 4];
664        for pixel in rgba.chunks_exact_mut(4) {
665            pixel[3] = 255;
666        }
667        let frame = Frame {
668            rgba: &rgba,
669            width,
670            height,
671        };
672        let mut stats = LayerStats::new(0, 0, width, height);
673        for step in 0..120 {
674            stats.accumulate_cells(&frame, &footprint, &NoisyWhite, (step * 2, 0), 24);
675        }
676        assert!(
677            build(&stats, 24).objects.is_empty(),
678            "noise in the reference cannot make clean page pixels opaque"
679        );
680    }
681}