Skip to main content

bambu_rs/core/
park.rs

1//! Pure, online "parked frame per layer" detector — the I/O-free core of the smooth
2//! timelapse, ported from the validated Python `live_mine_smooth.py` so `bambu serve`
3//! can drive it in-process (one ffmpeg as the only external tool; no python3 runtime).
4//!
5//! Signal: each layer the A1's native timelapse parks the head off to the far-left
6//! X-min with the print exposed. Against a causal per-pixel EMA "background" (the
7//! recent typical scene — head printing at center + the growing object), that park is
8//! the head appearing ANOMALOUSLY dark in the LEFT zone, so dark-vs-background mass in
9//! the left strip (`left_mass`) spikes once per layer. [`LiveParkDetector`] tracks the
10//! EMA, thresholds `left_mass` against a robust rolling baseline, groups above-threshold
11//! frames into islands, rejects implausible ones (too-long span = a filament wipe), and
12//! emits the sharpest frame of each island when it CLOSES (a few-frames lag) — the
13//! online analog of the batch picker.
14//!
15//! Tuning is config-driven with NO defaults (the knobs depend on camera/printer
16//! placement, which moves): [`ParkTuning`] deserializes from JSON and a missing field
17//! is a hard error, never a silent stale value. The caller owns all I/O (ffmpeg, frame
18//! bytes, writing `latest_park.jpg`); this stays pure so it's exhaustively unit-tested.
19
20use std::collections::VecDeque;
21
22use serde::{Deserialize, Serialize};
23
24/// Park-detection heuristics for ONE camera/printer setup. Deserialized from a config
25/// (e.g. `scripts/tuning.example.json`); there are deliberately NO defaults, so a
26/// missing field fails to parse rather than running with a wrong baked value. Extra
27/// keys in the JSON (the batch/select knobs, `_comment`s) are ignored.
28#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
29pub struct ParkTuning {
30    /// Stream sampling rate (frames/s) the detector is fed at.
31    pub fps: f64,
32    /// Park zone = the left this-fraction of the frame (camera framing).
33    pub left_frac: f64,
34    /// EMA background time-constant (seconds).
35    pub ema_seconds: f64,
36    /// Minimum `left_mass` for a real park (scales with framing/lighting/scale).
37    pub abs_floor: f64,
38    /// Threshold = rolling median + `mad_k` * MAD of `left_mass`.
39    pub mad_k: f64,
40    /// Above-threshold frames within this gap (seconds) form one island/park.
41    pub merge_gap_s: f64,
42    /// An island whose spike SPAN exceeds this (seconds) is a wipe/purge, not a park.
43    pub max_island_s: f64,
44    /// Parks closer than this (seconds) keep only the stronger.
45    pub min_sep_s: f64,
46    /// Pick the sharpest frame among those >= this * the island's max `left_mass`.
47    pub candidate_frac: f64,
48    /// Settle the background this long (seconds) before emitting.
49    pub warmup_s: f64,
50    /// Rolling-baseline window (seconds) for the threshold.
51    pub baseline_s: f64,
52}
53
54/// One emitted park: the chosen frame index, its time, and why it was chosen. `replace`
55/// = this park lands within `min_sep` of and is stronger than the one just emitted (the
56/// same layer), so the IO layer overwrites that frame rather than adding one.
57#[derive(Debug, Clone, PartialEq)]
58pub struct Park {
59    /// Frame index of the chosen (sharpest) frame of the island.
60    pub idx: u64,
61    /// Its timestamp (seconds) = `idx / fps`.
62    pub t: f64,
63    pub left_mass: f64,
64    pub sharpness: f64,
65    /// How far the island's peak rose above the floor, capped at 1.0.
66    pub confidence: f64,
67    /// Supersede the previously emitted park (a stronger close pair) vs. a new one.
68    pub replace: bool,
69}
70
71/// EMA smoothing factor for a ~`ema_seconds` background at sampling `fps`.
72pub 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
76/// Tenengrad-ish gradient energy: high for a settled/in-focus frame, low for a
77/// motion-blurred travel frame. Sum of squared neighbour differences, normalised.
78fn 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
98/// Median of a slice (average of the two middles for an even count), matching
99/// Python's `statistics.median`. Empty → 0.0.
100fn 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
119/// One burst frame for [`select_park_frame`]: its capture offset (ms after the layer edge)
120/// and the decoded grayscale (`w*h`, row-major).
121pub struct SelectFrame {
122    pub offset_ms: u64,
123    pub gray: Vec<u8>,
124}
125
126/// Knobs for [`select_park_frame`]. Distinct from [`ParkTuning`]: selection scores against
127/// the per-burst MEDIAN (not the live EMA), so its cutoffs differ. No defaults — every knob
128/// is supplied by the caller (it depends on camera/printer placement).
129#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
130pub struct SelectTuning {
131    /// Park zone = the left this-fraction of the frame.
132    pub left_frac: f64,
133    /// The park's left-mass must exceed this × the burst-median left-mass (relative outlier).
134    pub min_outlier: f64,
135    /// …and be at least this mean darkness (0–255) over the park zone (absolute floor).
136    pub min_left_density: f64,
137    /// Among frames with left-mass ≥ this × the burst max, pick the sharpest.
138    pub select_candidate_frac: f64,
139    /// Reject the pick (skip the layer) below this confidence.
140    pub min_confidence: f64,
141}
142
143/// Why a layer's burst yielded no parked frame.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum SkipReason {
146    /// No frame showed a strong left excursion — the park fell outside the burst, or the
147    /// head stayed over the print. A gap beats emitting a head-over-print frame.
148    ParkNotCaptured,
149    /// A park was found but it wasn't confident enough.
150    LowConfidence,
151}
152
153/// The outcome of picking a layer's parked frame from its burst.
154#[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
164/// Pick the parked ("head out of the way, object visible") frame from ONE layer's capture
165/// burst, or skip the layer. Pure port of the skill's `select_smooth.select_frame`.
166///
167/// Isolate the transient toolhead by subtracting the per-burst MEDIAN (static
168/// bed/object/printer/fixtures cancel), then measure how much it CHANGES the LEFT park
169/// zone (left `left_frac` of the frame, where an X-min park maps for a left-mounted
170/// camera). The change is measured by ABSOLUTE deviation from the median, not just the
171/// darker direction: depending on the camera the parked head reads dark (a nozzle against a
172/// bright bed) OR bright (the white extruder body against a darker backdrop), and a
173/// dark-only measure misses the bright case entirely (it scored ~0 and skipped every layer
174/// on the front-right A1 framing). The park frame's left change is a strong OUTLIER vs the
175/// burst median; the sharpest such frame (the settled dwell, not the motion-blurred travel)
176/// wins. A layer whose park fell outside the burst shows no outlier and is SKIPPED.
177pub 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    // Per-pixel median across the burst — the static scene (bed/object/fixtures).
191    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    // Change saliency (absolute deviation from the median, either polarity — the parked head
200    // may read dark or bright depending on the camera) summed over the left zone, + sharpness.
201    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; // a relative outlier…
220    let left_density = l_max / (park_hi * h) as f64; // …and absolutely dark enough
221    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    // The park is among the strongly-left frames; pick the sharpest of them.
230    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/// A picked frame for one layer's segment: which stream frame index to keep (→ its ring
255/// JPEG) and the selection confidence.
256#[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>)>, // (offset_ms, stream idx, gray)
267}
268
269/// Segment the CONTINUOUS camera stream by print layer and pick the parked frame per layer
270/// with [`select_park_frame`] — the dense-stream alternative to the sparse snapshot burst.
271///
272/// The native park runs at the LAYER CHANGE: a brief (~0.5 s) far-left dwell whose delay vs
273/// the `layer_num` edge is large and WILDLY variable (it's the layer-change gcode, so it
274/// lands near the layer's end relative to the edge, drifting with layer time). A sparse grid
275/// of single grabs misses it, and so does a short fixed window after the edge — the very
276/// failure that defeated the snapshot burst. The robust answer is to accumulate the WHOLE
277/// layer (every frame until the next layer edge) and let the MEDIAN subtraction isolate the
278/// transient far-left head against the layer's typical printing frames. So `window_ms` is a
279/// generous SAFETY CAP (max accumulation before forcing a selection, to bound memory if the
280/// A1's `layer_num` sticks), NOT a gate: in normal operation the next layer edge finalizes
281/// the segment first, well within the cap. Feed gray frames as they arrive (their stream
282/// `idx`, capture time, and the current layer); each layer's frames accumulate until the next
283/// layer (or the cap), then the selector runs over them and emits the chosen frame's index.
284/// Pure (no I/O): the caller owns ffmpeg + the ring JPEGs and copies the picked index out.
285pub struct SegmentSelector {
286    w: usize,
287    h: usize,
288    /// Safety cap (ms): the max a single layer's frames accumulate before a forced selection.
289    /// Normally the next layer edge finalizes the segment first; this only bites when
290    /// `layer_num` stalls, so it's set well above any real layer time.
291    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    /// Feed one stream frame. Returns a [`SegmentPick`] when a layer's window CLOSES with a
310    /// selected park — finalized either when the NEXT layer starts or `window_ms` elapses.
311    /// Frames after a layer's window (until the next layer) are ignored. `None` otherwise.
312    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                // Window elapsed → finalize; ignore later same-layer frames until next layer.
321                let pick = self.finalize();
322                self.done_layer = Some(layer);
323                return pick;
324            }
325            // A new layer arrived → finalize the old segment, open a fresh one.
326            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; // this layer's window already closed
332        }
333        self.open(layer, idx, t_ms, gray);
334        None
335    }
336
337    /// Finalize the last open segment at stream end.
338    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
395/// Online park detector. Feed grayscale frames one at a time via [`push`]; it returns a
396/// [`Park`] when an island CLOSES (else `None`), reproducing the batch picker's "sharpest
397/// frame of the dwell" at the cost of a few-frames lag. [`flush`] closes a still-open
398/// island when the stream ends.
399///
400/// [`push`]: LiveParkDetector::push
401/// [`flush`]: LiveParkDetector::flush
402pub 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    /// Build a detector for `w`x`h` grayscale frames with the given tuning. Every knob is
429    /// taken from `cfg` — no baked defaults.
430    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    /// Update the causal per-pixel EMA background with one frame and score it: the
460    /// dark-vs-background mass in the LEFT zone (the park signal) and the frame's
461    /// sharpness. The EMA is seeded from the first frame.
462    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    /// Robust threshold: max(abs_floor, rolling median + k·MAD of the quiet `left_mass`).
486    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    /// Feed one grayscale frame (length `w*h`) at index `idx`; returns a [`Park`] iff an
502    /// island closed on it.
503    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            // Only QUIET frames define the background; spikes (parks/wipes) must not
509            // raise the threshold and shadow the next park.
510            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; // let the background settle before emitting
517        }
518        match self.state {
519            State::Suppress => {
520                // A rejected wipe is still going — wait until it ends.
521                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                    // Spike SPAN too long → a wipe; suppress until it ends.
551                    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(); // island closed (a gap of quiet)
558                }
559                None
560            }
561        }
562    }
563
564    /// Pick the sharpest strong frame of the open island. If it lands within `min_sep` of
565    /// the last emit, keep only the STRONGER (batch parity): a weaker island is dropped, a
566    /// stronger one supersedes the just-emitted park (flagged `replace`).
567    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        // Sharpest of the strong frames; on a tie keep the FIRST (matches the Python max).
575        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; // too close AND not stronger → drop
594            }
595            replace = true; // stronger → supersede the previous park
596        }
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    /// Stream ended/disconnected: close an open island only if it had a real peak.
614    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    /// Short warmup/ema so the synthetic streams settle quickly; framing knobs mirror
646    /// the example config. The code has no defaults, so the test states them.
647    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    /// Bright bed + a STATIC dark fixture at the far-left edge + a static center object +
664    /// a dark head bar at `head_x` (feathered edges when not `sharp` = motion blur).
665    /// `weak` draws the head on only the top half of rows → a fainter island.
666    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; // head only on the top half → fainter
677            }
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    /// Push frames one at a time; return `(push_idx, park)` emitted, plus a flush.
696    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        // 4 park frames; the 2nd (idx 9) is the sharp settled one, the rest blurred travel.
740        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        // a weak (partial) island, then the real STRONGER park <min_sep later but far
779        // enough to close separately: emit the strong one flagged `replace`.
780        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        // No defaults: a config missing `abs_floor` must fail to parse, not run with 0.
807        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        // The shared example carries batch/select knobs + comments too; the live subset
817        // must parse, ignoring the rest.
818        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    //! Mirrors the skill's test_select_smooth.py: synthetic grayscale bursts (bright bed,
838    //! a STATIC dark object that must not be mistaken for the head, a dark moving head that
839    //! is over-print in the majority and parks far-left in a minority).
840    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    // ── bright-head park (the real front-right A1 framing) ──
1005    const DARK_BACKDROP: u8 = 80; // the purge bucket / wall filling the left of the frame
1006    const BRIGHT_HEAD: u8 = 240; // the WHITE extruder body, parked far-left
1007
1008    /// A frame where the LEFT zone is a static DARK backdrop (bucket/wall) and the parked
1009    /// head is BRIGHT — the inverse polarity of [`sframe`]. Over-print frames leave the left
1010    /// zone dark; a park lands the bright head over it, so the park BRIGHTENS the left zone.
1011    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; // static dark left backdrop
1019            }
1020            for px in img[row + (CENTER as usize - 3)..row + (CENTER as usize + 3)].iter_mut() {
1021                *px = OBJ; // static center print object
1022            }
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        // Real hardware: on the front-right A1 framing the parked head is the white extruder
1041        // body over a darker backdrop, so it BRIGHTENS the left zone. A dark-only saliency
1042        // scored ~0 here and skipped every layer; the absolute-change saliency catches it.
1043        // (Validated against the live capture: dark density 2.5 → skip, abs density 9.2 → pick.)
1044        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    //! Software-only DEVICE MODEL of the native park: the head is over the print (center)
1074    //! most of the time and parks FAR-LEFT for a brief dwell at a per-layer-VARIABLE delay
1075    //! (the real device's jitter). Feeding the modeled continuous stream through
1076    //! SegmentSelector reproduces in software both the win (a frame-accurate dense stream
1077    //! catches the jittery park every layer) and the failure (too-coarse sampling misses
1078    //! the brief dwell) — no ffmpeg, camera, or printer needed.
1079    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; // a static dark print object at center
1095            }
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    /// Model one layer's camera frames at `fps` over `layer_ms`: head over-print (center)
1116    /// except parked FAR-LEFT during `[park_at_ms, park_at_ms+park_dur_ms)`.
1117    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)], // (layer, park_at, park_dur, fps, layer_ms)
1137    ) -> 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        // 10 fps, parks at WILDLY varying delays (what defeats a sparse fixed grid).
1159        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        // picks are emitted in layer order
1169        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        // Layer 0 never parks → no pick (a gap beats a head-over-print frame); layer 1 does.
1178        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        // 1 fps (1000ms spacing) over a 300ms dwell tucked between samples → no frame lands
1190        // in the park → skipped. This is the real failure mode; the fix is dense sampling.
1191        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        // The REAL native park is the LAYER-CHANGE gcode: it fires near the END of a layer
1205        // relative to the layer_num edge (here ~14s into a 16s layer), NOT in the first few
1206        // seconds. This is what actually defeated the capture on hardware.
1207        //
1208        // Layers: two that park late + a third (never parks) to provide the closing edge.
1209        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        // A short window closes long before the park → catches nothing (the hardware bug).
1215        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        // A full-layer window finalizes on the NEXT layer edge → the park is in the segment.
1221        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}