ados_vision/tracker/appearance.rs
1//! Appearance descriptors and the candidate type the tracker associates over.
2
3use ados_protocol::framebus::{BoundingBox, Detection};
4
5/// Length of the model-free appearance descriptor the interim
6/// [`HistogramAppearance`] produces. Small and fixed so it is cheap to compare
7/// and carry; a learned embedding would pick its own length behind the trait.
8pub const APPEARANCE_DIM: usize = 16;
9
10/// An appearance descriptor for one candidate box.
11///
12/// This is a normalized feature vector: the model-free interim builds it from
13/// coarse colour/gradient statistics of the box pixels; a learned re-id model
14/// would emit an L2-normalized embedding of the same shape. It is compared with
15/// [`AppearanceModel::similarity`], not by raw equality, so two descriptors of
16/// the *same* object (under jitter / lighting change) score high and two
17/// *different* objects score low.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Appearance {
20 /// The feature vector. Length is model-defined; the interim uses
21 /// [`APPEARANCE_DIM`].
22 pub features: Vec<f32>,
23}
24
25impl Appearance {
26 /// Build from a raw feature slice (the harness and a real extractor both use
27 /// this). The vector is stored as-is; [`AppearanceModel::similarity`] owns
28 /// any normalization.
29 pub fn from_features(features: Vec<f32>) -> Self {
30 Self { features }
31 }
32}
33
34/// Pluggable appearance backend.
35///
36/// `extract` turns a candidate box (its pixels, in a real impl) into an
37/// [`Appearance`]; `similarity` scores two descriptors in `0..=1` (1 = identical
38/// appearance, 0 = unrelated). The tracker depends only on this trait, so the
39/// model-free [`HistogramAppearance`] shipped here can be swapped for a learned
40/// re-id / siamese embedding with no tracker change: the new model
41/// just produces longer descriptors and a learned similarity.
42///
43/// The default `extract` returns `None` (no pixels available, the pure /
44/// synthetic path), so a tracker can run appearance-aware on descriptors handed
45/// to it directly (the harness) while a deployment plugs in a real extractor.
46pub trait AppearanceModel: Send + Sync {
47 /// Extract a descriptor for the box `bbox` from the frame `pixels`. Returns
48 /// `None` when no frame is available (the model-free / synthetic path), in
49 /// which case the caller must supply the descriptor on the [`Candidate`].
50 fn extract(&self, _pixels: Option<&[u8]>, _bbox: &BoundingBox) -> Option<Appearance> {
51 None
52 }
53
54 /// Similarity of two descriptors in `0..=1`. Higher = more alike.
55 fn similarity(&self, a: &Appearance, b: &Appearance) -> f32;
56}
57
58/// The model-free interim appearance backend: cosine similarity over the
59/// descriptor vectors.
60///
61/// It does NOT extract from pixels (a real deployment swaps in a re-id model for
62/// that); it scores descriptors the caller already holds. Cosine
63/// similarity is scale-invariant and maps cleanly onto `0..=1` after clamping,
64/// so a colour/gradient histogram now and a learned embedding later both work
65/// through the same `similarity`. Two equal vectors score 1.0; orthogonal ones
66/// score 0.0.
67#[derive(Debug, Clone, Copy, Default)]
68pub struct HistogramAppearance;
69
70impl AppearanceModel for HistogramAppearance {
71 fn similarity(&self, a: &Appearance, b: &Appearance) -> f32 {
72 cosine_similarity(&a.features, &b.features)
73 }
74}
75
76/// Cosine similarity clamped to `0..=1` (negative correlation reads as "no
77/// match", not as anti-match). A zero-magnitude vector yields 0.
78pub(super) fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
79 if a.len() != b.len() || a.is_empty() {
80 return 0.0;
81 }
82 let mut dot = 0.0f32;
83 let mut na = 0.0f32;
84 let mut nb = 0.0f32;
85 for (x, y) in a.iter().zip(b.iter()) {
86 dot += x * y;
87 na += x * x;
88 nb += y * y;
89 }
90 if na <= 0.0 || nb <= 0.0 {
91 return 0.0;
92 }
93 (dot / (na.sqrt() * nb.sqrt())).clamp(0.0, 1.0)
94}
95
96/// One candidate detection for a frame, optionally carrying its appearance
97/// descriptor.
98///
99/// The motion-only [`SingleObjectTracker::update`](super::SingleObjectTracker::update) path wraps each wire
100/// [`Detection`] with `appearance: None`; the appearance-aware
101/// [`SingleObjectTracker::update_with_appearance`](super::SingleObjectTracker::update_with_appearance) path carries a descriptor per
102/// candidate (extracted from pixels in a real deployment, supplied directly by
103/// the synthetic harness). The descriptor never reaches the wire, only the
104/// resulting lock-state / association confidence does.
105#[derive(Debug, Clone)]
106pub struct Candidate {
107 /// The detection as it would appear on the wire (no appearance field).
108 pub detection: Detection,
109 /// Appearance descriptor for this box, when available.
110 pub appearance: Option<Appearance>,
111}
112
113impl Candidate {
114 /// A candidate with no appearance descriptor (motion-only path).
115 pub fn motion_only(detection: Detection) -> Self {
116 Self {
117 detection,
118 appearance: None,
119 }
120 }
121
122 /// A candidate carrying its appearance descriptor.
123 pub fn with_appearance(detection: Detection, appearance: Appearance) -> Self {
124 Self {
125 detection,
126 appearance: Some(appearance),
127 }
128 }
129}