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 assignment

  • auction() for iterative bidding

  • murty() for ranked assignments

  • jpda() 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