Skip to main content

ados_vision/tracker/
mod.rs

1//! A single-object visual tracker with an appearance model.
2//!
3//! Detectors are noisy: they jitter a box around an object, occasionally miss a
4//! frame, and report several objects when only one is the one a consumer locked
5//! onto (follow-me, cinematography framing, an inspection-target lock). This
6//! module turns that noisy per-frame stream into a single stable identity.
7//!
8//! It takes the per-frame detections (the canonical
9//! [`ados_protocol::framebus::Detection`], never a parallel type), predicts
10//! where the locked object should be next with a constant-velocity motion
11//! model, associates the single best detection by a COMBINED score (appearance
12//! similarity first, then motion-gate fit, then detector confidence), and
13//! stamps a persistent [`ados_protocol::framebus::Detection::track_id`] on the
14//! survivor. A brief miss (a few frames of occlusion or a dropped detection) is
15//! coasted on the prediction and keeps the id; only a real loss drops the lock
16//! and lets a re-acquire mint a fresh id.
17//!
18//! ## Why appearance, and where it comes from
19//!
20//! A gate that scores purely on geometry cannot tell two boxes apart when they
21//! overlap: a distractor that rides the prediction matches the gate as well as
22//! (or better than) the true target that maneuvered off it, so a best-IoU pick
23//! silently captures the wrong object. The fix is to also compare what the box
24//! *looks like*. This module captures the locked object's appearance template at
25//! confirm and matches candidates by appearance similarity, updating the
26//! template slowly so it cannot drift onto a distractor.
27//!
28//! The appearance descriptor is intentionally NOT on the wire [`Detection`]
29//! (raw embeddings would bloat every detection message). It travels through the
30//! tracker's own [`Candidate`] type as a side input. A real deployment extracts
31//! the descriptor from the candidate bbox's pixels via an [`AppearanceModel`]
32//! (a colour/gradient histogram by default; a learned re-id / siamese embedding
33//! plugs in through the same trait with no tracker change). The synthetic test
34//! harness supplies a descriptor per detection directly, so association can be
35//! exercised without pixels: a target and a distractor get *different*
36//! descriptors, exactly as two real objects would.
37//!
38//! ## The never-silent doctrine
39//!
40//! Association is appearance-first: among the candidates that clear the absolute
41//! appearance gate, the one that looks MOST like the template wins, and motion /
42//! detector-confidence only break a tie among candidates whose appearance is
43//! within an ambiguity margin of the best. A louder or better-positioned
44//! distractor can therefore never demote a candidate that is strictly more
45//! appearance-similar than it, the cue that owns identity (appearance) is not
46//! out-voted by the cues that only describe where a box sits and how confident
47//! the detector was about its class.
48//!
49//! When two admissible candidates are within that appearance margin of each
50//! other they are indistinguishable by appearance, so the tracker does NOT
51//! silently pick one: the lock goes [`LockState::Uncertain`], holds/coasts on the
52//! prediction, lowers its confidence, and raises a re-designation request
53//! ([`SingleObjectTracker::needs_redesignation`]) so the operator layer can
54//! require an explicit re-confirm before the lock is trusted again.
55//!
56//! Silent capture is PREVENTED for the cases vision can actually adjudicate: a
57//! distinguishable distractor (it loses the appearance race) and two co-visible
58//! appearance-ambiguous candidates (the never-silent hold fires). The one case
59//! that remains is a lone impostor that is appearance-identical to the target
60//! while the true target is absent from frame: that is information-theoretically
61//! unsolvable from vision alone (there is no signal to separate them), and it is
62//! handled out of band by the operator-re-designation backstop, not by a guess.
63//!
64//! The core is pure and deterministic, it owns no camera, socket, or clock. It
65//! advances on whatever per-frame candidate list it is handed, so the same input
66//! always yields the same id assignment.
67
68mod advance;
69mod appearance;
70mod config;
71mod kalman;
72mod scoring;
73
74pub use appearance::{Appearance, AppearanceModel, Candidate, HistogramAppearance, APPEARANCE_DIM};
75pub use config::{ScoreWeights, TrackState, TrackUpdate, TrackerConfig};
76
77use ados_protocol::framebus::{Detection, LockState};
78use kalman::BoxKalman;
79use scoring::{reported, total_cmp};
80
81/// The single-object tracker. Holds at most one track at a time.
82pub struct SingleObjectTracker {
83    cfg: TrackerConfig,
84    track: Option<Track>,
85    next_id: u64,
86    appearance: Box<dyn AppearanceModel>,
87    /// Latched while the current lock is asking for operator re-designation
88    /// (cleared on a fresh seed or an explicit [`Self::redesignate`]).
89    needs_redesignation: bool,
90}
91
92impl std::fmt::Debug for SingleObjectTracker {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("SingleObjectTracker")
95            .field("cfg", &self.cfg)
96            .field("track", &self.track)
97            .field("next_id", &self.next_id)
98            .field("needs_redesignation", &self.needs_redesignation)
99            .finish_non_exhaustive()
100    }
101}
102
103#[derive(Debug)]
104struct Track {
105    id: u64,
106    kf: BoxKalman,
107    state: TrackState,
108    /// Consecutive associated frames (for tentative→confirmed).
109    hits: u32,
110    /// Consecutive missed frames (for coast→lost).
111    misses: u32,
112    /// Class label of the locked object, carried through coasts.
113    class_label: String,
114    /// Last measured confidence, carried through coasts.
115    confidence: f32,
116    /// Last association confidence stamped on the wire (carried into a coast).
117    assoc_confidence: f32,
118    /// The locked object's appearance template, updated slowly on confident
119    /// associations. `None` until the first descriptor is seen.
120    template: Option<Appearance>,
121    /// Set when a re-association just brought the track out of a MISS-induced
122    /// coast. The first such re-association stays Coasting (reported Uncertain)
123    /// for one transitional re-confirm frame instead of snapping to Confirmed;
124    /// the next clean association clears this and confirms. This makes a handover
125    /// laundered during an occlusion surface doubt for at least one frame.
126    reconfirming: bool,
127    /// True while the current coast is an APPEARANCE-AMBIGUITY hold (two
128    /// indistinguishable candidates) rather than a genuine miss. An ambiguity
129    /// hold that is then resolved by a single lone admissible candidate (the
130    /// competitor left frame) re-locks immediately, the ambiguity was resolved
131    /// by the competitor's disappearance, so no extra re-confirm frame is owed.
132    /// Only a miss-coast (the laundering threat) takes the re-confirm window.
133    coast_ambiguous: bool,
134    /// True only when this track originates from an explicit operator
135    /// [`SingleObjectTracker::designate`] call. An automatically seeded track
136    /// (the most-confident auto-pick) is `false`: it is still tracked for
137    /// continuity, it keeps its id and predicted box, but a consumer publish
138    /// path must NOT present it as locked. Only a target the operator actually
139    /// designated carries a lock state to consumers, so a follow behavior can
140    /// never engage a subject nobody chose. This mirrors the offload publish
141    /// path, which stamps a lock state only on the designated track.
142    operator_designated: bool,
143}
144
145impl SingleObjectTracker {
146    /// A tracker with the model-free interim appearance backend
147    /// ([`HistogramAppearance`]).
148    pub fn new(cfg: TrackerConfig) -> Self {
149        Self::with_appearance_model(cfg, Box::new(HistogramAppearance))
150    }
151
152    /// A tracker with a chosen appearance backend. A learned re-id /
153    /// siamese embedding implementing [`AppearanceModel`] plugs in here with no
154    /// other change.
155    pub fn with_appearance_model(cfg: TrackerConfig, appearance: Box<dyn AppearanceModel>) -> Self {
156        Self {
157            cfg,
158            track: None,
159            next_id: 1,
160            appearance,
161            needs_redesignation: false,
162        }
163    }
164
165    /// The id the lock currently holds, if confirmed or coasting.
166    pub fn current_id(&self) -> Option<u64> {
167        self.track.as_ref().and_then(|t| match t.state {
168            TrackState::Confirmed | TrackState::Coasting => Some(t.id),
169            _ => None,
170        })
171    }
172
173    /// The lifecycle state.
174    pub fn state(&self) -> TrackState {
175        self.track.as_ref().map_or(TrackState::Idle, |t| t.state)
176    }
177
178    /// True while the tracker is asking the operator to re-designate the target
179    /// because the identity could not be resolved safely (two appearance-
180    /// ambiguous candidates). Composes with an operator re-acknowledge step:
181    /// the operator layer should require an explicit re-confirm before trusting
182    /// the lock again. The flag clears on a clean re-association or an explicit
183    /// [`Self::redesignate`].
184    pub fn needs_redesignation(&self) -> bool {
185        self.needs_redesignation
186    }
187
188    /// Operator re-designation hook: clear the ambiguity latch (the operator has
189    /// re-confirmed the target out of band). Does not move the lock; the next
190    /// frame re-associates from the held state.
191    pub fn redesignate(&mut self) {
192        self.needs_redesignation = false;
193    }
194
195    /// Operator designation: drop any current lock and seed a fresh track onto a
196    /// specific detection (the box the operator clicked), instead of the
197    /// most-confident one, the operator's pick overrides the confidence floor.
198    /// Returns the new track id. The next [`update`] associates from this seed.
199    /// Used by the click-to-follow path so the operator picks the target rather
200    /// than the tracker's auto-lock.
201    ///
202    /// [`update`]: Self::update
203    pub fn designate(&mut self, detection: &Detection) -> Option<u64> {
204        self.track = None;
205        let cand = Candidate::motion_only(detection.clone());
206        let _ = self.seed(&[&cand], true);
207        self.track.as_ref().map(|t| t.id)
208    }
209
210    /// Advance one frame on a list of wire detections, motion-only (no
211    /// appearance descriptors). Each detection is wrapped as a [`Candidate`]
212    /// with no appearance, so association falls back to the motion gate plus the
213    /// class-continuity guard. Kept for callers / tests that have no descriptor
214    /// source; the appearance defence is inactive on this path.
215    pub fn update(&mut self, detections: &[Detection]) -> TrackUpdate {
216        let candidates: Vec<Candidate> = detections
217            .iter()
218            .cloned()
219            .map(Candidate::motion_only)
220            .collect();
221        self.update_with_appearance(&candidates)
222    }
223
224    /// Advance one frame on a list of [`Candidate`]s carrying appearance
225    /// descriptors. This is the real path: the caller extracts a descriptor per
226    /// candidate from the frame pixels, or a synthetic harness
227    /// supplies one. Association is appearance-first (the most appearance-similar
228    /// admissible candidate wins; motion + confidence only break an
229    /// appearance tie), the appearance gate rejects shadowing distractors, and
230    /// the ambiguity guard refuses to silently pick between two
231    /// appearance-indistinguishable objects.
232    pub fn update_with_appearance(&mut self, candidates: &[Candidate]) -> TrackUpdate {
233        // Filter out detections below the confidence floor up front, and any
234        // box-less percept (a mask/pose/depth-only reading): this is a 2D-box
235        // tracker, so a candidate with no `bbox` is not a follow target.
236        let eligible: Vec<&Candidate> = candidates
237            .iter()
238            .filter(|c| {
239                c.detection.confidence >= self.cfg.min_confidence && c.detection.bbox.is_some()
240            })
241            .collect();
242
243        match self.track.take() {
244            None => self.seed(&eligible, false),
245            Some(track) => self.advance(track, &eligible),
246        }
247    }
248
249    /// No track yet: lock onto the most confident eligible detection (if any),
250    /// capturing its appearance template. `operator_designated` is `true` only
251    /// when the seed comes from an explicit [`Self::designate`] call (the
252    /// operator's pick); the automatic most-confident seed passes `false`, and
253    /// that flag rides the track so a consumer publish path can withhold the
254    /// lock state from an auto-seeded target.
255    fn seed(&mut self, candidates: &[&Candidate], operator_designated: bool) -> TrackUpdate {
256        // A fresh seed clears any pending re-designation: we are starting over.
257        self.needs_redesignation = false;
258        let best = candidates
259            .iter()
260            .copied()
261            .max_by(|a, b| total_cmp(a.detection.confidence, b.detection.confidence));
262        match best {
263            None => TrackUpdate {
264                state: TrackState::Idle,
265                detection: None,
266                track_id: None,
267                measured: false,
268                needs_redesignation: false,
269                operator_designated: false,
270            },
271            Some(cand) => {
272                let det = &cand.detection;
273                let id = self.next_id;
274                self.next_id += 1;
275                let template = self.template_for(cand);
276                let track = Track {
277                    id,
278                    kf: BoxKalman::new(
279                        det.bbox
280                            .as_ref()
281                            .expect("eligible candidate carries a bbox"),
282                        &self.cfg,
283                    ),
284                    // One hit is not yet enough to confirm (default confirm_hits
285                    // is 2), so a single spurious blob never claims an id.
286                    state: if self.cfg.confirm_hits <= 1 {
287                        TrackState::Confirmed
288                    } else {
289                        TrackState::Tentative
290                    },
291                    hits: 1,
292                    misses: 0,
293                    class_label: det.class_label.clone(),
294                    confidence: det.confidence,
295                    assoc_confidence: det.confidence,
296                    template,
297                    reconfirming: false,
298                    coast_ambiguous: false,
299                    operator_designated,
300                };
301                let state = track.state;
302                let report = if state == TrackState::Confirmed {
303                    Some(reported(
304                        &track,
305                        track.kf.bbox(),
306                        LockState::Locked,
307                        det.confidence,
308                    ))
309                } else {
310                    None
311                };
312                let id_out = self.current_id_for(&track);
313                self.track = Some(track);
314                TrackUpdate {
315                    state,
316                    detection: report,
317                    track_id: id_out,
318                    measured: true,
319                    needs_redesignation: false,
320                    operator_designated,
321                }
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests;