arcsort 0.1.0__py3-none-any.whl

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.
arcsort/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """ARC-SORT: Adaptive, Robust, Compensated SORT.
2
+
3
+ A multi-object tracking algorithm built on OC-SORT, designed for tracking
4
+ objects with non-linear motion under moving camera conditions.
5
+
6
+ from arcsort import ARCTracker
7
+ tracker = ARCTracker.from_preset("sportsmot")
8
+ tracks = tracker.update(detections, frame) # (N, 6) -> (M, 5)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from loguru import logger
16
+
17
+ # Library code stays silent unless the application opts in with
18
+ # logger.enable("arcsort") (the repo's tools do this via setup_logging).
19
+ logger.disable("arcsort")
20
+
21
+ from arcsort.tracker import ARCTracker, load_config # noqa: E402
22
+
23
+ __all__ = ["ARCTracker", "load_config", "__version__"]
arcsort/association.py ADDED
@@ -0,0 +1,529 @@
1
+ """Detection-to-track association logic.
2
+
3
+ Implements single-stage and BYTE two-stage association. The first stage
4
+ matches high-confidence detections to predicted tracks using IoU + OC-SORT's
5
+ observation-centric momentum (OCM) direction consistency. The second stage
6
+ (BYTE) matches remaining unmatched tracks against low-confidence detections
7
+ using IoU only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ from loguru import logger
14
+
15
+ from arcsort.matching import (
16
+ eiou_batch,
17
+ embedding_similarity_batch,
18
+ hybrid_score_batch,
19
+ iou_batch,
20
+ linear_assignment,
21
+ )
22
+ from arcsort.ocsort_components import compute_ocm_cost
23
+
24
+
25
+ def _compute_adaptive_weight(
26
+ emb_cost: np.ndarray,
27
+ w_assoc_emb: float,
28
+ bottom: float = 0.5,
29
+ ) -> np.ndarray:
30
+ """Compute per-element adaptive weight for embedding cost.
31
+
32
+ For each detection (row) and each track (column), the weight is scaled
33
+ down when the best and second-best embedding matches are close in score,
34
+ indicating ambiguity. This prevents the embedding cost from dominating
35
+ when appearance is not discriminative.
36
+
37
+ Follows the Deep-OC-SORT ``compute_aw_max_metric`` pattern.
38
+
39
+ Args:
40
+ emb_cost: Raw embedding similarity matrix, shape (N, M).
41
+ w_assoc_emb: Base embedding weight.
42
+ bottom: Ratio threshold below which weight is full. Range [0, 1].
43
+
44
+ Returns:
45
+ Weighted embedding cost matrix, shape (N, M).
46
+ """
47
+ w_emb = np.full_like(emb_cost, w_assoc_emb)
48
+
49
+ # When bottom >= 1.0, adaptive weighting is effectively disabled
50
+ # (all ratios are below bottom, so weight stays at full)
51
+ if bottom >= 1.0:
52
+ return w_emb * emb_cost
53
+
54
+ def _ambiguity_weights(best: np.ndarray, second: np.ndarray) -> np.ndarray:
55
+ safe_best = np.where(best == 0, 1.0, best)
56
+ ratio = second / safe_best
57
+ weights = 1.0 - np.maximum(ratio - bottom, 0.0) / (1.0 - bottom)
58
+ return np.where(best == 0, 0.0, weights)
59
+
60
+ # Row-wise: per-detection ambiguity (needs >= 2 candidate tracks)
61
+ if emb_cost.shape[1] >= 2:
62
+ top2 = np.partition(emb_cost, -2, axis=1)
63
+ w_emb *= _ambiguity_weights(top2[:, -1], top2[:, -2])[:, np.newaxis]
64
+
65
+ # Column-wise: per-track ambiguity (needs >= 2 candidate detections)
66
+ if emb_cost.shape[0] >= 2:
67
+ top2 = np.partition(emb_cost, -2, axis=0)
68
+ w_emb *= _ambiguity_weights(top2[-1], top2[-2])[np.newaxis, :]
69
+
70
+ return w_emb * emb_cost
71
+
72
+
73
+ def velocity_continuity_cost(
74
+ detections: np.ndarray,
75
+ track_predictions: np.ndarray,
76
+ track_filter_vels: np.ndarray,
77
+ track_last_obs: np.ndarray,
78
+ track_dt: np.ndarray,
79
+ weight: float,
80
+ r_cap: float,
81
+ max_dt: int = 2,
82
+ ) -> np.ndarray:
83
+ """Signed velocity-continuity bonus for the stage-1 cost matrix.
84
+
85
+ At a crossing, the competing boxes nearly coincide (spatial scores
86
+ tie, appearance is occluded or costume-degenerate) but the targets
87
+ keep their velocities — a swapped assignment implies a velocity
88
+ discontinuity. For each pair, compare the velocity implied by
89
+ matching detection i to track j, (c_det − c_last_obs)/Δt, with the
90
+ filter's velocity estimate; the residual r (normalized by predicted
91
+ box extent, so units are box-widths per frame) maps to a signed
92
+ bonus weight·(1/2 − min(r/r_cap, 1)) ∈ [−w/2, +w/2], the same shape
93
+ as OCM's inertia term. Tracks unseen for more than max_dt frames
94
+ contribute 0 — a coasted velocity compared against a many-frame
95
+ displacement average is noise, not evidence.
96
+
97
+ Args:
98
+ detections: (N, 4) xyxy.
99
+ track_predictions: (M, 4) xyxy predicted (post-CMC) boxes.
100
+ track_filter_vels: (M, 2) filter center velocities; NaN rows
101
+ disable the cue for that track.
102
+ track_last_obs: (M, 4+) xyxy last observed boxes.
103
+ track_dt: (M,) frames since last observation (>= 1).
104
+ weight: Bonus magnitude (like OCM's inertia_weight).
105
+ r_cap: Residual (in box-widths/frame) at which the bonus
106
+ saturates at −weight/2.
107
+ max_dt: Tracks with dt > max_dt get a zero term.
108
+
109
+ Returns:
110
+ (N, M) additive score term.
111
+ """
112
+ n, m = detections.shape[0], track_predictions.shape[0]
113
+ if n == 0 or m == 0 or weight == 0:
114
+ return np.zeros((n, m))
115
+
116
+ det_c = (detections[:, :2] + detections[:, 2:4]) / 2 # (N, 2)
117
+ obs_c = (track_last_obs[:, :2] + track_last_obs[:, 2:4]) / 2 # (M, 2)
118
+ dt = np.maximum(track_dt.astype(np.float64), 1.0) # (M,)
119
+
120
+ v_implied = (det_c[:, None, :] - obs_c[None, :, :]) / dt[None, :, None]
121
+ resid = np.linalg.norm(v_implied - track_filter_vels[None, :, :], axis=2)
122
+
123
+ extent = np.maximum(
124
+ (track_predictions[:, 2] - track_predictions[:, 0])
125
+ + (track_predictions[:, 3] - track_predictions[:, 1]),
126
+ 2.0,
127
+ ) / 2.0 # (M,)
128
+ r = resid / extent[None, :]
129
+
130
+ bonus = weight * (0.5 - np.minimum(r / max(r_cap, 1e-6), 1.0))
131
+
132
+ valid = (track_dt <= max_dt) & ~np.isnan(track_filter_vels).any(axis=1)
133
+ bonus[:, ~valid] = 0.0
134
+ return bonus
135
+
136
+
137
+ def associate(
138
+ detections: np.ndarray,
139
+ track_predictions: np.ndarray,
140
+ track_velocities: np.ndarray,
141
+ track_previous_obs: np.ndarray,
142
+ iou_threshold: float,
143
+ inertia_weight: float,
144
+ use_hybrid_distance: bool = False,
145
+ hybrid_config: dict | None = None,
146
+ emb_cost: np.ndarray | None = None,
147
+ w_assoc_emb: float = 0.75,
148
+ aw_off: bool = False,
149
+ aw_param: float = 0.5,
150
+ gate_mask: np.ndarray | None = None,
151
+ emb_rescue_thresh: float | None = None,
152
+ track_expansions: np.ndarray | None = None,
153
+ spatial_score: np.ndarray | None = None,
154
+ return_diagnostics: bool = False,
155
+ vel_cue: dict | None = None,
156
+ ) -> (
157
+ tuple[np.ndarray, np.ndarray, np.ndarray]
158
+ | tuple[np.ndarray, np.ndarray, np.ndarray, dict]
159
+ ):
160
+ """Associate detections to tracks using IoU/hybrid + OCM + ReID.
161
+
162
+ Builds a cost matrix combining spatial similarity, direction
163
+ consistency, and (optionally) appearance embedding similarity.
164
+ Solves the linear assignment problem and filters matches below
165
+ the score threshold.
166
+
167
+ When emb_cost is provided, the final score is:
168
+ ``score = score_base + ocm + w * embedding_similarity``
169
+ where w is either a fixed weight (aw_off=True) or adaptively
170
+ computed per element (aw_off=False) based on match ambiguity.
171
+
172
+ Embedding cost is masked to zero wherever the base spatial score
173
+ is <= 0 to prevent pure-appearance matches with no spatial support.
174
+
175
+ Args:
176
+ detections: Detection bboxes, shape (N, 4) in [x1, y1, x2, y2].
177
+ track_predictions: Predicted track bboxes, shape (M, 4).
178
+ track_velocities: Per-track velocity directions, shape (M, 2).
179
+ track_previous_obs: Per-track previous observations, shape (M, 5).
180
+ iou_threshold: Minimum score for a valid match.
181
+ inertia_weight: Weight for OCM direction consistency cost.
182
+ use_hybrid_distance: If True, use hybrid IoU/center fallback.
183
+ hybrid_config: Config dict for hybrid distance params.
184
+ emb_cost: Embedding similarity matrix, shape (N, M). Higher =
185
+ more similar. None to disable ReID fusion.
186
+ w_assoc_emb: Base weight for embedding cost. Default 0.75.
187
+ aw_off: If True, use fixed weight. If False, use adaptive
188
+ weighting based on 1st/2nd best ratio. Default False.
189
+ aw_param: Bottom threshold for adaptive weighting. Default 0.5.
190
+ gate_mask: Boolean matrix, shape (N, M). False forbids the
191
+ pair regardless of its score (e.g. Mahalanobis motion
192
+ gate). None to disable gating.
193
+ emb_rescue_thresh: Minimum embedding similarity for appearance
194
+ to rescue a zero-overlap pair. Rescue is only permitted
195
+ inside the motion gate, so both emb_cost and gate_mask must
196
+ be provided; otherwise no rescue happens. None disables
197
+ rescue (appearance can then only re-rank overlapping pairs).
198
+ track_expansions: Per-track ExpansionIoU factors, shape (M,),
199
+ or (M, 2) with anisotropic [ex, ey] columns. When provided,
200
+ the spatial score is EIoU with these factors (overrides
201
+ use_hybrid_distance). None disables EIoU.
202
+ return_diagnostics: If True, return a fourth element — a dict
203
+ with the assembled score matrices ("score": the full matrix
204
+ the solver saw, "spatial": the base spatial score) for
205
+ downstream ambiguity analysis. Never alters the assignment.
206
+ vel_cue: Optional velocity-continuity cue inputs (see
207
+ velocity_continuity_cost): {"filter_vels": (M, 2),
208
+ "last_obs": (M, 4+), "dt": (M,), "weight": float,
209
+ "r_cap": float}. None disables the term.
210
+ spatial_score: Precomputed spatial score matrix, shape (N, M)
211
+ (e.g. sigma-DIoU for eiou.mode "sigma"). Overrides
212
+ track_expansions and use_hybrid_distance. May contain
213
+ negative values (DIoU range (-1, 1]); such pairs can never
214
+ become matches because acceptance requires
215
+ ``score_base >= iou_threshold`` with a positive threshold.
216
+ The score is deliberately NOT clamped at 0: the negative
217
+ range preserves the assignment-ranking gradient between
218
+ separated pairs, and negative-score assignments are
219
+ filtered out by the acceptance test below.
220
+
221
+ Returns:
222
+ Tuple of:
223
+ matched: Matched pairs as array of shape (K, 2),
224
+ columns are [detection_idx, track_idx].
225
+ unmatched_dets: Indices of unmatched detections, shape (L,).
226
+ unmatched_trks: Indices of unmatched tracks, shape (P,).
227
+ """
228
+ num_dets = detections.shape[0]
229
+ num_trks = track_predictions.shape[0]
230
+
231
+ if num_trks == 0 or num_dets == 0:
232
+ empty = (
233
+ np.empty((0, 2), dtype=int),
234
+ np.arange(num_dets, dtype=int) if num_trks == 0
235
+ else np.empty(0, dtype=int),
236
+ np.empty(0, dtype=int) if num_trks == 0
237
+ else np.arange(num_trks, dtype=int),
238
+ )
239
+ if return_diagnostics:
240
+ zeros = np.zeros((num_dets, num_trks))
241
+ return (*empty, {"score": zeros, "spatial": zeros})
242
+ return empty
243
+
244
+ # Similarity score: precomputed (sigma-DIoU), EIoU, hybrid, or IoU
245
+ if spatial_score is not None:
246
+ score_base = spatial_score
247
+ elif track_expansions is not None:
248
+ score_base = eiou_batch(detections, track_predictions, track_expansions)
249
+ elif use_hybrid_distance:
250
+ hcfg = hybrid_config or {}
251
+ score_base = hybrid_score_batch(
252
+ detections,
253
+ track_predictions,
254
+ iou_fallback_thresh=hcfg.get("iou_fallback_thresh", 0.1),
255
+ shape_penalty_weight=hcfg.get("shape_penalty_weight", 0.3),
256
+ fallback_cap=hcfg.get("fallback_cap", 0.5),
257
+ )
258
+ else:
259
+ score_base = iou_batch(detections, track_predictions)
260
+
261
+ # OCM direction consistency cost
262
+ ocm_cost = compute_ocm_cost(
263
+ detections, track_velocities, track_previous_obs, inertia_weight
264
+ )
265
+
266
+ # Appearance rescue: pairs inside the motion gate whose embedding
267
+ # similarity is high enough may match without spatial overlap.
268
+ rescue_eligible = None
269
+ if (
270
+ emb_rescue_thresh is not None
271
+ and emb_cost is not None
272
+ and gate_mask is not None
273
+ ):
274
+ rescue_eligible = gate_mask & (emb_cost >= emb_rescue_thresh)
275
+
276
+ # Embedding appearance cost (additive fusion)
277
+ appearance_cost = 0
278
+ if emb_cost is not None:
279
+ # Mask embedding cost where spatial score is zero (no overlap),
280
+ # except for rescue-eligible pairs which keep their appearance
281
+ emb_masked = emb_cost.copy()
282
+ no_overlap = score_base <= 0
283
+ if rescue_eligible is not None:
284
+ no_overlap = no_overlap & ~rescue_eligible
285
+ emb_masked[no_overlap] = 0
286
+
287
+ if not aw_off:
288
+ appearance_cost = _compute_adaptive_weight(
289
+ emb_masked, w_assoc_emb, bottom=aw_param
290
+ )
291
+ else:
292
+ appearance_cost = w_assoc_emb * emb_masked
293
+
294
+ # Velocity-continuity bonus (crossing tie-breaker)
295
+ vel_cost = 0
296
+ if vel_cue is not None:
297
+ vel_cost = velocity_continuity_cost(
298
+ detections, track_predictions,
299
+ vel_cue["filter_vels"], vel_cue["last_obs"], vel_cue["dt"],
300
+ weight=vel_cue["weight"], r_cap=vel_cue["r_cap"],
301
+ )
302
+
303
+ # Combined score to maximize
304
+ score_matrix = score_base + ocm_cost + appearance_cost + vel_cost
305
+
306
+ # Forbid pairs outside the motion gate so the solver routes around them
307
+ if gate_mask is not None:
308
+ score_matrix = np.where(gate_mask, score_matrix, -1e6)
309
+
310
+ # Solve assignment (minimize negative score)
311
+ matched_indices = linear_assignment(-score_matrix)
312
+
313
+ # Separate matched and unmatched
314
+ matched_det_indices = set(matched_indices[:, 0]) if matched_indices.size > 0 else set()
315
+ matched_trk_indices = set(matched_indices[:, 1]) if matched_indices.size > 0 else set()
316
+
317
+ unmatched_dets = np.array(
318
+ [d for d in range(num_dets) if d not in matched_det_indices], dtype=int
319
+ )
320
+ unmatched_trks = np.array(
321
+ [t for t in range(num_trks) if t not in matched_trk_indices], dtype=int
322
+ )
323
+
324
+ # Filter matches below score threshold or outside the motion gate.
325
+ # Rescue-eligible pairs are accepted on appearance despite low overlap.
326
+ valid_matches = []
327
+ for det_idx, trk_idx in matched_indices:
328
+ gated_out = gate_mask is not None and not gate_mask[det_idx, trk_idx]
329
+ rescued = (
330
+ rescue_eligible is not None and rescue_eligible[det_idx, trk_idx]
331
+ )
332
+ spatially_valid = score_base[det_idx, trk_idx] >= iou_threshold
333
+ if gated_out or not (spatially_valid or rescued):
334
+ unmatched_dets = np.append(unmatched_dets, det_idx)
335
+ unmatched_trks = np.append(unmatched_trks, trk_idx)
336
+ else:
337
+ valid_matches.append([det_idx, trk_idx])
338
+
339
+ if len(valid_matches) == 0:
340
+ matched = np.empty((0, 2), dtype=int)
341
+ else:
342
+ matched = np.array(valid_matches, dtype=int)
343
+
344
+ if return_diagnostics:
345
+ return matched, unmatched_dets, unmatched_trks, {
346
+ "score": score_matrix,
347
+ "spatial": score_base,
348
+ }
349
+ return matched, unmatched_dets, unmatched_trks
350
+
351
+
352
+ def swap_margins(
353
+ matched: np.ndarray,
354
+ score: np.ndarray,
355
+ spatial: np.ndarray,
356
+ ) -> list[tuple[int, int, float]]:
357
+ """Permutation margins between accepted stage-1 matches.
358
+
359
+ For accepted matches a = (d_i, t_j) and b = (d_k, t_l), the margin
360
+ Δ = S[i,j] + S[k,l] − S[i,l] − S[k,j] measures how much total score
361
+ the assignment loses if the two identities are swapped. A small Δ
362
+ means the association decision was within noise of its permutation —
363
+ the only reliable online witness of a potential identity swap. Pairs
364
+ are only comparable when both cross-assignments have spatial support
365
+ (spatial > 0); OCM's signed bonus alone must not create phantom
366
+ swap candidates between distant targets.
367
+
368
+ Returns [(match_idx_a, match_idx_b, margin)] for comparable pairs.
369
+ """
370
+ k = matched.shape[0]
371
+ if k < 2:
372
+ return []
373
+ di = matched[:, 0]
374
+ tj = matched[:, 1]
375
+ cross = score[np.ix_(di, tj)] # cross[a, b] = S(d_a, t_b)
376
+ cross_sp = spatial[np.ix_(di, tj)]
377
+ own = np.diag(cross)
378
+ margin = own[:, None] + own[None, :] - cross - cross.T
379
+ feasible = (cross_sp > 0) & (cross_sp.T > 0)
380
+ out = []
381
+ for a in range(k):
382
+ for b in range(a + 1, k):
383
+ if feasible[a, b]:
384
+ out.append((a, b, float(margin[a, b])))
385
+ return out
386
+
387
+
388
+ def associate_ocr(
389
+ unmatched_detections: np.ndarray,
390
+ unmatched_track_last_obs: np.ndarray,
391
+ iou_threshold: float,
392
+ emb_cost: np.ndarray | None = None,
393
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
394
+ """OCR: attempt recovery matching using last observations.
395
+
396
+ Matches unmatched detections against tracks' last observed positions
397
+ instead of predicted positions. This helps recover from filter
398
+ divergence when tracks undergo sudden motion changes.
399
+
400
+ When emb_cost is provided, the cost combines IoU and embedding
401
+ similarity: ``cost = -(iou + emb_cost)``.
402
+
403
+ Args:
404
+ unmatched_detections: Unmatched detection bboxes, shape (N, 4).
405
+ unmatched_track_last_obs: Last observed bboxes for unmatched
406
+ tracks, shape (M, 4) in [x1, y1, x2, y2].
407
+ iou_threshold: Minimum IoU for a valid recovery match.
408
+ emb_cost: Embedding similarity matrix, shape (N, M). None to
409
+ disable appearance in OCR.
410
+
411
+ Returns:
412
+ Tuple of:
413
+ matched: Matched pairs, shape (K, 2).
414
+ still_unmatched_dets: Remaining unmatched detection indices.
415
+ still_unmatched_trks: Remaining unmatched track indices.
416
+ """
417
+ num_dets = unmatched_detections.shape[0]
418
+ num_trks = unmatched_track_last_obs.shape[0]
419
+
420
+ if num_dets == 0 or num_trks == 0:
421
+ return (
422
+ np.empty((0, 2), dtype=int),
423
+ np.arange(num_dets, dtype=int),
424
+ np.arange(num_trks, dtype=int),
425
+ )
426
+
427
+ iou_matrix = iou_batch(unmatched_detections, unmatched_track_last_obs)
428
+
429
+ # Add embedding cost if available
430
+ score_matrix = iou_matrix
431
+ if emb_cost is not None:
432
+ score_matrix = iou_matrix + emb_cost
433
+
434
+ matched_indices = linear_assignment(-score_matrix)
435
+
436
+ matched_det_indices = set(matched_indices[:, 0]) if matched_indices.size > 0 else set()
437
+ matched_trk_indices = set(matched_indices[:, 1]) if matched_indices.size > 0 else set()
438
+
439
+ still_unmatched_dets = np.array(
440
+ [d for d in range(num_dets) if d not in matched_det_indices], dtype=int
441
+ )
442
+ still_unmatched_trks = np.array(
443
+ [t for t in range(num_trks) if t not in matched_trk_indices], dtype=int
444
+ )
445
+
446
+ valid_matches = []
447
+ for det_idx, trk_idx in matched_indices:
448
+ if iou_matrix[det_idx, trk_idx] < iou_threshold:
449
+ still_unmatched_dets = np.append(still_unmatched_dets, det_idx)
450
+ still_unmatched_trks = np.append(still_unmatched_trks, trk_idx)
451
+ else:
452
+ valid_matches.append([det_idx, trk_idx])
453
+
454
+ if len(valid_matches) == 0:
455
+ matched = np.empty((0, 2), dtype=int)
456
+ else:
457
+ matched = np.array(valid_matches, dtype=int)
458
+
459
+ return matched, still_unmatched_dets, still_unmatched_trks
460
+
461
+
462
+ def associate_byte_second(
463
+ detections_low: np.ndarray,
464
+ track_predictions: np.ndarray,
465
+ iou_threshold: float,
466
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
467
+ """BYTE second-stage: match low-confidence detections to unmatched tracks.
468
+
469
+ Uses IoU only (no OCM, no appearance features) since low-confidence
470
+ detections have unreliable scores and crops. This conservative matching
471
+ helps recover occluded objects that produce weak detections.
472
+
473
+ Args:
474
+ detections_low: Low-confidence detection bboxes, shape (N, 4)
475
+ in [x1, y1, x2, y2].
476
+ track_predictions: Predicted bboxes for unmatched tracks,
477
+ shape (M, 4) in [x1, y1, x2, y2].
478
+ iou_threshold: Minimum IoU for a valid match. Typically lower
479
+ than first-stage threshold (e.g. 0.5).
480
+
481
+ Returns:
482
+ Tuple of:
483
+ matched: Matched pairs, shape (K, 2) as
484
+ [detection_idx, track_idx].
485
+ unmatched_dets: Unmatched low-conf detection indices.
486
+ unmatched_trks: Unmatched track indices.
487
+ """
488
+ num_dets = detections_low.shape[0]
489
+ num_trks = track_predictions.shape[0]
490
+
491
+ if num_dets == 0 or num_trks == 0:
492
+ return (
493
+ np.empty((0, 2), dtype=int),
494
+ np.arange(num_dets, dtype=int),
495
+ np.arange(num_trks, dtype=int),
496
+ )
497
+
498
+ iou_matrix = iou_batch(detections_low, track_predictions)
499
+
500
+ matched_indices = linear_assignment(-iou_matrix)
501
+
502
+ matched_det_indices = (
503
+ set(matched_indices[:, 0]) if matched_indices.size > 0 else set()
504
+ )
505
+ matched_trk_indices = (
506
+ set(matched_indices[:, 1]) if matched_indices.size > 0 else set()
507
+ )
508
+
509
+ unmatched_dets = np.array(
510
+ [d for d in range(num_dets) if d not in matched_det_indices], dtype=int
511
+ )
512
+ unmatched_trks = np.array(
513
+ [t for t in range(num_trks) if t not in matched_trk_indices], dtype=int
514
+ )
515
+
516
+ valid_matches = []
517
+ for det_idx, trk_idx in matched_indices:
518
+ if iou_matrix[det_idx, trk_idx] < iou_threshold:
519
+ unmatched_dets = np.append(unmatched_dets, det_idx)
520
+ unmatched_trks = np.append(unmatched_trks, trk_idx)
521
+ else:
522
+ valid_matches.append([det_idx, trk_idx])
523
+
524
+ if len(valid_matches) == 0:
525
+ matched = np.empty((0, 2), dtype=int)
526
+ else:
527
+ matched = np.array(valid_matches, dtype=int)
528
+
529
+ return matched, unmatched_dets, unmatched_trks