Skip to main content

visual_cortex_vision/
stability_mask.rs

1//! Block-state core for the temporal stability mask. Pure grid math —
2//! no I/O, no frames — so every rule is unit-testable in isolation.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7/// Per-block temporal state. Signatures are mean-luma u8.
8#[derive(Debug, Clone)]
9pub(crate) struct BlockState {
10    pub last_sig: u8,
11    /// Consecutive ticks the signature stayed within tolerance.
12    pub stable_ticks: u32,
13    /// Slow-moving estimate of the block's "permanent" appearance.
14    pub baseline: f32,
15    /// Set while the block is kept: the baseline stops absorbing (a held
16    /// tooltip must not become baseline and vanish mid-hover).
17    pub baseline_frozen: bool,
18    pub initialized: bool,
19}
20
21impl Default for BlockState {
22    fn default() -> Self {
23        Self {
24            last_sig: 0,
25            stable_ticks: 0,
26            baseline: 0.0,
27            baseline_frozen: false,
28            initialized: false,
29        }
30    }
31}
32
33pub(crate) struct MaskParams {
34    pub stable_ticks: u32,
35    /// EMA horizon in ticks (alpha = 1/horizon).
36    pub baseline_ticks: u32,
37    pub signature_tolerance: u8,
38    pub baseline_threshold: u8,
39}
40
41/// Advance one block one tick. Returns whether the block is KEPT this tick
42/// (stable AND novel vs baseline), updating all state per the spec:
43/// - stability counter resets on movement;
44/// - baseline is EMA-updated only while NOT frozen;
45/// - freeze begins when the block is kept, and thaws only when the block is
46///   no longer kept AND its signature has returned near the frozen baseline.
47pub(crate) fn step_block(state: &mut BlockState, sig: u8, p: &MaskParams) -> bool {
48    if !state.initialized {
49        *state = BlockState {
50            last_sig: sig,
51            stable_ticks: 0,
52            baseline: sig as f32,
53            baseline_frozen: false,
54            initialized: true,
55        };
56        return false; // cold start: nothing is stable yet
57    }
58
59    // Stability.
60    if sig.abs_diff(state.last_sig) <= p.signature_tolerance {
61        state.stable_ticks = state.stable_ticks.saturating_add(1);
62    } else {
63        state.stable_ticks = 0;
64    }
65    state.last_sig = sig;
66
67    let stable = state.stable_ticks >= p.stable_ticks;
68    let novel = (sig as f32 - state.baseline).abs() > p.baseline_threshold as f32;
69    let kept = stable && novel;
70
71    // Baseline bookkeeping.
72    if kept {
73        state.baseline_frozen = true;
74    } else if state.baseline_frozen {
75        // Thaw only once the overlay has actually gone: signature back near
76        // the frozen baseline.
77        if (sig as f32 - state.baseline).abs() <= p.baseline_threshold as f32 {
78            state.baseline_frozen = false;
79        }
80    }
81    if !state.baseline_frozen {
82        let alpha = 1.0 / p.baseline_ticks.max(1) as f32;
83        state.baseline += alpha * (sig as f32 - state.baseline);
84    }
85
86    kept
87}
88
89/// Morphological close (fill interior holes) then dilate by `dilate` rings,
90/// on a row-major block grid. Close = dilate-then-erode with a 1-ring
91/// structuring element.
92pub(crate) fn close_and_dilate(keep: &[bool], cols: usize, rows: usize, dilate: u32) -> Vec<bool> {
93    let d1 = dilate_grid(keep, cols, rows, 1);
94    let closed = erode_grid(&d1, cols, rows, 1);
95    if dilate == 0 {
96        closed
97    } else {
98        dilate_grid(&closed, cols, rows, dilate as usize)
99    }
100}
101
102fn dilate_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
103    let mut out = grid.to_vec();
104    for _ in 0..rings {
105        let src = out.clone();
106        for r in 0..rows {
107            for c in 0..cols {
108                if src[r * cols + c] {
109                    continue;
110                }
111                let neighbors_on = (r > 0 && src[(r - 1) * cols + c])
112                    || (r + 1 < rows && src[(r + 1) * cols + c])
113                    || (c > 0 && src[r * cols + c - 1])
114                    || (c + 1 < cols && src[r * cols + c + 1]);
115                if neighbors_on {
116                    out[r * cols + c] = true;
117                }
118            }
119        }
120    }
121    out
122}
123
124fn erode_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
125    let mut out = grid.to_vec();
126    for _ in 0..rings {
127        let src = out.clone();
128        for r in 0..rows {
129            for c in 0..cols {
130                if !src[r * cols + c] {
131                    continue;
132                }
133                let all_on = (r == 0 || src[(r - 1) * cols + c])
134                    && (r + 1 >= rows || src[(r + 1) * cols + c])
135                    && (c == 0 || src[r * cols + c - 1])
136                    && (c + 1 >= cols || src[r * cols + c + 1]);
137                if !all_on {
138                    out[r * cols + c] = false;
139                }
140            }
141        }
142    }
143    out
144}
145
146use visual_cortex_capture::{Frame, FrameView, Rate};
147
148use crate::debug::{DebugSink, DebugStage};
149use crate::detector::DetectorError;
150use crate::preprocessor::Preprocessor;
151
152/// Temporal stability mask: keeps only blocks that are *stable* over recent
153/// ticks yet *novel* versus a longer-term baseline — isolating
154/// newly-appeared static overlays (tooltips) from moving gameplay and
155/// permanent HUD. Suppressed blocks render black; kept blocks pass through
156/// byte-identical. See the design spec for the algebra.
157pub struct StabilityMask {
158    block: u32,
159    params: MaskParams,
160    dilate: u32,
161    /// Grid state; reset when the view dimensions change.
162    state: Vec<BlockState>,
163    dims: (u32, u32),
164    tick: u64,
165    sinks: Vec<Box<dyn DebugSink>>,
166}
167
168impl Default for StabilityMask {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl StabilityMask {
175    pub fn new() -> Self {
176        Self {
177            block: 16,
178            params: MaskParams {
179                stable_ticks: 6,
180                baseline_ticks: 100,
181                signature_tolerance: 4,
182                baseline_threshold: 25,
183            },
184            dilate: 1,
185            state: Vec::new(),
186            dims: (0, 0),
187            tick: 0,
188            sinks: Vec::new(),
189        }
190    }
191
192    /// Analysis block size in pixels (default 16).
193    pub fn block_size(mut self, px: u32) -> Self {
194        assert!(px > 0, "block size must be positive");
195        self.block = px;
196        self
197    }
198
199    /// Consecutive stable ticks before a block counts as stable (default 6).
200    pub fn stable_ticks(mut self, k: u32) -> Self {
201        self.params.stable_ticks = k;
202        self
203    }
204
205    /// Duration form of [`Self::stable_ticks`]; converted via the watcher's
206    /// rate (tick-based internals keep paused-time tests deterministic).
207    pub fn stable_for(self, rate: Rate, duration: Duration) -> Self {
208        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
209        self.stable_ticks(ticks.max(1))
210    }
211
212    /// Baseline EMA horizon in ticks (default 100).
213    pub fn baseline_ticks(mut self, t: u32) -> Self {
214        self.params.baseline_ticks = t.max(1);
215        self
216    }
217
218    /// Duration form of [`Self::baseline_ticks`].
219    pub fn baseline(self, rate: Rate, duration: Duration) -> Self {
220        let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
221        self.baseline_ticks(ticks.max(1))
222    }
223
224    /// Keep-mask dilation rings (default 1) — keeps glyphs off the fill
225    /// boundary and closes holes inside kept clusters.
226    pub fn dilate(mut self, rings: u32) -> Self {
227        self.dilate = rings;
228        self
229    }
230
231    /// Stability comparison tolerance on the 0-255 luma signature (default 4).
232    pub fn signature_tolerance(mut self, tol: u8) -> Self {
233        self.params.signature_tolerance = tol;
234        self
235    }
236
237    /// Novelty threshold versus the baseline (default 25).
238    pub fn baseline_threshold(mut self, thr: u8) -> Self {
239        self.params.baseline_threshold = thr;
240        self
241    }
242
243    /// Attach a debug tap (e.g. [`PngDump`](crate::PngDump)) receiving
244    /// per-tick Input/Baseline/Overlay/Output frames. Composable —
245    /// multiple sinks allowed. Stage frames are only materialized when at
246    /// least one sink is attached.
247    pub fn debug_sink(mut self, sink: impl DebugSink) -> Self {
248        self.sinks.push(Box::new(sink));
249        self
250    }
251
252    fn grid(&self, w: u32, h: u32) -> (usize, usize) {
253        (
254            (w as usize).div_ceil(self.block as usize),
255            (h as usize).div_ceil(self.block as usize),
256        )
257    }
258}
259
260/// Mean luma per block, row-major grid. Blocks at the right/bottom edges may
261/// be partial.
262fn block_signatures(view: &FrameView<'_>, block: u32, cols: usize, rows: usize) -> Vec<u8> {
263    let mut sums = vec![0u64; cols * rows];
264    let mut counts = vec![0u64; cols * rows];
265    for (y, row) in view.rows().enumerate() {
266        let br = y / block as usize;
267        for (x, px) in row.chunks_exact(4).enumerate() {
268            let bc = x / block as usize;
269            // Integer BT.601 luma, same weights as the detectors.
270            let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
271            sums[br * cols + bc] += luma as u64;
272            counts[br * cols + bc] += 1;
273        }
274    }
275    sums.iter()
276        .zip(&counts)
277        .map(|(s, c)| if *c == 0 { 0 } else { (s / c) as u8 })
278        .collect()
279}
280
281impl Preprocessor for StabilityMask {
282    fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
283        let (w, h) = (view.width(), view.height());
284        let (cols, rows) = self.grid(w, h);
285        if self.dims != (w, h) {
286            self.state = vec![BlockState::default(); cols * rows];
287            self.dims = (w, h);
288        }
289
290        let sigs = block_signatures(view, self.block, cols, rows);
291        let keep_raw: Vec<bool> = sigs
292            .iter()
293            .zip(self.state.iter_mut())
294            .map(|(sig, st)| step_block(st, *sig, &self.params))
295            .collect();
296        let keep = close_and_dilate(&keep_raw, cols, rows, self.dilate);
297
298        // Render: kept blocks byte-identical, suppressed blocks black.
299        let mut data = vec![0u8; w as usize * h as usize * 4];
300        for px in data.chunks_exact_mut(4) {
301            px[3] = 255;
302        }
303        for (y, row) in view.rows().enumerate() {
304            let br = y / self.block as usize;
305            for bc in 0..cols {
306                if !keep[br * cols + bc] {
307                    continue;
308                }
309                let x0 = bc * self.block as usize * 4;
310                let x1 = (((bc + 1) * self.block as usize) * 4).min(row.len());
311                let dst = y * w as usize * 4;
312                data[dst + x0..dst + x1].copy_from_slice(&row[x0..x1]);
313            }
314        }
315        let output = Frame::new(w, h, data)
316            .map(Arc::new)
317            .map_err(|e| DetectorError::Other(format!("mask render: {e}")))?;
318
319        if !self.sinks.is_empty() {
320            self.emit_debug(view, &keep, cols, &output);
321        }
322        self.tick += 1;
323        Ok(output)
324    }
325
326    fn set_label(&mut self, label: &str) {
327        for sink in &mut self.sinks {
328            sink.set_label(label);
329        }
330    }
331}
332
333impl StabilityMask {
334    /// Materialize the debug stage frames and fan them out. Only called
335    /// when sinks exist; sinks themselves must never block (drop-on-lag).
336    fn emit_debug(&mut self, view: &FrameView<'_>, keep: &[bool], cols: usize, output: &Frame) {
337        let (w, h) = (view.width(), view.height());
338        let input = Frame::new(w, h, view.to_vec()).expect("view-sized buffer");
339
340        // Baseline: each block filled with its EMA estimate, as gray.
341        // Overlay: input with suppressed blocks dimmed to 25% — the human view.
342        let mut baseline = vec![0u8; w as usize * h as usize * 4];
343        let mut overlay = input.data().to_vec();
344        for y in 0..h as usize {
345            let br = y / self.block as usize;
346            for x in 0..w as usize {
347                let bc = x / self.block as usize;
348                let i = (y * w as usize + x) * 4;
349                let b = self.state[br * cols + bc].baseline as u8;
350                baseline[i] = b;
351                baseline[i + 1] = b;
352                baseline[i + 2] = b;
353                baseline[i + 3] = 255;
354                if !keep[br * cols + bc] {
355                    overlay[i] /= 4;
356                    overlay[i + 1] /= 4;
357                    overlay[i + 2] /= 4;
358                }
359            }
360        }
361        let baseline = Frame::new(w, h, baseline).expect("view-sized buffer");
362        let overlay = Frame::new(w, h, overlay).expect("view-sized buffer");
363
364        for sink in &mut self.sinks {
365            sink.write(self.tick, DebugStage::Input, &input);
366            sink.write(self.tick, DebugStage::Baseline, &baseline);
367            sink.write(self.tick, DebugStage::Overlay, &overlay);
368            sink.write(self.tick, DebugStage::Output, output);
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn params() -> MaskParams {
378        MaskParams {
379            stable_ticks: 3,
380            baseline_ticks: 20,
381            signature_tolerance: 4,
382            baseline_threshold: 25,
383        }
384    }
385
386    fn run(state: &mut BlockState, sigs: &[u8], p: &MaskParams) -> Vec<bool> {
387        sigs.iter().map(|s| step_block(state, *s, p)).collect()
388    }
389
390    #[test]
391    fn constant_hud_block_is_never_kept() {
392        // Same signature forever: stable but never novel (baseline == sig).
393        let mut st = BlockState::default();
394        let kept = run(&mut st, &[100; 30], &params());
395        assert!(kept.iter().all(|k| !k), "HUD must stay suppressed");
396    }
397
398    #[test]
399    fn noisy_gameplay_block_is_never_kept() {
400        // Signature jumps beyond tolerance every tick: never stable.
401        let mut st = BlockState::default();
402        let sigs: Vec<u8> = (0..30).map(|i| if i % 2 == 0 { 40 } else { 200 }).collect();
403        let kept = run(&mut st, &sigs, &params());
404        assert!(
405            kept.iter().all(|k| !k),
406            "moving content must stay suppressed"
407        );
408    }
409
410    #[test]
411    fn tooltip_becomes_kept_after_k_stable_ticks() {
412        let mut st = BlockState::default();
413        // Settle on gameplay-ish baseline, then a tooltip appears (sig 200).
414        run(&mut st, &[60; 10], &params());
415        let kept = run(&mut st, &[200; 6], &params());
416        // Tick 1 resets stability (jump); the counter then reaches K=3 on the
417        // fourth tick of the new signature (index 3).
418        assert_eq!(kept, vec![false, false, false, true, true, true]);
419    }
420
421    #[test]
422    fn held_tooltip_survives_past_the_baseline_horizon() {
423        // The ghosting trap: without baseline freeze, ~baseline_ticks of a
424        // held tooltip would absorb it into the baseline and un-keep it.
425        let mut st = BlockState::default();
426        run(&mut st, &[60; 10], &params());
427        let long_hold = run(&mut st, &[200; 200], &params()); // 10x horizon
428        assert!(
429            long_hold[10..].iter().all(|k| *k),
430            "held tooltip must remain kept indefinitely"
431        );
432    }
433
434    #[test]
435    fn baseline_thaws_after_overlay_departs() {
436        let mut st = BlockState::default();
437        run(&mut st, &[60; 10], &params());
438        run(&mut st, &[200; 20], &params()); // tooltip held (frozen baseline)
439        assert!(st.baseline_frozen);
440        // Tooltip dismissed: back to the old scene.
441        let after = run(&mut st, &[60; 10], &params());
442        assert!(!st.baseline_frozen, "baseline resumes after departure");
443        assert!(after.iter().all(|k| !k), "restored scene is not novel");
444    }
445
446    #[test]
447    fn close_fills_interior_holes_and_dilate_expands() {
448        // 5x5 grid: ring of kept blocks with a hole in the middle.
449        let cols = 5;
450        let mut keep = vec![false; 25];
451        for (r, c) in [
452            (1usize, 1usize),
453            (1, 2),
454            (1, 3),
455            (2, 1),
456            (2, 3),
457            (3, 1),
458            (3, 2),
459            (3, 3),
460        ] {
461            keep[r * cols + c] = true;
462        }
463        let out = close_and_dilate(&keep, cols, 5, 0);
464        assert!(out[2 * cols + 2], "interior hole must be closed");
465        let out = close_and_dilate(&keep, cols, 5, 1);
466        assert!(out[cols], "edge cell (1,0) reached by one dilation ring");
467        assert!(
468            !out[0],
469            "corner is two steps away; one ring must not reach it"
470        );
471    }
472
473    #[test]
474    fn debug_sinks_receive_all_four_stages_per_tick() {
475        use std::sync::Mutex;
476
477        use crate::debug::{DebugSink, DebugStage};
478        use visual_cortex_capture::PxRect;
479
480        struct Recording(Arc<Mutex<Vec<(u64, DebugStage)>>>);
481        impl DebugSink for Recording {
482            fn write(&mut self, tick: u64, stage: DebugStage, _frame: &Frame) {
483                self.0.lock().unwrap().push((tick, stage));
484            }
485        }
486
487        let log = Arc::new(Mutex::new(Vec::new()));
488        let mut mask = StabilityMask::new()
489            .block_size(8)
490            .debug_sink(Recording(log.clone()));
491        let frame = Frame::solid(8, 8, [60, 60, 60, 255]);
492        let view = frame
493            .view(PxRect {
494                x: 0,
495                y: 0,
496                w: 8,
497                h: 8,
498            })
499            .unwrap();
500        mask.process(&view).unwrap();
501        mask.process(&view).unwrap();
502
503        use DebugStage::*;
504        assert_eq!(
505            *log.lock().unwrap(),
506            vec![
507                (0, Input),
508                (0, Baseline),
509                (0, Overlay),
510                (0, Output),
511                (1, Input),
512                (1, Baseline),
513                (1, Overlay),
514                (1, Output),
515            ]
516        );
517    }
518
519    #[test]
520    fn cold_start_keeps_nothing() {
521        let mut st = BlockState::default();
522        assert!(!step_block(&mut st, 200, &params()));
523    }
524}