Skip to main content

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}