1use std::sync::Arc;
5use std::time::Duration;
6
7#[derive(Debug, Clone)]
9pub(crate) struct BlockState {
10 pub last_sig: u8,
11 pub stable_ticks: u32,
13 pub baseline: f32,
15 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 pub baseline_ticks: u32,
37 pub signature_tolerance: u8,
38 pub baseline_threshold: u8,
39}
40
41pub(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; }
58
59 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 if kept {
73 state.baseline_frozen = true;
74 } else if state.baseline_frozen {
75 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
89pub(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
152pub struct StabilityMask {
158 block: u32,
159 params: MaskParams,
160 dilate: u32,
161 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 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 pub fn stable_ticks(mut self, k: u32) -> Self {
201 self.params.stable_ticks = k;
202 self
203 }
204
205 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 pub fn baseline_ticks(mut self, t: u32) -> Self {
214 self.params.baseline_ticks = t.max(1);
215 self
216 }
217
218 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 pub fn dilate(mut self, rings: u32) -> Self {
227 self.dilate = rings;
228 self
229 }
230
231 pub fn signature_tolerance(mut self, tol: u8) -> Self {
233 self.params.signature_tolerance = tol;
234 self
235 }
236
237 pub fn baseline_threshold(mut self, thr: u8) -> Self {
239 self.params.baseline_threshold = thr;
240 self
241 }
242
243 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
260fn 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 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 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 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 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 let mut st = BlockState::default();
394 let kept = run(&mut st, &[100; 30], ¶ms());
395 assert!(kept.iter().all(|k| !k), "HUD must stay suppressed");
396 }
397
398 #[test]
399 fn noisy_gameplay_block_is_never_kept() {
400 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, ¶ms());
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 run(&mut st, &[60; 10], ¶ms());
415 let kept = run(&mut st, &[200; 6], ¶ms());
416 assert_eq!(kept, vec![false, false, false, true, true, true]);
419 }
420
421 #[test]
422 fn held_tooltip_survives_past_the_baseline_horizon() {
423 let mut st = BlockState::default();
426 run(&mut st, &[60; 10], ¶ms());
427 let long_hold = run(&mut st, &[200; 200], ¶ms()); 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], ¶ms());
438 run(&mut st, &[200; 20], ¶ms()); assert!(st.baseline_frozen);
440 let after = run(&mut st, &[60; 10], ¶ms());
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 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, ¶ms()));
523 }
524}