1use std::collections::VecDeque;
21
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
29pub struct ParkTuning {
30 pub fps: f64,
32 pub left_frac: f64,
34 pub ema_seconds: f64,
36 pub abs_floor: f64,
38 pub mad_k: f64,
40 pub merge_gap_s: f64,
42 pub max_island_s: f64,
44 pub min_sep_s: f64,
46 pub candidate_frac: f64,
48 pub warmup_s: f64,
50 pub baseline_s: f64,
52}
53
54#[derive(Debug, Clone, PartialEq)]
58pub struct Park {
59 pub idx: u64,
61 pub t: f64,
63 pub left_mass: f64,
64 pub sharpness: f64,
65 pub confidence: f64,
67 pub replace: bool,
69}
70
71pub fn ema_alpha(ema_seconds: f64, fps: f64) -> f64 {
73 (1.0 - 1.0 / (ema_seconds * fps).max(1.0)).clamp(0.0, 0.999)
74}
75
76fn sharpness(gray: &[u8], w: usize, h: usize) -> f64 {
79 let mut s: i64 = 0;
80 for y in 0..h {
81 let row = y * w;
82 for x in 0..w - 1 {
83 let d = gray[row + x] as i64 - gray[row + x + 1] as i64;
84 s += d * d;
85 }
86 }
87 for y in 0..h - 1 {
88 let row = y * w;
89 let nxt = row + w;
90 for x in 0..w {
91 let d = gray[row + x] as i64 - gray[nxt + x] as i64;
92 s += d * d;
93 }
94 }
95 s as f64 / (w * h) as f64
96}
97
98fn median(v: &[f64]) -> f64 {
101 if v.is_empty() {
102 return 0.0;
103 }
104 let mut s = v.to_vec();
105 s.sort_by(|a, b| a.partial_cmp(b).unwrap());
106 let n = s.len();
107 if n % 2 == 1 {
108 s[n / 2]
109 } else {
110 (s[n / 2 - 1] + s[n / 2]) / 2.0
111 }
112}
113
114fn round_to(x: f64, decimals: i32) -> f64 {
115 let f = 10f64.powi(decimals);
116 (x * f).round() / f
117}
118
119pub struct SelectFrame {
122 pub offset_ms: u64,
123 pub gray: Vec<u8>,
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
130pub struct SelectTuning {
131 pub left_frac: f64,
133 pub min_outlier: f64,
135 pub min_left_density: f64,
137 pub select_candidate_frac: f64,
139 pub min_confidence: f64,
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum SkipReason {
146 ParkNotCaptured,
149 LowConfidence,
151}
152
153#[derive(Clone, Debug, PartialEq)]
155pub enum Selection {
156 Selected { offset_ms: u64, confidence: f64 },
157 Skipped { reason: SkipReason, confidence: f64 },
158}
159
160fn norm(v: f64, lo: f64, hi: f64) -> f64 {
161 if hi <= lo { 0.0 } else { (v - lo) / (hi - lo) }
162}
163
164pub fn select_park_frame(
178 frames: &[SelectFrame],
179 w: usize,
180 h: usize,
181 cfg: &SelectTuning,
182) -> Selection {
183 if frames.is_empty() {
184 return Selection::Skipped {
185 reason: SkipReason::ParkNotCaptured,
186 confidence: 0.0,
187 };
188 }
189 let n_px = w * h;
190 let mut med = vec![0f64; n_px];
192 let mut col = Vec::with_capacity(frames.len());
193 for (p, m) in med.iter_mut().enumerate() {
194 col.clear();
195 col.extend(frames.iter().map(|f| f.gray[p] as f64));
196 *m = median(&col);
197 }
198 let park_hi = ((cfg.left_frac * w as f64) as usize).max(1);
199 let mut left = Vec::with_capacity(frames.len());
202 let mut sharp = Vec::with_capacity(frames.len());
203 for f in frames {
204 let mut lm = 0.0;
205 for y in 0..h {
206 let row = y * w;
207 for x in 0..park_hi {
208 lm += (med[row + x] - f.gray[row + x] as f64).abs();
209 }
210 }
211 left.push(lm);
212 sharp.push(sharpness(&f.gray, w, h));
213 }
214 let l_med = {
215 let m = median(&left);
216 if m == 0.0 { 1.0 } else { m }
217 };
218 let l_max = left.iter().cloned().fold(f64::MIN, f64::max);
219 let outlier = l_max / l_med; let left_density = l_max / (park_hi * h) as f64; if outlier < cfg.min_outlier || left_density < cfg.min_left_density {
222 return Selection::Skipped {
223 reason: SkipReason::ParkNotCaptured,
224 confidence: 0.0,
225 };
226 }
227 let sh_lo = sharp.iter().cloned().fold(f64::MAX, f64::min);
228 let sh_hi = sharp.iter().cloned().fold(f64::MIN, f64::max);
229 let mut best = 0usize;
231 let mut best_sharp = f64::MIN;
232 for (i, (&l, &s)) in left.iter().zip(sharp.iter()).enumerate() {
233 if l >= cfg.select_candidate_frac * l_max && s > best_sharp {
234 best_sharp = s;
235 best = i;
236 }
237 }
238 let conf = round_to(
239 ((outlier - 1.5) / 4.0).min(1.0) * 0.6 + norm(sharp[best], sh_lo, sh_hi) * 0.4,
240 3,
241 );
242 if conf < cfg.min_confidence {
243 return Selection::Skipped {
244 reason: SkipReason::LowConfidence,
245 confidence: conf,
246 };
247 }
248 Selection::Selected {
249 offset_ms: frames[best].offset_ms,
250 confidence: conf,
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct SegmentPick {
258 pub layer: i64,
259 pub idx: u64,
260 pub confidence: f64,
261}
262
263struct Segment {
264 layer: i64,
265 start_ms: u64,
266 frames: Vec<(u64, u64, Vec<u8>)>, }
268
269pub struct SegmentSelector {
286 w: usize,
287 h: usize,
288 window_ms: u64,
292 cfg: SelectTuning,
293 pending: Option<Segment>,
294 done_layer: Option<i64>,
295}
296
297impl SegmentSelector {
298 pub fn new(w: usize, h: usize, window_ms: u64, cfg: SelectTuning) -> Self {
299 Self {
300 w,
301 h,
302 window_ms,
303 cfg,
304 pending: None,
305 done_layer: None,
306 }
307 }
308
309 pub fn push(&mut self, layer: i64, idx: u64, t_ms: u64, gray: Vec<u8>) -> Option<SegmentPick> {
313 if let Some(seg) = &mut self.pending {
314 if seg.layer == layer {
315 let off = t_ms.saturating_sub(seg.start_ms);
316 if off <= self.window_ms {
317 seg.frames.push((off, idx, gray));
318 return None;
319 }
320 let pick = self.finalize();
322 self.done_layer = Some(layer);
323 return pick;
324 }
325 let pick = self.finalize();
327 self.open(layer, idx, t_ms, gray);
328 return pick;
329 }
330 if self.done_layer == Some(layer) {
331 return None; }
333 self.open(layer, idx, t_ms, gray);
334 None
335 }
336
337 pub fn finish(&mut self) -> Option<SegmentPick> {
339 self.finalize()
340 }
341
342 fn open(&mut self, layer: i64, idx: u64, t_ms: u64, gray: Vec<u8>) {
343 self.pending = Some(Segment {
344 layer,
345 start_ms: t_ms,
346 frames: vec![(0, idx, gray)],
347 });
348 }
349
350 fn finalize(&mut self) -> Option<SegmentPick> {
351 let seg = self.pending.take()?;
352 let frames: Vec<SelectFrame> = seg
353 .frames
354 .iter()
355 .map(|(off, _idx, g)| SelectFrame {
356 offset_ms: *off,
357 gray: g.clone(),
358 })
359 .collect();
360 match select_park_frame(&frames, self.w, self.h, &self.cfg) {
361 Selection::Selected {
362 offset_ms,
363 confidence,
364 } => {
365 let idx = seg
366 .frames
367 .iter()
368 .find(|(off, _, _)| *off == offset_ms)
369 .map(|(_, i, _)| *i)?;
370 Some(SegmentPick {
371 layer: seg.layer,
372 idx,
373 confidence,
374 })
375 }
376 Selection::Skipped { .. } => None,
377 }
378 }
379}
380
381#[derive(Clone, Copy)]
382struct IslandFrame {
383 idx: u64,
384 left_mass: f64,
385 sharpness: f64,
386}
387
388#[derive(PartialEq)]
389enum State {
390 Idle,
391 InIsland,
392 Suppress,
393}
394
395pub struct LiveParkDetector {
403 w: usize,
404 h: usize,
405 fps: f64,
406 park_hi: usize,
407 alpha: f64,
408 abs_floor: f64,
409 mad_k: f64,
410 merge_gap: u64,
411 max_island: u64,
412 min_sep_s: f64,
413 cand_frac: f64,
414 warmup: u64,
415 baseline_cap: usize,
416 baseline: VecDeque<f64>,
417 ema: Option<Vec<f64>>,
418 seen: u64,
419 state: State,
420 island: Vec<IslandFrame>,
421 start_idx: Option<u64>,
422 last_hi: Option<u64>,
423 last_emit_idx: Option<u64>,
424 last_emit_lm: Option<f64>,
425}
426
427impl LiveParkDetector {
428 pub fn new(w: usize, h: usize, cfg: &ParkTuning) -> Self {
431 let fps = cfg.fps;
432 let samples = |secs: f64| ((secs * fps).round() as i64).max(1) as u64;
433 Self {
434 w,
435 h,
436 fps,
437 park_hi: ((cfg.left_frac * w as f64) as usize).max(1),
438 alpha: ema_alpha(cfg.ema_seconds, fps),
439 abs_floor: cfg.abs_floor,
440 mad_k: cfg.mad_k,
441 merge_gap: samples(cfg.merge_gap_s),
442 max_island: samples(cfg.max_island_s),
443 min_sep_s: cfg.min_sep_s,
444 cand_frac: cfg.candidate_frac,
445 warmup: samples(cfg.warmup_s),
446 baseline_cap: (((cfg.baseline_s * fps).round() as i64).max(8)) as usize,
447 baseline: VecDeque::new(),
448 ema: None,
449 seen: 0,
450 state: State::Idle,
451 island: Vec::new(),
452 start_idx: None,
453 last_hi: None,
454 last_emit_idx: None,
455 last_emit_lm: None,
456 }
457 }
458
459 fn score(&mut self, gray: &[u8]) -> (f64, f64) {
463 match &mut self.ema {
464 None => self.ema = Some(gray.iter().map(|&v| v as f64).collect()),
465 Some(ema) => {
466 for (e, &v) in ema.iter_mut().zip(gray.iter()) {
467 *e = self.alpha * *e + (1.0 - self.alpha) * v as f64;
468 }
469 }
470 }
471 let ema = self.ema.as_ref().unwrap();
472 let mut left_mass = 0.0;
473 for y in 0..self.h {
474 let row = y * self.w;
475 for x in 0..self.park_hi {
476 let d = ema[row + x] - gray[row + x] as f64;
477 if d > 0.0 {
478 left_mass += d;
479 }
480 }
481 }
482 (left_mass, sharpness(gray, self.w, self.h))
483 }
484
485 fn threshold(&self) -> f64 {
487 if self.baseline.is_empty() {
488 return self.abs_floor;
489 }
490 let bl: Vec<f64> = self.baseline.iter().copied().collect();
491 let med = median(&bl);
492 let mad = if bl.len() > 1 {
493 let dev: Vec<f64> = bl.iter().map(|v| (v - med).abs()).collect();
494 median(&dev)
495 } else {
496 0.0
497 };
498 self.abs_floor.max(med + self.mad_k * mad)
499 }
500
501 pub fn push(&mut self, gray: &[u8], idx: u64) -> Option<Park> {
504 let (lm, sh) = self.score(gray);
505 self.seen += 1;
506 let above = lm >= self.threshold();
507 if !above {
508 if self.baseline.len() >= self.baseline_cap {
511 self.baseline.pop_front();
512 }
513 self.baseline.push_back(lm);
514 }
515 if self.seen <= self.warmup {
516 return None; }
518 match self.state {
519 State::Suppress => {
520 if !above {
522 self.state = State::Idle;
523 }
524 None
525 }
526 State::Idle => {
527 if above {
528 self.state = State::InIsland;
529 self.island = vec![IslandFrame {
530 idx,
531 left_mass: lm,
532 sharpness: sh,
533 }];
534 self.start_idx = Some(idx);
535 self.last_hi = Some(idx);
536 }
537 None
538 }
539 State::InIsland => {
540 if above {
541 self.island.push(IslandFrame {
542 idx,
543 left_mass: lm,
544 sharpness: sh,
545 });
546 self.last_hi = Some(idx);
547 }
548 let span = self.last_hi.unwrap() - self.start_idx.unwrap() + 1;
549 if span > self.max_island {
550 self.state = State::Suppress;
552 self.island.clear();
553 self.start_idx = None;
554 return None;
555 }
556 if idx - self.last_hi.unwrap() >= self.merge_gap {
557 return self.close(); }
559 None
560 }
561 }
562 }
563
564 fn close(&mut self) -> Option<Park> {
568 let island_max = self
569 .island
570 .iter()
571 .map(|f| f.left_mass)
572 .fold(f64::MIN, f64::max);
573 let cutoff = self.cand_frac * island_max;
574 let best = self
576 .island
577 .iter()
578 .filter(|f| f.left_mass >= cutoff)
579 .fold(None::<IslandFrame>, |acc, &f| match acc {
580 Some(b) if b.sharpness >= f.sharpness => Some(b),
581 _ => Some(f),
582 })
583 .unwrap();
584 self.state = State::Idle;
585 self.island.clear();
586 self.start_idx = None;
587
588 let mut replace = false;
589 if let (Some(le_idx), Some(le_lm)) = (self.last_emit_idx, self.last_emit_lm)
590 && (best.idx as f64 - le_idx as f64) / self.fps < self.min_sep_s
591 {
592 if island_max <= le_lm {
593 return None; }
595 replace = true; }
597 self.last_emit_idx = Some(best.idx);
598 self.last_emit_lm = Some(island_max);
599 let conf = round_to(
600 ((island_max - self.abs_floor) / (self.abs_floor + 1e-9)).min(1.0),
601 3,
602 );
603 Some(Park {
604 idx: best.idx,
605 t: round_to(best.idx as f64 / self.fps, 2),
606 left_mass: round_to(best.left_mass, 1),
607 sharpness: round_to(best.sharpness, 1),
608 confidence: conf,
609 replace,
610 })
611 }
612
613 pub fn flush(&mut self) -> Option<Park> {
615 if self.state == State::InIsland && !self.island.is_empty() {
616 let peak = self
617 .island
618 .iter()
619 .map(|f| f.left_mass)
620 .fold(f64::MIN, f64::max);
621 if peak >= self.abs_floor {
622 return self.close();
623 }
624 self.state = State::Idle;
625 self.island.clear();
626 self.start_idx = None;
627 }
628 None
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 const W: usize = 48;
637 const H: usize = 24;
638 const BG: u8 = 200;
639 const FIX: u8 = 40;
640 const OBJ: u8 = 110;
641 const HEAD: u8 = 25;
642 const CENTER: usize = W / 2;
643 const LEFT: usize = 6;
644
645 fn cfg() -> ParkTuning {
648 ParkTuning {
649 fps: 3.0,
650 left_frac: 0.33,
651 ema_seconds: 6.0,
652 abs_floor: 1500.0,
653 mad_k: 6.0,
654 merge_gap_s: 1.2,
655 max_island_s: 3.0,
656 min_sep_s: 3.0,
657 candidate_frac: 0.75,
658 warmup_s: 0.5,
659 baseline_s: 20.0,
660 }
661 }
662
663 fn cframe(head_x: usize, sharp: bool, weak: bool) -> Vec<u8> {
667 let mut img = vec![BG; W * H];
668 for y in 0..H {
669 let row = y * W;
670 img[row] = FIX;
671 img[row + 1] = FIX;
672 for x in (W / 2 - 3)..(W / 2 + 3) {
673 img[row + x] = OBJ;
674 }
675 if weak && y >= H / 2 {
676 continue; }
678 for x in 0..W {
679 let d = (x as i64 - head_x as i64).unsigned_abs() as i64;
680 if d <= 4 {
681 img[row + x] = HEAD;
682 } else if !sharp && d <= 7 {
683 img[row + x] =
684 (HEAD as f64 + (d - 4) as f64 / 3.0 * (BG as f64 - HEAD as f64)) as u8;
685 }
686 }
687 }
688 img
689 }
690
691 fn park(head_x: usize) -> Vec<u8> {
692 cframe(head_x, true, false)
693 }
694
695 fn run(frames: &[Vec<u8>]) -> Vec<(u64, Park)> {
697 let mut det = LiveParkDetector::new(W, H, &cfg());
698 let mut emits = Vec::new();
699 for (idx, f) in frames.iter().enumerate() {
700 if let Some(p) = det.push(f, idx as u64) {
701 emits.push((idx as u64, p));
702 }
703 }
704 if let Some(p) = det.flush() {
705 emits.push((frames.len() as u64 - 1, p));
706 }
707 emits
708 }
709
710 fn repeat(f: &[u8], n: usize) -> Vec<Vec<u8>> {
711 std::iter::repeat_n(f.to_vec(), n).collect()
712 }
713
714 fn chain(parts: &[Vec<Vec<u8>>]) -> Vec<Vec<u8>> {
715 parts.iter().flatten().cloned().collect()
716 }
717
718 #[test]
719 fn flat_stream_emits_nothing() {
720 assert!(run(&repeat(&park(CENTER), 24)).is_empty());
721 }
722
723 #[test]
724 fn single_park_emits_once_after_the_island_closes() {
725 let frames = chain(&[
726 repeat(&park(CENTER), 8),
727 repeat(&park(LEFT), 3),
728 repeat(&park(CENTER), 10),
729 ]);
730 let emits = run(&frames);
731 assert_eq!(emits.len(), 1, "{emits:?}");
732 let (push_idx, p) = &emits[0];
733 assert!((8..=10).contains(&p.idx), "picked a park frame: {p:?}");
734 assert!(*push_idx > p.idx, "emitted on CLOSE = lag");
735 }
736
737 #[test]
738 fn dwell_emits_once_and_picks_sharpest() {
739 let dwell = vec![
741 cframe(LEFT, false, false),
742 cframe(LEFT, true, false),
743 cframe(LEFT, false, false),
744 cframe(LEFT, false, false),
745 ];
746 let frames = chain(&[repeat(&park(CENTER), 8), dwell, repeat(&park(CENTER), 10)]);
747 let emits = run(&frames);
748 assert_eq!(emits.len(), 1, "{emits:?}");
749 assert_eq!(emits[0].1.idx, 9, "idx 9 = the sharp frame: {emits:?}");
750 }
751
752 #[test]
753 fn two_parks_far_apart_emit_twice() {
754 let frames = chain(&[
755 repeat(&park(CENTER), 8),
756 repeat(&park(LEFT), 2),
757 repeat(&park(CENTER), 15),
758 repeat(&park(LEFT), 2),
759 repeat(&park(CENTER), 8),
760 ]);
761 assert_eq!(run(&frames).len(), 2);
762 }
763
764 #[test]
765 fn two_close_parks_collapse_to_one() {
766 let frames = chain(&[
767 repeat(&park(CENTER), 8),
768 repeat(&park(LEFT), 2),
769 repeat(&park(CENTER), 2),
770 repeat(&park(LEFT), 2),
771 repeat(&park(CENTER), 8),
772 ]);
773 assert_eq!(run(&frames).len(), 1);
774 }
775
776 #[test]
777 fn a_stronger_close_park_supersedes_the_weaker() {
778 let frames = chain(&[
781 repeat(&park(CENTER), 8),
782 repeat(&cframe(LEFT, true, true), 2),
783 repeat(&park(CENTER), 5),
784 repeat(&park(LEFT), 2),
785 repeat(&park(CENTER), 8),
786 ]);
787 let emits = run(&frames);
788 assert_eq!(emits.len(), 2, "{emits:?}");
789 assert!(!emits[0].1.replace, "weak emitted first: {emits:?}");
790 assert!(emits[1].1.replace, "strong supersedes it: {emits:?}");
791 assert!(emits[1].1.left_mass > emits[0].1.left_mass);
792 }
793
794 #[test]
795 fn long_left_event_is_rejected_as_a_wipe() {
796 let frames = chain(&[
797 repeat(&park(CENTER), 6),
798 repeat(&park(LEFT), 12),
799 repeat(&park(CENTER), 6),
800 ]);
801 assert!(run(&frames).is_empty());
802 }
803
804 #[test]
805 fn tuning_missing_a_knob_is_a_hard_error() {
806 let json = r#"{"fps":4,"left_frac":0.33,"ema_seconds":30,"mad_k":6,
808 "merge_gap_s":1.2,"max_island_s":3,"min_sep_s":3,"candidate_frac":0.75,
809 "warmup_s":4,"baseline_s":90}"#;
810 let err = serde_json::from_str::<ParkTuning>(json).unwrap_err();
811 assert!(err.to_string().contains("abs_floor"), "{err}");
812 }
813
814 #[test]
815 fn the_example_config_parses_and_ignores_extra_keys() {
816 let json = r#"{"_comment":"x","fps":4,"left_frac":0.33,"ema_seconds":30,
819 "abs_floor":1500,"mad_k":6,"merge_gap_s":1.2,"max_island_s":3,"min_sep_s":3,
820 "candidate_frac":0.75,"warmup_s":4,"baseline_s":90,"min_outlier":2.5,
821 "min_left_density":3,"min_confidence":0.4,"select_candidate_frac":0.6}"#;
822 let cfg: ParkTuning = serde_json::from_str(json).unwrap();
823 assert_eq!(cfg.abs_floor, 1500.0);
824 assert_eq!(cfg.candidate_frac, 0.75);
825 }
826
827 #[test]
828 fn ema_alpha_is_bounded_and_grows_with_the_window() {
829 assert!(ema_alpha(1.0, 1.0) <= 0.999);
830 assert!(ema_alpha(30.0, 4.0) > ema_alpha(6.0, 4.0));
831 assert!(ema_alpha(0.0, 0.0) >= 0.0);
832 }
833}
834
835#[cfg(test)]
836mod select_tests {
837 use super::*;
841
842 const SW: usize = 48;
843 const SH: usize = 24;
844 const BG: u8 = 200;
845 const OBJ: u8 = 110;
846 const HEAD: u8 = 25;
847 const CENTER: i64 = 24;
848
849 fn sframe(head_x: i64, sharp: bool, obj_x: i64) -> Vec<u8> {
850 let head_hw: i64 = 5;
851 let mut img = vec![BG; SW * SH];
852 for y in 0..SH {
853 let row = y * SW;
854 let lo = (obj_x - 3).max(0) as usize;
855 let hi = (obj_x + 3).min(SW as i64) as usize;
856 for px in img[row + lo..row + hi].iter_mut() {
857 *px = OBJ;
858 }
859 for x in 0..SW {
860 let d = (x as i64 - head_x).abs();
861 if d <= head_hw {
862 img[row + x] = HEAD;
863 } else if !sharp && d <= head_hw + 3 {
864 img[row + x] = (HEAD as f64
865 + (d - head_hw) as f64 / 3.0 * (BG as f64 - HEAD as f64))
866 as u8;
867 }
868 }
869 }
870 img
871 }
872
873 fn sburst(specs: &[(u64, i64, bool)], obj_x: i64) -> Vec<SelectFrame> {
874 specs
875 .iter()
876 .map(|&(o, hx, s)| SelectFrame {
877 offset_ms: o,
878 gray: sframe(hx, s, obj_x),
879 })
880 .collect()
881 }
882
883 fn ex() -> SelectTuning {
884 SelectTuning {
885 left_frac: 0.33,
886 min_outlier: 2.5,
887 min_left_density: 3.0,
888 select_candidate_frac: 0.6,
889 min_confidence: 0.40,
890 }
891 }
892
893 fn sel(b: &[SelectFrame]) -> Selection {
894 select_park_frame(b, SW, SH, &ex())
895 }
896
897 #[test]
898 fn selects_far_left_parked() {
899 let b = sburst(
900 &[
901 (300, CENTER, true),
902 (500, CENTER, true),
903 (700, 6, true),
904 (900, 6, true),
905 (1100, CENTER, true),
906 (1300, CENTER, true),
907 ],
908 CENTER,
909 );
910 match sel(&b) {
911 Selection::Selected { offset_ms, .. } => assert!(matches!(offset_ms, 700 | 900)),
912 other => panic!("expected selected, got {other:?}"),
913 }
914 }
915
916 #[test]
917 fn all_over_print_is_skipped() {
918 let b = sburst(
919 &[
920 (300, CENTER, true),
921 (500, CENTER, true),
922 (700, CENTER, true),
923 (900, CENTER, true),
924 ],
925 CENTER,
926 );
927 assert!(matches!(sel(&b), Selection::Skipped { .. }));
928 }
929
930 #[test]
931 fn prefers_sharp_park_over_blurred_more_left() {
932 let b = sburst(
933 &[
934 (300, CENTER, true),
935 (500, CENTER, true),
936 (700, 3, false),
937 (900, 8, true),
938 (1100, CENTER, true),
939 (1300, CENTER, true),
940 ],
941 CENTER,
942 );
943 assert_eq!(
944 sel(&b),
945 Selection::Selected {
946 offset_ms: 900,
947 confidence: match sel(&b) {
948 Selection::Selected { confidence, .. } => confidence,
949 _ => unreachable!(),
950 },
951 }
952 );
953 }
954
955 #[test]
956 fn static_dark_object_is_not_mistaken_for_head() {
957 let b = sburst(
958 &[
959 (300, CENTER, true),
960 (500, CENTER, true),
961 (700, 6, true),
962 (900, 6, true),
963 (1100, CENTER, true),
964 (1300, CENTER, true),
965 ],
966 CENTER,
967 );
968 assert!(matches!(sel(&b), Selection::Selected { .. }));
969 }
970
971 #[test]
972 fn panned_camera_still_resolves_the_park() {
973 let b = sburst(
974 &[
975 (300, CENTER + 10, true),
976 (500, CENTER + 10, true),
977 (700, 16, true),
978 (900, 16, true),
979 (1100, CENTER + 10, true),
980 (1300, CENTER + 10, true),
981 ],
982 CENTER + 10,
983 );
984 match sel(&b) {
985 Selection::Selected { offset_ms, .. } => assert!(matches!(offset_ms, 700 | 900)),
986 other => panic!("expected selected, got {other:?}"),
987 }
988 }
989
990 #[test]
991 fn park_before_burst_is_skipped() {
992 let b = sburst(
993 &[
994 (900, CENTER, true),
995 (1100, CENTER, true),
996 (1300, CENTER, true),
997 (1500, CENTER, true),
998 ],
999 CENTER,
1000 );
1001 assert!(matches!(sel(&b), Selection::Skipped { .. }));
1002 }
1003
1004 const DARK_BACKDROP: u8 = 80; const BRIGHT_HEAD: u8 = 240; fn sframe_bright(head_x: i64, sharp: bool) -> Vec<u8> {
1012 let head_hw: i64 = 5;
1013 let left_w = (SW as f64 * 0.33) as usize;
1014 let mut img = vec![BG; SW * SH];
1015 for y in 0..SH {
1016 let row = y * SW;
1017 for px in img[row..row + left_w].iter_mut() {
1018 *px = DARK_BACKDROP; }
1020 for px in img[row + (CENTER as usize - 3)..row + (CENTER as usize + 3)].iter_mut() {
1021 *px = OBJ; }
1023 for x in 0..SW {
1024 let d = (x as i64 - head_x).abs();
1025 if d <= head_hw {
1026 img[row + x] = BRIGHT_HEAD;
1027 } else if !sharp && d <= head_hw + 3 {
1028 let base = if x < left_w { DARK_BACKDROP } else { BG };
1029 img[row + x] = (BRIGHT_HEAD as f64
1030 + (d - head_hw) as f64 / 3.0 * (base as f64 - BRIGHT_HEAD as f64))
1031 as u8;
1032 }
1033 }
1034 }
1035 img
1036 }
1037
1038 #[test]
1039 fn selects_a_bright_head_park() {
1040 let specs = [
1045 (300u64, CENTER, true),
1046 (500, CENTER, true),
1047 (700, 6, true),
1048 (900, 6, true),
1049 (1100, CENTER, true),
1050 (1300, CENTER, true),
1051 ];
1052 let b: Vec<SelectFrame> = specs
1053 .iter()
1054 .map(|&(o, hx, s)| SelectFrame {
1055 offset_ms: o,
1056 gray: sframe_bright(hx, s),
1057 })
1058 .collect();
1059 match sel(&b) {
1060 Selection::Selected { offset_ms, .. } => {
1061 assert!(
1062 matches!(offset_ms, 700 | 900),
1063 "picks the bright park: {offset_ms}"
1064 )
1065 }
1066 other => panic!("the bright-head park must be selected, got {other:?}"),
1067 }
1068 }
1069}
1070
1071#[cfg(test)]
1072mod segment_tests {
1073 use super::*;
1080
1081 const SW: usize = 48;
1082 const SH: usize = 24;
1083 const BG: u8 = 200;
1084 const OBJ: u8 = 110;
1085 const HEAD: u8 = 25;
1086 const CENTER: i64 = 24;
1087
1088 fn sframe(head_x: i64) -> Vec<u8> {
1089 let head_hw: i64 = 5;
1090 let mut img = vec![BG; SW * SH];
1091 for y in 0..SH {
1092 let row = y * SW;
1093 for px in img[row + (CENTER as usize - 3)..row + (CENTER as usize + 3)].iter_mut() {
1094 *px = OBJ; }
1096 for x in 0..SW {
1097 if (x as i64 - head_x).abs() <= head_hw {
1098 img[row + x] = HEAD;
1099 }
1100 }
1101 }
1102 img
1103 }
1104
1105 fn ex() -> SelectTuning {
1106 SelectTuning {
1107 left_frac: 0.33,
1108 min_outlier: 2.5,
1109 min_left_density: 3.0,
1110 select_candidate_frac: 0.6,
1111 min_confidence: 0.40,
1112 }
1113 }
1114
1115 fn sim_layer(
1118 park_at_ms: u64,
1119 park_dur_ms: u64,
1120 fps: u64,
1121 layer_ms: u64,
1122 ) -> Vec<(u64, Vec<u8>)> {
1123 let dt = (1000 / fps).max(1);
1124 let mut out = Vec::new();
1125 let mut t = 0u64;
1126 while t < layer_ms {
1127 let parked = t >= park_at_ms && t < park_at_ms + park_dur_ms;
1128 out.push((t, sframe(if parked { 6 } else { CENTER })));
1129 t += dt;
1130 }
1131 out
1132 }
1133
1134 fn run(
1135 sel: &mut SegmentSelector,
1136 layers: &[(i64, u64, u64, u64, u64)], ) -> Vec<SegmentPick> {
1138 let mut idx = 0u64;
1139 let mut base = 0u64;
1140 let mut picks = Vec::new();
1141 for &(layer, pa, pd, fps, lms) in layers {
1142 for (t, gray) in sim_layer(pa, pd, fps, lms) {
1143 if let Some(p) = sel.push(layer, idx, base + t, gray) {
1144 picks.push(p);
1145 }
1146 idx += 1;
1147 }
1148 base += lms;
1149 }
1150 if let Some(p) = sel.finish() {
1151 picks.push(p);
1152 }
1153 picks
1154 }
1155
1156 #[test]
1157 fn dense_stream_catches_the_jittery_park_each_layer() {
1158 let mut sel = SegmentSelector::new(SW, SH, 3000, ex());
1160 let layers: Vec<_> = [500u64, 2300, 900, 2400, 1500]
1161 .iter()
1162 .enumerate()
1163 .map(|(l, &pa)| (l as i64, pa, 500u64, 10u64, 4000u64))
1164 .collect();
1165 let picks = run(&mut sel, &layers);
1166 assert_eq!(picks.len(), 5, "one park caught per layer: {picks:?}");
1167 assert!(picks.iter().all(|p| p.confidence > 0.0), "{picks:?}");
1168 assert_eq!(
1170 picks.iter().map(|p| p.layer).collect::<Vec<_>>(),
1171 vec![0, 1, 2, 3, 4]
1172 );
1173 }
1174
1175 #[test]
1176 fn an_over_print_only_layer_is_skipped() {
1177 let mut sel = SegmentSelector::new(SW, SH, 3000, ex());
1179 let picks = run(
1180 &mut sel,
1181 &[(0, 99_999, 500, 10, 4000), (1, 800, 500, 10, 4000)],
1182 );
1183 assert_eq!(picks.len(), 1, "{picks:?}");
1184 assert_eq!(picks[0].layer, 1);
1185 }
1186
1187 #[test]
1188 fn too_coarse_sampling_misses_the_brief_park() {
1189 let mut sel = SegmentSelector::new(SW, SH, 3000, ex());
1192 let picks = run(
1193 &mut sel,
1194 &[(0, 1300, 300, 1, 4000), (1, 1300, 300, 1, 4000)],
1195 );
1196 assert!(
1197 picks.is_empty(),
1198 "coarse sampling misses the brief park: {picks:?}"
1199 );
1200 }
1201
1202 #[test]
1203 fn full_layer_window_catches_a_park_at_the_layer_change() {
1204 let layers = [
1210 (0i64, 14_000u64, 500u64, 10u64, 16_000u64),
1211 (1, 14_000, 500, 10, 16_000),
1212 (2, 99_999, 500, 10, 16_000),
1213 ];
1214 let mut short = SegmentSelector::new(SW, SH, 3000, ex());
1216 assert!(
1217 run(&mut short, &layers).is_empty(),
1218 "a 3s window closes before the layer-change park"
1219 );
1220 let mut full = SegmentSelector::new(SW, SH, 120_000, ex());
1222 let picks = run(&mut full, &layers);
1223 assert_eq!(
1224 picks.iter().map(|p| p.layer).collect::<Vec<_>>(),
1225 vec![0, 1],
1226 "full-layer segmenting catches the late park each parked layer: {picks:?}"
1227 );
1228 }
1229}