Assignment Algorithms
This example demonstrates various assignment algorithms for data association.
Overview
Assignment algorithms solve the measurement-to-track association problem:
2D Assignment: One-to-one matching (Hungarian, Auction)
Multi-dimensional: S-D assignment for multi-sensor fusion
K-best: Finding multiple good assignments (Murty’s algorithm)
Algorithms
- Hungarian Algorithm (Kuhn-Munkres)
Optimal O(n³) solution for 2D assignment
Guaranteed minimum cost assignment
- Auction Algorithm
Bertsekas’ auction-based approach
Good for sparse cost matrices
Assignment in Action: The result of optimal assignment showing tracks correctly associated with measurements over time.
- Global Nearest Neighbor (GNN)
Fast suboptimal assignment
Uses gating for efficiency
- JPDA
Joint Probabilistic Data Association
Maintains association probabilities
- Murty’s K-best
Finds k best assignments
Used in MHT hypothesis management
Multi-Sensor Fusion: S-D assignment extends 2D methods to fuse measurements from multiple sensors.
Key Concepts
Cost matrix: Negative log-likelihood of associations
Gating: Statistical test to reduce candidates
Dummy assignments: Handling missed detections and clutter
Code Highlights
The example demonstrates:
Constructing cost matrices from track-measurement distances
Using
hungarian()for optimal assignmentauction()for iterative biddingmurty()for ranked assignmentsjpda()for association probabilities
Source Code
1"""
2Assignment Algorithms Example
3=============================
4
5This example demonstrates the assignment algorithms in PyTCL:
6
72D Assignment:
8- Hungarian algorithm (optimal)
9- Auction algorithm
10- Linear sum assignment wrapper
11
12K-Best 2D Assignment (v0.17.0):
13- Murty's algorithm for finding k-best assignments
14- Ranked assignment enumeration with cost thresholds
15
163D Assignment (v0.17.0):
17- Lagrangian relaxation
18- Auction-based 3D assignment
19- Greedy assignment
20- 2D decomposition method
21
22Data Association:
23- Global Nearest Neighbor (GNN)
24- Gating (ellipsoidal and rectangular)
25- JPDA (Joint Probabilistic Data Association)
26
27These algorithms are fundamental for multi-target tracking, where
28measurements must be assigned to tracks optimally.
29"""
30
31import os
32from pathlib import Path
33
34import numpy as np
35import plotly.graph_objects as go
36from plotly.subplots import make_subplots
37
38from pytcl.assignment_algorithms import ( # 2D Assignment
39 assign2d,
40 assign3d,
41 assign3d_auction,
42 assign3d_lagrangian,
43 auction,
44 chi2_gate_threshold,
45 compute_association_cost,
46 decompose_to_2d,
47 ellipsoidal_gate,
48 gated_gnn_association,
49 gnn_association,
50 greedy_3d,
51 hungarian,
52 jpda,
53 kbest_assign2d,
54 mahalanobis_distance,
55 murty,
56 ranked_assignments,
57 rectangular_gate,
58)
59
60SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
61OUTPUT_DIR = Path(__file__).resolve().parent / "output"
62
63
64def demo_2d_assignment():
65 """Demonstrate basic 2D assignment algorithms."""
66 print("=" * 70)
67 print("2D Assignment Algorithms Demo")
68 print("=" * 70)
69
70 # Create a cost matrix: 4 tracks, 5 measurements
71 # Lower cost = better match
72 np.random.seed(42)
73 cost = np.array(
74 [
75 [10, 5, 13, 4, 8], # Track 0: best match is measurement 3
76 [3, 15, 8, 12, 6], # Track 1: best match is measurement 0
77 [12, 7, 9, 5, 11], # Track 2: best match is measurement 3 or 1
78 [8, 6, 4, 10, 3], # Track 3: best match is measurement 4
79 ],
80 dtype=float,
81 )
82
83 print("\nCost matrix (4 tracks x 5 measurements):")
84 print(cost)
85
86 # Hungarian algorithm (optimal)
87 row_h, col_h, cost_h = hungarian(cost)
88 print("\n--- Hungarian Algorithm (Optimal) ---")
89 print(f"Assignments: {list(zip(row_h, col_h))}")
90 print(f"Total cost: {cost_h}")
91
92 # Auction algorithm
93 row_a, col_a, cost_a = auction(cost)
94 print("\n--- Auction Algorithm ---")
95 print(f"Assignments: {list(zip(row_a, col_a))}")
96 print(f"Total cost: {cost_a}")
97
98 # Using assign2d unified interface
99 result = assign2d(cost)
100 print("\n--- assign2d() Interface ---")
101 print(f"Row indices: {result.row_indices}")
102 print(f"Col indices: {result.col_indices}")
103 print(f"Total cost: {result.cost}")
104
105 # Rectangular (non-square) assignment
106 print("\n--- Rectangular Assignment ---")
107 rect_cost = np.random.rand(3, 6) * 10 # 3 tracks, 6 measurements
108 row_rect, col_rect, cost_rect = hungarian(rect_cost)
109 print("3 tracks assigned to 6 measurements")
110 print(f"Assignments: {list(zip(row_rect, col_rect))}")
111 assigned_cols = set(col_rect)
112 unassigned_cols = [i for i in range(6) if i not in assigned_cols]
113 print(f"Unassigned measurements: {unassigned_cols}")
114
115 # With cost of non-assignment (allows skipping bad matches)
116 print("\n--- With Cost of Non-Assignment ---")
117 cost_with_skip = np.array(
118 [
119 [10, 100, 100],
120 [100, 5, 100],
121 [100, 100, 100], # Track 2 has no good matches
122 ],
123 dtype=float,
124 )
125 result_skip = assign2d(cost_with_skip, cost_of_non_assignment=20)
126 print("Cost matrix with one track having no good matches:")
127 print(cost_with_skip)
128 print(f"Assignments: {list(zip(result_skip.row_indices, result_skip.col_indices))}")
129 print(f"Unassigned rows: {list(result_skip.unassigned_rows)}")
130
131
132def demo_kbest_assignment():
133 """Demonstrate k-best 2D assignment (Murty's algorithm)."""
134 print("\n" + "=" * 70)
135 print("K-Best 2D Assignment Demo (Murty's Algorithm)")
136 print("=" * 70)
137
138 # Cost matrix
139 cost = np.array(
140 [
141 [10, 5, 13],
142 [3, 15, 8],
143 [12, 7, 9],
144 ],
145 dtype=float,
146 )
147
148 print("\nCost matrix (3x3):")
149 print(cost)
150
151 # Find k best assignments
152 k = 5
153 result = murty(cost, k=k)
154
155 print(f"\n--- Finding {k} Best Assignments ---")
156 print(f"Found: {result.n_found} assignments")
157
158 for i, (assignment, cost_val) in enumerate(zip(result.assignments, result.costs)):
159 row_ind = assignment.row_indices
160 col_ind = assignment.col_indices
161 print(f"\n Solution {i + 1} (cost={cost_val:.1f}):")
162 print(f" Assignments: {list(zip(row_ind, col_ind))}")
163
164 # With cost threshold
165 print("\n--- With Cost Threshold ---")
166 result_thresh = kbest_assign2d(cost, k=10, cost_threshold=20)
167 print(f"Assignments with cost <= 20: {result_thresh.n_found}")
168 for i, c in enumerate(result_thresh.costs):
169 print(f" Solution {i + 1}: cost={c:.1f}")
170
171 # Ranked assignments (convenience function)
172 print("\n--- Ranked Assignment Enumeration ---")
173 ranked = ranked_assignments(cost, max_assignments=6)
174 print(f"Enumerated {ranked.n_found} assignments in order of increasing cost")
175 print(f"Cost range: [{ranked.costs[0]:.1f}, {ranked.costs[-1]:.1f}]")
176
177
178def demo_3d_assignment():
179 """Demonstrate 3D assignment algorithms."""
180 print("\n" + "=" * 70)
181 print("3D Assignment Algorithms Demo")
182 print("=" * 70)
183
184 # 3D assignment: associate measurements across 3 scans
185 # Cost tensor: cost[i, j, k] = cost of associating
186 # measurement i from scan 1, j from scan 2, k from scan 3
187 np.random.seed(42)
188 n = 5 # 5 measurements per scan
189 cost = np.random.rand(n, n, n) * 10
190
191 # Add some low-cost "true" associations on the diagonal
192 for i in range(n):
193 cost[i, i, i] = np.random.rand() * 0.5
194
195 print(f"\nCost tensor shape: {cost.shape}")
196 print("(5 measurements per scan, 3 scans)")
197
198 # Greedy algorithm (fast but suboptimal)
199 print("\n--- Greedy Algorithm ---")
200 result_greedy = greedy_3d(cost)
201 print(f"Assignments found: {result_greedy.tuples.shape[0]}")
202 print(f"Total cost: {result_greedy.cost:.3f}")
203
204 # 2D decomposition method
205 print("\n--- 2D Decomposition Method ---")
206 result_decomp = decompose_to_2d(cost)
207 print(f"Assignments found: {result_decomp.tuples.shape[0]}")
208 print(f"Total cost: {result_decomp.cost:.3f}")
209
210 # Lagrangian relaxation (better quality)
211 print("\n--- Lagrangian Relaxation ---")
212 result_lagr = assign3d_lagrangian(cost, max_iter=100, tol=0.01)
213 print(f"Assignments found: {result_lagr.tuples.shape[0]}")
214 print(f"Total cost: {result_lagr.cost:.3f}")
215 print(f"Iterations: {result_lagr.n_iterations}")
216 print(f"Duality gap: {result_lagr.gap:.4f}")
217
218 # Auction algorithm
219 print("\n--- Auction Algorithm ---")
220 result_auct = assign3d_auction(cost, max_iter=500)
221 print(f"Assignments found: {result_auct.tuples.shape[0]}")
222 print(f"Total cost: {result_auct.cost:.3f}")
223 print(f"Iterations: {result_auct.n_iterations}")
224
225 # Unified interface
226 print("\n--- Unified assign3d() Interface ---")
227 for method in ["greedy", "decompose", "lagrangian"]:
228 result = assign3d(cost, method=method)
229 print(
230 f" {method:12s}: cost={result.cost:.3f}, "
231 f"assignments={result.tuples.shape[0]}"
232 )
233
234 # Show the actual assignments from the best method
235 print("\n--- Best Solution Details ---")
236 best = result_lagr
237 print("Assignment tuples (scan1, scan2, scan3):")
238 for row in best.tuples:
239 i, j, k = row
240 print(f" ({i}, {j}, {k}) -> cost={cost[i, j, k]:.3f}")
241
242
243def demo_gating():
244 """Demonstrate measurement gating."""
245 print("\n" + "=" * 70)
246 print("Measurement Gating Demo")
247 print("=" * 70)
248
249 # Track predicted state and covariance
250 track_pred = np.array([10.0, 20.0]) # 2D position
251 innovation_cov = np.array([[4.0, 0.5], [0.5, 2.0]]) # Innovation covariance
252
253 # Measurements (some close, some far)
254 measurements = np.array(
255 [
256 [10.5, 20.2], # Very close
257 [11.0, 19.5], # Close
258 [12.5, 22.0], # Moderate
259 [8.0, 18.0], # Moderate
260 [20.0, 30.0], # Far
261 [5.0, 25.0], # Far
262 ]
263 )
264
265 print(f"\nTrack predicted position: {track_pred}")
266 print(f"Innovation covariance:\n{innovation_cov}")
267 print(f"\nMeasurements:\n{measurements}")
268
269 # Compute Mahalanobis distances
270 print("\n--- Mahalanobis Distances ---")
271 for i, meas in enumerate(measurements):
272 innovation = meas - track_pred
273 dist = mahalanobis_distance(innovation, innovation_cov)
274 print(f" Measurement {i}: distance = {dist:.3f}")
275
276 # Chi-squared gate threshold
277 n_dims = 2
278 gate_prob = 0.99
279 threshold = chi2_gate_threshold(gate_prob, n_dims)
280 print(f"\nGate threshold (99% probability, 2D): {threshold:.3f}")
281
282 # Ellipsoidal gating
283 print("\n--- Ellipsoidal Gating ---")
284 gated_indices = []
285 for i, meas in enumerate(measurements):
286 innovation = meas - track_pred
287 in_gate = ellipsoidal_gate(innovation, innovation_cov, threshold)
288 status = "PASS" if in_gate else "FAIL"
289 if in_gate:
290 gated_indices.append(i)
291 print(f" Measurement {i}: {status}")
292 print(f"Gated measurement indices: {gated_indices}")
293
294 # Rectangular gating (simpler, less accurate)
295 print("\n--- Rectangular Gating ---")
296 for i, meas in enumerate(measurements):
297 innovation = meas - track_pred
298 in_gate = rectangular_gate(innovation, innovation_cov, num_sigmas=3.0)
299 status = "PASS" if in_gate else "FAIL"
300 print(f" Measurement {i}: {status}")
301
302
303def demo_gnn_association():
304 """Demonstrate Global Nearest Neighbor association."""
305 print("\n" + "=" * 70)
306 print("Global Nearest Neighbor (GNN) Association Demo")
307 print("=" * 70)
308
309 # Scenario: 3 tracks, 4 measurements
310 np.random.seed(42)
311
312 # Track predicted positions (2D)
313 track_preds = np.array(
314 [
315 [10.0, 20.0],
316 [30.0, 40.0],
317 [50.0, 60.0],
318 ]
319 )
320
321 # Track covariances (stacked)
322 track_covs = np.array([np.eye(2) * 4.0 for _ in range(3)])
323
324 # Measurements (3 close to tracks, 1 false alarm)
325 measurements = np.array(
326 [
327 [10.5, 19.8], # Close to track 0
328 [30.2, 40.5], # Close to track 1
329 [49.5, 60.2], # Close to track 2
330 [100.0, 100.0], # False alarm (far from all tracks)
331 ]
332 )
333
334 print("Track positions:")
335 for i, pred in enumerate(track_preds):
336 print(f" Track {i}: {pred}")
337
338 print("\nMeasurements:")
339 for i, meas in enumerate(measurements):
340 print(f" Measurement {i}: {meas}")
341
342 # Compute cost matrix
343 print("\n--- Computing Cost Matrix ---")
344 cost_matrix = compute_association_cost(
345 track_preds,
346 track_covs,
347 measurements,
348 )
349 print("Cost matrix (tracks x measurements):")
350 print(np.array2string(cost_matrix, precision=2, suppress_small=True))
351
352 # GNN association using the cost matrix
353 print("\n--- GNN Association ---")
354 gate_threshold = chi2_gate_threshold(0.99, 2)
355 result = gnn_association(cost_matrix, gate_threshold=gate_threshold)
356
357 print(f"Track -> Measurement mapping: {list(result.track_to_measurement)}")
358 print(f"Measurement -> Track mapping: {list(result.measurement_to_track)}")
359 print(f"Total cost: {result.total_cost:.3f}")
360
361 # Interpret results
362 print("\nInterpretation:")
363 for track_idx, meas_idx in enumerate(result.track_to_measurement):
364 if meas_idx >= 0:
365 print(f" Track {track_idx} <- Measurement {meas_idx}")
366 else:
367 print(f" Track {track_idx} <- (no measurement)")
368
369 unassigned_meas = [i for i, t in enumerate(result.measurement_to_track) if t < 0]
370 if unassigned_meas:
371 print(f" Unassigned measurements (false alarms): {unassigned_meas}")
372
373 # Gated GNN (combined gating + association)
374 print("\n--- Gated GNN Association ---")
375 result_gated = gated_gnn_association(
376 track_preds,
377 track_covs,
378 measurements,
379 gate_probability=0.99,
380 )
381 print(f"Track -> Measurement: {list(result_gated.track_to_measurement)}")
382 print(f"Total cost: {result_gated.total_cost:.3f}")
383
384
385def demo_jpda():
386 """Demonstrate Joint Probabilistic Data Association."""
387 print("\n" + "=" * 70)
388 print("JPDA (Joint Probabilistic Data Association) Demo")
389 print("=" * 70)
390
391 # Scenario: 2 closely-spaced tracks with ambiguous measurements
392 track_states = [
393 np.array([10.0, 0.0, 20.0, 0.0]), # [x, vx, y, vy]
394 np.array([12.0, 0.0, 21.0, 0.0]), # Close to track 0
395 ]
396
397 track_covs = [np.diag([2.0, 0.1, 2.0, 0.1]) for _ in range(2)]
398
399 # Measurements in the ambiguous region
400 measurements = np.array(
401 [
402 [10.5, 20.2], # Could belong to track 0 or 1
403 [11.5, 20.8], # Could belong to track 0 or 1
404 [50.0, 50.0], # False alarm
405 ]
406 )
407
408 # Measurement model: H extracts [x, y] from state
409 H = np.array(
410 [
411 [1, 0, 0, 0],
412 [0, 0, 1, 0],
413 ]
414 )
415 R = np.eye(2) * 1.0
416
417 print("Track positions (closely spaced):")
418 for i, state in enumerate(track_states):
419 print(f" Track {i}: position=({state[0]:.1f}, {state[2]:.1f})")
420
421 print("\nMeasurements:")
422 for i, meas in enumerate(measurements):
423 print(f" Measurement {i}: {meas}")
424
425 # JPDA computes association probabilities
426 print("\n--- JPDA Association Probabilities ---")
427 result = jpda(
428 track_states,
429 track_covs,
430 measurements,
431 H=H,
432 R=R,
433 detection_prob=0.9,
434 clutter_density=1e-6,
435 gate_probability=0.99,
436 )
437
438 print("Association probability matrix (tracks x [measurements..., no-detect]):")
439 print(" Rows: tracks, Columns: [meas 0, meas 1, meas 2, no-detection]")
440 print(np.array2string(result.association_probs, precision=3, suppress_small=True))
441
442 print("\nInterpretation:")
443 n_tracks, n_cols = result.association_probs.shape
444 n_meas = n_cols - 1 # Last column is no-detection probability
445 for i in range(n_tracks):
446 print(f" Track {i}:")
447 for j in range(n_meas):
448 prob = result.association_probs[i, j]
449 if prob > 0.01: # Only show significant probabilities
450 print(f" P(measurement {j}) = {prob:.3f}")
451 print(f" P(no detection) = {result.association_probs[i, -1]:.3f}")
452
453
454def demo_tracking_scenario():
455 """Demonstrate a complete tracking scenario."""
456 print("\n" + "=" * 70)
457 print("Complete Tracking Scenario Demo")
458 print("=" * 70)
459
460 np.random.seed(42)
461
462 # Simulation: 3 targets, 10 time steps
463 n_targets = 3
464 n_steps = 10
465
466 # Initial target positions
467 targets = np.array(
468 [
469 [0.0, 0.0],
470 [50.0, 0.0],
471 [25.0, 43.3], # Equilateral triangle
472 ]
473 )
474
475 # Target velocities
476 velocities = np.array(
477 [
478 [2.0, 1.0],
479 [-1.0, 2.0],
480 [0.0, -1.5],
481 ]
482 )
483
484 print(f"Simulating {n_targets} targets over {n_steps} time steps")
485 print("Initial positions:", targets.tolist())
486
487 # Track states (initially equal to true positions)
488 track_states = targets.copy()
489 track_covs = np.array([np.eye(2) * 10.0 for _ in range(n_targets)])
490
491 # Measurement noise
492 meas_std = 2.0
493
494 # Run simulation
495 assignment_history = []
496 for t in range(n_steps):
497 # Move targets
498 targets = targets + velocities
499
500 # Generate measurements (with some noise and false alarms)
501 measurements = targets + np.random.randn(n_targets, 2) * meas_std
502
503 # Add false alarms
504 n_false = np.random.poisson(1) # Average 1 false alarm per scan
505 if n_false > 0:
506 false_alarms = np.random.rand(n_false, 2) * 100 - 25
507 measurements = np.vstack([measurements, false_alarms])
508
509 # Use gated_gnn_association which handles gating internally
510 result = gated_gnn_association(
511 track_states,
512 track_covs,
513 measurements,
514 gate_probability=0.99,
515 )
516
517 # Use track_to_measurement directly
518 track_to_meas = list(result.track_to_measurement)
519 assignment_history.append(track_to_meas)
520
521 # Update track states (simple: just use measurement if assigned)
522 for i, meas_idx in enumerate(track_to_meas):
523 if meas_idx >= 0:
524 # Blend prediction with measurement
525 alpha = 0.7 # Measurement weight
526 track_states[i] = alpha * measurements[meas_idx] + (1 - alpha) * (
527 track_states[i] + velocities[i]
528 )
529 else:
530 # No measurement, just predict
531 track_states[i] = track_states[i] + velocities[i]
532
533 print("\nAssignment history (track -> measurement index):")
534 for t, assignments in enumerate(assignment_history):
535 print(f" Step {t}: {assignments}")
536
537 print("\nFinal track positions:")
538 for i, state in enumerate(track_states):
539 true_pos = targets[i]
540 error = np.linalg.norm(state - true_pos)
541 print(f" Track {i}: {state} (error: {error:.2f})")
542
543
544def main():
545 """Run all demonstrations."""
546 print("\n" + "#" * 70)
547 print("# PyTCL Assignment Algorithms Example")
548 print("#" * 70)
549
550 # Basic 2D assignment
551 demo_2d_assignment()
552
553 # K-best assignment (Murty)
554 demo_kbest_assignment()
555
556 # 3D assignment
557 demo_3d_assignment()
558
559 # Gating
560 demo_gating()
561
562 # Data association
563 demo_gnn_association()
564
565 # JPDA
566 demo_jpda()
567
568 # Complete scenario
569 demo_tracking_scenario()
570
571 # Visualization
572 visualize_assignment_problem()
573
574 print("\n" + "=" * 70)
575 print("Example complete!")
576 print("=" * 70)
577
578
579def visualize_assignment_problem():
580 """Visualize a 2D assignment problem with cost heatmap."""
581 print("\nGenerating cost matrix visualization...")
582
583 # Create a cost matrix
584 np.random.seed(42)
585 n_tracks = 5
586 n_measurements = 6
587 cost = np.random.randint(1, 20, (n_tracks, n_measurements)).astype(float)
588
589 # Solve assignment
590 track_indices, measurement_indices, assignment_cost = hungarian(cost)
591
592 # Create heatmap
593 fig = go.Figure(data=go.Heatmap(z=cost, colorscale="Viridis"))
594
595 # Add assignment lines
596 for t_idx, m_idx in zip(track_indices, measurement_indices):
597 fig.add_annotation(
598 x=m_idx,
599 y=t_idx,
600 text="OK",
601 showarrow=False,
602 font=dict(color="red", size=16),
603 )
604
605 fig.update_layout(
606 title="2D Assignment Problem: Cost Matrix with Optimal Assignments",
607 xaxis_title="Measurement Index",
608 yaxis_title="Track Index",
609 height=400,
610 width=600,
611 )
612
613 if SHOW_PLOTS:
614 fig.show()
615 else:
616 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
617 fig.write_html(
618 str(OUTPUT_DIR / "assignment_algorithms.html"),
619 include_plotlyjs="cdn",
620 div_id="assignment_algorithms",
621 )
622
623
624if __name__ == "__main__":
625 main()
Running the Example
python examples/assignment_algorithms.py
See Also
Multi-Target Tracking - Using assignment in MTT
Performance Evaluation - Evaluating association quality