@camera.ui/rust-postprocessor 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +9 -0
- package/index.js +1 -0
- package/package.json +11 -11
- package/.yarnrc.yml +0 -1
- package/Cargo.toml +0 -29
- package/build.rs +0 -5
- package/rust-toolchain.toml +0 -2
- package/src/iou.rs +0 -83
- package/src/lib.rs +0 -471
- package/src/line_crossing.rs +0 -214
- package/src/merge.rs +0 -312
- package/src/nms.rs +0 -296
- package/src/tracker.rs +0 -1064
- package/src/types.rs +0 -40
- package/src/zone_filter.rs +0 -567
package/src/tracker.rs
DELETED
|
@@ -1,1064 +0,0 @@
|
|
|
1
|
-
//! Object tracker — thin wrapper around `norfair-rs` providing per-class
|
|
2
|
-
//! IoU tracking with stable global track ids.
|
|
3
|
-
//!
|
|
4
|
-
//! Architecture: one underlying `norfair_rs::Tracker` instance per class
|
|
5
|
-
//! label (Frigate's pattern). Each frame's detections are bucketed by label
|
|
6
|
-
//! and dispatched to the matching sub-tracker. Track ids returned to the
|
|
7
|
-
//! consumer are remapped from norfair's per-instance counter into a single
|
|
8
|
-
//! globally-unique namespace owned by this struct, so different classes
|
|
9
|
-
//! never collide.
|
|
10
|
-
//!
|
|
11
|
-
//! Lifetime model: tracks live as long as norfair's `hit_counter_max`
|
|
12
|
-
//! window allows. The consumer can call `reset()` at any time to drop all
|
|
13
|
-
//! per-class trackers and start fresh — used to align tracker state with
|
|
14
|
-
//! external segment boundaries (e.g. cascade activate/deactivate in the
|
|
15
|
-
//! detection coordinator).
|
|
16
|
-
|
|
17
|
-
use std::collections::{HashMap, HashSet};
|
|
18
|
-
|
|
19
|
-
use nalgebra::DMatrix;
|
|
20
|
-
use norfair_rs::distances::distance_function_by_name;
|
|
21
|
-
use norfair_rs::{Detection as NfDetection, Tracker, TrackerConfig};
|
|
22
|
-
|
|
23
|
-
use crate::line_crossing::{
|
|
24
|
-
prepare_lines, segment_intersection, CrossingDirection, DetectionLineInput, LineCrossingEvent,
|
|
25
|
-
LineDirectionFilter, PreparedLine,
|
|
26
|
-
};
|
|
27
|
-
use crate::types::{Detection, TrackedDetection};
|
|
28
|
-
use crate::zone_filter::{filter_indices, prepare_zones, PreparedZones, ZoneInput};
|
|
29
|
-
|
|
30
|
-
/// Move items out of `src` at the given sorted indices. Avoids cloning.
|
|
31
|
-
fn extract_by_indices(mut src: Vec<Detection>, indices: &[u32]) -> Vec<Detection> {
|
|
32
|
-
if indices.len() == src.len() {
|
|
33
|
-
return src; // all kept — no work needed
|
|
34
|
-
}
|
|
35
|
-
let mut out = Vec::with_capacity(indices.len());
|
|
36
|
-
// Mark slots to keep, then drain in-order via swap with sentinel.
|
|
37
|
-
// Since Detection has a String (label), we use Option to take ownership.
|
|
38
|
-
let mut slots: Vec<Option<Detection>> = src.drain(..).map(Some).collect();
|
|
39
|
-
for &i in indices {
|
|
40
|
-
if let Some(det) = slots[i as usize].take() {
|
|
41
|
-
out.push(det);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
out
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/// Configuration for [`ObjectTracker`].
|
|
48
|
-
#[derive(Debug, Clone)]
|
|
49
|
-
pub struct ObjectTrackerConfig {
|
|
50
|
-
/// IoU threshold below which a candidate detection is considered a match
|
|
51
|
-
/// for an existing track. Higher = stricter. Typical values: 0.3 — 0.5.
|
|
52
|
-
pub iou_threshold: f32,
|
|
53
|
-
/// Frames a track survives without a fresh detection before being
|
|
54
|
-
/// dropped (Kalman extrapolation continues during this window). Higher
|
|
55
|
-
/// values bridge longer occlusions at the cost of stale tracks.
|
|
56
|
-
pub hit_counter_max: i32,
|
|
57
|
-
/// Frames a new track must be matched before it gets a permanent id
|
|
58
|
-
/// (filters one-frame false positives).
|
|
59
|
-
pub initialization_delay: i32,
|
|
60
|
-
/// Frames a dead track (hit_counter < 0) stays available for ReID
|
|
61
|
-
/// re-matching. When a new detection appears near a dead track, norfair
|
|
62
|
-
/// merges them — the old track ID is preserved and the Kalman filter is
|
|
63
|
-
/// replaced with the new track's state. Set to 0 or None to disable.
|
|
64
|
-
pub reid_hit_counter_max: Option<i32>,
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
impl Default for ObjectTrackerConfig {
|
|
68
|
-
fn default() -> Self {
|
|
69
|
-
Self {
|
|
70
|
-
iou_threshold: 0.3,
|
|
71
|
-
hit_counter_max: 15,
|
|
72
|
-
initialization_delay: 3,
|
|
73
|
-
reid_hit_counter_max: None,
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/// One norfair sub-tracker plus its local-id → global-id remapping.
|
|
79
|
-
struct ClassTracker {
|
|
80
|
-
tracker: Tracker,
|
|
81
|
-
/// Maps norfair's internal `global_id` (which is per-tracker-instance) to
|
|
82
|
-
/// the externally-visible track id we hand back to the consumer.
|
|
83
|
-
id_map: HashMap<i32, u32>,
|
|
84
|
-
/// Previous-frame centroid `(cx, cy)` per track, used for the line
|
|
85
|
-
/// crossing prev→curr segment. Updated at the end of every frame.
|
|
86
|
-
prev_centroid_map: HashMap<u32, (f32, f32)>,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/// Payload embedded in each norfair Detection's `data` field. After
|
|
90
|
-
/// `tracker.update()`, we check each TrackedObject's `last_detection.data`
|
|
91
|
-
/// — if its `frame_seq` matches the current frame, the track was matched.
|
|
92
|
-
/// This is 100% reliable regardless of hit_counter semantics or sustain mode.
|
|
93
|
-
#[derive(Debug, Clone)]
|
|
94
|
-
struct FrameTag {
|
|
95
|
-
frame_seq: u64,
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/// Result of one frame of [`ObjectTracker::update`].
|
|
99
|
-
#[derive(Debug, Default)]
|
|
100
|
-
pub struct UpdateResult {
|
|
101
|
-
pub tracked: Vec<TrackedDetection>,
|
|
102
|
-
pub crossings: Vec<LineCrossingEvent>,
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/// Multi-class object tracker.
|
|
106
|
-
pub struct ObjectTracker {
|
|
107
|
-
config: ObjectTrackerConfig,
|
|
108
|
-
trackers: HashMap<String, ClassTracker>,
|
|
109
|
-
next_track_id: u32,
|
|
110
|
-
prepared_lines: Vec<PreparedLine>,
|
|
111
|
-
/// `(track_id, line_name)` set tracking which crossings have already
|
|
112
|
-
/// fired, so a single track can't trigger the same line twice in its
|
|
113
|
-
/// lifetime.
|
|
114
|
-
crossing_memory: HashSet<(u32, String)>,
|
|
115
|
-
/// Configured detection zones (privacy masks + active include/exclude
|
|
116
|
-
/// regions). Empty when no zones are set, in which case `update()`
|
|
117
|
-
/// only applies the confidence threshold to incoming detections.
|
|
118
|
-
prepared_zones: PreparedZones,
|
|
119
|
-
/// Minimum detection confidence — detections below this threshold are
|
|
120
|
-
/// dropped before they reach the tracker.
|
|
121
|
-
min_confidence: f32,
|
|
122
|
-
/// Monotonically increasing frame counter. Embedded in each Detection's
|
|
123
|
-
/// `data` field so we can reliably determine "matched this frame" after
|
|
124
|
-
/// `tracker.update()` — independent of hit_counter semantics.
|
|
125
|
-
frame_seq: u64,
|
|
126
|
-
/// Reusable scratch buffer for per-frame label bucketing. Cleared and
|
|
127
|
-
/// re-populated in every `update()` call — avoids re-allocating the
|
|
128
|
-
/// HashMap on every frame.
|
|
129
|
-
by_label: HashMap<String, Vec<Detection>>,
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
impl ObjectTracker {
|
|
133
|
-
pub fn new(config: ObjectTrackerConfig) -> Self {
|
|
134
|
-
Self {
|
|
135
|
-
config,
|
|
136
|
-
trackers: HashMap::new(),
|
|
137
|
-
next_track_id: 1,
|
|
138
|
-
prepared_lines: Vec::new(),
|
|
139
|
-
crossing_memory: HashSet::new(),
|
|
140
|
-
prepared_zones: PreparedZones::default(),
|
|
141
|
-
min_confidence: 0.0,
|
|
142
|
-
frame_seq: 0,
|
|
143
|
-
by_label: HashMap::new(),
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/// Replace the configured detection zones (privacy masks + include/
|
|
148
|
-
/// exclude regions). Pass an empty list to disable zone filtering
|
|
149
|
-
/// entirely. Coordinates are in `[0, 100]` UI space — internally
|
|
150
|
-
/// normalized to `[0.0, 1.0]` and the polygon is auto-closed.
|
|
151
|
-
pub fn set_zones(&mut self, zones: Vec<ZoneInput>) {
|
|
152
|
-
self.prepared_zones = prepare_zones(&zones);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/// Set the global minimum confidence threshold. Detections below this
|
|
156
|
-
/// score are dropped before they enter the tracker.
|
|
157
|
-
pub fn set_min_confidence(&mut self, min_confidence: f32) {
|
|
158
|
-
self.min_confidence = min_confidence.max(0.0);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/// Apply the configured zones + confidence threshold to a list of
|
|
162
|
-
/// detections WITHOUT advancing the tracker state. Returns the indices
|
|
163
|
-
/// of the detections that pass the filter — the caller can then map
|
|
164
|
-
/// the indices back onto a parallel array of richer values (face/lpd
|
|
165
|
-
/// items wrapped as Detection with a substitute label).
|
|
166
|
-
///
|
|
167
|
-
/// Used for the external sensor write path where a plugin reports
|
|
168
|
-
/// detections directly and we need to apply zone filtering without
|
|
169
|
-
/// running the full tracker.
|
|
170
|
-
pub fn filter_indices(&self, detections: &[Detection]) -> Vec<u32> {
|
|
171
|
-
filter_indices(detections, &self.prepared_zones, self.min_confidence)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/// Replace the configured crossing lines. The aspect ratio is the
|
|
175
|
-
/// camera's `width / height`, used so the perpendicular crossing line
|
|
176
|
-
/// matches what the user drew in visual space.
|
|
177
|
-
///
|
|
178
|
-
/// Crossing memory is cleared on every reconfigure — re-firing the same
|
|
179
|
-
/// crossing event for an existing track immediately after a line edit
|
|
180
|
-
/// is the desired behavior so the user can validate their changes.
|
|
181
|
-
pub fn set_lines(&mut self, lines: Vec<DetectionLineInput>, aspect_ratio: f32) {
|
|
182
|
-
self.prepared_lines = prepare_lines(&lines, aspect_ratio);
|
|
183
|
-
self.crossing_memory.clear();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/// Set how many frames a dead track stays available for ReID re-matching.
|
|
187
|
-
/// Pass 0 or negative to disable ReID entirely. When enabled, tracks
|
|
188
|
-
/// that expire (hit_counter < 0) enter a ReID phase where they can be
|
|
189
|
-
/// re-matched to new detections via IoU — preserving the old track ID.
|
|
190
|
-
pub fn set_reid_hit_counter_max(&mut self, frames: i32) {
|
|
191
|
-
let value = if frames > 0 { Some(frames) } else { None };
|
|
192
|
-
self.config.reid_hit_counter_max = value;
|
|
193
|
-
// Update all existing sub-trackers
|
|
194
|
-
for class in self.trackers.values_mut() {
|
|
195
|
-
class.tracker.config.reid_hit_counter_max = value;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/// Refresh the ReID counter for all dead tracks back to max. Call this
|
|
200
|
-
/// every frame while an external cascade is active — dead tracks will
|
|
201
|
-
/// never expire as long as the cascade keeps refreshing them. When the
|
|
202
|
-
/// cascade ends, stop calling this and tracks expire naturally.
|
|
203
|
-
pub fn refresh_reid(&mut self) {
|
|
204
|
-
let max = match self.config.reid_hit_counter_max {
|
|
205
|
-
Some(m) => m,
|
|
206
|
-
None => return,
|
|
207
|
-
};
|
|
208
|
-
for class in self.trackers.values_mut() {
|
|
209
|
-
for obj in &mut class.tracker.tracked_objects {
|
|
210
|
-
if obj.hit_counter < 0 {
|
|
211
|
-
if let Some(ref mut rc) = obj.reid_hit_counter {
|
|
212
|
-
*rc = max;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/// Drop all tracks across all classes. The next call to `update` starts
|
|
220
|
-
/// from a clean slate; track ids restart from 1.
|
|
221
|
-
pub fn reset(&mut self) {
|
|
222
|
-
self.trackers.clear();
|
|
223
|
-
self.next_track_id = 1;
|
|
224
|
-
self.crossing_memory.clear();
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/// Total number of active tracks across all class trackers — useful for
|
|
228
|
-
/// diagnostics.
|
|
229
|
-
pub fn track_count(&self) -> usize {
|
|
230
|
-
self
|
|
231
|
-
.trackers
|
|
232
|
-
.values()
|
|
233
|
-
.map(|c| c.tracker.tracked_objects.len())
|
|
234
|
-
.sum()
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/// Process a frame's detections and return the active tracks plus any
|
|
238
|
-
/// line-crossing events that fired this frame.
|
|
239
|
-
///
|
|
240
|
-
/// Pipeline applied to the input list:
|
|
241
|
-
/// 1. Confidence threshold filter (`min_confidence`).
|
|
242
|
-
/// 2. Zone filter (privacy masks + include/exclude regions).
|
|
243
|
-
/// 3. Per-class IoU + Kalman tracker (`norfair-rs`).
|
|
244
|
-
/// 4. Line-crossing detection.
|
|
245
|
-
///
|
|
246
|
-
/// `timestamp_ms` is forwarded onto the emitted crossing events so the
|
|
247
|
-
/// consumer can sequence them with external state. The tracker itself
|
|
248
|
-
/// doesn't use it.
|
|
249
|
-
pub fn update(&mut self, detections: Vec<Detection>, timestamp_ms: f64) -> UpdateResult {
|
|
250
|
-
self.frame_seq += 1;
|
|
251
|
-
|
|
252
|
-
// Steps 1+2: drop detections that fail the confidence threshold or
|
|
253
|
-
// sit outside any configured zones. Use filter_indices + index-based
|
|
254
|
-
// extraction to move detections out of the original Vec without
|
|
255
|
-
// cloning any label Strings.
|
|
256
|
-
let detections = {
|
|
257
|
-
let indices = filter_indices(&detections, &self.prepared_zones, self.min_confidence);
|
|
258
|
-
extract_by_indices(detections, &indices)
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
let mut tracked: Vec<TrackedDetection> = Vec::new();
|
|
262
|
-
|
|
263
|
-
if detections.is_empty() {
|
|
264
|
-
// Even with no detections we still tick all sub-trackers so their
|
|
265
|
-
// Kalman filters extrapolate and aging works correctly.
|
|
266
|
-
tracked.extend(self.tick_empty(timestamp_ms));
|
|
267
|
-
} else {
|
|
268
|
-
// Bucket detections by class label. Reuse the struct-level HashMap
|
|
269
|
-
// (clear + re-populate) so we don't allocate a new one every frame.
|
|
270
|
-
// The inner Vec buffers are also reused across frames.
|
|
271
|
-
for vec in self.by_label.values_mut() {
|
|
272
|
-
vec.clear();
|
|
273
|
-
}
|
|
274
|
-
for det in detections {
|
|
275
|
-
self
|
|
276
|
-
.by_label
|
|
277
|
-
.entry(det.label.clone())
|
|
278
|
-
.or_default()
|
|
279
|
-
.push(det);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Collect labels that need ticking: existing trackers without
|
|
283
|
-
// detections this frame get an empty update, labels with detections
|
|
284
|
-
// get their bucket. We collect keys upfront to avoid borrowing
|
|
285
|
-
// `self.trackers` / `self.by_label` while calling `run_class`.
|
|
286
|
-
let empty_labels: Vec<String> = self
|
|
287
|
-
.trackers
|
|
288
|
-
.keys()
|
|
289
|
-
.filter(|k| self.by_label.get(k.as_str()).is_none_or(|v| v.is_empty()))
|
|
290
|
-
.cloned()
|
|
291
|
-
.collect();
|
|
292
|
-
for label in &empty_labels {
|
|
293
|
-
tracked.extend(self.run_class(label, Vec::new(), timestamp_ms));
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
let active_labels: Vec<String> = self
|
|
297
|
-
.by_label
|
|
298
|
-
.keys()
|
|
299
|
-
.filter(|k| !self.by_label[k.as_str()].is_empty())
|
|
300
|
-
.cloned()
|
|
301
|
-
.collect();
|
|
302
|
-
for label in active_labels {
|
|
303
|
-
let dets = std::mem::take(self.by_label.get_mut(&label).unwrap());
|
|
304
|
-
tracked.extend(self.run_class(&label, dets, timestamp_ms));
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Line crossing: only compute when lines are configured. Skip the
|
|
309
|
-
// centroid history bookkeeping entirely when there are no lines — it
|
|
310
|
-
// would just accumulate entries that are never read.
|
|
311
|
-
let crossings = if self.prepared_lines.is_empty() {
|
|
312
|
-
Vec::new()
|
|
313
|
-
} else {
|
|
314
|
-
let c = self.compute_crossings(&tracked, timestamp_ms);
|
|
315
|
-
self.refresh_centroid_history(&tracked);
|
|
316
|
-
self.gc_crossing_memory();
|
|
317
|
-
c
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
UpdateResult { tracked, crossings }
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/// Tick every sub-tracker with an empty detection list. Used when the
|
|
324
|
-
/// consumer reports zero detections for a frame.
|
|
325
|
-
fn tick_empty(&mut self, timestamp_ms: f64) -> Vec<TrackedDetection> {
|
|
326
|
-
let labels: Vec<String> = self.trackers.keys().cloned().collect();
|
|
327
|
-
let mut output = Vec::new();
|
|
328
|
-
for label in labels {
|
|
329
|
-
output.extend(self.run_class(&label, Vec::new(), timestamp_ms));
|
|
330
|
-
}
|
|
331
|
-
output
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/// Walk every tracked detection's prev → current centroid segment and
|
|
335
|
-
/// emit one event per (line × first crossing) pair. Memory is updated
|
|
336
|
-
/// in-place so each (track_id, line_name) only fires once.
|
|
337
|
-
fn compute_crossings(
|
|
338
|
-
&mut self,
|
|
339
|
-
tracked: &[TrackedDetection],
|
|
340
|
-
timestamp_ms: f64,
|
|
341
|
-
) -> Vec<LineCrossingEvent> {
|
|
342
|
-
if self.prepared_lines.is_empty() || tracked.is_empty() {
|
|
343
|
-
return Vec::new();
|
|
344
|
-
}
|
|
345
|
-
let mut events = Vec::new();
|
|
346
|
-
|
|
347
|
-
for det in tracked {
|
|
348
|
-
// Look up previous centroid via the per-class store. We have to
|
|
349
|
-
// know which class bucket holds this track id — since trackers are
|
|
350
|
-
// keyed by label and we have det.label, that's a direct lookup.
|
|
351
|
-
let class = match self.trackers.get(&det.label) {
|
|
352
|
-
Some(c) => c,
|
|
353
|
-
None => continue,
|
|
354
|
-
};
|
|
355
|
-
let prev = match class.prev_centroid_map.get(&det.track_id) {
|
|
356
|
-
Some(&p) => p,
|
|
357
|
-
None => continue, // first frame for this track — no segment yet
|
|
358
|
-
};
|
|
359
|
-
let curr_cx = det.x + det.width * 0.5;
|
|
360
|
-
let curr_cy = det.y + det.height * 0.5;
|
|
361
|
-
// Skip degenerate "no movement" updates — they can't cross anything
|
|
362
|
-
// and just waste cycles.
|
|
363
|
-
if (prev.0 - curr_cx).abs() < 1e-9 && (prev.1 - curr_cy).abs() < 1e-9 {
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
let det_label_lc = det.label.to_lowercase();
|
|
368
|
-
for line in &self.prepared_lines {
|
|
369
|
-
if !line.labels.is_empty() && !line.labels.contains(&det_label_lc) {
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
let memory_key = (det.track_id, line.name.clone());
|
|
373
|
-
if self.crossing_memory.contains(&memory_key) {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
let cross = segment_intersection(
|
|
378
|
-
prev.0,
|
|
379
|
-
prev.1,
|
|
380
|
-
curr_cx,
|
|
381
|
-
curr_cy,
|
|
382
|
-
line.line_a[0],
|
|
383
|
-
line.line_a[1],
|
|
384
|
-
line.line_b[0],
|
|
385
|
-
line.line_b[1],
|
|
386
|
-
);
|
|
387
|
-
if cross == 0.0 {
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
let direction = if cross > 0.0 {
|
|
392
|
-
CrossingDirection::AToB
|
|
393
|
-
} else {
|
|
394
|
-
CrossingDirection::BToA
|
|
395
|
-
};
|
|
396
|
-
let allowed = match line.direction {
|
|
397
|
-
LineDirectionFilter::Both => true,
|
|
398
|
-
LineDirectionFilter::AToB => direction == CrossingDirection::AToB,
|
|
399
|
-
LineDirectionFilter::BToA => direction == CrossingDirection::BToA,
|
|
400
|
-
};
|
|
401
|
-
if !allowed {
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
self.crossing_memory.insert(memory_key);
|
|
406
|
-
events.push(LineCrossingEvent {
|
|
407
|
-
line_name: line.name.clone(),
|
|
408
|
-
direction,
|
|
409
|
-
track_id: det.track_id,
|
|
410
|
-
label: det.label.clone(),
|
|
411
|
-
confidence: det.confidence,
|
|
412
|
-
timestamp_ms,
|
|
413
|
-
prev_pos: [prev.0, prev.1],
|
|
414
|
-
curr_pos: [curr_cx, curr_cy],
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
events
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/// After crossings have been computed, store the latest centroid for
|
|
423
|
-
/// each track so the next frame has a "previous" to compare against.
|
|
424
|
-
fn refresh_centroid_history(&mut self, tracked: &[TrackedDetection]) {
|
|
425
|
-
// Group ids by class first to do fewer hashmap lookups.
|
|
426
|
-
let mut by_class: HashMap<String, Vec<(u32, f32, f32)>> = HashMap::new();
|
|
427
|
-
for det in tracked {
|
|
428
|
-
let cx = det.x + det.width * 0.5;
|
|
429
|
-
let cy = det.y + det.height * 0.5;
|
|
430
|
-
by_class
|
|
431
|
-
.entry(det.label.clone())
|
|
432
|
-
.or_default()
|
|
433
|
-
.push((det.track_id, cx, cy));
|
|
434
|
-
}
|
|
435
|
-
for (label, entries) in by_class {
|
|
436
|
-
if let Some(class) = self.trackers.get_mut(&label) {
|
|
437
|
-
for (id, cx, cy) in entries {
|
|
438
|
-
class.prev_centroid_map.insert(id, (cx, cy));
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/// Drop crossing memory entries whose track id has expired. Without
|
|
445
|
-
/// this the set would grow unboundedly across a long-running tracker.
|
|
446
|
-
fn gc_crossing_memory(&mut self) {
|
|
447
|
-
if self.crossing_memory.is_empty() {
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
let mut alive: HashSet<u32> = HashSet::new();
|
|
451
|
-
for class in self.trackers.values() {
|
|
452
|
-
for id in class.id_map.values() {
|
|
453
|
-
alive.insert(*id);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
self
|
|
457
|
-
.crossing_memory
|
|
458
|
-
.retain(|(track_id, _)| alive.contains(track_id));
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/// Run one frame through the sub-tracker for `label`, creating it if it
|
|
462
|
-
/// doesn't exist yet.
|
|
463
|
-
fn run_class(
|
|
464
|
-
&mut self,
|
|
465
|
-
label: &str,
|
|
466
|
-
detections: Vec<Detection>,
|
|
467
|
-
_timestamp_ms: f64,
|
|
468
|
-
) -> Vec<TrackedDetection> {
|
|
469
|
-
let current_frame = self.frame_seq;
|
|
470
|
-
|
|
471
|
-
// Lazily construct sub-tracker on first sight of a class.
|
|
472
|
-
if !self.trackers.contains_key(label) {
|
|
473
|
-
let nf_config = self.build_nf_config();
|
|
474
|
-
let tracker = match Tracker::new(nf_config) {
|
|
475
|
-
Ok(t) => t,
|
|
476
|
-
Err(_) => return Vec::new(),
|
|
477
|
-
};
|
|
478
|
-
self.trackers.insert(
|
|
479
|
-
label.to_string(),
|
|
480
|
-
ClassTracker {
|
|
481
|
-
tracker,
|
|
482
|
-
id_map: HashMap::new(),
|
|
483
|
-
prev_centroid_map: HashMap::new(),
|
|
484
|
-
},
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Convert Detection → norfair Detection. All detections in a frame
|
|
489
|
-
// share a single Arc<FrameTag> — one allocation instead of N.
|
|
490
|
-
let tag: std::sync::Arc<dyn std::any::Any + Send + Sync> = std::sync::Arc::new(FrameTag {
|
|
491
|
-
frame_seq: current_frame,
|
|
492
|
-
});
|
|
493
|
-
let nf_detections: Vec<NfDetection> = detections
|
|
494
|
-
.iter()
|
|
495
|
-
.filter_map(|det| {
|
|
496
|
-
let x2 = det.x + det.width;
|
|
497
|
-
let y2 = det.y + det.height;
|
|
498
|
-
let points =
|
|
499
|
-
DMatrix::from_row_slice(1, 4, &[det.x as f64, det.y as f64, x2 as f64, y2 as f64]);
|
|
500
|
-
let mut nf = NfDetection::new(points).ok()?;
|
|
501
|
-
nf.label = Some(det.label.clone());
|
|
502
|
-
nf.scores = Some(vec![det.confidence as f64]);
|
|
503
|
-
nf.data = Some(tag.clone());
|
|
504
|
-
Some(nf)
|
|
505
|
-
})
|
|
506
|
-
.collect();
|
|
507
|
-
|
|
508
|
-
let class = self.trackers.get_mut(label).expect("inserted above");
|
|
509
|
-
let active = class.tracker.update(nf_detections, 1, None);
|
|
510
|
-
|
|
511
|
-
// Snapshot data from norfair tracked objects before releasing borrow.
|
|
512
|
-
struct Raw {
|
|
513
|
-
norfair_global_id: i32,
|
|
514
|
-
x1: f64,
|
|
515
|
-
y1: f64,
|
|
516
|
-
x2: f64,
|
|
517
|
-
y2: f64,
|
|
518
|
-
confidence: f32,
|
|
519
|
-
age: u32,
|
|
520
|
-
speed: f32,
|
|
521
|
-
/// True when last_detection carries the current frame's FrameTag.
|
|
522
|
-
matched_this_frame: bool,
|
|
523
|
-
}
|
|
524
|
-
let raw: Vec<Raw> = active
|
|
525
|
-
.into_iter()
|
|
526
|
-
.filter_map(|obj| {
|
|
527
|
-
let est = &obj.estimate;
|
|
528
|
-
if est.ncols() < 4 || est.nrows() < 1 {
|
|
529
|
-
return None;
|
|
530
|
-
}
|
|
531
|
-
let confidence = obj
|
|
532
|
-
.last_detection
|
|
533
|
-
.as_ref()
|
|
534
|
-
.and_then(|d| d.scores.as_ref())
|
|
535
|
-
.and_then(|s| s.first().copied())
|
|
536
|
-
.unwrap_or(0.0) as f32;
|
|
537
|
-
let vel = &obj.estimate_velocity;
|
|
538
|
-
let speed = if vel.ncols() >= 4 && vel.nrows() >= 1 {
|
|
539
|
-
let vcx = ((vel[(0, 0)] + vel[(0, 2)]) / 2.0) as f32;
|
|
540
|
-
let vcy = ((vel[(0, 1)] + vel[(0, 3)]) / 2.0) as f32;
|
|
541
|
-
(vcx * vcx + vcy * vcy).sqrt()
|
|
542
|
-
} else {
|
|
543
|
-
0.0
|
|
544
|
-
};
|
|
545
|
-
let matched_this_frame = obj
|
|
546
|
-
.last_detection
|
|
547
|
-
.as_ref()
|
|
548
|
-
.and_then(|d| d.data.as_ref())
|
|
549
|
-
.and_then(|d| d.downcast_ref::<FrameTag>())
|
|
550
|
-
.is_some_and(|tag| tag.frame_seq == current_frame);
|
|
551
|
-
|
|
552
|
-
Some(Raw {
|
|
553
|
-
norfair_global_id: obj.global_id,
|
|
554
|
-
x1: est[(0, 0)],
|
|
555
|
-
y1: est[(0, 1)],
|
|
556
|
-
x2: est[(0, 2)],
|
|
557
|
-
y2: est[(0, 3)],
|
|
558
|
-
confidence,
|
|
559
|
-
age: obj.age.max(0) as u32,
|
|
560
|
-
speed,
|
|
561
|
-
matched_this_frame,
|
|
562
|
-
})
|
|
563
|
-
})
|
|
564
|
-
.collect();
|
|
565
|
-
|
|
566
|
-
// Build output TrackedDetections. Allocate the label String once and
|
|
567
|
-
// clone it for each track — avoids repeated `label.to_string()` calls.
|
|
568
|
-
let label_owned = label.to_string();
|
|
569
|
-
let mut output: Vec<TrackedDetection> = Vec::with_capacity(raw.len());
|
|
570
|
-
for r in raw {
|
|
571
|
-
let track_id = match class.id_map.get(&r.norfair_global_id) {
|
|
572
|
-
Some(&id) => id,
|
|
573
|
-
None => {
|
|
574
|
-
let id = self.next_track_id;
|
|
575
|
-
self.next_track_id = self.next_track_id.wrapping_add(1).max(1);
|
|
576
|
-
class.id_map.insert(r.norfair_global_id, id);
|
|
577
|
-
id
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
let width = (r.x2 - r.x1).max(0.0) as f32;
|
|
581
|
-
let height = (r.y2 - r.y1).max(0.0) as f32;
|
|
582
|
-
|
|
583
|
-
output.push(TrackedDetection {
|
|
584
|
-
x: r.x1 as f32,
|
|
585
|
-
y: r.y1 as f32,
|
|
586
|
-
width,
|
|
587
|
-
height,
|
|
588
|
-
confidence: r.confidence,
|
|
589
|
-
label: label_owned.clone(),
|
|
590
|
-
track_id,
|
|
591
|
-
track_age: r.age,
|
|
592
|
-
track_lost: !r.matched_this_frame,
|
|
593
|
-
track_speed: r.speed,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Garbage-collect remap entries for tracks no longer in norfair's store
|
|
598
|
-
// (neither active NOR in ReID phase). We must keep id_map entries for
|
|
599
|
-
// ReID-phase tracks (hit_counter < 0 but still in tracked_objects) so
|
|
600
|
-
// that when norfair merges them back, the old track_id is preserved.
|
|
601
|
-
let norfair_alive: HashSet<i32> = class
|
|
602
|
-
.tracker
|
|
603
|
-
.tracked_objects
|
|
604
|
-
.iter()
|
|
605
|
-
.map(|o| o.global_id)
|
|
606
|
-
.collect();
|
|
607
|
-
class
|
|
608
|
-
.id_map
|
|
609
|
-
.retain(|nf_id, _| norfair_alive.contains(nf_id));
|
|
610
|
-
let mapped_ids: HashSet<u32> = class.id_map.values().copied().collect();
|
|
611
|
-
class
|
|
612
|
-
.prev_centroid_map
|
|
613
|
-
.retain(|k, _| mapped_ids.contains(k));
|
|
614
|
-
|
|
615
|
-
output
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
fn build_nf_config(&self) -> TrackerConfig {
|
|
619
|
-
// norfair's IoU distance returns 1 - IoU, so the threshold for matching
|
|
620
|
-
// is "1 - iou_threshold". A configured iou_threshold of 0.3 means a
|
|
621
|
-
// detection matches a track when IoU >= 0.3, i.e. distance < 0.7.
|
|
622
|
-
let distance_threshold = (1.0 - self.config.iou_threshold).max(0.0) as f64;
|
|
623
|
-
let mut cfg = TrackerConfig::new(distance_function_by_name("iou"), distance_threshold);
|
|
624
|
-
cfg.hit_counter_max = self.config.hit_counter_max;
|
|
625
|
-
cfg.initialization_delay = self.config.initialization_delay;
|
|
626
|
-
|
|
627
|
-
// ReID: when a track dies (hit_counter < 0), keep it for re-matching
|
|
628
|
-
// using the same IoU distance function. A new detection near the dead
|
|
629
|
-
// track's last estimate triggers merge() → old ID preserved, new
|
|
630
|
-
// Kalman state replaces old (no drift problem).
|
|
631
|
-
if let Some(reid_max) = self.config.reid_hit_counter_max {
|
|
632
|
-
cfg.reid_distance_function = Some(distance_function_by_name("iou"));
|
|
633
|
-
cfg.reid_distance_threshold = distance_threshold;
|
|
634
|
-
cfg.reid_hit_counter_max = Some(reid_max);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
cfg
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
#[cfg(test)]
|
|
642
|
-
mod tests {
|
|
643
|
-
use super::*;
|
|
644
|
-
|
|
645
|
-
fn det(x: f32, y: f32, w: f32, h: f32, label: &str) -> Detection {
|
|
646
|
-
Detection {
|
|
647
|
-
x,
|
|
648
|
-
y,
|
|
649
|
-
width: w,
|
|
650
|
-
height: h,
|
|
651
|
-
confidence: 0.9,
|
|
652
|
-
label: label.to_string(),
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
#[test]
|
|
657
|
-
fn assigns_ids_to_new_detections() {
|
|
658
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
659
|
-
hit_counter_max: 5,
|
|
660
|
-
initialization_delay: 0,
|
|
661
|
-
..Default::default()
|
|
662
|
-
});
|
|
663
|
-
let res = t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
664
|
-
assert!(!res.tracked.is_empty());
|
|
665
|
-
assert!(res.tracked[0].track_id >= 1);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
#[test]
|
|
669
|
-
fn maintains_id_across_frames() {
|
|
670
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
671
|
-
hit_counter_max: 5,
|
|
672
|
-
initialization_delay: 0,
|
|
673
|
-
..Default::default()
|
|
674
|
-
});
|
|
675
|
-
let res1 = t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
676
|
-
let res2 = t.update(vec![det(0.11, 0.11, 0.2, 0.2, "person")], 1.0);
|
|
677
|
-
if !res1.tracked.is_empty() && !res2.tracked.is_empty() {
|
|
678
|
-
assert_eq!(res1.tracked[0].track_id, res2.tracked[0].track_id);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
#[test]
|
|
683
|
-
fn different_classes_get_different_ids() {
|
|
684
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
685
|
-
hit_counter_max: 5,
|
|
686
|
-
initialization_delay: 0,
|
|
687
|
-
..Default::default()
|
|
688
|
-
});
|
|
689
|
-
let res = t.update(
|
|
690
|
-
vec![
|
|
691
|
-
det(0.1, 0.1, 0.2, 0.2, "person"),
|
|
692
|
-
det(0.5, 0.5, 0.2, 0.2, "car"),
|
|
693
|
-
],
|
|
694
|
-
0.0,
|
|
695
|
-
);
|
|
696
|
-
let ids: std::collections::HashSet<u32> = res.tracked.iter().map(|d| d.track_id).collect();
|
|
697
|
-
assert_eq!(ids.len(), 2);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
#[test]
|
|
701
|
-
fn matched_frame_not_lost() {
|
|
702
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
703
|
-
hit_counter_max: 5,
|
|
704
|
-
initialization_delay: 0,
|
|
705
|
-
..Default::default()
|
|
706
|
-
});
|
|
707
|
-
let r1 = t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
708
|
-
let r2 = t.update(vec![det(0.11, 0.11, 0.2, 0.2, "person")], 1.0);
|
|
709
|
-
let r3 = t.update(vec![det(0.12, 0.12, 0.2, 0.2, "person")], 2.0);
|
|
710
|
-
assert!(
|
|
711
|
-
!r1.tracked.is_empty() && !r1.tracked[0].track_lost,
|
|
712
|
-
"first frame must be matched"
|
|
713
|
-
);
|
|
714
|
-
assert!(
|
|
715
|
-
!r2.tracked.is_empty() && !r2.tracked[0].track_lost,
|
|
716
|
-
"second matched frame"
|
|
717
|
-
);
|
|
718
|
-
assert!(
|
|
719
|
-
!r3.tracked.is_empty() && !r3.tracked[0].track_lost,
|
|
720
|
-
"third matched frame"
|
|
721
|
-
);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
#[test]
|
|
725
|
-
fn unmatched_frame_marked_lost() {
|
|
726
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
727
|
-
hit_counter_max: 5,
|
|
728
|
-
initialization_delay: 0,
|
|
729
|
-
..Default::default()
|
|
730
|
-
});
|
|
731
|
-
let _ = t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
732
|
-
let _ = t.update(vec![det(0.11, 0.11, 0.2, 0.2, "person")], 1.0);
|
|
733
|
-
let res = t.update(Vec::new(), 2.0);
|
|
734
|
-
if !res.tracked.is_empty() {
|
|
735
|
-
assert!(res.tracked[0].track_lost, "unmatched frame must be lost");
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
#[test]
|
|
740
|
-
fn reset_clears_state() {
|
|
741
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
742
|
-
hit_counter_max: 5,
|
|
743
|
-
initialization_delay: 0,
|
|
744
|
-
..Default::default()
|
|
745
|
-
});
|
|
746
|
-
t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
747
|
-
assert!(t.track_count() >= 1);
|
|
748
|
-
t.reset();
|
|
749
|
-
assert_eq!(t.track_count(), 0);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
#[test]
|
|
753
|
-
fn line_crossing_a_to_b() {
|
|
754
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
755
|
-
hit_counter_max: 5,
|
|
756
|
-
initialization_delay: 0,
|
|
757
|
-
..Default::default()
|
|
758
|
-
});
|
|
759
|
-
// Vertical handle at x=50 in 0..100 UI space → perpendicular crossing
|
|
760
|
-
// line is horizontal across the middle.
|
|
761
|
-
t.set_lines(
|
|
762
|
-
vec![DetectionLineInput {
|
|
763
|
-
name: "gate".to_string(),
|
|
764
|
-
direction: LineDirectionFilter::Both,
|
|
765
|
-
labels: vec![],
|
|
766
|
-
points: [[30.0, 50.0], [70.0, 50.0]],
|
|
767
|
-
}],
|
|
768
|
-
16.0 / 9.0,
|
|
769
|
-
);
|
|
770
|
-
|
|
771
|
-
// Move a person across the perpendicular line. Track centroid moves
|
|
772
|
-
// from (0.4, 0.5) to (0.6, 0.5) — crossing the horizontal line at y=0.5.
|
|
773
|
-
// Centroid (0.45, 0.5) → (0.55, 0.5). Boxes overlap enough for IoU
|
|
774
|
-
// matching (~0.33) and the track segment crosses the vertical line at
|
|
775
|
-
// x=0.5 between y=0.3 and y=0.7.
|
|
776
|
-
let r1 = t.update(vec![det(0.35, 0.40, 0.20, 0.20, "person")], 0.0);
|
|
777
|
-
assert_eq!(r1.crossings.len(), 0, "first frame: no prev → no crossing");
|
|
778
|
-
let r2 = t.update(vec![det(0.45, 0.40, 0.20, 0.20, "person")], 1.0);
|
|
779
|
-
assert_eq!(r2.crossings.len(), 1, "movement should fire one crossing");
|
|
780
|
-
let crossing = &r2.crossings[0];
|
|
781
|
-
assert_eq!(crossing.line_name, "gate");
|
|
782
|
-
assert_eq!(crossing.label, "person");
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
#[test]
|
|
786
|
-
fn line_crossing_label_filter() {
|
|
787
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
788
|
-
hit_counter_max: 5,
|
|
789
|
-
initialization_delay: 0,
|
|
790
|
-
..Default::default()
|
|
791
|
-
});
|
|
792
|
-
t.set_lines(
|
|
793
|
-
vec![DetectionLineInput {
|
|
794
|
-
name: "vehicle-only".to_string(),
|
|
795
|
-
direction: LineDirectionFilter::Both,
|
|
796
|
-
labels: vec!["car".to_string()],
|
|
797
|
-
points: [[30.0, 50.0], [70.0, 50.0]],
|
|
798
|
-
}],
|
|
799
|
-
16.0 / 9.0,
|
|
800
|
-
);
|
|
801
|
-
let _ = t.update(vec![det(0.35, 0.40, 0.20, 0.20, "person")], 0.0);
|
|
802
|
-
let r2 = t.update(vec![det(0.45, 0.40, 0.20, 0.20, "person")], 1.0);
|
|
803
|
-
assert_eq!(
|
|
804
|
-
r2.crossings.len(),
|
|
805
|
-
0,
|
|
806
|
-
"person should not match vehicle-only line"
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
#[test]
|
|
811
|
-
fn track_speed_static_then_moving() {
|
|
812
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
813
|
-
hit_counter_max: 5,
|
|
814
|
-
initialization_delay: 0,
|
|
815
|
-
..Default::default()
|
|
816
|
-
});
|
|
817
|
-
// Static frames — Kalman velocity should be ~0.
|
|
818
|
-
let r1 = t.update(vec![det(0.10, 0.10, 0.20, 0.20, "person")], 0.0);
|
|
819
|
-
assert_eq!(r1.tracked.len(), 1);
|
|
820
|
-
assert!(
|
|
821
|
-
r1.tracked[0].track_speed < 0.01,
|
|
822
|
-
"first frame speed should be ~0"
|
|
823
|
-
);
|
|
824
|
-
|
|
825
|
-
let r2 = t.update(vec![det(0.10, 0.10, 0.20, 0.20, "person")], 100.0);
|
|
826
|
-
assert!(
|
|
827
|
-
r2.tracked[0].track_speed < 0.01,
|
|
828
|
-
"static frame speed should be ~0"
|
|
829
|
-
);
|
|
830
|
-
|
|
831
|
-
// Move consistently for several frames so Kalman velocity builds up.
|
|
832
|
-
// Each step moves +0.05 in x.
|
|
833
|
-
let mut last_speed = 0.0f32;
|
|
834
|
-
for i in 2..8 {
|
|
835
|
-
let x = 0.10 + (i - 1) as f32 * 0.05;
|
|
836
|
-
let r = t.update(vec![det(x, 0.10, 0.20, 0.20, "person")], (i * 100) as f64);
|
|
837
|
-
last_speed = r.tracked[0].track_speed;
|
|
838
|
-
}
|
|
839
|
-
assert!(
|
|
840
|
-
last_speed > 0.001,
|
|
841
|
-
"expected Kalman velocity > 0 after sustained movement, got {}",
|
|
842
|
-
last_speed
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
#[test]
|
|
847
|
-
fn tracked_detection_carries_input_confidence() {
|
|
848
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
849
|
-
hit_counter_max: 5,
|
|
850
|
-
initialization_delay: 0,
|
|
851
|
-
..Default::default()
|
|
852
|
-
});
|
|
853
|
-
let mut d = det(0.10, 0.10, 0.20, 0.20, "person");
|
|
854
|
-
d.confidence = 0.87;
|
|
855
|
-
let r = t.update(vec![d], 0.0);
|
|
856
|
-
assert!(!r.tracked.is_empty());
|
|
857
|
-
// Confidence flowing through norfair's scores -> TrackedDetection.confidence
|
|
858
|
-
assert!(
|
|
859
|
-
(r.tracked[0].confidence - 0.87).abs() < 1e-3,
|
|
860
|
-
"expected ~0.87, got {}",
|
|
861
|
-
r.tracked[0].confidence
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
#[test]
|
|
866
|
-
fn confidence_threshold_drops_low_score() {
|
|
867
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
868
|
-
hit_counter_max: 5,
|
|
869
|
-
initialization_delay: 0,
|
|
870
|
-
..Default::default()
|
|
871
|
-
});
|
|
872
|
-
t.set_min_confidence(0.5);
|
|
873
|
-
let mut low = det(0.1, 0.1, 0.2, 0.2, "person");
|
|
874
|
-
low.confidence = 0.4;
|
|
875
|
-
let mut high = det(0.5, 0.5, 0.2, 0.2, "person");
|
|
876
|
-
high.confidence = 0.8;
|
|
877
|
-
let res = t.update(vec![low, high], 0.0);
|
|
878
|
-
assert_eq!(res.tracked.len(), 1);
|
|
879
|
-
assert!((res.tracked[0].x - 0.5).abs() < 1e-6);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
#[test]
|
|
883
|
-
fn zone_exclude_drops_detection_in_zone() {
|
|
884
|
-
use crate::zone_filter::{ZoneFilterMode, ZoneInput, ZoneMatchType};
|
|
885
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
886
|
-
hit_counter_max: 5,
|
|
887
|
-
initialization_delay: 0,
|
|
888
|
-
..Default::default()
|
|
889
|
-
});
|
|
890
|
-
t.set_zones(vec![ZoneInput {
|
|
891
|
-
labels: vec![],
|
|
892
|
-
filter: ZoneFilterMode::Exclude,
|
|
893
|
-
match_type: ZoneMatchType::Intersect,
|
|
894
|
-
is_privacy_mask: false,
|
|
895
|
-
// Top-left quadrant
|
|
896
|
-
points: vec![[0.0, 0.0], [50.0, 0.0], [50.0, 50.0], [0.0, 50.0]],
|
|
897
|
-
}]);
|
|
898
|
-
let inside = det(0.10, 0.10, 0.20, 0.20, "person"); // dropped
|
|
899
|
-
let outside = det(0.60, 0.60, 0.20, 0.20, "person"); // kept
|
|
900
|
-
let res = t.update(vec![inside, outside], 0.0);
|
|
901
|
-
assert_eq!(res.tracked.len(), 1);
|
|
902
|
-
assert!(res.tracked[0].x > 0.5);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
#[test]
|
|
906
|
-
fn line_crossing_only_fires_once_per_track() {
|
|
907
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
908
|
-
hit_counter_max: 5,
|
|
909
|
-
initialization_delay: 0,
|
|
910
|
-
..Default::default()
|
|
911
|
-
});
|
|
912
|
-
t.set_lines(
|
|
913
|
-
vec![DetectionLineInput {
|
|
914
|
-
name: "gate".to_string(),
|
|
915
|
-
direction: LineDirectionFilter::Both,
|
|
916
|
-
labels: vec![],
|
|
917
|
-
points: [[30.0, 50.0], [70.0, 50.0]],
|
|
918
|
-
}],
|
|
919
|
-
16.0 / 9.0,
|
|
920
|
-
);
|
|
921
|
-
let _ = t.update(vec![det(0.35, 0.40, 0.20, 0.20, "person")], 0.0);
|
|
922
|
-
let r2 = t.update(vec![det(0.45, 0.40, 0.20, 0.20, "person")], 1.0);
|
|
923
|
-
// After crossing, keep moving — same track id, but the line memory
|
|
924
|
-
// must prevent a second event for this (track, line) pair.
|
|
925
|
-
let r3 = t.update(vec![det(0.55, 0.40, 0.20, 0.20, "person")], 2.0);
|
|
926
|
-
assert_eq!(r2.crossings.len(), 1);
|
|
927
|
-
assert_eq!(r3.crossings.len(), 0, "memory should suppress repeat");
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
#[test]
|
|
931
|
-
fn track_expires_without_reid() {
|
|
932
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
933
|
-
hit_counter_max: 3,
|
|
934
|
-
initialization_delay: 0,
|
|
935
|
-
..Default::default() // reid_hit_counter_max: None
|
|
936
|
-
});
|
|
937
|
-
let _ = t.update(vec![det(0.1, 0.1, 0.2, 0.2, "person")], 0.0);
|
|
938
|
-
let _ = t.update(vec![det(0.11, 0.11, 0.2, 0.2, "person")], 100.0);
|
|
939
|
-
|
|
940
|
-
let mut alive_count = 0;
|
|
941
|
-
for i in 2..20 {
|
|
942
|
-
let r = t.update(Vec::new(), (i * 100) as f64);
|
|
943
|
-
if !r.tracked.is_empty() {
|
|
944
|
-
alive_count += 1;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
assert!(
|
|
948
|
-
alive_count < 18,
|
|
949
|
-
"without ReID, track should expire (alive for {} frames)",
|
|
950
|
-
alive_count
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
#[test]
|
|
955
|
-
fn reid_preserves_id_after_gap() {
|
|
956
|
-
// ReID: track dies after hit_counter_max, enters ReID phase.
|
|
957
|
-
// When person reappears, norfair merges → old ID preserved.
|
|
958
|
-
// initialization_delay=1 is required so the new track enters the
|
|
959
|
-
// "initializing" phase — norfair's ReID Stage 5 only matches
|
|
960
|
-
// dead tracks against newly-matched INITIALIZING objects.
|
|
961
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
962
|
-
hit_counter_max: 3,
|
|
963
|
-
initialization_delay: 1,
|
|
964
|
-
reid_hit_counter_max: Some(20),
|
|
965
|
-
..Default::default()
|
|
966
|
-
});
|
|
967
|
-
|
|
968
|
-
// Establish track (need init_delay+1 frames to fully initialize)
|
|
969
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 0.0);
|
|
970
|
-
let r2 = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 100.0);
|
|
971
|
-
assert_eq!(r2.tracked.len(), 1);
|
|
972
|
-
let id = r2.tracked[0].track_id;
|
|
973
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 200.0);
|
|
974
|
-
|
|
975
|
-
// Disappear for 10 frames — exceeds hit_counter_max=3, track "dies"
|
|
976
|
-
// but stays in ReID phase (reid_hit_counter_max=20)
|
|
977
|
-
for i in 3..13 {
|
|
978
|
-
t.update(Vec::new(), (i * 100) as f64);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// Reappear at same position — ReID should match and restore old ID.
|
|
982
|
-
// First frame: new initializing track created.
|
|
983
|
-
// Second frame: norfair promotes it → ReID matches with dead track → merge.
|
|
984
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 1300.0);
|
|
985
|
-
let r_back = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 1400.0);
|
|
986
|
-
|
|
987
|
-
let found = r_back.tracked.iter().any(|t| t.track_id == id);
|
|
988
|
-
assert!(
|
|
989
|
-
found,
|
|
990
|
-
"ReID should restore old track id {} (got: {:?})",
|
|
991
|
-
id,
|
|
992
|
-
r_back
|
|
993
|
-
.tracked
|
|
994
|
-
.iter()
|
|
995
|
-
.map(|t| t.track_id)
|
|
996
|
-
.collect::<Vec<_>>()
|
|
997
|
-
);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
#[test]
|
|
1001
|
-
fn reid_expires_after_reid_hit_counter_max() {
|
|
1002
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
1003
|
-
hit_counter_max: 3,
|
|
1004
|
-
initialization_delay: 1,
|
|
1005
|
-
reid_hit_counter_max: Some(5),
|
|
1006
|
-
..Default::default()
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
// Establish track
|
|
1010
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 0.0);
|
|
1011
|
-
let r2 = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 100.0);
|
|
1012
|
-
let id = r2.tracked[0].track_id;
|
|
1013
|
-
|
|
1014
|
-
// Let track die (hit_counter_max=3) AND exhaust ReID window (5 frames)
|
|
1015
|
-
for i in 2..20 {
|
|
1016
|
-
t.update(Vec::new(), (i * 100) as f64);
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// Reappear — ReID window expired, should get a NEW id
|
|
1020
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 2000.0);
|
|
1021
|
-
let r_back = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 2100.0);
|
|
1022
|
-
|
|
1023
|
-
let has_old = r_back.tracked.iter().any(|t| t.track_id == id);
|
|
1024
|
-
assert!(
|
|
1025
|
-
!has_old,
|
|
1026
|
-
"ReID should have expired — old id {} should not appear",
|
|
1027
|
-
id
|
|
1028
|
-
);
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
#[test]
|
|
1032
|
-
fn set_reid_hit_counter_max_dynamically() {
|
|
1033
|
-
let mut t = ObjectTracker::new(ObjectTrackerConfig {
|
|
1034
|
-
hit_counter_max: 3,
|
|
1035
|
-
initialization_delay: 1,
|
|
1036
|
-
reid_hit_counter_max: None, // disabled initially
|
|
1037
|
-
..Default::default()
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
// Enable ReID dynamically (e.g. cascade activated)
|
|
1041
|
-
t.set_reid_hit_counter_max(50);
|
|
1042
|
-
|
|
1043
|
-
// Establish track
|
|
1044
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 0.0);
|
|
1045
|
-
let r2 = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 100.0);
|
|
1046
|
-
let id = r2.tracked[0].track_id;
|
|
1047
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 200.0);
|
|
1048
|
-
|
|
1049
|
-
// Let track die
|
|
1050
|
-
for i in 3..13 {
|
|
1051
|
-
t.update(Vec::new(), (i * 100) as f64);
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// Reappear — should get old ID via ReID
|
|
1055
|
-
let _ = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 1300.0);
|
|
1056
|
-
let r_back = t.update(vec![det(0.30, 0.30, 0.20, 0.20, "person")], 1400.0);
|
|
1057
|
-
|
|
1058
|
-
let found = r_back.tracked.iter().any(|t| t.track_id == id);
|
|
1059
|
-
assert!(found, "Dynamic ReID should restore old track id {}", id);
|
|
1060
|
-
|
|
1061
|
-
// Disable ReID (cascade deactivated)
|
|
1062
|
-
t.set_reid_hit_counter_max(0);
|
|
1063
|
-
}
|
|
1064
|
-
}
|