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;