@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.
@@ -0,0 +1,214 @@
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
+ }
package/src/merge.rs ADDED
@@ -0,0 +1,312 @@
1
+ //! Per-class union-find clustering of nearby/overlapping detections.
2
+ //!
3
+ //! This is a separate dedup pass from NMS: where NMS keeps the highest-
4
+ //! confidence box and discards its rivals, this function **merges** every
5
+ //! cluster of close-or-overlapping same-label boxes into a single union
6
+ //! bounding box covering all members. Useful when an object detector
7
+ //! consistently splits a single physical object into several adjacent
8
+ //! detections (e.g. a person torso + legs becoming two boxes).
9
+ //!
10
+ //! Two boxes are clustered together if either condition holds:
11
+ //! 1. Their top-left corners are within `close_threshold` along both
12
+ //! axes (cheap proximity check that survives zero IoU), OR
13
+ //! 2. Their IoU exceeds `iou_threshold`.
14
+ //!
15
+ //! Output: one Detection per connected component, with `box` set to the
16
+ //! axis-aligned union of all member boxes (clamped to `[0, 1]`) and
17
+ //! `confidence` set to the maximum confidence in the cluster. The cluster
18
+ //! inherits the label and remaining metadata from the first member of the
19
+ //! group (all members share the same label since we group by label first).
20
+
21
+ use std::collections::HashMap;
22
+
23
+ use crate::types::Detection;
24
+
25
+ /// Merge nearby/overlapping same-label detections via union-find clustering.
26
+ pub fn merge_detections(
27
+ detections: Vec<Detection>,
28
+ iou_threshold: f32,
29
+ close_threshold: f32,
30
+ ) -> Vec<Detection> {
31
+ if detections.is_empty() {
32
+ return Vec::new();
33
+ }
34
+
35
+ // Fast path: when all detections share the same label (common after NMS
36
+ // reduces to 1-2 objects of the same class), skip HashMap grouping.
37
+ let all_same_label = detections.windows(2).all(|w| w[0].label == w[1].label);
38
+
39
+ let mut result: Vec<Detection> = Vec::with_capacity(detections.len());
40
+
41
+ if all_same_label {
42
+ let indices: Vec<usize> = (0..detections.len()).collect();
43
+ merge_cluster(
44
+ &detections,
45
+ &indices,
46
+ iou_threshold,
47
+ close_threshold,
48
+ &mut result,
49
+ );
50
+ return result;
51
+ }
52
+
53
+ // Group by label so clustering only happens within a class.
54
+ let mut by_label: HashMap<String, Vec<usize>> = HashMap::new();
55
+ for (i, det) in detections.iter().enumerate() {
56
+ by_label.entry(det.label.clone()).or_default().push(i);
57
+ }
58
+
59
+ for (_label, indices) in by_label {
60
+ merge_cluster(
61
+ &detections,
62
+ &indices,
63
+ iou_threshold,
64
+ close_threshold,
65
+ &mut result,
66
+ );
67
+ }
68
+
69
+ result
70
+ }
71
+
72
+ /// Cluster a single-label group of detections and append results to `out`.
73
+ fn merge_cluster(
74
+ detections: &[Detection],
75
+ indices: &[usize],
76
+ iou_threshold: f32,
77
+ close_threshold: f32,
78
+ out: &mut Vec<Detection>,
79
+ ) {
80
+ let n = indices.len();
81
+
82
+ if n == 1 {
83
+ out.push(detections[indices[0]].clone());
84
+ return;
85
+ }
86
+
87
+ // Pack box corners + areas into a SoA layout for cache-friendly
88
+ // pairwise comparison: [x1, y1, x2, y2, area] per detection.
89
+ let mut boxes = vec![0.0f32; n * 5];
90
+ for (i, &orig_idx) in indices.iter().enumerate() {
91
+ let det = &detections[orig_idx];
92
+ let off = i * 5;
93
+ boxes[off] = det.x;
94
+ boxes[off + 1] = det.y;
95
+ boxes[off + 2] = det.x + det.width;
96
+ boxes[off + 3] = det.y + det.height;
97
+ boxes[off + 4] = det.width * det.height;
98
+ }
99
+
100
+ // Union-find with path-compression. `parent[i]` points to the
101
+ // representative of i's cluster.
102
+ let mut parent: Vec<usize> = (0..n).collect();
103
+ fn find(parent: &mut [usize], mut x: usize) -> usize {
104
+ while parent[x] != x {
105
+ parent[x] = parent[parent[x]];
106
+ x = parent[x];
107
+ }
108
+ x
109
+ }
110
+ fn union(parent: &mut [usize], a: usize, b: usize) {
111
+ let ra = find(parent, a);
112
+ let rb = find(parent, b);
113
+ parent[ra] = rb;
114
+ }
115
+
116
+ for i in 0..n {
117
+ let off_i = i * 5;
118
+ let ix1 = boxes[off_i];
119
+ let iy1 = boxes[off_i + 1];
120
+ let ix2 = boxes[off_i + 2];
121
+ let iy2 = boxes[off_i + 3];
122
+ let i_area = boxes[off_i + 4];
123
+
124
+ for j in (i + 1)..n {
125
+ let off_j = j * 5;
126
+
127
+ let close = (ix1 - boxes[off_j]).abs() <= close_threshold
128
+ && (iy1 - boxes[off_j + 1]).abs() <= close_threshold;
129
+ if close {
130
+ union(&mut parent, i, j);
131
+ continue;
132
+ }
133
+
134
+ let inter_x1 = ix1.max(boxes[off_j]);
135
+ let inter_y1 = iy1.max(boxes[off_j + 1]);
136
+ let inter_x2 = ix2.min(boxes[off_j + 2]);
137
+ let inter_y2 = iy2.min(boxes[off_j + 3]);
138
+ if inter_x2 <= inter_x1 || inter_y2 <= inter_y1 {
139
+ continue;
140
+ }
141
+ let inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1);
142
+ let union_area = i_area + boxes[off_j + 4] - inter_area;
143
+ if union_area <= 0.0 {
144
+ continue;
145
+ }
146
+ let iou = inter_area / union_area;
147
+ if iou > iou_threshold {
148
+ union(&mut parent, i, j);
149
+ }
150
+ }
151
+ }
152
+
153
+ // Collect cluster members keyed by their representative.
154
+ let mut clusters: HashMap<usize, Vec<usize>> = HashMap::new();
155
+ for i in 0..n {
156
+ let root = find(&mut parent, i);
157
+ clusters.entry(root).or_default().push(i);
158
+ }
159
+
160
+ for (_root, members) in clusters {
161
+ if members.len() == 1 {
162
+ out.push(detections[indices[members[0]]].clone());
163
+ continue;
164
+ }
165
+
166
+ let mut min_x = 1.0f32;
167
+ let mut min_y = 1.0f32;
168
+ let mut max_x = 0.0f32;
169
+ let mut max_y = 0.0f32;
170
+ let mut max_conf = 0.0f32;
171
+
172
+ for &m in &members {
173
+ let off = m * 5;
174
+ if boxes[off] < min_x {
175
+ min_x = boxes[off];
176
+ }
177
+ if boxes[off + 1] < min_y {
178
+ min_y = boxes[off + 1];
179
+ }
180
+ if boxes[off + 2] > max_x {
181
+ max_x = boxes[off + 2];
182
+ }
183
+ if boxes[off + 3] > max_y {
184
+ max_y = boxes[off + 3];
185
+ }
186
+ let conf = detections[indices[m]].confidence;
187
+ if conf > max_conf {
188
+ max_conf = conf;
189
+ }
190
+ }
191
+
192
+ min_x = min_x.max(0.0);
193
+ min_y = min_y.max(0.0);
194
+ max_x = max_x.min(1.0);
195
+ max_y = max_y.min(1.0);
196
+
197
+ let w = max_x - min_x;
198
+ let h = max_y - min_y;
199
+ if w <= 0.0 || h <= 0.0 {
200
+ continue;
201
+ }
202
+
203
+ let template = &detections[indices[members[0]]];
204
+ out.push(Detection {
205
+ x: min_x,
206
+ y: min_y,
207
+ width: w,
208
+ height: h,
209
+ confidence: max_conf,
210
+ label: template.label.clone(),
211
+ });
212
+ }
213
+ }
214
+
215
+ #[cfg(test)]
216
+ mod tests {
217
+ use super::*;
218
+
219
+ fn det(x: f32, y: f32, w: f32, h: f32, conf: f32, label: &str) -> Detection {
220
+ Detection {
221
+ x,
222
+ y,
223
+ width: w,
224
+ height: h,
225
+ confidence: conf,
226
+ label: label.to_string(),
227
+ }
228
+ }
229
+
230
+ #[test]
231
+ fn empty_input() {
232
+ assert!(merge_detections(Vec::new(), 0.5, 0.1).is_empty());
233
+ }
234
+
235
+ #[test]
236
+ fn single_detection_unchanged() {
237
+ let input = vec![det(0.1, 0.1, 0.2, 0.2, 0.9, "person")];
238
+ let out = merge_detections(input, 0.5, 0.1);
239
+ assert_eq!(out.len(), 1);
240
+ assert!((out[0].x - 0.1).abs() < 1e-6);
241
+ assert!((out[0].confidence - 0.9).abs() < 1e-6);
242
+ }
243
+
244
+ #[test]
245
+ fn overlapping_boxes_merged_to_union() {
246
+ // Two boxes with significant overlap should collapse into one
247
+ // covering the full extent.
248
+ let input = vec![
249
+ det(0.1, 0.1, 0.2, 0.2, 0.7, "person"),
250
+ det(0.15, 0.15, 0.2, 0.2, 0.9, "person"),
251
+ ];
252
+ let out = merge_detections(input, 0.01, 0.001);
253
+ assert_eq!(out.len(), 1);
254
+ let m = &out[0];
255
+ // Union covers [0.1..0.35] x [0.1..0.35]
256
+ assert!((m.x - 0.1).abs() < 1e-6);
257
+ assert!((m.y - 0.1).abs() < 1e-6);
258
+ assert!((m.width - 0.25).abs() < 1e-6);
259
+ assert!((m.height - 0.25).abs() < 1e-6);
260
+ // Max confidence in the cluster
261
+ assert!((m.confidence - 0.9).abs() < 1e-6);
262
+ }
263
+
264
+ #[test]
265
+ fn close_corners_merged_even_without_iou() {
266
+ // Two non-overlapping boxes whose top-left corners are within
267
+ // close_threshold should still cluster (the cheap proximity rule).
268
+ let input = vec![
269
+ det(0.10, 0.10, 0.05, 0.05, 0.8, "person"),
270
+ det(0.12, 0.12, 0.05, 0.05, 0.7, "person"),
271
+ ];
272
+ let out = merge_detections(input, 0.5, 0.05);
273
+ assert_eq!(out.len(), 1);
274
+ }
275
+
276
+ #[test]
277
+ fn different_labels_not_merged() {
278
+ let input = vec![
279
+ det(0.1, 0.1, 0.2, 0.2, 0.7, "person"),
280
+ det(0.1, 0.1, 0.2, 0.2, 0.9, "car"),
281
+ ];
282
+ let out = merge_detections(input, 0.01, 0.001);
283
+ assert_eq!(out.len(), 2);
284
+ }
285
+
286
+ #[test]
287
+ fn distant_boxes_not_merged() {
288
+ let input = vec![
289
+ det(0.1, 0.1, 0.05, 0.05, 0.7, "person"),
290
+ det(0.8, 0.8, 0.05, 0.05, 0.9, "person"),
291
+ ];
292
+ let out = merge_detections(input, 0.5, 0.01);
293
+ assert_eq!(out.len(), 2);
294
+ }
295
+
296
+ #[test]
297
+ fn three_box_chain() {
298
+ // a–b overlap, b–c overlap, a–c don't — union-find should still
299
+ // collapse all three into one cluster via the chain.
300
+ let input = vec![
301
+ det(0.10, 0.10, 0.10, 0.10, 0.5, "person"),
302
+ det(0.15, 0.15, 0.10, 0.10, 0.6, "person"),
303
+ det(0.20, 0.20, 0.10, 0.10, 0.7, "person"),
304
+ ];
305
+ let out = merge_detections(input, 0.01, 0.001);
306
+ assert_eq!(out.len(), 1);
307
+ // Union covers [0.10..0.30] x [0.10..0.30]
308
+ let m = &out[0];
309
+ assert!((m.x - 0.1).abs() < 1e-6);
310
+ assert!((m.width - 0.2).abs() < 1e-6);
311
+ }
312
+ }