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 +23 -0
- arcsort/association.py +529 -0
- arcsort/cmc.py +169 -0
- arcsort/configs/__init__.py +35 -0
- arcsort/configs/dancetrack.yaml +74 -0
- arcsort/configs/sportsmot.yaml +80 -0
- arcsort/configs/sportsmot_deepeiou_inputs.yaml +68 -0
- arcsort/detection.py +367 -0
- arcsort/imm_kalman_filter.py +977 -0
- arcsort/interop.py +42 -0
- arcsort/kalman_filter.py +347 -0
- arcsort/matching.py +484 -0
- arcsort/ocsort_components.py +160 -0
- arcsort/reid.py +326 -0
- arcsort/track.py +237 -0
- arcsort/tracker.py +1181 -0
- arcsort/utils.py +141 -0
- arcsort/visualizer.py +109 -0
- arcsort-0.1.0.dist-info/METADATA +301 -0
- arcsort-0.1.0.dist-info/RECORD +25 -0
- arcsort-0.1.0.dist-info/WHEEL +5 -0
- arcsort-0.1.0.dist-info/licenses/LICENSE +202 -0
- arcsort-0.1.0.dist-info/licenses/NOTICE +17 -0
- arcsort-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +49 -0
- arcsort-0.1.0.dist-info/top_level.txt +1 -0
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
|