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
401#[derive(Debug, Clone, PartialEq, Eq)]
403pub(crate) struct Component {
404 pub members: Vec<usize>,
406 pub bbox: (usize, usize, usize, usize),
408}
409
410pub(crate) fn components(
413 keep: &[bool],
414 cols: usize,
415 rows: usize,
416 min_size: usize,
417) -> Vec<Component> {
418 let mut out = Vec::new();
419 let mut seen = vec![false; keep.len()];
420 let mut stack = Vec::new();
421 for start in 0..keep.len() {
422 if !keep[start] || seen[start] {
423 continue;
424 }
425 let (mut r0, mut r1, mut c0, mut c1) = (rows, 0usize, cols, 0usize);
426 let mut members = Vec::new();
427 stack.push(start);
428 seen[start] = true;
429 while let Some(i) = stack.pop() {
430 members.push(i);
431 let (r, c) = (i / cols, i % cols);
432 r0 = r0.min(r);
433 r1 = r1.max(r);
434 c0 = c0.min(c);
435 c1 = c1.max(c);
436 let mut push = |j: usize| {
437 if keep[j] && !seen[j] {
438 seen[j] = true;
439 stack.push(j);
440 }
441 };
442 if r > 0 {
443 push(i - cols);
444 }
445 if r + 1 < rows {
446 push(i + cols);
447 }
448 if c > 0 {
449 push(i - 1);
450 }
451 if c + 1 < cols {
452 push(i + 1);
453 }
454 }
455 if members.len() >= min_size {
456 out.push(Component {
457 members,
458 bbox: (r0, r1, c0, c1),
459 });
460 }
461 }
462 out
463}
464
465pub(crate) fn fill_components_bbox(
468 keep: &[bool],
469 cols: usize,
470 rows: usize,
471 min_size: usize,
472) -> Vec<bool> {
473 let mut out = vec![false; keep.len()];
474 for comp in components(keep, cols, rows, min_size) {
475 let (r0, r1, c0, c1) = comp.bbox;
476 for r in r0..=r1 {
477 for c in c0..=c1 {
478 out[r * cols + c] = true;
479 }
480 }
481 }
482 out
483}
484
485const PANEL_MOVING_FRAC: f32 = 0.20;
489const PANEL_DENSITY_FLOOR: f32 = 0.35;
494const PANEL_GROWTH_SLACK: f32 = 0.02;
500const PANEL_OVERLAP_FRAC: f32 = 0.30;
503const PANEL_PENDING_DEADLINE_K: u16 = 8;
506const PANEL_ONSET_WINDOW: u16 = 3;
513
514#[derive(Debug, Clone)]
515pub(crate) struct Panel {
516 members: Vec<usize>,
520 sticky: (usize, usize, usize, usize),
523 presented: bool,
524 quiet: u16,
526 age: u16,
528 last_kept: usize,
530 no_growth: u16,
532 low_support: u16,
535}
536
537#[derive(Debug, Default)]
546pub(crate) struct PanelTracker {
547 panels: Vec<Panel>,
548}
549
550fn bbox_of(
551 blocks: impl Iterator<Item = usize>,
552 cols: usize,
553) -> Option<(usize, usize, usize, usize)> {
554 let mut b: Option<(usize, usize, usize, usize)> = None;
555 for i in blocks {
556 let (r, c) = (i / cols, i % cols);
557 b = Some(match b {
558 None => (r, r, c, c),
559 Some((r0, r1, c0, c1)) => (r0.min(r), r1.max(r), c0.min(c), c1.max(c)),
560 });
561 }
562 b
563}
564
565impl PanelTracker {
566 pub(crate) fn reset(&mut self) {
567 self.panels.clear();
568 }
569
570 pub(crate) fn panels(&self) -> impl Iterator<Item = (bool, (usize, usize, usize, usize))> + '_ {
571 self.panels.iter().map(|p| (p.presented, p.sticky))
572 }
573
574 pub(crate) fn tick(
585 &mut self,
586 moving: &[bool],
587 keep_raw: &[bool],
588 cols: usize,
589 rows: usize,
590 k_panel: u16,
591 min_component: usize,
592 ) -> Vec<bool> {
593 let n = cols * rows;
594 let mut owner = vec![usize::MAX; n];
600 for pass in 0..2 {
607 for (pi, p) in self.panels.iter().enumerate() {
608 let settled_content = p.quiet >= k_panel && p.last_kept >= min_component;
609 let routable = if pass == 0 {
610 !p.presented && (p.age < PANEL_ONSET_WINDOW || settled_content)
611 } else {
612 p.presented
613 };
614 if routable {
615 for &m in &p.members {
616 owner[m] = pi;
617 }
618 }
619 }
620 }
621 let core = owner.clone();
622 for (i, &own) in core.iter().enumerate() {
623 if own == usize::MAX {
624 continue;
625 }
626 let (r, c) = (i / cols, i % cols);
627 let mut mark = |j: usize| {
628 if owner[j] == usize::MAX {
629 owner[j] = own;
630 }
631 };
632 if r > 0 {
633 mark(i - cols);
634 }
635 if r + 1 < rows {
636 mark(i + cols);
637 }
638 if c > 0 {
639 mark(i - 1);
640 }
641 if c + 1 < cols {
642 mark(i + 1);
643 }
644 }
645
646 let len = self.panels.len();
654 let mut extend: Vec<Vec<usize>> = vec![Vec::new(); len];
655 let mut dead = vec![false; len];
656 let mut redirect: Vec<usize> = (0..len).collect();
657 let mut fresh: Vec<Component> = Vec::new();
658 fn resolve(redirect: &[usize], mut i: usize) -> usize {
659 while redirect[i] != i {
660 i = redirect[i];
661 }
662 i
663 }
664 for comp in components(moving, cols, rows, 1) {
665 let mut counts = vec![0usize; len];
666 for &m in &comp.members {
667 if owner[m] != usize::MAX {
668 counts[resolve(&redirect, owner[m])] += 1;
669 }
670 }
671 let mut matched: Vec<usize> = (0..len)
672 .filter(|&pi| counts[pi] > 0 && !dead[pi])
673 .filter(|&pi| {
674 let p = &self.panels[pi];
675 !p.presented
676 || counts[pi] as f32 / comp.members.len() as f32 >= PANEL_OVERLAP_FRAC
677 })
678 .collect();
679 if matched.is_empty() {
680 if comp.members.len() >= min_component {
681 fresh.push(comp);
682 }
683 continue;
684 }
685 matched.sort_by_key(|&pi| (!self.panels[pi].presented, pi));
688 let survivor = matched[0];
689 for &victim in &matched[1..] {
690 let (v_members, v_presented, v_sticky, v_last) = {
691 let v = &mut self.panels[victim];
692 (
693 std::mem::take(&mut v.members),
694 v.presented,
695 v.sticky,
696 v.last_kept,
697 )
698 };
699 let moved = std::mem::take(&mut extend[victim]);
700 let sv = &mut self.panels[survivor];
701 sv.members.extend(v_members);
702 sv.last_kept += v_last;
703 if v_presented {
704 sv.presented = true;
705 let a = sv.sticky;
706 sv.sticky = (
707 a.0.min(v_sticky.0),
708 a.1.max(v_sticky.1),
709 a.2.min(v_sticky.2),
710 a.3.max(v_sticky.3),
711 );
712 }
713 extend[survivor].extend(moved);
714 dead[victim] = true;
715 redirect[victim] = survivor;
716 }
717 extend[survivor].extend_from_slice(&comp.members);
718 }
719
720 let mut in_fresh = vec![false; n];
723 for comp in &fresh {
724 for &m in &comp.members {
725 in_fresh[m] = true;
726 }
727 }
728 for (pi, p) in self.panels.iter().enumerate() {
729 if dead[pi] || p.presented || !extend[pi].is_empty() {
730 continue;
731 }
732 let covered = p.members.iter().filter(|&&m| in_fresh[m]).count();
733 if covered * 2 >= p.members.len() {
734 dead[pi] = true;
735 }
736 }
737
738 let deadline = k_panel.saturating_mul(PANEL_PENDING_DEADLINE_K);
740 for (pi, p) in self.panels.iter_mut().enumerate() {
741 if dead[pi] {
742 continue;
743 }
744 if !p.presented && p.age >= deadline {
745 dead[pi] = true; continue;
747 }
748 if !extend[pi].is_empty() {
755 p.members.extend_from_slice(&extend[pi]);
756 p.members.sort_unstable();
757 p.members.dedup();
758 }
759 let total = p.members.len().max(1) as f32;
760 let mv = p.members.iter().filter(|&&m| moving[m]).count() as f32;
761 if mv / total <= PANEL_MOVING_FRAC {
762 p.quiet = p.quiet.saturating_add(1);
763 } else {
764 p.quiet = 0;
765 p.no_growth = 0;
766 }
767 p.age = p.age.saturating_add(1);
768
769 let support = p.members.iter().filter(|&&m| keep_raw[m]).count();
774 if p.presented {
775 if support < min_component {
776 p.low_support = p.low_support.saturating_add(1);
777 if p.low_support >= k_panel {
778 dead[pi] = true; continue;
780 }
781 } else {
782 p.low_support = 0;
783 }
784 if let Some(b) = bbox_of(p.members.iter().copied(), cols) {
786 let sk = p.sticky;
787 p.sticky = (sk.0.min(b.0), sk.1.max(b.1), sk.2.min(b.2), sk.3.max(b.3));
788 }
789 }
790
791 if p.quiet >= k_panel {
792 let kept: Vec<usize> = p.members.iter().copied().filter(|&m| keep_raw[m]).collect();
796 let slack = ((p.members.len() as f32 * PANEL_GROWTH_SLACK) as usize).max(2);
802 let growing = kept.len() > p.last_kept.saturating_add(slack);
803 p.last_kept = kept.len();
804 p.no_growth = if growing {
805 0
806 } else {
807 p.no_growth.saturating_add(1)
808 };
809 let dense_bbox = bbox_of(kept.iter().copied(), cols).filter(|b| {
810 let area = ((b.1 - b.0 + 1) * (b.3 - b.2 + 1)) as f32;
811 kept.len() as f32 / area >= PANEL_DENSITY_FLOOR
812 });
813 if p.presented {
814 if let Some(b) = dense_bbox {
815 if p.no_growth >= 2 {
816 p.sticky = b; }
818 }
819 } else if kept.len() >= min_component && p.no_growth >= 2 {
820 if let Some(b) = dense_bbox {
825 p.presented = true;
826 p.sticky = b;
827 }
828 }
829 }
830 }
831 let mut di = dead.iter();
832 self.panels.retain(|_| !*di.next().unwrap());
833
834 for comp in fresh {
836 self.panels.push(Panel {
837 sticky: comp.bbox,
838 members: comp.members,
839 presented: false,
840 quiet: 0,
841 age: 0,
842 last_kept: 0,
843 no_growth: 0,
844 low_support: 0,
845 });
846 }
847
848 let mut member_of = vec![false; n];
854 for p in &self.panels {
855 for &m in &p.members {
856 member_of[m] = true;
857 }
858 }
859 let orphan: Vec<bool> = (0..n)
860 .map(|i| keep_raw[i] && !member_of[i] && !moving[i])
861 .collect();
862 for comp in components(&orphan, cols, rows, min_component) {
863 self.panels.push(Panel {
864 sticky: comp.bbox,
865 members: comp.members,
866 presented: false,
867 quiet: 0,
868 age: 0,
869 last_kept: 0,
870 no_growth: 0,
871 low_support: 0,
872 });
873 }
874
875 if std::env::var_os("VC_PANEL_TRACE").is_some() {
878 for (pi, p) in self.panels.iter().enumerate() {
879 if p.members.len() >= 40 {
880 let mv = p.members.iter().filter(|&&m| moving[m]).count();
881 let kept = p.members.iter().filter(|&&m| keep_raw[m]).count();
882 eprintln!(
883 "PANEL {pi}: presented={} age={} quiet={} members={} moving={} kept={} last_kept={} sticky={:?}",
884 p.presented, p.age, p.quiet, p.members.len(), mv, kept, p.last_kept, p.sticky
885 );
886 }
887 }
888 eprintln!("PANEL --- tick done ({} panels)", self.panels.len());
889 }
890
891 let mut out = vec![false; n];
892 for p in self.panels.iter().filter(|p| p.presented) {
893 let (r0, r1, c0, c1) = p.sticky;
894 for r in r0..=r1 {
895 for c in c0..=c1 {
896 out[r * cols + c] = true;
897 }
898 }
899 }
900 out
901 }
902}
903
904pub struct StabilityMask {
910 block: u32,
911 params: MaskParams,
912 dilate: u32,
913 scene_threshold: f32,
914 fill_components: bool,
915 min_component: usize,
916 atomic_panels: bool,
917 tracker: PanelTracker,
918 holdover_explicit: bool,
919 state: Vec<Block>,
921 dims: (u32, u32),
922 tick: u64,
923 sinks: Vec<Box<dyn DebugSink>>,
924}
925
926impl Default for StabilityMask {
927 fn default() -> Self {
928 Self::new()
929 }
930}
931
932impl StabilityMask {
933 pub fn new() -> Self {
934 Self {
935 block: 16,
936 params: MaskParams {
937 stable_ticks: 6,
938 baseline_ticks: 100,
939 signature_tolerance: 10,
940 novelty_threshold: 18,
941 contrast_threshold: 14,
942 holdover_ticks: 12,
943 long_run: 18,
944 },
945 dilate: 1,
946 scene_threshold: 0.65,
947 fill_components: true,
948 min_component: 4,
949 atomic_panels: true,
950 tracker: PanelTracker::default(),
951 holdover_explicit: false,
952 state: Vec::new(),
953 dims: (0, 0),
954 tick: 0,
955 sinks: Vec::new(),
956 }
957 }
958
959 pub fn block_size(mut self, px: u32) -> Self {
961 assert!(px > 0, "block size must be positive");
962 self.block = px;
963 self
964 }
965
966 pub fn stable_ticks(mut self, k: u32) -> Self {
970 let k = k.clamp(1, u16::MAX as u32) as u16;
971 self.params.stable_ticks = k;
972 self.params.long_run = k.saturating_mul(3);
973 if !self.holdover_explicit {
974 self.params.holdover_ticks = k.saturating_mul(2);
975 }
976 self
977 }
978
979 pub fn stable_for(self, rate: Rate, duration: Duration) -> Self {
982 let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
983 self.stable_ticks(ticks.max(1))
984 }
985
986 pub fn baseline_ticks(mut self, t: u32) -> Self {
989 self.params.baseline_ticks = t.max(1);
990 self
991 }
992
993 pub fn baseline(self, rate: Rate, duration: Duration) -> Self {
995 let ticks = (duration.as_secs_f64() / rate.period().as_secs_f64()).ceil() as u32;
996 self.baseline_ticks(ticks.max(1))
997 }
998
999 pub fn dilate(mut self, rings: u32) -> Self {
1002 self.dilate = rings;
1003 self
1004 }
1005
1006 pub fn signature_tolerance(mut self, tol: u8) -> Self {
1008 self.params.signature_tolerance = tol;
1009 self
1010 }
1011
1012 pub fn baseline_threshold(mut self, thr: u8) -> Self {
1014 self.params.novelty_threshold = thr;
1015 self
1016 }
1017
1018 pub fn contrast_threshold(mut self, thr: u8) -> Self {
1022 self.params.contrast_threshold = thr;
1023 self
1024 }
1025
1026 pub fn scene_threshold(mut self, frac: f32) -> Self {
1030 assert!((0.0..=1.0).contains(&frac), "fraction in 0..=1");
1031 self.scene_threshold = frac;
1032 self
1033 }
1034
1035 pub fn atomic_panels(mut self, on: bool) -> Self {
1041 self.atomic_panels = on;
1042 self
1043 }
1044
1045 pub fn fill_components(mut self, on: bool) -> Self {
1049 self.fill_components = on;
1050 self
1051 }
1052
1053 pub fn min_component(mut self, blocks: usize) -> Self {
1056 self.min_component = blocks.max(1);
1057 self
1058 }
1059
1060 pub fn holdover_ticks(mut self, h: u32) -> Self {
1063 self.params.holdover_ticks = h.clamp(1, u16::MAX as u32) as u16;
1064 self.holdover_explicit = true;
1065 self
1066 }
1067
1068 pub fn debug_sink(mut self, sink: impl DebugSink) -> Self {
1073 self.sinks.push(Box::new(sink));
1074 self
1075 }
1076
1077 fn grid(&self, w: u32, h: u32) -> (usize, usize) {
1078 (
1079 (w as usize).div_ceil(self.block as usize),
1080 (h as usize).div_ceil(self.block as usize),
1081 )
1082 }
1083}
1084
1085fn compute_signatures(
1087 view: &FrameView<'_>,
1088 block: u32,
1089 cols: usize,
1090 rows: usize,
1091) -> Vec<Signature> {
1092 let n = cols * rows;
1093 let b = block as usize;
1094 let mut sub_sum = vec![[0u64; 16]; n];
1095 let mut sub_cnt = vec![[0u32; 16]; n];
1096 let mut sum = vec![0u64; n];
1097 let mut sum_sq = vec![0u64; n];
1098 let mut cnt = vec![0u64; n];
1099
1100 for (y, row) in view.rows().enumerate() {
1101 let br = y / b;
1102 let sr = ((y % b) * 4) / b; for (x, px) in row.chunks_exact(4).enumerate() {
1104 let bc = x / b;
1105 let sc = ((x % b) * 4) / b;
1106 let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
1108 let bi = br * cols + bc;
1109 let si = sr * 4 + sc;
1110 sub_sum[bi][si] += luma as u64;
1111 sub_cnt[bi][si] += 1;
1112 sum[bi] += luma as u64;
1113 sum_sq[bi] += (luma as u64) * (luma as u64);
1114 cnt[bi] += 1;
1115 }
1116 }
1117
1118 (0..n)
1119 .map(|i| {
1120 let mut sig = [0u8; SIG_LEN];
1121 let mean = sum[i].checked_div(cnt[i]).unwrap_or(0) as u8;
1122 for s in 0..16 {
1123 sig[s] = sub_sum[i][s]
1125 .checked_div(sub_cnt[i][s] as u64)
1126 .map_or(mean, |v| v as u8);
1127 }
1128 sig[16] = if cnt[i] == 0 {
1129 0
1130 } else {
1131 let m = sum[i] as f64 / cnt[i] as f64;
1132 let var = (sum_sq[i] as f64 / cnt[i] as f64) - m * m;
1133 var.max(0.0).sqrt().min(255.0) as u8
1134 };
1135 Signature(sig)
1136 })
1137 .collect()
1138}
1139
1140impl Preprocessor for StabilityMask {
1141 fn process(&mut self, view: &FrameView<'_>) -> Result<Arc<Frame>, DetectorError> {
1142 let (w, h) = (view.width(), view.height());
1143 let (cols, rows) = self.grid(w, h);
1144 if self.dims != (w, h) {
1145 self.state = vec![Block::default(); cols * rows];
1146 self.tracker.reset();
1147 self.dims = (w, h);
1148 }
1149 let n = cols * rows;
1150 let p = &self.params;
1151
1152 let sigs = compute_signatures(view, self.block, cols, rows);
1154 let mut moving = vec![false; n];
1155 let mut initialized = 0usize;
1156 let mut idle = 0usize;
1157 let mut perturbed = 0usize;
1158 let mut census_eligible = vec![false; n];
1159 let mut census_count = 0usize;
1160 let mut would_enter = 0usize;
1161 for i in 0..n {
1162 let b = &self.state[i];
1163 if !b.initialized {
1164 continue;
1165 }
1166 initialized += 1;
1167 if b.phase == Phase::Idle {
1168 idle += 1;
1169 }
1170 let mv = is_moving(&sigs[i], &b.last, p.signature_tolerance);
1171 moving[i] = mv;
1172 if mv {
1173 perturbed += 1;
1174 } else {
1175 if b.unstable_run >= p.stable_ticks {
1176 census_eligible[i] = true;
1177 census_count += 1;
1178 }
1179 if b.phase == Phase::Idle
1182 && b.stable_ticks >= p.stable_ticks
1183 && is_novel_vs(
1184 &sigs[i],
1185 &narrow(&b.base),
1186 p.novelty_threshold,
1187 p.contrast_threshold,
1188 )
1189 {
1190 would_enter += 1;
1191 }
1192 }
1193 }
1194 let denom = initialized.max(1) as f32;
1195 let frac = perturbed as f32 / denom;
1196 let globals = Globals {
1197 scene_tick: frac >= self.scene_threshold,
1198 corroborated_tick: frac >= CORROBORATION_FRAC,
1199 census_hit: (census_count as f32 / denom) >= CENSUS_FRAC,
1200 drift_guard: (would_enter as f32 / idle.max(1) as f32) >= DRIFT_FRAC,
1201 };
1202
1203 let mut keep_raw = vec![false; n];
1205 for i in 0..n {
1206 keep_raw[i] = step_block(
1207 &mut self.state[i],
1208 &sigs[i],
1209 moving[i],
1210 census_eligible[i],
1211 &globals,
1212 p,
1213 );
1214 }
1215
1216 let keep = if self.atomic_panels {
1218 let presented = self.tracker.tick(
1219 &moving,
1220 &keep_raw,
1221 cols,
1222 rows,
1223 p.stable_ticks,
1224 self.min_component,
1225 );
1226 if self.dilate == 0 {
1227 presented
1228 } else {
1229 dilate_grid(&presented, cols, rows, self.dilate as usize)
1230 }
1231 } else if self.fill_components {
1232 let filled = fill_components_bbox(&keep_raw, cols, rows, self.min_component);
1233 if self.dilate == 0 {
1234 filled
1235 } else {
1236 dilate_grid(&filled, cols, rows, self.dilate as usize)
1237 }
1238 } else {
1239 close_and_dilate(&keep_raw, cols, rows, self.dilate)
1240 };
1241
1242 let mut data = vec![0u8; w as usize * h as usize * 4];
1244 for px in data.chunks_exact_mut(4) {
1245 px[3] = 255;
1246 }
1247 for (y, row) in view.rows().enumerate() {
1248 let br = y / self.block as usize;
1249 for bc in 0..cols {
1250 if !keep[br * cols + bc] {
1251 continue;
1252 }
1253 let x0 = bc * self.block as usize * 4;
1254 let x1 = (((bc + 1) * self.block as usize) * 4).min(row.len());
1255 let dst = y * w as usize * 4;
1256 data[dst + x0..dst + x1].copy_from_slice(&row[x0..x1]);
1257 }
1258 }
1259 let output = Frame::new(w, h, data)
1260 .map(Arc::new)
1261 .map_err(|e| DetectorError::Other(format!("mask render: {e}")))?;
1262
1263 if !self.sinks.is_empty() {
1264 self.emit_debug(view, &keep, cols, &output);
1265 }
1266 self.tick += 1;
1267 Ok(output)
1268 }
1269
1270 fn set_label(&mut self, label: &str) {
1271 for sink in &mut self.sinks {
1272 sink.set_label(label);
1273 }
1274 }
1275}
1276
1277impl StabilityMask {
1278 fn emit_debug(&mut self, view: &FrameView<'_>, keep: &[bool], cols: usize, output: &Frame) {
1281 let (w, h) = (view.width(), view.height());
1282 let input = Frame::new(w, h, view.to_vec()).expect("view-sized buffer");
1283
1284 let mut baseline = vec![0u8; w as usize * h as usize * 4];
1289 let mut state = vec![0u8; w as usize * h as usize * 4];
1290 let mut overlay = input.data().to_vec();
1291 for y in 0..h as usize {
1292 let br = y / self.block as usize;
1293 for x in 0..w as usize {
1294 let bc = x / self.block as usize;
1295 let i = (y * w as usize + x) * 4;
1296 let blk = &self.state[br * cols + bc];
1297 let mean: u32 = blk.base[..16].iter().map(|v| (*v >> 8) as u32).sum::<u32>() / 16;
1298 baseline[i] = mean as u8;
1299 baseline[i + 1] = mean as u8;
1300 baseline[i + 2] = mean as u8;
1301 baseline[i + 3] = 255;
1302 let bgra: [u8; 4] = if blk.reseeded {
1303 [0, 0, 220, 255] } else {
1305 match blk.phase {
1306 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],
1310 }
1311 };
1312 state[i..i + 4].copy_from_slice(&bgra);
1313 if !keep[br * cols + bc] {
1314 overlay[i] /= 4;
1315 overlay[i + 1] /= 4;
1316 overlay[i + 2] /= 4;
1317 }
1318 }
1319 }
1320 if self.atomic_panels {
1323 let bpx = self.block as usize;
1324 for (presented, (r0, r1, c0, c1)) in self.tracker.panels() {
1325 let shade = if presented { 255u8 } else { 110 };
1326 let mut paint = |br: usize, bc: usize| {
1327 let y0 = br * bpx;
1328 let x0 = bc * bpx;
1329 for y in y0..(y0 + bpx).min(h as usize) {
1330 for x in x0..(x0 + bpx).min(w as usize) {
1331 let i = (y * w as usize + x) * 4;
1332 state[i] = shade;
1333 state[i + 1] = shade;
1334 state[i + 2] = shade;
1335 }
1336 }
1337 };
1338 for bc in c0..=c1 {
1339 paint(r0, bc);
1340 paint(r1, bc);
1341 }
1342 for br in r0..=r1 {
1343 paint(br, c0);
1344 paint(br, c1);
1345 }
1346 }
1347 }
1348 let baseline = Frame::new(w, h, baseline).expect("view-sized buffer");
1349 let state = Frame::new(w, h, state).expect("view-sized buffer");
1350 let overlay = Frame::new(w, h, overlay).expect("view-sized buffer");
1351
1352 for sink in &mut self.sinks {
1353 sink.write(self.tick, DebugStage::Input, &input);
1354 sink.write(self.tick, DebugStage::Baseline, &baseline);
1355 sink.write(self.tick, DebugStage::Overlay, &overlay);
1356 sink.write(self.tick, DebugStage::State, &state);
1357 sink.write(self.tick, DebugStage::Output, output);
1358 }
1359 }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365 use std::sync::Mutex;
1366
1367 fn params() -> MaskParams {
1368 MaskParams {
1369 stable_ticks: 3,
1370 baseline_ticks: 20,
1371 signature_tolerance: 10,
1372 novelty_threshold: 18,
1373 contrast_threshold: 14,
1374 holdover_ticks: 6,
1375 long_run: 9,
1376 }
1377 }
1378
1379 fn flat(luma: u8) -> Signature {
1381 let mut s = [luma; SIG_LEN];
1382 s[16] = 0;
1383 s.into()
1384 }
1385
1386 fn textured(luma: u8, spread: u8, c: u8) -> Signature {
1388 let mut s = [0u8; SIG_LEN];
1389 for (i, v) in s[..16].iter_mut().enumerate() {
1390 *v = if i % 2 == 0 {
1391 luma.saturating_add(spread)
1392 } else {
1393 luma.saturating_sub(spread)
1394 };
1395 }
1396 s[16] = c;
1397 Signature(s)
1398 }
1399
1400 impl From<[u8; SIG_LEN]> for Signature {
1401 fn from(v: [u8; SIG_LEN]) -> Self {
1402 Signature(v)
1403 }
1404 }
1405
1406 const QUIET: Globals = Globals {
1407 scene_tick: false,
1408 corroborated_tick: false,
1409 census_hit: false,
1410 drift_guard: false,
1411 };
1412
1413 fn run(b: &mut Block, script: &[(Signature, Globals)], p: &MaskParams) -> Vec<bool> {
1416 script
1417 .iter()
1418 .map(|(sig, g)| {
1419 let mv = b.initialized && is_moving(sig, &b.last, p.signature_tolerance);
1420 let census = b.initialized && !mv && b.unstable_run >= p.stable_ticks;
1421 step_block(b, sig, mv, census, g, p)
1422 })
1423 .collect()
1424 }
1425
1426 fn quiet(sigs: &[Signature]) -> Vec<(Signature, Globals)> {
1427 sigs.iter().map(|s| (*s, QUIET)).collect()
1428 }
1429
1430 fn rep(sig: Signature, n: usize) -> Vec<Signature> {
1431 vec![sig; n]
1432 }
1433
1434 #[test]
1437 fn single_component_noise_cannot_flip_movement_or_novelty() {
1438 let a = flat(100);
1439 let mut b = a;
1440 b.0[3] = 140; assert!(!is_moving(&b, &a, 10), "one component is not movement");
1442 assert!(
1443 !is_novel_vs(&b, &a.0, 18, 14),
1444 "one component is not novelty"
1445 );
1446 b.0[7] = 140; assert!(is_moving(&b, &a, 10));
1448 assert!(is_novel_vs(&b, &a.0, 18, 14));
1449 }
1450
1451 #[test]
1452 fn dark_on_dark_flat_panel_is_novel_via_contrast() {
1453 let scene = textured(30, 12, 40);
1457 let panel = flat(30);
1458 assert!(
1459 is_novel_vs(&panel, &scene.0, 18, 14),
1460 "flat panel over textured scene must be novel"
1461 );
1462 }
1463
1464 #[test]
1465 fn signatures_capture_subblock_structure() {
1466 let frame = Frame::from_fn(16, 16, |x, _| {
1467 if x < 8 {
1468 [0, 0, 0, 255]
1469 } else {
1470 [255, 255, 255, 255]
1471 }
1472 });
1473 let view = frame
1474 .view(visual_cortex_capture::PxRect {
1475 x: 0,
1476 y: 0,
1477 w: 16,
1478 h: 16,
1479 })
1480 .unwrap();
1481 let sigs = compute_signatures(&view, 16, 1, 1);
1482 let s = &sigs[0].0;
1483 assert!(s[0] < 10 && s[1] < 10, "left sub-cells dark");
1484 assert!(s[2] > 245 && s[3] > 245, "right sub-cells bright");
1485 assert!(s[16] > 100, "half-and-half block has high contrast");
1486 }
1487
1488 #[test]
1491 fn constant_hud_block_is_never_kept() {
1492 let mut b = Block::default();
1493 let out = run(&mut b, &quiet(&rep(textured(100, 20, 30), 30)), ¶ms());
1494 assert!(out.iter().all(|k| !k), "HUD must stay suppressed");
1495 }
1496
1497 #[test]
1498 fn noisy_gameplay_block_is_never_kept() {
1499 let mut b = Block::default();
1500 let sigs: Vec<Signature> = (0..30)
1501 .map(|i| flat(if i % 2 == 0 { 40 } else { 200 }))
1502 .collect();
1503 let out = run(&mut b, &quiet(&sigs), ¶ms());
1504 assert!(out.iter().all(|k| !k), "churn must stay suppressed");
1505 }
1506
1507 #[test]
1508 fn tooltip_becomes_kept_after_k_stable_ticks() {
1509 let mut b = Block::default();
1510 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1511 let out = run(&mut b, &quiet(&rep(flat(200), 6)), ¶ms());
1512 assert_eq!(out, vec![false, false, false, true, true, true]);
1514 assert_eq!(b.phase, Phase::Kept);
1515 }
1516
1517 #[test]
1518 fn held_tooltip_survives_indefinitely() {
1519 let mut b = Block::default();
1520 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1521 let out = run(&mut b, &quiet(&rep(flat(200), 200)), ¶ms());
1522 assert!(out[10..].iter().all(|k| *k), "no ghosting, ever");
1523 assert!(b.base_frozen, "baseline frozen under the kept overlay");
1524 }
1525
1526 #[test]
1527 fn dismissed_tooltip_thaws_and_suppresses() {
1528 let mut b = Block::default();
1529 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1530 run(&mut b, &quiet(&rep(flat(200), 10)), ¶ms());
1531 let out = run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1532 assert_eq!(b.phase, Phase::Idle);
1533 assert!(!b.base_frozen, "baseline resumed after departure");
1534 assert!(!out.last().unwrap(), "restored scene suppressed");
1535 }
1536
1537 #[test]
1540 fn browsing_hand_off_never_goes_dark() {
1541 let mut b = Block::default();
1545 run(&mut b, &quiet(&rep(flat(60), 10)), ¶ms());
1546 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());
1550 assert!(
1551 out.iter().all(|k| *k),
1552 "hand-off must render every tick, got {out:?}"
1553 );
1554 assert_eq!(b.phase, Phase::Kept, "re-latched onto tooltip B");
1555 }
1556
1557 #[test]
1558 fn flash_over_held_tooltip_relatches_without_reseed() {
1559 let p = params();
1564 let mut b = Block::default();
1565 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1566 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let flash = Globals {
1568 scene_tick: true,
1569 corroborated_tick: true,
1570 census_hit: false,
1571 drift_guard: false,
1572 };
1573 let script = vec![
1574 (flat(255), flash),
1575 (flat(255), flash),
1576 (flat(200), QUIET), (flat(200), QUIET),
1578 ];
1579 let out = run(&mut b, &script, &p);
1580 assert!(out.iter().all(|k| *k), "renders through the flash: {out:?}");
1581 assert_eq!(b.phase, Phase::Kept, "immediate ovl re-latch");
1582 assert!(!b.reseeded, "a flash is not a scene change");
1583 assert!(b.base_frozen, "invariant intact: base still frozen");
1584 }
1585
1586 #[test]
1587 fn reveal_after_occluded_scene_change_resolves_on_next_motion() {
1588 let p = params();
1595 let mut b = Block::default();
1596 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1597 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let scene = Globals {
1599 scene_tick: true,
1600 corroborated_tick: true,
1601 census_hit: false,
1602 drift_guard: false,
1603 };
1604 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");
1607 let out = run(&mut b, &quiet(&rep(flat(120), 8)), &p);
1609 assert!(*out.last().unwrap(), "ambiguous reveal stays rendered");
1610 let walking = Globals {
1612 scene_tick: false,
1613 corroborated_tick: true,
1614 census_hit: false,
1615 drift_guard: false,
1616 };
1617 let script: Vec<(Signature, Globals)> = (0..12)
1618 .map(|i| (flat(if i % 2 == 0 { 80 } else { 170 }), walking))
1619 .collect();
1620 run(&mut b, &script, &p);
1621 let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1622 assert!(!out.last().unwrap(), "re-anchored after motion");
1623 let base = narrow(&b.base);
1624 assert!(
1625 base[0].abs_diff(140) < 10,
1626 "baseline follows the real scene"
1627 );
1628 }
1629
1630 #[test]
1631 fn browsing_hand_off_survives_a_prior_flash() {
1632 let p = params();
1636 let mut b = Block::default();
1637 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1638 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let flash = Globals {
1640 scene_tick: true,
1641 corroborated_tick: true,
1642 census_hit: false,
1643 drift_guard: false,
1644 };
1645 run(&mut b, &[(flat(255), flash), (flat(255), flash)], &p);
1646 run(&mut b, &quiet(&rep(flat(200), 4)), &p); assert_eq!(b.phase, Phase::Kept);
1648 let mut script = rep(flat(150), 1);
1650 script.extend(rep(flat(230), 8));
1651 let out = run(&mut b, &quiet(&script), &p);
1652 assert!(
1653 out.iter().all(|k| *k),
1654 "hand-off renders throughout: {out:?}"
1655 );
1656 assert_eq!(b.phase, Phase::Kept, "tooltip B kept");
1657 let base = narrow(&b.base);
1658 assert!(
1659 base[0].abs_diff(60) < 10,
1660 "baseline still the scene, never overlay content: base={} frozen={} scene_flag={}",
1661 base[0],
1662 b.base_frozen,
1663 b.scene_flag
1664 );
1665 }
1666
1667 #[test]
1668 fn walk_and_stop_reseeds_instead_of_keeping() {
1669 let p = params();
1672 let mut b = Block::default();
1673 run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1674 let walking = Globals {
1675 scene_tick: false,
1676 corroborated_tick: true, census_hit: false,
1678 drift_guard: false,
1679 };
1680 let script: Vec<(Signature, Globals)> = (0..12)
1681 .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), walking))
1682 .collect();
1683 run(&mut b, &script, &p); let out = run(&mut b, &quiet(&rep(flat(140), 8)), &p);
1685 assert!(
1686 out.iter().all(|k| !k),
1687 "new scene must not be kept: {out:?}"
1688 );
1689 let base = narrow(&b.base);
1690 assert!(base[0].abs_diff(140) < 10, "re-seeded to the new scene");
1691 }
1692
1693 #[test]
1694 fn fast_browsing_churn_is_not_corroborated_and_final_tooltip_is_kept() {
1695 let p = params();
1700 let mut b = Block::default();
1701 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1702 let script: Vec<Signature> = (0..20)
1704 .map(|i| flat(if i % 2 == 0 { 200 } else { 120 }))
1705 .collect();
1706 run(&mut b, &quiet(&script), &p);
1707 assert!(!b.run_corroborated, "local churn is never corroborated");
1708 let out = run(&mut b, &quiet(&rep(flat(200), 6)), &p);
1710 assert!(*out.last().unwrap(), "final tooltip must be kept: {out:?}");
1711 assert_eq!(b.phase, Phase::Kept);
1712 }
1713
1714 #[test]
1715 fn minimap_pulse_train_is_never_kept() {
1716 let p = params();
1719 let mut b = Block::default();
1720 let mut script = Vec::new();
1721 for _ in 0..6 {
1722 script.extend(rep(textured(80, 15, 25), 8)); script.extend(rep(textured(140, 15, 25), 2)); }
1725 let out = run(&mut b, &quiet(&script), &p);
1726 assert!(out.iter().all(|k| !k), "pulsing HUD must stay suppressed");
1727 }
1728
1729 #[test]
1730 fn census_flag_reseeds_short_global_motion() {
1731 let p = params();
1734 let mut b = Block::default();
1735 run(&mut b, &quiet(&rep(flat(60), 5)), &p);
1736 let script: Vec<(Signature, Globals)> = (0..5)
1737 .map(|i| (flat(if i % 2 == 0 { 90 } else { 180 }), QUIET))
1738 .collect();
1739 run(&mut b, &script, &p); let census = Globals {
1741 scene_tick: false,
1742 corroborated_tick: false,
1743 census_hit: true,
1744 drift_guard: false,
1745 };
1746 let mut settle = vec![(flat(140), census); 3];
1749 settle.extend(quiet(&rep(flat(140), 6)));
1750 let out = run(&mut b, &settle, &p);
1751 assert!(out.iter().all(|k| !k), "census settle re-seeds: {out:?}");
1752 }
1753
1754 #[test]
1755 fn drift_guard_reseeds_slow_drift_but_never_pop_ins() {
1756 let p = params();
1757 let guard = Globals {
1758 scene_tick: false,
1759 corroborated_tick: false,
1760 census_hit: false,
1761 drift_guard: true,
1762 };
1763 let mut b = Block::default();
1767 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1768 let script: Vec<(Signature, Globals)> =
1769 (0..12).map(|i| (flat(60 + 8 * (i + 1)), guard)).collect();
1770 let out = run(&mut b, &script, &p);
1771 assert!(out.iter().all(|k| !k), "ambient fade must not be kept");
1772 let mut b = Block::default();
1775 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1776 let script: Vec<(Signature, Globals)> =
1777 rep(flat(200), 6).into_iter().map(|s| (s, guard)).collect();
1778 let out = run(&mut b, &script, &p);
1779 assert!(*out.last().unwrap(), "pop-in is kept even under the guard");
1780 }
1781
1782 #[test]
1783 fn holdover_expires_to_idle_when_content_keeps_churning() {
1784 let p = params();
1785 let mut b = Block::default();
1786 run(&mut b, &quiet(&rep(flat(60), 10)), &p);
1787 run(&mut b, &quiet(&rep(flat(200), 10)), &p); let script: Vec<Signature> = (0..12)
1790 .map(|i| flat(if i % 2 == 0 { 90 } else { 170 }))
1791 .collect();
1792 let out = run(&mut b, &quiet(&script), &p);
1793 assert!(out[0], "holdover renders at first");
1794 assert!(!out.last().unwrap(), "expired holdover suppresses");
1795 assert_eq!(b.phase, Phase::Idle);
1796 }
1797
1798 #[test]
1801 fn component_fill_drops_speckle_and_fills_bboxes() {
1802 let cols = 6;
1804 let mut keep = vec![false; 36];
1805 for (r, c) in [(1, 1), (2, 1), (3, 1), (3, 2), (3, 3)] {
1806 keep[r * cols + c] = true;
1807 }
1808 keep[5 * cols + 5] = true; let out = fill_components_bbox(&keep, cols, 6, 4);
1810 assert!(out[cols + 2] && out[cols + 3], "bbox filled the L's notch");
1811 assert!(!out[5 * cols + 5], "speckle dropped");
1812 }
1813
1814 #[test]
1815 fn close_fills_interior_holes_and_dilate_expands() {
1816 let cols = 5;
1817 let mut keep = vec![false; 25];
1818 for (r, c) in [
1819 (1usize, 1usize),
1820 (1, 2),
1821 (1, 3),
1822 (2, 1),
1823 (2, 3),
1824 (3, 1),
1825 (3, 2),
1826 (3, 3),
1827 ] {
1828 keep[r * cols + c] = true;
1829 }
1830 let out = close_and_dilate(&keep, cols, 5, 0);
1831 assert!(out[2 * cols + 2], "interior hole must be closed");
1832 let out = close_and_dilate(&keep, cols, 5, 1);
1833 assert!(out[cols], "edge cell reached by one dilation ring");
1834 assert!(
1835 !out[0],
1836 "corner is two steps away; one ring must not reach it"
1837 );
1838 }
1839
1840 #[test]
1841 fn cold_start_keeps_nothing() {
1842 let mut b = Block::default();
1843 assert!(!step_block(
1844 &mut b,
1845 &flat(200),
1846 false,
1847 false,
1848 &QUIET,
1849 ¶ms()
1850 ));
1851 }
1852
1853 fn grid(cols: usize, rows: usize, rects: &[(usize, usize, usize, usize)]) -> Vec<bool> {
1857 let mut g = vec![false; cols * rows];
1858 for &(r0, r1, c0, c1) in rects {
1859 for r in r0..=r1 {
1860 for c in c0..=c1 {
1861 g[r * cols + c] = true;
1862 }
1863 }
1864 }
1865 g
1866 }
1867
1868 const K_PANEL: u16 = 3;
1869
1870 fn ptick(
1871 t: &mut PanelTracker,
1872 cols: usize,
1873 rows: usize,
1874 moving: &[(usize, usize, usize, usize)],
1875 keep: &[(usize, usize, usize, usize)],
1876 ) -> Vec<bool> {
1877 t.tick(
1878 &grid(cols, rows, moving),
1879 &grid(cols, rows, keep),
1880 cols,
1881 rows,
1882 K_PANEL,
1883 1,
1884 )
1885 }
1886
1887 #[test]
1892 fn onset_group_presents_whole_despite_fragmentary_keeps() {
1893 let (c, r) = (10, 10);
1894 let mut t = PanelTracker::default();
1895 let rect = (1, 6, 1, 6);
1896 let out = ptick(&mut t, c, r, &[rect], &[]);
1898 assert!(out.iter().all(|k| !k), "onset tick: nothing rendered");
1899 let frags = [(1, 2, 1, 6), (5, 6, 1, 3)];
1901 for _ in 0..(K_PANEL + 1) {
1902 let out = ptick(&mut t, c, r, &[], &frags);
1903 assert!(out.iter().all(|k| !k), "still settling: no render");
1904 }
1905 let out = ptick(&mut t, c, r, &[], &frags);
1908 assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "whole rect at once");
1909 }
1910
1911 #[test]
1912 fn gameplay_churn_never_presents_and_expires() {
1913 let (c, r) = (10, 10);
1914 let mut t = PanelTracker::default();
1915 for _ in 0..(K_PANEL * PANEL_PENDING_DEADLINE_K + 2) {
1917 let out = ptick(&mut t, c, r, &[(2, 7, 2, 7)], &[(3, 3, 3, 3)]);
1918 assert!(out.iter().all(|k| !k), "churn must never present");
1919 }
1920 assert!(
1923 t.panels().all(|(presented, _)| !presented),
1924 "churn never presents"
1925 );
1926 }
1927
1928 #[test]
1929 fn camera_pan_settles_to_nothing_via_the_group_kept_test() {
1930 let (c, r) = (10, 10);
1934 let mut t = PanelTracker::default();
1935 for _ in 0..3 {
1936 ptick(&mut t, c, r, &[(0, 9, 0, 9)], &[]);
1937 }
1938 for _ in 0..(K_PANEL * PANEL_PENDING_DEADLINE_K + 2) {
1939 let out = ptick(&mut t, c, r, &[], &[]);
1940 assert!(out.iter().all(|k| !k), "no panel from a pan");
1941 }
1942 assert_eq!(t.panels().count(), 0, "churn panel reaped at deadline");
1943 }
1944
1945 fn present_panel(t: &mut PanelTracker, c: usize, r: usize, rect: (usize, usize, usize, usize)) {
1946 ptick(t, c, r, &[rect], &[]);
1947 for _ in 0..(K_PANEL + 2) {
1950 ptick(t, c, r, &[], &[rect]);
1951 }
1952 }
1953
1954 #[test]
1955 fn hand_off_keeps_the_rect_and_dismissal_withdraws_whole() {
1956 let (c, r) = (10, 10);
1957 let mut t = PanelTracker::default();
1958 let rect = (1, 6, 1, 6);
1959 present_panel(&mut t, c, r, rect);
1960 let full = grid(c, r, &[rect]);
1961 for _ in 0..2 {
1964 let out = ptick(&mut t, c, r, &[rect], &[]);
1965 assert_eq!(out, full, "rect stays whole through churn");
1966 }
1967 for _ in 0..K_PANEL {
1968 let out = ptick(&mut t, c, r, &[], &[(1, 4, 1, 6)]);
1969 assert_eq!(out, full, "still whole while settling");
1970 }
1971 let out = ptick(&mut t, c, r, &[], &[(1, 4, 1, 6)]);
1973 assert_eq!(out, grid(c, r, &[(1, 4, 1, 6)]), "atomic snap");
1974 ptick(&mut t, c, r, &[(1, 4, 1, 6)], &[]);
1977 for _ in 0..(K_PANEL - 2) {
1978 let out = ptick(&mut t, c, r, &[], &[]);
1979 assert!(!out.iter().all(|k| !k), "grace while support collapses");
1980 }
1981 let out = ptick(&mut t, c, r, &[], &[]);
1982 assert!(out.iter().all(|k| !k), "withdrawn whole");
1983 assert_eq!(t.panels().count(), 0);
1984 }
1985
1986 #[test]
1987 fn straggler_trickle_does_not_postpone_presentation() {
1988 let (c, r) = (60, 4);
1994 let mut t = PanelTracker::default();
1995 ptick(&mut t, c, r, &[(1, 2, 1, 50)], &[]); let mut presented = false;
1997 for i in 0..(K_PANEL as usize + 4) {
1998 let cols_kept = (30 + i).min(50);
1999 let out = ptick(&mut t, c, r, &[], &[(1, 2, 1, cols_kept)]);
2000 presented = out.iter().any(|k| *k);
2001 if presented {
2002 break;
2003 }
2004 }
2005 assert!(presented, "trickle must not starve presentation");
2006 }
2007
2008 #[test]
2009 fn stale_pending_hypotheses_never_starve_a_new_onset() {
2010 let (c, r) = (10, 10);
2016 let mut t = PanelTracker::default();
2017 for _ in 0..6 {
2019 ptick(&mut t, c, r, &[(1, 8, 1, 8)], &[]);
2020 }
2021 ptick(&mut t, c, r, &[(2, 6, 2, 6), (9, 9, 1, 8)], &[]);
2025 for _ in 0..(K_PANEL + 2) {
2026 ptick(&mut t, c, r, &[(9, 9, 1, 8)], &[(2, 6, 2, 6)]);
2027 }
2028 let out = ptick(&mut t, c, r, &[(9, 9, 1, 8)], &[(2, 6, 2, 6)]);
2029 assert_eq!(
2030 out,
2031 grid(c, r, &[(2, 6, 2, 6)]),
2032 "the settled tooltip must present despite the stale hypothesis"
2033 );
2034 }
2035
2036 #[test]
2037 fn sparse_scene_settle_scatter_never_presents() {
2038 let (c, r) = (20, 20);
2041 let mut t = PanelTracker::default();
2042 ptick(&mut t, c, r, &[(0, 19, 0, 19)], &[]);
2043 let scatter: Vec<(usize, usize, usize, usize)> = (0..400)
2044 .filter(|i| i % 7 == 0)
2045 .map(|i| (i / 20, i / 20, i % 20, i % 20))
2046 .collect();
2047 for _ in 0..(K_PANEL + 6) {
2048 let out = ptick(&mut t, c, r, &[], &scatter);
2049 assert!(out.iter().all(|k| !k), "sparse scatter must not present");
2050 }
2051 }
2052
2053 #[test]
2054 fn sparse_settle_waits_for_assembly_instead_of_dying() {
2055 let (c, r) = (10, 10);
2060 let mut t = PanelTracker::default();
2061 ptick(&mut t, c, r, &[(1, 6, 1, 6)], &[]);
2062 let scatter = [(1, 1, 1, 1), (3, 3, 3, 3), (5, 5, 5, 5), (6, 6, 2, 2)];
2063 for _ in 0..(K_PANEL * 4) {
2064 let out = ptick(&mut t, c, r, &[], &scatter);
2065 assert!(out.iter().all(|k| !k), "sparse: waiting, not rendering");
2066 }
2067 for _ in 0..2 {
2069 ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2070 }
2071 let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2072 assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "presents once dense");
2073 }
2074
2075 #[test]
2076 fn disjoint_band_onsets_merge_into_one_panel() {
2077 let (c, r) = (10, 10);
2081 let mut t = PanelTracker::default();
2082 ptick(&mut t, c, r, &[(1, 2, 1, 6)], &[]); ptick(&mut t, c, r, &[(5, 6, 1, 6)], &[]); ptick(&mut t, c, r, &[(3, 4, 1, 6)], &[]); for _ in 0..(K_PANEL + 1) {
2086 let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2087 assert!(out.iter().all(|k| !k), "no partial band presents");
2088 }
2089 let out = ptick(&mut t, c, r, &[], &[(1, 6, 1, 6)]);
2090 assert_eq!(out, grid(c, r, &[(1, 6, 1, 6)]), "one whole rect");
2091 assert_eq!(t.panels().count(), 1, "bands merged into one panel");
2092 }
2093
2094 #[test]
2095 fn disjoint_panels_present_and_withdraw_independently() {
2096 let (c, r) = (12, 12);
2097 let mut t = PanelTracker::default();
2098 let (a, b) = ((1, 3, 1, 3), (7, 10, 7, 10));
2099 ptick(&mut t, c, r, &[a, b], &[]);
2101 for _ in 0..(K_PANEL + 2) {
2102 ptick(&mut t, c, r, &[], &[a, b]);
2103 }
2104 let both = grid(c, r, &[a, b]);
2105 let out = ptick(&mut t, c, r, &[], &[a, b]);
2106 assert_eq!(out, both, "both panels presented");
2107 ptick(&mut t, c, r, &[b], &[a]);
2110 for _ in 0..(K_PANEL + 1) {
2111 ptick(&mut t, c, r, &[], &[a]);
2112 }
2113 let out = ptick(&mut t, c, r, &[], &[a]);
2114 assert_eq!(out, grid(c, r, &[a]), "only the first remains");
2115 }
2116
2117 #[test]
2118 fn slow_drift_kept_content_presents_via_orphan_seeding() {
2119 let (c, r) = (10, 10);
2122 let mut t = PanelTracker::default();
2123 for _ in 0..(K_PANEL + 4) {
2124 ptick(&mut t, c, r, &[], &[(2, 6, 2, 6)]); }
2126 let out = ptick(&mut t, c, r, &[], &[(2, 6, 2, 6)]);
2127 assert_eq!(out, grid(c, r, &[(2, 6, 2, 6)]), "fade content presents");
2128 }
2129
2130 #[test]
2131 fn cursor_blip_inside_presented_panel_is_harmless() {
2132 let (c, r) = (10, 10);
2133 let mut t = PanelTracker::default();
2134 let rect = (1, 6, 1, 6);
2135 present_panel(&mut t, c, r, rect);
2136 let full = grid(c, r, &[rect]);
2137 for _ in 0..6 {
2140 let out = ptick(&mut t, c, r, &[(3, 3, 3, 3)], &[rect]);
2141 assert_eq!(out, full);
2142 }
2143 assert_eq!(t.panels().count(), 1);
2144 }
2145
2146 #[test]
2147 fn neighboring_motion_does_not_extend_a_panel() {
2148 let (c, r) = (12, 12);
2149 let mut t = PanelTracker::default();
2150 let rect = (1, 4, 1, 4);
2151 present_panel(&mut t, c, r, rect);
2152 let full = grid(c, r, &[rect]);
2153 for _ in 0..4 {
2156 let out = ptick(&mut t, c, r, &[(5, 10, 1, 10)], &[rect]);
2157 assert_eq!(out, full, "edge motion must not bloat the rect");
2158 }
2159 }
2160
2161 #[test]
2168 fn large_pop_in_covering_most_of_the_region_is_revealed() {
2169 use visual_cortex_capture::PxRect;
2170 let mut mask = StabilityMask::new()
2171 .block_size(8)
2172 .stable_ticks(3)
2173 .min_component(1)
2174 .dilate(0);
2175 let full = PxRect {
2176 x: 0,
2177 y: 0,
2178 w: 48,
2179 h: 48,
2180 }; let bg = Frame::solid(48, 48, [60, 60, 60, 255]);
2183 let tip = Frame::from_fn(48, 48, |x, y| {
2184 if y < 24 || (y < 32 && x < 32) {
2185 [230, 230, 230, 255]
2186 } else {
2187 [60, 60, 60, 255]
2188 }
2189 });
2190 for _ in 0..8 {
2191 mask.process(&bg.view(full).unwrap()).unwrap();
2192 }
2193 let mut last = None;
2194 for _ in 0..8 {
2195 last = Some(mask.process(&tip.view(full).unwrap()).unwrap());
2196 }
2197 let out = last.unwrap();
2198 let px = &out.data()[(4 * 48 + 4) * 4..(4 * 48 + 4) * 4 + 3];
2199 assert_eq!(px, &[230, 230, 230], "pop-in region revealed, got {px:?}");
2200 }
2201
2202 #[test]
2207 fn staggered_pop_in_reveals_atomically_through_process() {
2208 use visual_cortex_capture::PxRect;
2209 let mut mask = StabilityMask::new()
2210 .block_size(8)
2211 .stable_ticks(3)
2212 .min_component(1)
2213 .dilate(0);
2214 let full = PxRect {
2215 x: 0,
2216 y: 0,
2217 w: 64,
2218 h: 64,
2219 }; let bg = Frame::solid(64, 64, [60, 60, 60, 255]);
2221 let wave = |rows_to: u32| {
2224 Frame::from_fn(64, 64, move |x, y| {
2225 if (8..rows_to).contains(&y) && (8..40).contains(&x) {
2226 [230, 230, 230, 255]
2227 } else {
2228 [60, 60, 60, 255]
2229 }
2230 })
2231 };
2232 for _ in 0..8 {
2233 mask.process(&bg.view(full).unwrap()).unwrap();
2234 }
2235 let mut outputs = Vec::new();
2236 outputs.push(mask.process(&wave(24).view(full).unwrap()).unwrap());
2237 for _ in 0..12 {
2238 outputs.push(mask.process(&wave(40).view(full).unwrap()).unwrap());
2239 }
2240 let mut fractions = Vec::new();
2244 for out in &outputs {
2245 let mut vis = 0usize;
2246 let mut tot = 0usize;
2247 for y in 8..40usize {
2248 for x in 8..40usize {
2249 let i = (y * 64 + x) * 4;
2250 tot += 1;
2251 if out.data()[i] == 230 {
2252 vis += 1;
2253 }
2254 }
2255 }
2256 fractions.push(vis as f64 / tot as f64);
2257 }
2258 assert!(
2259 fractions.iter().all(|f| *f < 0.05 || *f > 0.95),
2260 "no partial reveals allowed: {fractions:?}"
2261 );
2262 assert!(
2263 fractions.last().unwrap() > &0.95,
2264 "tooltip presented: {fractions:?}"
2265 );
2266 }
2267
2268 #[test]
2269 fn dismissal_withdraws_atomically_through_process() {
2270 use visual_cortex_capture::PxRect;
2271 let mut mask = StabilityMask::new()
2272 .block_size(8)
2273 .stable_ticks(3)
2274 .min_component(1)
2275 .dilate(0);
2276 let full = PxRect {
2277 x: 0,
2278 y: 0,
2279 w: 64,
2280 h: 64,
2281 };
2282 let bg = Frame::solid(64, 64, [60, 60, 60, 255]);
2283 let tip = Frame::from_fn(64, 64, |x, y| {
2284 if (8..40).contains(&y) && (8..40).contains(&x) {
2285 [230, 230, 230, 255]
2286 } else {
2287 [60, 60, 60, 255]
2288 }
2289 });
2290 for _ in 0..8 {
2291 mask.process(&bg.view(full).unwrap()).unwrap();
2292 }
2293 for _ in 0..10 {
2294 mask.process(&tip.view(full).unwrap()).unwrap();
2295 }
2296 let mut fractions = Vec::new();
2297 for _ in 0..10 {
2298 let out = mask.process(&bg.view(full).unwrap()).unwrap();
2299 let mut vis = 0usize;
2300 for y in 8..40usize {
2301 for x in 8..40usize {
2302 if out.data()[(y * 64 + x) * 4] != 0 {
2303 vis += 1;
2304 }
2305 }
2306 }
2307 fractions.push(vis as f64 / 1024.0);
2308 }
2309 assert!(
2310 fractions.iter().all(|f| *f < 0.05 || *f > 0.95),
2311 "withdraw must be atomic: {fractions:?}"
2312 );
2313 assert!(fractions.last().unwrap() < &0.05, "gone: {fractions:?}");
2314 }
2315
2316 #[test]
2319 fn builder_derives_holdover_and_long_run_from_stable_ticks() {
2320 let m = StabilityMask::new().stable_ticks(5);
2321 assert_eq!(m.params.holdover_ticks, 10, "H = 2K");
2322 assert_eq!(m.params.long_run, 15, "U = 3K");
2323 let m = StabilityMask::new().holdover_ticks(5).stable_ticks(10);
2325 assert_eq!(m.params.holdover_ticks, 5, "explicit H wins");
2326 assert_eq!(m.params.long_run, 30);
2327 let m = StabilityMask::new().stable_ticks(300);
2329 assert_eq!(m.params.holdover_ticks, 600);
2330 }
2331
2332 #[test]
2335 fn partial_edge_blocks_average_available_pixels_only() {
2336 use visual_cortex_capture::PxRect;
2337 let frame = Frame::from_fn(19, 13, |x, _| {
2341 if x < 16 {
2342 [40, 40, 40, 255]
2343 } else {
2344 [220, 220, 220, 255]
2345 }
2346 });
2347 let view = frame
2348 .view(PxRect {
2349 x: 0,
2350 y: 0,
2351 w: 19,
2352 h: 13,
2353 })
2354 .unwrap();
2355 let sigs = compute_signatures(&view, 16, 2, 1);
2356 assert!(
2360 sigs[1].0[..16].iter().all(|v| *v > 200),
2361 "partial block averages available pixels: {:?}",
2362 sigs[1].0
2363 );
2364 assert!(sigs[0].0[..16].iter().all(|v| *v < 60));
2365 }
2366
2367 #[test]
2370 fn debug_sinks_receive_all_five_stages_per_tick() {
2371 struct Recording(Arc<Mutex<Vec<(u64, DebugStage)>>>);
2372 impl DebugSink for Recording {
2373 fn write(&mut self, tick: u64, stage: DebugStage, _frame: &Frame) {
2374 self.0.lock().unwrap().push((tick, stage));
2375 }
2376 }
2377
2378 let log = Arc::new(Mutex::new(Vec::new()));
2379 let mut mask = StabilityMask::new()
2380 .block_size(8)
2381 .debug_sink(Recording(log.clone()));
2382 let frame = Frame::solid(8, 8, [60, 60, 60, 255]);
2383 let view = frame
2384 .view(visual_cortex_capture::PxRect {
2385 x: 0,
2386 y: 0,
2387 w: 8,
2388 h: 8,
2389 })
2390 .unwrap();
2391 mask.process(&view).unwrap();
2392 mask.process(&view).unwrap();
2393
2394 use DebugStage::*;
2395 assert_eq!(
2396 *log.lock().unwrap(),
2397 vec![
2398 (0, Input),
2399 (0, Baseline),
2400 (0, Overlay),
2401 (0, State),
2402 (0, Output),
2403 (1, Input),
2404 (1, Baseline),
2405 (1, Overlay),
2406 (1, State),
2407 (1, Output),
2408 ]
2409 );
2410 }
2411}