vrt-track: 3D multi-object tracking + metric geometry
vrt-track is pure CPU (nalgebra + vrt-types, no TensorRT/CUDA) — a natural
upstream candidate. It turns per-frame detections (box + score + class, plus optional
metric depth and appearance) into stable track ids with a 3D state. Feed it
downstream of the GPU pipeline (see vrt-pipeline-compose); it costs < 0.1 ms/frame.
The 3D state (the point of the crate)
Kalman state is 8-D: [px, py, pz, w, h, vx, vy, vz] — box-centre pixels px,py,
depth pz, box size w,h, 3D velocity. Constant-velocity in 3D; box size is a
random walk. This is not the classic image-plane xywh SORT filter — it carries a
real depth axis. Files: kalman.rs (the filter), track.rs (Tracklet + public
Track), tracker.rs (association + lifecycle), association.rs (cost matrices +
Hungarian).
Depth is optional + graceful. Detection::with_depth(z) attaches a mask-sampled
metric range; the measurement is always [px,py,pz,w,h], but with no depth the depth
row's variance is inflated so its Kalman gain → 0 and pz/vz coast on the motion
model — the filter behaves exactly like an image-plane tracker on the observed axes.
Supply real depth and the 3D estimate sharpens. One fixed-size code path, no reshaping.
Association (why ids stay stable)
ByteTrack two stages: high-confidence detections match first (IoU + optional
appearance), then a recovery pass over low-confidence detections keeps established
tracks alive through partial/occluded frames. Assignment is a dependency-free
Hungarian (optimal, MOT-scale is trivial). Three fusions layer onto the IoU cost,
each only lowers cost (rescue, never block); the depth gate is the hard veto:
- Depth gate (
association::gate_depth, tracker.rs config depth_gate_rel/abs) —
reject a pair when |z_track − z_det| > max(abs, rel·z_track), both sides depth-known.
Kills id-swaps between objects overlapping in the image but at different ranges.
Keep it loose (rel 0.35, abs 0.7 m): monocular-depth noise on a static object
is large, and a tight gate false-rejects a valid match → id churn. Genuine
foreground/background separation is far larger and still vetoes a swap.
- Centre-proximity rescue (
association::fuse_center, stage 2 only) — an occluded
object's box shrinks to the visible fragment, so IoU drops below the gate even though
the centre hasn't moved. A size-normalised centre cost re-acquires it. Gated to
near-static tracks (image-plane speed < STATIC_SPEED_PX, in tracker.rs): a fast
coasting track's centre has drifted, so centre-only matching would let it steal a
neighbour. Applied in stage 2 only — stage-1 fusion let adjacent same-depth objects
swap.
- NSA measurement noise (
track.rs, NSA_ALPHA) — R ← R·(1 + α·(1−score)), α=2:
a low-confidence box nudges the state gently, a crisp one updates firmly.
Lifecycle + real dt
Tentative → Confirmed → Lost → Removed. A Lost track coasts on the Kalman for
track_buffer frames (default 60 ≈ 4 s at 15 fps) so intermittent detection doesn't
churn its id, then is reaped. update(&dets) steps at dt=1; update_dt(&dets, dt)
scales the Kalman F/Q by real inter-frame seconds (jitter-robust under variable fps).
Metric 3D + world frame (vrt-types)
CameraIntrinsics::from_hfov(w, h, hfov_deg) builds fx,fy,cx,cy from a spec'd FoV;
unproject(px,py,z) → camera-frame (X,Y,Z) metres.
Undistorter (undistort.rs) — one GPU k1 barrel remap applied before seg/depth
so boxes/masks/metric-3D are rectified-pinhole.
Track::metric_position(intr) = camera-frame metres; world_position(intr, extr)
applies a CameraExtrinsics { r, t } pose → shared world frame (IDENTITY =
single-camera). This is the basis for the world-frame BEV (vrt-viz::render_bev) and
multi-camera fusion.
Appearance / ReID (feature appearance, off by default)
A hook, not a model: supply L2-normalised embeddings via Detection::feature;
cosine distance is min-fused into the IoU cost (BoT-SORT style) with a per-track EMA
bank. No embedder in the crate — vrt-osnet (or the detector's own backbone
features) provides the vectors. Update the bank only from confident detections.
Tuning knobs (TrackerConfig)
track_high_thresh/track_low_thresh (the two association tiers), match_thresh /
match_thresh_second (IoU gates), depth_gate_rel/depth_gate_abs, track_buffer,
min_hits, KalmanParams.
Gotchas
- IoU alone fails on occlusion — a partially-hidden object's box changes shape, so
geometry can't always re-acquire it; the centre-fuse handles the static case, but the
robust fix for shape-changed re-entry is appearance ReID.
- Loose depth gate on purpose — tightening it to "look precise" causes id churn on
static objects from monocular-depth noise. Diagnose churn with the example's
distinct-ids/100-frame counter.
- Not full BoT-SORT — camera-motion compensation (GMC) is deliberately omitted
(unneeded for fixed cameras). Add it back only for a moving/handheld camera.
Related skills
vrt-pipeline-compose — where the GPU detections + mask-sampled depth come from.
jetson-benchmarking — the tracker is CPU; measure it off the GPU wall.
1---2name: vrt-tracking3description: Use when working on multi-object tracking or metric-3D geometry — the vrt-track 3D Kalman, ByteTrack association, depth gating, NSA noise, occlusion recovery, coasting, world-frame BEV, camera intrinsics/extrinsics/undistort (vrt-types), or the appearance/ReID hook. Covers the tracker's design + tuning knobs and how metric depth feeds it, not GPU/model internals.4---56# vrt-track: 3D multi-object tracking + metric geometry78`vrt-track` is **pure CPU** (nalgebra + `vrt-types`, no TensorRT/CUDA) — a natural9upstream candidate. It turns per-frame detections (box + score + class, plus optional10**metric depth** and **appearance**) into stable track ids with a **3D** state. Feed it11downstream of the GPU pipeline (see `vrt-pipeline-compose`); it costs < 0.1 ms/frame.1213## The 3D state (the point of the crate)1415Kalman state is **8-D**: `[px, py, pz, w, h, vx, vy, vz]` — box-centre pixels `px,py`,16**depth `pz`**, box size `w,h`, 3D velocity. Constant-velocity in 3D; box size is a17random walk. This is *not* the classic image-plane `xywh` SORT filter — it carries a18real depth axis. Files: `kalman.rs` (the filter), `track.rs` (`Tracklet` + public19`Track`), `tracker.rs` (association + lifecycle), `association.rs` (cost matrices +20Hungarian).2122**Depth is optional + graceful.** `Detection::with_depth(z)` attaches a mask-sampled23metric range; the measurement is always `[px,py,pz,w,h]`, but with no depth the depth24row's variance is inflated so its Kalman gain → 0 and `pz`/`vz` coast on the motion25model — the filter behaves exactly like an image-plane tracker on the observed axes.26Supply real depth and the 3D estimate sharpens. One fixed-size code path, no reshaping.2728## Association (why ids stay stable)2930ByteTrack two stages: high-confidence detections match first (IoU + optional31appearance), then a recovery pass over low-confidence detections keeps established32tracks alive through partial/occluded frames. Assignment is a dependency-free33**Hungarian** (optimal, MOT-scale is trivial). Three fusions layer onto the IoU cost,34each **only lowers** cost (rescue, never block); the depth gate is the hard veto:3536- **Depth gate** (`association::gate_depth`, `tracker.rs` config `depth_gate_rel/abs`) —37 reject a pair when `|z_track − z_det| > max(abs, rel·z_track)`, both sides depth-known.38 Kills id-swaps between objects overlapping in the image but at different ranges.39 **Keep it loose** (`rel 0.35`, `abs 0.7 m`): monocular-depth noise on a static object40 is large, and a tight gate false-rejects a valid match → id churn. Genuine41 foreground/background separation is far larger and still vetoes a swap.42- **Centre-proximity rescue** (`association::fuse_center`, stage 2 only) — an occluded43 object's box shrinks to the visible fragment, so IoU drops below the gate even though44 the centre hasn't moved. A size-normalised centre cost re-acquires it. **Gated to45 near-static tracks** (image-plane speed < `STATIC_SPEED_PX`, in `tracker.rs`): a fast46 coasting track's centre has drifted, so centre-only matching would let it steal a47 neighbour. Applied in stage 2 only — stage-1 fusion let adjacent same-depth objects48 swap.49- **NSA measurement noise** (`track.rs`, `NSA_ALPHA`) — `R ← R·(1 + α·(1−score))`, α=2:50 a low-confidence box nudges the state gently, a crisp one updates firmly.5152## Lifecycle + real dt5354`Tentative → Confirmed → Lost → Removed`. A `Lost` track coasts on the Kalman for55`track_buffer` frames (default 60 ≈ 4 s at 15 fps) so intermittent detection doesn't56churn its id, then is reaped. `update(&dets)` steps at `dt=1`; `update_dt(&dets, dt)`57scales the Kalman F/Q by real inter-frame seconds (jitter-robust under variable fps).5859## Metric 3D + world frame (`vrt-types`)6061- `CameraIntrinsics::from_hfov(w, h, hfov_deg)` builds `fx,fy,cx,cy` from a spec'd FoV;62 `unproject(px,py,z)` → camera-frame `(X,Y,Z)` metres.63- `Undistorter` (`undistort.rs`) — one GPU `k1` barrel remap applied **before** seg/depth64 so boxes/masks/metric-3D are rectified-pinhole.65- `Track::metric_position(intr)` = camera-frame metres; `world_position(intr, extr)`66 applies a `CameraExtrinsics { r, t }` pose → shared world frame (`IDENTITY` =67 single-camera). This is the basis for the world-frame BEV (`vrt-viz::render_bev`) and68 multi-camera fusion.6970## Appearance / ReID (feature `appearance`, off by default)7172A **hook**, not a model: supply L2-normalised embeddings via `Detection::feature`;73cosine distance is min-fused into the IoU cost (BoT-SORT style) with a per-track EMA74bank. No embedder in the crate — `vrt-osnet` (or the detector's own backbone75features) provides the vectors. Update the bank only from confident detections.7677## Tuning knobs (`TrackerConfig`)7879`track_high_thresh`/`track_low_thresh` (the two association tiers), `match_thresh` /80`match_thresh_second` (IoU gates), `depth_gate_rel`/`depth_gate_abs`, `track_buffer`,81`min_hits`, `KalmanParams`.8283## Gotchas8485- **IoU alone fails on occlusion** — a partially-hidden object's box changes *shape*, so86 geometry can't always re-acquire it; the centre-fuse handles the static case, but the87 robust fix for shape-changed re-entry is appearance ReID.88- **Loose depth gate on purpose** — tightening it to "look precise" causes id churn on89 static objects from monocular-depth noise. Diagnose churn with the example's90 distinct-ids/100-frame counter.91- **Not full BoT-SORT** — camera-motion compensation (GMC) is deliberately omitted92 (unneeded for fixed cameras). Add it back only for a moving/handheld camera.9394## Related skills9596- `vrt-pipeline-compose` — where the GPU detections + mask-sampled depth come from.97- `jetson-benchmarking` — the tracker is CPU; measure it off the GPU wall.