@camera.ui/rust-postprocessor 0.0.1
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/.yarnrc.yml +1 -0
- package/CHANGELOG.md +8 -0
- package/CONTRIBUTING.md +1 -0
- package/Cargo.toml +29 -0
- package/README.md +5 -0
- package/build.rs +5 -0
- package/index.d.ts +254 -0
- package/index.js +585 -0
- package/package.json +86 -0
- package/rust-toolchain.toml +2 -0
- package/src/iou.rs +83 -0
- package/src/lib.rs +471 -0
- package/src/line_crossing.rs +214 -0
- package/src/merge.rs +312 -0
- package/src/nms.rs +296 -0
- package/src/tracker.rs +1064 -0
- package/src/types.rs +40 -0
- package/src/zone_filter.rs +567 -0
package/src/iou.rs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//! Intersection-over-Union helpers.
|
|
2
|
+
//!
|
|
3
|
+
//! Boxes are in normalized `[x, y, width, height]` format with coordinates in
|
|
4
|
+
//! `[0.0, 1.0]`. This matches the convention used by the Node SDK
|
|
5
|
+
//! (`BoundingBox`).
|
|
6
|
+
|
|
7
|
+
/// Compute IoU between two `[x, y, width, height]` boxes.
|
|
8
|
+
///
|
|
9
|
+
/// Returns 0 if either box has non-positive area or there is no overlap.
|
|
10
|
+
#[inline]
|
|
11
|
+
pub fn box_iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
|
|
12
|
+
let ax2 = a[0] + a[2];
|
|
13
|
+
let ay2 = a[1] + a[3];
|
|
14
|
+
let bx2 = b[0] + b[2];
|
|
15
|
+
let by2 = b[1] + b[3];
|
|
16
|
+
|
|
17
|
+
let ix1 = a[0].max(b[0]);
|
|
18
|
+
let iy1 = a[1].max(b[1]);
|
|
19
|
+
let ix2 = ax2.min(bx2);
|
|
20
|
+
let iy2 = ay2.min(by2);
|
|
21
|
+
|
|
22
|
+
if ix2 <= ix1 || iy2 <= iy1 {
|
|
23
|
+
return 0.0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let inter = (ix2 - ix1) * (iy2 - iy1);
|
|
27
|
+
let a_area = a[2] * a[3];
|
|
28
|
+
let b_area = b[2] * b[3];
|
|
29
|
+
let union = a_area + b_area - inter;
|
|
30
|
+
|
|
31
|
+
if union > 0.0 {
|
|
32
|
+
inter / union
|
|
33
|
+
} else {
|
|
34
|
+
0.0
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[cfg(test)]
|
|
39
|
+
mod tests {
|
|
40
|
+
use super::*;
|
|
41
|
+
|
|
42
|
+
#[test]
|
|
43
|
+
fn perfect_overlap() {
|
|
44
|
+
let a = [0.1, 0.1, 0.2, 0.2];
|
|
45
|
+
let b = [0.1, 0.1, 0.2, 0.2];
|
|
46
|
+
assert!((box_iou(&a, &b) - 1.0).abs() < 1e-6);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn no_overlap() {
|
|
51
|
+
let a = [0.0, 0.0, 0.1, 0.1];
|
|
52
|
+
let b = [0.5, 0.5, 0.1, 0.1];
|
|
53
|
+
assert_eq!(box_iou(&a, &b), 0.0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[test]
|
|
57
|
+
fn half_overlap_horizontal() {
|
|
58
|
+
// a = [0..0.2] x [0..0.2], b = [0.1..0.3] x [0..0.2]
|
|
59
|
+
// intersection = 0.1 * 0.2 = 0.02
|
|
60
|
+
// union = 0.04 + 0.04 - 0.02 = 0.06
|
|
61
|
+
// iou = 1/3
|
|
62
|
+
let a = [0.0, 0.0, 0.2, 0.2];
|
|
63
|
+
let b = [0.1, 0.0, 0.2, 0.2];
|
|
64
|
+
let v = box_iou(&a, &b);
|
|
65
|
+
assert!((v - 1.0 / 3.0).abs() < 1e-6, "got {v}");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[test]
|
|
69
|
+
fn contained() {
|
|
70
|
+
let a = [0.0, 0.0, 1.0, 1.0];
|
|
71
|
+
let b = [0.25, 0.25, 0.5, 0.5];
|
|
72
|
+
// inter = 0.25, union = 1.0
|
|
73
|
+
let v = box_iou(&a, &b);
|
|
74
|
+
assert!((v - 0.25).abs() < 1e-6, "got {v}");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[test]
|
|
78
|
+
fn zero_area() {
|
|
79
|
+
let a = [0.0, 0.0, 0.0, 0.0];
|
|
80
|
+
let b = [0.0, 0.0, 0.5, 0.5];
|
|
81
|
+
assert_eq!(box_iou(&a, &b), 0.0);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
}
|