@camera.ui/rust-postprocessor 0.0.1 → 0.0.3
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 +50 -5
- package/index.js +53 -52
- package/package.json +17 -17
- 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/lib.rs
DELETED
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
//! camera.ui rust-postprocessor — high-performance NMS, IoU and object
|
|
2
|
-
//! tracking exposed to Node.js via napi-rs.
|
|
3
|
-
//!
|
|
4
|
-
//! - [`nms`] — SIMD greedy NMS over a list of detections (per-class).
|
|
5
|
-
//! - [`box_iou`] — single-pair IoU helper for normalized boxes.
|
|
6
|
-
//! - [`ObjectTracker`] — multi-class IoU+Kalman tracker built on
|
|
7
|
-
//! `norfair-rs` (Rust port of the Python norfair library used by Frigate).
|
|
8
|
-
//!
|
|
9
|
-
//! All coordinates are normalized to `[0.0, 1.0]` so the tracker is
|
|
10
|
-
//! resolution-independent.
|
|
11
|
-
|
|
12
|
-
mod iou;
|
|
13
|
-
mod line_crossing;
|
|
14
|
-
mod merge;
|
|
15
|
-
mod nms;
|
|
16
|
-
mod tracker;
|
|
17
|
-
mod types;
|
|
18
|
-
mod zone_filter;
|
|
19
|
-
|
|
20
|
-
use napi_derive::napi;
|
|
21
|
-
|
|
22
|
-
use crate::line_crossing::{
|
|
23
|
-
CrossingDirection as InnerCrossingDirection, DetectionLineInput as InnerDetectionLineInput,
|
|
24
|
-
LineDirectionFilter as InnerLineDirectionFilter,
|
|
25
|
-
};
|
|
26
|
-
use crate::tracker::{ObjectTracker as InnerObjectTracker, ObjectTrackerConfig};
|
|
27
|
-
use crate::zone_filter::{
|
|
28
|
-
ZoneFilterMode as InnerZoneFilterMode, ZoneInput as InnerZoneInput,
|
|
29
|
-
ZoneMatchType as InnerZoneMatchType,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/// Detection in normalized image coordinates `[0.0, 1.0]`.
|
|
33
|
-
#[napi(object)]
|
|
34
|
-
pub struct Detection {
|
|
35
|
-
pub x: f64,
|
|
36
|
-
pub y: f64,
|
|
37
|
-
pub width: f64,
|
|
38
|
-
pub height: f64,
|
|
39
|
-
pub confidence: f64,
|
|
40
|
-
pub label: String,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/// Detection with stable identity assigned by [`ObjectTracker`].
|
|
44
|
-
#[napi(object)]
|
|
45
|
-
pub struct TrackedDetection {
|
|
46
|
-
pub x: f64,
|
|
47
|
-
pub y: f64,
|
|
48
|
-
pub width: f64,
|
|
49
|
-
pub height: f64,
|
|
50
|
-
pub confidence: f64,
|
|
51
|
-
pub label: String,
|
|
52
|
-
/// Stable id across frames. Resets only on `ObjectTracker::reset()`.
|
|
53
|
-
pub track_id: u32,
|
|
54
|
-
/// Number of frames this track has existed (1 on first emit).
|
|
55
|
-
pub track_age: u32,
|
|
56
|
-
/// True when the box is being kept alive by Kalman extrapolation
|
|
57
|
-
/// instead of a fresh detection match this frame.
|
|
58
|
-
pub track_lost: bool,
|
|
59
|
-
/// Average centroid speed in normalized units/second over a sliding
|
|
60
|
-
/// ~1 s window of past positions. Consumers can compare against a
|
|
61
|
-
/// threshold (typical: 0.05) to distinguish moving from stationary
|
|
62
|
-
/// tracks. 0 when the track has only one sample so far.
|
|
63
|
-
pub track_speed: f64,
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/// Bounding box used by [`box_iou`].
|
|
67
|
-
#[napi(object)]
|
|
68
|
-
pub struct BoundingBox {
|
|
69
|
-
pub x: f64,
|
|
70
|
-
pub y: f64,
|
|
71
|
-
pub width: f64,
|
|
72
|
-
pub height: f64,
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/// How an active zone affects matching detections.
|
|
76
|
-
#[napi(string_enum = "kebab-case")]
|
|
77
|
-
pub enum ZoneFilterMode {
|
|
78
|
-
Include,
|
|
79
|
-
Exclude,
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/// Where in the polygon the box must sit for the zone to apply.
|
|
83
|
-
#[napi(string_enum = "kebab-case")]
|
|
84
|
-
pub enum ZoneMatchType {
|
|
85
|
-
/// Any overlap with the polygon counts.
|
|
86
|
-
Intersect,
|
|
87
|
-
/// All four corners of the box must be inside the polygon.
|
|
88
|
-
Contain,
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/// User-facing detection zone definition (matches the SDK shape).
|
|
92
|
-
///
|
|
93
|
-
/// `points` are polygon vertices in `[0, 100]` UI coordinates. The
|
|
94
|
-
/// polygon is auto-closed so the caller does not need to repeat the
|
|
95
|
-
/// first vertex at the end.
|
|
96
|
-
#[napi(object)]
|
|
97
|
-
pub struct DetectionZone {
|
|
98
|
-
pub labels: Vec<String>,
|
|
99
|
-
pub filter: ZoneFilterMode,
|
|
100
|
-
/// Match mode (intersect vs contain). Mapped to `type` on the JS side
|
|
101
|
-
/// of the SDK — the consumer is responsible for translating that field.
|
|
102
|
-
pub match_type: ZoneMatchType,
|
|
103
|
-
pub is_privacy_mask: bool,
|
|
104
|
-
pub points: Vec<Vec<f64>>,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/// Crossing line direction filter.
|
|
108
|
-
///
|
|
109
|
-
/// `"both"` accepts either direction. `"a-to-b"` and `"b-to-a"` only fire
|
|
110
|
-
/// when the track moves from one specific side to the other.
|
|
111
|
-
#[napi(string_enum = "kebab-case")]
|
|
112
|
-
pub enum LineDirection {
|
|
113
|
-
Both,
|
|
114
|
-
AToB,
|
|
115
|
-
BToA,
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/// User-facing crossing line definition.
|
|
119
|
-
///
|
|
120
|
-
/// `points` are the two handle endpoints in `[0, 100]` UI coordinates.
|
|
121
|
-
/// `labels` is the set of allowed detection labels — empty means "any label".
|
|
122
|
-
#[napi(object)]
|
|
123
|
-
pub struct DetectionLine {
|
|
124
|
-
pub name: String,
|
|
125
|
-
pub direction: LineDirection,
|
|
126
|
-
pub labels: Vec<String>,
|
|
127
|
-
pub points: Vec<Vec<f64>>,
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/// Crossing event emitted by [`ObjectTracker::update`].
|
|
131
|
-
#[napi(object)]
|
|
132
|
-
pub struct LineCrossingEvent {
|
|
133
|
-
pub line_name: String,
|
|
134
|
-
pub direction: LineDirection,
|
|
135
|
-
pub track_id: u32,
|
|
136
|
-
pub label: String,
|
|
137
|
-
pub confidence: f64,
|
|
138
|
-
/// Frame timestamp in milliseconds (forwarded from the `update()` call).
|
|
139
|
-
pub timestamp_ms: f64,
|
|
140
|
-
pub prev_x: f64,
|
|
141
|
-
pub prev_y: f64,
|
|
142
|
-
pub curr_x: f64,
|
|
143
|
-
pub curr_y: f64,
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/// Result of [`ObjectTracker::update`] — active tracks plus any crossings
|
|
147
|
-
/// fired this frame.
|
|
148
|
-
#[napi(object)]
|
|
149
|
-
pub struct UpdateResult {
|
|
150
|
-
pub tracked: Vec<TrackedDetection>,
|
|
151
|
-
pub crossings: Vec<LineCrossingEvent>,
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/// Constructor options for [`ObjectTracker`].
|
|
155
|
-
#[napi(object)]
|
|
156
|
-
pub struct ObjectTrackerOptions {
|
|
157
|
-
/// IoU threshold above which a candidate is matched to an existing track.
|
|
158
|
-
/// Higher = stricter matching. Default 0.3.
|
|
159
|
-
pub iou_threshold: Option<f64>,
|
|
160
|
-
/// Frames a track survives without a fresh detection (Kalman extrapolation
|
|
161
|
-
/// continues during this window). Default 15.
|
|
162
|
-
pub hit_counter_max: Option<i32>,
|
|
163
|
-
/// Frames a new track must be matched before getting a permanent id —
|
|
164
|
-
/// filters one-frame false positives. Default 3.
|
|
165
|
-
pub initialization_delay: Option<i32>,
|
|
166
|
-
/// Frames a dead track stays available for ReID re-matching. When a new
|
|
167
|
-
/// detection appears near a dead track, norfair merges them — the old
|
|
168
|
-
/// track ID is preserved. Default: disabled.
|
|
169
|
-
pub reid_hit_counter_max: Option<i32>,
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
fn to_internal(d: Detection) -> crate::types::Detection {
|
|
173
|
-
crate::types::Detection {
|
|
174
|
-
x: d.x as f32,
|
|
175
|
-
y: d.y as f32,
|
|
176
|
-
width: d.width as f32,
|
|
177
|
-
height: d.height as f32,
|
|
178
|
-
confidence: d.confidence as f32,
|
|
179
|
-
label: d.label,
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
fn from_internal(d: crate::types::Detection) -> Detection {
|
|
184
|
-
Detection {
|
|
185
|
-
x: d.x as f64,
|
|
186
|
-
y: d.y as f64,
|
|
187
|
-
width: d.width as f64,
|
|
188
|
-
height: d.height as f64,
|
|
189
|
-
confidence: d.confidence as f64,
|
|
190
|
-
label: d.label,
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
fn zone_filter_to_internal(m: ZoneFilterMode) -> InnerZoneFilterMode {
|
|
195
|
-
match m {
|
|
196
|
-
ZoneFilterMode::Include => InnerZoneFilterMode::Include,
|
|
197
|
-
ZoneFilterMode::Exclude => InnerZoneFilterMode::Exclude,
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
fn zone_match_to_internal(m: ZoneMatchType) -> InnerZoneMatchType {
|
|
202
|
-
match m {
|
|
203
|
-
ZoneMatchType::Intersect => InnerZoneMatchType::Intersect,
|
|
204
|
-
ZoneMatchType::Contain => InnerZoneMatchType::Contain,
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
fn detection_zone_to_internal(zone: DetectionZone) -> Option<InnerZoneInput> {
|
|
209
|
-
let mut points: Vec<[f64; 2]> = Vec::with_capacity(zone.points.len());
|
|
210
|
-
for p in zone.points {
|
|
211
|
-
if p.len() != 2 {
|
|
212
|
-
return None;
|
|
213
|
-
}
|
|
214
|
-
points.push([p[0], p[1]]);
|
|
215
|
-
}
|
|
216
|
-
Some(InnerZoneInput {
|
|
217
|
-
labels: zone.labels,
|
|
218
|
-
filter: zone_filter_to_internal(zone.filter),
|
|
219
|
-
match_type: zone_match_to_internal(zone.match_type),
|
|
220
|
-
is_privacy_mask: zone.is_privacy_mask,
|
|
221
|
-
points,
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
fn line_direction_to_internal(d: LineDirection) -> InnerLineDirectionFilter {
|
|
226
|
-
match d {
|
|
227
|
-
LineDirection::Both => InnerLineDirectionFilter::Both,
|
|
228
|
-
LineDirection::AToB => InnerLineDirectionFilter::AToB,
|
|
229
|
-
LineDirection::BToA => InnerLineDirectionFilter::BToA,
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
fn line_direction_from_internal(d: InnerCrossingDirection) -> LineDirection {
|
|
234
|
-
match d {
|
|
235
|
-
InnerCrossingDirection::AToB => LineDirection::AToB,
|
|
236
|
-
InnerCrossingDirection::BToA => LineDirection::BToA,
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
fn detection_line_to_internal(line: DetectionLine) -> Option<InnerDetectionLineInput> {
|
|
241
|
-
// Validate `points` is exactly two `[x, y]` pairs.
|
|
242
|
-
if line.points.len() != 2 {
|
|
243
|
-
return None;
|
|
244
|
-
}
|
|
245
|
-
let p1 = &line.points[0];
|
|
246
|
-
let p2 = &line.points[1];
|
|
247
|
-
if p1.len() != 2 || p2.len() != 2 {
|
|
248
|
-
return None;
|
|
249
|
-
}
|
|
250
|
-
Some(InnerDetectionLineInput {
|
|
251
|
-
name: line.name,
|
|
252
|
-
direction: line_direction_to_internal(line.direction),
|
|
253
|
-
labels: line.labels,
|
|
254
|
-
points: [[p1[0], p1[1]], [p2[0], p2[1]]],
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
fn from_internal_tracked(d: crate::types::TrackedDetection) -> TrackedDetection {
|
|
259
|
-
TrackedDetection {
|
|
260
|
-
x: d.x as f64,
|
|
261
|
-
y: d.y as f64,
|
|
262
|
-
width: d.width as f64,
|
|
263
|
-
height: d.height as f64,
|
|
264
|
-
confidence: d.confidence as f64,
|
|
265
|
-
label: d.label,
|
|
266
|
-
track_id: d.track_id,
|
|
267
|
-
track_age: d.track_age,
|
|
268
|
-
track_lost: d.track_lost,
|
|
269
|
-
track_speed: d.track_speed as f64,
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/// Run greedy non-maximum suppression on a list of detections.
|
|
274
|
-
///
|
|
275
|
-
/// Detections are suppressed only against higher-confidence boxes of the
|
|
276
|
-
/// same `label`. Output is sorted by confidence descending. Pass
|
|
277
|
-
/// `maxDetections` to cap the result length.
|
|
278
|
-
#[napi]
|
|
279
|
-
pub fn nms(
|
|
280
|
-
detections: Vec<Detection>,
|
|
281
|
-
iou_threshold: f64,
|
|
282
|
-
max_detections: Option<u32>,
|
|
283
|
-
) -> Vec<Detection> {
|
|
284
|
-
let internal: Vec<crate::types::Detection> = detections.into_iter().map(to_internal).collect();
|
|
285
|
-
let max = max_detections.map(|n| n as usize);
|
|
286
|
-
let kept = crate::nms::nms(internal, iou_threshold as f32, max);
|
|
287
|
-
kept.into_iter().map(from_internal).collect()
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/// Cluster nearby/overlapping same-label detections into single union
|
|
291
|
-
/// boxes via union-find. Two boxes join the same cluster if their
|
|
292
|
-
/// top-left corners are within `closeThreshold` along both axes OR if
|
|
293
|
-
/// their IoU exceeds `iouThreshold`. Each cluster collapses to one
|
|
294
|
-
/// `Detection` whose box covers the axis-aligned union of all members
|
|
295
|
-
/// (clamped to `[0, 1]`) and whose confidence is the maximum in the
|
|
296
|
-
/// cluster. Different labels are never clustered together.
|
|
297
|
-
#[napi]
|
|
298
|
-
pub fn merge(
|
|
299
|
-
detections: Vec<Detection>,
|
|
300
|
-
iou_threshold: f64,
|
|
301
|
-
close_threshold: f64,
|
|
302
|
-
) -> Vec<Detection> {
|
|
303
|
-
let internal: Vec<crate::types::Detection> = detections.into_iter().map(to_internal).collect();
|
|
304
|
-
let merged =
|
|
305
|
-
crate::merge::merge_detections(internal, iou_threshold as f32, close_threshold as f32);
|
|
306
|
-
merged.into_iter().map(from_internal).collect()
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/// Compute IoU between two normalized `[x, y, width, height]` boxes.
|
|
310
|
-
#[napi(js_name = "boxIou")]
|
|
311
|
-
pub fn box_iou(a: BoundingBox, b: BoundingBox) -> f64 {
|
|
312
|
-
let aa = [a.x as f32, a.y as f32, a.width as f32, a.height as f32];
|
|
313
|
-
let bb = [b.x as f32, b.y as f32, b.width as f32, b.height as f32];
|
|
314
|
-
crate::iou::box_iou(&aa, &bb) as f64
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/// Multi-class IoU + Kalman object tracker.
|
|
318
|
-
///
|
|
319
|
-
/// Wraps `norfair-rs` with one sub-tracker per class label. Track ids are
|
|
320
|
-
/// stable across frames and globally unique across classes. The tracker is
|
|
321
|
-
/// resolution-independent — feed normalized `[0.0, 1.0]` coordinates.
|
|
322
|
-
#[napi]
|
|
323
|
-
pub struct ObjectTracker {
|
|
324
|
-
inner: InnerObjectTracker,
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
#[napi]
|
|
328
|
-
impl ObjectTracker {
|
|
329
|
-
#[napi(constructor)]
|
|
330
|
-
pub fn new(options: Option<ObjectTrackerOptions>) -> Self {
|
|
331
|
-
let mut config = ObjectTrackerConfig::default();
|
|
332
|
-
if let Some(opts) = options {
|
|
333
|
-
if let Some(t) = opts.iou_threshold {
|
|
334
|
-
config.iou_threshold = t as f32;
|
|
335
|
-
}
|
|
336
|
-
if let Some(h) = opts.hit_counter_max {
|
|
337
|
-
config.hit_counter_max = h;
|
|
338
|
-
}
|
|
339
|
-
if let Some(i) = opts.initialization_delay {
|
|
340
|
-
config.initialization_delay = i;
|
|
341
|
-
}
|
|
342
|
-
if let Some(r) = opts.reid_hit_counter_max {
|
|
343
|
-
config.reid_hit_counter_max = if r > 0 { Some(r) } else { None };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
Self {
|
|
347
|
-
inner: InnerObjectTracker::new(config),
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/// Process one frame's detections and return active tracks plus any
|
|
352
|
-
/// line-crossing events that fired this frame. Detections without
|
|
353
|
-
/// overlap to existing tracks spawn new ones; existing tracks without a
|
|
354
|
-
/// matching detection are kept alive via Kalman extrapolation until
|
|
355
|
-
/// `hitCounterMax` frames have elapsed.
|
|
356
|
-
///
|
|
357
|
-
/// `timestampMs` is forwarded onto emitted crossing events for sequencing
|
|
358
|
-
/// — pass `Date.now()` (or any monotonic millisecond clock).
|
|
359
|
-
#[napi]
|
|
360
|
-
pub fn update(&mut self, detections: Vec<Detection>, timestamp_ms: f64) -> UpdateResult {
|
|
361
|
-
let internal: Vec<crate::types::Detection> = detections.into_iter().map(to_internal).collect();
|
|
362
|
-
let result = self.inner.update(internal, timestamp_ms);
|
|
363
|
-
UpdateResult {
|
|
364
|
-
tracked: result
|
|
365
|
-
.tracked
|
|
366
|
-
.into_iter()
|
|
367
|
-
.map(from_internal_tracked)
|
|
368
|
-
.collect(),
|
|
369
|
-
crossings: result
|
|
370
|
-
.crossings
|
|
371
|
-
.into_iter()
|
|
372
|
-
.map(|c| LineCrossingEvent {
|
|
373
|
-
line_name: c.line_name,
|
|
374
|
-
direction: line_direction_from_internal(c.direction),
|
|
375
|
-
track_id: c.track_id,
|
|
376
|
-
label: c.label,
|
|
377
|
-
confidence: c.confidence as f64,
|
|
378
|
-
timestamp_ms: c.timestamp_ms,
|
|
379
|
-
prev_x: c.prev_pos[0] as f64,
|
|
380
|
-
prev_y: c.prev_pos[1] as f64,
|
|
381
|
-
curr_x: c.curr_pos[0] as f64,
|
|
382
|
-
curr_y: c.curr_pos[1] as f64,
|
|
383
|
-
})
|
|
384
|
-
.collect(),
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/// Replace the configured crossing lines. Pass an empty array to disable
|
|
389
|
-
/// line crossings entirely. The `aspectRatio` is the camera's
|
|
390
|
-
/// `width / height` so the perpendicular crossing line ends up visually
|
|
391
|
-
/// perpendicular to the handle the user drew.
|
|
392
|
-
///
|
|
393
|
-
/// Crossing memory is cleared on every reconfigure so existing tracks
|
|
394
|
-
/// will fire again the moment they cross a freshly-edited line.
|
|
395
|
-
#[napi]
|
|
396
|
-
pub fn set_lines(&mut self, lines: Vec<DetectionLine>, aspect_ratio: f64) {
|
|
397
|
-
let internal: Vec<InnerDetectionLineInput> = lines
|
|
398
|
-
.into_iter()
|
|
399
|
-
.filter_map(detection_line_to_internal)
|
|
400
|
-
.collect();
|
|
401
|
-
self.inner.set_lines(internal, aspect_ratio as f32);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/// Replace the configured detection zones (privacy masks + active
|
|
405
|
-
/// include/exclude regions). Pass an empty array to disable zone
|
|
406
|
-
/// filtering entirely. Coordinates are in `[0, 100]` UI space — the
|
|
407
|
-
/// tracker normalizes them internally and auto-closes polygons.
|
|
408
|
-
///
|
|
409
|
-
/// The filter runs at the start of every `update()` call before
|
|
410
|
-
/// detections reach the underlying tracker.
|
|
411
|
-
#[napi]
|
|
412
|
-
pub fn set_zones(&mut self, zones: Vec<DetectionZone>) {
|
|
413
|
-
let internal: Vec<InnerZoneInput> = zones
|
|
414
|
-
.into_iter()
|
|
415
|
-
.filter_map(detection_zone_to_internal)
|
|
416
|
-
.collect();
|
|
417
|
-
self.inner.set_zones(internal);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/// Set the minimum detection confidence threshold. Detections below
|
|
421
|
-
/// this score are dropped at the start of every `update()` call before
|
|
422
|
-
/// they reach the zone filter or tracker. Default 0.0 (no threshold).
|
|
423
|
-
#[napi]
|
|
424
|
-
pub fn set_min_confidence(&mut self, min_confidence: f64) {
|
|
425
|
-
self.inner.set_min_confidence(min_confidence as f32);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/// Set how many frames a dead track stays available for ReID re-matching.
|
|
429
|
-
/// When a new detection appears near a dead track's last position,
|
|
430
|
-
/// norfair merges them — the old track ID is preserved with fresh
|
|
431
|
-
/// Kalman state. Pass 0 to disable ReID. Use to tie track re-ID
|
|
432
|
-
/// window to an external cascade timeout (e.g. `cascadeTimeout * fps`).
|
|
433
|
-
#[napi]
|
|
434
|
-
pub fn set_reid_hit_counter_max(&mut self, frames: i32) {
|
|
435
|
-
self.inner.set_reid_hit_counter_max(frames);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/// Refresh the ReID counter for all dead tracks back to max. Call every
|
|
439
|
-
/// frame while a cascade is active so dead tracks never expire during
|
|
440
|
-
/// the cascade window. Stop calling when the cascade ends.
|
|
441
|
-
#[napi]
|
|
442
|
-
pub fn refresh_reid(&mut self) {
|
|
443
|
-
self.inner.refresh_reid();
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/// Apply the configured zones + confidence threshold to a list of
|
|
447
|
-
/// detections WITHOUT advancing the tracker state. Returns the indices
|
|
448
|
-
/// (positions in the input array) of the detections that pass the
|
|
449
|
-
/// filter. Used for the external-sensor-write path where a plugin
|
|
450
|
-
/// reports detections directly and we want to apply zone filtering
|
|
451
|
-
/// without running the full tracker.
|
|
452
|
-
#[napi]
|
|
453
|
-
pub fn filter_indices(&self, detections: Vec<Detection>) -> Vec<u32> {
|
|
454
|
-
let internal: Vec<crate::types::Detection> = detections.into_iter().map(to_internal).collect();
|
|
455
|
-
self.inner.filter_indices(&internal)
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/// Drop every active track. The next `update()` starts from a clean
|
|
459
|
-
/// slate and track ids restart from 1. Use this to align tracker state
|
|
460
|
-
/// with external segment boundaries (e.g. cascade activate/deactivate).
|
|
461
|
-
#[napi]
|
|
462
|
-
pub fn reset(&mut self) {
|
|
463
|
-
self.inner.reset();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/// Total number of active tracks across all class buckets.
|
|
467
|
-
#[napi(getter)]
|
|
468
|
-
pub fn track_count(&self) -> u32 {
|
|
469
|
-
self.inner.track_count() as u32
|
|
470
|
-
}
|
|
471
|
-
}
|
package/src/line_crossing.rs
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
//! Line-crossing detection.
|
|
2
|
-
//!
|
|
3
|
-
//! Crossing lines are perpendicular to a "handle" segment drawn by the user
|
|
4
|
-
//! in the UI. We compute the perpendicular in visual (aspect-ratio-corrected)
|
|
5
|
-
//! space so the line orientation matches what the user sees, then store both
|
|
6
|
-
//! endpoints normalized in `[0.0, 1.0]`. On every frame the tracker walks
|
|
7
|
-
//! each active track's previous→current centroid segment and checks for
|
|
8
|
-
//! intersection against every prepared line; the sign of the cross product
|
|
9
|
-
//! between the two segments tells us whether the track went A→B or B→A.
|
|
10
|
-
|
|
11
|
-
use std::collections::HashSet;
|
|
12
|
-
|
|
13
|
-
/// Direction filter applied to a configured crossing line.
|
|
14
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
15
|
-
pub enum LineDirectionFilter {
|
|
16
|
-
/// Either A→B or B→A fires an event.
|
|
17
|
-
Both,
|
|
18
|
-
AToB,
|
|
19
|
-
BToA,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/// Direction of an emitted crossing event.
|
|
23
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
24
|
-
pub enum CrossingDirection {
|
|
25
|
-
AToB,
|
|
26
|
-
BToA,
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/// User-facing line definition (matches the UI shape).
|
|
30
|
-
///
|
|
31
|
-
/// `points` is the two handle endpoints in `[0, 100]` UI coordinates.
|
|
32
|
-
/// `aspect_ratio` (set on `prepare_lines`) is the camera's `width / height`
|
|
33
|
-
/// — used so the perpendicular line ends up visually perpendicular instead
|
|
34
|
-
/// of stretched along the longer axis of the normalized space.
|
|
35
|
-
#[derive(Debug, Clone)]
|
|
36
|
-
pub struct DetectionLineInput {
|
|
37
|
-
pub name: String,
|
|
38
|
-
pub direction: LineDirectionFilter,
|
|
39
|
-
/// Allowed labels (case-insensitive). Empty means "all labels".
|
|
40
|
-
pub labels: Vec<String>,
|
|
41
|
-
pub points: [[f64; 2]; 2],
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/// Pre-computed crossing line ready for fast per-frame intersection tests.
|
|
45
|
-
#[derive(Debug, Clone)]
|
|
46
|
-
pub struct PreparedLine {
|
|
47
|
-
pub name: String,
|
|
48
|
-
pub direction: LineDirectionFilter,
|
|
49
|
-
/// Lowercased labels — empty set means "all labels allowed".
|
|
50
|
-
pub labels: HashSet<String>,
|
|
51
|
-
/// Both endpoints of the perpendicular crossing line in normalized
|
|
52
|
-
/// `[0.0, 1.0]` coordinates.
|
|
53
|
-
pub line_a: [f32; 2],
|
|
54
|
-
pub line_b: [f32; 2],
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/// Crossing event emitted by the tracker.
|
|
58
|
-
#[derive(Debug, Clone)]
|
|
59
|
-
pub struct LineCrossingEvent {
|
|
60
|
-
pub line_name: String,
|
|
61
|
-
pub direction: CrossingDirection,
|
|
62
|
-
pub track_id: u32,
|
|
63
|
-
pub label: String,
|
|
64
|
-
pub confidence: f32,
|
|
65
|
-
pub timestamp_ms: f64,
|
|
66
|
-
pub prev_pos: [f32; 2],
|
|
67
|
-
pub curr_pos: [f32; 2],
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/// Convert raw user-supplied lines (UI 0-100 coordinates) into prepared
|
|
71
|
-
/// lines with the perpendicular crossing segment computed in normalized
|
|
72
|
-
/// space. `aspect_ratio` should be `width / height` of the camera frame.
|
|
73
|
-
pub fn prepare_lines(lines: &[DetectionLineInput], aspect_ratio: f32) -> Vec<PreparedLine> {
|
|
74
|
-
lines
|
|
75
|
-
.iter()
|
|
76
|
-
.map(|line| {
|
|
77
|
-
// Normalize handle points from 0..100 to 0..1
|
|
78
|
-
let h1x = (line.points[0][0] / 100.0) as f32;
|
|
79
|
-
let h1y = (line.points[0][1] / 100.0) as f32;
|
|
80
|
-
let h2x = (line.points[1][0] / 100.0) as f32;
|
|
81
|
-
let h2y = (line.points[1][1] / 100.0) as f32;
|
|
82
|
-
|
|
83
|
-
let mid_x = (h1x + h2x) * 0.5;
|
|
84
|
-
let mid_y = (h1y + h2y) * 0.5;
|
|
85
|
-
|
|
86
|
-
// Compute the perpendicular in *visual* space (where x is stretched
|
|
87
|
-
// by aspect_ratio so a square frame is actually square). After
|
|
88
|
-
// computing the perpendicular we scale x back to normalized space
|
|
89
|
-
// so the result is a unit-vector-direction perpendicular that
|
|
90
|
-
// matches what the user drew.
|
|
91
|
-
let dx_vis = (h2x - h1x) * aspect_ratio;
|
|
92
|
-
let dy_vis = h2y - h1y;
|
|
93
|
-
let perp_x_vis = -dy_vis;
|
|
94
|
-
let perp_y_vis = dx_vis;
|
|
95
|
-
|
|
96
|
-
let perp_x_norm = perp_x_vis / aspect_ratio;
|
|
97
|
-
let perp_y_norm = perp_y_vis;
|
|
98
|
-
let perp_len = (perp_x_norm * perp_x_norm + perp_y_norm * perp_y_norm)
|
|
99
|
-
.sqrt()
|
|
100
|
-
.max(1e-12);
|
|
101
|
-
let handle_len = ((h2x - h1x).powi(2) + (h2y - h1y).powi(2))
|
|
102
|
-
.sqrt()
|
|
103
|
-
.max(1e-12);
|
|
104
|
-
let scale = handle_len / perp_len;
|
|
105
|
-
let perp_x = perp_x_norm * scale;
|
|
106
|
-
let perp_y = perp_y_norm * scale;
|
|
107
|
-
|
|
108
|
-
let line_a = [mid_x - perp_x * 0.5, mid_y - perp_y * 0.5];
|
|
109
|
-
let line_b = [mid_x + perp_x * 0.5, mid_y + perp_y * 0.5];
|
|
110
|
-
|
|
111
|
-
let labels: HashSet<String> = line.labels.iter().map(|l| l.to_lowercase()).collect();
|
|
112
|
-
|
|
113
|
-
PreparedLine {
|
|
114
|
-
name: line.name.clone(),
|
|
115
|
-
direction: line.direction,
|
|
116
|
-
labels,
|
|
117
|
-
line_a,
|
|
118
|
-
line_b,
|
|
119
|
-
}
|
|
120
|
-
})
|
|
121
|
-
.collect()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/// Segment-segment intersection check between `(a, b)` (track motion) and
|
|
125
|
-
/// `(c, d)` (crossing line). Returns the *signed cross product* between the
|
|
126
|
-
/// two direction vectors at the intersection — positive means the track is
|
|
127
|
-
/// going from line side A to side B, negative means B to A. Returns 0 if
|
|
128
|
-
/// the segments do not intersect or are parallel.
|
|
129
|
-
#[inline]
|
|
130
|
-
pub fn segment_intersection(
|
|
131
|
-
ax: f32,
|
|
132
|
-
ay: f32,
|
|
133
|
-
bx: f32,
|
|
134
|
-
by: f32,
|
|
135
|
-
cx: f32,
|
|
136
|
-
cy: f32,
|
|
137
|
-
dx: f32,
|
|
138
|
-
dy: f32,
|
|
139
|
-
) -> f32 {
|
|
140
|
-
let denom = (bx - ax) * (dy - cy) - (by - ay) * (dx - cx);
|
|
141
|
-
if denom.abs() < 1e-12 {
|
|
142
|
-
return 0.0;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let t = ((cx - ax) * (dy - cy) - (cy - ay) * (dx - cx)) / denom;
|
|
146
|
-
let u = ((cx - ax) * (by - ay) - (cy - ay) * (bx - ax)) / denom;
|
|
147
|
-
|
|
148
|
-
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) {
|
|
149
|
-
let track_dx = bx - ax;
|
|
150
|
-
let track_dy = by - ay;
|
|
151
|
-
let line_dx = dx - cx;
|
|
152
|
-
let line_dy = dy - cy;
|
|
153
|
-
track_dx * line_dy - track_dy * line_dx
|
|
154
|
-
} else {
|
|
155
|
-
0.0
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
#[cfg(test)]
|
|
160
|
-
mod tests {
|
|
161
|
-
use super::*;
|
|
162
|
-
|
|
163
|
-
fn line(name: &str, p1: [f64; 2], p2: [f64; 2]) -> DetectionLineInput {
|
|
164
|
-
DetectionLineInput {
|
|
165
|
-
name: name.to_string(),
|
|
166
|
-
direction: LineDirectionFilter::Both,
|
|
167
|
-
labels: Vec::new(),
|
|
168
|
-
points: [p1, p2],
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
#[test]
|
|
173
|
-
fn prepare_horizontal_handle_yields_vertical_line() {
|
|
174
|
-
// Horizontal handle from (10, 50) to (90, 50) → perpendicular is vertical
|
|
175
|
-
let prepared = prepare_lines(&[line("h", [10.0, 50.0], [90.0, 50.0])], 16.0 / 9.0);
|
|
176
|
-
assert_eq!(prepared.len(), 1);
|
|
177
|
-
let p = &prepared[0];
|
|
178
|
-
// The perpendicular should be vertical so x of A and B match
|
|
179
|
-
assert!((p.line_a[0] - p.line_b[0]).abs() < 1e-4);
|
|
180
|
-
// The midpoint x should be 0.5
|
|
181
|
-
assert!((p.line_a[0] - 0.5).abs() < 1e-4);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
#[test]
|
|
185
|
-
fn segment_intersection_basic() {
|
|
186
|
-
// Track segment (0,0)→(1,1), line segment (0,1)→(1,0). They cross at (0.5,0.5).
|
|
187
|
-
let cross = segment_intersection(0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0);
|
|
188
|
-
assert!(cross != 0.0);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
#[test]
|
|
192
|
-
fn segment_intersection_no_overlap() {
|
|
193
|
-
// Parallel
|
|
194
|
-
let cross = segment_intersection(0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
|
195
|
-
assert_eq!(cross, 0.0);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
#[test]
|
|
199
|
-
fn segment_intersection_disjoint() {
|
|
200
|
-
// Skew but disjoint
|
|
201
|
-
let cross = segment_intersection(0.0, 0.0, 0.1, 0.1, 0.5, 0.5, 0.6, 0.6);
|
|
202
|
-
assert_eq!(cross, 0.0);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
#[test]
|
|
206
|
-
fn cross_sign_indicates_direction() {
|
|
207
|
-
// Track moves left→right across a vertical line at x=0.5
|
|
208
|
-
let cross_lr = segment_intersection(0.4, 0.5, 0.6, 0.5, 0.5, 0.0, 0.5, 1.0);
|
|
209
|
-
assert!(cross_lr > 0.0);
|
|
210
|
-
// Reverse: right→left should produce opposite sign
|
|
211
|
-
let cross_rl = segment_intersection(0.6, 0.5, 0.4, 0.5, 0.5, 0.0, 0.5, 1.0);
|
|
212
|
-
assert!(cross_rl < 0.0);
|
|
213
|
-
}
|
|
214
|
-
}
|