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    /// Cells where this object may appear in a frame: its box, run along the long axis to the layer edge when
49    /// it is elongated (a scrollbar thumb travels its whole track). `(x0, x1, y0, y1)`, half-open, in cells.
50    pub fn zone(&self, cw: usize, ch: usize) -> (usize, usize, usize, usize) {
51        let (bx0, bx1) = (self.bx.saturating_sub(1), (self.bx + self.bw + 1).min(cw));
52        let (by0, by1) = (self.by.saturating_sub(1), (self.by + self.bh + 1).min(ch));
53        if self.bh >= 3 * self.bw {
54            (bx0, bx1, 0, ch)
55        } else if self.bw >= 3 * self.bh {
56            (0, cw, by0, by1)
57        } else {
58            (bx0, bx1, by0, by1)
59        }
60    }
61}
62
63impl LayerModel {
64    /// Cells inside any opaque object's zone: the only place an overlay can fade or move, so the only place a
65    /// still-changing pixel at the end of a static run is suspect (elsewhere it is page content still loading).
66    pub fn overlay_zone(&self) -> Vec<bool> {
67        let mut z = vec![false; self.cw * self.ch];
68        for o in self.objects.iter().filter(|o| o.kind == Kind::Opaque) {
69            let (x0, x1, y0, y1) = o.zone(self.cw, self.ch);
70            for y in y0..y1 {
71                for x in x0..x1 {
72                    z[y * self.cw + x] = true;
73                }
74            }
75        }
76        z
77    }
78}
79
80#[derive(Clone, Debug)]
81pub struct LayerModel {
82    pub x: i64,
83    pub y: i64,
84    pub cw: usize,
85    pub ch: usize,
86    pub objects: Vec<Object>,
87}
88
89/// Minimum comparisons for a cell to be classified at all.
90const ENOUGH: u16 = 8;
91const POOL: usize = 1; // radius in cells: 3×3
92
93fn pooled(v: &[u32], cw: usize, ch: usize) -> Vec<u64> {
94    // Separable box sum with radius POOL, zero outside.
95    let mut rows = vec![0u64; cw * ch];
96    for y in 0..ch {
97        for x in 0..cw {
98            let (a, b) = (x.saturating_sub(POOL), (x + POOL).min(cw - 1));
99            rows[y * cw + x] = (a..=b).map(|i| v[y * cw + i] as u64).sum();
100        }
101    }
102    let mut out = vec![0u64; cw * ch];
103    for y in 0..ch {
104        let (a, b) = (y.saturating_sub(POOL), (y + POOL).min(ch - 1));
105        for x in 0..cw {
106            out[y * cw + x] = (a..=b).map(|j| rows[j * cw + x]).sum();
107        }
108    }
109    out
110}
111
112fn dilate(m: &[bool], cw: usize, ch: usize) -> Vec<bool> {
113    let mut out = vec![false; m.len()];
114    for y in 0..ch {
115        for x in 0..cw {
116            if !m[y * cw + x] {
117                continue;
118            }
119            for yy in y.saturating_sub(1)..=(y + 1).min(ch - 1) {
120                for xx in x.saturating_sub(1)..=(x + 1).min(cw - 1) {
121                    out[yy * cw + xx] = true;
122                }
123            }
124        }
125    }
126    out
127}
128
129fn erode(m: &[bool], cw: usize, ch: usize) -> Vec<bool> {
130    let inv: Vec<bool> = m.iter().map(|&b| !b).collect();
131    dilate(&inv, cw, ch).iter().map(|&b| !b).collect()
132}
133
134/// 8-connected components; returns (label per cell, 0 = none) and component count.
135fn components(m: &[bool], cw: usize, ch: usize) -> (Vec<u32>, u32) {
136    let mut label = vec![0u32; m.len()];
137    let mut next = 0;
138    let mut stack = Vec::new();
139    for start in 0..m.len() {
140        if !m[start] || label[start] != 0 {
141            continue;
142        }
143        next += 1;
144        label[start] = next;
145        stack.push(start);
146        while let Some(i) = stack.pop() {
147            let (x, y) = (i % cw, i / cw);
148            for yy in y.saturating_sub(1)..=(y + 1).min(ch - 1) {
149                for xx in x.saturating_sub(1)..=(x + 1).min(cw - 1) {
150                    let j = yy * cw + xx;
151                    if m[j] && label[j] == 0 {
152                        label[j] = next;
153                        stack.push(j);
154                    }
155                }
156            }
157        }
158    }
159    (label, next)
160}
161
162/// Builds the layer's overlay objects. `tau` is the per-channel disagreement threshold.
163pub fn build(stats: &LayerStats, tau: i32) -> LayerModel {
164    let (cw, ch) = (stats.cw, stats.ch);
165    let len = cw * ch;
166    let n = pooled(&stats.field(|a| a.n as u32), cw, ch);
167    let bad = pooled(&stats.field(|a| a.bad as u32), cw, ch);
168    let sr = pooled(&stats.field(|a| a.sr), cw, ch);
169    let so = pooled(&stats.field(|a| a.so), cw, ch);
170    let srr = pooled(&stats.field(|a| a.srr), cw, ch);
171    let sro = pooled(&stats.field(|a| a.sro), cw, ch);
172    let soo = pooled(&stats.field(|a| a.soo), cw, ch);
173    let mut opaque = vec![false; len];
174    let mut veil = vec![false; len];
175    for i in 0..len {
176        if stats.cells[i].n < ENOUGH {
177            continue;
178        }
179        let nn = n[i].max(1) as f64;
180        let frac = bad[i] as f64 / nn;
181        let (mr, mo) = (sr[i] as f64 / nn, so[i] as f64 / nn);
182        let vr = srr[i] as f64 / nn - mr * mr;
183        let cov = sro[i] as f64 / nn - mr * mo;
184        let vo = soo[i] as f64 / nn - mo * mo;
185        // Upper confidence bound of the page→observation slope; no page texture means no slope evidence.
186        let hi = if vr > 1.0 {
187            let slope = cov / vr;
188            let resid = (vo - cov * cov / vr).max(0.0);
189            let se = ((resid + 4.0) / (nn * vr)).sqrt();
190            slope + 2.0 * se
191        } else {
192            f64::INFINITY
193        };
194        if frac > 0.3 || hi < 0.15 {
195            opaque[i] = true;
196        } else if hi < 0.85 {
197            veil[i] = true;
198        }
199    }
200    let enough: Vec<bool> = stats.cells.iter().map(|a| a.n >= ENOUGH).collect();
201    let core: Vec<bool> = erode(&dilate(&opaque, cw, ch), cw, ch)
202        .iter()
203        .zip(&enough)
204        .map(|(&a, &b)| a && b)
205        .collect();
206    let grown: Vec<bool> = dilate(&core, cw, ch)
207        .iter()
208        .zip(&enough)
209        .map(|(&a, &b)| a && b)
210        .collect();
211    let veil: Vec<bool> = erode(&dilate(&veil, cw, ch), cw, ch)
212        .iter()
213        .zip(&enough)
214        .zip(&grown)
215        .map(|((&a, &b), &g)| a && b && !g)
216        .collect();
217    let mut objects = Vec::new();
218    for (kind, mask) in [(Kind::Opaque, &grown), (Kind::Veil, &veil)] {
219        let (label, count) = components(mask, cw, ch);
220        let mut boxes = vec![(usize::MAX, usize::MAX, 0usize, 0usize, 0usize); count as usize];
221        for (i, &l) in label.iter().enumerate() {
222            if l == 0 {
223                continue;
224            }
225            let b = &mut boxes[l as usize - 1];
226            let (x, y) = (i % cw, i / cw);
227            b.0 = b.0.min(x);
228            b.1 = b.1.min(y);
229            b.2 = b.2.max(x);
230            b.3 = b.3.max(y);
231            b.4 += 1;
232        }
233        for (k, &(x0, y0, x1, y1, area)) in boxes.iter().enumerate() {
234            // Three cells (12 native pixels) is below any overlay worth tracking and mostly codec speckle.
235            if area < 3 {
236                continue;
237            }
238            let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1);
239            let mut m = vec![false; bw * bh];
240            let mut tpl = vec![[0u8; 3]; bw * bh];
241            let mut stable = Vec::new();
242            for yy in 0..bh {
243                for xx in 0..bw {
244                    let i = (y0 + yy) * cw + x0 + xx;
245                    if label[i] != k as u32 + 1 {
246                        continue;
247                    }
248                    let j = yy * bw + xx;
249                    m[j] = true;
250                    let a = stats.cells[i];
251                    let nb = a.bad as u32;
252                    if nb == 0 {
253                        continue;
254                    }
255                    tpl[j] = [
256                        ((a.br + nb / 2) / nb) as u8,
257                        ((a.bg + nb / 2) / nb) as u8,
258                        ((a.bb + nb / 2) / nb) as u8,
259                    ];
260                    let ml = a.bl as f64 / nb as f64;
261                    let spread = (a.bll as f64 / nb as f64 - ml * ml).max(0.0).sqrt();
262                    // Present often enough to know its look, and the look is consistent.
263                    if kind == Kind::Opaque && core[i] && nb >= 4 && spread <= tau as f64 / 2.0 {
264                        stable.push(j as u32);
265                    }
266                }
267            }
268            let edges = stable
269                .iter()
270                .filter(|&&j| {
271                    let j = j as usize;
272                    let (xx, yy) = (j % bw, j / bw);
273                    let l = |a: usize| {
274                        let t = tpl[a];
275                        super::luma(t[0], t[1], t[2]) as i32
276                    };
277                    let c = l(j);
278                    (xx > 0 && (c - l(j - 1)).abs() > 16) || (yy > 0 && (c - l(j - bw)).abs() > 16)
279                })
280                .count();
281            let step = (stable.len() / 2000).max(1);
282            let coarse = stable.iter().step_by(step).copied().collect();
283            // Dominant uniform colour: most stable cells within tolerance of their median colour.
284            let flat = (stable.len() >= 4)
285                .then(|| {
286                    let mut ls: Vec<(u32, [u8; 3])> = stable
287                        .iter()
288                        .map(|&j| {
289                            let t = tpl[j as usize];
290                            (super::luma(t[0], t[1], t[2]), t)
291                        })
292                        .collect();
293                    ls.sort_by_key(|p| p.0);
294                    let med = ls[ls.len() / 2].1;
295                    let near = ls
296                        .iter()
297                        .filter(|(_, t)| (0..3).all(|k| (t[k] as i32 - med[k] as i32).abs() <= tau))
298                        .count();
299                    (near * 10 >= ls.len() * 6).then_some(med)
300                })
301                .flatten();
302            objects.push(Object {
303                kind,
304                bx: x0,
305                by: y0,
306                bw,
307                bh,
308                mask: m,
309                tpl,
310                textured: edges >= 10,
311                stable,
312                coarse,
313                last: (0, 0),
314                flat,
315            });
316        }
317    }
318    LayerModel {
319        x: stats.x,
320        y: stats.y,
321        cw,
322        ch,
323        objects,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::kernels::reveal::locate::tiers;
331    use crate::kernels::reveal::stats::CellRef;
332    use crate::kernels::reveal::{Footprint, Frame, TIER_CLEAN};
333
334    struct Page(u8);
335    impl CellRef for Page {
336        fn cell(&self, _: i64, _: i64) -> Option<[u8; 3]> {
337            Some([self.0; 3])
338        }
339        fn cells(&self) -> (i64, i64, i64, i64) {
340            (0, 0, 48, 32)
341        }
342    }
343
344    fn button(page: u8, ink: u8) -> Vec<u8> {
345        let mut rgba = vec![255; 96 * 64 * 4];
346        for y in 0..64 {
347            for x in 0..96 {
348                let v = if (16..80).contains(&x) && (16..48).contains(&y) {
349                    if (32..64).contains(&x) && (24..40).contains(&y) {
350                        ink
351                    } else {
352                        240
353                    }
354                } else {
355                    page
356                };
357                let i = (y * 96 + x) * 4;
358                rgba[i..i + 3].fill(v);
359            }
360        }
361        rgba
362    }
363
364    // A button has a mostly uniform background but also its own visible label. If that label changes, the
365    // old template no longer locates it; matching only its background must not certify the new label as page.
366    #[test]
367    fn a_changed_label_on_an_opaque_button_is_not_clean_page_content() {
368        let mut stats = LayerStats::new(0, 0, 96, 64);
369        let mask = vec![1; 96 * 64];
370        let footprint = Footprint {
371            x: 0,
372            y: 0,
373            width: 96,
374            height: 64,
375            mask: &mask,
376        };
377        for background in (0..=240).step_by(16) {
378            let rgba = button(background, 0);
379            stats.accumulate_cells(
380                &Frame {
381                    rgba: &rgba,
382                    width: 96,
383                    height: 64,
384                },
385                &footprint,
386                &Page(background),
387                (0, 0),
388                24,
389            );
390        }
391        let mut model = build(&stats, 24);
392        let rgba = button(128, 80);
393        let tier = tiers(
394            &Frame {
395                rgba: &rgba,
396                width: 96,
397                height: 64,
398            },
399            &mut model,
400            24,
401        );
402        for y in 10..22 {
403            for x in 10..38 {
404                assert!(
405                    tier[y * 48 + x] < TIER_CLEAN,
406                    "button cell ({x}, {y}) treated as page"
407                );
408            }
409        }
410        assert_eq!(tier[0], TIER_CLEAN);
411    }
412}