11from collections import defaultdict
22from dataclasses import dataclass
3- from typing import Any , List , Optional , Tuple
3+ from typing import Any , List , Optional , Tuple , Set
44
55import networkx as nx
66import numpy as np
1010
1111@dataclass (frozen = True )
1212class TrackNode :
13- """
14- Represents a detection node in the tracking graph.
15-
16- Attributes:
17- frame_id (int): Frame index where detection occurred.
18- det_idx (int): Detection index in the frame.
19- class_id (int): Class ID of the detection.
20- position (tuple): Center position of the detection.
21- bbox (np.ndarray): Bounding box coordinates.
22- confidence (float): Detection confidence score.
23- """
24-
2513 frame_id : int
2614 det_idx : int
2715 class_id : int
@@ -43,11 +31,6 @@ def __str__(self):
4331
4432
4533class KSPSolver :
46- """
47- Solver for the K-Shortest Paths (KSP) tracking problem.
48- Builds a graph from detections and extracts multiple disjoint paths.
49- """
50-
5134 def __init__ (
5235 self ,
5336 path_overlap_penalty : float = 40 ,
@@ -56,17 +39,7 @@ def __init__(
5639 size_weight : float = 0.1 ,
5740 conf_weight : float = 0.1 ,
5841 ):
59- """
60- Initialize the KSPSolver.
61-
62- Args:
63- path_overlap_penalty (float): Penalty for edge reuse in successive paths.
64- iou_weight (float): Weight for IoU penalty.
65- dist_weight (float): Weight for center distance.
66- size_weight (float): Weight for size penalty.
67- conf_weight (float): Weight for confidence penalty.
68- """
69- self .path_overlap_penalty = 40
42+ self .path_overlap_penalty = path_overlap_penalty if path_overlap_penalty is not None else 40
7043 self .weight_key = "weight"
7144 self .source = "SOURCE"
7245 self .sink = "SINK"
@@ -84,65 +57,80 @@ def __init__(
8457 if conf_weight is not None :
8558 self .weights ["conf" ] = conf_weight
8659
60+ # Entry/exit region settings
61+ self .entry_exit_regions : List [Tuple [int , int , int , int ]] = [] # (x1, y1, x2, y2)
62+
63+ # Border region settings
64+ self .use_border_regions = True
65+ self .active_borders : Set [str ] = {"left" , "right" , "top" , "bottom" }
66+ self .border_margin = 40
67+ self .frame_size = (1920 , 1080 )
68+
8769 self .reset ()
8870
8971 def reset (self ) -> None :
90- """
91- Reset the solver state and clear all detections and graph.
92- This clears the detection buffer and initializes a new empty graph.
93- """
9472 self .detection_per_frame = []
9573 self .graph = nx .DiGraph ()
9674
9775 def append_frame (self , detections : sv .Detections ) -> None :
98- """
99- Add detections for a new frame to the buffer.
100-
101- Args:
102- detections (sv.Detections): Detections for the frame.
103- """
10476 self .detection_per_frame .append (detections )
10577
10678 def _get_center (self , bbox : np .ndarray ) -> np .ndarray :
79+ x1 , y1 , x2 , y2 = bbox
80+ return np .array ([(x1 + x2 ) / 2 , (y1 + y2 ) / 2 ])
81+
82+ def set_entry_exit_regions (self , regions : List [Tuple [int , int , int , int ]]) -> None :
83+ """
84+ Set rectangular entry/exit zones (x1, y1, x2, y2).
10785 """
108- Compute the center of a bounding box.
86+ self . entry_exit_regions = regions
10987
110- Args:
111- bbox (np.ndarray): Bounding box coordinates (x1, y1, x2, y2).
88+ def set_border_entry_exit (
89+ self ,
90+ use_border : Optional [bool ] = True ,
91+ borders : Optional [Set [str ]] = None ,
92+ margin : Optional [int ] = 40 ,
93+ frame_size : Optional [Tuple [int , int ]] = (1920 , 1080 ),
94+ ) -> None :
95+ """
96+ Configure border-based entry/exit zones.
11297
113- Returns:
114- np.ndarray: Center coordinates (x, y).
98+ Args:
99+ use_border (bool): Enable/disable border-based entry/exit.
100+ borders (set): Set of borders to use. {"left", "right", "top", "bottom"}
101+ margin (int): Border thickness in pixels.
102+ frame_size (Tuple[int, int]): Size of the image (width, height).
115103 """
116- x1 , y1 , x2 , y2 = bbox
117- return np .array ([(x1 + x2 ) / 2 , (y1 + y2 ) / 2 ])
104+ self .use_border_regions = use_border
105+ self .active_borders = borders if borders is not None else {"left" , "right" , "top" , "bottom" }
106+ self .border_margin = margin
107+ self .frame_size = frame_size
118108
119- def _in_door (self , node : TrackNode ):
109+ def _in_door (self , node : TrackNode ) -> bool :
120110 x , y = node .position
121- width , height = (1920 , 1080 )
122-
123- border_margin = 40
124- in_border = (
125- x <= border_margin
126- or x >= width - border_margin
127- or y <= border_margin
128- or y >= height - border_margin
129- )
130111
131- return in_border
112+ # Check custom rectangular regions
113+ for x1 , y1 , x2 , y2 in self .entry_exit_regions :
114+ if x1 <= x <= x2 and y1 <= y <= y2 :
115+ return True
132116
133- def _edge_cost (self , nodeU : TrackNode , nodeV : TrackNode ) -> float :
134- """
135- Compute the cost of connecting two detections (nodes) in the graph.
136- The cost is a weighted sum of IoU penalty, center distance,
137- size penalty, and confidence penalty.
117+ # Check image border zones
118+ if self .use_border_regions :
119+ width , height = self .frame_size
120+ m = self .border_margin
138121
139- Args:
140- nodeU (TrackNode): Source node.
141- nodeV (TrackNode): Target node.
122+ if "left" in self .active_borders and x <= m :
123+ return True
124+ if "right" in self .active_borders and x >= width - m :
125+ return True
126+ if "top" in self .active_borders and y <= m :
127+ return True
128+ if "bottom" in self .active_borders and y >= height - m :
129+ return True
142130
143- Returns:
144- float: Edge cost.
145- """
131+ return False
132+
133+ def _edge_cost ( self , nodeU : TrackNode , nodeV : TrackNode ) -> float :
146134 bboxU , bboxV = nodeU .bbox , nodeV .bbox
147135 conf_u , conf_v = nodeU .confidence , nodeV .confidence
148136
@@ -151,9 +139,7 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float:
151139
152140 area_a = (bboxU [2 ] - bboxU [0 ]) * (bboxU [3 ] - bboxU [1 ])
153141 area_b = (bboxV [2 ] - bboxV [0 ]) * (bboxV [3 ] - bboxV [1 ])
154- size_penalty = np .log (
155- (max (area_a , area_b ) / (min (area_a , area_b ) + 1e-6 )) + 1e-6
156- )
142+ size_penalty = np .log ((max (area_a , area_b ) / (min (area_a , area_b ) + 1e-6 )) + 1e-6 )
157143
158144 conf_penalty = 1 - min (conf_u , conf_v )
159145
@@ -165,10 +151,6 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float:
165151 )
166152
167153 def _build_graph (self ):
168- """
169- Build the tracking graph from all buffered detections.
170- Each detection is a node, and edges connect detections in consecutive frames.
171- """
172154 G = nx .DiGraph ()
173155 G .add_node (self .source )
174156 G .add_node (self .sink )
@@ -193,10 +175,8 @@ def _build_graph(self):
193175 for t in range (len (node_frames ) - 1 ):
194176 for node_a in node_frames [t ]:
195177 if self ._in_door (node_a ):
196- G .add_edge (self .source , node_a , weight = (t ) * 2 )
197- G .add_edge (
198- node_a , self .sink , weight = ((len (node_frames ) - 1 ) - (t )) * 2
199- )
178+ G .add_edge (self .source , node_a , weight = t * 2 )
179+ G .add_edge (node_a , self .sink , weight = (len (node_frames ) - 1 - t ) * 2 )
200180
201181 for node_b in node_frames [t + 1 ]:
202182 cost = self ._edge_cost (node_a , node_b )
@@ -209,22 +189,7 @@ def _build_graph(self):
209189
210190 self .graph = G
211191
212- def solve (
213- self ,
214- k : Optional [int ] = None ,
215- ) -> List [List [TrackNode ]]:
216- """
217- Extract up to k node-disjoint shortest paths from the graph using a
218- successive shortest path approach.
219-
220- Args:
221- k (Optional[int]): Maximum number of paths to extract. If None,
222- uses the maximum number of detections in any frame.
223-
224- Returns:
225- List[List[TrackNode]]: List of node-disjoint paths (tracks),
226- each path is a list of TrackNode objects.
227- """
192+ def solve (self , k : Optional [int ] = None ) -> List [List [TrackNode ]]:
228193 self ._build_graph ()
229194
230195 G_base = self .graph .copy ()
@@ -237,32 +202,23 @@ def solve(
237202 for _i in tqdm (range (k ), desc = "Extracting k-shortest paths" , leave = True ):
238203 G_mod = G_base .copy ()
239204
240- # Update edge weights to penalize reused edges
241205 for u , v , data in G_mod .edges (data = True ):
242206 base = data [self .weight_key ]
243207 penalty = self .path_overlap_penalty * 1000 * edge_reuse [(u , v )] * base
244208 data [self .weight_key ] = base + penalty
245209
246210 try :
247- # Find shortest path from source to sink
248- _ , path = nx .single_source_dijkstra (
249- G_mod , self .source , self .sink , weight = self .weight_key
250- )
211+ _ , path = nx .single_source_dijkstra (G_mod , self .source , self .sink , weight = self .weight_key )
251212 except nx .NetworkXNoPath :
252213 print (f"No path found from source to sink at { _i } th iteration" )
253214 break
254215
255- # Check for duplicate paths
256216 if path [1 :- 1 ] in paths :
257217 print ("Duplicate path found!" )
258- # NOTE: Changed to continue for debugging to extrapolate the
259- # track detects to investigate the reason for fewer paths generated
260- # Change this to break when done
261218 continue
262219
263220 paths .append (path [1 :- 1 ])
264221
265- # Mark edges in this path as reused for future penalty
266222 for u , v in zip (path [:- 1 ], path [1 :]):
267223 edge_reuse [(u , v )] += 1
268224
0 commit comments