@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/src/types.rs ADDED
@@ -0,0 +1,40 @@
1
+ //! Shared internal types used by NMS and the tracker wrapper.
2
+ //!
3
+ //! These mirror the JS SDK shapes (`BoundingBox`, `Detection`,
4
+ //! `TrackedDetection`) but live entirely on the Rust side. The napi
5
+ //! bindings in `lib.rs` convert to/from these on the boundary.
6
+
7
+ /// Detection in normalized image coordinates `[0.0, 1.0]`.
8
+ #[derive(Debug, Clone)]
9
+ pub struct Detection {
10
+ pub x: f32,
11
+ pub y: f32,
12
+ pub width: f32,
13
+ pub height: f32,
14
+ pub confidence: f32,
15
+ pub label: String,
16
+ }
17
+
18
+ /// Tracked detection with stable identity across frames.
19
+ #[derive(Debug, Clone)]
20
+ pub struct TrackedDetection {
21
+ pub x: f32,
22
+ pub y: f32,
23
+ pub width: f32,
24
+ pub height: f32,
25
+ pub confidence: f32,
26
+ pub label: String,
27
+ /// Globally-unique track id assigned by the tracker. Stable across
28
+ /// matched frames; resets only on `ObjectTracker::reset()`.
29
+ pub track_id: u32,
30
+ /// Number of frames this track has existed (1 on first appearance).
31
+ pub track_age: u32,
32
+ /// True when this output frame did not actually match a fresh detection
33
+ /// — the tracker is keeping the box alive via Kalman extrapolation.
34
+ pub track_lost: bool,
35
+ /// Average centroid speed in normalized units/second over a short
36
+ /// sliding window (~1s of past positions). 0 means the track is
37
+ /// effectively stationary. Used by consumers to prefer moving tracks
38
+ /// over static ones (e.g. thumbnail selection).
39
+ pub track_speed: f32,
40
+ }
@@ -0,0 +1,567 @@
1
+ //! Detection zone filter — drops detections based on:
2
+ //! 1. Confidence threshold (`min_confidence`).
3
+ //! 2. Whether the detection's label is allowed by any configured zone
4
+ //! (when at least one zone declares a non-empty `labels` list).
5
+ //! 3. Privacy masks: detections fully contained inside a privacy mask
6
+ //! are removed.
7
+ //! 4. Active include/exclude zones with intersect/contain semantics.
8
+ //!
9
+ //! Zones are stored normalized to `[0.0, 1.0]` (input coordinates from the
10
+ //! UI come in `[0, 100]` and are scaled by [`prepare_zones`]). Polygons
11
+ //! are auto-closed if the caller didn't repeat the first vertex at the
12
+ //! end.
13
+ //!
14
+ //! Polygon math: the in-polygon test uses the standard ray-casting
15
+ //! algorithm with an extra "point exactly on edge" branch so detections
16
+ //! whose corner sits precisely on a zone boundary still count as inside.
17
+
18
+ use std::collections::HashSet;
19
+
20
+ use crate::types::Detection;
21
+
22
+ /// Where in the polygon the box must sit for the zone to apply.
23
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24
+ pub enum ZoneMatchType {
25
+ /// Any overlap with the zone counts.
26
+ Intersect,
27
+ /// All four corners of the box must be inside the zone.
28
+ Contain,
29
+ }
30
+
31
+ /// How a zone affects matching detections.
32
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
+ pub enum ZoneFilterMode {
34
+ /// Detection passes only if it sits in at least one Include zone.
35
+ Include,
36
+ /// Detection is dropped if it sits in any Exclude zone.
37
+ Exclude,
38
+ }
39
+
40
+ /// User-supplied zone definition (matches the SDK shape).
41
+ #[derive(Debug, Clone)]
42
+ pub struct ZoneInput {
43
+ /// Optional label filter — empty means "all labels".
44
+ pub labels: Vec<String>,
45
+ pub filter: ZoneFilterMode,
46
+ pub match_type: ZoneMatchType,
47
+ pub is_privacy_mask: bool,
48
+ /// Polygon vertices in `[0, 100]` UI coordinates. Doesn't need to be
49
+ /// closed — `prepare_zones` will close it automatically.
50
+ pub points: Vec<[f64; 2]>,
51
+ }
52
+
53
+ /// Pre-computed zone ready for fast per-frame filtering.
54
+ ///
55
+ /// Note: there is no `is_privacy_mask` field — privacy masks live in
56
+ /// `PreparedZones.privacy_masks`, active zones in
57
+ /// `PreparedZones.active_zones`. The bucket the zone sits in tells the
58
+ /// filter how to treat it.
59
+ #[derive(Debug, Clone)]
60
+ pub struct PreparedZone {
61
+ pub labels: HashSet<String>,
62
+ pub filter: ZoneFilterMode,
63
+ pub match_type: ZoneMatchType,
64
+ /// Closed polygon in normalized `[0.0, 1.0]` coordinates.
65
+ pub points: Vec<[f32; 2]>,
66
+ }
67
+
68
+ /// Sorted-into-buckets prepared zones plus the union of all label
69
+ /// restrictions across active zones.
70
+ #[derive(Debug, Clone, Default)]
71
+ pub struct PreparedZones {
72
+ pub privacy_masks: Vec<PreparedZone>,
73
+ pub active_zones: Vec<PreparedZone>,
74
+ /// Union of every active zone's allowed labels (lowercased). Empty
75
+ /// means "no per-zone label restriction is in effect".
76
+ pub all_labels: HashSet<String>,
77
+ }
78
+
79
+ /// Convert UI 0-100 zones into normalized prepared zones bucketed by type.
80
+ pub fn prepare_zones(zones: &[ZoneInput]) -> PreparedZones {
81
+ let mut privacy_masks = Vec::new();
82
+ let mut active_zones = Vec::new();
83
+ let mut all_labels: HashSet<String> = HashSet::new();
84
+
85
+ for zone in zones {
86
+ // Normalize 0..100 to 0..1
87
+ let mut points: Vec<[f32; 2]> = zone
88
+ .points
89
+ .iter()
90
+ .map(|p| [(p[0] / 100.0) as f32, (p[1] / 100.0) as f32])
91
+ .collect();
92
+
93
+ // Auto-close: append the first vertex if it's not already at the end
94
+ if points.len() > 1 {
95
+ let first = points[0];
96
+ let last = points[points.len() - 1];
97
+ if (first[0] - last[0]).abs() > 1e-9 || (first[1] - last[1]).abs() > 1e-9 {
98
+ points.push(first);
99
+ }
100
+ }
101
+
102
+ let mut labels: HashSet<String> = HashSet::new();
103
+ for label in &zone.labels {
104
+ let lc = label.to_lowercase();
105
+ labels.insert(lc.clone());
106
+ if !zone.is_privacy_mask {
107
+ all_labels.insert(lc);
108
+ }
109
+ }
110
+
111
+ let prepared = PreparedZone {
112
+ labels,
113
+ filter: zone.filter,
114
+ match_type: zone.match_type,
115
+ points,
116
+ };
117
+
118
+ if zone.is_privacy_mask {
119
+ privacy_masks.push(prepared);
120
+ } else {
121
+ active_zones.push(prepared);
122
+ }
123
+ }
124
+
125
+ PreparedZones {
126
+ privacy_masks,
127
+ active_zones,
128
+ all_labels,
129
+ }
130
+ }
131
+
132
+ /// Standard ray-cast point-in-polygon test, augmented with a "point lies
133
+ /// exactly on an edge" check that returns true when applicable. The
134
+ /// polygon must be closed (first vertex == last vertex).
135
+ fn is_point_in_polygon(px: f32, py: f32, polygon: &[[f32; 2]]) -> bool {
136
+ if polygon.len() < 3 {
137
+ return false;
138
+ }
139
+ let mut inside = false;
140
+ let n = polygon.len();
141
+ let mut j = n - 1;
142
+ for i in 0..n {
143
+ let xi = polygon[i][0];
144
+ let yi = polygon[i][1];
145
+ let xj = polygon[j][0];
146
+ let yj = polygon[j][1];
147
+
148
+ // Edge containment: point lies exactly on (or very near) the edge.
149
+ let min_x = xi.min(xj);
150
+ let max_x = xi.max(xj);
151
+ let min_y = yi.min(yj);
152
+ let max_y = yi.max(yj);
153
+ if px >= min_x && px <= max_x && py >= min_y && py <= max_y {
154
+ if (xi - xj).abs() < 1e-9 {
155
+ // Vertical edge
156
+ if (px - xi).abs() < 1e-9 {
157
+ return true;
158
+ }
159
+ } else {
160
+ let m = (yj - yi) / (xj - xi);
161
+ if (py - (m * px + (yi - m * xi))).abs() < 1e-9 {
162
+ return true;
163
+ }
164
+ }
165
+ }
166
+
167
+ // Standard ray-cast: count intersections with horizontal ray to the
168
+ // left of the test point.
169
+ let yi_above = yi > py;
170
+ let yj_above = yj > py;
171
+ if yi_above != yj_above {
172
+ let x_intersect = (xj - xi) * (py - yi) / (yj - yi) + xi;
173
+ if px < x_intersect {
174
+ inside = !inside;
175
+ }
176
+ }
177
+
178
+ j = i;
179
+ }
180
+ inside
181
+ }
182
+
183
+ /// Parametric segment-segment intersection check (does not return the
184
+ /// crossing point — only whether the segments cross).
185
+ fn do_lines_intersect(a1: [f32; 2], a2: [f32; 2], b1: [f32; 2], b2: [f32; 2]) -> bool {
186
+ let denom = (b2[1] - b1[1]) * (a2[0] - a1[0]) - (b2[0] - b1[0]) * (a2[1] - a1[1]);
187
+ if denom.abs() < 1e-12 {
188
+ return false;
189
+ }
190
+ let ua = ((b2[0] - b1[0]) * (a1[1] - b1[1]) - (b2[1] - b1[1]) * (a1[0] - b1[0])) / denom;
191
+ let ub = ((a2[0] - a1[0]) * (a1[1] - b1[1]) - (a2[1] - a1[1]) * (a1[0] - b1[0])) / denom;
192
+ (0.0..=1.0).contains(&ua) && (0.0..=1.0).contains(&ub)
193
+ }
194
+
195
+ #[inline]
196
+ fn box_corners(det: &Detection) -> [[f32; 2]; 4] {
197
+ let x2 = det.x + det.width;
198
+ let y2 = det.y + det.height;
199
+ [[det.x, det.y], [x2, det.y], [x2, y2], [det.x, y2]]
200
+ }
201
+
202
+ /// True if the detection box has any overlap with the polygon, or if any
203
+ /// polygon vertex sits inside the box, or if any edges cross.
204
+ fn box_intersects_polygon(det: &Detection, polygon: &[[f32; 2]]) -> bool {
205
+ let corners = box_corners(det);
206
+ let x2 = det.x + det.width;
207
+ let y2 = det.y + det.height;
208
+
209
+ // 1) Any corner of the box inside the polygon → overlap.
210
+ for &[cx, cy] in &corners {
211
+ if is_point_in_polygon(cx, cy, polygon) {
212
+ return true;
213
+ }
214
+ }
215
+ // 2) Any vertex of the polygon inside the box → overlap.
216
+ for &[px, py] in polygon {
217
+ if px >= det.x && px <= x2 && py >= det.y && py <= y2 {
218
+ return true;
219
+ }
220
+ }
221
+ // 3) Any edge of the box crosses any edge of the polygon → overlap.
222
+ let edges = [
223
+ (corners[0], corners[1]),
224
+ (corners[1], corners[2]),
225
+ (corners[2], corners[3]),
226
+ (corners[3], corners[0]),
227
+ ];
228
+ if polygon.len() < 2 {
229
+ return false;
230
+ }
231
+ for i in 0..(polygon.len() - 1) {
232
+ for &(ea, eb) in &edges {
233
+ if do_lines_intersect(ea, eb, polygon[i], polygon[i + 1]) {
234
+ return true;
235
+ }
236
+ }
237
+ }
238
+ false
239
+ }
240
+
241
+ /// True if all four corners of the box are inside the polygon.
242
+ fn box_contained_in_polygon(det: &Detection, polygon: &[[f32; 2]]) -> bool {
243
+ for &[cx, cy] in &box_corners(det) {
244
+ if !is_point_in_polygon(cx, cy, polygon) {
245
+ return false;
246
+ }
247
+ }
248
+ true
249
+ }
250
+
251
+ #[inline]
252
+ fn zone_accepts_label(zone: &PreparedZone, lc_label: &str) -> bool {
253
+ zone.labels.is_empty() || zone.labels.contains(lc_label)
254
+ }
255
+
256
+ /// Same predicate as [`filter_detections`] but operates on a borrowed
257
+ /// slice and returns the indices of detections that pass the filter
258
+ /// instead of new owned values. Used by callers that need to project the
259
+ /// filter result back onto a parallel array of richer types (face/lpd
260
+ /// items wrapped as Detection with a substitute label, etc.).
261
+ pub fn filter_indices(
262
+ detections: &[Detection],
263
+ zones: &PreparedZones,
264
+ min_confidence: f32,
265
+ ) -> Vec<u32> {
266
+ let PreparedZones {
267
+ privacy_masks,
268
+ active_zones,
269
+ all_labels,
270
+ } = zones;
271
+
272
+ let mut out: Vec<u32> = Vec::with_capacity(detections.len());
273
+ for (i, det) in detections.iter().enumerate() {
274
+ if det.confidence < min_confidence {
275
+ continue;
276
+ }
277
+
278
+ // Fast path: no zones at all → confidence threshold only.
279
+ if active_zones.is_empty() && privacy_masks.is_empty() {
280
+ out.push(i as u32);
281
+ continue;
282
+ }
283
+
284
+ let label_lc = det.label.to_lowercase();
285
+ if !all_labels.is_empty() && !all_labels.contains(&label_lc) {
286
+ continue;
287
+ }
288
+
289
+ let mut dropped = false;
290
+ for mask in privacy_masks {
291
+ if box_contained_in_polygon(det, &mask.points) {
292
+ dropped = true;
293
+ break;
294
+ }
295
+ }
296
+ if dropped {
297
+ continue;
298
+ }
299
+
300
+ let mut has_include_zone = false;
301
+ let mut satisfies_include = false;
302
+
303
+ for zone in active_zones {
304
+ if !zone_accepts_label(zone, &label_lc) {
305
+ continue;
306
+ }
307
+ let intersects = box_intersects_polygon(det, &zone.points);
308
+ let contained = intersects && box_contained_in_polygon(det, &zone.points);
309
+
310
+ match zone.filter {
311
+ ZoneFilterMode::Exclude => match zone.match_type {
312
+ ZoneMatchType::Contain => {
313
+ if intersects {
314
+ dropped = true;
315
+ break;
316
+ }
317
+ }
318
+ ZoneMatchType::Intersect => {
319
+ if intersects || contained {
320
+ dropped = true;
321
+ break;
322
+ }
323
+ }
324
+ },
325
+ ZoneFilterMode::Include => {
326
+ has_include_zone = true;
327
+ if !satisfies_include {
328
+ match zone.match_type {
329
+ ZoneMatchType::Contain => {
330
+ if contained {
331
+ satisfies_include = true;
332
+ }
333
+ }
334
+ ZoneMatchType::Intersect => {
335
+ if intersects {
336
+ satisfies_include = true;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ if dropped {
346
+ continue;
347
+ }
348
+ if has_include_zone && !satisfies_include {
349
+ continue;
350
+ }
351
+ out.push(i as u32);
352
+ }
353
+ out
354
+ }
355
+
356
+ /// Apply confidence threshold + zone filter to a list of detections.
357
+ ///
358
+ /// Delegates to [`filter_indices`] and extracts the kept detections by
359
+ /// index — single implementation, no duplicated logic.
360
+ #[cfg(test)]
361
+ fn filter_detections(
362
+ detections: Vec<Detection>,
363
+ zones: &PreparedZones,
364
+ min_confidence: f32,
365
+ ) -> Vec<Detection> {
366
+ let indices = filter_indices(&detections, zones, min_confidence);
367
+ if indices.len() == detections.len() {
368
+ return detections; // all kept — avoid extra work
369
+ }
370
+ let mut slots: Vec<Option<Detection>> = detections.into_iter().map(Some).collect();
371
+ indices
372
+ .iter()
373
+ .filter_map(|&i| slots[i as usize].take())
374
+ .collect()
375
+ }
376
+
377
+ #[cfg(test)]
378
+ mod tests {
379
+ use super::*;
380
+
381
+ fn det(x: f32, y: f32, w: f32, h: f32, label: &str) -> Detection {
382
+ Detection {
383
+ x,
384
+ y,
385
+ width: w,
386
+ height: h,
387
+ confidence: 0.9,
388
+ label: label.to_string(),
389
+ }
390
+ }
391
+
392
+ fn rect_zone(
393
+ x1: f64,
394
+ y1: f64,
395
+ x2: f64,
396
+ y2: f64,
397
+ mode: ZoneFilterMode,
398
+ mt: ZoneMatchType,
399
+ labels: Vec<String>,
400
+ ) -> ZoneInput {
401
+ ZoneInput {
402
+ labels,
403
+ filter: mode,
404
+ match_type: mt,
405
+ is_privacy_mask: false,
406
+ points: vec![[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
407
+ }
408
+ }
409
+
410
+ #[test]
411
+ fn confidence_filter_only() {
412
+ let zones = prepare_zones(&[]);
413
+ let mut a = det(0.1, 0.1, 0.2, 0.2, "person");
414
+ a.confidence = 0.4;
415
+ let mut b = det(0.3, 0.3, 0.2, 0.2, "person");
416
+ b.confidence = 0.8;
417
+ let out = filter_detections(vec![a, b], &zones, 0.5);
418
+ assert_eq!(out.len(), 1);
419
+ assert!(out[0].confidence >= 0.5);
420
+ }
421
+
422
+ #[test]
423
+ fn include_zone_contains_box() {
424
+ // Zone covers the middle 50% of the image
425
+ let zones = prepare_zones(&[rect_zone(
426
+ 25.0,
427
+ 25.0,
428
+ 75.0,
429
+ 75.0,
430
+ ZoneFilterMode::Include,
431
+ ZoneMatchType::Contain,
432
+ vec![],
433
+ )]);
434
+ // Box fully inside
435
+ let inside = det(0.30, 0.30, 0.20, 0.20, "person");
436
+ // Box outside the zone
437
+ let outside = det(0.85, 0.85, 0.10, 0.10, "person");
438
+ let out = filter_detections(vec![inside, outside], &zones, 0.0);
439
+ assert_eq!(out.len(), 1);
440
+ }
441
+
442
+ #[test]
443
+ fn include_intersect_keeps_partial_overlap() {
444
+ let zones = prepare_zones(&[rect_zone(
445
+ 25.0,
446
+ 25.0,
447
+ 75.0,
448
+ 75.0,
449
+ ZoneFilterMode::Include,
450
+ ZoneMatchType::Intersect,
451
+ vec![],
452
+ )]);
453
+ // Box overlaps the zone but isn't fully contained
454
+ let partial = det(0.70, 0.30, 0.20, 0.20, "person");
455
+ let out = filter_detections(vec![partial], &zones, 0.0);
456
+ assert_eq!(out.len(), 1);
457
+ }
458
+
459
+ #[test]
460
+ fn exclude_zone_drops_box() {
461
+ let zones = prepare_zones(&[rect_zone(
462
+ 25.0,
463
+ 25.0,
464
+ 75.0,
465
+ 75.0,
466
+ ZoneFilterMode::Exclude,
467
+ ZoneMatchType::Intersect,
468
+ vec![],
469
+ )]);
470
+ let inside = det(0.30, 0.30, 0.20, 0.20, "person");
471
+ let outside = det(0.05, 0.05, 0.10, 0.10, "person");
472
+ let out = filter_detections(vec![inside, outside], &zones, 0.0);
473
+ assert_eq!(out.len(), 1);
474
+ assert!((out[0].x - 0.05).abs() < 1e-6);
475
+ }
476
+
477
+ #[test]
478
+ fn privacy_mask_drops_contained_box() {
479
+ let mut mask = rect_zone(
480
+ 0.0,
481
+ 0.0,
482
+ 50.0,
483
+ 50.0,
484
+ ZoneFilterMode::Include,
485
+ ZoneMatchType::Intersect,
486
+ vec![],
487
+ );
488
+ mask.is_privacy_mask = true;
489
+ let zones = prepare_zones(&[mask]);
490
+ let inside = det(0.10, 0.10, 0.20, 0.20, "person");
491
+ let outside = det(0.60, 0.60, 0.20, 0.20, "person");
492
+ let out = filter_detections(vec![inside, outside], &zones, 0.0);
493
+ assert_eq!(out.len(), 1);
494
+ assert!((out[0].x - 0.60).abs() < 1e-6);
495
+ }
496
+
497
+ #[test]
498
+ fn label_filter_restricts_globally() {
499
+ // Zone is configured for cars only — persons should be dropped even
500
+ // outside the zone (the union of all zone labels becomes a global
501
+ // allow-list).
502
+ let zones = prepare_zones(&[rect_zone(
503
+ 0.0,
504
+ 0.0,
505
+ 100.0,
506
+ 100.0,
507
+ ZoneFilterMode::Include,
508
+ ZoneMatchType::Intersect,
509
+ vec!["car".to_string()],
510
+ )]);
511
+ let person = det(0.30, 0.30, 0.20, 0.20, "person");
512
+ let car = det(0.30, 0.30, 0.20, 0.20, "car");
513
+ let out = filter_detections(vec![person, car], &zones, 0.0);
514
+ assert_eq!(out.len(), 1);
515
+ assert_eq!(out[0].label, "car");
516
+ }
517
+
518
+ #[test]
519
+ fn label_zone_with_other_label_zone_combined() {
520
+ // Two zones: one for "person", one for "car". Detections of either
521
+ // class are allowed; cats get dropped.
522
+ let zones = prepare_zones(&[
523
+ rect_zone(
524
+ 0.0,
525
+ 0.0,
526
+ 100.0,
527
+ 100.0,
528
+ ZoneFilterMode::Include,
529
+ ZoneMatchType::Intersect,
530
+ vec!["person".to_string()],
531
+ ),
532
+ rect_zone(
533
+ 0.0,
534
+ 0.0,
535
+ 100.0,
536
+ 100.0,
537
+ ZoneFilterMode::Include,
538
+ ZoneMatchType::Intersect,
539
+ vec!["car".to_string()],
540
+ ),
541
+ ]);
542
+ let person = det(0.30, 0.30, 0.20, 0.20, "person");
543
+ let car = det(0.30, 0.30, 0.20, 0.20, "car");
544
+ let cat = det(0.30, 0.30, 0.20, 0.20, "cat");
545
+ let out = filter_detections(vec![person, car, cat], &zones, 0.0);
546
+ assert_eq!(out.len(), 2);
547
+ }
548
+
549
+ #[test]
550
+ fn auto_close_polygon() {
551
+ // Pass a polygon that doesn't end with the start vertex — prepare
552
+ // should close it for us.
553
+ let zone = ZoneInput {
554
+ labels: vec![],
555
+ filter: ZoneFilterMode::Include,
556
+ match_type: ZoneMatchType::Intersect,
557
+ is_privacy_mask: false,
558
+ points: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
559
+ };
560
+ let zones = prepare_zones(&[zone]);
561
+ assert_eq!(zones.active_zones[0].points.len(), 5);
562
+ let first = zones.active_zones[0].points[0];
563
+ let last = zones.active_zones[0].points[4];
564
+ assert!((first[0] - last[0]).abs() < 1e-6);
565
+ assert!((first[1] - last[1]).abs() < 1e-6);
566
+ }
567
+ }