1use std::sync::Arc;
17use std::time::Duration;
18
19use visual_cortex_capture::{Frame, FrameView, Rate};
20
21use crate::debug::{DebugSink, DebugStage};
22use crate::detector::DetectorError;
23use crate::preprocessor::Preprocessor;
24
25pub(crate) const SIG_LEN: usize = 17;
27const VOTE: usize = 2;
29const CORROBORATION_FRAC: f32 = 0.35;
32const CENSUS_FRAC: f32 = 0.60;
35const DRIFT_FRAC: f32 = 0.50;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub(crate) struct Signature(pub [u8; SIG_LEN]);
43
44impl Default for Signature {
45 fn default() -> Self {
46 Signature([0; SIG_LEN])
47 }
48}
49
50pub(crate) fn is_moving(cur: &Signature, last: &Signature, tol: u8) -> bool {
52 cur.0
53 .iter()
54 .zip(&last.0)
55 .filter(|(a, b)| a.abs_diff(**b) > tol)
56 .count()
57 >= VOTE
58}
59
60pub(crate) fn is_novel_vs(cur: &Signature, reference: &[u8; SIG_LEN], nov: u8, con: u8) -> bool {
64 let sub_votes = cur.0[..16]
65 .iter()
66 .zip(&reference[..16])
67 .filter(|(a, b)| a.abs_diff(**b) > nov)
68 .count();
69 sub_votes >= VOTE || cur.0[16].abs_diff(reference[16]) > con
70}
71
72fn narrow(base: &[u16; SIG_LEN]) -> [u8; SIG_LEN] {
74 let mut out = [0u8; SIG_LEN];
75 for (o, b) in out.iter_mut().zip(base) {
76 *o = (b >> 8) as u8;
77 }
78 out
79}
80
81fn widen(sig: &Signature) -> [u16; SIG_LEN] {
82 let mut out = [0u16; SIG_LEN];
83 for (o, s) in out.iter_mut().zip(&sig.0) {
84 *o = (*s as u16) << 8;
85 }
86 out
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub(crate) enum Phase {
91 Idle,
92 Kept,
93 Holdover,
94}
95
96#[derive(Debug, Clone)]
97pub(crate) struct Block {
98 pub last: Signature,
99 pub base: [u16; SIG_LEN],
101 pub ovl: Signature,
103 pub stable_ticks: u16,
104 pub unstable_run: u16,
105 pub holdover: u16,
106 pub phase: Phase,
107 pub initialized: bool,
108 pub base_frozen: bool,
109 pub scene_flag: bool,
113 pub run_corroborated: bool,
117 pub reseeded: bool,
119}
120
121impl Default for Block {
122 fn default() -> Self {
123 Self {
124 last: Signature::default(),
125 base: [0; SIG_LEN],
126 ovl: Signature::default(),
127 stable_ticks: 0,
128 unstable_run: 0,
129 holdover: 0,
130 phase: Phase::Idle,
131 initialized: false,
132 base_frozen: false,
133 scene_flag: false,
134 run_corroborated: false,
135 reseeded: false,
136 }
137 }
138}
139
140pub(crate) struct MaskParams {
141 pub stable_ticks: u16,
143 pub baseline_ticks: u32,
145 pub signature_tolerance: u8,
147 pub novelty_threshold: u8,
149 pub contrast_threshold: u8,
151 pub holdover_ticks: u16,
153 pub long_run: u16,
155}
156
157#[derive(Debug, Clone, Copy, Default)]
159pub(crate) struct Globals {
160 pub scene_tick: bool,
162 pub corroborated_tick: bool,
164 pub census_hit: bool,
167 pub drift_guard: bool,
169}
170
171fn reseed(b: &mut Block, sig: &Signature) {
172 b.base = widen(sig);
173 b.base_frozen = false;
174 b.scene_flag = false;
175 b.run_corroborated = false;
176 b.unstable_run = 0;
177 b.phase = Phase::Idle;
178 b.reseeded = true;
179}
180
181fn ema_update(b: &mut Block, sig: &Signature, horizon: u32) {
182 let t = i64::from(horizon.max(1));
183 for (base, s) in b.base.iter_mut().zip(&sig.0) {
184 let target = i64::from(*s) << 8;
185 let delta = target - i64::from(*base);
186 let mut step = delta / t;
187 if step == 0 && delta != 0 {
188 step = delta.signum(); }
190 *base = (i64::from(*base) + step) as u16;
191 }
192}
193
194pub(crate) fn step_block(
197 b: &mut Block,
198 sig: &Signature,
199 mv: bool,
200 census_eligible: bool,
201 g: &Globals,
202 p: &MaskParams,
203) -> bool {
204 b.reseeded = false;
205 if !b.initialized {
206 b.last = *sig;
207 b.base = widen(sig);
208 b.ovl = *sig;
209 b.initialized = true;
210 return false; }
212
213 b.last = *sig;
215 if mv {
216 b.stable_ticks = 0;
217 b.unstable_run = b.unstable_run.saturating_add(1);
218 if g.corroborated_tick {
219 b.run_corroborated = true;
220 }
221 if g.scene_tick {
222 b.scene_flag = true;
223 }
224 } else {
225 b.stable_ticks = b.stable_ticks.saturating_add(1);
226 }
227 if census_eligible && g.census_hit {
228 b.scene_flag = true;
229 }
230
231 let base_u8 = narrow(&b.base);
232 let stable = b.stable_ticks >= p.stable_ticks;
233 let settled_now = !mv && b.stable_ticks == p.stable_ticks;
234 let novel = is_novel_vs(sig, &base_u8, p.novelty_threshold, p.contrast_threshold);
235
236 match b.phase {
237 Phase::Kept => {
238 if mv {
241 b.phase = Phase::Holdover;
242 b.holdover = p.holdover_ticks;
243 } else {
244 b.ovl = *sig;
245 b.unstable_run = 0;
246 b.run_corroborated = false;
247 }
248 true }
250 Phase::Holdover => {
251 if !mv && !is_novel_vs(sig, &b.ovl.0, p.novelty_threshold, p.contrast_threshold) {
256 b.phase = Phase::Kept;
257 b.unstable_run = 0;
258 b.run_corroborated = false;
259 return true;
260 }
261 if settled_now {
262 if novel {
263 b.phase = Phase::Kept;
271 b.ovl = *sig;
272 b.unstable_run = 0;
273 b.run_corroborated = false;
274 return true;
275 }
276 b.phase = Phase::Idle;
280 b.base_frozen = false;
281 b.scene_flag = false;
282 b.unstable_run = 0;
283 b.run_corroborated = false;
284 return false;
285 }
286 b.holdover = b.holdover.saturating_sub(1);
287 if b.holdover == 0 {
288 b.phase = Phase::Idle;
289 return false;
290 }
291 true }
293 Phase::Idle => {
294 if settled_now {
295 let long_run = b.unstable_run >= p.long_run && b.run_corroborated;
296 let scene = b.scene_flag;
297 b.unstable_run = 0;
298 b.run_corroborated = false;
299 if long_run || scene {
300 reseed(b, sig);
303 return false;
304 }
305 b.scene_flag = false;
306 if novel {
307 b.phase = Phase::Kept;
312 b.base_frozen = true;
313 b.ovl = *sig;
314 return true;
315 }
316 } else if stable && novel {
317 if g.drift_guard {
319 reseed(b, sig);
320 return false;
321 }
322 b.phase = Phase::Kept;
323 b.base_frozen = true;
324 b.ovl = *sig;
325 b.unstable_run = 0;
326 b.run_corroborated = false;
327 return true;
328 }
329 if b.base_frozen && stable && !novel {
330 b.base_frozen = false; }
332 if !b.base_frozen && !mv && !novel {
338 ema_update(b, sig, p.baseline_ticks);
339 }
340 false
341 }
342 }
343}
344
345pub(crate) fn close_and_dilate(keep: &[bool], cols: usize, rows: usize, dilate: u32) -> Vec<bool> {
348 let d1 = dilate_grid(keep, cols, rows, 1);
349 let closed = erode_grid(&d1, cols, rows, 1);
350 if dilate == 0 {
351 closed
352 } else {
353 dilate_grid(&closed, cols, rows, dilate as usize)
354 }
355}
356
357pub(crate) fn dilate_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
358 let mut out = grid.to_vec();
359 for _ in 0..rings {
360 let src = out.clone();
361 for r in 0..rows {
362 for c in 0..cols {
363 if src[r * cols + c] {
364 continue;
365 }
366 let neighbors_on = (r > 0 && src[(r - 1) * cols + c])
367 || (r + 1 < rows && src[(r + 1) * cols + c])
368 || (c > 0 && src[r * cols + c - 1])
369 || (c + 1 < cols && src[r * cols + c + 1]);
370 if neighbors_on {
371 out[r * cols + c] = true;
372 }
373 }
374 }
375 }
376 out
377}
378
379fn erode_grid(grid: &[bool], cols: usize, rows: usize, rings: usize) -> Vec<bool> {
380 let mut out = grid.to_vec();
381 for _ in 0..rings {
382 let src = out.clone();
383 for r in 0..rows {
384 for c in 0..cols {
385 if !src[r * cols + c] {
386 continue;
387 }
388 let all_on = (r == 0 || src[(r - 1) * cols + c])
389 && (r + 1 >= rows || src[(r + 1) * cols + c])
390 && (c == 0 || src[r * cols + c - 1])
391 && (c + 1 >= cols || src[r * cols + c + 1]);
392 if !all_on {
393 out[r * cols + c] = false;
394 }
395 }
396 }
397 }
398 out
399}
400
401pub(crate) fn fill_components_bbox(
406 keep: &[bool],
407 cols: usize,
408 rows: usize,
409 min_size: usize,
410) -> Vec<bool> {
411 let mut out = vec![false; keep.len()];
412 let mut seen = vec![false; keep.len()];
413 let mut stack = Vec::new();
414 for start in 0..keep.len() {
415 if !keep[start] || seen[start] {
416 continue;
417 }
418 let (mut r0, mut r1, mut c0, mut c1) = (rows, 0usize, cols, 0usize);
420 let mut size = 0usize;
421 stack.push(start);
422 seen[start] = true;
423 while let Some(i) = stack.pop() {
424 size += 1;
425 let (r, c) = (i / cols, i % cols);
426 r0 = r0.min(r);
427 r1 = r1.max(r);
428 c0 = c0.min(c);
429 c1 = c1.max(c);
430 let mut push = |j: usize| {
431 if keep[j] && !seen[j] {
432 seen[j] = true;
433 stack.push(j);
434 }
435 };
436 if r > 0 {
437 push(i - cols);
438 }
439 if r + 1 < rows {
440 push(i + cols);
441 }
442 if c > 0 {
443 push(i - 1);
444 }
445 if c + 1 < cols {
446 push(i + 1);
447 }
448 }
449 if size < min_size {
450 continue; }
452 for r in r0..=r1 {
453 for c in c0..=c1 {
454 out[r * cols + c] = true;
455 }
456 }
457 }
458 out
459}
460
461pub struct StabilityMask {
467 block: u32,
468 params: MaskParams,
469 dilate: u32,
470 scene_threshold: f32,
471 fill_components: bool,
472 min_component: usize,
473 holdover_explicit: bool,
474 state: Vec<Block>,
476 dims: (u32, u32),
477 tick: u64,
478 sinks: Vec<Box<dyn DebugSink>>,
479}
480
481impl Default for StabilityMask {
482 fn default() -> Self {
483 Self::new()
484 }
485}
486
487impl StabilityMask {
488 pub fn new() -> Self {
489 Self {
490 block: 16,
491 params: MaskParams {
492 stable_ticks: 6,
493 baseline_ticks: 100,
494 signature_tolerance: 10,
495 novelty_threshold: 18,
496 contrast_threshold: 14,
497 holdover_ticks: 12,
498 long_run: 18,
499 },
500 dilate: 1,
501 scene_threshold: 0.65,
502 fill_components: true,
503 min_component: 4,
504 holdover_explicit: false,
505 state: Vec::new(),
506 dims: (0, 0),
507 tick: 0,
508 sinks: Vec::new(),
509 }
510 }
511
512 pub fn block_size(mut self, px: u32) -> Self {
514 assert!(px > 0, "block size must be positive");
515 self.block = px;
516 self
517 }
518
519 pub fn stable_ticks(mut self, k: u32) -> Self {
523 let k = k.clamp(1, u16::MAX as u32) as u16;
524 self.params.stable_ticks = k;
525 self.params.long_run = k.saturating_mul(3);
526 if !self.holdover_explicit {
527 self.params.holdover_ticks = k.saturating_mul(2);
528 }
529 self
530 }
531
532 pub fn stable_for(self, rate: Rate, duration: Duration) -> Self {
535 let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
536 self.stable_ticks(ticks.max(1))
537 }
538
539 pub fn baseline_ticks(mut self, t: u32) -> Self {
542 self.params.baseline_ticks = t.max(1);
543 self
544 }
545
546 pub fn baseline(self, rate: Rate, duration: Duration) -> Self {
548 let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
549 self.baseline_ticks(ticks.max(1))
550 }
551
552 pub fn dilate(mut self, rings: u32) -> Self {
555 self.dilate = rings;
556 self
557 }
558
559 pub fn signature_tolerance(mut self, tol: u8) -> Self {
561 self.params.signature_tolerance = tol;
562 self
563 }
564
565 pub fn baseline_threshold(mut self, thr: u8) -> Self {
567 self.params.novelty_threshold = thr;
568 self
569 }
570
571 pub fn contrast_threshold(mut self, thr: u8) -> Self {
575 self.params.contrast_threshold = thr;
576 self
577 }
578
579 pub fn scene_threshold(mut self, frac: f32) -> Self {
583 assert!((0.0..=1.0).contains(&frac), "fraction in 0..=1");
584 self.scene_threshold = frac;
585 self
586 }
587
588 pub fn fill_components(mut self, on: bool) -> Self {
592 self.fill_components = on;
593 self
594 }
595
596 pub fn min_component(mut self, blocks: usize) -> Self {
599 self.min_component = blocks.max(1);
600 self
601 }
602
603 pub fn holdover_ticks(mut self, h: u32) -> Self {
606 self.params.holdover_ticks = h.clamp(1, u16::MAX as u32) as u16;
607 self.holdover_explicit = true;
608 self
609 }
610
611 pub fn debug_sink(mut self, sink: impl DebugSink) -> Self {
616 self.sinks.push(Box::new(sink));
617 self
618 }
619
620 fn grid(&self, w: u32, h: u32) -> (usize, usize) {
621 (
622 (w as usize).div_ceil(self.block as usize),
623 (h as usize).div_ceil(self.block as usize),
624 )
625 }
626}
627
628fn compute_signatures(
630 view: &FrameView<'_>,
631 block: u32,
632 cols: usize,
633 rows: usize,
634) -> Vec<Signature> {
635 let n = cols * rows;
636 let b = block as usize;
637 let mut sub_sum = vec![[0u64; 16]; n];
638 let mut sub_cnt = vec![[0u32; 16]; n];
639 let mut sum = vec![0u64; n];
640 let mut sum_sq = vec![0u64; n];
641 let mut cnt = vec![0u64; n];
642
643 for (y, row) in view.rows().enumerate() {
644 let br = y / b;
645 let sr = ((y % b) * 4) / b; for (x, px) in row.chunks_exact(4).enumerate() {
647 let bc = x / b;
648 let sc = ((x % b) * 4) / b;
649 let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
651 let bi = br * cols + bc;
652 let si = sr * 4 + sc;
653 sub_sum[bi][si] += luma as u64;
654 sub_cnt[bi][si] += 1;
655 sum[bi] += luma as u64;
656 sum_sq[bi] += (luma as u64) * (luma as u64);
657 cnt[bi] += 1;
658 }
659 }
660
661 (0..n)
662 .map(|i| {
663 let mut sig = [0u8; SIG_LEN];
664 let mean = sum[i].checked_div(cnt[i]).unwrap_or(0) as u8;
665 for s in 0..16 {
666 sig[s] = sub_sum[i][s]
668 .checked_div(sub_cnt[i][s] as u64)
669 .map_or(mean, |v| v as u8);
670 }
671 sig[16] = if cnt[i] == 0 {
672 0
673 } else {
674 let m = sum[i] as f64 / cnt[i] as f64;
675 let var = (sum_sq[i] as f64 / cnt[i] as f64) - m * m;
676 var.max(0.0).sqrt().min(255.0) as u8
677 };
678 Signature(sig)
679 })
680 .collect()
681}
682
683impl Preprocessor for StabilityMask {
684 fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
685 let (w, h) = (view.width(), view.height());
686 let (cols, rows) = self.grid(w, h);
687 if self.dims != (w, h) {
688 self.state = vec![Block::default(); cols * rows];
689 self.dims = (w, h);
690 }
691 let n = cols * rows;
692 let p = &self.params;
693
694 let sigs = compute_signatures(view, self.block, cols, rows);
696 let mut moving = vec![false; n];
697 let mut initialized = 0usize;
698 let mut idle = 0usize;
699 let mut perturbed = 0usize;
700 let mut census_eligible = vec![false; n];
701 let mut census_count = 0usize;
702 let mut would_enter = 0usize;
703 for i in 0..n {
704 let b = &self.state[i];
705 if !b.initialized {
706 continue;
707 }
708 initialized += 1;
709 if b.phase == Phase::Idle {
710 idle += 1;
711 }
712 let mv = is_moving(&sigs[i], &b.last, p.signature_tolerance);
713 moving[i] = mv;
714 if mv {
715 perturbed += 1;
716 } else {
717 if b.unstable_run >= p.stable_ticks {
718 census_eligible[i] = true;
719 census_count += 1;
720 }
721 if b.phase == Phase::Idle
724 && b.stable_ticks >= p.stable_ticks
725 && is_novel_vs(
726 &sigs[i],
727 &narrow(&b.base),
728 p.novelty_threshold,
729 p.contrast_threshold,
730 )
731 {
732 would_enter += 1;
733 }
734 }
735 }
736 let denom = initialized.max(1) as f32;
737 let frac = perturbed as f32 / denom;
738 let globals = Globals {
739 scene_tick: frac >= self.scene_threshold,
740 corroborated_tick: frac >= CORROBORATION_FRAC,
741 census_hit: (census_count as f32 / denom) >= CENSUS_FRAC,
742 drift_guard: (would_enter as f32 / idle.max(1) as f32) >= DRIFT_FRAC,
743 };
744
745 let mut keep_raw = vec![false; n];
747 for i in 0..n {
748 keep_raw[i] = step_block(
749 &mut self.state[i],
750 &sigs[i],
751 moving[i],
752 census_eligible[i],
753 &globals,
754 p,
755 );
756 }
757
758 let keep = if self.fill_components {
760 let filled = fill_components_bbox(&keep_raw, cols, rows, self.min_component);
761 if self.dilate == 0 {
762 filled
763 } else {
764 dilate_grid(&filled, cols, rows, self.dilate as usize)
765 }
766 } else {
767 close_and_dilate(&keep_raw, cols, rows, self.dilate)
768 };
769
770 let mut data = vec![0u8; w as usize * h as usize * 4];
772 for px in data.chunks_exact_mut(4) {
773 px[3] = 255;
774 }
775 for (y, row) in view.rows().enumerate() {
776 let br = y / self.block as usize;
777 for bc in 0..cols {
778 if !keep[br * cols + bc] {
779 continue;
780 }
781 let x0 = bc * self.block as usize * 4;
782 let x1 = (((bc + 1) * self.block as usize) * 4).min(row.len());
783 let dst = y * w as usize * 4;
784 data[dst + x0..dst + x1].copy_from_slice(&row[x0..x1]);
785 }
786 }
787 let output = Frame::new(w, h, data)
788 .map(Arc::new)
789 .map_err(|e| DetectorError::Other(format!("mask render: {e}")))?;
790
791 if !self.sinks.is_empty() {
792 self.emit_debug(view, &keep, cols, &output);
793 }
794 self.tick += 1;
795 Ok(output)
796 }
797
798 fn set_label(&mut self, label: &str) {
799 for sink in &mut self.sinks {
800 sink.set_label(label);
801 }
802 }
803}
804
805impl StabilityMask {
806 fn emit_debug(&mut self, view: &FrameView<'_>, keep: &[bool], cols: usize, output: &Frame) {
809 let (w, h) = (view.width(), view.height());
810 let input = Frame::new(w, h, view.to_vec()).expect("view-sized buffer");
811
812 let mut baseline = vec![0u8; w as usize * h as usize * 4];
817 let mut state = vec![0u8; w as usize * h as usize * 4];
818 let mut overlay = input.data().to_vec();
819 for y in 0..h as usize {
820 let br = y / self.block as usize;
821 for x in 0..w as usize {
822 let bc = x / self.block as usize;
823 let i = (y * w as usize + x) * 4;
824 let blk = &self.state[br * cols + bc];
825 let mean: u32 = blk.base[..16].iter().map(|v| (*v >> 8) as u32).sum::<u32>() / 16;
826 baseline[i] = mean as u8;
827 baseline[i + 1] = mean as u8;
828 baseline[i + 2] = mean as u8;
829 baseline[i + 3] = 255;
830 let bgra: [u8; 4] = if blk.reseeded {
831 [0, 0, 220, 255] } else {
833 match blk.phase {
834 Phase::Kept => [0, 200, 0, 255], Phase::Holdover => [0, 200, 200, 255], Phase::Idle if blk.base_frozen => [120, 40, 0, 255], Phase::Idle => [0, 0, 0, 255],
838 }
839 };
840 state[i..i + 4].copy_from_slice(&bgra);
841 if !keep[br * cols + bc] {
842 overlay[i] /= 4;
843 overlay[i + 1] /= 4;
844 overlay[i + 2] /= 4;
845 }
846 }
847 }
848 let baseline = Frame::new(w, h, baseline).expect("view-sized buffer");
849 let state = Frame::new(w, h, state).expect("view-sized buffer");
850 let overlay = Frame::new(w, h, overlay).expect("view-sized buffer");
851
852 for sink in &mut self.sinks {
853 sink.write(self.tick, DebugStage::Input, &input);
854 sink.write(self.tick, DebugStage::Baseline, &baseline);
855 sink.write(self.tick, DebugStage::Overlay, &overlay);
856 sink.write(self.tick, DebugStage::State, &state);
857 sink.write(self.tick, DebugStage::Output, output);
858 }
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865 use std::sync::Mutex;
866
867 fn params() -> MaskParams {
868 MaskParams {
869 stable_ticks: 3,
870 baseline_ticks: 20,
871 signature_tolerance: 10,
872 novelty_threshold: 18,
873 contrast_threshold: 14,
874 holdover_ticks: 6,
875 long_run: 9,
876 }
877 }
878
879 fn flat(luma: u8) -> Signature {
881 let mut s = [luma; SIG_LEN];
882 s[16] = 0;
883 s.into()
884 }
885
886 fn textured(luma: u8, spread: u8, c: u8) -> Signature {
888 let mut s = [0u8; SIG_LEN];
889 for (i, v) in s[..16].iter_mut().enumerate() {
890 *v = if i % 2 == 0 {
891 luma.saturating_add(spread)
892 } else {
893 luma.saturating_sub(spread)
894 };
895 }
896 s[16] = c;
897 Signature(s)
898 }
899
900 impl From<[u8; SIG_LEN]> for Signature {
901 fn from(v: [u8; SIG_LEN]) -> Self {
902 Signature(v)
903 }
904 }
905
906 const QUIET: Globals = Globals {
907 scene_tick: false,
908 corroborated_tick: false,
909 census_hit: false,
910 drift_guard: false,
911 };
912
913 fn run(b: &mut Block, script: &[(Signature, Globals)], p: &MaskParams) -> Vec<bool> {
916 script
917 .iter()
918 .map(|(sig, g)| {
919 let mv = b.initialized && is_moving(sig, &b.last, p.signature_tolerance);
920 let census = b.initialized && !mv && b.unstable_run >= p.stable_ticks;
921 step_block(b, sig, mv, census, g, p)
922 })
923 .collect()
924 }
925
926 fn quiet(sigs: &[Signature]) -> Vec<(Signature, Globals)> {
927 sigs.iter().map(|s| (*s, QUIET)).collect()
928 }
929
930 fn rep(sig: Signature, n: usize) -> Vec<Signature> {
931 vec![sig; n]
932 }
933
934 #[test]
937 fn single_component_noise_cannot_flip_movement_or_novelty() {
938 let a = flat(100);
939 let mut b = a;
940 b.0[3] = 140; assert!(!is_moving(&b, &a, 10), "one component is not movement");
942 assert!(
943 !is_novel_vs(&b, &a.0, 18, 14),
944 "one component is not novelty"
945 );
946 b.0[7] = 140; assert!(is_moving(&b, &a, 10));
948 assert!(is_novel_vs(&b, &a.0, 18, 14));
949 }
950
951 #[test]
952 fn dark_on_dark_flat_panel_is_novel_via_contrast() {
953 let scene = textured(30, 12, 40);
957 let panel = flat(30);
958 assert!(
959 is_novel_vs(&panel, &scene.0, 18, 14),
960 "flat panel over textured scene must be novel"
961 );
962 }
963
964 #[test]
965 fn signatures_capture_subblock_structure() {
966 let frame = Frame::from_fn(16, 16, |x, _| {
967 if x < 8 {
968 [0, 0, 0, 255]
969 } else {
970 [255, 255, 255, 255]
971 }
972 });
973 let view = frame
974 .view(visual_cortex_capture::PxRect {
975 x: 0,
976 y: 0,
977 w: 16,
978 h: 16,
979 })
980 .unwrap();
981 let sigs = compute_signatures(&view, 16, 1, 1);
982 let s = &sigs[0].0;
983 assert!(s[0] < 10 && s[1] < 10, "left sub-cells dark");
984 assert!(s[2] > 245 && s[3] > 245, "right sub-cells bright");
985 assert!(s[16] > 100, "half-and-half block has high contrast");
986 }
987
988 #[test]
991 fn constant_hud_block_is_never_kept() {
992 let mut b = Block::default();
993 let out = run(&mut b, &quiet(&rep(textured(100, 20, 30), 30)), ¶ms());
994 assert!(out.iter().all(|k| !k), "HUD must stay suppressed");
995 }
996
997 #[test]
998 fn noisy_gameplay_block_is_never_kept() {
999 let mut b = Block::default();
1000 let sigs: Vec<Signature> = (0..30)
1001 .map(|i| flat(if i % 2 == 0 { 40 } else { 200 }))
1002 .collect();
1003 let out = run(&mut b, &quiet(&sigs), ¶ms());
1004 assert!(out.iter().all(|k| !k), "churn must stay suppressed");
1005 }
1006
1007 #[test]
1008 fn tooltip_becomes_kept_after_k_stable_ticks() {
1009 let mut b = Block::default();
1010 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1011 let out = run(&mut b, &quiet(&rep(flat(200), 6)), ¶ms());
1012 assert_eq!(out, vec![false, false, false, true, true, true]);
1014 assert_eq!(b.phase, Phase::Kept);
1015 }
1016
1017 #[test]
1018 fn held_tooltip_survives_indefinitely() {
1019 let mut b = Block::default();
1020 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1021 let out = run(&mut b, &quiet(&rep(flat(200), 200)), ¶ms());
1022 assert!(out[10..].iter().all(|k| *k), "no ghosting, ever");
1023 assert!(b.base_frozen, "baseline frozen under the kept overlay");
1024 }
1025
1026 #[test]
1027 fn dismissed_tooltip_thaws_and_suppresses() {
1028 let mut b = Block::default();
1029 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1030 run(&mut b, &quiet(&rep(flat(200), 10)), ¶ms());
1031 let out = run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1032 assert_eq!(b.phase, Phase::Idle);
1033 assert!(!b.base_frozen, "baseline resumed after departure");
1034 assert!(!out.last().unwrap(), "restored scene suppressed");
1035 }
1036
1037 #[test]
1040 fn browsing_hand_off_never_goes_dark() {
1041 let mut b = Block::default();
1045 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1046 run(&mut b, &quiet(&rep(flat(200), 10)), ¶ms()); let mut script = rep(flat(150), 1); script.extend(rep(flat(230), 8)); let out = run(&mut b, &quiet(&script), ¶ms());
1050 assert!(
1051 out.iter().all(|k| *k),
1052 "hand-off must render every tick, got {out:?}"
1053 );
1054 assert_eq!(b.phase, Phase::Kept, "re-latched onto tooltip B");
1055 }
1056
1057 #[test]
1058 fn flash_over_held_tooltip_relatches_without_reseed() {
1059 let p = params();
1064 let mut b = Block::default();
1065 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1066 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let flash = Globals {
1068 scene_tick: true,
1069 corroborated_tick: true,
1070 census_hit: false,
1071 drift_guard: false,
1072 };
1073 let script = vec![
1074 (flat(255), flash),
1075 (flat(255), flash),
1076 (flat(200), QUIET), (flat(200), QUIET),
1078 ];
1079 let out = run(&mut b, &script, &p);
1080 assert!(out.iter().all(|k| *k), "renders through the flash: {out:?}");
1081 assert_eq!(b.phase, Phase::Kept, "immediate ovl re-latch");
1082 assert!(!b.reseeded, "a flash is not a scene change");
1083 assert!(b.base_frozen, "invariant intact: base still frozen");
1084 }
1085
1086 #[test]
1087 fn reveal_after_occluded_scene_change_resolves_on_next_motion() {
1088 let p = params();
1095 let mut b = Block::default();
1096 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1097 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let scene = Globals {
1099 scene_tick: true,
1100 corroborated_tick: true,
1101 census_hit: false,
1102 drift_guard: false,
1103 };
1104 run(&mut b, &[(flat(150), scene)], &p); run(&mut b, &quiet(&rep(flat(200), 6)), &p); assert_eq!(b.phase, Phase::Kept, "tooltip survives the event");
1107 let out = run(&mut b, &quiet(&rep(flat(120), 8)), &p);
1109 assert!(*out.last().unwrap(), "ambiguous reveal stays rendered");
1110 let walking = Globals {
1112 scene_tick: false,
1113 corroborated_tick: true,
1114 census_hit: false,
1115 drift_guard: false,
1116 };
1117 let script: Vec<(Signature, Globals)> = (0..12)
1118 .map(|i| (flat(if i % 2 == 0 { 80 } else { 170 }), walking))
1119 .collect();
1120 run(&mut b, &script, &p);
1121 let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1122 assert!(!out.last().unwrap(), "re-anchored after motion");
1123 let base = narrow(&b.base);
1124 assert!(
1125 base[0].abs_diff(140) < 10,
1126 "baseline follows the real scene"
1127 );
1128 }
1129
1130 #[test]
1131 fn browsing_hand_off_survives_a_prior_flash() {
1132 let p = params();
1136 let mut b = Block::default();
1137 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1138 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let flash = Globals {
1140 scene_tick: true,
1141 corroborated_tick: true,
1142 census_hit: false,
1143 drift_guard: false,
1144 };
1145 run(&mut b, &[(flat(255), flash), (flat(255), flash)], &p);
1146 run(&mut b, &quiet(&rep(flat(200), 4)), &p); assert_eq!(b.phase, Phase::Kept);
1148 let mut script = rep(flat(150), 1);
1150 script.extend(rep(flat(230), 8));
1151 let out = run(&mut b, &quiet(&script), &p);
1152 assert!(
1153 out.iter().all(|k| *k),
1154 "hand-off renders throughout: {out:?}"
1155 );
1156 assert_eq!(b.phase, Phase::Kept, "tooltip B kept");
1157 let base = narrow(&b.base);
1158 assert!(
1159 base[0].abs_diff(60) < 10,
1160 "baseline still the scene, never overlay content: base={} frozen={} scene_flag={}",
1161 base[0],
1162 b.base_frozen,
1163 b.scene_flag
1164 );
1165 }
1166
1167 #[test]
1168 fn walk_and_stop_reseeds_instead_of_keeping() {
1169 let p = params();
1172 let mut b = Block::default();
1173 run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1174 let walking = Globals {
1175 scene_tick: false,
1176 corroborated_tick: true, census_hit: false,
1178 drift_guard: false,
1179 };
1180 let script: Vec<(Signature, Globals)> = (0..12)
1181 .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), walking))
1182 .collect();
1183 run(&mut b, &script, &p); let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1185 assert!(
1186 out.iter().all(|k| !k),
1187 "new scene must not be kept: {out:?}"
1188 );
1189 let base = narrow(&b.base);
1190 assert!(base[0].abs_diff(140) < 10, "re-seeded to the new scene");
1191 }
1192
1193 #[test]
1194 fn fast_browsing_churn_is_not_corroborated_and_final_tooltip_is_kept() {
1195 let p = params();
1200 let mut b = Block::default();
1201 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1202 let script: Vec<Signature> = (0..20)
1204 .map(|i| flat(if i % 2 == 0 { 200 } else { 120 }))
1205 .collect();
1206 run(&mut b, &quiet(&script), &p);
1207 assert!(!b.run_corroborated, "local churn is never corroborated");
1208 let out = run(&mut b, &quiet(&rep(flat(200), 6)), &p);
1210 assert!(*out.last().unwrap(), "final tooltip must be kept: {out:?}");
1211 assert_eq!(b.phase, Phase::Kept);
1212 }
1213
1214 #[test]
1215 fn minimap_pulse_train_is_never_kept() {
1216 let p = params();
1219 let mut b = Block::default();
1220 let mut script = Vec::new();
1221 for _ in 0..6 {
1222 script.extend(rep(textured(80, 15, 25), 8)); script.extend(rep(textured(140, 15, 25), 2)); }
1225 let out = run(&mut b, &quiet(&script), &p);
1226 assert!(out.iter().all(|k| !k), "pulsing HUD must stay suppressed");
1227 }
1228
1229 #[test]
1230 fn census_flag_reseeds_short_global_motion() {
1231 let p = params();
1234 let mut b = Block::default();
1235 run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1236 let script: Vec<(Signature, Globals)> = (0..5)
1237 .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), QUIET))
1238 .collect();
1239 run(&mut b, &script, &p); let census = Globals {
1241 scene_tick: false,
1242 corroborated_tick: false,
1243 census_hit: true,
1244 drift_guard: false,
1245 };
1246 let mut settle = vec![(flat(140), census); 3];
1249 settle.extend(quiet(&rep(flat(140), 6)));
1250 let out = run(&mut b, &settle, &p);
1251 assert!(out.iter().all(|k| !k), "census settle re-seeds: {out:?}");
1252 }
1253
1254 #[test]
1255 fn drift_guard_reseeds_slow_drift_but_never_pop_ins() {
1256 let p = params();
1257 let guard = Globals {
1258 scene_tick: false,
1259 corroborated_tick: false,
1260 census_hit: false,
1261 drift_guard: true,
1262 };
1263 let mut b = Block::default();
1267 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1268 let script: Vec<(Signature, Globals)> =
1269 (0..12).map(|i| (flat(60 + 8 * (i + 1)), guard)).collect();
1270 let out = run(&mut b, &script, &p);
1271 assert!(out.iter().all(|k| !k), "ambient fade must not be kept");
1272 let mut b = Block::default();
1275 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1276 let script: Vec<(Signature, Globals)> =
1277 rep(flat(200), 6).into_iter().map(|s| (s, guard)).collect();
1278 let out = run(&mut b, &script, &p);
1279 assert!(*out.last().unwrap(), "pop-in is kept even under the guard");
1280 }
1281
1282 #[test]
1283 fn holdover_expires_to_idle_when_content_keeps_churning() {
1284 let p = params();
1285 let mut b = Block::default();
1286 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1287 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let script: Vec<Signature> = (0..12)
1290 .map(|i| flat(if i % 2 == 0 { 90 } else { 170 }))
1291 .collect();
1292 let out = run(&mut b, &quiet(&script), &p);
1293 assert!(out[0], "holdover renders at first");
1294 assert!(!out.last().unwrap(), "expired holdover suppresses");
1295 assert_eq!(b.phase, Phase::Idle);
1296 }
1297
1298 #[test]
1301 fn component_fill_drops_speckle_and_fills_bboxes() {
1302 let cols = 6;
1304 let mut keep = vec![false; 36];
1305 for (r, c) in [(1, 1), (2, 1), (3, 1), (3, 2), (3, 3)] {
1306 keep[r * cols + c] = true;
1307 }
1308 keep[5 * cols + 5] = true; let out = fill_components_bbox(&keep, cols, 6, 4);
1310 assert!(out[cols + 2] && out[cols + 3], "bbox filled the L's notch");
1311 assert!(!out[5 * cols + 5], "speckle dropped");
1312 }
1313
1314 #[test]
1315 fn close_fills_interior_holes_and_dilate_expands() {
1316 let cols = 5;
1317 let mut keep = vec![false; 25];
1318 for (r, c) in [
1319 (1usize, 1usize),
1320 (1, 2),
1321 (1, 3),
1322 (2, 1),
1323 (2, 3),
1324 (3, 1),
1325 (3, 2),
1326 (3, 3),
1327 ] {
1328 keep[r * cols + c] = true;
1329 }
1330 let out = close_and_dilate(&keep, cols, 5, 0);
1331 assert!(out[2 * cols + 2], "interior hole must be closed");
1332 let out = close_and_dilate(&keep, cols, 5, 1);
1333 assert!(out[cols], "edge cell reached by one dilation ring");
1334 assert!(
1335 !out[0],
1336 "corner is two steps away; one ring must not reach it"
1337 );
1338 }
1339
1340 #[test]
1341 fn cold_start_keeps_nothing() {
1342 let mut b = Block::default();
1343 assert!(!step_block(
1344 &mut b,
1345 &flat(200),
1346 false,
1347 false,
1348 &QUIET,
1349 ¶ms()
1350 ));
1351 }
1352
1353 #[test]
1360 fn large_pop_in_covering_most_of_the_region_is_revealed() {
1361 use visual_cortex_capture::PxRect;
1362 let mut mask = StabilityMask::new()
1363 .block_size(8)
1364 .stable_ticks(3)
1365 .min_component(1)
1366 .dilate(0);
1367 let full = PxRect {
1368 x: 0,
1369 y: 0,
1370 w: 48,
1371 h: 48,
1372 }; let bg = Frame::solid(48, 48, [60, 60, 60, 255]);
1375 let tip = Frame::from_fn(48, 48, |x, y| {
1376 if y < 24 || (y < 32 && x < 32) {
1377 [230, 230, 230, 255]
1378 } else {
1379 [60, 60, 60, 255]
1380 }
1381 });
1382 for _ in 0..8 {
1383 mask.process(&bg.view(full).unwrap()).unwrap();
1384 }
1385 let mut last = None;
1386 for _ in 0..8 {
1387 last = Some(mask.process(&tip.view(full).unwrap()).unwrap());
1388 }
1389 let out = last.unwrap();
1390 let px = &out.data()[(4 * 48 + 4) * 4..(4 * 48 + 4) * 4 + 3];
1391 assert_eq!(px, &[230, 230, 230], "pop-in region revealed, got {px:?}");
1392 }
1393
1394 #[test]
1397 fn builder_derives_holdover_and_long_run_from_stable_ticks() {
1398 let m = StabilityMask::new().stable_ticks(5);
1399 assert_eq!(m.params.holdover_ticks, 10, "H = 2K");
1400 assert_eq!(m.params.long_run, 15, "U = 3K");
1401 let m = StabilityMask::new().holdover_ticks(5).stable_ticks(10);
1403 assert_eq!(m.params.holdover_ticks, 5, "explicit H wins");
1404 assert_eq!(m.params.long_run, 30);
1405 let m = StabilityMask::new().stable_ticks(300);
1407 assert_eq!(m.params.holdover_ticks, 600);
1408 }
1409
1410 #[test]
1413 fn partial_edge_blocks_average_available_pixels_only() {
1414 use visual_cortex_capture::PxRect;
1415 let frame = Frame::from_fn(19, 13, |x, _| {
1419 if x < 16 {
1420 [40, 40, 40, 255]
1421 } else {
1422 [220, 220, 220, 255]
1423 }
1424 });
1425 let view = frame
1426 .view(PxRect {
1427 x: 0,
1428 y: 0,
1429 w: 19,
1430 h: 13,
1431 })
1432 .unwrap();
1433 let sigs = compute_signatures(&view, 16, 2, 1);
1434 assert!(
1438 sigs[1].0[..16].iter().all(|v| *v > 200),
1439 "partial block averages available pixels: {:?}",
1440 sigs[1].0
1441 );
1442 assert!(sigs[0].0[..16].iter().all(|v| *v < 60));
1443 }
1444
1445 #[test]
1448 fn debug_sinks_receive_all_five_stages_per_tick() {
1449 struct Recording(Arc<Mutex<Vec<(u64, DebugStage)>>>);
1450 impl DebugSink for Recording {
1451 fn write(&mut self, tick: u64, stage: DebugStage, _frame: &Frame) {
1452 self.0.lock().unwrap().push((tick, stage));
1453 }
1454 }
1455
1456 let log = Arc::new(Mutex::new(Vec::new()));
1457 let mut mask = StabilityMask::new()
1458 .block_size(8)
1459 .debug_sink(Recording(log.clone()));
1460 let frame = Frame::solid(8, 8, [60, 60, 60, 255]);
1461 let view = frame
1462 .view(visual_cortex_capture::PxRect {
1463 x: 0,
1464 y: 0,
1465 w: 8,
1466 h: 8,
1467 })
1468 .unwrap();
1469 mask.process(&view).unwrap();
1470 mask.process(&view).unwrap();
1471
1472 use DebugStage::*;
1473 assert_eq!(
1474 *log.lock().unwrap(),
1475 vec![
1476 (0, Input),
1477 (0, Baseline),
1478 (0, Overlay),
1479 (0, State),
1480 (0, Output),
1481 (1, Input),
1482 (1, Baseline),
1483 (1, Overlay),
1484 (1, State),
1485 (1, Output),
1486 ]
1487 );
1488 }
1489}