From d2e524e8b71040d0cd30f1d0b0f3641083997a47 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 02:24:22 -0400 Subject: [PATCH 001/100] ADD: Added KSPTracker for offline trackering --- trackers/core/ksptracker/__init__.py | 0 trackers/core/ksptracker/tracker.py | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 trackers/core/ksptracker/__init__.py create mode 100644 trackers/core/ksptracker/tracker.py diff --git a/trackers/core/ksptracker/__init__.py b/trackers/core/ksptracker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py new file mode 100644 index 00000000..77a72d5f --- /dev/null +++ b/trackers/core/ksptracker/tracker.py @@ -0,0 +1,18 @@ +import numpy as np +import supervision as sv +from trackers.core.base import BaseTracker + + +class KSPTracker(BaseTracker): + """ + Offline tracker using the K-Shortest Paths (KSP) algorithm. + """ + def __init__(self): + self.reset() + + def update(self, detections: sv.Detections) -> sv.Detections: + self.detection_buffer.append(detections) + return detections + + def reset(self) -> None: + pass \ No newline at end of file From b83da074f3ffd229f534a23024a1f3dab0155d17 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 02:25:11 -0400 Subject: [PATCH 002/100] ADD: Added IOU calcuations of bboxes --- trackers/core/ksptracker/tracker.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 77a72d5f..f85a9bd0 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,15 +1,38 @@ import numpy as np import supervision as sv +import networkx as nx from trackers.core.base import BaseTracker - class KSPTracker(BaseTracker): """ Offline tracker using the K-Shortest Paths (KSP) algorithm. """ - def __init__(self): + def __init__(self, + max_gap: int = 30, + min_confidence: float = 0.3, + max_paths: int = 1000, + max_distance: float = 0.3, + ): + self.max_gap = max_gap + self.min_confidence = min_confidence + self.max_paths = max_paths + self.max_distance = max_distance self.reset() + def _calc_iou(self, bbox1: np.ndarray, bbox2: np.ndarray): + x1, y1 = max(bbox1[0], bbox2[0]), max(bbox1[1], bbox2[1]) + x2, y2 = min(bbox1[2], bbox2[2]), min(bbox1[3], bbox2[3]) + eps = 1e-5 # To not allow division by 0 + + x_inter, y_inter = max(0, x2 - x1) , min(0, y2 - y1) + intersection_area = x_inter * y_inter + area1 = (bbox1[3] - bbox1[1]) * (bbox1[2] - bbox1[0]) + area2 = (bbox2[3] - bbox2[1]) * (bbox2[2] - bbox2[0]) + union = area1 + area2 - intersection_area + + return intersection_area / (union + eps) + + def update(self, detections: sv.Detections) -> sv.Detections: self.detection_buffer.append(detections) return detections From 53b8c914711a034017dfc243ee06fd0c3f5e72cd Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 02:40:17 -0400 Subject: [PATCH 003/100] ADD: Added a function to build a directed graph from detections --- trackers/core/ksptracker/tracker.py | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index f85a9bd0..07bdaa3c 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,8 +1,17 @@ import numpy as np import supervision as sv import networkx as nx +from dataclasses import dataclass +from typing import List from trackers.core.base import BaseTracker +@dataclass +class TrackNode: + frame_id: int + detection_id: int + bbox: np.ndarray + confidence: float + class KSPTracker(BaseTracker): """ Offline tracker using the K-Shortest Paths (KSP) algorithm. @@ -19,6 +28,12 @@ def __init__(self, self.max_distance = max_distance self.reset() + def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: + iou = self._calc_iou(node1.bbox, node2.bbox) + eps = (1 - self.max_distance) + + return iou / eps + def _calc_iou(self, bbox1: np.ndarray, bbox2: np.ndarray): x1, y1 = max(bbox1[0], bbox2[0]), max(bbox1[1], bbox2[1]) x2, y2 = min(bbox1[2], bbox2[2]), min(bbox1[3], bbox2[3]) @@ -31,9 +46,59 @@ def _calc_iou(self, bbox1: np.ndarray, bbox2: np.ndarray): union = area1 + area2 - intersection_area return intersection_area / (union + eps) + + def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: + iou = self._calc_iou(node1.bbox, node2.bbox) + frame_gap = node2.frame_id - node1.frame_id + return -iou * (1.0 / frame_gap) + + def _build_graph(self, all_detections: List[sv.Detections]): + diGraph = nx.DiGraph() # Create a directed graph + + # Adding your 2 virtual locations/nodes + diGraph.add_node("source") + diGraph.add_node("sink") + + for frame_idx, detections in enumerate(all_detections): + for det_idx in range(len(detections)): + node = TrackNode( + frame_id=frame_idx, + detection_id=det_idx, + bbox=detections.xyxy[det_idx], + confidence=detections.confidence[det_idx] + ) + + diGraph.add_node(node) + + # Connecting the source node to the first frame + if (frame_idx == 0): + diGraph.add_edge("source", node, weight=-node.confidence) + + # Connecting the last frame to the sink node + if frame_idx == len(all_detections) - 1: + diGraph.add_edge(node, "sink", weight=0) + + for future_frame_idx in range(frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections))): + future_dets = all_detections[future_frame_idx] + + for future_det_idx in range(len(future_dets)): + future_node = TrackNode( + frame_id=future_frame_idx, + detection_id=future_det_idx, + bbox=future_dets.xyxy[future_det_idx], + confidence=future_dets.confidence[future_det_idx] + ) + + if self._can_connect_nodes(node, future_node): + weight = self._edge_cost(node, future_node) + diGraph.add_edge(node, future_node, weight=weight) + return diGraph def update(self, detections: sv.Detections) -> sv.Detections: + """ + Run KSP algorithm on all detections (offline). + """ self.detection_buffer.append(detections) return detections From cb557cb7bfc3cea05c554873c3e7474f10898c0b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 02:45:42 -0400 Subject: [PATCH 004/100] ADD: Added K-Shortest Paths algorithm --- trackers/core/ksptracker/tracker.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 07bdaa3c..7b140208 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -94,6 +94,14 @@ def _build_graph(self, all_detections: List[sv.Detections]): diGraph.add_edge(node, future_node, weight=weight) return diGraph + def ksp(self, diGraph: nx.DiGraph): + paths = [] + for path in nx.shortest_simple_paths(diGraph, "source", "sink", weight="weight"): + if len(paths) > self.max_paths: + break + + # Add path to paths but remove the source and sink nodes + paths.append(path[1 : -1]) def update(self, detections: sv.Detections) -> sv.Detections: """ From b082cd0768086465a2c60b3fe2204b818adcf97d Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 02:58:51 -0400 Subject: [PATCH 005/100] ADD: Added a function to process tracks from source to sink --- trackers/core/ksptracker/tracker.py | 51 ++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 7b140208..9ca2867d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -28,6 +28,13 @@ def __init__(self, self.max_distance = max_distance self.reset() + def update(self, detections: sv.Detections) -> sv.Detections: + """ + Run KSP algorithm on all detections (offline). + """ + self.detection_buffer.append(detections) + return detections + def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: iou = self._calc_iou(node1.bbox, node2.bbox) eps = (1 - self.max_distance) @@ -94,21 +101,49 @@ def _build_graph(self, all_detections: List[sv.Detections]): diGraph.add_edge(node, future_node, weight=weight) return diGraph - def ksp(self, diGraph: nx.DiGraph): + def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: paths = [] for path in nx.shortest_simple_paths(diGraph, "source", "sink", weight="weight"): if len(paths) > self.max_paths: break - + # Add path to paths but remove the source and sink nodes paths.append(path[1 : -1]) + return paths + + def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: + all_detections = [] + all_tracker_ids = [] - def update(self, detections: sv.Detections) -> sv.Detections: - """ - Run KSP algorithm on all detections (offline). - """ - self.detection_buffer.append(detections) - return detections + for frame_idx, detections in enumerate(self.detection_buffer): + all_tracker_ids = [-1] * len(detections) + + for det_idx in range(len(detections)): + if (frame_idx, det_idx) in assignments: + all_tracker_ids[det_idx] = assignments[(all_tracker_ids, det_idx)] + + all_detections.append(detections) + all_detections.extend(all_tracker_ids) + + final_detections = sv.Detections.merge(all_detections) + final_detections.tracker_id = np.array(all_tracker_ids) + + return final_detections + + + def process_tracks(self) -> sv.Detections: + diGraph = self._build_graph(self.detection_buffer) + + paths = self.ksp(diGraph=diGraph) + + track_id = 0 + assignments = {} + for path in paths: + track_id += 1 + for node in path: + assignments[(node.frame_id, node.detection_id)] = track_id + + return self._update_detections_with_tracks(assignments) def reset(self) -> None: pass \ No newline at end of file From 0c260e37bd63f7833f05e739b508d8ab2f49d846 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 03:00:59 -0400 Subject: [PATCH 006/100] BUG: Fixed the reset function --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 9ca2867d..fd207e74 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -146,4 +146,4 @@ def process_tracks(self) -> sv.Detections: return self._update_detections_with_tracks(assignments) def reset(self) -> None: - pass \ No newline at end of file + self.detection_buffer = [] \ No newline at end of file From 1adc1cf9dac07883987e2412da9f096d49ea61a9 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 15:23:48 -0400 Subject: [PATCH 007/100] BUG: Fixed IOU calculation, can_connected_nodes(), and update_detections_with_tracks --- trackers/core/ksptracker/tracker.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index fd207e74..b44d6a1c 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -37,13 +37,12 @@ def update(self, detections: sv.Detections) -> sv.Detections: def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: iou = self._calc_iou(node1.bbox, node2.bbox) - eps = (1 - self.max_distance) - return iou / eps + return iou >= (1 - self.max_distance) def _calc_iou(self, bbox1: np.ndarray, bbox2: np.ndarray): - x1, y1 = max(bbox1[0], bbox2[0]), max(bbox1[1], bbox2[1]) - x2, y2 = min(bbox1[2], bbox2[2]), min(bbox1[3], bbox2[3]) + x1, y1 = max(bbox1[0], bbox2[0]), min(bbox1[1], bbox2[1]) + x2, y2 = max(bbox1[2], bbox2[2]), min(bbox1[3], bbox2[3]) eps = 1e-5 # To not allow division by 0 x_inter, y_inter = max(0, x2 - x1) , min(0, y2 - y1) @@ -112,23 +111,18 @@ def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: return paths def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: - all_detections = [] - all_tracker_ids = [] + output = [] for frame_idx, detections in enumerate(self.detection_buffer): - all_tracker_ids = [-1] * len(detections) - + tracker_ids = [] for det_idx in range(len(detections)): - if (frame_idx, det_idx) in assignments: - all_tracker_ids[det_idx] = assignments[(all_tracker_ids, det_idx)] + tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) - all_detections.append(detections) - all_detections.extend(all_tracker_ids) + new_dets = detections.with_tracker_ids(np.array(tracker_ids)) + output.append(new_dets) - final_detections = sv.Detections.merge(all_detections) - final_detections.tracker_id = np.array(all_tracker_ids) + return sv.Detections.merge(output) - return final_detections def process_tracks(self) -> sv.Detections: From f24c25a301dd4cc5dcaf1682b0f0bba9fa04d254 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 16:55:20 -0400 Subject: [PATCH 008/100] FIX: Fixes for bugs found for the KSP tracker issues during testing --- trackers/core/ksptracker/tracker.py | 140 ++++++++++++++-------------- 1 file changed, 69 insertions(+), 71 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index b44d6a1c..ea99495d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -2,16 +2,22 @@ import supervision as sv import networkx as nx from dataclasses import dataclass -from typing import List +from typing import List, Dict, Tuple, Union from trackers.core.base import BaseTracker -@dataclass +@dataclass(frozen=True) class TrackNode: frame_id: int detection_id: int - bbox: np.ndarray + bbox: tuple confidence: float + def __hash__(self): + return hash((self.frame_id, self.detection_id)) + + def __eq__(self, other): + return isinstance(other, TrackNode) and (self.frame_id, self.detection_id) == (other.frame_id, other.detection_id) + class KSPTracker(BaseTracker): """ Offline tracker using the K-Shortest Paths (KSP) algorithm. @@ -20,124 +26,116 @@ def __init__(self, max_gap: int = 30, min_confidence: float = 0.3, max_paths: int = 1000, - max_distance: float = 0.3, - ): + max_distance: float = 0.3): self.max_gap = max_gap self.min_confidence = min_confidence self.max_paths = max_paths self.max_distance = max_distance self.reset() + def reset(self) -> None: + self.detection_buffer: List[sv.Detections] = [] + def update(self, detections: sv.Detections) -> sv.Detections: - """ - Run KSP algorithm on all detections (offline). - """ self.detection_buffer.append(detections) return detections - def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: - iou = self._calc_iou(node1.bbox, node2.bbox) + def _calc_iou(self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple]) -> float: + bbox1 = np.array(bbox1) + bbox2 = np.array(bbox2) - return iou >= (1 - self.max_distance) + x1 = max(bbox1[0], bbox2[0]) + y1 = max(bbox1[1], bbox2[1]) + x2 = min(bbox1[2], bbox2[2]) + y2 = min(bbox1[3], bbox2[3]) - def _calc_iou(self, bbox1: np.ndarray, bbox2: np.ndarray): - x1, y1 = max(bbox1[0], bbox2[0]), min(bbox1[1], bbox2[1]) - x2, y2 = max(bbox1[2], bbox2[2]), min(bbox1[3], bbox2[3]) - eps = 1e-5 # To not allow division by 0 + inter_area = max(0, x2 - x1) * max(0, y2 - y1) + area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) + area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) + union = area1 + area2 - inter_area + 1e-5 # epsilon to avoid div by 0 - x_inter, y_inter = max(0, x2 - x1) , min(0, y2 - y1) - intersection_area = x_inter * y_inter - area1 = (bbox1[3] - bbox1[1]) * (bbox1[2] - bbox1[0]) - area2 = (bbox2[3] - bbox2[1]) * (bbox2[2] - bbox2[0]) - union = area1 + area2 - intersection_area + return inter_area / union + + def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: + return self._calc_iou(node1.bbox, node2.bbox) >= (1 - self.max_distance) - return intersection_area / (union + eps) - def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: iou = self._calc_iou(node1.bbox, node2.bbox) frame_gap = node2.frame_id - node1.frame_id return -iou * (1.0 / frame_gap) - - def _build_graph(self, all_detections: List[sv.Detections]): - diGraph = nx.DiGraph() # Create a directed graph - # Adding your 2 virtual locations/nodes - diGraph.add_node("source") - diGraph.add_node("sink") + def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: + graph = nx.DiGraph() + graph.add_node("source") + graph.add_node("sink") for frame_idx, detections in enumerate(all_detections): for det_idx in range(len(detections)): node = TrackNode( frame_id=frame_idx, detection_id=det_idx, - bbox=detections.xyxy[det_idx], + bbox=tuple(detections.xyxy[det_idx]), confidence=detections.confidence[det_idx] ) + graph.add_node(node) - diGraph.add_node(node) - - # Connecting the source node to the first frame - if (frame_idx == 0): - diGraph.add_edge("source", node, weight=-node.confidence) + if frame_idx == 0: + graph.add_edge("source", node, weight=-node.confidence) - # Connecting the last frame to the sink node if frame_idx == len(all_detections) - 1: - diGraph.add_edge(node, "sink", weight=0) + graph.add_edge(node, "sink", weight=0) - for future_frame_idx in range(frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections))): - future_dets = all_detections[future_frame_idx] - - for future_det_idx in range(len(future_dets)): + for next_frame in range(frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections))): + for next_idx in range(len(all_detections[next_frame])): future_node = TrackNode( - frame_id=future_frame_idx, - detection_id=future_det_idx, - bbox=future_dets.xyxy[future_det_idx], - confidence=future_dets.confidence[future_det_idx] + frame_id=next_frame, + detection_id=next_idx, + bbox=tuple(all_detections[next_frame].xyxy[next_idx]), + confidence=all_detections[next_frame].confidence[next_idx] ) - + if self._can_connect_nodes(node, future_node): - weight = self._edge_cost(node, future_node) - diGraph.add_edge(node, future_node, weight=weight) - return diGraph + graph.add_edge(node, future_node, weight=self._edge_cost(node, future_node)) - def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: - paths = [] - for path in nx.shortest_simple_paths(diGraph, "source", "sink", weight="weight"): - if len(paths) > self.max_paths: - break + return graph - # Add path to paths but remove the source and sink nodes - paths.append(path[1 : -1]) + def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: + paths = [] + try: + gen_paths = nx.shortest_simple_paths(graph, "source", "sink", weight="weight") + for i, path in enumerate(gen_paths): + if i >= self.max_paths: + break + paths.append(path[1:-1]) # strip 'source' and 'sink' + except nx.NetworkXNoPath: + pass return paths - + def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: output = [] - + for frame_idx, detections in enumerate(self.detection_buffer): tracker_ids = [] for det_idx in range(len(detections)): - tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) + tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) # Default to -1 if not assigned - new_dets = detections.with_tracker_ids(np.array(tracker_ids)) - output.append(new_dets) + # Attach tracker IDs to current frame detections + detections.tracker_id = np.array(tracker_ids) + output.append(detections) + # Merge all updated detections into a single sv.Detections object return sv.Detections.merge(output) - - def process_tracks(self) -> sv.Detections: - diGraph = self._build_graph(self.detection_buffer) - - paths = self.ksp(diGraph=diGraph) + if not self.detection_buffer: + return sv.Detections.empty() + + graph = self._build_graph(self.detection_buffer) + paths = self.ksp(graph) - track_id = 0 assignments = {} - for path in paths: - track_id += 1 + for track_id, path in enumerate(paths, start=1): for node in path: assignments[(node.frame_id, node.detection_id)] = track_id return self._update_detections_with_tracks(assignments) - - def reset(self) -> None: - self.detection_buffer = [] \ No newline at end of file From 1aba6c2846ded472062ab045721700b2960c3568 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 22:08:42 -0400 Subject: [PATCH 009/100] MISC: Added comments for functions and fixed formatting --- trackers/core/ksptracker/tracker.py | 163 +++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 25 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index ea99495d..517d9fec 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -2,31 +2,59 @@ import supervision as sv import networkx as nx from dataclasses import dataclass -from typing import List, Dict, Tuple, Union +from typing import List, Dict, Tuple, Union, Optional, Any from trackers.core.base import BaseTracker @dataclass(frozen=True) class TrackNode: + """ + Represents a detection node in the tracking graph. + + Attributes: + frame_id (int): Frame index where detection occurred + detection_id (int): Detection index within the frame + bbox (tuple): Bounding box coordinates (x1, y1, x2, y2) + confidence (float): Detection confidence score + """ frame_id: int detection_id: int bbox: tuple confidence: float - def __hash__(self): + def __hash__(self) -> int: + """Generates hash based on frame_id and detection_id.""" return hash((self.frame_id, self.detection_id)) - def __eq__(self, other): - return isinstance(other, TrackNode) and (self.frame_id, self.detection_id) == (other.frame_id, other.detection_id) + def __eq__(self, other: Any) -> bool: + """Compares equality based on frame_id and detection_id.""" + return isinstance(other, TrackNode) and \ + (self.frame_id, self.detection_id) == (other.frame_id, other.detection_id) class KSPTracker(BaseTracker): """ - Offline tracker using the K-Shortest Paths (KSP) algorithm. + Offline tracker using K-Shortest Paths (KSP) algorithm. + + Attributes: + max_gap (int): Maximum allowed frame gap between detections in a track + min_confidence (float): Minimum confidence threshold for detections + max_paths (int): Maximum number of paths to find in KSP algorithm + max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edge connections """ + def __init__(self, max_gap: int = 30, min_confidence: float = 0.3, max_paths: int = 1000, - max_distance: float = 0.3): + max_distance: float = 0.3) -> None: + """ + Initializes KSP tracker with configuration parameters. + + Args: + max_gap (int): Max frame gap between connected detections (default: 30) + min_confidence (float): Minimum detection confidence (default: 0.3) + max_paths (int): Maximum number of paths to find (default: 1000) + max_distance (float): Max dissimilarity (1-IoU) for connections (default: 0.3) + """ self.max_gap = max_gap self.min_confidence = min_confidence self.max_paths = max_paths @@ -34,13 +62,33 @@ def __init__(self, self.reset() def reset(self) -> None: + """Resets the tracker's internal state.""" self.detection_buffer: List[sv.Detections] = [] def update(self, detections: sv.Detections) -> sv.Detections: + """ + Updates tracker with new detections (stores without processing). + + Args: + detections (sv.Detections): New detections for current frame + + Returns: + detections (sv.Detections): Input detections + """ self.detection_buffer.append(detections) return detections def _calc_iou(self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple]) -> float: + """ + Calculates Intersection over Union (IoU) between two bounding boxes. + + Args: + bbox1 (Union[np.ndarray, tuple]): First bounding box (x1, y1, x2, y2) + bbox2 (Union[np.ndarray, tuple]): Second bounding box (x1, y1, x2, y2) + + Returns: + IOU (float): IoU value between 0.0 and 1.0 + """ bbox1 = np.array(bbox1) bbox2 = np.array(bbox2) @@ -57,17 +105,48 @@ def _calc_iou(self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tu return inter_area / union def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: + """ + Determines if two nodes can be connected based on IoU threshold. + + Args: + node1 (TrackNode): First track node + node2 (TrackNode): Second track node + + Returns: + bool: True if IoU >= (1 - max_distance), False otherwise + """ return self._calc_iou(node1.bbox, node2.bbox) >= (1 - self.max_distance) def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: + """ + Computes edge cost between two nodes for graph weighting. + + Args: + node1 (TrackNode): Source node + node2 (TrackNode): Target node + + Returns: + float: Negative IoU normalized by frame gap + """ iou = self._calc_iou(node1.bbox, node2.bbox) frame_gap = node2.frame_id - node1.frame_id return -iou * (1.0 / frame_gap) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: - graph = nx.DiGraph() - graph.add_node("source") - graph.add_node("sink") + """ + Constructs tracking graph from buffered detections. + + Args: + all_detections (List[sv.Detections]): List of detections per frame + + Returns: + directed_graph (nx.DiGraph): Directed graph with connections between nodes with calculated weights + """ + diGraph = nx.DiGraph() + + # Add the 2 virtual nodes + diGraph.add_node("source") + diGraph.add_node("sink") for frame_idx, detections in enumerate(all_detections): for det_idx in range(len(detections)): @@ -77,14 +156,17 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: bbox=tuple(detections.xyxy[det_idx]), confidence=detections.confidence[det_idx] ) - graph.add_node(node) + diGraph.add_node(node) + # Connect to source for first frame if frame_idx == 0: - graph.add_edge("source", node, weight=-node.confidence) + diGraph.add_edge("source", node, weight=-node.confidence) + # Connect to sink for last frame if frame_idx == len(all_detections) - 1: - graph.add_edge(node, "sink", weight=0) + diGraph.add_edge(node, "sink", weight=0) + # Create edges to future frames for next_frame in range(frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections))): for next_idx in range(len(all_detections[next_frame])): future_node = TrackNode( @@ -95,14 +177,23 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: ) if self._can_connect_nodes(node, future_node): - graph.add_edge(node, future_node, weight=self._edge_cost(node, future_node)) + diGraph.add_edge(node, future_node, weight=self._edge_cost(node, future_node)) - return graph + return diGraph - def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: - paths = [] + def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: + """ + Finds K-Shortest Paths in the tracking graph. + + Args: + graph (nx.DiGraph): Constructed tracking graph + + Returns: + paths (List[List[TrackNode]]): List of track paths (each path is list of TrackNodes) + """ + paths: List[List[TrackNode]] = [] try: - gen_paths = nx.shortest_simple_paths(graph, "source", "sink", weight="weight") + gen_paths = nx.shortest_simple_paths(diGraph, "source", "sink", weight="weight") for i, path in enumerate(gen_paths): if i >= self.max_paths: break @@ -112,30 +203,52 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: return paths def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: - output = [] + """ + Assigns track IDs to detections based on path assignments. + + Args: + assignments (dict): Mapping of (frame_id, detection_id) to track_id + + Returns: + detections (sv.Detections): Detections with tracker_id attribute populated + """ + output: List[sv.Detections] = [] for frame_idx, detections in enumerate(self.detection_buffer): - tracker_ids = [] + tracker_ids: List[int] = [] for det_idx in range(len(detections)): - tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) # Default to -1 if not assigned + tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) # -1 for unassigned # Attach tracker IDs to current frame detections detections.tracker_id = np.array(tracker_ids) output.append(detections) - # Merge all updated detections into a single sv.Detections object + # Merge all updated detections return sv.Detections.merge(output) def process_tracks(self) -> sv.Detections: + """ + Processes buffered detections to generate tracks. + + Steps: + 1. Build tracking graph + 2. Find K-Shortest Paths + 3. Assign track IDs + 4. Return detections with tracker IDs + + Returns: + detections (sv.Detections): Merged detections with tracker_id attribute populated + """ if not self.detection_buffer: return sv.Detections.empty() - graph = self._build_graph(self.detection_buffer) - paths = self.ksp(graph) + graph: nx.DiGraph = self._build_graph(self.detection_buffer) + paths: List[List[TrackNode]] = self.ksp(graph) - assignments = {} + # Create track ID assignments + assignments: Dict[Tuple[int, int], int] = {} for track_id, path in enumerate(paths, start=1): for node in path: assignments[(node.frame_id, node.detection_id)] = track_id - return self._update_detections_with_tracks(assignments) + return self._update_detections_with_tracks(assignments) \ No newline at end of file From db5261738869e6ec3a0ab1435d54ca0a2ec97ffa Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 16 Jun 2025 22:10:06 -0400 Subject: [PATCH 010/100] MISC: Removed unused imports --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 517d9fec..b911147d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -2,7 +2,7 @@ import supervision as sv import networkx as nx from dataclasses import dataclass -from typing import List, Dict, Tuple, Union, Optional, Any +from typing import List, Dict, Tuple, Union, Any from trackers.core.base import BaseTracker @dataclass(frozen=True) From 31b5a5bc605ce69fa34281a19b144b993a99b02b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 19:27:18 +0000 Subject: [PATCH 011/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 61 +++++++++++++++++++---------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index b911147d..d17c09af 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,10 +1,13 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Tuple, Union + +import networkx as nx import numpy as np import supervision as sv -import networkx as nx -from dataclasses import dataclass -from typing import List, Dict, Tuple, Union, Any + from trackers.core.base import BaseTracker + @dataclass(frozen=True) class TrackNode: """ @@ -16,6 +19,7 @@ class TrackNode: bbox (tuple): Bounding box coordinates (x1, y1, x2, y2) confidence (float): Detection confidence score """ + frame_id: int detection_id: int bbox: tuple @@ -27,8 +31,11 @@ def __hash__(self) -> int: def __eq__(self, other: Any) -> bool: """Compares equality based on frame_id and detection_id.""" - return isinstance(other, TrackNode) and \ - (self.frame_id, self.detection_id) == (other.frame_id, other.detection_id) + return isinstance(other, TrackNode) and (self.frame_id, self.detection_id) == ( + other.frame_id, + other.detection_id, + ) + class KSPTracker(BaseTracker): """ @@ -40,12 +47,14 @@ class KSPTracker(BaseTracker): max_paths (int): Maximum number of paths to find in KSP algorithm max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edge connections """ - - def __init__(self, - max_gap: int = 30, - min_confidence: float = 0.3, - max_paths: int = 1000, - max_distance: float = 0.3) -> None: + + def __init__( + self, + max_gap: int = 30, + min_confidence: float = 0.3, + max_paths: int = 1000, + max_distance: float = 0.3, + ) -> None: """ Initializes KSP tracker with configuration parameters. @@ -78,7 +87,9 @@ def update(self, detections: sv.Detections) -> sv.Detections: self.detection_buffer.append(detections) return detections - def _calc_iou(self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple]) -> float: + def _calc_iou( + self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple] + ) -> float: """ Calculates Intersection over Union (IoU) between two bounding boxes. @@ -154,7 +165,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: frame_id=frame_idx, detection_id=det_idx, bbox=tuple(detections.xyxy[det_idx]), - confidence=detections.confidence[det_idx] + confidence=detections.confidence[det_idx], ) diGraph.add_node(node) @@ -167,17 +178,23 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: diGraph.add_edge(node, "sink", weight=0) # Create edges to future frames - for next_frame in range(frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections))): + for next_frame in range( + frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections)) + ): for next_idx in range(len(all_detections[next_frame])): future_node = TrackNode( frame_id=next_frame, detection_id=next_idx, bbox=tuple(all_detections[next_frame].xyxy[next_idx]), - confidence=all_detections[next_frame].confidence[next_idx] + confidence=all_detections[next_frame].confidence[next_idx], ) if self._can_connect_nodes(node, future_node): - diGraph.add_edge(node, future_node, weight=self._edge_cost(node, future_node)) + diGraph.add_edge( + node, + future_node, + weight=self._edge_cost(node, future_node), + ) return diGraph @@ -193,7 +210,9 @@ def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: """ paths: List[List[TrackNode]] = [] try: - gen_paths = nx.shortest_simple_paths(diGraph, "source", "sink", weight="weight") + gen_paths = nx.shortest_simple_paths( + diGraph, "source", "sink", weight="weight" + ) for i, path in enumerate(gen_paths): if i >= self.max_paths: break @@ -213,11 +232,13 @@ def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: detections (sv.Detections): Detections with tracker_id attribute populated """ output: List[sv.Detections] = [] - + for frame_idx, detections in enumerate(self.detection_buffer): tracker_ids: List[int] = [] for det_idx in range(len(detections)): - tracker_ids.append(assignments.get((frame_idx, det_idx), -1)) # -1 for unassigned + tracker_ids.append( + assignments.get((frame_idx, det_idx), -1) + ) # -1 for unassigned # Attach tracker IDs to current frame detections detections.tracker_id = np.array(tracker_ids) @@ -251,4 +272,4 @@ def process_tracks(self) -> sv.Detections: for node in path: assignments[(node.frame_id, node.detection_id)] = track_id - return self._update_detections_with_tracks(assignments) \ No newline at end of file + return self._update_detections_with_tracks(assignments) From 206d532e070530a6d2085ecd72b82352f6cdfae3 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Tue, 17 Jun 2025 15:47:01 -0400 Subject: [PATCH 012/100] MISC: Fixed formatting --- trackers/core/ksptracker/tracker.py | 244 +++++++++++++++------------- 1 file changed, 128 insertions(+), 116 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index d17c09af..0619c80b 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -10,8 +10,7 @@ @dataclass(frozen=True) class TrackNode: - """ - Represents a detection node in the tracking graph. + """Represents a detection node in the tracking graph. Attributes: frame_id (int): Frame index where detection occurred @@ -26,26 +25,39 @@ class TrackNode: confidence: float def __hash__(self) -> int: - """Generates hash based on frame_id and detection_id.""" + """Generates hash based on frame_id and detection_id. + + Returns: + int: Hash value of the node + """ return hash((self.frame_id, self.detection_id)) def __eq__(self, other: Any) -> bool: - """Compares equality based on frame_id and detection_id.""" - return isinstance(other, TrackNode) and (self.frame_id, self.detection_id) == ( + """Compares equality based on frame_id and detection_id. + + Args: + other (Any): Object to compare with + + Returns: + bool: True if nodes are equal, False otherwise + """ + if not isinstance(other, TrackNode): + return False + return (self.frame_id, self.detection_id) == ( other.frame_id, other.detection_id, ) class KSPTracker(BaseTracker): - """ - Offline tracker using K-Shortest Paths (KSP) algorithm. + """Offline tracker using K-Shortest Paths (KSP) algorithm. Attributes: max_gap (int): Maximum allowed frame gap between detections in a track min_confidence (float): Minimum confidence threshold for detections max_paths (int): Maximum number of paths to find in KSP algorithm - max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edge connections + max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edges + detection_buffer (List[sv.Detections]): Buffer storing all frame detections """ def __init__( @@ -55,14 +67,13 @@ def __init__( max_paths: int = 1000, max_distance: float = 0.3, ) -> None: - """ - Initializes KSP tracker with configuration parameters. + """Initialize KSP tracker with configuration parameters. Args: - max_gap (int): Max frame gap between connected detections (default: 30) - min_confidence (float): Minimum detection confidence (default: 0.3) - max_paths (int): Maximum number of paths to find (default: 1000) - max_distance (float): Max dissimilarity (1-IoU) for connections (default: 0.3) + max_gap (int): Max frame gap between connected detections + min_confidence (float): Minimum detection confidence + max_paths (int): Maximum number of paths to find + max_distance (float): Max dissimilarity (1-IoU) for connections """ self.max_gap = max_gap self.min_confidence = min_confidence @@ -71,34 +82,34 @@ def __init__( self.reset() def reset(self) -> None: - """Resets the tracker's internal state.""" + """Reset the tracker's internal state.""" self.detection_buffer: List[sv.Detections] = [] def update(self, detections: sv.Detections) -> sv.Detections: - """ - Updates tracker with new detections (stores without processing). + """Update tracker with new detections (stores without processing). Args: detections (sv.Detections): New detections for current frame Returns: - detections (sv.Detections): Input detections + sv.Detections: Input detections (unmodified) """ self.detection_buffer.append(detections) return detections def _calc_iou( - self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple] + self, + bbox1: Union[np.ndarray, tuple], + bbox2: Union[np.ndarray, tuple] ) -> float: - """ - Calculates Intersection over Union (IoU) between two bounding boxes. + """Calculate Intersection over Union (IoU) between two bounding boxes. Args: bbox1 (Union[np.ndarray, tuple]): First bounding box (x1, y1, x2, y2) bbox2 (Union[np.ndarray, tuple]): Second bounding box (x1, y1, x2, y2) Returns: - IOU (float): IoU value between 0.0 and 1.0 + float: IoU value between 0.0 and 1.0 """ bbox1 = np.array(bbox1) bbox2 = np.array(bbox2) @@ -116,160 +127,161 @@ def _calc_iou( return inter_area / union def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: - """ - Determines if two nodes can be connected based on IoU threshold. + """Determine if two nodes can be connected based on IoU threshold. Args: node1 (TrackNode): First track node node2 (TrackNode): Second track node Returns: - bool: True if IoU >= (1 - max_distance), False otherwise + bool: True if nodes can be connected, False otherwise """ - return self._calc_iou(node1.bbox, node2.bbox) >= (1 - self.max_distance) + if node2.frame_id <= node1.frame_id: + return False + if node2.frame_id - node1.frame_id > self.max_gap: + return False + iou = self._calc_iou(node1.bbox, node2.bbox) + return iou >= (1 - self.max_distance) def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: - """ - Computes edge cost between two nodes for graph weighting. + """Calculate edge cost between two nodes. Args: - node1 (TrackNode): Source node - node2 (TrackNode): Target node + node1 (TrackNode): First track node + node2 (TrackNode): Second track node Returns: - float: Negative IoU normalized by frame gap + float: Edge cost based on IoU and frame gap """ iou = self._calc_iou(node1.bbox, node2.bbox) frame_gap = node2.frame_id - node1.frame_id return -iou * (1.0 / frame_gap) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: - """ - Constructs tracking graph from buffered detections. + """Build directed graph from all detections. Args: - all_detections (List[sv.Detections]): List of detections per frame + all_detections (List[sv.Detections]): List of detections from frames Returns: - directed_graph (nx.DiGraph): Directed graph with connections between nodes with calculated weights + nx.DiGraph: Directed graph with detection nodes and edges """ - diGraph = nx.DiGraph() + G = nx.DiGraph() + G.add_node("source") + G.add_node("sink") - # Add the 2 virtual nodes - diGraph.add_node("source") - diGraph.add_node("sink") + # Add detection nodes and edges + for frame_idx, dets in enumerate(all_detections): + for det_idx in range(len(dets)): + if dets.confidence[det_idx] < self.min_confidence: + continue - for frame_idx, detections in enumerate(all_detections): - for det_idx in range(len(detections)): node = TrackNode( frame_id=frame_idx, detection_id=det_idx, - bbox=tuple(detections.xyxy[det_idx]), - confidence=detections.confidence[det_idx], + bbox=tuple(dets.xyxy[det_idx]), + confidence=dets.confidence[det_idx] ) - diGraph.add_node(node) + G.add_node(node) - # Connect to source for first frame + # Connect to source if first frame if frame_idx == 0: - diGraph.add_edge("source", node, weight=-node.confidence) + G.add_edge("source", node, weight=-node.confidence) - # Connect to sink for last frame + # Connect to sink if last frame if frame_idx == len(all_detections) - 1: - diGraph.add_edge(node, "sink", weight=0) + G.add_edge(node, "sink", weight=0) + + # Connect to future frames within max_gap + future_range = range( + frame_idx + 1, + min(frame_idx + self.max_gap + 1, len(all_detections)) + ) + for future_idx in future_range: + future_dets = all_detections[future_idx] + for future_det_idx in range(len(future_dets)): + if future_dets.confidence[future_det_idx] < self.min_confidence: + continue - # Create edges to future frames - for next_frame in range( - frame_idx + 1, min(frame_idx + self.max_gap, len(all_detections)) - ): - for next_idx in range(len(all_detections[next_frame])): future_node = TrackNode( - frame_id=next_frame, - detection_id=next_idx, - bbox=tuple(all_detections[next_frame].xyxy[next_idx]), - confidence=all_detections[next_frame].confidence[next_idx], + frame_id=future_idx, + detection_id=future_det_idx, + bbox=tuple(future_dets.xyxy[future_det_idx]), + confidence=future_dets.confidence[future_det_idx] ) if self._can_connect_nodes(node, future_node): - diGraph.add_edge( - node, - future_node, - weight=self._edge_cost(node, future_node), - ) + weight = self._edge_cost(node, future_node) + G.add_edge(node, future_node, weight=weight) - return diGraph + return G - def ksp(self, diGraph: nx.DiGraph) -> List[List[TrackNode]]: - """ - Finds K-Shortest Paths in the tracking graph. + def _update_detections_with_tracks( + self, + assignments: Dict + ) -> sv.Detections: + """Update detections with track IDs based on assignments. Args: - graph (nx.DiGraph): Constructed tracking graph + assignments (Dict): Maps (frame_id, det_id) to track_id Returns: - paths (List[List[TrackNode]]): List of track paths (each path is list of TrackNodes) + sv.Detections: Updated detections with tracker_ids assigned """ - paths: List[List[TrackNode]] = [] - try: - gen_paths = nx.shortest_simple_paths( - diGraph, "source", "sink", weight="weight" - ) - for i, path in enumerate(gen_paths): - if i >= self.max_paths: - break - paths.append(path[1:-1]) # strip 'source' and 'sink' - except nx.NetworkXNoPath: - pass - return paths + all_detections = [] + all_tracker_ids = [] - def _update_detections_with_tracks(self, assignments: dict) -> sv.Detections: - """ - Assigns track IDs to detections based on path assignments. + for frame_idx, dets in enumerate(self.detection_buffer): + frame_tracker_ids = [-1] * len(dets) - Args: - assignments (dict): Mapping of (frame_id, detection_id) to track_id + for det_idx in range(len(dets)): + key = (frame_idx, det_idx) + if key in assignments: + frame_tracker_ids[det_idx] = assignments[key] - Returns: - detections (sv.Detections): Detections with tracker_id attribute populated - """ - output: List[sv.Detections] = [] + all_detections.append(dets) + all_tracker_ids.extend(frame_tracker_ids) - for frame_idx, detections in enumerate(self.detection_buffer): - tracker_ids: List[int] = [] - for det_idx in range(len(detections)): - tracker_ids.append( - assignments.get((frame_idx, det_idx), -1) - ) # -1 for unassigned + final_detections = sv.Detections.merge(all_detections) + final_detections.tracker_id = np.array(all_tracker_ids) - # Attach tracker IDs to current frame detections - detections.tracker_id = np.array(tracker_ids) - output.append(detections) + return final_detections - # Merge all updated detections - return sv.Detections.merge(output) + def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: + """Find K-shortest paths in the graph. - def process_tracks(self) -> sv.Detections: + Args: + graph (nx.DiGraph): Directed graph of detection nodes + + Returns: + List[List[TrackNode]]: List of paths, each path is list of TrackNodes """ - Processes buffered detections to generate tracks. + paths = [] + for path in nx.shortest_simple_paths( + graph, + "source", + "sink", + weight="weight" + ): + if len(paths) >= self.max_paths: + break + # Remove source and sink nodes from path + paths.append(path[1:-1]) + return paths - Steps: - 1. Build tracking graph - 2. Find K-Shortest Paths - 3. Assign track IDs - 4. Return detections with tracker IDs + def process_tracks(self) -> sv.Detections: + """Process all buffered detections to create final tracks. Returns: - detections (sv.Detections): Merged detections with tracker_id attribute populated + sv.Detections: Detections with assigned track IDs """ - if not self.detection_buffer: - return sv.Detections.empty() - - graph: nx.DiGraph = self._build_graph(self.detection_buffer) - paths: List[List[TrackNode]] = self.ksp(graph) + graph = self._build_graph(self.detection_buffer) + paths = self.ksp(graph) - # Create track ID assignments - assignments: Dict[Tuple[int, int], int] = {} + # Assign track IDs + assignments = {} for track_id, path in enumerate(paths, start=1): for node in path: assignments[(node.frame_id, node.detection_id)] = track_id - return self._update_detections_with_tracks(assignments) + return \ No newline at end of file From 0544904b177870c399bb791737296156eb97b971 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 20:00:01 +0000 Subject: [PATCH 013/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 0619c80b..3e1e227b 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Tuple, Union +from typing import Any, Dict, List, Union import networkx as nx import numpy as np @@ -98,9 +98,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: return detections def _calc_iou( - self, - bbox1: Union[np.ndarray, tuple], - bbox2: Union[np.ndarray, tuple] + self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple] ) -> float: """Calculate Intersection over Union (IoU) between two bounding boxes. @@ -180,7 +178,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: frame_id=frame_idx, detection_id=det_idx, bbox=tuple(dets.xyxy[det_idx]), - confidence=dets.confidence[det_idx] + confidence=dets.confidence[det_idx], ) G.add_node(node) @@ -195,7 +193,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: # Connect to future frames within max_gap future_range = range( frame_idx + 1, - min(frame_idx + self.max_gap + 1, len(all_detections)) + min(frame_idx + self.max_gap + 1, len(all_detections)), ) for future_idx in future_range: future_dets = all_detections[future_idx] @@ -207,7 +205,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: frame_id=future_idx, detection_id=future_det_idx, bbox=tuple(future_dets.xyxy[future_det_idx]), - confidence=future_dets.confidence[future_det_idx] + confidence=future_dets.confidence[future_det_idx], ) if self._can_connect_nodes(node, future_node): @@ -216,10 +214,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks( - self, - assignments: Dict - ) -> sv.Detections: + def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: """Update detections with track IDs based on assignments. Args: @@ -257,12 +252,7 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: List[List[TrackNode]]: List of paths, each path is list of TrackNodes """ paths = [] - for path in nx.shortest_simple_paths( - graph, - "source", - "sink", - weight="weight" - ): + for path in nx.shortest_simple_paths(graph, "source", "sink", weight="weight"): if len(paths) >= self.max_paths: break # Remove source and sink nodes from path @@ -284,4 +274,4 @@ def process_tracks(self) -> sv.Detections: for node in path: assignments[(node.frame_id, node.detection_id)] = track_id - return \ No newline at end of file + return From f5dcaf3688acc333282560b93750e32713b8ae0b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Tue, 17 Jun 2025 16:07:51 -0400 Subject: [PATCH 014/100] MISC: Add type annotation for pre-commit --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 3e1e227b..7631d2cf 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -251,7 +251,7 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: Returns: List[List[TrackNode]]: List of paths, each path is list of TrackNodes """ - paths = [] + paths: List[List[TrackNode]] = [] for path in nx.shortest_simple_paths(graph, "source", "sink", weight="weight"): if len(paths) >= self.max_paths: break From 9d5d37d532107c85fdab7877b7796dce22019103 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 00:22:25 -0400 Subject: [PATCH 015/100] MISC: Add networkx to dependency --- pyproject.toml | 3 +++ uv.lock | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3e0e12b0..597e580b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,9 @@ metrics = [ "tensorboard>=2.19.0", "wandb>=0.19.11", ] +ksptracker = [ + "networkx>=3.2.1", +] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 3afc9958..bcea71da 100644 --- a/uv.lock +++ b/uv.lock @@ -6635,6 +6635,10 @@ cu126 = [ { name = "torchvision", version = "0.22.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'darwin' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, { name = "torchvision", version = "0.22.1+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(sys_platform != 'darwin' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, ] +ksptracker = [ + { name = "networkx", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, +] metrics = [ { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, { name = "matplotlib", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu118') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cpu' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu124') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu118' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-cu126') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu124' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm61') or (extra == 'extra-8-trackers-cu126' and extra == 'extra-8-trackers-rocm624') or (extra == 'extra-8-trackers-rocm61' and extra == 'extra-8-trackers-rocm624')" }, @@ -6700,6 +6704,7 @@ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = ">=3.11.16" }, { name = "matplotlib", marker = "extra == 'metrics'", specifier = ">=3.9.4" }, + { name = "networkx", marker = "extra == 'ksptracker'", specifier = ">=3.2.1" }, { name = "numpy", specifier = ">=2.0.2" }, { name = "pytorch-triton-rocm", marker = "sys_platform == 'darwin' and extra == 'rocm61'", specifier = ">=2.0.0" }, { name = "pytorch-triton-rocm", marker = "sys_platform == 'darwin' and extra == 'rocm624'", specifier = ">=2.0.0" }, @@ -6736,7 +6741,7 @@ requires-dist = [ { name = "validators", specifier = ">=0.34.0" }, { name = "wandb", marker = "extra == 'metrics'", specifier = ">=0.19.11" }, ] -provides-extras = ["cpu", "cu126", "cu124", "cu118", "rocm61", "rocm624", "reid", "metrics"] +provides-extras = ["cpu", "cu126", "cu124", "cu118", "rocm61", "rocm624", "reid", "metrics", "ksptracker"] [package.metadata.requires-dev] build = [ From 0452a2fdde31086d14945158a7f52651361d8453 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 00:23:36 -0400 Subject: [PATCH 016/100] FIX: Switch to sv.box_iou_batch --- trackers/core/ksptracker/tracker.py | 33 +++-------------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 7631d2cf..7f0604ea 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -96,34 +96,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: """ self.detection_buffer.append(detections) return detections - - def _calc_iou( - self, bbox1: Union[np.ndarray, tuple], bbox2: Union[np.ndarray, tuple] - ) -> float: - """Calculate Intersection over Union (IoU) between two bounding boxes. - - Args: - bbox1 (Union[np.ndarray, tuple]): First bounding box (x1, y1, x2, y2) - bbox2 (Union[np.ndarray, tuple]): Second bounding box (x1, y1, x2, y2) - - Returns: - float: IoU value between 0.0 and 1.0 - """ - bbox1 = np.array(bbox1) - bbox2 = np.array(bbox2) - - x1 = max(bbox1[0], bbox2[0]) - y1 = max(bbox1[1], bbox2[1]) - x2 = min(bbox1[2], bbox2[2]) - y2 = min(bbox1[3], bbox2[3]) - - inter_area = max(0, x2 - x1) * max(0, y2 - y1) - area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) - area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) - union = area1 + area2 - inter_area + 1e-5 # epsilon to avoid div by 0 - - return inter_area / union - + def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: """Determine if two nodes can be connected based on IoU threshold. @@ -138,7 +111,7 @@ def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: return False if node2.frame_id - node1.frame_id > self.max_gap: return False - iou = self._calc_iou(node1.bbox, node2.bbox) + iou = sv.box_iou_batch(node1.bbox, node2.bbox) return iou >= (1 - self.max_distance) def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: @@ -151,7 +124,7 @@ def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: Returns: float: Edge cost based on IoU and frame gap """ - iou = self._calc_iou(node1.bbox, node2.bbox) + iou = sv.box_iou_batch(node1.bbox, node2.bbox) frame_gap = node2.frame_id - node1.frame_id return -iou * (1.0 / frame_gap) From f3b849bc358927853c67d9b855e3a11d3205a5db Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 00:31:49 -0400 Subject: [PATCH 017/100] FIX: process_tracks returned None --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 7f0604ea..a6216067 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -247,4 +247,4 @@ def process_tracks(self) -> sv.Detections: for node in path: assignments[(node.frame_id, node.detection_id)] = track_id - return + return self._update_detections_with_tracks(assignments=assignments) From 614b2fbf397fa99e61b0a7f47f6e8a891c37476d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 04:32:46 +0000 Subject: [PATCH 018/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index a6216067..a7dd162f 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Union +from typing import Any, Dict, List import networkx as nx import numpy as np @@ -96,7 +96,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: """ self.detection_buffer.append(detections) return detections - + def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: """Determine if two nodes can be connected based on IoU threshold. From 0e6bc41323e042c72857c10c7e5b755394ebf905 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 01:39:12 -0400 Subject: [PATCH 019/100] UPDATE: Updated the way the directed acyclic graph is built --- trackers/core/ksptracker/tracker.py | 94 ++++++++++------------------- 1 file changed, 32 insertions(+), 62 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index a6216067..d45e7e6c 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -14,14 +14,14 @@ class TrackNode: Attributes: frame_id (int): Frame index where detection occurred - detection_id (int): Detection index within the frame - bbox (tuple): Bounding box coordinates (x1, y1, x2, y2) + grid_cell_id (int): Discretized grid cell id where the detection center + position (tuple): Grid coordinates of the detection center (x_center, y_center) confidence (float): Detection confidence score """ frame_id: int - detection_id: int - bbox: tuple + grid_cell_id: int + position: tuple confidence: float def __hash__(self) -> int: @@ -62,6 +62,7 @@ class KSPTracker(BaseTracker): def __init__( self, + grid_size: int = 20, max_gap: int = 30, min_confidence: float = 0.3, max_paths: int = 1000, @@ -70,11 +71,13 @@ def __init__( """Initialize KSP tracker with configuration parameters. Args: + grid_size (int): Size (in pixels) of each square cell in the spatial grid max_gap (int): Max frame gap between connected detections min_confidence (float): Minimum detection confidence max_paths (int): Maximum number of paths to find max_distance (float): Max dissimilarity (1-IoU) for connections """ + self.grid_size = grid_size self.max_gap = max_gap self.min_confidence = min_confidence self.max_paths = max_paths @@ -97,36 +100,13 @@ def update(self, detections: sv.Detections) -> sv.Detections: self.detection_buffer.append(detections) return detections - def _can_connect_nodes(self, node1: TrackNode, node2: TrackNode) -> bool: - """Determine if two nodes can be connected based on IoU threshold. - - Args: - node1 (TrackNode): First track node - node2 (TrackNode): Second track node - - Returns: - bool: True if nodes can be connected, False otherwise - """ - if node2.frame_id <= node1.frame_id: - return False - if node2.frame_id - node1.frame_id > self.max_gap: - return False - iou = sv.box_iou_batch(node1.bbox, node2.bbox) - return iou >= (1 - self.max_distance) - - def _edge_cost(self, node1: TrackNode, node2: TrackNode) -> float: - """Calculate edge cost between two nodes. - - Args: - node1 (TrackNode): First track node - node2 (TrackNode): Second track node - - Returns: - float: Edge cost based on IoU and frame gap - """ - iou = sv.box_iou_batch(node1.bbox, node2.bbox) - frame_gap = node2.frame_id - node1.frame_id - return -iou * (1.0 / frame_gap) + def _discretized_grid_cell_idation(self, bbox: np.ndarray) -> tuple: + x_center = (bbox[2] - bbox[0]) / 2 + y_center = (bbox[3] - bbox[1]) / 2 + grid_x_center = int(x_center // self.grid_size) + grid_y_center = int(y_center // self.grid_size) + + return (grid_x_center, grid_y_center) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: """Build directed graph from all detections. @@ -141,50 +121,40 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: G.add_node("source") G.add_node("sink") - # Add detection nodes and edges + node_dict: Dict[int, List[TrackNode]] = {} + for frame_idx, dets in enumerate(all_detections): + node_dict[frame_idx] = [] for det_idx in range(len(dets)): if dets.confidence[det_idx] < self.min_confidence: continue + pos = self._discretized_grid_cell_idation(dets.xyxy[det_idx], 1, 1) + cell_id = hash(pos) + node = TrackNode( frame_id=frame_idx, - detection_id=det_idx, - bbox=tuple(dets.xyxy[det_idx]), + grid_cell_id=cell_id, + position=pos, confidence=dets.confidence[det_idx], ) + G.add_node(node) + node_dict[frame_idx].append(node) - # Connect to source if first frame if frame_idx == 0: G.add_edge("source", node, weight=-node.confidence) - - # Connect to sink if last frame + if frame_idx == len(all_detections) - 1: G.add_edge(node, "sink", weight=0) - # Connect to future frames within max_gap - future_range = range( - frame_idx + 1, - min(frame_idx + self.max_gap + 1, len(all_detections)), - ) - for future_idx in future_range: - future_dets = all_detections[future_idx] - for future_det_idx in range(len(future_dets)): - if future_dets.confidence[future_det_idx] < self.min_confidence: - continue - - future_node = TrackNode( - frame_id=future_idx, - detection_id=future_det_idx, - bbox=tuple(future_dets.xyxy[future_det_idx]), - confidence=future_dets.confidence[future_det_idx], - ) - - if self._can_connect_nodes(node, future_node): - weight = self._edge_cost(node, future_node) - G.add_edge(node, future_node, weight=weight) - + for i in range(len(all_detections) - 1): + for node in node_dict[i]: + for node_next in node_dict[i + 1]: + dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) + if dist <= 2: + G.add_edge(node, node_next, weight=dist - node_next.confidence) + return G def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: From f82a1129ee9b556462a57998fae12eb98b6fc6da Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 01:42:40 -0400 Subject: [PATCH 020/100] UPDATE: Updated process_tasks --- trackers/core/ksptracker/tracker.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index d45e7e6c..47a8d282 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -154,7 +154,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) if dist <= 2: G.add_edge(node, node_next, weight=dist - node_next.confidence) - + return G def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: @@ -215,6 +215,9 @@ def process_tracks(self) -> sv.Detections: assignments = {} for track_id, path in enumerate(paths, start=1): for node in path: - assignments[(node.frame_id, node.detection_id)] = track_id + for det_idx, det in enumerate(self.detection_buffer[node.frame_id]): + pos = self._discretized_grid_cell_idation(det.xyxy[det_idx], 1, 1) + if pos == node.position: + assignments[(node.frame_id, node.detection_id)] = track_id return self._update_detections_with_tracks(assignments=assignments) From 0543291a21ba62f0fcf46182c2e4ebfde06ffcfd Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:01:00 -0400 Subject: [PATCH 021/100] FIX: Removed the width and height parameters --- trackers/core/ksptracker/tracker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 47a8d282..28f00e81d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -100,7 +100,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: self.detection_buffer.append(detections) return detections - def _discretized_grid_cell_idation(self, bbox: np.ndarray) -> tuple: + def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: x_center = (bbox[2] - bbox[0]) / 2 y_center = (bbox[3] - bbox[1]) / 2 grid_x_center = int(x_center // self.grid_size) @@ -129,7 +129,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: if dets.confidence[det_idx] < self.min_confidence: continue - pos = self._discretized_grid_cell_idation(dets.xyxy[det_idx], 1, 1) + pos = self._discretized_grid_cell_id(dets.xyxy[det_idx]) cell_id = hash(pos) node = TrackNode( @@ -216,7 +216,7 @@ def process_tracks(self) -> sv.Detections: for track_id, path in enumerate(paths, start=1): for node in path: for det_idx, det in enumerate(self.detection_buffer[node.frame_id]): - pos = self._discretized_grid_cell_idation(det.xyxy[det_idx], 1, 1) + pos = self._discretized_grid_cell_idation(det.xyxy[det_idx]) if pos == node.position: assignments[(node.frame_id, node.detection_id)] = track_id From 123565707efa39800740c94e0336c20b0ecd4c10 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:03:34 -0400 Subject: [PATCH 022/100] FIX: Updated xyxy array type --- trackers/core/ksptracker/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 28f00e81d..57452de5 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -129,7 +129,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: if dets.confidence[det_idx] < self.min_confidence: continue - pos = self._discretized_grid_cell_id(dets.xyxy[det_idx]) + pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) cell_id = hash(pos) node = TrackNode( @@ -216,7 +216,7 @@ def process_tracks(self) -> sv.Detections: for track_id, path in enumerate(paths, start=1): for node in path: for det_idx, det in enumerate(self.detection_buffer[node.frame_id]): - pos = self._discretized_grid_cell_idation(det.xyxy[det_idx]) + pos = self._discretized_grid_cell_idation(np.array(det.xyxy[det_idx])) if pos == node.position: assignments[(node.frame_id, node.detection_id)] = track_id From c85e0b8514cfccbcc5e29327983b868e601a13f9 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:15:38 -0400 Subject: [PATCH 023/100] FIX: Fixes after testing --- trackers/core/ksptracker/tracker.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 57452de5..15ba141a 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -30,7 +30,7 @@ def __hash__(self) -> int: Returns: int: Hash value of the node """ - return hash((self.frame_id, self.detection_id)) + return hash((self.frame_id, self.grid_cell_id)) def __eq__(self, other: Any) -> bool: """Compares equality based on frame_id and detection_id. @@ -43,9 +43,9 @@ def __eq__(self, other: Any) -> bool: """ if not isinstance(other, TrackNode): return False - return (self.frame_id, self.detection_id) == ( + return (self.frame_id, self.grid_cell_id) == ( other.frame_id, - other.detection_id, + other.grid_cell_id, ) @@ -216,8 +216,9 @@ def process_tracks(self) -> sv.Detections: for track_id, path in enumerate(paths, start=1): for node in path: for det_idx, det in enumerate(self.detection_buffer[node.frame_id]): - pos = self._discretized_grid_cell_idation(np.array(det.xyxy[det_idx])) + # print(det) + pos = self._discretized_grid_cell_id(np.array(det[0])) if pos == node.position: - assignments[(node.frame_id, node.detection_id)] = track_id + assignments[(node.frame_id, node.grid_cell_id)] = track_id return self._update_detections_with_tracks(assignments=assignments) From 49a6b2e08deaa41d884106915eaad0a72602489c Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:33:52 -0400 Subject: [PATCH 024/100] UPDATE: Update KSP --- trackers/core/ksptracker/tracker.py | 49 +++++++++++++++-------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 15ba141a..37bdd8ab 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -55,7 +55,6 @@ class KSPTracker(BaseTracker): Attributes: max_gap (int): Maximum allowed frame gap between detections in a track min_confidence (float): Minimum confidence threshold for detections - max_paths (int): Maximum number of paths to find in KSP algorithm max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edges detection_buffer (List[sv.Detections]): Buffer storing all frame detections """ @@ -65,7 +64,6 @@ def __init__( grid_size: int = 20, max_gap: int = 30, min_confidence: float = 0.3, - max_paths: int = 1000, max_distance: float = 0.3, ) -> None: """Initialize KSP tracker with configuration parameters. @@ -74,13 +72,11 @@ def __init__( grid_size (int): Size (in pixels) of each square cell in the spatial grid max_gap (int): Max frame gap between connected detections min_confidence (float): Minimum detection confidence - max_paths (int): Maximum number of paths to find max_distance (float): Max dissimilarity (1-IoU) for connections """ self.grid_size = grid_size self.max_gap = max_gap self.min_confidence = min_confidence - self.max_paths = max_paths self.max_distance = max_distance self.reset() @@ -121,6 +117,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: G.add_node("source") G.add_node("sink") + self.node_to_detection: Dict[TrackNode, tuple] = {} node_dict: Dict[int, List[TrackNode]] = {} for frame_idx, dets in enumerate(all_detections): @@ -141,9 +138,10 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: G.add_node(node) node_dict[frame_idx].append(node) + self.node_to_detection[node] = (frame_idx, det_idx) if frame_idx == 0: - G.add_edge("source", node, weight=-node.confidence) + G.add_edge("source", node, weight=max(-node.confidence, 0.001)) if frame_idx == len(all_detections) - 1: G.add_edge(node, "sink", weight=0) @@ -153,7 +151,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: for node_next in node_dict[i + 1]: dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) if dist <= 2: - G.add_edge(node, node_next, weight=dist - node_next.confidence) + G.add_edge(node, node_next, weight=max(dist - node_next.confidence, 0.001)) return G @@ -169,6 +167,15 @@ def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: all_detections = [] all_tracker_ids = [] + assigned = set() + assignment_map = {} + for track_id, path in enumerate(assignments, start=1): + for node in path: + det_key = self.node_to_detection.get(node) + if det_key and det_key not in assigned: + assignment_map[det_key] = track_id + assigned.add(det_key) + for frame_idx, dets in enumerate(self.detection_buffer): frame_tracker_ids = [-1] * len(dets) @@ -195,11 +202,18 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: List[List[TrackNode]]: List of paths, each path is list of TrackNodes """ paths: List[List[TrackNode]] = [] - for path in nx.shortest_simple_paths(graph, "source", "sink", weight="weight"): - if len(paths) >= self.max_paths: + G_copy = graph.copy() + + while True: + try: + path = nx.shortest_path(G_copy, source="source", target="sink", weight="weight") + if len(path) < 2: + break + paths.append(path[1:-1]) + for node in path[1:-1]: + G_copy.remove_node(node) + except nx.NetworkXNoPath: break - # Remove source and sink nodes from path - paths.append(path[1:-1]) return paths def process_tracks(self) -> sv.Detections: @@ -209,16 +223,5 @@ def process_tracks(self) -> sv.Detections: sv.Detections: Detections with assigned track IDs """ graph = self._build_graph(self.detection_buffer) - paths = self.ksp(graph) - - # Assign track IDs - assignments = {} - for track_id, path in enumerate(paths, start=1): - for node in path: - for det_idx, det in enumerate(self.detection_buffer[node.frame_id]): - # print(det) - pos = self._discretized_grid_cell_id(np.array(det[0])) - if pos == node.position: - assignments[(node.frame_id, node.grid_cell_id)] = track_id - - return self._update_detections_with_tracks(assignments=assignments) + disjoint_paths = self.ksp(graph) + return self._update_detections_with_tracks(assignments=disjoint_paths) From 1f965e6a2b8f844254ba9f6a9ed82b4e6c004c9d Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:34:39 -0400 Subject: [PATCH 025/100] ADD: Added Docstrings --- trackers/core/ksptracker/tracker.py | 119 +++++++++++++++++----------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 37bdd8ab..36e1d7dd 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -10,36 +10,38 @@ @dataclass(frozen=True) class TrackNode: - """Represents a detection node in the tracking graph. + """ + Represents a detection node in the tracking graph. Attributes: frame_id (int): Frame index where detection occurred - grid_cell_id (int): Discretized grid cell id where the detection center - position (tuple): Grid coordinates of the detection center (x_center, y_center) + grid_cell_id (int): Discretized grid cell ID of detection center + position (tuple): Grid coordinates (x_bin, y_bin) confidence (float): Detection confidence score """ - frame_id: int grid_cell_id: int position: tuple confidence: float def __hash__(self) -> int: - """Generates hash based on frame_id and detection_id. + """ + Generate hash using frame and grid cell. Returns: - int: Hash value of the node + int: Hash value for node """ return hash((self.frame_id, self.grid_cell_id)) def __eq__(self, other: Any) -> bool: - """Compares equality based on frame_id and detection_id. + """ + Compare nodes by frame and grid cell ID. Args: - other (Any): Object to compare with + other (Any): Object to compare Returns: - bool: True if nodes are equal, False otherwise + bool: True if same node, False otherwise """ if not isinstance(other, TrackNode): return False @@ -50,13 +52,14 @@ def __eq__(self, other: Any) -> bool: class KSPTracker(BaseTracker): - """Offline tracker using K-Shortest Paths (KSP) algorithm. + """ + Offline tracker using K-Shortest Paths (KSP). Attributes: - max_gap (int): Maximum allowed frame gap between detections in a track - min_confidence (float): Minimum confidence threshold for detections - max_distance (float): Maximum allowed dissimilarity (1 - IoU) for edges - detection_buffer (List[sv.Detections]): Buffer storing all frame detections + grid_size (int): Size of each grid cell (in pixels) + max_gap (int): Max frame gap between connections + min_confidence (float): Minimum detection confidence + max_distance (float): Max dissimilarity (1 - IoU) allowed """ def __init__( @@ -66,13 +69,14 @@ def __init__( min_confidence: float = 0.3, max_distance: float = 0.3, ) -> None: - """Initialize KSP tracker with configuration parameters. + """ + Initialize KSP tracker with config parameters. Args: - grid_size (int): Size (in pixels) of each square cell in the spatial grid - max_gap (int): Max frame gap between connected detections - min_confidence (float): Minimum detection confidence - max_distance (float): Max dissimilarity (1-IoU) for connections + grid_size (int): Pixel size of each grid cell + max_gap (int): Max frames between connected detections + min_confidence (float): Min detection confidence + max_distance (float): Max allowed dissimilarity """ self.grid_size = grid_size self.max_gap = max_gap @@ -81,37 +85,49 @@ def __init__( self.reset() def reset(self) -> None: - """Reset the tracker's internal state.""" + """ + Reset the internal detection buffer. + """ self.detection_buffer: List[sv.Detections] = [] def update(self, detections: sv.Detections) -> sv.Detections: - """Update tracker with new detections (stores without processing). + """ + Append new detections to the buffer. Args: - detections (sv.Detections): New detections for current frame + detections (sv.Detections): Frame detections Returns: - sv.Detections: Input detections (unmodified) + sv.Detections: Same as input """ self.detection_buffer.append(detections) return detections - + def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: + """ + Get grid cell ID from bbox center. + + Args: + bbox (np.ndarray): Bounding box coordinates + + Returns: + tuple: Grid (x_bin, y_bin) + """ x_center = (bbox[2] - bbox[0]) / 2 y_center = (bbox[3] - bbox[1]) / 2 grid_x_center = int(x_center // self.grid_size) grid_y_center = int(y_center // self.grid_size) - return (grid_x_center, grid_y_center) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: - """Build directed graph from all detections. + """ + Build graph from all buffered detections. Args: - all_detections (List[sv.Detections]): List of detections from frames + all_detections (List[sv.Detections]): All video detections Returns: - nx.DiGraph: Directed graph with detection nodes and edges + nx.DiGraph: Directed graph with detection nodes """ G = nx.DiGraph() G.add_node("source") @@ -142,27 +158,35 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: if frame_idx == 0: G.add_edge("source", node, weight=max(-node.confidence, 0.001)) - if frame_idx == len(all_detections) - 1: G.add_edge(node, "sink", weight=0) for i in range(len(all_detections) - 1): for node in node_dict[i]: for node_next in node_dict[i + 1]: - dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) + dist = np.linalg.norm( + np.array(node.position) - np.array(node_next.position) + ) if dist <= 2: - G.add_edge(node, node_next, weight=max(dist - node_next.confidence, 0.001)) + G.add_edge( + node_next, + node, + weight=max(dist - node_next.confidence, 0.001), + ) return G - def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: - """Update detections with track IDs based on assignments. + def _update_detections_with_tracks( + self, assignments: Dict + ) -> sv.Detections: + """ + Assign track IDs to detections. Args: - assignments (Dict): Maps (frame_id, det_id) to track_id + assignments (Dict): Paths from KSP with track IDs Returns: - sv.Detections: Updated detections with tracker_ids assigned + sv.Detections: Merged detections with tracker IDs """ all_detections = [] all_tracker_ids = [] @@ -178,35 +202,36 @@ def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: for frame_idx, dets in enumerate(self.detection_buffer): frame_tracker_ids = [-1] * len(dets) - for det_idx in range(len(dets)): key = (frame_idx, det_idx) - if key in assignments: - frame_tracker_ids[det_idx] = assignments[key] + if key in assignment_map: + frame_tracker_ids[det_idx] = assignment_map[key] all_detections.append(dets) all_tracker_ids.extend(frame_tracker_ids) final_detections = sv.Detections.merge(all_detections) final_detections.tracker_id = np.array(all_tracker_ids) - return final_detections def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: - """Find K-shortest paths in the graph. + """ + Find multiple disjoint shortest paths. Args: - graph (nx.DiGraph): Directed graph of detection nodes + graph (nx.DiGraph): Detection graph Returns: - List[List[TrackNode]]: List of paths, each path is list of TrackNodes + List[List[TrackNode]]: Disjoint detection paths """ paths: List[List[TrackNode]] = [] G_copy = graph.copy() while True: try: - path = nx.shortest_path(G_copy, source="source", target="sink", weight="weight") + path = nx.shortest_path( + G_copy, source="source", target="sink", weight="weight" + ) if len(path) < 2: break paths.append(path[1:-1]) @@ -214,14 +239,16 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: G_copy.remove_node(node) except nx.NetworkXNoPath: break + return paths def process_tracks(self) -> sv.Detections: - """Process all buffered detections to create final tracks. + """ + Run tracker and assign detections to tracks. Returns: - sv.Detections: Detections with assigned track IDs + sv.Detections: Final detections with track IDs """ graph = self._build_graph(self.detection_buffer) disjoint_paths = self.ksp(graph) - return self._update_detections_with_tracks(assignments=disjoint_paths) + return self._update_detections_with_tracks(assignments=disjoint_paths) \ No newline at end of file From 8f9e00b09b636594f1dd797b39f30321dc08f798 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 06:37:30 +0000 Subject: [PATCH 026/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 1a56f30d..19cb9d6e 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -19,6 +19,7 @@ class TrackNode: position (tuple): Grid coordinates (x_bin, y_bin) confidence (float): Detection confidence score """ + frame_id: int grid_cell_id: int position: tuple @@ -176,9 +177,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks( - self, assignments: Dict - ) -> sv.Detections: + def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: """ Assign track IDs to detections. @@ -251,4 +250,4 @@ def process_tracks(self) -> sv.Detections: """ graph = self._build_graph(self.detection_buffer) disjoint_paths = self.ksp(graph) - return self._update_detections_with_tracks(assignments=disjoint_paths) \ No newline at end of file + return self._update_detections_with_tracks(assignments=disjoint_paths) From 0e37b383e4b540deacccd8a24bcc6e392eb97e5f Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 02:45:44 -0400 Subject: [PATCH 027/100] FIX: precommit fix --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 19cb9d6e..131e8596 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -177,7 +177,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks(self, assignments: Dict) -> sv.Detections: + def _update_detections_with_tracks(self, assignments: List[List[TrackNode]]) -> sv.Detections: """ Assign track IDs to detections. From cc1f5d7710f832145dccb8a915adf5ef56d5b6b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 06:46:01 +0000 Subject: [PATCH 028/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 131e8596..7811f1ed 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -177,7 +177,9 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks(self, assignments: List[List[TrackNode]]) -> sv.Detections: + def _update_detections_with_tracks( + self, assignments: List[List[TrackNode]] + ) -> sv.Detections: """ Assign track IDs to detections. From 1f43fc5625e422533a4ba14293ff59c027b20e19 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 03:10:12 -0400 Subject: [PATCH 029/100] UPDATE: Made process_tracks return list of detections --- trackers/core/ksptracker/tracker.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 7811f1ed..75253444 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -177,9 +177,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks( - self, assignments: List[List[TrackNode]] - ) -> sv.Detections: + def _update_detections_with_tracks(self, assignments: List[List[TrackNode]]) -> List[sv.Detections]: """ Assign track IDs to detections. @@ -187,10 +185,9 @@ def _update_detections_with_tracks( assignments (Dict): Paths from KSP with track IDs Returns: - sv.Detections: Merged detections with tracker IDs + List[sv.Detections]: Merged detections with tracker IDs """ all_detections = [] - all_tracker_ids = [] assigned = set() assignment_map = {} @@ -208,12 +205,10 @@ def _update_detections_with_tracks( if key in assignment_map: frame_tracker_ids[det_idx] = assignment_map[key] + dets.tracker_id = np.array(frame_tracker_ids) all_detections.append(dets) - all_tracker_ids.extend(frame_tracker_ids) - final_detections = sv.Detections.merge(all_detections) - final_detections.tracker_id = np.array(all_tracker_ids) - return final_detections + return all_detections def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: """ @@ -243,12 +238,12 @@ def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: return paths - def process_tracks(self) -> sv.Detections: + def process_tracks(self) -> List[sv.Detections]: """ Run tracker and assign detections to tracks. Returns: - sv.Detections: Final detections with track IDs + List[sv.Detections]: Final detections with track IDs """ graph = self._build_graph(self.detection_buffer) disjoint_paths = self.ksp(graph) From 2cf26d64a0c4a15bd8cff393ff499415d0044a59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 07:12:28 +0000 Subject: [PATCH 030/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 75253444..11c03bc1 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -177,7 +177,9 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: return G - def _update_detections_with_tracks(self, assignments: List[List[TrackNode]]) -> List[sv.Detections]: + def _update_detections_with_tracks( + self, assignments: List[List[TrackNode]] + ) -> List[sv.Detections]: """ Assign track IDs to detections. From 11b68a48edafa2d04625b53f32aefbb774cfc061 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 19 Jun 2025 14:23:47 -0400 Subject: [PATCH 031/100] UPDATE: removed all unused occurrences of 'max_gap' --- trackers/core/ksptracker/tracker.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 11c03bc1..6013c043 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -58,7 +58,6 @@ class KSPTracker(BaseTracker): Attributes: grid_size (int): Size of each grid cell (in pixels) - max_gap (int): Max frame gap between connections min_confidence (float): Minimum detection confidence max_distance (float): Max dissimilarity (1 - IoU) allowed """ @@ -66,7 +65,6 @@ class KSPTracker(BaseTracker): def __init__( self, grid_size: int = 20, - max_gap: int = 30, min_confidence: float = 0.3, max_distance: float = 0.3, ) -> None: @@ -75,12 +73,10 @@ def __init__( Args: grid_size (int): Pixel size of each grid cell - max_gap (int): Max frames between connected detections min_confidence (float): Min detection confidence max_distance (float): Max allowed dissimilarity """ self.grid_size = grid_size - self.max_gap = max_gap self.min_confidence = min_confidence self.max_distance = max_distance self.reset() From 6b8fbc3db04cf234685192cbd1ef629f9dbe23a1 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 20 Jun 2025 14:06:28 -0400 Subject: [PATCH 032/100] UPDATE: Updated KSP solver --- trackers/core/ksptracker/tracker.py | 164 +++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 18 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 6013c043..e8fe2902 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -65,6 +65,7 @@ class KSPTracker(BaseTracker): def __init__( self, grid_size: int = 20, + max_paths: int = 20, min_confidence: float = 0.3, max_distance: float = 0.3, ) -> None: @@ -79,6 +80,7 @@ def __init__( self.grid_size = grid_size self.min_confidence = min_confidence self.max_distance = max_distance + self.max_paths = max_paths self.reset() def reset(self) -> None: @@ -115,6 +117,9 @@ def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: grid_x_center = int(x_center // self.grid_size) grid_y_center = int(y_center // self.grid_size) return (grid_x_center, grid_y_center) + + def _edge_cost(self, confidence: float): + return -np.log( confidence / ((1 - confidence) + 1e6)) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: """ @@ -152,9 +157,9 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: G.add_node(node) node_dict[frame_idx].append(node) self.node_to_detection[node] = (frame_idx, det_idx) - + if frame_idx == 0: - G.add_edge("source", node, weight=max(-node.confidence, 0.001)) + G.add_edge("source", node, weight=0) if frame_idx == len(all_detections) - 1: G.add_edge(node, "sink", weight=0) @@ -166,10 +171,29 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: ) if dist <= 2: G.add_edge( - node_next, node, - weight=max(dist - node_next.confidence, 0.001), + node_next, + weight=self._edge_cost(confidence=node.confidence), ) + print(f"Number of detections in frame 0: {len(node_dict.get(0, []))}") + print(f"Number of detections in last frame: {len(node_dict.get(len(all_detections) - 1, []))}") + + print("Edges from source:") + for u, v in G.edges("source"): + print(f"source -> {v}") + + print("Edges to sink:") + for u, v in G.in_edges("sink"): + print(f"{u} -> sink") + + for i in range(len(all_detections) - 1): + edges_between = 0 + for node in node_dict[i]: + for node_next in node_dict[i + 1]: + if G.has_edge(node, node_next): + edges_between += 1 + print(f"Edges between frame {i} and {i+1}: {edges_between}") + return G @@ -208,33 +232,137 @@ def _update_detections_with_tracks( return all_detections - def ksp(self, graph: nx.DiGraph) -> List[List[TrackNode]]: + def _shortest_path(self, G: nx.DiGraph) -> tuple: + """ + Compute the shortest path from 'source' to 'sink' using Bellman-Ford. + + Args: + G (nx.DiGraph): Graph with possible negative edge weights. + + Returns: + tuple: (path, total_cost, lengths) where path is a list of nodes, + total_cost is the path weight, and lengths is a dict of all + shortest distances from source. + + Raises: + RuntimeError: If a negative weight cycle is detected. + KeyError: If 'sink' is not reachable. + """ + try: + lengths, paths = nx.single_source_bellman_ford(G, "source", weight="weight") + if "sink" not in paths: + raise KeyError("No path found from source to sink.") + return paths["sink"], lengths["sink"], lengths + except nx.NetworkXUnbounded: + raise RuntimeError("Graph contains a negative weight cycle.") + + def _extend_graph(self, G: nx.DiGraph, paths: List[List[TrackNode]]) -> nx.DiGraph: + """ + Extend the graph by removing previously used nodes. + + Args: + G (nx.DiGraph): Original detection graph. + paths (List[List[TrackNode]]): Found disjoint paths. + + Returns: + nx.DiGraph: Modified graph with used nodes removed. + """ + G_extended = G.copy() + for path in paths: + for node in path: + if node in G_extended: + if node in G_extended and node not in {"source", "sink"}: + G_extended.remove_node(node) + return G_extended + + def _transform_edge_cost(self, G: nx.DiGraph, shortest_costs: Dict[Any, float]) -> nx.DiGraph: + """ + Transform edge weights to non-negative using cost shifting. + + Args: + G (nx.DiGraph): Graph with possibly negative weights. + shortest_costs (dict): Shortest distances from source. + + Returns: + nx.DiGraph: Cost-shifted graph. + """ + Gc = nx.DiGraph() + for u, v, data in G.edges(data=True): + if u not in shortest_costs or v not in shortest_costs: + continue + original = data["weight"] + transformed = original + shortest_costs[u] - shortest_costs[v] + Gc.add_edge(u, v, weight=transformed) + return Gc + + def _interlace_paths( + self, current_paths: List[List[TrackNode]], new_path: List[TrackNode] + ) -> List[TrackNode]: + """ + Filter out nodes from the new path that conflict with existing paths. + + Args: + current_paths (List[List[TrackNode]]): Existing disjoint paths. + new_path (List[TrackNode]): New shortest path candidate. + + Returns: + List[TrackNode]: Cleaned, non-conflicting path. + """ + used_nodes = set() + for path in current_paths: + for node in path: + if isinstance(node, TrackNode): # Skip 'source'/'sink' nodes + used_nodes.add((node.frame_id, node.grid_cell_id)) + + interlaced = [] + for node in new_path: + if isinstance(node, TrackNode): + key = (node.frame_id, node.grid_cell_id) + if key not in used_nodes: + interlaced.append(node) + + return interlaced + + + + def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: """ Find multiple disjoint shortest paths. Args: - graph (nx.DiGraph): Detection graph + G (nx.DiGraph): Detection graph. Returns: - List[List[TrackNode]]: Disjoint detection paths + List[List[TrackNode]]: List of disjoint detection paths. """ - paths: List[List[TrackNode]] = [] - G_copy = graph.copy() + path, cost, lengths = self._shortest_path(G) + P = [path] + cost_P = [cost] + + for l in range(1, self.max_paths): + if l != 1 and cost_P[-1] >= cost_P[-2]: + return P # early termination + + Gl = self._extend_graph(G, P) + Gc_l = self._transform_edge_cost(Gl, lengths) - while True: try: - path = nx.shortest_path( - G_copy, source="source", target="sink", weight="weight" - ) - if len(path) < 2: + dkslengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") + + if "sink" not in paths_dict: break - paths.append(path[1:-1]) - for node in path[1:-1]: - G_copy.remove_node(node) + + new_path = paths_dict["sink"] + cost_P.append(lengths["sink"]) + print(new_path) + interlaced_path = self._interlace_paths(P, new_path) + P.append(interlaced_path) + + lengths = dkslengths except nx.NetworkXNoPath: break - return paths + return P def process_tracks(self) -> List[sv.Detections]: """ From 5134e5e6af8f35ca6afe69a3b6d9ab86cf565ee2 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 20 Jun 2025 14:07:55 -0400 Subject: [PATCH 033/100] ADD: Docstrings --- trackers/core/ksptracker/tracker.py | 181 ++++++++++++---------------- 1 file changed, 77 insertions(+), 104 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index e8fe2902..6fe99ed2 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -14,10 +14,10 @@ class TrackNode: Represents a detection node in the tracking graph. Attributes: - frame_id (int): Frame index where detection occurred - grid_cell_id (int): Discretized grid cell ID of detection center - position (tuple): Grid coordinates (x_bin, y_bin) - confidence (float): Detection confidence score + frame_id (int): Frame index where detection occurred. + grid_cell_id (int): Discretized grid cell ID of detection center. + position (tuple): Grid coordinates (x_bin, y_bin). + confidence (float): Detection confidence score. """ frame_id: int @@ -26,40 +26,25 @@ class TrackNode: confidence: float def __hash__(self) -> int: - """ - Generate hash using frame and grid cell. - - Returns: - int: Hash value for node - """ + """Generate hash using frame and grid cell.""" return hash((self.frame_id, self.grid_cell_id)) def __eq__(self, other: Any) -> bool: - """ - Compare nodes by frame and grid cell ID. - - Args: - other (Any): Object to compare - - Returns: - bool: True if same node, False otherwise - """ + """Compare nodes by frame and grid cell ID.""" if not isinstance(other, TrackNode): return False - return (self.frame_id, self.grid_cell_id) == ( - other.frame_id, - other.grid_cell_id, - ) + return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) class KSPTracker(BaseTracker): """ - Offline tracker using K-Shortest Paths (KSP). + Offline tracker using K-Shortest Paths (KSP) algorithm. Attributes: - grid_size (int): Size of each grid cell (in pixels) - min_confidence (float): Minimum detection confidence - max_distance (float): Max dissimilarity (1 - IoU) allowed + grid_size (int): Size of each grid cell (pixels). + min_confidence (float): Minimum detection confidence to consider. + max_distance (float): Maximum spatial distance between nodes to connect. + max_paths (int): Maximum number of paths (tracks) to find. """ def __init__( @@ -70,12 +55,13 @@ def __init__( max_distance: float = 0.3, ) -> None: """ - Initialize KSP tracker with config parameters. + Initialize the KSP tracker. Args: - grid_size (int): Pixel size of each grid cell - min_confidence (float): Min detection confidence - max_distance (float): Max allowed dissimilarity + grid_size (int): Pixel size of grid cells. + max_paths (int): Max number of paths to find. + min_confidence (float): Minimum confidence to keep detection. + max_distance (float): Max allowed spatial distance between nodes. """ self.grid_size = grid_size self.min_confidence = min_confidence @@ -84,9 +70,7 @@ def __init__( self.reset() def reset(self) -> None: - """ - Reset the internal detection buffer. - """ + """Reset the detection buffer.""" self.detection_buffer: List[sv.Detections] = [] def update(self, detections: sv.Detections) -> sv.Detections: @@ -94,42 +78,52 @@ def update(self, detections: sv.Detections) -> sv.Detections: Append new detections to the buffer. Args: - detections (sv.Detections): Frame detections + detections (sv.Detections): Detections for the current frame. Returns: - sv.Detections: Same as input + sv.Detections: The same detections passed in. """ self.detection_buffer.append(detections) return detections def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: """ - Get grid cell ID from bbox center. + Compute discretized grid cell coordinates from bbox center. Args: - bbox (np.ndarray): Bounding box coordinates + bbox (np.ndarray): Bounding box [x1, y1, x2, y2]. Returns: - tuple: Grid (x_bin, y_bin) + tuple: (grid_x, grid_y) discretized coordinates. """ - x_center = (bbox[2] - bbox[0]) / 2 - y_center = (bbox[3] - bbox[1]) / 2 - grid_x_center = int(x_center // self.grid_size) - grid_y_center = int(y_center // self.grid_size) - return (grid_x_center, grid_y_center) - - def _edge_cost(self, confidence: float): - return -np.log( confidence / ((1 - confidence) + 1e6)) + x_center = (bbox[2] + bbox[0]) / 2 + y_center = (bbox[3] + bbox[1]) / 2 + grid_x = int(x_center // self.grid_size) + grid_y = int(y_center // self.grid_size) + return (grid_x, grid_y) + + def _edge_cost(self, confidence: float) -> float: + """ + Compute edge cost from detection confidence. + + Args: + confidence (float): Detection confidence score. + + Returns: + float: Edge cost for KSP (should be non-negative after transform). + """ + # Add small epsilon to denominator to avoid division by zero + return -np.log(confidence / ((1 - confidence) + 1e-6)) def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: """ - Build graph from all buffered detections. + Build a directed graph from buffered detections. Args: - all_detections (List[sv.Detections]): All video detections + all_detections (List[sv.Detections]): List of detections per frame. Returns: - nx.DiGraph: Directed graph with detection nodes + nx.DiGraph: Directed graph representing detections and edges. """ G = nx.DiGraph() G.add_node("source") @@ -157,7 +151,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: G.add_node(node) node_dict[frame_idx].append(node) self.node_to_detection[node] = (frame_idx, det_idx) - + if frame_idx == 0: G.add_edge("source", node, weight=0) if frame_idx == len(all_detections) - 1: @@ -175,25 +169,6 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: node_next, weight=self._edge_cost(confidence=node.confidence), ) - print(f"Number of detections in frame 0: {len(node_dict.get(0, []))}") - print(f"Number of detections in last frame: {len(node_dict.get(len(all_detections) - 1, []))}") - - print("Edges from source:") - for u, v in G.edges("source"): - print(f"source -> {v}") - - print("Edges to sink:") - for u, v in G.in_edges("sink"): - print(f"{u} -> sink") - - for i in range(len(all_detections) - 1): - edges_between = 0 - for node in node_dict[i]: - for node_next in node_dict[i + 1]: - if G.has_edge(node, node_next): - edges_between += 1 - print(f"Edges between frame {i} and {i+1}: {edges_between}") - return G @@ -201,13 +176,13 @@ def _update_detections_with_tracks( self, assignments: List[List[TrackNode]] ) -> List[sv.Detections]: """ - Assign track IDs to detections. + Assign track IDs to detections based on paths. Args: - assignments (Dict): Paths from KSP with track IDs + assignments (List[List[TrackNode]]): List of detection paths. Returns: - List[sv.Detections]: Merged detections with tracker IDs + List[sv.Detections]: Detections with assigned tracker IDs. """ all_detections = [] @@ -234,19 +209,19 @@ def _update_detections_with_tracks( def _shortest_path(self, G: nx.DiGraph) -> tuple: """ - Compute the shortest path from 'source' to 'sink' using Bellman-Ford. + Compute shortest path from 'source' to 'sink' using Bellman-Ford. Args: - G (nx.DiGraph): Graph with possible negative edge weights. + G (nx.DiGraph): Graph with possible negative edges. Returns: - tuple: (path, total_cost, lengths) where path is a list of nodes, - total_cost is the path weight, and lengths is a dict of all - shortest distances from source. + tuple: (path, total_cost, lengths) where path is list of nodes, + total_cost is the total weight of that path, and lengths is + dict of shortest distances from source. Raises: - RuntimeError: If a negative weight cycle is detected. - KeyError: If 'sink' is not reachable. + RuntimeError: If negative cycle detected. + KeyError: If sink unreachable. """ try: lengths, paths = nx.single_source_bellman_ford(G, "source", weight="weight") @@ -258,33 +233,34 @@ def _shortest_path(self, G: nx.DiGraph) -> tuple: def _extend_graph(self, G: nx.DiGraph, paths: List[List[TrackNode]]) -> nx.DiGraph: """ - Extend the graph by removing previously used nodes. + Remove nodes used in previous paths to enforce disjointness. Args: - G (nx.DiGraph): Original detection graph. - paths (List[List[TrackNode]]): Found disjoint paths. + G (nx.DiGraph): Original graph. + paths (List[List[TrackNode]]): Previously found paths. Returns: - nx.DiGraph: Modified graph with used nodes removed. + nx.DiGraph: Extended graph with used nodes removed. """ G_extended = G.copy() for path in paths: for node in path: - if node in G_extended: - if node in G_extended and node not in {"source", "sink"}: - G_extended.remove_node(node) + if node in G_extended and node not in {"source", "sink"}: + G_extended.remove_node(node) return G_extended - def _transform_edge_cost(self, G: nx.DiGraph, shortest_costs: Dict[Any, float]) -> nx.DiGraph: + def _transform_edge_cost( + self, G: nx.DiGraph, shortest_costs: Dict[Any, float] + ) -> nx.DiGraph: """ - Transform edge weights to non-negative using cost shifting. + Apply cost transformation to ensure non-negative edge weights. Args: G (nx.DiGraph): Graph with possibly negative weights. - shortest_costs (dict): Shortest distances from source. + shortest_costs (dict): Shortest path distances from source. Returns: - nx.DiGraph: Cost-shifted graph. + nx.DiGraph: Cost-transformed graph. """ Gc = nx.DiGraph() for u, v, data in G.edges(data=True): @@ -294,24 +270,24 @@ def _transform_edge_cost(self, G: nx.DiGraph, shortest_costs: Dict[Any, float]) transformed = original + shortest_costs[u] - shortest_costs[v] Gc.add_edge(u, v, weight=transformed) return Gc - + def _interlace_paths( self, current_paths: List[List[TrackNode]], new_path: List[TrackNode] ) -> List[TrackNode]: """ - Filter out nodes from the new path that conflict with existing paths. + Remove nodes from new_path that conflict with current_paths. Args: current_paths (List[List[TrackNode]]): Existing disjoint paths. - new_path (List[TrackNode]): New shortest path candidate. + new_path (List[TrackNode]): New candidate path. Returns: - List[TrackNode]: Cleaned, non-conflicting path. + List[TrackNode]: Interlaced path without conflicts. """ used_nodes = set() for path in current_paths: for node in path: - if isinstance(node, TrackNode): # Skip 'source'/'sink' nodes + if isinstance(node, TrackNode): used_nodes.add((node.frame_id, node.grid_cell_id)) interlaced = [] @@ -323,11 +299,9 @@ def _interlace_paths( return interlaced - - def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: """ - Find multiple disjoint shortest paths. + Compute k disjoint shortest paths using KSP algorithm. Args: G (nx.DiGraph): Detection graph. @@ -347,18 +321,17 @@ def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: Gc_l = self._transform_edge_cost(Gl, lengths) try: - dkslengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") + lengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") if "sink" not in paths_dict: break new_path = paths_dict["sink"] cost_P.append(lengths["sink"]) - print(new_path) + interlaced_path = self._interlace_paths(P, new_path) P.append(interlaced_path) - lengths = dkslengths except nx.NetworkXNoPath: break @@ -366,10 +339,10 @@ def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: def process_tracks(self) -> List[sv.Detections]: """ - Run tracker and assign detections to tracks. + Run the tracking algorithm and assign track IDs to detections. Returns: - List[sv.Detections]: Final detections with track IDs + List[sv.Detections]: Detections updated with tracker IDs. """ graph = self._build_graph(self.detection_buffer) disjoint_paths = self.ksp(graph) From c0a026982394f9f6441da8545a85086ee10634ce Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 20 Jun 2025 14:12:38 -0400 Subject: [PATCH 034/100] UPDATE: Updated graph to be a member variable --- trackers/core/ksptracker/tracker.py | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 6fe99ed2..ffdb718d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -67,6 +67,7 @@ def __init__( self.min_confidence = min_confidence self.max_distance = max_distance self.max_paths = max_paths + self.G = nx.DiGraph() self.reset() def reset(self) -> None: @@ -125,9 +126,9 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: Returns: nx.DiGraph: Directed graph representing detections and edges. """ - G = nx.DiGraph() - G.add_node("source") - G.add_node("sink") + self.G = nx.DiGraph() + self.G.add_node("source") + self.G.add_node("sink") self.node_to_detection: Dict[TrackNode, tuple] = {} node_dict: Dict[int, List[TrackNode]] = {} @@ -148,14 +149,14 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: confidence=dets.confidence[det_idx], ) - G.add_node(node) + self.G.add_node(node) node_dict[frame_idx].append(node) self.node_to_detection[node] = (frame_idx, det_idx) if frame_idx == 0: - G.add_edge("source", node, weight=0) + self.G.add_edge("source", node, weight=0) if frame_idx == len(all_detections) - 1: - G.add_edge(node, "sink", weight=0) + self.G.add_edge(node, "sink", weight=0) for i in range(len(all_detections) - 1): for node in node_dict[i]: @@ -164,13 +165,12 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: np.array(node.position) - np.array(node_next.position) ) if dist <= 2: - G.add_edge( + self.G.add_edge( node, node_next, weight=self._edge_cost(confidence=node.confidence), ) - return G def _update_detections_with_tracks( self, assignments: List[List[TrackNode]] @@ -207,12 +207,12 @@ def _update_detections_with_tracks( return all_detections - def _shortest_path(self, G: nx.DiGraph) -> tuple: + def _shortest_path(self) -> tuple: """ Compute shortest path from 'source' to 'sink' using Bellman-Ford. Args: - G (nx.DiGraph): Graph with possible negative edges. + self.G (nx.DiGraph): Graph with possible negative edges. Returns: tuple: (path, total_cost, lengths) where path is list of nodes, @@ -224,25 +224,25 @@ def _shortest_path(self, G: nx.DiGraph) -> tuple: KeyError: If sink unreachable. """ try: - lengths, paths = nx.single_source_bellman_ford(G, "source", weight="weight") + lengths, paths = nx.single_source_bellman_ford(self.G, "source", weight="weight") if "sink" not in paths: raise KeyError("No path found from source to sink.") return paths["sink"], lengths["sink"], lengths except nx.NetworkXUnbounded: raise RuntimeError("Graph contains a negative weight cycle.") - def _extend_graph(self, G: nx.DiGraph, paths: List[List[TrackNode]]) -> nx.DiGraph: + def _extend_graph(self, paths: List[List[TrackNode]]) -> nx.DiGraph: """ Remove nodes used in previous paths to enforce disjointness. Args: - G (nx.DiGraph): Original graph. + self.G (nx.DiGraph): Original graph. paths (List[List[TrackNode]]): Previously found paths. Returns: nx.DiGraph: Extended graph with used nodes removed. """ - G_extended = G.copy() + G_extended = self.G.copy() for path in paths: for node in path: if node in G_extended and node not in {"source", "sink"}: @@ -250,20 +250,20 @@ def _extend_graph(self, G: nx.DiGraph, paths: List[List[TrackNode]]) -> nx.DiGra return G_extended def _transform_edge_cost( - self, G: nx.DiGraph, shortest_costs: Dict[Any, float] + self, shortest_costs: Dict[Any, float] ) -> nx.DiGraph: """ Apply cost transformation to ensure non-negative edge weights. Args: - G (nx.DiGraph): Graph with possibly negative weights. + self.G (nx.DiGraph): Graph with possibly negative weights. shortest_costs (dict): Shortest path distances from source. Returns: nx.DiGraph: Cost-transformed graph. """ Gc = nx.DiGraph() - for u, v, data in G.edges(data=True): + for u, v, data in self.G.edges(data=True): if u not in shortest_costs or v not in shortest_costs: continue original = data["weight"] @@ -299,17 +299,17 @@ def _interlace_paths( return interlaced - def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: + def ksp(self) -> List[List[TrackNode]]: """ Compute k disjoint shortest paths using KSP algorithm. Args: - G (nx.DiGraph): Detection graph. + self.G (nx.DiGraph): Detection graph. Returns: List[List[TrackNode]]: List of disjoint detection paths. """ - path, cost, lengths = self._shortest_path(G) + path, cost, lengths = self._shortest_path(self.G) P = [path] cost_P = [cost] @@ -317,7 +317,7 @@ def ksp(self, G: nx.DiGraph) -> List[List[TrackNode]]: if l != 1 and cost_P[-1] >= cost_P[-2]: return P # early termination - Gl = self._extend_graph(G, P) + Gl = self._extend_graph(self.G, P) Gc_l = self._transform_edge_cost(Gl, lengths) try: @@ -344,6 +344,6 @@ def process_tracks(self) -> List[sv.Detections]: Returns: List[sv.Detections]: Detections updated with tracker IDs. """ - graph = self._build_graph(self.detection_buffer) - disjoint_paths = self.ksp(graph) + self._build_graph(self.detection_buffer) + disjoint_paths = self.ksp() return self._update_detections_with_tracks(assignments=disjoint_paths) From 31715afa5d718841582c1906029be21a4399d4c5 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 20 Jun 2025 14:17:34 -0400 Subject: [PATCH 035/100] UPDATE: Docstrings --- trackers/core/ksptracker/tracker.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index ffdb718d..caa3b85a 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -111,20 +111,21 @@ def _edge_cost(self, confidence: float) -> float: confidence (float): Detection confidence score. Returns: - float: Edge cost for KSP (should be non-negative after transform). + float: Edge cost for KSP (non-negative after transform). """ # Add small epsilon to denominator to avoid division by zero return -np.log(confidence / ((1 - confidence) + 1e-6)) - def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: + def _build_graph(self, all_detections: List[sv.Detections]) -> None: """ Build a directed graph from buffered detections. Args: all_detections (List[sv.Detections]): List of detections per frame. - Returns: - nx.DiGraph: Directed graph representing detections and edges. + Side Effects: + Sets self.G to the constructed graph. + Populates self.node_to_detection mapping. """ self.G = nx.DiGraph() self.G.add_node("source") @@ -171,7 +172,6 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> nx.DiGraph: weight=self._edge_cost(confidence=node.confidence), ) - def _update_detections_with_tracks( self, assignments: List[List[TrackNode]] ) -> List[sv.Detections]: @@ -211,9 +211,6 @@ def _shortest_path(self) -> tuple: """ Compute shortest path from 'source' to 'sink' using Bellman-Ford. - Args: - self.G (nx.DiGraph): Graph with possible negative edges. - Returns: tuple: (path, total_cost, lengths) where path is list of nodes, total_cost is the total weight of that path, and lengths is @@ -236,7 +233,6 @@ def _extend_graph(self, paths: List[List[TrackNode]]) -> nx.DiGraph: Remove nodes used in previous paths to enforce disjointness. Args: - self.G (nx.DiGraph): Original graph. paths (List[List[TrackNode]]): Previously found paths. Returns: @@ -250,20 +246,20 @@ def _extend_graph(self, paths: List[List[TrackNode]]) -> nx.DiGraph: return G_extended def _transform_edge_cost( - self, shortest_costs: Dict[Any, float] + self, G: nx.DiGraph, shortest_costs: Dict[Any, float] ) -> nx.DiGraph: """ Apply cost transformation to ensure non-negative edge weights. Args: - self.G (nx.DiGraph): Graph with possibly negative weights. + G (nx.DiGraph): Graph with possibly negative weights. shortest_costs (dict): Shortest path distances from source. Returns: nx.DiGraph: Cost-transformed graph. """ Gc = nx.DiGraph() - for u, v, data in self.G.edges(data=True): + for u, v, data in G.edges(data=True): if u not in shortest_costs or v not in shortest_costs: continue original = data["weight"] @@ -303,13 +299,10 @@ def ksp(self) -> List[List[TrackNode]]: """ Compute k disjoint shortest paths using KSP algorithm. - Args: - self.G (nx.DiGraph): Detection graph. - Returns: List[List[TrackNode]]: List of disjoint detection paths. """ - path, cost, lengths = self._shortest_path(self.G) + path, cost, lengths = self._shortest_path() P = [path] cost_P = [cost] @@ -317,7 +310,7 @@ def ksp(self) -> List[List[TrackNode]]: if l != 1 and cost_P[-1] >= cost_P[-2]: return P # early termination - Gl = self._extend_graph(self.G, P) + Gl = self._extend_graph(P) Gc_l = self._transform_edge_cost(Gl, lengths) try: From c8535ffe4c826a1f7172523bf60e2b41177c3604 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 20 Jun 2025 14:57:27 -0400 Subject: [PATCH 036/100] BUG: Diagnosing untracked detections --- trackers/core/ksptracker/tracker.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index caa3b85a..125f0b61 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -113,7 +113,6 @@ def _edge_cost(self, confidence: float) -> float: Returns: float: Edge cost for KSP (non-negative after transform). """ - # Add small epsilon to denominator to avoid division by zero return -np.log(confidence / ((1 - confidence) + 1e-6)) def _build_graph(self, all_detections: List[sv.Detections]) -> None: @@ -205,6 +204,18 @@ def _update_detections_with_tracks( dets.tracker_id = np.array(frame_tracker_ids) all_detections.append(dets) + for frame_idx, dets in enumerate(all_detections): + tracker_ids = dets.tracker_id + num_tracked = np.sum(tracker_ids != -1) + print(f"[Frame {frame_idx}] Total Detections: {len(tracker_ids)} | Tracked: {num_tracked}") + + for det_idx, tid in enumerate(tracker_ids): + if tid == -1: + print(f" ⛔ Untracked Detection {det_idx}: BBox={dets.xyxy[det_idx]}, Conf={dets.confidence[det_idx]:.2f}") + else: + print(f" ✅ Track {tid} assigned to Detection {det_idx}: BBox={dets.xyxy[det_idx]}, Conf={dets.confidence[det_idx]:.2f}") + + return all_detections def _shortest_path(self) -> tuple: @@ -212,9 +223,7 @@ def _shortest_path(self) -> tuple: Compute shortest path from 'source' to 'sink' using Bellman-Ford. Returns: - tuple: (path, total_cost, lengths) where path is list of nodes, - total_cost is the total weight of that path, and lengths is - dict of shortest distances from source. + tuple: (path, total_cost, lengths) Raises: RuntimeError: If negative cycle detected. From 35404f2acc4dd690d59de0417bb99684d329bbaa Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 21 Jun 2025 00:39:44 -0400 Subject: [PATCH 037/100] BUG: Debugging KSP thru visuals --- trackers/core/ksptracker/tracker.py | 267 +++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 7 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 125f0b61..465923f7 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -7,7 +7,252 @@ from trackers.core.base import BaseTracker +import matplotlib.pyplot as plt +import networkx as nx + +import itertools + + +def visualize_tracking_graph_debug(G: nx.DiGraph, max_edges=500): + import matplotlib.pyplot as plt + import networkx as nx + + plt.figure(figsize=(18, 8)) + + # Collect all TrackNode nodes + track_nodes = [n for n in G.nodes if isinstance(n, TrackNode)] + frames = sorted(set(n.frame_id for n in track_nodes)) + + frame_to_x = {f: i for i, f in enumerate(frames)} + + # Group nodes by frame and sort by det_idx (or confidence) + nodes_by_frame = {} + for node in track_nodes: + nodes_by_frame.setdefault(node.frame_id, []).append(node) + for frame in nodes_by_frame: + nodes_by_frame[frame].sort(key=lambda n: n.det_idx) # or key=lambda n: -n.confidence for sorting by confidence + + pos = {} + for node in G.nodes: + if node == "source": + pos[node] = (-1, 0) + elif node == "sink": + pos[node] = (len(frames), 0) + elif isinstance(node, TrackNode): + x = frame_to_x[node.frame_id] + # vertical spacing: spread nodes evenly in y-axis + idx = nodes_by_frame[node.frame_id].index(node) + total = len(nodes_by_frame[node.frame_id]) + # spread vertically between 0 and total, centered around 0 + y = idx - total / 2 + pos[node] = (x, y) + + # Draw nodes + nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightblue", alpha=0.9) + + # Labels + labels = {} + for node in G.nodes: + if node == "source": + labels[node] = "SRC" + elif node == "sink": + labels[node] = "SNK" + elif isinstance(node, TrackNode): + labels[node] = f"F{node.frame_id},D{node.det_idx}" + nx.draw_networkx_labels(G, pos, labels, font_size=8) + + # Edges (limit number) + edges_to_draw = list(G.edges(data=True))[:max_edges] + edge_list = [(u, v) for u, v, _ in edges_to_draw] + nx.draw_networkx_edges(G, pos, edgelist=edge_list, arrowstyle='->', arrowsize=15, edge_color='gray') + + # Edge weights + edge_labels = {(u, v): f"{d['weight']:.2f}" for u, v, d in edges_to_draw} + nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=7, label_pos=0.5) + + plt.title("Tracking Graph - Directed Timeline Layout with Vertical Spacing") + plt.xlabel("Frame Index") + plt.ylabel("Detection Vertical Position (Jittered)") + plt.grid(True) + plt.tight_layout() + plt.show() + +def visualize_all_shortest_bellman_ford_paths(G: nx.DiGraph): + try: + all_paths = list(nx.all_shortest_paths(G, source="source", target="sink", weight="weight", method="bellman-ford")) + if not all_paths: + print("No path found from source to sink.") + return + shortest_path_length = sum(G[u][v]['weight'] for u, v in zip(all_paths[0][:-1], all_paths[0][1:])) + print(f"Number of shortest paths from source to sink: {len(all_paths)}") + print(f"Each shortest path has cost: {shortest_path_length:.2f}") + + except nx.NetworkXUnbounded: + print("Negative weight cycle detected.") + return + + plt.figure(figsize=(18, 8)) + + # Layout + track_nodes = [n for n in G.nodes if isinstance(n, TrackNode)] + frames = sorted(set(n.frame_id for n in track_nodes)) + frame_to_x = {f: i for i, f in enumerate(frames)} + + nodes_by_frame = {} + for node in track_nodes: + nodes_by_frame.setdefault(node.frame_id, []).append(node) + for frame in nodes_by_frame: + nodes_by_frame[frame].sort(key=lambda n: n.det_idx) + + pos = {} + for node in G.nodes: + if node == "source": + pos[node] = (-1, 0) + elif node == "sink": + pos[node] = (len(frames), 0) + elif isinstance(node, TrackNode): + x = frame_to_x[node.frame_id] + idx = nodes_by_frame[node.frame_id].index(node) + total = len(nodes_by_frame[node.frame_id]) + y = idx - total / 2 + pos[node] = (x, y) + + # Draw all nodes and labels + nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightgray", alpha=0.7) + labels = { + node: ( + "SRC" if node == "source" + else "SNK" if node == "sink" + else f"F{node.frame_id},D{node.det_idx}" + ) + for node in G.nodes + } + nx.draw_networkx_labels(G, pos, labels, font_size=8) + + # Draw all edges (light gray) + nx.draw_networkx_edges(G, pos, edge_color="lightgray", alpha=0.3) + + # Draw edge weights for all edges + edge_labels = {(u, v): f"{d['weight']:.2f}" for u, v, d in G.edges(data=True)} + nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=6) + + # Collect all edges in all shortest paths + edges_in_paths = set() + nodes_in_paths = set() + for path in all_paths: + nodes_in_paths.update(path) + edges_in_paths.update(zip(path[:-1], path[1:])) + + # Draw all nodes in shortest paths highlighted (orange) + nx.draw_networkx_nodes(G, pos, nodelist=nodes_in_paths, node_color="orange") + + # Draw all edges in shortest paths highlighted (red, thicker) + nx.draw_networkx_edges( + G, pos, + edgelist=edges_in_paths, + edge_color="red", + width=2.5, + arrowstyle="->", + arrowsize=15, + label="Shortest Paths" + ) + + plt.title("Bellman-Ford All Shortest Paths Visualization") + plt.xlabel("Frame Index") + plt.ylabel("Detection Index Offset") + plt.grid(True) + plt.tight_layout() + plt.show() + +def visualize_path(G, path, pos, path_num, color="red"): + plt.figure(figsize=(12, 6)) + + # Draw all nodes and edges lightly + nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightgray", alpha=0.7) + nx.draw_networkx_edges(G, pos, edge_color="lightgray", alpha=0.3) + labels = { + node: ( + "SRC" if node == "source" + else "SNK" if node == "sink" + else f"F{node.frame_id},D{node.det_idx}" + ) + for node in G.nodes + } + nx.draw_networkx_labels(G, pos, labels, font_size=8) + + # Highlight the path nodes and edges + nx.draw_networkx_nodes(G, pos, nodelist=path, node_color=color) + path_edges = list(zip(path[:-1], path[1:])) + nx.draw_networkx_edges(G, pos, edgelist=path_edges, edge_color=color, width=3, arrowstyle="->", arrowsize=15) + + plt.title(f"Node-Disjoint Shortest Path #{path_num}") + plt.xlabel("Frame Index") + plt.ylabel("Detection Index Offset") + plt.grid(True) + plt.tight_layout() + plt.show() + + +def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weight="weight"): + G = G_orig.copy() + + # Setup layout once to keep consistent positioning + track_nodes = [n for n in G.nodes if hasattr(n, "frame_id") and hasattr(n, "det_idx")] + frames = sorted(set(n.frame_id for n in track_nodes)) + frame_to_x = {f: i for i, f in enumerate(frames)} + nodes_by_frame = {} + for node in track_nodes: + nodes_by_frame.setdefault(node.frame_id, []).append(node) + for frame in nodes_by_frame: + nodes_by_frame[frame].sort(key=lambda n: n.det_idx) + pos = {} + for node in G.nodes: + if node == "source": + pos[node] = (-1, 0) + elif node == "sink": + pos[node] = (len(frames), 0) + elif hasattr(node, "frame_id") and hasattr(node, "det_idx"): + x = frame_to_x[node.frame_id] + idx = nodes_by_frame[node.frame_id].index(node) + total = len(nodes_by_frame[node.frame_id]) + y = idx - total / 2 + pos[node] = (x, y) + else: + pos[node] = (0, 0) # fallback + + all_paths = [] + colors = itertools.cycle(["red", "blue", "green", "orange", "purple", "brown", "cyan", "magenta"]) + + while True: + try: + length, paths = nx.single_source_bellman_ford(G, source=source, weight=weight) + if sink not in paths: + print("No more paths found.") + break + + shortest_path = paths[sink] + cost = length[sink] + all_paths.append(shortest_path) + color = next(colors) + print(f"Found path #{len(all_paths)} with cost {cost:.2f}: {shortest_path}") + # Visualize current path + visualize_path(G_orig, shortest_path, pos, len(all_paths), color=color) + + # Remove intermediate nodes to get node-disjoint paths + intermediate_nodes = shortest_path[1:-1] + G.remove_nodes_from(intermediate_nodes) + + except nx.NetworkXNoPath: + print("No more paths found.") + break + except nx.NetworkXUnbounded: + print("Negative weight cycle detected.") + break + + print(f"Total node-disjoint shortest paths found: {len(all_paths)}") + return all_paths + @dataclass(frozen=True) class TrackNode: """ @@ -22,6 +267,7 @@ class TrackNode: frame_id: int grid_cell_id: int + det_idx: int position: tuple confidence: float @@ -34,6 +280,9 @@ def __eq__(self, other: Any) -> bool: if not isinstance(other, TrackNode): return False return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) + + def __str__(self): + return str(self.frame_id) + " " + str(self.det_idx) class KSPTracker(BaseTracker): @@ -103,7 +352,7 @@ def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: grid_y = int(y_center // self.grid_size) return (grid_x, grid_y) - def _edge_cost(self, confidence: float) -> float: + def _edge_cost(self, confidence: float, dist: float) -> float: """ Compute edge cost from detection confidence. @@ -113,7 +362,7 @@ def _edge_cost(self, confidence: float) -> float: Returns: float: Edge cost for KSP (non-negative after transform). """ - return -np.log(confidence / ((1 - confidence) + 1e-6)) + return -np.log(confidence) def _build_graph(self, all_detections: List[sv.Detections]) -> None: """ @@ -145,6 +394,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: node = TrackNode( frame_id=frame_idx, grid_cell_id=cell_id, + det_idx=det_idx, position=pos, confidence=dets.confidence[det_idx], ) @@ -164,11 +414,11 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: dist = np.linalg.norm( np.array(node.position) - np.array(node_next.position) ) - if dist <= 2: - self.G.add_edge( + + self.G.add_edge( node, node_next, - weight=self._edge_cost(confidence=node.confidence), + weight=self._edge_cost(confidence=node.confidence, dist=dist), ) def _update_detections_with_tracks( @@ -347,5 +597,8 @@ def process_tracks(self) -> List[sv.Detections]: List[sv.Detections]: Detections updated with tracker IDs. """ self._build_graph(self.detection_buffer) - disjoint_paths = self.ksp() - return self._update_detections_with_tracks(assignments=disjoint_paths) + visualize_tracking_graph_debug(self.G) + paths = find_and_visualize_disjoint_paths(self.G) + return [] + # disjoint_paths = self.ksp() + # return self._update_detections_with_tracks(assignments=disjoint_paths) From 01c8cf123bdd891e3d1ee07f94dfba183a772f9a Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 21 Jun 2025 23:06:07 -0400 Subject: [PATCH 038/100] BUG: Bellman Ford path visualization --- trackers/core/ksptracker/tracker.py | 68 ++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 465923f7..7816359b 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -193,10 +193,43 @@ def visualize_path(G, path, pos, path_num, color="red"): plt.show() -def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weight="weight"): +def path_to_detections(path) -> sv.Detections: + track_nodes = [node for node in path if hasattr(node, "frame_id") and hasattr(node, "det_idx")] + + xyxys = [] + confidences = [] + class_ids = [] + + for node in track_nodes: + det_idx = node.det_idx + dets = node.dets # sv.Detections + + bbox = dets.xyxy[det_idx] + if bbox is None or len(bbox) != 4: + raise ValueError(f"Invalid bbox at node {node}: {bbox}") + + xyxys.append(bbox) + confidences.append(dets.confidence[det_idx]) + + if hasattr(dets, "class_id") and len(dets.class_id) > det_idx: + class_ids.append(dets.class_id[det_idx]) + else: + class_ids.append(0) + + xyxys = np.array(xyxys) + confidences = np.array(confidences) + class_ids = np.array(class_ids) + + if xyxys.ndim != 2 or xyxys.shape[1] != 4: + raise ValueError(f"xyxy must be 2D array with shape (_,4), got shape {xyxys.shape}") + + return sv.Detections(xyxy=xyxys, confidence=confidences, class_id=class_ids) + + +def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weight="weight") -> List[sv.Detections]: G = G_orig.copy() - - # Setup layout once to keep consistent positioning + + # Compute layout positions once for consistency track_nodes = [n for n in G.nodes if hasattr(n, "frame_id") and hasattr(n, "det_idx")] frames = sorted(set(n.frame_id for n in track_nodes)) frame_to_x = {f: i for i, f in enumerate(frames)} @@ -207,9 +240,9 @@ def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weig nodes_by_frame[frame].sort(key=lambda n: n.det_idx) pos = {} for node in G.nodes: - if node == "source": + if node == source: pos[node] = (-1, 0) - elif node == "sink": + elif node == sink: pos[node] = (len(frames), 0) elif hasattr(node, "frame_id") and hasattr(node, "det_idx"): x = frame_to_x[node.frame_id] @@ -220,7 +253,7 @@ def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weig else: pos[node] = (0, 0) # fallback - all_paths = [] + all_detections = [] colors = itertools.cycle(["red", "blue", "green", "orange", "purple", "brown", "cyan", "magenta"]) while True: @@ -232,14 +265,15 @@ def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weig shortest_path = paths[sink] cost = length[sink] - all_paths.append(shortest_path) color = next(colors) - print(f"Found path #{len(all_paths)} with cost {cost:.2f}: {shortest_path}") + print(f"Found path with cost {cost:.2f}: {shortest_path}") + + visualize_path(G_orig, shortest_path, pos, len(all_detections) + 1, color=color) - # Visualize current path - visualize_path(G_orig, shortest_path, pos, len(all_paths), color=color) + dets = path_to_detections(shortest_path) + all_detections.append(dets) - # Remove intermediate nodes to get node-disjoint paths + # Remove intermediate nodes to enforce node-disjointness intermediate_nodes = shortest_path[1:-1] G.remove_nodes_from(intermediate_nodes) @@ -250,9 +284,9 @@ def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weig print("Negative weight cycle detected.") break - print(f"Total node-disjoint shortest paths found: {len(all_paths)}") - return all_paths - + print(f"Total node-disjoint shortest paths found: {len(all_detections)}") + return all_detections + @dataclass(frozen=True) class TrackNode: """ @@ -270,6 +304,7 @@ class TrackNode: det_idx: int position: tuple confidence: float + dets: Any def __hash__(self) -> int: """Generate hash using frame and grid cell.""" @@ -396,6 +431,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: grid_cell_id=cell_id, det_idx=det_idx, position=pos, + dets=dets, confidence=dets.confidence[det_idx], ) @@ -598,7 +634,7 @@ def process_tracks(self) -> List[sv.Detections]: """ self._build_graph(self.detection_buffer) visualize_tracking_graph_debug(self.G) - paths = find_and_visualize_disjoint_paths(self.G) - return [] + detections_list = find_and_visualize_disjoint_paths(self.G) + return detections_list # disjoint_paths = self.ksp() # return self._update_detections_with_tracks(assignments=disjoint_paths) From e113c24b39dcbf0dab9c5c3baaf3f300370e8e86 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sun, 22 Jun 2025 15:17:05 -0400 Subject: [PATCH 039/100] BUG: Same edge weights --- .../core/ksptracker/InteractivePMapViewer.py | 77 +++ trackers/core/ksptracker/tracker.py | 558 ++++++++++++++++-- 2 files changed, 578 insertions(+), 57 deletions(-) create mode 100644 trackers/core/ksptracker/InteractivePMapViewer.py diff --git a/trackers/core/ksptracker/InteractivePMapViewer.py b/trackers/core/ksptracker/InteractivePMapViewer.py new file mode 100644 index 00000000..578b0ad8 --- /dev/null +++ b/trackers/core/ksptracker/InteractivePMapViewer.py @@ -0,0 +1,77 @@ +import matplotlib.pyplot as plt +import matplotlib.patches as patches +import numpy as np + +class InteractivePMapViewer: + def __init__(self, frames, p_maps, grid_size=50, alpha=0.4, show_text=True, cmap='Greens'): + self.frames = frames + self.p_maps = p_maps + self.grid_size = grid_size + self.alpha = alpha + self.show_text = show_text + self.cmap = cmap + self.idx = 0 + + self.fig, self.ax = plt.subplots() + self.fig.canvas.mpl_connect('key_press_event', self.on_key) + self.im = None + self.texts = [] + self.patches = [] + + self.render() + + plt.show() + + def render(self): + self.ax.clear() + frame = self.frames[self.idx] + p_map = self.p_maps[self.idx] + h, w = frame.shape[:2] + + self.ax.imshow(frame) + normed = np.clip(p_map, 0.0, 1.0) + grid_h, grid_w = p_map.shape + + self.patches.clear() + self.texts.clear() + + for gy in range(grid_h): + for gx in range(grid_w): + prob = normed[gy, gx] + if prob > 0: + x = gx * self.grid_size + y = gy * self.grid_size + rect = patches.Rectangle((x, y), self.grid_size, self.grid_size, + linewidth=0.5, edgecolor='white', + facecolor=plt.cm.get_cmap(self.cmap)(prob), + alpha=self.alpha) + self.ax.add_patch(rect) + self.patches.append(rect) + + if self.show_text: + text = self.ax.text(x + self.grid_size / 2, + y + self.grid_size / 2, + f"{prob:.2f}", + ha='center', va='center', + fontsize=6, color='black') + self.texts.append(text) + + # Grid lines + for gx in range(0, w, self.grid_size): + self.ax.axvline(gx, color='white', lw=0.3, alpha=0.5) + for gy in range(0, h, self.grid_size): + self.ax.axhline(gy, color='white', lw=0.3, alpha=0.5) + + self.ax.set_title(f"Frame {self.idx + 1}/{len(self.frames)}") + self.ax.set_xlim([0, w]) + self.ax.set_ylim([h, 0]) + self.ax.axis('off') + self.fig.canvas.draw() + + def on_key(self, event): + if event.key == 'right': + self.idx = min(self.idx + 1, len(self.frames) - 1) + self.render() + elif event.key == 'left': + self.idx = max(self.idx - 1, 0) + self.render() diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 7816359b..588b0e1f 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -10,7 +10,11 @@ import matplotlib.pyplot as plt import networkx as nx +import cv2 import itertools +from copy import deepcopy +from pyvis.network import Network +from trackers.core.ksptracker.InteractivePMapViewer import InteractivePMapViewer def visualize_tracking_graph_debug(G: nx.DiGraph, max_edges=500): @@ -287,6 +291,255 @@ def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weig print(f"Total node-disjoint shortest paths found: {len(all_detections)}") return all_detections +def visualize_tracking_graph_with_path(G: nx.DiGraph, path: list, title: str = "Tracking Graph with Path") -> None: + """ + Visualize the graph with: + - Nodes arranged by frame and position + - Edge costs displayed for all edges + - Highlight a specific path with thicker, colored edges and nodes + + Args: + G (nx.DiGraph): The tracking graph. + path (list): A list of nodes forming the path. + title (str): Plot title. + """ + pos = {} + spacing_x = 5 + spacing_y = 5 + + # Determine max frame for sink placement + max_frame = max((n.frame_id for n in G.nodes if isinstance(n, TrackNode)), default=0) + + # Position nodes: x by frame_id, y by position y_bin (grid cell) + for node in G.nodes(): + if node == "source": + pos[node] = (-spacing_x, 0) + elif node == "sink": + pos[node] = (spacing_x * (max_frame + 1), 0) + elif isinstance(node, TrackNode): + pos[node] = (spacing_x * node.frame_id, spacing_y * node.position[1]) + + path_edges = set(zip(path, path[1:])) + + node_colors = [] + for node in G.nodes(): + if node == "source": + node_colors.append("green") + elif node == "sink": + node_colors.append("red") + elif node in path: + node_colors.append("orange") + else: + node_colors.append("white") + + edge_colors = [] + edge_widths = [] + for u, v in G.edges(): + if (u, v) in path_edges: + edge_colors.append("orange") + edge_widths.append(2.5) + else: + edge_colors.append("gray") + edge_widths.append(.5) + + plt.figure(figsize=(16, 8)) + nx.draw( + G, + pos, + with_labels=False, + node_color=node_colors, + edge_color=edge_colors, + width=edge_widths, + node_size=250, + arrows=True, + ) + + # Show edge weights for all edges + edge_labels = { + (u, v): f"{G[u][v]['weight']:.2f}" + for u, v in G.edges() + } + nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_color='black', font_size=7) + + # Node labels (frame:det_idx) + node_labels = { + node: f"{node.frame_id}:{node.det_idx}" if isinstance(node, TrackNode) else node + for node in G.nodes() + } + nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=6) + + plt.title(title) + plt.axis("off") + plt.tight_layout() + plt.show() + +def visualize_tracking_graph_with_path_pyvis(G: nx.DiGraph, path: list, output_file="graph.html"): + net = Network(height="800px", width="100%", directed=True) + net.force_atlas_2based() + + spacing_x = 300 + spacing_y = 50 + path_edges = set(zip(path, path[1:])) + max_frame = max((n.frame_id for n in G.nodes if isinstance(n, TrackNode)), default=0) + + for node in G.nodes(): + if node == "source": + x, y = -spacing_x, 0 + elif node == "sink": + x, y = spacing_x * (max_frame + 1), 0 + elif isinstance(node, TrackNode): + x = spacing_x * node.frame_id + y = spacing_y * node.position[1] + else: + x, y = 0, 0 + + node_id = str(node) + label = f"{node}" if not isinstance(node, TrackNode) else f"{node.frame_id}:{node.det_idx}" + color = "green" if node == "source" else "red" if node == "sink" else "orange" if node in path else "lightgray" + + net.add_node(node_id, label=label, color=color, x=x, y=y, physics=False) + + for u, v, data in G.edges(data=True): + u_id, v_id = str(u), str(v) + color = "orange" if (u, v) in path_edges else "gray" + width = 3 if (u, v) in path_edges else 1 + label = f"{data['weight']:.2f}" + net.add_edge(u_id, v_id, color=color, width=width, label=label) + + net.set_options(""" + var options = { + "nodes": { + "font": { + "size": 14 + } + }, + "edges": { + "font": { + "size": 10, + "align": "middle" + }, + "smooth": false + }, + "physics": { + "enabled": false + } + } + """) + net.show(output_file, notebook=False) + +def visualize_p_map_on_image(frame, p_map, grid_size, alpha=0.4, show_text=True, cmap='Greens'): + """ + Overlay probability map as a grid on the image using Matplotlib. + + Args: + frame (np.ndarray): Original image (H x W x 3). + p_map (np.ndarray): Probability map (grid_h x grid_w). + grid_size (int): Grid cell size in pixels. + alpha (float): Transparency of the overlay. + show_text (bool): Whether to draw probability values in each cell. + cmap (str): Matplotlib colormap name. + """ + h, w = frame.shape[:2] + grid_h, grid_w = p_map.shape + + fig, ax = plt.subplots(figsize=(w / 100, h / 100), dpi=100) + ax.imshow(frame) + + # Normalize p_map to [0,1] for colormap + normed = np.clip(p_map, 0.0, 1.0) + + # Draw each cell with its color and value + for gy in range(grid_h): + for gx in range(grid_w): + prob = normed[gy, gx] + if prob > 0: + x = gx * grid_size + y = gy * grid_size + rect = plt.Rectangle((x, y), grid_size, grid_size, + color=plt.cm.get_cmap(cmap)(prob), alpha=alpha) + ax.add_patch(rect) + + if show_text: + ax.text(x + grid_size / 2, y + grid_size / 2, + f"{prob:.2f}", + ha='center', va='center', + fontsize=6, color='black') + + # Draw grid lines + for gx in range(0, w, grid_size): + ax.axvline(gx, color='white', lw=0.5, alpha=0.5) + for gy in range(0, h, grid_size): + ax.axhline(gy, color='white', lw=0.5, alpha=0.5) + + ax.set_xlim([0, w]) + ax.set_ylim([h, 0]) + ax.axis('off') + plt.tight_layout() + plt.show() + +def visualize_multiple_p_maps(frames, p_maps, grid_size, alpha=0.4, cols=4, show_text=False, cmap='Greens'): + """ + Render multiple frames with overlaid probability maps in a grid layout. + + Args: + frames (List[np.ndarray]): List of images (H x W x 3). + p_maps (List[np.ndarray]): List of corresponding 2D probability maps. + grid_size (int): Size of each grid cell in pixels. + alpha (float): Transparency of overlay. + cols (int): Number of columns in the subplot grid. + show_text (bool): Show probability values. + cmap (str): Matplotlib colormap. + """ + assert len(frames) == len(p_maps), "Each frame must have a corresponding p_map" + + num_frames = len(frames) + rows = (num_frames + cols - 1) // cols + fig, axes = plt.subplots(rows, cols, figsize=(cols * 4, rows * 3)) + + if rows == 1 and cols == 1: + axes = np.array([[axes]]) + elif rows == 1 or cols == 1: + axes = np.reshape(axes, (rows, cols)) + + for idx, (frame, p_map) in enumerate(zip(frames, p_maps)): + ax = axes[idx // cols, idx % cols] + h, w = frame.shape[:2] + ax.imshow(frame) + + normed = np.clip(p_map, 0.0, 1.0) + grid_h, grid_w = p_map.shape + + for gy in range(grid_h): + for gx in range(grid_w): + prob = normed[gy, gx] + if prob > 0: + x = gx * grid_size + y = gy * grid_size + rect = plt.Rectangle((x, y), grid_size, grid_size, + color=plt.cm.get_cmap(cmap)(prob), alpha=alpha) + ax.add_patch(rect) + if show_text: + ax.text(x + grid_size / 2, y + grid_size / 2, + f"{prob:.2f}", ha='center', va='center', + fontsize=5, color='black') + + for gx in range(0, w, grid_size): + ax.axvline(gx, color='white', lw=0.3, alpha=0.5) + for gy in range(0, h, grid_size): + ax.axhline(gy, color='white', lw=0.3, alpha=0.5) + + ax.set_title(f"Frame {idx}") + ax.set_xlim([0, w]) + ax.set_ylim([h, 0]) + ax.axis('off') + + # Hide unused axes + for i in range(num_frames, rows * cols): + axes[i // cols, i % cols].axis('off') + + plt.tight_layout() + plt.show() + @dataclass(frozen=True) class TrackNode: """ @@ -333,10 +586,11 @@ class KSPTracker(BaseTracker): def __init__( self, - grid_size: int = 20, + grid_size: int = 25, max_paths: int = 20, min_confidence: float = 0.3, max_distance: float = 0.3, + img_dim: tuple = (512, 512), ) -> None: """ Initialize the KSP tracker. @@ -351,9 +605,14 @@ def __init__( self.min_confidence = min_confidence self.max_distance = max_distance self.max_paths = max_paths + self.img_dim = img_dim + self.frames = [] self.G = nx.DiGraph() self.reset() + def set_image_dim(self, dim: tuple): + self.img_dim = dim + def reset(self) -> None: """Reset the detection buffer.""" self.detection_buffer: List[sv.Detections] = [] @@ -386,8 +645,54 @@ def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: grid_x = int(x_center // self.grid_size) grid_y = int(y_center // self.grid_size) return (grid_x, grid_y) + + def get_overlapped_cells(self, bbox: np.ndarray) -> list: + """ + Return all grid cells overlapped by the bounding box. + + Args: + bbox (np.ndarray): [x1, y1, x2, y2] + + Returns: + List of tuples [(grid_x, grid_y), ...] + """ + x1, y1, x2, y2 = bbox + grid_x1 = int(x1 // self.grid_size) + grid_y1 = int(y1 // self.grid_size) + grid_x2 = int(x2 // self.grid_size) + grid_y2 = int(y2 // self.grid_size) + + overlapped_cells = [] + for gx in range(grid_x1, grid_x2 + 1): + for gy in range(grid_y1, grid_y2 + 1): + overlapped_cells.append((gx, gy)) + return overlapped_cells + + def get_node_probability(self, node: TrackNode) -> float: + """ + Retrieve the probability from p_map for the given TrackNode. + + Args: + node (TrackNode): The node, with a .position attribute as (grid_x, grid_y). + + Returns: + float: Probability value at the node's grid cell. + """ + frame_id = node.frame_id + grid_x, grid_y = node.position + + if frame_id < 0 or frame_id >= len(self.p_maps): + return 0.0 # Invalid frame index - def _edge_cost(self, confidence: float, dist: float) -> float: + p_map = self.p_maps[frame_id] + grid_height, grid_width = p_map.shape + + if 0 <= grid_x < grid_width and 0 <= grid_y < grid_height: + return float(p_map[grid_y, grid_x]) # Note row=y, col=x in numpy indexing + else: + return 0.0 # Out of bounds + + def _edge_cost(self, node: TrackNode, confidence: float, dist: float) -> float: """ Compute edge cost from detection confidence. @@ -397,71 +702,137 @@ def _edge_cost(self, confidence: float, dist: float) -> float: Returns: float: Edge cost for KSP (non-negative after transform). """ - return -np.log(confidence) + pu = self.get_node_probability(node) + print(pu) + return -np.log10(pu / (1 - pu)) - def _build_graph(self, all_detections: List[sv.Detections]) -> None: + def build_probability_maps(self, all_detections): """ - Build a directed graph from buffered detections. + Build a list of probability maps, one per frame. Args: - all_detections (List[sv.Detections]): List of detections per frame. + all_detections (list of list of dict): + Each element is a list of detections for a frame, + each detection dict has: + 'xyxy': [x1, y1, x2, y2] + 'confidence': float between 0 and 1 - Side Effects: - Sets self.G to the constructed graph. - Populates self.node_to_detection mapping. + Returns: + list of np.ndarray: Each element is a 2D probability map for that frame. """ + + img_width, img_height = self.img_dim + + grid_width = (img_width + self.grid_size - 1) // self.grid_size + grid_height = (img_height + self.grid_size - 1) // self.grid_size + + all_p_maps = [] + + for dets in all_detections: + p_map = np.zeros((grid_height, grid_width), dtype=np.float32) + + # If no detections, just append zero map + if dets.is_empty(): + all_p_maps.append(p_map) + continue + + for i in range(len(dets)): + bbox = dets.xyxy[i] # tensor or numpy array [x1,y1,x2,y2] + conf = dets.confidence[i] # tensor or numpy scalar + + # Convert bbox to numpy if needed + if hasattr(bbox, 'cpu'): + bbox = bbox.cpu().numpy() + if hasattr(conf, 'item'): + conf = conf.item() + + x1, y1, x2, y2 = bbox + + grid_x1 = int(x1 // self.grid_size) + grid_y1 = int(y1 // self.grid_size) + grid_x2 = int(x2 // self.grid_size) + grid_y2 = int(y2 // self.grid_size) + + grid_x1 = max(0, min(grid_x1, grid_width - 1)) + grid_x2 = max(0, min(grid_x2, grid_width - 1)) + grid_y1 = max(0, min(grid_y1, grid_height - 1)) + grid_y2 = max(0, min(grid_y2, grid_height - 1)) + + for gx in range(grid_x1, grid_x2 + 1): + for gy in range(grid_y1, grid_y2 + 1): + p_map[gy, gx] = max(p_map[gy, gx], conf) + + all_p_maps.append(p_map) + + self.p_maps = all_p_maps + + def _build_graph(self, all_detections: List[sv.Detections]) -> None: + self.build_probability_maps(all_detections=all_detections) + self.G = nx.DiGraph() self.G.add_node("source") self.G.add_node("sink") - self.node_to_detection: Dict[TrackNode, tuple] = {} + # self.node_to_detection: Dict[TrackNode, tuple] = {} node_dict: Dict[int, List[TrackNode]] = {} for frame_idx, dets in enumerate(all_detections): node_dict[frame_idx] = [] - for det_idx in range(len(dets)): - if dets.confidence[det_idx] < self.min_confidence: + + # Sort detections by (x1, y1) top-left corner of bbox + if len(dets) > 0: + # Get an array of [x1, y1] + coords = np.array([[bbox[0], bbox[1]] for bbox in dets.xyxy]) + sorted_indices = np.lexsort((coords[:, 1], coords[:, 0])) # sort by x then y + else: + sorted_indices = [] + + # Build nodes in sorted order for stable det_idx + for new_det_idx, original_det_idx in enumerate(sorted_indices): + if dets.confidence[original_det_idx] < self.min_confidence: continue - pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) + pos = self._discretized_grid_cell_id(np.array(dets.xyxy[original_det_idx])) cell_id = hash(pos) node = TrackNode( frame_id=frame_idx, grid_cell_id=cell_id, - det_idx=det_idx, + det_idx=new_det_idx, # use stable new_det_idx position=pos, dets=dets, - confidence=dets.confidence[det_idx], + confidence=dets.confidence[original_det_idx], ) self.G.add_node(node) node_dict[frame_idx].append(node) - self.node_to_detection[node] = (frame_idx, det_idx) + # self.node_to_detection[node] = (frame_idx, original_det_idx) # map to original det_idx if frame_idx == 0: self.G.add_edge("source", node, weight=0) if frame_idx == len(all_detections) - 1: self.G.add_edge(node, "sink", weight=0) + # Add edges as before for i in range(len(all_detections) - 1): for node in node_dict[i]: for node_next in node_dict[i + 1]: - dist = np.linalg.norm( - np.array(node.position) - np.array(node_next.position) - ) - + dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) + w = self._edge_cost(node, confidence=node.confidence, dist=dist) + print(w) self.G.add_edge( - node, - node_next, - weight=self._edge_cost(confidence=node.confidence, dist=dist), - ) + node, + node_next, + weight=w, + ) + def _update_detections_with_tracks( self, assignments: List[List[TrackNode]] ) -> List[sv.Detections]: """ - Assign track IDs to detections based on paths. + Assign track IDs to detections based on spatially consistent paths, + with debug output per frame. Args: assignments (List[List[TrackNode]]): List of detection paths. @@ -469,39 +840,58 @@ def _update_detections_with_tracks( Returns: List[sv.Detections]: Detections with assigned tracker IDs. """ + print(len(assignments)) all_detections = [] assigned = set() assignment_map = {} + + # Map (frame_id, grid_cell_id) to track ID for track_id, path in enumerate(assignments, start=1): for node in path: - det_key = self.node_to_detection.get(node) - if det_key and det_key not in assigned: - assignment_map[det_key] = track_id - assigned.add(det_key) + if node in {"source", "sink"}: + continue + + key = (node.frame_id, node.grid_cell_id) + if key not in assigned: + assignment_map[key] = track_id + assigned.add(key) + + import pprint + p = pprint.PrettyPrinter(4) + p.pprint(assignment_map) for frame_idx, dets in enumerate(self.detection_buffer): frame_tracker_ids = [-1] * len(dets) + det_pos_to_idx = {} + + for det_idx in range(len(dets)): + pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) + det_pos_to_idx[pos] = det_idx + for det_idx in range(len(dets)): - key = (frame_idx, det_idx) + pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) + key = (frame_idx, hash(pos)) + print( "Frame: "+ str(frame_idx), key, det_pos_to_idx[pos], assignment_map[key] if key in assignment_map else "NIL") if key in assignment_map: - frame_tracker_ids[det_idx] = assignment_map[key] + frame_tracker_ids[det_pos_to_idx[pos]] = assignment_map[key] dets.tracker_id = np.array(frame_tracker_ids) all_detections.append(dets) - for frame_idx, dets in enumerate(all_detections): - tracker_ids = dets.tracker_id - num_tracked = np.sum(tracker_ids != -1) - print(f"[Frame {frame_idx}] Total Detections: {len(tracker_ids)} | Tracked: {num_tracked}") - - for det_idx, tid in enumerate(tracker_ids): + # Debug output for this frame + num_tracked = sum(tid != -1 for tid in frame_tracker_ids) + print(f"[Frame {frame_idx}] Total Detections: {len(frame_tracker_ids)} | Tracked: {num_tracked}") + for det_idx, tid in enumerate(frame_tracker_ids): + bbox = dets.xyxy[det_idx] + conf = dets.confidence[det_idx] if tid == -1: - print(f" ⛔ Untracked Detection {det_idx}: BBox={dets.xyxy[det_idx]}, Conf={dets.confidence[det_idx]:.2f}") + print(f" ⛔ Untracked Detection {det_idx}: BBox={bbox}, Conf={conf:.2f}") else: - print(f" ✅ Track {tid} assigned to Detection {det_idx}: BBox={dets.xyxy[det_idx]}, Conf={dets.confidence[det_idx]:.2f}") - + print(f" ✅ Track {tid} assigned to Detection {det_idx}: BBox={bbox}, Conf={conf:.2f}") + frames_rgb = [cv2.cvtColor(f, cv2.COLOR_BGR2RGB) for f in self.frames] + InteractivePMapViewer(frames_rgb, self.p_maps, grid_size=self.grid_size, show_text=True) return all_detections def _shortest_path(self) -> tuple: @@ -523,22 +913,73 @@ def _shortest_path(self) -> tuple: except nx.NetworkXUnbounded: raise RuntimeError("Graph contains a negative weight cycle.") - def _extend_graph(self, paths: List[List[TrackNode]]) -> nx.DiGraph: + def _extend_graph(self, paths: list[list]): """ - Remove nodes used in previous paths to enforce disjointness. + Extend the graph as per Berclaz et al. (2011), Table 4: + - Split each used node (except source/sink) into `v_in` and `v_out` + - Add a zero-cost edge v_in → v_out + - Redirect edges accordingly + - Reverse and negate all path edges Args: paths (List[List[TrackNode]]): Previously found paths. Returns: - nx.DiGraph: Extended graph with used nodes removed. + nx.DiGraph: Extended graph with node splits and reversed edges. """ - G_extended = self.G.copy() + G_ext = self.G.copy() + split_map = {} + + # Step 1: Split internal nodes (not source/sink) for path in paths: for node in path: - if node in G_extended and node not in {"source", "sink"}: - G_extended.remove_node(node) - return G_extended + if node in {"source", "sink"}: + continue + if node in split_map: + continue # Already split + + # Deepcopy node to keep all attributes, add split_tag for identification + v_in = deepcopy(node) + v_out = deepcopy(node) + object.__setattr__(v_in, "split_tag", "in") + object.__setattr__(v_out, "split_tag", "out") + split_map[node] = (v_in, v_out) + + # Add new nodes + G_ext.add_node(v_in) + G_ext.add_node(v_out) + + # Add auxiliary zero-cost edge v_in -> v_out + G_ext.add_edge(v_in, v_out, weight=0) + + # Redirect incoming edges to v_in + for u, _, data in list(G_ext.in_edges(node, data=True)): + G_ext.add_edge(u, v_in, **data) + G_ext.remove_edge(u, node) + + # Redirect outgoing edges from v_out + for _, v, data in list(G_ext.out_edges(node, data=True)): + G_ext.add_edge(v_out, v, **data) + G_ext.remove_edge(node, v) + + # Remove original node + G_ext.remove_node(node) + + # Step 2: Reverse and negate all edges in used paths + for path in paths: + for i in range(len(path) - 1): + u, v = path[i], path[i + 1] + + # Use split nodes if they exist + u_out = split_map[u][1] if u in split_map else u + v_in = split_map[v][0] if v in split_map else v + + if G_ext.has_edge(u_out, v_in): + cost = G_ext[u_out][v_in]["weight"] + G_ext.remove_edge(u_out, v_in) + G_ext.add_edge(v_in, u_out, weight=-cost) + + return G_ext def _transform_edge_cost( self, G: nx.DiGraph, shortest_costs: Dict[Any, float] @@ -558,7 +999,7 @@ def _transform_edge_cost( if u not in shortest_costs or v not in shortest_costs: continue original = data["weight"] - transformed = original + shortest_costs[u] - shortest_costs[v] + transformed = abs(original + shortest_costs[u] - shortest_costs[v]) Gc.add_edge(u, v, weight=transformed) return Gc @@ -597,12 +1038,15 @@ def ksp(self) -> List[List[TrackNode]]: Returns: List[List[TrackNode]]: List of disjoint detection paths. """ + path, cost, lengths = self._shortest_path() P = [path] - cost_P = [cost] + cost_P = [] + print("Cost: " + str(cost)) + visualize_tracking_graph_with_path_pyvis(self.G, P[-1], "graph1.html") for l in range(1, self.max_paths): - if l != 1 and cost_P[-1] >= cost_P[-2]: + if l > 2 and cost_P[-1] >= cost_P[-2]: return P # early termination Gl = self._extend_graph(P) @@ -619,10 +1063,10 @@ def ksp(self) -> List[List[TrackNode]]: interlaced_path = self._interlace_paths(P, new_path) P.append(interlaced_path) - + visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") except nx.NetworkXNoPath: break - + print(len(P)) return P def process_tracks(self) -> List[sv.Detections]: @@ -633,8 +1077,8 @@ def process_tracks(self) -> List[sv.Detections]: List[sv.Detections]: Detections updated with tracker IDs. """ self._build_graph(self.detection_buffer) - visualize_tracking_graph_debug(self.G) - detections_list = find_and_visualize_disjoint_paths(self.G) - return detections_list - # disjoint_paths = self.ksp() - # return self._update_detections_with_tracks(assignments=disjoint_paths) + # visualize_tracking_graph_debug(self.G) + # detections_list = find_and_visualize_disjoint_paths(self.G) + # return detections_list + disjoint_paths = self.ksp() + return self._update_detections_with_tracks(assignments=disjoint_paths) From 0aaec74d2a14c29b16cc68a89171e56bfee93b9b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 23 Jun 2025 03:04:17 -0400 Subject: [PATCH 040/100] BUG: Debugging KSP... --- trackers/core/ksptracker/tracker.py | 114 ++++++++++++++++------------ 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 588b0e1f..c09225fa 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -10,6 +10,7 @@ import matplotlib.pyplot as plt import networkx as nx +import math import cv2 import itertools from copy import deepcopy @@ -557,7 +558,7 @@ class TrackNode: det_idx: int position: tuple confidence: float - dets: Any + # dets: Any def __hash__(self) -> int: """Generate hash using frame and grid cell.""" @@ -570,7 +571,7 @@ def __eq__(self, other: Any) -> bool: return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) def __str__(self): - return str(self.frame_id) + " " + str(self.det_idx) + return str(self.frame_id) + " " + str(self.det_idx) + " " + str(self.grid_cell_id) class KSPTracker(BaseTracker): @@ -692,7 +693,7 @@ def get_node_probability(self, node: TrackNode) -> float: else: return 0.0 # Out of bounds - def _edge_cost(self, node: TrackNode, confidence: float, dist: float) -> float: + def _edge_cost(self, node: TrackNode) -> float: """ Compute edge cost from detection confidence. @@ -703,64 +704,65 @@ def _edge_cost(self, node: TrackNode, confidence: float, dist: float) -> float: float: Edge cost for KSP (non-negative after transform). """ pu = self.get_node_probability(node) - print(pu) - return -np.log10(pu / (1 - pu)) + print(node, "PU: " + str(pu), -math.log10(pu / ((1 - pu) + 1e-6))) + return -math.log10(pu / ((1 - pu) + 1e-6)) def build_probability_maps(self, all_detections): """ - Build a list of probability maps, one per frame. + Build a list of probability maps (with Gaussian spread), one per frame. Args: - all_detections (list of list of dict): - Each element is a list of detections for a frame, - each detection dict has: - 'xyxy': [x1, y1, x2, y2] - 'confidence': float between 0 and 1 + all_detections (list of sv.Detections): List of detection sets per frame. Returns: list of np.ndarray: Each element is a 2D probability map for that frame. """ - img_width, img_height = self.img_dim + grid_size = self.grid_size + + grid_width = (img_width + grid_size - 1) // grid_size + grid_height = (img_height + grid_size - 1) // grid_size - grid_width = (img_width + self.grid_size - 1) // self.grid_size - grid_height = (img_height + self.grid_size - 1) // self.grid_size + print(f"Image size: {img_width}x{img_height}") + print(f"Grid size: {grid_size}") + print(f"Grid width cells: {grid_width}, Grid height cells: {grid_height}") all_p_maps = [] + sigma = 1 # in grid units; controls the spread of confidence for dets in all_detections: p_map = np.zeros((grid_height, grid_width), dtype=np.float32) - # If no detections, just append zero map if dets.is_empty(): all_p_maps.append(p_map) continue for i in range(len(dets)): - bbox = dets.xyxy[i] # tensor or numpy array [x1,y1,x2,y2] - conf = dets.confidence[i] # tensor or numpy scalar + bbox = dets.xyxy[i] + conf = dets.confidence[i] - # Convert bbox to numpy if needed if hasattr(bbox, 'cpu'): bbox = bbox.cpu().numpy() if hasattr(conf, 'item'): conf = conf.item() x1, y1, x2, y2 = bbox - - grid_x1 = int(x1 // self.grid_size) - grid_y1 = int(y1 // self.grid_size) - grid_x2 = int(x2 // self.grid_size) - grid_y2 = int(y2 // self.grid_size) - - grid_x1 = max(0, min(grid_x1, grid_width - 1)) - grid_x2 = max(0, min(grid_x2, grid_width - 1)) - grid_y1 = max(0, min(grid_y1, grid_height - 1)) - grid_y2 = max(0, min(grid_y2, grid_height - 1)) - - for gx in range(grid_x1, grid_x2 + 1): - for gy in range(grid_y1, grid_y2 + 1): - p_map[gy, gx] = max(p_map[gy, gx], conf) + x_center = (x1 + x2) / 2 + y_center = (y1 + y2) / 2 + + # center in grid units + grid_xc = x_center / grid_size + grid_yc = y_center / grid_size + + radius = int(2 * sigma) + for dx in range(-radius, radius + 1): + for dy in range(-radius, radius + 1): + gx = int(grid_xc + dx) + gy = int(grid_yc + dy) + if 0 <= gx < grid_width and 0 <= gy < grid_height: + dist2 = dx**2 + dy**2 + weight = conf * math.exp(-dist2 / (2 * sigma**2)) + p_map[gy, gx] = max(p_map[gy, gx], weight) all_p_maps.append(p_map) @@ -779,13 +781,15 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: for frame_idx, dets in enumerate(all_detections): node_dict[frame_idx] = [] - # Sort detections by (x1, y1) top-left corner of bbox - if len(dets) > 0: - # Get an array of [x1, y1] - coords = np.array([[bbox[0], bbox[1]] for bbox in dets.xyxy]) - sorted_indices = np.lexsort((coords[:, 1], coords[:, 0])) # sort by x then y - else: - sorted_indices = [] + # # Sort detections by (x1, y1) top-left corner of bbox + # if len(dets) > 0: + # # Get an array of [x1, y1] + # coords = np.array([[bbox[0], bbox[1]] for bbox in dets.xyxy]) + # sorted_indices = np.lexsort((coords[:, 1], coords[:, 0])) # sort by x then y + # else: + # sorted_indices = [] + + sorted_indices = list(range(len(dets))) if len(dets) > 0 else [] # Build nodes in sorted order for stable det_idx for new_det_idx, original_det_idx in enumerate(sorted_indices): @@ -800,7 +804,6 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: grid_cell_id=cell_id, det_idx=new_det_idx, # use stable new_det_idx position=pos, - dets=dets, confidence=dets.confidence[original_det_idx], ) @@ -816,10 +819,10 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: # Add edges as before for i in range(len(all_detections) - 1): for node in node_dict[i]: + w = self._edge_cost(node) + for node_next in node_dict[i + 1]: dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) - w = self._edge_cost(node, confidence=node.confidence, dist=dist) - print(w) self.G.add_edge( node, node_next, @@ -894,7 +897,7 @@ def _update_detections_with_tracks( InteractivePMapViewer(frames_rgb, self.p_maps, grid_size=self.grid_size, show_text=True) return all_detections - def _shortest_path(self) -> tuple: + def _shortest_path(self, G: nx.Graph) -> tuple: """ Compute shortest path from 'source' to 'sink' using Bellman-Ford. @@ -906,7 +909,7 @@ def _shortest_path(self) -> tuple: KeyError: If sink unreachable. """ try: - lengths, paths = nx.single_source_bellman_ford(self.G, "source", weight="weight") + lengths, paths = nx.single_source_bellman_ford(G, "source", weight="weight") if "sink" not in paths: raise KeyError("No path found from source to sink.") return paths["sink"], lengths["sink"], lengths @@ -999,7 +1002,7 @@ def _transform_edge_cost( if u not in shortest_costs or v not in shortest_costs: continue original = data["weight"] - transformed = abs(original + shortest_costs[u] - shortest_costs[v]) + transformed = original + shortest_costs[u] - shortest_costs[v] Gc.add_edge(u, v, weight=transformed) return Gc @@ -1039,18 +1042,30 @@ def ksp(self) -> List[List[TrackNode]]: List[List[TrackNode]]: List of disjoint detection paths. """ - path, cost, lengths = self._shortest_path() + path, cost, s_lengths = self._shortest_path(self.G) P = [path] - cost_P = [] + cost_P = [cost] + Gc_l_last = None + + + print("Cost: " + str(cost)) + print(self.G) visualize_tracking_graph_with_path_pyvis(self.G, P[-1], "graph1.html") for l in range(1, self.max_paths): - if l > 2 and cost_P[-1] >= cost_P[-2]: + if l != 1 and cost_P[-1] >= cost_P[-2]: return P # early termination + + wHa = Gc_l_last or self.G + print(l, wHa) + s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") + print(s_lengths == s_lens) + shortest_costs = s_lens + Gl = self._extend_graph(P) - Gc_l = self._transform_edge_cost(Gl, lengths) + Gc_l = self._transform_edge_cost(Gl, shortest_costs) try: lengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") @@ -1063,6 +1078,7 @@ def ksp(self) -> List[List[TrackNode]]: interlaced_path = self._interlace_paths(P, new_path) P.append(interlaced_path) + Gc_l_last = Gc_l visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") except nx.NetworkXNoPath: break From 764c776460b0ee2823f13bf60f4f4107754a6a32 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Mon, 23 Jun 2025 03:31:06 -0400 Subject: [PATCH 041/100] PROGRESS: Able to get less tracer switchs --- trackers/core/ksptracker/tracker.py | 75 +++++++++++++++++------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index c09225fa..1fe22a86 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -557,6 +557,7 @@ class TrackNode: grid_cell_id: int det_idx: int position: tuple + bbox: Any confidence: float # dets: Any @@ -669,7 +670,7 @@ def get_overlapped_cells(self, bbox: np.ndarray) -> list: overlapped_cells.append((gx, gy)) return overlapped_cells - def get_node_probability(self, node: TrackNode) -> float: + def get_node_probability(self, node: TrackNode, Position) -> float: """ Retrieve the probability from p_map for the given TrackNode. @@ -680,7 +681,7 @@ def get_node_probability(self, node: TrackNode) -> float: float: Probability value at the node's grid cell. """ frame_id = node.frame_id - grid_x, grid_y = node.position + grid_x, grid_y = Position if frame_id < 0 or frame_id >= len(self.p_maps): return 0.0 # Invalid frame index @@ -689,11 +690,15 @@ def get_node_probability(self, node: TrackNode) -> float: grid_height, grid_width = p_map.shape if 0 <= grid_x < grid_width and 0 <= grid_y < grid_height: - return float(p_map[grid_y, grid_x]) # Note row=y, col=x in numpy indexing + cells = self.get_overlapped_cells(node.bbox) + if ((grid_x, grid_y) in cells): + return float(p_map[grid_y, grid_x]) # Note row=y, col=x in numpy indexing + else: + return 0 else: return 0.0 # Out of bounds - def _edge_cost(self, node: TrackNode) -> float: + def _edge_cost(self, node: TrackNode, node_next_pos: tuple) -> float: """ Compute edge cost from detection confidence. @@ -703,9 +708,9 @@ def _edge_cost(self, node: TrackNode) -> float: Returns: float: Edge cost for KSP (non-negative after transform). """ - pu = self.get_node_probability(node) - print(node, "PU: " + str(pu), -math.log10(pu / ((1 - pu) + 1e-6))) - return -math.log10(pu / ((1 - pu) + 1e-6)) + pu = self.get_node_probability(node, node_next_pos) + print(node, "PU: " + str(pu), -math.log10((pu + 1e-6) / ((1 - pu) + 1e-6))) + return -math.log10((pu + 1e-6) / ((1 - pu) + 1e-6)) def build_probability_maps(self, all_detections): """ @@ -803,6 +808,7 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: frame_id=frame_idx, grid_cell_id=cell_id, det_idx=new_det_idx, # use stable new_det_idx + bbox=np.array(dets.xyxy[original_det_idx]), position=pos, confidence=dets.confidence[original_det_idx], ) @@ -819,10 +825,10 @@ def _build_graph(self, all_detections: List[sv.Detections]) -> None: # Add edges as before for i in range(len(all_detections) - 1): for node in node_dict[i]: - w = self._edge_cost(node) for node_next in node_dict[i + 1]: dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) + w = self._edge_cost(node, node_next.position) self.G.add_edge( node, node_next, @@ -1042,7 +1048,13 @@ def ksp(self) -> List[List[TrackNode]]: List[List[TrackNode]]: List of disjoint detection paths. """ - path, cost, s_lengths = self._shortest_path(self.G) + #path, cost, s_lengths = self._shortest_path(self.G) + lengths, paths = nx.single_source_bellman_ford(self.G, "source", weight="weight") + if "sink" not in paths: + raise KeyError("No path found from source to sink.") + + path, cost = paths["sink"], lengths["sink"] + print(cost, nx.shortest_path_length(self.G, "source", "sink", weight="weight")) P = [path] cost_P = [cost] Gc_l_last = None @@ -1053,36 +1065,35 @@ def ksp(self) -> List[List[TrackNode]]: print(self.G) visualize_tracking_graph_with_path_pyvis(self.G, P[-1], "graph1.html") - for l in range(1, self.max_paths): - if l != 1 and cost_P[-1] >= cost_P[-2]: - return P # early termination + # for l in range(1, self.max_paths): + # if l != 1 and cost_P[-1] >= cost_P[-2]: + # return P # early termination - wHa = Gc_l_last or self.G - print(l, wHa) - s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") - print(s_lengths == s_lens) - shortest_costs = s_lens + # wHa = Gc_l_last or self.G + # print(l, wHa) + # s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") + # shortest_costs = s_lens - Gl = self._extend_graph(P) - Gc_l = self._transform_edge_cost(Gl, shortest_costs) + # Gl = self._extend_graph(P) + # Gc_l = self._transform_edge_cost(Gl, shortest_costs) - try: - lengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") + # try: + # lengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") - if "sink" not in paths_dict: - break + # if "sink" not in paths_dict: + # break - new_path = paths_dict["sink"] - cost_P.append(lengths["sink"]) + # new_path = paths_dict["sink"] + # cost_P.append(lengths["sink"]) - interlaced_path = self._interlace_paths(P, new_path) - P.append(interlaced_path) - Gc_l_last = Gc_l - visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") - except nx.NetworkXNoPath: - break - print(len(P)) + # interlaced_path = self._interlace_paths(P, new_path) + # P.append(interlaced_path) + # Gc_l_last = Gc_l + # visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") + # except nx.NetworkXNoPath: + # break + # print(len(P)) return P def process_tracks(self) -> List[sv.Detections]: From ccd77af3f0587842b7d8cf60d6eca403e45ea33a Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Tue, 24 Jun 2025 23:35:50 -0400 Subject: [PATCH 042/100] CHECKPOINT: Changing the entirely --- trackers/core/ksptracker/tracker.py | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 1fe22a86..cb97d782 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -710,7 +710,7 @@ def _edge_cost(self, node: TrackNode, node_next_pos: tuple) -> float: """ pu = self.get_node_probability(node, node_next_pos) print(node, "PU: " + str(pu), -math.log10((pu + 1e-6) / ((1 - pu) + 1e-6))) - return -math.log10((pu + 1e-6) / ((1 - pu) + 1e-6)) + return np.linalg.norm(np.array(node.position) - np.array(node_next_pos)) def build_probability_maps(self, all_detections): """ @@ -1065,35 +1065,35 @@ def ksp(self) -> List[List[TrackNode]]: print(self.G) visualize_tracking_graph_with_path_pyvis(self.G, P[-1], "graph1.html") - # for l in range(1, self.max_paths): - # if l != 1 and cost_P[-1] >= cost_P[-2]: - # return P # early termination + for l in range(1, self.max_paths): + if l != 1 and cost_P[-1] >= cost_P[-2]: + return P # early termination - # wHa = Gc_l_last or self.G - # print(l, wHa) - # s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") - # shortest_costs = s_lens + wHa = Gc_l_last or self.G + print(l, wHa) + #s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") + #shortest_costs = s_lens - # Gl = self._extend_graph(P) - # Gc_l = self._transform_edge_cost(Gl, shortest_costs) + Gl = self._extend_graph(P) + Gc_l = Gl # self._transform_edge_cost(Gl, shortest_costs) - # try: - # lengths, paths_dict = nx.single_source_dijkstra(Gc_l, "source", weight="weight") + try: + lengths, paths_dict = nx.single_source_bellman_ford(Gc_l, "source", weight="weight") - # if "sink" not in paths_dict: - # break + if "sink" not in paths_dict: + break - # new_path = paths_dict["sink"] - # cost_P.append(lengths["sink"]) + new_path = paths_dict["sink"] + cost_P.append(lengths["sink"]) - # interlaced_path = self._interlace_paths(P, new_path) - # P.append(interlaced_path) - # Gc_l_last = Gc_l - # visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") - # except nx.NetworkXNoPath: - # break - # print(len(P)) + interlaced_path = self._interlace_paths(P, new_path) + P.append(interlaced_path) + Gc_l_last = Gc_l + visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") + except nx.NetworkXNoPath: + break + print(len(P)) return P def process_tracks(self) -> List[sv.Detections]: From af750b6a13a9d4c82983634b4e36982a9f6a4d87 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 25 Jun 2025 02:40:45 -0400 Subject: [PATCH 043/100] UPDATE: Changed the entire logic for KSP --- trackers/core/ksptracker/tracker.py | 1284 +++++++-------------------- 1 file changed, 307 insertions(+), 977 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index cb97d782..0b3eb852 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -8,403 +8,209 @@ from trackers.core.base import BaseTracker import matplotlib.pyplot as plt -import networkx as nx - +from tqdm import tqdm import math import cv2 import itertools from copy import deepcopy from pyvis.network import Network +import matplotlib.colors as mcolors +import matplotlib.cm as cm from trackers.core.ksptracker.InteractivePMapViewer import InteractivePMapViewer +import pprint +p = pprint.PrettyPrinter(4) +import copy -def visualize_tracking_graph_debug(G: nx.DiGraph, max_edges=500): - import matplotlib.pyplot as plt - import networkx as nx - - plt.figure(figsize=(18, 8)) - - # Collect all TrackNode nodes - track_nodes = [n for n in G.nodes if isinstance(n, TrackNode)] - frames = sorted(set(n.frame_id for n in track_nodes)) - - frame_to_x = {f: i for i, f in enumerate(frames)} - - # Group nodes by frame and sort by det_idx (or confidence) - nodes_by_frame = {} - for node in track_nodes: - nodes_by_frame.setdefault(node.frame_id, []).append(node) - for frame in nodes_by_frame: - nodes_by_frame[frame].sort(key=lambda n: n.det_idx) # or key=lambda n: -n.confidence for sorting by confidence - - pos = {} - for node in G.nodes: - if node == "source": - pos[node] = (-1, 0) - elif node == "sink": - pos[node] = (len(frames), 0) - elif isinstance(node, TrackNode): - x = frame_to_x[node.frame_id] - # vertical spacing: spread nodes evenly in y-axis - idx = nodes_by_frame[node.frame_id].index(node) - total = len(nodes_by_frame[node.frame_id]) - # spread vertically between 0 and total, centered around 0 - y = idx - total / 2 - pos[node] = (x, y) - - # Draw nodes - nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightblue", alpha=0.9) - - # Labels - labels = {} - for node in G.nodes: - if node == "source": - labels[node] = "SRC" - elif node == "sink": - labels[node] = "SNK" - elif isinstance(node, TrackNode): - labels[node] = f"F{node.frame_id},D{node.det_idx}" - nx.draw_networkx_labels(G, pos, labels, font_size=8) - - # Edges (limit number) - edges_to_draw = list(G.edges(data=True))[:max_edges] - edge_list = [(u, v) for u, v, _ in edges_to_draw] - nx.draw_networkx_edges(G, pos, edgelist=edge_list, arrowstyle='->', arrowsize=15, edge_color='gray') - - # Edge weights - edge_labels = {(u, v): f"{d['weight']:.2f}" for u, v, d in edges_to_draw} - nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=7, label_pos=0.5) - - plt.title("Tracking Graph - Directed Timeline Layout with Vertical Spacing") - plt.xlabel("Frame Index") - plt.ylabel("Detection Vertical Position (Jittered)") - plt.grid(True) - plt.tight_layout() - plt.show() - -def visualize_all_shortest_bellman_ford_paths(G: nx.DiGraph): - try: - all_paths = list(nx.all_shortest_paths(G, source="source", target="sink", weight="weight", method="bellman-ford")) - if not all_paths: - print("No path found from source to sink.") - return - shortest_path_length = sum(G[u][v]['weight'] for u, v in zip(all_paths[0][:-1], all_paths[0][1:])) - print(f"Number of shortest paths from source to sink: {len(all_paths)}") - print(f"Each shortest path has cost: {shortest_path_length:.2f}") - - except nx.NetworkXUnbounded: - print("Negative weight cycle detected.") - return - - plt.figure(figsize=(18, 8)) - - # Layout - track_nodes = [n for n in G.nodes if isinstance(n, TrackNode)] - frames = sorted(set(n.frame_id for n in track_nodes)) - frame_to_x = {f: i for i, f in enumerate(frames)} - - nodes_by_frame = {} - for node in track_nodes: - nodes_by_frame.setdefault(node.frame_id, []).append(node) - for frame in nodes_by_frame: - nodes_by_frame[frame].sort(key=lambda n: n.det_idx) - - pos = {} - for node in G.nodes: - if node == "source": - pos[node] = (-1, 0) - elif node == "sink": - pos[node] = (len(frames), 0) - elif isinstance(node, TrackNode): - x = frame_to_x[node.frame_id] - idx = nodes_by_frame[node.frame_id].index(node) - total = len(nodes_by_frame[node.frame_id]) - y = idx - total / 2 - pos[node] = (x, y) - - # Draw all nodes and labels - nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightgray", alpha=0.7) - labels = { - node: ( - "SRC" if node == "source" - else "SNK" if node == "sink" - else f"F{node.frame_id},D{node.det_idx}" - ) - for node in G.nodes - } - nx.draw_networkx_labels(G, pos, labels, font_size=8) - - # Draw all edges (light gray) - nx.draw_networkx_edges(G, pos, edge_color="lightgray", alpha=0.3) - - # Draw edge weights for all edges - edge_labels = {(u, v): f"{d['weight']:.2f}" for u, v, d in G.edges(data=True)} - nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=6) - - # Collect all edges in all shortest paths - edges_in_paths = set() - nodes_in_paths = set() - for path in all_paths: - nodes_in_paths.update(path) - edges_in_paths.update(zip(path[:-1], path[1:])) - - # Draw all nodes in shortest paths highlighted (orange) - nx.draw_networkx_nodes(G, pos, nodelist=nodes_in_paths, node_color="orange") - - # Draw all edges in shortest paths highlighted (red, thicker) - nx.draw_networkx_edges( - G, pos, - edgelist=edges_in_paths, - edge_color="red", - width=2.5, - arrowstyle="->", - arrowsize=15, - label="Shortest Paths" - ) - - plt.title("Bellman-Ford All Shortest Paths Visualization") - plt.xlabel("Frame Index") - plt.ylabel("Detection Index Offset") - plt.grid(True) - plt.tight_layout() - plt.show() - -def visualize_path(G, path, pos, path_num, color="red"): - plt.figure(figsize=(12, 6)) - - # Draw all nodes and edges lightly - nx.draw_networkx_nodes(G, pos, node_size=200, node_color="lightgray", alpha=0.7) - nx.draw_networkx_edges(G, pos, edge_color="lightgray", alpha=0.3) - labels = { - node: ( - "SRC" if node == "source" - else "SNK" if node == "sink" - else f"F{node.frame_id},D{node.det_idx}" - ) - for node in G.nodes - } - nx.draw_networkx_labels(G, pos, labels, font_size=8) - - # Highlight the path nodes and edges - nx.draw_networkx_nodes(G, pos, nodelist=path, node_color=color) - path_edges = list(zip(path[:-1], path[1:])) - nx.draw_networkx_edges(G, pos, edgelist=path_edges, edge_color=color, width=3, arrowstyle="->", arrowsize=15) - - plt.title(f"Node-Disjoint Shortest Path #{path_num}") - plt.xlabel("Frame Index") - plt.ylabel("Detection Index Offset") - plt.grid(True) - plt.tight_layout() - plt.show() - - -def path_to_detections(path) -> sv.Detections: - track_nodes = [node for node in path if hasattr(node, "frame_id") and hasattr(node, "det_idx")] - - xyxys = [] - confidences = [] - class_ids = [] - - for node in track_nodes: - det_idx = node.det_idx - dets = node.dets # sv.Detections - - bbox = dets.xyxy[det_idx] - if bbox is None or len(bbox) != 4: - raise ValueError(f"Invalid bbox at node {node}: {bbox}") - - xyxys.append(bbox) - confidences.append(dets.confidence[det_idx]) - - if hasattr(dets, "class_id") and len(dets.class_id) > det_idx: - class_ids.append(dets.class_id[det_idx]) - else: - class_ids.append(0) - - xyxys = np.array(xyxys) - confidences = np.array(confidences) - class_ids = np.array(class_ids) - - if xyxys.ndim != 2 or xyxys.shape[1] != 4: - raise ValueError(f"xyxy must be 2D array with shape (_,4), got shape {xyxys.shape}") - - return sv.Detections(xyxy=xyxys, confidence=confidences, class_id=class_ids) - - -def find_and_visualize_disjoint_paths(G_orig, source="source", sink="sink", weight="weight") -> List[sv.Detections]: - G = G_orig.copy() - - # Compute layout positions once for consistency - track_nodes = [n for n in G.nodes if hasattr(n, "frame_id") and hasattr(n, "det_idx")] - frames = sorted(set(n.frame_id for n in track_nodes)) - frame_to_x = {f: i for i, f in enumerate(frames)} - nodes_by_frame = {} - for node in track_nodes: - nodes_by_frame.setdefault(node.frame_id, []).append(node) - for frame in nodes_by_frame: - nodes_by_frame[frame].sort(key=lambda n: n.det_idx) - pos = {} - for node in G.nodes: - if node == source: - pos[node] = (-1, 0) - elif node == sink: - pos[node] = (len(frames), 0) - elif hasattr(node, "frame_id") and hasattr(node, "det_idx"): - x = frame_to_x[node.frame_id] - idx = nodes_by_frame[node.frame_id].index(node) - total = len(nodes_by_frame[node.frame_id]) - y = idx - total / 2 - pos[node] = (x, y) - else: - pos[node] = (0, 0) # fallback - - all_detections = [] - colors = itertools.cycle(["red", "blue", "green", "orange", "purple", "brown", "cyan", "magenta"]) - - while True: - try: - length, paths = nx.single_source_bellman_ford(G, source=source, weight=weight) - if sink not in paths: - print("No more paths found.") - break - - shortest_path = paths[sink] - cost = length[sink] - color = next(colors) - print(f"Found path with cost {cost:.2f}: {shortest_path}") - - visualize_path(G_orig, shortest_path, pos, len(all_detections) + 1, color=color) - - dets = path_to_detections(shortest_path) - all_detections.append(dets) - - # Remove intermediate nodes to enforce node-disjointness - intermediate_nodes = shortest_path[1:-1] - G.remove_nodes_from(intermediate_nodes) - - except nx.NetworkXNoPath: - print("No more paths found.") - break - except nx.NetworkXUnbounded: - print("Negative weight cycle detected.") - break - - print(f"Total node-disjoint shortest paths found: {len(all_detections)}") - return all_detections - -def visualize_tracking_graph_with_path(G: nx.DiGraph, path: list, title: str = "Tracking Graph with Path") -> None: +@dataclass(frozen=True) +class TrackNode: """ - Visualize the graph with: - - Nodes arranged by frame and position - - Edge costs displayed for all edges - - Highlight a specific path with thicker, colored edges and nodes + Represents a detection node in the tracking graph. - Args: - G (nx.DiGraph): The tracking graph. - path (list): A list of nodes forming the path. - title (str): Plot title. + Attributes: + frame_id (int): Frame index where detection occurred. + grid_cell_id (int): Discretized grid cell ID of detection center. + position (tuple): Grid coordinates (x_bin, y_bin). + confidence (float): Detection confidence score. """ - pos = {} - spacing_x = 5 - spacing_y = 5 + frame_id: int + grid_cell_id: int + det_idx: int + position: tuple + bbox: Any + confidence: float - # Determine max frame for sink placement - max_frame = max((n.frame_id for n in G.nodes if isinstance(n, TrackNode)), default=0) + def __hash__(self) -> int: + """Generate hash using frame and grid cell.""" + return hash((self.frame_id, self.grid_cell_id)) - # Position nodes: x by frame_id, y by position y_bin (grid cell) - for node in G.nodes(): - if node == "source": - pos[node] = (-spacing_x, 0) - elif node == "sink": - pos[node] = (spacing_x * (max_frame + 1), 0) - elif isinstance(node, TrackNode): - pos[node] = (spacing_x * node.frame_id, spacing_y * node.position[1]) + def __eq__(self, other: Any) -> bool: + """Compare nodes by frame and grid cell ID.""" + if not isinstance(other, TrackNode): + return False + return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) + + def __str__(self): + return f"{self.frame_id} {self.det_idx} {self.grid_cell_id}" + +def compute_edge_cost( + det_a_xyxy, det_b_xyxy, conf_a, conf_b, class_a, class_b, + iou_weight=0.5, dist_weight=0.3, size_weight=0.1, conf_weight=0.1 +): + # Block if class doesn't match + if class_a != class_b: + return float('inf') + + # Get box centers + def get_center(box): + x1, y1, x2, y2 = box + return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + + center_a = get_center(det_a_xyxy) + center_b = get_center(det_b_xyxy) + euclidean_dist = np.linalg.norm(center_a - center_b) + + # IoU between boxes + def box_iou(boxA, boxB): + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + inter_area = max(0, xB - xA) * max(0, yB - yA) + boxA_area = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) + boxB_area = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) + union_area = boxA_area + boxB_area - inter_area + return inter_area / union_area if union_area > 0 else 0 + + iou = box_iou(det_a_xyxy, det_b_xyxy) + iou_penalty = 1 - iou # lower IoU = higher cost + + # Size ratio penalty + area_a = (det_a_xyxy[2] - det_a_xyxy[0]) * (det_a_xyxy[3] - det_a_xyxy[1]) + area_b = (det_b_xyxy[2] - det_b_xyxy[0]) * (det_b_xyxy[3] - det_b_xyxy[1]) + size_ratio = max(area_a, area_b) / (min(area_a, area_b) + 1e-5) + size_penalty = np.log(size_ratio + 1e-5) # higher penalty for large size change + + # Confidence penalty + conf_penalty = 1 - min(conf_a, conf_b) + + # Weighted sum + cost = ( + iou_weight * iou_penalty + + dist_weight * euclidean_dist + + size_weight * size_penalty + + conf_weight * conf_penalty + ) + return cost + +def build_tracking_graph_with_source_sink(detections_per_frame: List): + G = nx.DiGraph() + node_data = {} + + # Add all detection nodes + for frame_idx, detections in enumerate(detections_per_frame): + for det_idx, bbox in enumerate(detections.xyxy): + node_id = (frame_idx, det_idx) + G.add_node(node_id) + node_data[node_id] = { + "bbox": bbox, + "confidence": float(detections.confidence[det_idx]), + "class": str(detections.data["class_name"][det_idx]) + } + + # Add edges between detections in consecutive frames + for t in range(len(detections_per_frame) - 1): + dets_a = detections_per_frame[t] + dets_b = detections_per_frame[t + 1] + + for i, box_a in enumerate(dets_a.xyxy): + for j, box_b in enumerate(dets_b.xyxy): + node_a = (t, i) + node_b = (t + 1, j) + + class_a = str(dets_a.data["class_name"][i]) + class_b = str(dets_b.data["class_name"][j]) + conf_a = float(dets_a.confidence[i]) + conf_b = float(dets_b.confidence[j]) + + cost = compute_edge_cost( + box_a, box_b, conf_a, conf_b, class_a, class_b + ) - path_edges = set(zip(path, path[1:])) + if cost < float('inf'): + G.add_edge(node_a, node_b, weight=cost) - node_colors = [] - for node in G.nodes(): - if node == "source": - node_colors.append("green") - elif node == "sink": - node_colors.append("red") - elif node in path: - node_colors.append("orange") - else: - node_colors.append("white") - - edge_colors = [] - edge_widths = [] - for u, v in G.edges(): - if (u, v) in path_edges: - edge_colors.append("orange") - edge_widths.append(2.5) - else: - edge_colors.append("gray") - edge_widths.append(.5) - - plt.figure(figsize=(16, 8)) - nx.draw( - G, - pos, - with_labels=False, - node_color=node_colors, - edge_color=edge_colors, - width=edge_widths, - node_size=250, - arrows=True, - ) + # Add SOURCE and SINK nodes + G.add_node("SOURCE") + G.add_node("SINK") - # Show edge weights for all edges - edge_labels = { - (u, v): f"{G[u][v]['weight']:.2f}" - for u, v in G.edges() - } - nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_color='black', font_size=7) + # Connect SOURCE to all detections in the first frame + for det_idx in range(len(detections_per_frame[0].xyxy)): + G.add_edge("SOURCE", (0, det_idx), weight=0) - # Node labels (frame:det_idx) - node_labels = { - node: f"{node.frame_id}:{node.det_idx}" if isinstance(node, TrackNode) else node - for node in G.nodes() - } - nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=6) + # Connect all detections in the last frame to SINK + last_frame = len(detections_per_frame) - 1 + for det_idx in range(len(detections_per_frame[-1].xyxy)): + G.add_edge((last_frame, det_idx), "SINK", weight=0) - plt.title(title) - plt.axis("off") - plt.tight_layout() - plt.show() + return G, node_data -def visualize_tracking_graph_with_path_pyvis(G: nx.DiGraph, path: list, output_file="graph.html"): +def visualize_tracking_graph_with_path_pyvis( + G, node_data, path=None, output_file="graph.html" +): net = Network(height="800px", width="100%", directed=True) - net.force_atlas_2based() - + net.toggle_physics(False) spacing_x = 300 spacing_y = 50 - path_edges = set(zip(path, path[1:])) - max_frame = max((n.frame_id for n in G.nodes if isinstance(n, TrackNode)), default=0) + path_edges = set(zip(path, path[1:])) if path else set() + + # Collect frames and assign vertical positions + frame_positions = {} for node in G.nodes(): - if node == "source": + if isinstance(node, tuple) and len(node) == 2: + frame, det_idx = node + frame_positions.setdefault(frame, []).append(det_idx) + for frame in frame_positions: + frame_positions[frame].sort() + + frames = list(frame_positions.keys()) + max_frame = max(frames) if frames else 0 + + for node in G.nodes(): + if node == "SOURCE": x, y = -spacing_x, 0 - elif node == "sink": + label = "SOURCE" + color = "green" + title = "Source node" + elif node == "SINK": x, y = spacing_x * (max_frame + 1), 0 - elif isinstance(node, TrackNode): - x = spacing_x * node.frame_id - y = spacing_y * node.position[1] + label = "SINK" + color = "red" + title = "Sink node" else: - x, y = 0, 0 - - node_id = str(node) - label = f"{node}" if not isinstance(node, TrackNode) else f"{node.frame_id}:{node.det_idx}" - color = "green" if node == "source" else "red" if node == "sink" else "orange" if node in path else "lightgray" - - net.add_node(node_id, label=label, color=color, x=x, y=y, physics=False) + frame, det_idx = node + x = spacing_x * frame + y = spacing_y * frame_positions[frame].index(det_idx) + label = f"{frame}:{det_idx}" + color = "orange" if path and node in path else "lightgray" + data = node_data.get(node, {}) + title = f"Frame: {frame}
ID: {det_idx}" + if "confidence" in data: + title += f"
Conf: {data['confidence']:.2f}" + if "class" in data: + title += f"
Class: {data['class']}" + net.add_node( + str(node), label=label, color=color, x=x, y=y, + physics=False, title=title + ) for u, v, data in G.edges(data=True): u_id, v_id = str(u), str(v) color = "orange" if (u, v) in path_edges else "gray" width = 3 if (u, v) in path_edges else 1 - label = f"{data['weight']:.2f}" + weight = float(data.get("weight", 0)) + label = f"{weight:.2f}" net.add_edge(u_id, v_id, color=color, width=width, label=label) net.set_options(""" @@ -426,154 +232,141 @@ def visualize_tracking_graph_with_path_pyvis(G: nx.DiGraph, path: list, output_f } } """) - net.show(output_file, notebook=False) -def visualize_p_map_on_image(frame, p_map, grid_size, alpha=0.4, show_text=True, cmap='Greens'): - """ - Overlay probability map as a grid on the image using Matplotlib. + net.write_html(output_file, notebook=False, open_browser=True) - Args: - frame (np.ndarray): Original image (H x W x 3). - p_map (np.ndarray): Probability map (grid_h x grid_w). - grid_size (int): Grid cell size in pixels. - alpha (float): Transparency of the overlay. - show_text (bool): Whether to draw probability values in each cell. - cmap (str): Matplotlib colormap name. - """ - h, w = frame.shape[:2] - grid_h, grid_w = p_map.shape - - fig, ax = plt.subplots(figsize=(w / 100, h / 100), dpi=100) - ax.imshow(frame) - - # Normalize p_map to [0,1] for colormap - normed = np.clip(p_map, 0.0, 1.0) - - # Draw each cell with its color and value - for gy in range(grid_h): - for gx in range(grid_w): - prob = normed[gy, gx] - if prob > 0: - x = gx * grid_size - y = gy * grid_size - rect = plt.Rectangle((x, y), grid_size, grid_size, - color=plt.cm.get_cmap(cmap)(prob), alpha=alpha) - ax.add_patch(rect) - - if show_text: - ax.text(x + grid_size / 2, y + grid_size / 2, - f"{prob:.2f}", - ha='center', va='center', - fontsize=6, color='black') - - # Draw grid lines - for gx in range(0, w, grid_size): - ax.axvline(gx, color='white', lw=0.5, alpha=0.5) - for gy in range(0, h, grid_size): - ax.axhline(gy, color='white', lw=0.5, alpha=0.5) - - ax.set_xlim([0, w]) - ax.set_ylim([h, 0]) - ax.axis('off') - plt.tight_layout() - plt.show() - -def visualize_multiple_p_maps(frames, p_maps, grid_size, alpha=0.4, cols=4, show_text=False, cmap='Greens'): +def assign_tracker_ids_from_paths(paths, node_data): """ - Render multiple frames with overlaid probability maps in a grid layout. - + Converts paths and node data into frame-wise sv.Detections with tracker IDs assigned. + Args: - frames (List[np.ndarray]): List of images (H x W x 3). - p_maps (List[np.ndarray]): List of corresponding 2D probability maps. - grid_size (int): Size of each grid cell in pixels. - alpha (float): Transparency of overlay. - cols (int): Number of columns in the subplot grid. - show_text (bool): Show probability values. - cmap (str): Matplotlib colormap. + paths (list of lists): Each inner list is a tracking path (list of nodes). + node_data (dict): Maps node tuples (frame, det_idx) to dicts containing detection info, + e.g. 'xyxy', 'confidence', 'class_id', etc. + + Returns: + dict: frame_index -> sv.Detections for that frame with tracker_id assigned. """ - assert len(frames) == len(p_maps), "Each frame must have a corresponding p_map" - - num_frames = len(frames) - rows = (num_frames + cols - 1) // cols - fig, axes = plt.subplots(rows, cols, figsize=(cols * 4, rows * 3)) - - if rows == 1 and cols == 1: - axes = np.array([[axes]]) - elif rows == 1 or cols == 1: - axes = np.reshape(axes, (rows, cols)) - - for idx, (frame, p_map) in enumerate(zip(frames, p_maps)): - ax = axes[idx // cols, idx % cols] - h, w = frame.shape[:2] - ax.imshow(frame) - - normed = np.clip(p_map, 0.0, 1.0) - grid_h, grid_w = p_map.shape - - for gy in range(grid_h): - for gx in range(grid_w): - prob = normed[gy, gx] - if prob > 0: - x = gx * grid_size - y = gy * grid_size - rect = plt.Rectangle((x, y), grid_size, grid_size, - color=plt.cm.get_cmap(cmap)(prob), alpha=alpha) - ax.add_patch(rect) - if show_text: - ax.text(x + grid_size / 2, y + grid_size / 2, - f"{prob:.2f}", ha='center', va='center', - fontsize=5, color='black') - - for gx in range(0, w, grid_size): - ax.axvline(gx, color='white', lw=0.3, alpha=0.5) - for gy in range(0, h, grid_size): - ax.axhline(gy, color='white', lw=0.3, alpha=0.5) - - ax.set_title(f"Frame {idx}") - ax.set_xlim([0, w]) - ax.set_ylim([h, 0]) - ax.axis('off') - - # Hide unused axes - for i in range(num_frames, rows * cols): - axes[i // cols, i % cols].axis('off') - - plt.tight_layout() - plt.show() + # Normalize input: if a single path, wrap into a list + if isinstance(paths, list) and len(paths) > 0 and not isinstance(paths[0], list): + paths = [paths] -@dataclass(frozen=True) -class TrackNode: - """ - Represents a detection node in the tracking graph. + frame_to_raw_dets = {} - Attributes: - frame_id (int): Frame index where detection occurred. - grid_cell_id (int): Discretized grid cell ID of detection center. - position (tuple): Grid coordinates (x_bin, y_bin). - confidence (float): Detection confidence score. - """ + for tracker_id, path in enumerate(paths, start=1): + for node in path: + if node in ("SOURCE", "SINK"): + continue + try: + frame, det_idx = node + except Exception: + continue - frame_id: int - grid_cell_id: int - det_idx: int - position: tuple - bbox: Any - confidence: float - # dets: Any + det_info = node_data.get(node) + if det_info is None: + continue + + # Store detection info per frame + frame_to_raw_dets.setdefault(frame, []).append({ + "xyxy": det_info["bbox"], + "confidence": det_info["confidence"], + "class_id": 0, + "tracker_id": tracker_id, + }) + + # Now convert each frame detections to sv.Detections objects + frame_to_detections = {} + + for frame, dets_list in frame_to_raw_dets.items(): + xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) + confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) + class_id = np.array([d["class_id"] for d in dets_list], dtype=int) + tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) + + # Construct sv.Detections with tracker_id as an attribute + detections = sv.Detections( + xyxy=xyxy, + confidence=confidence, + class_id=class_id, + tracker_id=tracker_id, + ) + frame_to_detections[frame] = detections - def __hash__(self) -> int: - """Generate hash using frame and grid cell.""" - return hash((self.frame_id, self.grid_cell_id)) + return frame_to_detections - def __eq__(self, other: Any) -> bool: - """Compare nodes by frame and grid cell ID.""" - if not isinstance(other, TrackNode): - return False - return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) +def extend_graph( + G: nx.DiGraph, path: list, weight_key="weight", discourage_weight=1e6 +): + """ + Given a path, extend the graph by: + - Splitting each node (except SOURCE/SINK) into v_in and v_out + - Redirecting incoming edges to v_in and outgoing edges from v_out + - Adding a zero-weight edge between v_in and v_out + - Reversing the used path and adding high-weight edges to discourage reuse + """ + extended_G = nx.DiGraph() + extended_G.add_nodes_from(G.nodes(data=True)) - def __str__(self): - return str(self.frame_id) + " " + str(self.det_idx) + " " + str(self.grid_cell_id) + # Add all original edges + for u, v, data in G.edges(data=True): + extended_G.add_edge(u, v, **data) + + for node in path: + if node in ("SOURCE", "SINK"): + continue + + node_in = f"{node}_in" + node_out = f"{node}_out" + + # Split node + extended_G.add_node(node_in, **G.nodes[node]) + extended_G.add_node(node_out, **G.nodes[node]) + extended_G.add_edge(node_in, node_out, **{weight_key: 0.0}) + + # Rewire incoming edges to node_in + for pred in list(G.predecessors(node)): + extended_G.add_edge(pred, node_in, **G.edges[pred, node]) + + # Rewire outgoing edges from node_out + for succ in list(G.successors(node)): + extended_G.add_edge(node_out, succ, **G.edges[node, succ]) + + # Remove original node edges + extended_G.remove_node(node) + + # Discourage reusing this path by reversing it and setting high weights + for i in range(len(path) - 1): + u, v = path[i], path[i + 1] + if u in ("SOURCE", "SINK") or v in ("SOURCE", "SINK"): + continue + u_out = f"{u}_out" if f"{u}_out" in extended_G else u + v_in = f"{v}_in" if f"{v}_in" in extended_G else v + + # Reverse with discourage weight + extended_G.add_edge(v_in, u_out, **{weight_key: discourage_weight}) + + return extended_G + +def greedy_disjoint_paths_with_extension( + G: nx.DiGraph, node_data, source="SOURCE", sink="SINK", max_paths=10000 +) -> list: + """ + Extract disjoint paths using graph extension and discourage reuse. + """ + G = copy.deepcopy(G) # Don't mutate original + paths = [] + + for _ in range(max_paths): + try: + path = nx.shortest_path(G, source=source, target=sink, weight="weight") + paths.append(path) + G = extend_graph(G, path) # Extend to discourage reuse, preserve structure + except nx.NetworkXNoPath: + print("no path") + + break + return paths class KSPTracker(BaseTracker): """ @@ -631,481 +424,18 @@ def update(self, detections: sv.Detections) -> sv.Detections: """ self.detection_buffer.append(detections) return detections - - def _discretized_grid_cell_id(self, bbox: np.ndarray) -> tuple: - """ - Compute discretized grid cell coordinates from bbox center. - - Args: - bbox (np.ndarray): Bounding box [x1, y1, x2, y2]. - - Returns: - tuple: (grid_x, grid_y) discretized coordinates. - """ - x_center = (bbox[2] + bbox[0]) / 2 - y_center = (bbox[3] + bbox[1]) / 2 - grid_x = int(x_center // self.grid_size) - grid_y = int(y_center // self.grid_size) - return (grid_x, grid_y) - - def get_overlapped_cells(self, bbox: np.ndarray) -> list: - """ - Return all grid cells overlapped by the bounding box. - - Args: - bbox (np.ndarray): [x1, y1, x2, y2] - - Returns: - List of tuples [(grid_x, grid_y), ...] - """ - x1, y1, x2, y2 = bbox - grid_x1 = int(x1 // self.grid_size) - grid_y1 = int(y1 // self.grid_size) - grid_x2 = int(x2 // self.grid_size) - grid_y2 = int(y2 // self.grid_size) - - overlapped_cells = [] - for gx in range(grid_x1, grid_x2 + 1): - for gy in range(grid_y1, grid_y2 + 1): - overlapped_cells.append((gx, gy)) - return overlapped_cells - - def get_node_probability(self, node: TrackNode, Position) -> float: - """ - Retrieve the probability from p_map for the given TrackNode. - - Args: - node (TrackNode): The node, with a .position attribute as (grid_x, grid_y). - - Returns: - float: Probability value at the node's grid cell. - """ - frame_id = node.frame_id - grid_x, grid_y = Position - - if frame_id < 0 or frame_id >= len(self.p_maps): - return 0.0 # Invalid frame index - - p_map = self.p_maps[frame_id] - grid_height, grid_width = p_map.shape - - if 0 <= grid_x < grid_width and 0 <= grid_y < grid_height: - cells = self.get_overlapped_cells(node.bbox) - if ((grid_x, grid_y) in cells): - return float(p_map[grid_y, grid_x]) # Note row=y, col=x in numpy indexing - else: - return 0 - else: - return 0.0 # Out of bounds - - def _edge_cost(self, node: TrackNode, node_next_pos: tuple) -> float: - """ - Compute edge cost from detection confidence. - - Args: - confidence (float): Detection confidence score. - - Returns: - float: Edge cost for KSP (non-negative after transform). - """ - pu = self.get_node_probability(node, node_next_pos) - print(node, "PU: " + str(pu), -math.log10((pu + 1e-6) / ((1 - pu) + 1e-6))) - return np.linalg.norm(np.array(node.position) - np.array(node_next_pos)) - - def build_probability_maps(self, all_detections): - """ - Build a list of probability maps (with Gaussian spread), one per frame. - - Args: - all_detections (list of sv.Detections): List of detection sets per frame. - - Returns: - list of np.ndarray: Each element is a 2D probability map for that frame. - """ - img_width, img_height = self.img_dim - grid_size = self.grid_size - - grid_width = (img_width + grid_size - 1) // grid_size - grid_height = (img_height + grid_size - 1) // grid_size - - print(f"Image size: {img_width}x{img_height}") - print(f"Grid size: {grid_size}") - print(f"Grid width cells: {grid_width}, Grid height cells: {grid_height}") - - all_p_maps = [] - sigma = 1 # in grid units; controls the spread of confidence - - for dets in all_detections: - p_map = np.zeros((grid_height, grid_width), dtype=np.float32) - - if dets.is_empty(): - all_p_maps.append(p_map) - continue - - for i in range(len(dets)): - bbox = dets.xyxy[i] - conf = dets.confidence[i] - - if hasattr(bbox, 'cpu'): - bbox = bbox.cpu().numpy() - if hasattr(conf, 'item'): - conf = conf.item() - - x1, y1, x2, y2 = bbox - x_center = (x1 + x2) / 2 - y_center = (y1 + y2) / 2 - - # center in grid units - grid_xc = x_center / grid_size - grid_yc = y_center / grid_size - - radius = int(2 * sigma) - for dx in range(-radius, radius + 1): - for dy in range(-radius, radius + 1): - gx = int(grid_xc + dx) - gy = int(grid_yc + dy) - if 0 <= gx < grid_width and 0 <= gy < grid_height: - dist2 = dx**2 + dy**2 - weight = conf * math.exp(-dist2 / (2 * sigma**2)) - p_map[gy, gx] = max(p_map[gy, gx], weight) - - all_p_maps.append(p_map) - - self.p_maps = all_p_maps - - def _build_graph(self, all_detections: List[sv.Detections]) -> None: - self.build_probability_maps(all_detections=all_detections) - - self.G = nx.DiGraph() - self.G.add_node("source") - self.G.add_node("sink") - - # self.node_to_detection: Dict[TrackNode, tuple] = {} - node_dict: Dict[int, List[TrackNode]] = {} - - for frame_idx, dets in enumerate(all_detections): - node_dict[frame_idx] = [] - - # # Sort detections by (x1, y1) top-left corner of bbox - # if len(dets) > 0: - # # Get an array of [x1, y1] - # coords = np.array([[bbox[0], bbox[1]] for bbox in dets.xyxy]) - # sorted_indices = np.lexsort((coords[:, 1], coords[:, 0])) # sort by x then y - # else: - # sorted_indices = [] - - sorted_indices = list(range(len(dets))) if len(dets) > 0 else [] - - # Build nodes in sorted order for stable det_idx - for new_det_idx, original_det_idx in enumerate(sorted_indices): - if dets.confidence[original_det_idx] < self.min_confidence: - continue - - pos = self._discretized_grid_cell_id(np.array(dets.xyxy[original_det_idx])) - cell_id = hash(pos) - - node = TrackNode( - frame_id=frame_idx, - grid_cell_id=cell_id, - det_idx=new_det_idx, # use stable new_det_idx - bbox=np.array(dets.xyxy[original_det_idx]), - position=pos, - confidence=dets.confidence[original_det_idx], - ) - - self.G.add_node(node) - node_dict[frame_idx].append(node) - # self.node_to_detection[node] = (frame_idx, original_det_idx) # map to original det_idx - - if frame_idx == 0: - self.G.add_edge("source", node, weight=0) - if frame_idx == len(all_detections) - 1: - self.G.add_edge(node, "sink", weight=0) - - # Add edges as before - for i in range(len(all_detections) - 1): - for node in node_dict[i]: - - for node_next in node_dict[i + 1]: - dist = np.linalg.norm(np.array(node.position) - np.array(node_next.position)) - w = self._edge_cost(node, node_next.position) - self.G.add_edge( - node, - node_next, - weight=w, - ) - - - def _update_detections_with_tracks( - self, assignments: List[List[TrackNode]] - ) -> List[sv.Detections]: - """ - Assign track IDs to detections based on spatially consistent paths, - with debug output per frame. - - Args: - assignments (List[List[TrackNode]]): List of detection paths. - - Returns: - List[sv.Detections]: Detections with assigned tracker IDs. - """ - print(len(assignments)) - all_detections = [] - - assigned = set() - assignment_map = {} - - # Map (frame_id, grid_cell_id) to track ID - for track_id, path in enumerate(assignments, start=1): - for node in path: - if node in {"source", "sink"}: - continue - - key = (node.frame_id, node.grid_cell_id) - if key not in assigned: - assignment_map[key] = track_id - assigned.add(key) - - import pprint - p = pprint.PrettyPrinter(4) - p.pprint(assignment_map) - - for frame_idx, dets in enumerate(self.detection_buffer): - frame_tracker_ids = [-1] * len(dets) - det_pos_to_idx = {} - - for det_idx in range(len(dets)): - pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) - det_pos_to_idx[pos] = det_idx - - for det_idx in range(len(dets)): - pos = self._discretized_grid_cell_id(np.array(dets.xyxy[det_idx])) - key = (frame_idx, hash(pos)) - print( "Frame: "+ str(frame_idx), key, det_pos_to_idx[pos], assignment_map[key] if key in assignment_map else "NIL") - if key in assignment_map: - frame_tracker_ids[det_pos_to_idx[pos]] = assignment_map[key] - - dets.tracker_id = np.array(frame_tracker_ids) - all_detections.append(dets) - - # Debug output for this frame - num_tracked = sum(tid != -1 for tid in frame_tracker_ids) - print(f"[Frame {frame_idx}] Total Detections: {len(frame_tracker_ids)} | Tracked: {num_tracked}") - for det_idx, tid in enumerate(frame_tracker_ids): - bbox = dets.xyxy[det_idx] - conf = dets.confidence[det_idx] - if tid == -1: - print(f" ⛔ Untracked Detection {det_idx}: BBox={bbox}, Conf={conf:.2f}") - else: - print(f" ✅ Track {tid} assigned to Detection {det_idx}: BBox={bbox}, Conf={conf:.2f}") - - frames_rgb = [cv2.cvtColor(f, cv2.COLOR_BGR2RGB) for f in self.frames] - InteractivePMapViewer(frames_rgb, self.p_maps, grid_size=self.grid_size, show_text=True) - return all_detections - - def _shortest_path(self, G: nx.Graph) -> tuple: - """ - Compute shortest path from 'source' to 'sink' using Bellman-Ford. - - Returns: - tuple: (path, total_cost, lengths) - - Raises: - RuntimeError: If negative cycle detected. - KeyError: If sink unreachable. - """ - try: - lengths, paths = nx.single_source_bellman_ford(G, "source", weight="weight") - if "sink" not in paths: - raise KeyError("No path found from source to sink.") - return paths["sink"], lengths["sink"], lengths - except nx.NetworkXUnbounded: - raise RuntimeError("Graph contains a negative weight cycle.") - - def _extend_graph(self, paths: list[list]): - """ - Extend the graph as per Berclaz et al. (2011), Table 4: - - Split each used node (except source/sink) into `v_in` and `v_out` - - Add a zero-cost edge v_in → v_out - - Redirect edges accordingly - - Reverse and negate all path edges - - Args: - paths (List[List[TrackNode]]): Previously found paths. - - Returns: - nx.DiGraph: Extended graph with node splits and reversed edges. - """ - G_ext = self.G.copy() - split_map = {} - - # Step 1: Split internal nodes (not source/sink) - for path in paths: - for node in path: - if node in {"source", "sink"}: - continue - if node in split_map: - continue # Already split - - # Deepcopy node to keep all attributes, add split_tag for identification - v_in = deepcopy(node) - v_out = deepcopy(node) - object.__setattr__(v_in, "split_tag", "in") - object.__setattr__(v_out, "split_tag", "out") - split_map[node] = (v_in, v_out) - - # Add new nodes - G_ext.add_node(v_in) - G_ext.add_node(v_out) - - # Add auxiliary zero-cost edge v_in -> v_out - G_ext.add_edge(v_in, v_out, weight=0) - - # Redirect incoming edges to v_in - for u, _, data in list(G_ext.in_edges(node, data=True)): - G_ext.add_edge(u, v_in, **data) - G_ext.remove_edge(u, node) - - # Redirect outgoing edges from v_out - for _, v, data in list(G_ext.out_edges(node, data=True)): - G_ext.add_edge(v_out, v, **data) - G_ext.remove_edge(node, v) - - # Remove original node - G_ext.remove_node(node) - - # Step 2: Reverse and negate all edges in used paths - for path in paths: - for i in range(len(path) - 1): - u, v = path[i], path[i + 1] - - # Use split nodes if they exist - u_out = split_map[u][1] if u in split_map else u - v_in = split_map[v][0] if v in split_map else v - - if G_ext.has_edge(u_out, v_in): - cost = G_ext[u_out][v_in]["weight"] - G_ext.remove_edge(u_out, v_in) - G_ext.add_edge(v_in, u_out, weight=-cost) - - return G_ext - - def _transform_edge_cost( - self, G: nx.DiGraph, shortest_costs: Dict[Any, float] - ) -> nx.DiGraph: - """ - Apply cost transformation to ensure non-negative edge weights. - - Args: - G (nx.DiGraph): Graph with possibly negative weights. - shortest_costs (dict): Shortest path distances from source. - - Returns: - nx.DiGraph: Cost-transformed graph. - """ - Gc = nx.DiGraph() - for u, v, data in G.edges(data=True): - if u not in shortest_costs or v not in shortest_costs: - continue - original = data["weight"] - transformed = original + shortest_costs[u] - shortest_costs[v] - Gc.add_edge(u, v, weight=transformed) - return Gc - - def _interlace_paths( - self, current_paths: List[List[TrackNode]], new_path: List[TrackNode] - ) -> List[TrackNode]: - """ - Remove nodes from new_path that conflict with current_paths. - - Args: - current_paths (List[List[TrackNode]]): Existing disjoint paths. - new_path (List[TrackNode]): New candidate path. - - Returns: - List[TrackNode]: Interlaced path without conflicts. - """ - used_nodes = set() - for path in current_paths: - for node in path: - if isinstance(node, TrackNode): - used_nodes.add((node.frame_id, node.grid_cell_id)) - - interlaced = [] - for node in new_path: - if isinstance(node, TrackNode): - key = (node.frame_id, node.grid_cell_id) - if key not in used_nodes: - interlaced.append(node) - - return interlaced - - def ksp(self) -> List[List[TrackNode]]: - """ - Compute k disjoint shortest paths using KSP algorithm. - - Returns: - List[List[TrackNode]]: List of disjoint detection paths. - """ - - #path, cost, s_lengths = self._shortest_path(self.G) - lengths, paths = nx.single_source_bellman_ford(self.G, "source", weight="weight") - if "sink" not in paths: - raise KeyError("No path found from source to sink.") - - path, cost = paths["sink"], lengths["sink"] - print(cost, nx.shortest_path_length(self.G, "source", "sink", weight="weight")) - P = [path] - cost_P = [cost] - Gc_l_last = None - - - print("Cost: " + str(cost)) - print(self.G) - visualize_tracking_graph_with_path_pyvis(self.G, P[-1], "graph1.html") - - for l in range(1, self.max_paths): - if l != 1 and cost_P[-1] >= cost_P[-2]: - return P # early termination - - - wHa = Gc_l_last or self.G - print(l, wHa) - #s_lens, _ = nx.single_source_dijkstra(wHa, "source", weight="weight") - #shortest_costs = s_lens - - Gl = self._extend_graph(P) - Gc_l = Gl # self._transform_edge_cost(Gl, shortest_costs) - - try: - lengths, paths_dict = nx.single_source_bellman_ford(Gc_l, "source", weight="weight") - - if "sink" not in paths_dict: - break - - new_path = paths_dict["sink"] - cost_P.append(lengths["sink"]) - - interlaced_path = self._interlace_paths(P, new_path) - P.append(interlaced_path) - Gc_l_last = Gc_l - visualize_tracking_graph_with_path_pyvis(Gc_l, P[-1], "graph" + str(len(P)) + ".html") - except nx.NetworkXNoPath: - break - print(len(P)) - return P - def process_tracks(self) -> List[sv.Detections]: - """ - Run the tracking algorithm and assign track IDs to detections. - - Returns: - List[sv.Detections]: Detections updated with tracker IDs. - """ - self._build_graph(self.detection_buffer) - # visualize_tracking_graph_debug(self.G) - # detections_list = find_and_visualize_disjoint_paths(self.G) - # return detections_list - disjoint_paths = self.ksp() - return self._update_detections_with_tracks(assignments=disjoint_paths) + G, node_data = build_tracking_graph_with_source_sink(self.detection_buffer) + paths = greedy_disjoint_paths_with_extension(G, node_data) + print(len(paths)) + + if not paths: + print("No valid paths found.") + return [] + + return assign_tracker_ids_from_paths(paths, node_data) + # self._build_graph(self.detection_buffer) + # # visualize_tracking_graph_debug(self.G) + # # detections_list = find_and_visualize_disjoint_paths(self.G) + # # return detections_list From c764107a5ed0ce3cac985ec70381d72b0103bce9 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 01:23:04 -0400 Subject: [PATCH 044/100] FIX: Testing and changing got something relative to expectation? --- .../core/ksptracker/InteractivePMapViewer.py | 77 ---- trackers/core/ksptracker/tracker.py | 393 +++++------------- 2 files changed, 112 insertions(+), 358 deletions(-) delete mode 100644 trackers/core/ksptracker/InteractivePMapViewer.py diff --git a/trackers/core/ksptracker/InteractivePMapViewer.py b/trackers/core/ksptracker/InteractivePMapViewer.py deleted file mode 100644 index 578b0ad8..00000000 --- a/trackers/core/ksptracker/InteractivePMapViewer.py +++ /dev/null @@ -1,77 +0,0 @@ -import matplotlib.pyplot as plt -import matplotlib.patches as patches -import numpy as np - -class InteractivePMapViewer: - def __init__(self, frames, p_maps, grid_size=50, alpha=0.4, show_text=True, cmap='Greens'): - self.frames = frames - self.p_maps = p_maps - self.grid_size = grid_size - self.alpha = alpha - self.show_text = show_text - self.cmap = cmap - self.idx = 0 - - self.fig, self.ax = plt.subplots() - self.fig.canvas.mpl_connect('key_press_event', self.on_key) - self.im = None - self.texts = [] - self.patches = [] - - self.render() - - plt.show() - - def render(self): - self.ax.clear() - frame = self.frames[self.idx] - p_map = self.p_maps[self.idx] - h, w = frame.shape[:2] - - self.ax.imshow(frame) - normed = np.clip(p_map, 0.0, 1.0) - grid_h, grid_w = p_map.shape - - self.patches.clear() - self.texts.clear() - - for gy in range(grid_h): - for gx in range(grid_w): - prob = normed[gy, gx] - if prob > 0: - x = gx * self.grid_size - y = gy * self.grid_size - rect = patches.Rectangle((x, y), self.grid_size, self.grid_size, - linewidth=0.5, edgecolor='white', - facecolor=plt.cm.get_cmap(self.cmap)(prob), - alpha=self.alpha) - self.ax.add_patch(rect) - self.patches.append(rect) - - if self.show_text: - text = self.ax.text(x + self.grid_size / 2, - y + self.grid_size / 2, - f"{prob:.2f}", - ha='center', va='center', - fontsize=6, color='black') - self.texts.append(text) - - # Grid lines - for gx in range(0, w, self.grid_size): - self.ax.axvline(gx, color='white', lw=0.3, alpha=0.5) - for gy in range(0, h, self.grid_size): - self.ax.axhline(gy, color='white', lw=0.3, alpha=0.5) - - self.ax.set_title(f"Frame {self.idx + 1}/{len(self.frames)}") - self.ax.set_xlim([0, w]) - self.ax.set_ylim([h, 0]) - self.ax.axis('off') - self.fig.canvas.draw() - - def on_key(self, event): - if event.key == 'right': - self.idx = min(self.idx + 1, len(self.frames) - 1) - self.render() - elif event.key == 'left': - self.idx = max(self.idx - 1, 0) - self.render() diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 0b3eb852..1414de2b 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple, Optional import networkx as nx import numpy as np @@ -7,31 +7,13 @@ from trackers.core.base import BaseTracker -import matplotlib.pyplot as plt -from tqdm import tqdm -import math -import cv2 -import itertools -from copy import deepcopy -from pyvis.network import Network -import matplotlib.colors as mcolors -import matplotlib.cm as cm -from trackers.core.ksptracker.InteractivePMapViewer import InteractivePMapViewer - import pprint -p = pprint.PrettyPrinter(4) -import copy +p = pprint.PrettyPrinter() @dataclass(frozen=True) class TrackNode: """ Represents a detection node in the tracking graph. - - Attributes: - frame_id (int): Frame index where detection occurred. - grid_cell_id (int): Discretized grid cell ID of detection center. - position (tuple): Grid coordinates (x_bin, y_bin). - confidence (float): Detection confidence score. """ frame_id: int grid_cell_id: int @@ -41,15 +23,13 @@ class TrackNode: confidence: float def __hash__(self) -> int: - """Generate hash using frame and grid cell.""" - return hash((self.frame_id, self.grid_cell_id)) + return hash((self.frame_id, self.det_idx)) def __eq__(self, other: Any) -> bool: - """Compare nodes by frame and grid cell ID.""" if not isinstance(other, TrackNode): return False - return (self.frame_id, self.grid_cell_id) == (other.frame_id, other.grid_cell_id) - + return (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) + def __str__(self): return f"{self.frame_id} {self.det_idx} {self.grid_cell_id}" @@ -57,20 +37,16 @@ def compute_edge_cost( det_a_xyxy, det_b_xyxy, conf_a, conf_b, class_a, class_b, iou_weight=0.5, dist_weight=0.3, size_weight=0.1, conf_weight=0.1 ): - # Block if class doesn't match - if class_a != class_b: - return float('inf') - - # Get box centers + print("TEST") + print(det_a_xyxy) def get_center(box): x1, y1, x2, y2 = box return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) - + center_a = get_center(det_a_xyxy) center_b = get_center(det_b_xyxy) euclidean_dist = np.linalg.norm(center_a - center_b) - # IoU between boxes def box_iou(boxA, boxB): xA = max(boxA[0], boxB[0]) yA = max(boxA[1], boxB[1]) @@ -83,18 +59,15 @@ def box_iou(boxA, boxB): return inter_area / union_area if union_area > 0 else 0 iou = box_iou(det_a_xyxy, det_b_xyxy) - iou_penalty = 1 - iou # lower IoU = higher cost + iou_penalty = 1 - iou - # Size ratio penalty area_a = (det_a_xyxy[2] - det_a_xyxy[0]) * (det_a_xyxy[3] - det_a_xyxy[1]) area_b = (det_b_xyxy[2] - det_b_xyxy[0]) * (det_b_xyxy[3] - det_b_xyxy[1]) size_ratio = max(area_a, area_b) / (min(area_a, area_b) + 1e-5) - size_penalty = np.log(size_ratio + 1e-5) # higher penalty for large size change + size_penalty = np.log(size_ratio + 1e-5) - # Confidence penalty conf_penalty = 1 - min(conf_a, conf_b) - # Weighted sum cost = ( iou_weight * iou_penalty + dist_weight * euclidean_dist + @@ -103,40 +76,47 @@ def box_iou(boxA, boxB): ) return cost -def build_tracking_graph_with_source_sink(detections_per_frame: List): +def build_tracking_graph_with_source_sink(detections_per_frame: List[sv.Detections]): G = nx.DiGraph() - node_data = {} - + node_list_per_frame = [] + print("SDFSDFSD") # Add all detection nodes for frame_idx, detections in enumerate(detections_per_frame): + frame_nodes = [] for det_idx, bbox in enumerate(detections.xyxy): - node_id = (frame_idx, det_idx) - G.add_node(node_id) - node_data[node_id] = { - "bbox": bbox, - "confidence": float(detections.confidence[det_idx]), - "class": str(detections.data["class_name"][det_idx]) - } + position = ( + float(bbox[0] + bbox[2]) / 2.0, + float(bbox[1] + bbox[3]) / 2.0 + ) + node = TrackNode( + frame_id=frame_idx, + det_idx=det_idx, + position=position, + bbox=bbox, + confidence=float(detections.confidence[det_idx]), + grid_cell_id=None + ) + G.add_node(node) + frame_nodes.append(node) + node_list_per_frame.append(frame_nodes) # Add edges between detections in consecutive frames - for t in range(len(detections_per_frame) - 1): + for t in range(len(node_list_per_frame) - 1): + nodes_a = node_list_per_frame[t] + nodes_b = node_list_per_frame[t + 1] dets_a = detections_per_frame[t] dets_b = detections_per_frame[t + 1] - for i, box_a in enumerate(dets_a.xyxy): - for j, box_b in enumerate(dets_b.xyxy): - node_a = (t, i) - node_b = (t + 1, j) - + for i, node_a in enumerate(nodes_a): + for j, node_b in enumerate(nodes_b): class_a = str(dets_a.data["class_name"][i]) class_b = str(dets_b.data["class_name"][j]) conf_a = float(dets_a.confidence[i]) conf_b = float(dets_b.confidence[j]) cost = compute_edge_cost( - box_a, box_b, conf_a, conf_b, class_a, class_b + node_a.bbox, node_b.bbox, conf_a, conf_b, class_a, class_b ) - if cost < float('inf'): G.add_edge(node_a, node_b, weight=cost) @@ -145,145 +125,39 @@ def build_tracking_graph_with_source_sink(detections_per_frame: List): G.add_node("SINK") # Connect SOURCE to all detections in the first frame - for det_idx in range(len(detections_per_frame[0].xyxy)): - G.add_edge("SOURCE", (0, det_idx), weight=0) + for node in node_list_per_frame[0]: + G.add_edge("SOURCE", node, weight=0) # Connect all detections in the last frame to SINK - last_frame = len(detections_per_frame) - 1 - for det_idx in range(len(detections_per_frame[-1].xyxy)): - G.add_edge((last_frame, det_idx), "SINK", weight=0) - - return G, node_data + for node in node_list_per_frame[-1]: + G.add_edge(node, "SINK", weight=0) -def visualize_tracking_graph_with_path_pyvis( - G, node_data, path=None, output_file="graph.html" -): - net = Network(height="800px", width="100%", directed=True) - net.toggle_physics(False) - spacing_x = 300 - spacing_y = 50 - - path_edges = set(zip(path, path[1:])) if path else set() - - # Collect frames and assign vertical positions - frame_positions = {} - for node in G.nodes(): - if isinstance(node, tuple) and len(node) == 2: - frame, det_idx = node - frame_positions.setdefault(frame, []).append(det_idx) - for frame in frame_positions: - frame_positions[frame].sort() - - frames = list(frame_positions.keys()) - max_frame = max(frames) if frames else 0 - - for node in G.nodes(): - if node == "SOURCE": - x, y = -spacing_x, 0 - label = "SOURCE" - color = "green" - title = "Source node" - elif node == "SINK": - x, y = spacing_x * (max_frame + 1), 0 - label = "SINK" - color = "red" - title = "Sink node" - else: - frame, det_idx = node - x = spacing_x * frame - y = spacing_y * frame_positions[frame].index(det_idx) - label = f"{frame}:{det_idx}" - color = "orange" if path and node in path else "lightgray" - data = node_data.get(node, {}) - title = f"Frame: {frame}
ID: {det_idx}" - if "confidence" in data: - title += f"
Conf: {data['confidence']:.2f}" - if "class" in data: - title += f"
Class: {data['class']}" - net.add_node( - str(node), label=label, color=color, x=x, y=y, - physics=False, title=title - ) + return G - for u, v, data in G.edges(data=True): - u_id, v_id = str(u), str(v) - color = "orange" if (u, v) in path_edges else "gray" - width = 3 if (u, v) in path_edges else 1 - weight = float(data.get("weight", 0)) - label = f"{weight:.2f}" - net.add_edge(u_id, v_id, color=color, width=width, label=label) - - net.set_options(""" - var options = { - "nodes": { - "font": { - "size": 14 - } - }, - "edges": { - "font": { - "size": 10, - "align": "middle" - }, - "smooth": false - }, - "physics": { - "enabled": false - } - } - """) - - net.write_html(output_file, notebook=False, open_browser=True) - -def assign_tracker_ids_from_paths(paths, node_data): +def assign_tracker_ids_from_paths(paths: List[List[TrackNode]], num_frames: int) -> Dict[int, sv.Detections]: """ - Converts paths and node data into frame-wise sv.Detections with tracker IDs assigned. - - Args: - paths (list of lists): Each inner list is a tracking path (list of nodes). - node_data (dict): Maps node tuples (frame, det_idx) to dicts containing detection info, - e.g. 'xyxy', 'confidence', 'class_id', etc. - - Returns: - dict: frame_index -> sv.Detections for that frame with tracker_id assigned. + Converts paths (list of list of TrackNode) into frame-wise sv.Detections with tracker IDs assigned. """ - # Normalize input: if a single path, wrap into a list - if isinstance(paths, list) and len(paths) > 0 and not isinstance(paths[0], list): - paths = [paths] - - frame_to_raw_dets = {} + frame_to_dets = {frame: [] for frame in range(num_frames)} for tracker_id, path in enumerate(paths, start=1): for node in path: - if node in ("SOURCE", "SINK"): - continue - try: - frame, det_idx = node - except Exception: - continue - - det_info = node_data.get(node) - if det_info is None: - continue - - # Store detection info per frame - frame_to_raw_dets.setdefault(frame, []).append({ - "xyxy": det_info["bbox"], - "confidence": det_info["confidence"], - "class_id": 0, - "tracker_id": tracker_id, - }) - - # Now convert each frame detections to sv.Detections objects - frame_to_detections = {} + if isinstance(node, TrackNode): + frame_to_dets[node.frame_id].append({ + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": 0, + "tracker_id": tracker_id, + }) - for frame, dets_list in frame_to_raw_dets.items(): + frame_to_detections = {} + for frame, dets_list in frame_to_dets.items(): + if not dets_list: + continue xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) class_id = np.array([d["class_id"] for d in dets_list], dtype=int) tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) - - # Construct sv.Detections with tracker_id as an attribute detections = sv.Detections( xyxy=xyxy, confidence=confidence, @@ -294,89 +168,62 @@ def assign_tracker_ids_from_paths(paths, node_data): return frame_to_detections -def extend_graph( - G: nx.DiGraph, path: list, weight_key="weight", discourage_weight=1e6 -): - """ - Given a path, extend the graph by: - - Splitting each node (except SOURCE/SINK) into v_in and v_out - - Redirecting incoming edges to v_in and outgoing edges from v_out - - Adding a zero-weight edge between v_in and v_out - - Reversing the used path and adding high-weight edges to discourage reuse - """ - extended_G = nx.DiGraph() - extended_G.add_nodes_from(G.nodes(data=True)) - - # Add all original edges - for u, v, data in G.edges(data=True): - extended_G.add_edge(u, v, **data) - - for node in path: - if node in ("SOURCE", "SINK"): - continue - - node_in = f"{node}_in" - node_out = f"{node}_out" +def path_cost(G: nx.DiGraph, path: list, weight_key="weight") -> float: + return sum(G[u][v][weight_key] for u, v in zip(path[:-1], path[1:])) - # Split node - extended_G.add_node(node_in, **G.nodes[node]) - extended_G.add_node(node_out, **G.nodes[node]) - extended_G.add_edge(node_in, node_out, **{weight_key: 0.0}) +# ------------- Main Function ---------------- +from collections import defaultdict - # Rewire incoming edges to node_in - for pred in list(G.predecessors(node)): - extended_G.add_edge(pred, node_in, **G.edges[pred, node]) - - # Rewire outgoing edges from node_out - for succ in list(G.successors(node)): - extended_G.add_edge(node_out, succ, **G.edges[node, succ]) - - # Remove original node edges - extended_G.remove_node(node) - - # Discourage reusing this path by reversing it and setting high weights - for i in range(len(path) - 1): - u, v = path[i], path[i + 1] - if u in ("SOURCE", "SINK") or v in ("SOURCE", "SINK"): - continue - - u_out = f"{u}_out" if f"{u}_out" in extended_G else u - v_in = f"{v}_in" if f"{v}_in" in extended_G else v - - # Reverse with discourage weight - extended_G.add_edge(v_in, u_out, **{weight_key: discourage_weight}) - - return extended_G - -def greedy_disjoint_paths_with_extension( - G: nx.DiGraph, node_data, source="SOURCE", sink="SINK", max_paths=10000 -) -> list: +def discourage_path_edges(G, path, weight_key="weight", penalty=1e6): """ - Extract disjoint paths using graph extension and discourage reuse. + Add penalty to edges along the given path to discourage reuse. """ - G = copy.deepcopy(G) # Don't mutate original + for u, v in zip(path[:-1], path[1:]): + if G.has_edge(u, v): + G[u][v][weight_key] += penalty + +def iterative_k_shortest_paths_soft_penalty( + G: nx.DiGraph, + source="SOURCE", + sink="SINK", + k=5, + weight_key="weight", + base_penalty=10.0, # smaller penalty +): + G_base = G.copy() paths = [] + edge_reuse_count = defaultdict(int) + + for iteration in range(k): + G_mod = G_base.copy() + + # Increase edge weights softly according to reuse count + for (u, v, data) in G_mod.edges(data=True): + base_cost = data[weight_key] + reuse_pen = base_penalty * edge_reuse_count[(u, v)] * base_cost + data[weight_key] = base_cost + reuse_pen - for _ in range(max_paths): try: - path = nx.shortest_path(G, source=source, target=sink, weight="weight") - paths.append(path) - G = extend_graph(G, path) # Extend to discourage reuse, preserve structure + length, path = nx.single_source_dijkstra(G_mod, source, sink, weight=weight_key) except nx.NetworkXNoPath: - print("no path") - + print(f"No more paths found after {len(paths)} iterations.") break - return paths + if path in paths: + print("Duplicate path found, stopping early.") + break + + print(f"Found path {iteration + 1} with cost {length}") + paths.append(path) + + # Update reuse counts for edges in this path + for u, v in zip(path[:-1], path[1:]): + edge_reuse_count[(u, v)] += 1 + + return paths class KSPTracker(BaseTracker): """ Offline tracker using K-Shortest Paths (KSP) algorithm. - - Attributes: - grid_size (int): Size of each grid cell (pixels). - min_confidence (float): Minimum detection confidence to consider. - max_distance (float): Maximum spatial distance between nodes to connect. - max_paths (int): Maximum number of paths (tracks) to find. """ def __init__( @@ -387,15 +234,6 @@ def __init__( max_distance: float = 0.3, img_dim: tuple = (512, 512), ) -> None: - """ - Initialize the KSP tracker. - - Args: - grid_size (int): Pixel size of grid cells. - max_paths (int): Max number of paths to find. - min_confidence (float): Minimum confidence to keep detection. - max_distance (float): Max allowed spatial distance between nodes. - """ self.grid_size = grid_size self.min_confidence = min_confidence self.max_distance = max_distance @@ -409,33 +247,26 @@ def set_image_dim(self, dim: tuple): self.img_dim = dim def reset(self) -> None: - """Reset the detection buffer.""" self.detection_buffer: List[sv.Detections] = [] def update(self, detections: sv.Detections) -> sv.Detections: - """ - Append new detections to the buffer. - - Args: - detections (sv.Detections): Detections for the current frame. - - Returns: - sv.Detections: The same detections passed in. - """ self.detection_buffer.append(detections) return detections - - def process_tracks(self) -> List[sv.Detections]: - G, node_data = build_tracking_graph_with_source_sink(self.detection_buffer) - paths = greedy_disjoint_paths_with_extension(G, node_data) - print(len(paths)) + def process_tracks(self) -> Dict[int, sv.Detections]: + max_detection_obj = len(max(self.detection_buffer, key=lambda d: len(d.xyxy))) + + print(f"Number of detections: {max_detection_obj}") + G = build_tracking_graph_with_source_sink(self.detection_buffer) + paths = iterative_k_shortest_paths_soft_penalty(G, source="SOURCE", sink="SINK", k=max_detection_obj) + print(f"Extracted {len(paths)} tracks.") + for i in paths: + p.pprint(i) + # print(paths[0] == paths[1]) + # for path in paths: + # print(path) + # print([p.position for p in path[1:-1]]) if not paths: print("No valid paths found.") - return [] - - return assign_tracker_ids_from_paths(paths, node_data) - # self._build_graph(self.detection_buffer) - # # visualize_tracking_graph_debug(self.G) - # # detections_list = find_and_visualize_disjoint_paths(self.G) - # # return detections_list + return {} + return assign_tracker_ids_from_paths(paths, num_frames=len(self.detection_buffer)) From 1113154139ea824f6a8a591696b269da59c4276a Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:03:03 -0400 Subject: [PATCH 045/100] MISC: Clean up! --- trackers/core/ksptracker/solver.py | 134 +++++++++++++ trackers/core/ksptracker/tracker.py | 286 +++++----------------------- 2 files changed, 178 insertions(+), 242 deletions(-) create mode 100644 trackers/core/ksptracker/solver.py diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py new file mode 100644 index 00000000..7d847918 --- /dev/null +++ b/trackers/core/ksptracker/solver.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from collections import defaultdict +from typing import Any, List, Optional + +import supervision as sv +import networkx as nx +import numpy as np + + +@dataclass(frozen=True) +class TrackNode: + frame_id: int + det_idx: int + class_id: int + position: tuple + bbox: np.ndarray + confidence: float + + def __hash__(self): + return hash((self.frame_id, self.det_idx)) + + def __eq__(self, other: Any): + return isinstance(other, TrackNode) and (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) + + def __str__(self): + return f"{self.frame_id}:{self.det_idx}@{self.position}" + + +class KSP_Solver: + def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): + self.base_penalty = base_penalty + self.weight_key = weight_key + self.source = "SOURCE" + self.sink = "SINK" + self.detection_per_frame: List[sv.Detections] = [] + self.reset() + + def reset(self): + self.detection_per_frame: List[sv.Detections] = [] + self.graph: nx.DiGraph = nx.DiGraph() + + def append_frame(self, detections: sv.Detections): + self.detection_per_frame.append(detections) + + def _get_center(self, bbox): + x1, y1, x2, y2 = bbox + return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + + def _iou(self, a, b): + x1, y1, x2, y2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]) + inter = max(0, x2 - x1) * max(0, y2 - y1) + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + return inter / (area_a + area_b - inter + 1e-6) + + def _edge_cost(self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, conf_w=0.1): + center_dist = np.linalg.norm(self._get_center(a) - self._get_center(b)) + iou_penalty = 1 - self._iou(a, b) + + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + size_penalty = np.log((max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6) + + conf_penalty = 1 - min(conf_a, conf_b) + + return iou_w * iou_penalty + dist_w * center_dist + size_w * size_penalty + conf_w * conf_penalty + + def _build_graph(self): + G = nx.DiGraph() + G.add_node(self.source) + G.add_node(self.sink) + + node_frames: List[List[TrackNode]] = [] + + for frame_id, detections in enumerate(self.detection_per_frame): + frame_nodes = [] + for det_idx, bbox in enumerate(detections.xyxy): + node = TrackNode( + frame_id=frame_id, + det_idx=det_idx, + class_id=int(detections.class_id[det_idx]), + position=tuple(self._get_center(bbox)), + bbox=bbox, + confidence=float(detections.confidence[det_idx]) + ) + G.add_node(node) + frame_nodes.append(node) + node_frames.append(frame_nodes) + + for t in range(len(node_frames) - 1): + for node_a in node_frames[t]: + for node_b in node_frames[t + 1]: + cost = self._edge_cost(node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence) + G.add_edge(node_a, node_b, weight=cost) + + for node in node_frames[0]: + G.add_edge(self.source, node, weight=0.0) + for node in node_frames[-1]: + G.add_edge(node, self.sink, weight=0.0) + + self.graph = G + + def solve(self, k: Optional[int] = None) -> List[List[Any]]: + self._build_graph() + + G_base = self.graph.copy() + edge_reuse = defaultdict(int) + paths = [] + + if k is None: + k = max(len(f.xyxy) for f in self.detection_per_frame) + + for _ in range(k): + G_mod = G_base.copy() + + for u, v, data in G_mod.edges(data=True): + base = data[self.weight_key] + penalty = self.base_penalty * edge_reuse[(u, v)] * base + data[self.weight_key] = base + penalty + + try: + _, path = nx.single_source_dijkstra(G_mod, self.source, self.sink, weight=self.weight_key) + except nx.NetworkXNoPath: + break + + if path in paths: + break + + paths.append(path) + + for u, v in zip(path[:-1], path[1:]): + edge_reuse[(u, v)] += 1 + + return paths \ No newline at end of file diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 1414de2b..0e1ac18d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -6,221 +6,9 @@ import supervision as sv from trackers.core.base import BaseTracker +from trackers.core.ksptracker.solver import KSP_Solver, TrackNode -import pprint -p = pprint.PrettyPrinter() -@dataclass(frozen=True) -class TrackNode: - """ - Represents a detection node in the tracking graph. - """ - frame_id: int - grid_cell_id: int - det_idx: int - position: tuple - bbox: Any - confidence: float - - def __hash__(self) -> int: - return hash((self.frame_id, self.det_idx)) - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, TrackNode): - return False - return (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) - - def __str__(self): - return f"{self.frame_id} {self.det_idx} {self.grid_cell_id}" - -def compute_edge_cost( - det_a_xyxy, det_b_xyxy, conf_a, conf_b, class_a, class_b, - iou_weight=0.5, dist_weight=0.3, size_weight=0.1, conf_weight=0.1 -): - print("TEST") - print(det_a_xyxy) - def get_center(box): - x1, y1, x2, y2 = box - return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) - - center_a = get_center(det_a_xyxy) - center_b = get_center(det_b_xyxy) - euclidean_dist = np.linalg.norm(center_a - center_b) - - def box_iou(boxA, boxB): - xA = max(boxA[0], boxB[0]) - yA = max(boxA[1], boxB[1]) - xB = min(boxA[2], boxB[2]) - yB = min(boxA[3], boxB[3]) - inter_area = max(0, xB - xA) * max(0, yB - yA) - boxA_area = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) - boxB_area = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) - union_area = boxA_area + boxB_area - inter_area - return inter_area / union_area if union_area > 0 else 0 - - iou = box_iou(det_a_xyxy, det_b_xyxy) - iou_penalty = 1 - iou - - area_a = (det_a_xyxy[2] - det_a_xyxy[0]) * (det_a_xyxy[3] - det_a_xyxy[1]) - area_b = (det_b_xyxy[2] - det_b_xyxy[0]) * (det_b_xyxy[3] - det_b_xyxy[1]) - size_ratio = max(area_a, area_b) / (min(area_a, area_b) + 1e-5) - size_penalty = np.log(size_ratio + 1e-5) - - conf_penalty = 1 - min(conf_a, conf_b) - - cost = ( - iou_weight * iou_penalty + - dist_weight * euclidean_dist + - size_weight * size_penalty + - conf_weight * conf_penalty - ) - return cost - -def build_tracking_graph_with_source_sink(detections_per_frame: List[sv.Detections]): - G = nx.DiGraph() - node_list_per_frame = [] - print("SDFSDFSD") - # Add all detection nodes - for frame_idx, detections in enumerate(detections_per_frame): - frame_nodes = [] - for det_idx, bbox in enumerate(detections.xyxy): - position = ( - float(bbox[0] + bbox[2]) / 2.0, - float(bbox[1] + bbox[3]) / 2.0 - ) - node = TrackNode( - frame_id=frame_idx, - det_idx=det_idx, - position=position, - bbox=bbox, - confidence=float(detections.confidence[det_idx]), - grid_cell_id=None - ) - G.add_node(node) - frame_nodes.append(node) - node_list_per_frame.append(frame_nodes) - - # Add edges between detections in consecutive frames - for t in range(len(node_list_per_frame) - 1): - nodes_a = node_list_per_frame[t] - nodes_b = node_list_per_frame[t + 1] - dets_a = detections_per_frame[t] - dets_b = detections_per_frame[t + 1] - - for i, node_a in enumerate(nodes_a): - for j, node_b in enumerate(nodes_b): - class_a = str(dets_a.data["class_name"][i]) - class_b = str(dets_b.data["class_name"][j]) - conf_a = float(dets_a.confidence[i]) - conf_b = float(dets_b.confidence[j]) - - cost = compute_edge_cost( - node_a.bbox, node_b.bbox, conf_a, conf_b, class_a, class_b - ) - if cost < float('inf'): - G.add_edge(node_a, node_b, weight=cost) - - # Add SOURCE and SINK nodes - G.add_node("SOURCE") - G.add_node("SINK") - - # Connect SOURCE to all detections in the first frame - for node in node_list_per_frame[0]: - G.add_edge("SOURCE", node, weight=0) - - # Connect all detections in the last frame to SINK - for node in node_list_per_frame[-1]: - G.add_edge(node, "SINK", weight=0) - - return G - -def assign_tracker_ids_from_paths(paths: List[List[TrackNode]], num_frames: int) -> Dict[int, sv.Detections]: - """ - Converts paths (list of list of TrackNode) into frame-wise sv.Detections with tracker IDs assigned. - """ - frame_to_dets = {frame: [] for frame in range(num_frames)} - - for tracker_id, path in enumerate(paths, start=1): - for node in path: - if isinstance(node, TrackNode): - frame_to_dets[node.frame_id].append({ - "xyxy": node.bbox, - "confidence": node.confidence, - "class_id": 0, - "tracker_id": tracker_id, - }) - - frame_to_detections = {} - for frame, dets_list in frame_to_dets.items(): - if not dets_list: - continue - xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) - confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) - class_id = np.array([d["class_id"] for d in dets_list], dtype=int) - tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) - detections = sv.Detections( - xyxy=xyxy, - confidence=confidence, - class_id=class_id, - tracker_id=tracker_id, - ) - frame_to_detections[frame] = detections - - return frame_to_detections - -def path_cost(G: nx.DiGraph, path: list, weight_key="weight") -> float: - return sum(G[u][v][weight_key] for u, v in zip(path[:-1], path[1:])) - -# ------------- Main Function ---------------- -from collections import defaultdict - -def discourage_path_edges(G, path, weight_key="weight", penalty=1e6): - """ - Add penalty to edges along the given path to discourage reuse. - """ - for u, v in zip(path[:-1], path[1:]): - if G.has_edge(u, v): - G[u][v][weight_key] += penalty - -def iterative_k_shortest_paths_soft_penalty( - G: nx.DiGraph, - source="SOURCE", - sink="SINK", - k=5, - weight_key="weight", - base_penalty=10.0, # smaller penalty -): - G_base = G.copy() - paths = [] - edge_reuse_count = defaultdict(int) - - for iteration in range(k): - G_mod = G_base.copy() - - # Increase edge weights softly according to reuse count - for (u, v, data) in G_mod.edges(data=True): - base_cost = data[weight_key] - reuse_pen = base_penalty * edge_reuse_count[(u, v)] * base_cost - data[weight_key] = base_cost + reuse_pen - - try: - length, path = nx.single_source_dijkstra(G_mod, source, sink, weight=weight_key) - except nx.NetworkXNoPath: - print(f"No more paths found after {len(paths)} iterations.") - break - - if path in paths: - print("Duplicate path found, stopping early.") - break - - print(f"Found path {iteration + 1} with cost {length}") - paths.append(path) - - # Update reuse counts for edges in this path - for u, v in zip(path[:-1], path[1:]): - edge_reuse_count[(u, v)] += 1 - - return paths class KSPTracker(BaseTracker): """ Offline tracker using K-Shortest Paths (KSP) algorithm. @@ -228,45 +16,59 @@ class KSPTracker(BaseTracker): def __init__( self, - grid_size: int = 25, - max_paths: int = 20, - min_confidence: float = 0.3, - max_distance: float = 0.3, - img_dim: tuple = (512, 512), ) -> None: - self.grid_size = grid_size - self.min_confidence = min_confidence - self.max_distance = max_distance - self.max_paths = max_paths - self.img_dim = img_dim - self.frames = [] - self.G = nx.DiGraph() + self._solver = KSP_Solver() + + self._solver.reset() self.reset() - def set_image_dim(self, dim: tuple): - self.img_dim = dim - def reset(self) -> None: - self.detection_buffer: List[sv.Detections] = [] + self._solver.reset() def update(self, detections: sv.Detections) -> sv.Detections: - self.detection_buffer.append(detections) + self._solver.append_frame(detections) return detections + + def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames: int) -> Dict[int, sv.Detections]: + """ + Converts paths (list of list of TrackNode) into frame-wise sv.Detections with tracker IDs assigned. + """ + frame_to_dets = {frame: [] for frame in range(num_frames)} + + for tracker_id, path in enumerate(paths, start=1): + for node in path: + if isinstance(node, TrackNode): + frame_to_dets[node.frame_id].append({ + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": node.class_id, + "tracker_id": tracker_id, + }) + + frame_to_detections = {} + for frame, dets_list in frame_to_dets.items(): + if not dets_list: + continue + xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) + confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) + class_id = np.array([d["class_id"] for d in dets_list], dtype=int) + tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) + detections = sv.Detections( + xyxy=xyxy, + confidence=confidence, + class_id=class_id, + tracker_id=tracker_id, + ) + frame_to_detections[frame] = detections - def process_tracks(self) -> Dict[int, sv.Detections]: - max_detection_obj = len(max(self.detection_buffer, key=lambda d: len(d.xyxy))) + return frame_to_detections - print(f"Number of detections: {max_detection_obj}") - G = build_tracking_graph_with_source_sink(self.detection_buffer) - paths = iterative_k_shortest_paths_soft_penalty(G, source="SOURCE", sink="SINK", k=max_detection_obj) + def process_tracks(self) -> Dict[int, sv.Detections]: + paths = self._solver.solve() print(f"Extracted {len(paths)} tracks.") - for i in paths: - p.pprint(i) - # print(paths[0] == paths[1]) - # for path in paths: - # print(path) - # print([p.position for p in path[1:-1]]) + if not paths: print("No valid paths found.") return {} - return assign_tracker_ids_from_paths(paths, num_frames=len(self.detection_buffer)) + + return self.assign_tracker_ids_from_paths(paths, num_frames=len(self._solver.detection_per_frame)) From daa90dc35edc8087e80a0a9f5f64d980adb44024 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:20:44 -0400 Subject: [PATCH 046/100] UPDATE: Tracker with the least change in detection position track a detection --- trackers/core/ksptracker/solver.py | 4 +-- trackers/core/ksptracker/tracker.py | 56 +++++++++++++++++++---------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 7d847918..11bcc828 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -123,10 +123,10 @@ def solve(self, k: Optional[int] = None) -> List[List[Any]]: except nx.NetworkXNoPath: break - if path in paths: + if path[1:-1] in paths: break - paths.append(path) + paths.append(path[1:-1]) for u, v in zip(path[:-1], path[1:]): edge_reuse[(u, v)] += 1 diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 0e1ac18d..18d00b40 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,7 +1,6 @@ -from dataclasses import dataclass +from collections import defaultdict from typing import Any, Dict, List, Tuple, Optional -import networkx as nx import numpy as np import supervision as sv @@ -18,7 +17,6 @@ def __init__( self, ) -> None: self._solver = KSP_Solver() - self._solver.reset() self.reset() @@ -31,24 +29,48 @@ def update(self, detections: sv.Detections) -> sv.Detections: def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames: int) -> Dict[int, sv.Detections]: """ - Converts paths (list of list of TrackNode) into frame-wise sv.Detections with tracker IDs assigned. + Assigns each detection a unique tracker ID by preferring the path with the least motion change (displacement). """ - frame_to_dets = {frame: [] for frame in range(num_frames)} + # Track where each node appears + node_to_candidates = defaultdict(list) for tracker_id, path in enumerate(paths, start=1): - for node in path: - if isinstance(node, TrackNode): - frame_to_dets[node.frame_id].append({ - "xyxy": node.bbox, - "confidence": node.confidence, - "class_id": node.class_id, - "tracker_id": tracker_id, - }) + for i, node in enumerate(path): + next_node = path[i + 1] if i + 1 < len(path) else None + node_to_candidates[node].append((tracker_id, next_node)) + + # Select best tracker for each node based on minimal displacement + node_to_tracker = {} + for node, candidates in node_to_candidates.items(): + min_displacement = float('inf') + selected_tracker = None + for tracker_id, next_node in candidates: + if next_node: + dx = node.position[0] - next_node.position[0] + dy = node.position[1] - next_node.position[1] + displacement = dx * dx + dy * dy # use squared distance for speed + else: + displacement = 0 # last node in path, no penalty + + if displacement < min_displacement: + min_displacement = displacement + selected_tracker = tracker_id + + node_to_tracker[node] = selected_tracker + # Organize detections by frame + frame_to_dets = defaultdict(list) + for node, tracker_id in node_to_tracker.items(): + frame_to_dets[node.frame_id].append({ + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": node.class_id, + "tracker_id": tracker_id, + }) + + # Convert into sv.Detections frame_to_detections = {} for frame, dets_list in frame_to_dets.items(): - if not dets_list: - continue xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) class_id = np.array([d["class_id"] for d in dets_list], dtype=int) @@ -62,13 +84,11 @@ def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames frame_to_detections[frame] = detections return frame_to_detections - + def process_tracks(self) -> Dict[int, sv.Detections]: paths = self._solver.solve() - print(f"Extracted {len(paths)} tracks.") if not paths: - print("No valid paths found.") return {} return self.assign_tracker_ids_from_paths(paths, num_frames=len(self._solver.detection_per_frame)) From 4e79595bff634b885b9e074ad4316ccd4481a0c2 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:27:02 -0400 Subject: [PATCH 047/100] MISC: Docstrings --- trackers/core/ksptracker/solver.py | 83 +++++++++++++++++++++++++++-- trackers/core/ksptracker/tracker.py | 58 +++++++++++++++----- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 11bcc828..3c64ecd7 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -9,6 +9,17 @@ @dataclass(frozen=True) class TrackNode: + """ + Represents a detection node in the tracking graph. + + Attributes: + frame_id (int): Frame index where detection occurred. + det_idx (int): Detection index in the frame. + class_id (int): Class ID of the detection. + position (tuple): Center position of the detection. + bbox (np.ndarray): Bounding box coordinates. + confidence (float): Detection confidence score. + """ frame_id: int det_idx: int class_id: int @@ -20,14 +31,29 @@ def __hash__(self): return hash((self.frame_id, self.det_idx)) def __eq__(self, other: Any): - return isinstance(other, TrackNode) and (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) + return ( + isinstance(other, TrackNode) + and (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) + ) def __str__(self): return f"{self.frame_id}:{self.det_idx}@{self.position}" class KSP_Solver: + """ + Solver for the K-Shortest Paths (KSP) tracking problem. + Builds a graph from detections and extracts multiple disjoint paths. + """ + def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): + """ + Initialize the KSP_Solver. + + Args: + base_penalty (float): Penalty for edge reuse in successive paths. + weight_key (str): Edge attribute to use for weights. + """ self.base_penalty = base_penalty self.weight_key = weight_key self.source = "SOURCE" @@ -36,17 +62,45 @@ def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): self.reset() def reset(self): + """ + Reset the solver state and clear all detections and graph. + """ self.detection_per_frame: List[sv.Detections] = [] self.graph: nx.DiGraph = nx.DiGraph() def append_frame(self, detections: sv.Detections): + """ + Add detections for a new frame. + + Args: + detections (sv.Detections): Detections for the frame. + """ self.detection_per_frame.append(detections) def _get_center(self, bbox): + """ + Compute the center of a bounding box. + + Args: + bbox (np.ndarray): Bounding box coordinates. + + Returns: + np.ndarray: Center coordinates. + """ x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) def _iou(self, a, b): + """ + Compute Intersection over Union (IoU) between two bounding boxes. + + Args: + a (np.ndarray): First bounding box. + b (np.ndarray): Second bounding box. + + Returns: + float: IoU value. + """ x1, y1, x2, y2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = (a[2] - a[0]) * (a[3] - a[1]) @@ -54,6 +108,17 @@ def _iou(self, a, b): return inter / (area_a + area_b - inter + 1e-6) def _edge_cost(self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, conf_w=0.1): + """ + Compute the cost of connecting two detections. + + Args: + a, b (np.ndarray): Bounding boxes. + conf_a, conf_b (float): Detection confidences. + iou_w, dist_w, size_w, conf_w (float): Weights for cost components. + + Returns: + float: Edge cost. + """ center_dist = np.linalg.norm(self._get_center(a) - self._get_center(b)) iou_penalty = 1 - self._iou(a, b) @@ -66,6 +131,9 @@ def _edge_cost(self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, co return iou_w * iou_penalty + dist_w * center_dist + size_w * size_penalty + conf_w * conf_penalty def _build_graph(self): + """ + Build the tracking graph from all buffered detections. + """ G = nx.DiGraph() G.add_node(self.source) G.add_node(self.sink) @@ -100,12 +168,21 @@ def _build_graph(self): self.graph = G - def solve(self, k: Optional[int] = None) -> List[List[Any]]: + def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: + """ + Extract up to k node-disjoint shortest paths from the graph. + + Args: + k (Optional[int]): Maximum number of paths to extract. + + Returns: + List[List[TrackNode]]: List of node-disjoint paths (tracks). + """ self._build_graph() G_base = self.graph.copy() edge_reuse = defaultdict(int) - paths = [] + paths: List[List[TrackNode]] = [] if k is None: k = max(len(f.xyxy) for f in self.detection_per_frame) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 18d00b40..a95149c5 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Dict, List, Tuple, Optional +from typing import Any, Dict, List import numpy as np import supervision as sv @@ -13,25 +13,48 @@ class KSPTracker(BaseTracker): Offline tracker using K-Shortest Paths (KSP) algorithm. """ - def __init__( - self, - ) -> None: + def __init__(self) -> None: + """ + Initialize the KSPTracker and its solver. + """ self._solver = KSP_Solver() self._solver.reset() self.reset() def reset(self) -> None: + """ + Reset the solver and clear any stored state. + """ self._solver.reset() def update(self, detections: sv.Detections) -> sv.Detections: + """ + Add detections for the current frame to the solver. + + Args: + detections (sv.Detections): Detections for the current frame. + + Returns: + sv.Detections: The same detections passed in. + """ self._solver.append_frame(detections) return detections - - def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames: int) -> Dict[int, sv.Detections]: - """ - Assigns each detection a unique tracker ID by preferring the path with the least motion change (displacement). + + def assign_tracker_ids_from_paths( + self, paths: List[List[TrackNode]], num_frames: int + ) -> Dict[int, sv.Detections]: """ + Assigns each detection a unique tracker ID by preferring the path with + the least motion change (displacement). + + Args: + paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. + num_frames (int): Number of frames in the sequence. + Returns: + Dict[int, sv.Detections]: Mapping from frame index to sv.Detections + with tracker IDs assigned. + """ # Track where each node appears node_to_candidates = defaultdict(list) for tracker_id, path in enumerate(paths, start=1): @@ -48,7 +71,7 @@ def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames if next_node: dx = node.position[0] - next_node.position[0] dy = node.position[1] - next_node.position[1] - displacement = dx * dx + dy * dy # use squared distance for speed + displacement = dx * dx + dy * dy # squared distance else: displacement = 0 # last node in path, no penalty @@ -84,11 +107,18 @@ def assign_tracker_ids_from_paths(self, paths: List[List[TrackNode]], num_frames frame_to_detections[frame] = detections return frame_to_detections - - def process_tracks(self) -> Dict[int, sv.Detections]: - paths = self._solver.solve() + def process_tracks(self) -> List[sv.Detections]: + """ + Run the KSP solver and assign tracker IDs to detections. + + Returns: + List[sv.Detections]: Mapping from frame index to sv.Detections + with tracker IDs assigned. + """ + paths = self._solver.solve() if not paths: return {} - - return self.assign_tracker_ids_from_paths(paths, num_frames=len(self._solver.detection_per_frame)) + return self.assign_tracker_ids_from_paths( + paths, num_frames=len(self._solver.detection_per_frame) + ) From 253a15818f11bec87a17905e33d5fdf584bd931a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 06:28:41 +0000 Subject: [PATCH 048/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 45 ++++++++++++++++++++--------- trackers/core/ksptracker/tracker.py | 22 ++++++++------ 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 3c64ecd7..087ac591 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -1,10 +1,10 @@ -from dataclasses import dataclass from collections import defaultdict +from dataclasses import dataclass from typing import Any, List, Optional -import supervision as sv import networkx as nx import numpy as np +import supervision as sv @dataclass(frozen=True) @@ -20,6 +20,7 @@ class TrackNode: bbox (np.ndarray): Bounding box coordinates. confidence (float): Detection confidence score. """ + frame_id: int det_idx: int class_id: int @@ -31,9 +32,9 @@ def __hash__(self): return hash((self.frame_id, self.det_idx)) def __eq__(self, other: Any): - return ( - isinstance(other, TrackNode) - and (self.frame_id, self.det_idx) == (other.frame_id, other.det_idx) + return isinstance(other, TrackNode) and (self.frame_id, self.det_idx) == ( + other.frame_id, + other.det_idx, ) def __str__(self): @@ -101,13 +102,20 @@ def _iou(self, a, b): Returns: float: IoU value. """ - x1, y1, x2, y2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]) + x1, y1, x2, y2 = ( + max(a[0], b[0]), + max(a[1], b[1]), + min(a[2], b[2]), + min(a[3], b[3]), + ) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) return inter / (area_a + area_b - inter + 1e-6) - def _edge_cost(self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, conf_w=0.1): + def _edge_cost( + self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, conf_w=0.1 + ): """ Compute the cost of connecting two detections. @@ -124,11 +132,18 @@ def _edge_cost(self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, co area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) - size_penalty = np.log((max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6) + size_penalty = np.log( + (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 + ) conf_penalty = 1 - min(conf_a, conf_b) - return iou_w * iou_penalty + dist_w * center_dist + size_w * size_penalty + conf_w * conf_penalty + return ( + iou_w * iou_penalty + + dist_w * center_dist + + size_w * size_penalty + + conf_w * conf_penalty + ) def _build_graph(self): """ @@ -149,7 +164,7 @@ def _build_graph(self): class_id=int(detections.class_id[det_idx]), position=tuple(self._get_center(bbox)), bbox=bbox, - confidence=float(detections.confidence[det_idx]) + confidence=float(detections.confidence[det_idx]), ) G.add_node(node) frame_nodes.append(node) @@ -158,7 +173,9 @@ def _build_graph(self): for t in range(len(node_frames) - 1): for node_a in node_frames[t]: for node_b in node_frames[t + 1]: - cost = self._edge_cost(node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence) + cost = self._edge_cost( + node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence + ) G.add_edge(node_a, node_b, weight=cost) for node in node_frames[0]: @@ -196,7 +213,9 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: data[self.weight_key] = base + penalty try: - _, path = nx.single_source_dijkstra(G_mod, self.source, self.sink, weight=self.weight_key) + _, path = nx.single_source_dijkstra( + G_mod, self.source, self.sink, weight=self.weight_key + ) except nx.NetworkXNoPath: break @@ -208,4 +227,4 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: for u, v in zip(path[:-1], path[1:]): edge_reuse[(u, v)] += 1 - return paths \ No newline at end of file + return paths diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index a95149c5..864474cb 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Dict, List +from typing import Dict, List import numpy as np import supervision as sv @@ -65,7 +65,7 @@ def assign_tracker_ids_from_paths( # Select best tracker for each node based on minimal displacement node_to_tracker = {} for node, candidates in node_to_candidates.items(): - min_displacement = float('inf') + min_displacement = float("inf") selected_tracker = None for tracker_id, next_node in candidates: if next_node: @@ -84,18 +84,22 @@ def assign_tracker_ids_from_paths( # Organize detections by frame frame_to_dets = defaultdict(list) for node, tracker_id in node_to_tracker.items(): - frame_to_dets[node.frame_id].append({ - "xyxy": node.bbox, - "confidence": node.confidence, - "class_id": node.class_id, - "tracker_id": tracker_id, - }) + frame_to_dets[node.frame_id].append( + { + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": node.class_id, + "tracker_id": tracker_id, + } + ) # Convert into sv.Detections frame_to_detections = {} for frame, dets_list in frame_to_dets.items(): xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) - confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) + confidence = np.array( + [d["confidence"] for d in dets_list], dtype=np.float32 + ) class_id = np.array([d["class_id"] for d in dets_list], dtype=int) tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) detections = sv.Detections( From 23672e4afaa7984836e796990cb7a11be333dd3f Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:45:58 -0400 Subject: [PATCH 049/100] MISC: Precommit --- trackers/core/ksptracker/solver.py | 4 ++-- trackers/core/ksptracker/tracker.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 087ac591..fbd813b0 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -1,6 +1,6 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Any, List, Optional +from typing import Any, List, Optional, Tuple import networkx as nx import numpy as np @@ -198,7 +198,7 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: self._build_graph() G_base = self.graph.copy() - edge_reuse = defaultdict(int) + edge_reuse: defaultdict[Tuple[Any, Any], int] = defaultdict(int) paths: List[List[TrackNode]] = [] if k is None: diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 864474cb..2324dfa2 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Dict, List +from typing import Any, Dict, List import numpy as np import supervision as sv @@ -59,7 +59,7 @@ def assign_tracker_ids_from_paths( node_to_candidates = defaultdict(list) for tracker_id, path in enumerate(paths, start=1): for i, node in enumerate(path): - next_node = path[i + 1] if i + 1 < len(path) else None + next_node: Any = path[i + 1] if i + 1 < len(path) else None node_to_candidates[node].append((tracker_id, next_node)) # Select best tracker for each node based on minimal displacement @@ -68,7 +68,7 @@ def assign_tracker_ids_from_paths( min_displacement = float("inf") selected_tracker = None for tracker_id, next_node in candidates: - if next_node: + if next_node is not None: dx = node.position[0] - next_node.position[0] dy = node.position[1] - next_node.position[1] displacement = dx * dx + dy * dy # squared distance From 13f2de2ec93b0c31242b42d6f77ee8a76069f289 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:48:46 -0400 Subject: [PATCH 050/100] MISC: Precommit --- trackers/core/ksptracker/solver.py | 6 +++--- trackers/core/ksptracker/tracker.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index fbd813b0..51ca6d44 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -66,8 +66,8 @@ def reset(self): """ Reset the solver state and clear all detections and graph. """ - self.detection_per_frame: List[sv.Detections] = [] - self.graph: nx.DiGraph = nx.DiGraph() + self.detection_per_frame = [] + self.graph = nx.DiGraph() def append_frame(self, detections: sv.Detections): """ @@ -153,7 +153,7 @@ def _build_graph(self): G.add_node(self.source) G.add_node(self.sink) - node_frames: List[List[TrackNode]] = [] + node_frames = [] for frame_id, detections in enumerate(self.detection_per_frame): frame_nodes = [] diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 2324dfa2..99dcd3a3 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -42,7 +42,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: def assign_tracker_ids_from_paths( self, paths: List[List[TrackNode]], num_frames: int - ) -> Dict[int, sv.Detections]: + ) -> List[sv.Detections]: """ Assigns each detection a unique tracker ID by preferring the path with the least motion change (displacement). @@ -94,7 +94,7 @@ def assign_tracker_ids_from_paths( ) # Convert into sv.Detections - frame_to_detections = {} + frame_to_detections = [] for frame, dets_list in frame_to_dets.items(): xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) confidence = np.array( @@ -108,7 +108,7 @@ def assign_tracker_ids_from_paths( class_id=class_id, tracker_id=tracker_id, ) - frame_to_detections[frame] = detections + frame_to_detections.append(detections) return frame_to_detections @@ -122,7 +122,7 @@ def process_tracks(self) -> List[sv.Detections]: """ paths = self._solver.solve() if not paths: - return {} + return [] return self.assign_tracker_ids_from_paths( paths, num_frames=len(self._solver.detection_per_frame) ) From 18e52c91ff9b67733ea5891c0173621cf2e26b3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 06:48:59 +0000 Subject: [PATCH 051/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 99dcd3a3..491a77e0 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Any, Dict, List +from typing import Any, List import numpy as np import supervision as sv From 704f4765970a95a72bca9d063a478e4a774cc9eb Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:50:57 -0400 Subject: [PATCH 052/100] MISC: Precommit --- trackers/core/ksptracker/tracker.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 99dcd3a3..1efebf31 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -41,7 +41,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: return detections def assign_tracker_ids_from_paths( - self, paths: List[List[TrackNode]], num_frames: int + self, paths: List[List[TrackNode]] ) -> List[sv.Detections]: """ Assigns each detection a unique tracker ID by preferring the path with @@ -49,7 +49,6 @@ def assign_tracker_ids_from_paths( Args: paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. - num_frames (int): Number of frames in the sequence. Returns: Dict[int, sv.Detections]: Mapping from frame index to sv.Detections @@ -66,7 +65,7 @@ def assign_tracker_ids_from_paths( node_to_tracker = {} for node, candidates in node_to_candidates.items(): min_displacement = float("inf") - selected_tracker = None + selected_tracker = -1 for tracker_id, next_node in candidates: if next_node is not None: dx = node.position[0] - next_node.position[0] From 789d8cfdc0ec63a02299e6d150477ea7176c8634 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:52:41 -0400 Subject: [PATCH 053/100] MISC: Precommit --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index a0dfbdd6..4d53c4f5 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -123,5 +123,5 @@ def process_tracks(self) -> List[sv.Detections]: if not paths: return [] return self.assign_tracker_ids_from_paths( - paths, num_frames=len(self._solver.detection_per_frame) + paths ) From a23cf66b4f2edc6721f5a03d045f6b0dff4e364d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 06:52:57 +0000 Subject: [PATCH 054/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 4d53c4f5..9270ec93 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -122,6 +122,4 @@ def process_tracks(self) -> List[sv.Detections]: paths = self._solver.solve() if not paths: return [] - return self.assign_tracker_ids_from_paths( - paths - ) + return self.assign_tracker_ids_from_paths(paths) From 2aa2e0a1dad8d217632c23a0ee6290136f7eeba7 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 28 Jun 2025 02:59:52 -0400 Subject: [PATCH 055/100] UPDATE: Added num_of_tracks param --- trackers/core/ksptracker/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 4d53c4f5..dc4e56f9 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -111,7 +111,7 @@ def assign_tracker_ids_from_paths( return frame_to_detections - def process_tracks(self) -> List[sv.Detections]: + def process_tracks(self, num_of_tracks=None) -> List[sv.Detections]: """ Run the KSP solver and assign tracker IDs to detections. @@ -119,7 +119,7 @@ def process_tracks(self) -> List[sv.Detections]: List[sv.Detections]: Mapping from frame index to sv.Detections with tracker IDs assigned. """ - paths = self._solver.solve() + paths = self._solver.solve(num_of_tracks) if not paths: return [] return self.assign_tracker_ids_from_paths( From 9b70a31ca1c102570cd83136131ea056cbd3adf1 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Tue, 1 Jul 2025 03:16:18 -0400 Subject: [PATCH 056/100] UPDATE: Added tqdm and small changes --- trackers/core/ksptracker/solver.py | 14 ++++++++------ trackers/core/ksptracker/tracker.py | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 51ca6d44..78b7516d 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -5,6 +5,7 @@ import networkx as nx import numpy as np import supervision as sv +from tqdm import tqdm @dataclass(frozen=True) @@ -114,7 +115,7 @@ def _iou(self, a, b): return inter / (area_a + area_b - inter + 1e-6) def _edge_cost( - self, a, b, conf_a, conf_b, iou_w=0.5, dist_w=0.3, size_w=0.1, conf_w=0.1 + self, a, b, conf_a, conf_b, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1 ): """ Compute the cost of connecting two detections. @@ -145,7 +146,7 @@ def _edge_cost( + conf_w * conf_penalty ) - def _build_graph(self): + def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): """ Build the tracking graph from all buffered detections. """ @@ -174,7 +175,8 @@ def _build_graph(self): for node_a in node_frames[t]: for node_b in node_frames[t + 1]: cost = self._edge_cost( - node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence + node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence, + iou_w=iou_w, dist_w=dist_w, size_w=size_w, conf_w=conf_w ) G.add_edge(node_a, node_b, weight=cost) @@ -185,7 +187,7 @@ def _build_graph(self): self.graph = G - def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: + def solve(self, k: Optional[int] = None, iou_weight=0.9, dist_weight=0.4, size_weight=0.1, conf_weight=0.1) -> List[List[TrackNode]]: """ Extract up to k node-disjoint shortest paths from the graph. @@ -195,7 +197,7 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: Returns: List[List[TrackNode]]: List of node-disjoint paths (tracks). """ - self._build_graph() + self._build_graph(iou_w=iou_weight) G_base = self.graph.copy() edge_reuse: defaultdict[Tuple[Any, Any], int] = defaultdict(int) @@ -204,7 +206,7 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: if k is None: k = max(len(f.xyxy) for f in self.detection_per_frame) - for _ in range(k): + for _ in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): G_mod = G_base.copy() for u, v, data in G_mod.edges(data=True): diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 38c20824..cf94364a 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -55,11 +55,13 @@ def assign_tracker_ids_from_paths( with tracker IDs assigned. """ # Track where each node appears + framed_nodes = defaultdict(list) node_to_candidates = defaultdict(list) for tracker_id, path in enumerate(paths, start=1): for i, node in enumerate(path): next_node: Any = path[i + 1] if i + 1 < len(path) else None node_to_candidates[node].append((tracker_id, next_node)) + framed_nodes[node.frame_id].append(node) # Select best tracker for each node based on minimal displacement node_to_tracker = {} @@ -82,6 +84,7 @@ def assign_tracker_ids_from_paths( # Organize detections by frame frame_to_dets = defaultdict(list) + for node, tracker_id in node_to_tracker.items(): frame_to_dets[node.frame_id].append( { @@ -90,7 +93,7 @@ def assign_tracker_ids_from_paths( "class_id": node.class_id, "tracker_id": tracker_id, } - ) + ) # Convert into sv.Detections frame_to_detections = [] From 12b924ac8f669fef54f1dab9500e1606ba92568b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:16:50 +0000 Subject: [PATCH 057/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 19 ++++++++++++++++--- trackers/core/ksptracker/tracker.py | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 78b7516d..3e61770f 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -175,8 +175,14 @@ def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): for node_a in node_frames[t]: for node_b in node_frames[t + 1]: cost = self._edge_cost( - node_a.bbox, node_b.bbox, node_a.confidence, node_b.confidence, - iou_w=iou_w, dist_w=dist_w, size_w=size_w, conf_w=conf_w + node_a.bbox, + node_b.bbox, + node_a.confidence, + node_b.confidence, + iou_w=iou_w, + dist_w=dist_w, + size_w=size_w, + conf_w=conf_w, ) G.add_edge(node_a, node_b, weight=cost) @@ -187,7 +193,14 @@ def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): self.graph = G - def solve(self, k: Optional[int] = None, iou_weight=0.9, dist_weight=0.4, size_weight=0.1, conf_weight=0.1) -> List[List[TrackNode]]: + def solve( + self, + k: Optional[int] = None, + iou_weight=0.9, + dist_weight=0.4, + size_weight=0.1, + conf_weight=0.1, + ) -> List[List[TrackNode]]: """ Extract up to k node-disjoint shortest paths from the graph. diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index cf94364a..12eb8950 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -93,7 +93,7 @@ def assign_tracker_ids_from_paths( "class_id": node.class_id, "tracker_id": tracker_id, } - ) + ) # Convert into sv.Detections frame_to_detections = [] From b6ce7a296a5329ffa53017fcc1b34a7f5b6be230 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 16:27:47 -0400 Subject: [PATCH 058/100] UPDATE: Changes reflecting the comments from the code review --- trackers/core/ksptracker/solver.py | 103 ++++++++++++++++++++-------- trackers/core/ksptracker/tracker.py | 88 +++++++++++++++++++++--- 2 files changed, 153 insertions(+), 38 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 3e61770f..d691b5de 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -42,7 +42,38 @@ def __str__(self): return f"{self.frame_id}:{self.det_idx}@{self.position}" -class KSP_Solver: +def box_iou_batch(boxes_true: np.ndarray, boxes_detection: np.ndarray) -> np.ndarray: + """ + Compute Intersection over Union (IoU) of two sets of bounding boxes - + `boxes_true` and `boxes_detection`. Both sets + of boxes are expected to be in `(x_min, y_min, x_max, y_max)` format. + + Args: + boxes_true (np.ndarray): 2D `np.ndarray` representing ground-truth boxes. + `shape = (N, 4)` where `N` is number of true objects. + boxes_detection (np.ndarray): 2D `np.ndarray` representing detection boxes. + `shape = (M, 4)` where `M` is number of detected objects. + + Returns: + np.ndarray: Pairwise IoU of boxes from `boxes_true` and `boxes_detection`. + `shape = (N, M)` where `N` is number of true objects and + `M` is number of detected objects. + """ + area_true = (boxes_true[:, 2] - boxes_true[:, 0]) * (boxes_true[:, 3] - boxes_true[:, 1]) + area_detection = (boxes_detection[:, 2] - boxes_detection[:, 0]) * (boxes_detection[:, 3] - boxes_detection[:, 1]) + + top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) + bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) + + wh = np.clip(bottom_right - top_left, a_min=0, a_max=None) + area_inter = wh[:, :, 0] * wh[:, :, 1] + + ious = area_inter / (area_true[:, None] + area_detection - area_inter) + + ious = np.nan_to_num(ious) + return ious + +class KSPSolver: """ Solver for the K-Shortest Paths (KSP) tracking problem. Builds a graph from detections and extracts multiple disjoint paths. @@ -61,6 +92,12 @@ def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): self.source = "SOURCE" self.sink = "SINK" self.detection_per_frame: List[sv.Detections] = [] + self.weights = { + "iou": 0.9, + "dist": 0.1, + "size": 0.1, + "conf": 0.1 + } self.reset() def reset(self): @@ -70,6 +107,25 @@ def reset(self): self.detection_per_frame = [] self.graph = nx.DiGraph() + def append_config(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1): + """ + Update the weights for edge cost calculation. + + Args: + iou_weight (float): Weight for IoU penalty. + dist_weight (float): Weight for center distance. + size_weight (float): Weight for size penalty. + conf_weight (float): Weight for confidence penalty. + """ + if iou_weight is not None: + self.weights["iou"] = iou_weight + if dist_weight is not None: + self.weights["dist"] = dist_weight + if size_weight is not None: + self.weights["size"] = size_weight + if conf_weight is not None: + self.weights["conf"] = conf_weight + def append_frame(self, detections: sv.Detections): """ Add detections for a new frame. @@ -114,39 +170,39 @@ def _iou(self, a, b): area_b = (b[2] - b[0]) * (b[3] - b[1]) return inter / (area_a + area_b - inter + 1e-6) - def _edge_cost( - self, a, b, conf_a, conf_b, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1 - ): + def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): """ Compute the cost of connecting two detections. Args: a, b (np.ndarray): Bounding boxes. conf_a, conf_b (float): Detection confidences. - iou_w, dist_w, size_w, conf_w (float): Weights for cost components. Returns: float: Edge cost. """ - center_dist = np.linalg.norm(self._get_center(a) - self._get_center(b)) - iou_penalty = 1 - self._iou(a, b) + bboxU, bboxV = nodeU.bbox, nodeV.bbox + conf_u, conf_v = nodeU.confidence, nodeV.confidence - area_a = (a[2] - a[0]) * (a[3] - a[1]) - area_b = (b[2] - b[0]) * (b[3] - b[1]) + center_dist = np.linalg.norm(self._get_center(bboxU) - self._get_center(bboxV)) + iou_penalty = 1 - self._iou(bboxU, bboxV) + + area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) + area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) size_penalty = np.log( (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 ) - conf_penalty = 1 - min(conf_a, conf_b) + conf_penalty = 1 - min(conf_u, conf_v) return ( - iou_w * iou_penalty - + dist_w * center_dist - + size_w * size_penalty - + conf_w * conf_penalty + self.weights["iou"] * iou_penalty + + self.weights["dist"] * center_dist + + self.weights["size"] * size_penalty + + self.weights["conf"] * conf_penalty ) - def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): + def _build_graph(self): """ Build the tracking graph from all buffered detections. """ @@ -174,16 +230,7 @@ def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): for t in range(len(node_frames) - 1): for node_a in node_frames[t]: for node_b in node_frames[t + 1]: - cost = self._edge_cost( - node_a.bbox, - node_b.bbox, - node_a.confidence, - node_b.confidence, - iou_w=iou_w, - dist_w=dist_w, - size_w=size_w, - conf_w=conf_w, - ) + cost = self._edge_cost(node_a, node_b) G.add_edge(node_a, node_b, weight=cost) for node in node_frames[0]: @@ -196,10 +243,6 @@ def _build_graph(self, iou_w=0.9, dist_w=0.1, size_w=0.1, conf_w=0.1): def solve( self, k: Optional[int] = None, - iou_weight=0.9, - dist_weight=0.4, - size_weight=0.1, - conf_weight=0.1, ) -> List[List[TrackNode]]: """ Extract up to k node-disjoint shortest paths from the graph. @@ -210,7 +253,7 @@ def solve( Returns: List[List[TrackNode]]: List of node-disjoint paths (tracks). """ - self._build_graph(iou_w=iou_weight) + self._build_graph() G_base = self.graph.copy() edge_reuse: defaultdict[Tuple[Any, Any], int] = defaultdict(int) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 12eb8950..c731657a 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,11 +1,14 @@ from collections import defaultdict -from typing import Any, List +from typing import Any, List, Optional, Callable import numpy as np +import cv2 +import os import supervision as sv from trackers.core.base import BaseTracker -from trackers.core.ksptracker.solver import KSP_Solver, TrackNode +from trackers.core.ksptracker.solver import KSPSolver, TrackNode +from tqdm import tqdm class KSPTracker(BaseTracker): @@ -13,12 +16,17 @@ class KSPTracker(BaseTracker): Offline tracker using K-Shortest Paths (KSP) algorithm. """ - def __init__(self) -> None: + def __init__(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1) -> None: """ Initialize the KSPTracker and its solver. """ - self._solver = KSP_Solver() - self._solver.reset() + self._solver = KSPSolver() + self._solver.append_config( + iou_weight=iou_weight, + dist_weight=dist_weight, + size_weight=size_weight, + conf_weight=conf_weight + ) self.reset() def reset(self) -> None: @@ -27,6 +35,23 @@ def reset(self) -> None: """ self._solver.reset() + def update_config(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1): + """ + Update the configuration weights for the KSP algorithm. + + Args: + iou_weight (float): Weight for IoU component. + dist_weight (float): Weight for distance component. + size_weight (float): Weight for size component. + conf_weight (float): Weight for confidence component. + """ + self._solver.append_config( + iou_weight=iou_weight, + dist_weight=dist_weight, + size_weight=size_weight, + conf_weight=conf_weight + ) + def update(self, detections: sv.Detections) -> sv.Detections: """ Add detections for the current frame to the solver. @@ -114,14 +139,61 @@ def assign_tracker_ids_from_paths( return frame_to_detections - def process_tracks(self, num_of_tracks=None) -> List[sv.Detections]: + def process_tracks( + self, + source_path: str = None, + get_model_detections: Optional[Callable[[np.ndarray], sv.Detections]] = None, + num_of_tracks: Optional[int] = None + ) -> List[sv.Detections]: """ Run the KSP solver and assign tracker IDs to detections. + Args: + source_path (str, optional): Path to video file or directory of frames. + get_model_detections (Optional[Callable[[np.ndarray], sv.Detections]]): + Function that takes an image (np.ndarray) and returns sv.Detections. + num_of_tracks (Optional[int]): Number of tracks to extract (K). + Returns: - List[sv.Detections]: Mapping from frame index to sv.Detections - with tracker IDs assigned. + List[sv.Detections]: List of sv.Detections with tracker IDs assigned. """ + if not source_path: + raise ValueError( + "`source_path` must be a string path to a directory or an .mp4 file." + ) + if not get_model_detections: + raise TypeError( + "`get_model_detections` must be a callable that returns an instance of `sv.Detections`." + ) + if source_path.lower().endswith(".mp4"): + frames_generator = sv.get_video_frames_generator(source_path=source_path) + video_info = sv.VideoInfo.from_video_path(video_path=source_path) + for frame in tqdm( + frames_generator, + total=video_info.total_frames, + desc="Extracting detections and buffering from video", + dynamic_ncols=True, + ): + detections = get_model_detections(frame) + self.update(detections) + elif os.path.isdir(source_path): + frame_paths = sorted( + [ + os.path.join(source_path, f) + for f in os.listdir(source_path) + if f.lower().endswith('.jpg') + ] + ) + for frame_path in tqdm( + frame_paths, + desc='Extracting detections and buffering directory', + dynamic_ncols=True, + ): + image = cv2.imread(frame_path) + detections = get_model_detections(image) + self.update(detections) + else: + raise ValueError(f"{source_path} not found!") paths = self._solver.solve(num_of_tracks) if not paths: return [] From 286f946bae7e1910f599e8cabf5cd06dde2a7964 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 20:29:01 +0000 Subject: [PATCH 059/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 28 +++++++++++++++------------- trackers/core/ksptracker/tracker.py | 26 +++++++++++++++----------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index d691b5de..4bb29b47 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -59,20 +59,25 @@ def box_iou_batch(boxes_true: np.ndarray, boxes_detection: np.ndarray) -> np.nda `shape = (N, M)` where `N` is number of true objects and `M` is number of detected objects. """ - area_true = (boxes_true[:, 2] - boxes_true[:, 0]) * (boxes_true[:, 3] - boxes_true[:, 1]) - area_detection = (boxes_detection[:, 2] - boxes_detection[:, 0]) * (boxes_detection[:, 3] - boxes_detection[:, 1]) + area_true = (boxes_true[:, 2] - boxes_true[:, 0]) * ( + boxes_true[:, 3] - boxes_true[:, 1] + ) + area_detection = (boxes_detection[:, 2] - boxes_detection[:, 0]) * ( + boxes_detection[:, 3] - boxes_detection[:, 1] + ) - top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) - bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) + top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) + bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) - wh = np.clip(bottom_right - top_left, a_min=0, a_max=None) - area_inter = wh[:, :, 0] * wh[:, :, 1] + wh = np.clip(bottom_right - top_left, a_min=0, a_max=None) + area_inter = wh[:, :, 0] * wh[:, :, 1] ious = area_inter / (area_true[:, None] + area_detection - area_inter) ious = np.nan_to_num(ious) return ious + class KSPSolver: """ Solver for the K-Shortest Paths (KSP) tracking problem. @@ -92,12 +97,7 @@ def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): self.source = "SOURCE" self.sink = "SINK" self.detection_per_frame: List[sv.Detections] = [] - self.weights = { - "iou": 0.9, - "dist": 0.1, - "size": 0.1, - "conf": 0.1 - } + self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} self.reset() def reset(self): @@ -107,7 +107,9 @@ def reset(self): self.detection_per_frame = [] self.graph = nx.DiGraph() - def append_config(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1): + def append_config( + self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + ): """ Update the weights for edge cost calculation. diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index c731657a..8b9d8758 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -1,14 +1,14 @@ +import os from collections import defaultdict -from typing import Any, List, Optional, Callable +from typing import Any, Callable, List, Optional -import numpy as np import cv2 -import os +import numpy as np import supervision as sv +from tqdm import tqdm from trackers.core.base import BaseTracker from trackers.core.ksptracker.solver import KSPSolver, TrackNode -from tqdm import tqdm class KSPTracker(BaseTracker): @@ -16,7 +16,9 @@ class KSPTracker(BaseTracker): Offline tracker using K-Shortest Paths (KSP) algorithm. """ - def __init__(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1) -> None: + def __init__( + self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + ) -> None: """ Initialize the KSPTracker and its solver. """ @@ -25,7 +27,7 @@ def __init__(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight iou_weight=iou_weight, dist_weight=dist_weight, size_weight=size_weight, - conf_weight=conf_weight + conf_weight=conf_weight, ) self.reset() @@ -35,7 +37,9 @@ def reset(self) -> None: """ self._solver.reset() - def update_config(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1): + def update_config( + self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + ): """ Update the configuration weights for the KSP algorithm. @@ -49,7 +53,7 @@ def update_config(self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_w iou_weight=iou_weight, dist_weight=dist_weight, size_weight=size_weight, - conf_weight=conf_weight + conf_weight=conf_weight, ) def update(self, detections: sv.Detections) -> sv.Detections: @@ -143,7 +147,7 @@ def process_tracks( self, source_path: str = None, get_model_detections: Optional[Callable[[np.ndarray], sv.Detections]] = None, - num_of_tracks: Optional[int] = None + num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: """ Run the KSP solver and assign tracker IDs to detections. @@ -181,12 +185,12 @@ def process_tracks( [ os.path.join(source_path, f) for f in os.listdir(source_path) - if f.lower().endswith('.jpg') + if f.lower().endswith(".jpg") ] ) for frame_path in tqdm( frame_paths, - desc='Extracting detections and buffering directory', + desc="Extracting detections and buffering directory", dynamic_ncols=True, ): image = cv2.imread(frame_path) From 89e792f7fdbab873cbbcaf102a2fbd78fd20a498 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 16:41:55 -0400 Subject: [PATCH 060/100] Pre-commit --- trackers/core/ksptracker/tracker.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 8b9d8758..149fba50 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -145,7 +145,7 @@ def assign_tracker_ids_from_paths( def process_tracks( self, - source_path: str = None, + source_path: Optional[str] = None, get_model_detections: Optional[Callable[[np.ndarray], sv.Detections]] = None, num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: @@ -153,7 +153,7 @@ def process_tracks( Run the KSP solver and assign tracker IDs to detections. Args: - source_path (str, optional): Path to video file or directory of frames. + source_path (Optional[str]): Path to video file or directory of frames. get_model_detections (Optional[Callable[[np.ndarray], sv.Detections]]): Function that takes an image (np.ndarray) and returns sv.Detections. num_of_tracks (Optional[int]): Number of tracks to extract (K). @@ -167,7 +167,8 @@ def process_tracks( ) if not get_model_detections: raise TypeError( - "`get_model_detections` must be a callable that returns an instance of `sv.Detections`." + "`get_model_detections` must be a callable that returns an " + "instance of `sv.Detections`." ) if source_path.lower().endswith(".mp4"): frames_generator = sv.get_video_frames_generator(source_path=source_path) @@ -185,12 +186,12 @@ def process_tracks( [ os.path.join(source_path, f) for f in os.listdir(source_path) - if f.lower().endswith(".jpg") + if f.lower().endswith('.jpg') ] ) for frame_path in tqdm( frame_paths, - desc="Extracting detections and buffering directory", + desc='Extracting detections and buffering directory', dynamic_ncols=True, ): image = cv2.imread(frame_path) From 9aadfa800edab92e67346a1899e248758c885079 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 20:44:36 +0000 Subject: [PATCH 061/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 149fba50..c26a4927 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -186,12 +186,12 @@ def process_tracks( [ os.path.join(source_path, f) for f in os.listdir(source_path) - if f.lower().endswith('.jpg') + if f.lower().endswith(".jpg") ] ) for frame_path in tqdm( frame_paths, - desc='Extracting detections and buffering directory', + desc="Extracting detections and buffering directory", dynamic_ncols=True, ): image = cv2.imread(frame_path) From 2de57ccd99b4c5f2d0709392d79ae1ea5c8f5543 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 16:48:04 -0400 Subject: [PATCH 062/100] Debug: Debugging the KSP solve --- trackers/core/ksptracker/solver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 4bb29b47..77b07fbb 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -277,9 +277,11 @@ def solve( G_mod, self.source, self.sink, weight=self.weight_key ) except nx.NetworkXNoPath: + print(f"No path found from source to sink at {_}th iteration") break if path[1:-1] in paths: + print(f"Duplicate path found!") break paths.append(path[1:-1]) From 04dc790b7358d9a2266f4b7e8c3c610ff5d0a0d3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 20:48:22 +0000 Subject: [PATCH 063/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 77b07fbb..13bad79a 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -281,7 +281,7 @@ def solve( break if path[1:-1] in paths: - print(f"Duplicate path found!") + print("Duplicate path found!") break paths.append(path[1:-1]) From 23efbe90dc1827e012d40ce3b515817653f4ee5b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 17:16:13 -0400 Subject: [PATCH 064/100] Debug: Debugging the KSP ith itr --- trackers/core/ksptracker/solver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 13bad79a..cba459f9 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -265,6 +265,8 @@ def solve( k = max(len(f.xyxy) for f in self.detection_per_frame) for _ in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): + print(f"{_}th iteration") + G_mod = G_base.copy() for u, v, data in G_mod.edges(data=True): From 47066b77d00763bb6a4bb0f3d39933c25ff81db5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 21:17:20 +0000 Subject: [PATCH 065/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index cba459f9..4b6be45d 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -266,7 +266,7 @@ def solve( for _ in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): print(f"{_}th iteration") - + G_mod = G_base.copy() for u, v, data in G_mod.edges(data=True): From aa5bae4fac17dd79d27ef758a1a5793af56778ad Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 17:26:54 -0400 Subject: [PATCH 066/100] Debug: Debugging the KSP solve --- trackers/core/ksptracker/solver.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 4b6be45d..459e6062 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -265,8 +265,6 @@ def solve( k = max(len(f.xyxy) for f in self.detection_per_frame) for _ in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): - print(f"{_}th iteration") - G_mod = G_base.copy() for u, v, data in G_mod.edges(data=True): @@ -284,7 +282,7 @@ def solve( if path[1:-1] in paths: print("Duplicate path found!") - break + continue paths.append(path[1:-1]) From 589c1dca1a972c9722ed3a6d711f29ea77b8e814 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 18:11:15 -0400 Subject: [PATCH 067/100] Debug: Change base penalty --- trackers/core/ksptracker/solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 459e6062..6494a275 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -84,7 +84,7 @@ class KSPSolver: Builds a graph from detections and extracts multiple disjoint paths. """ - def __init__(self, base_penalty: float = 10.0, weight_key: str = "weight"): + def __init__(self, base_penalty: float = 40.0, weight_key: str = "weight"): """ Initialize the KSP_Solver. From 9c7b994b69185b8166eab08d918b6032422cb7c2 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 18:28:45 -0400 Subject: [PATCH 068/100] UPDATE: Changed base_penalty to path_overlap_penalty hyper params --- trackers/core/ksptracker/solver.py | 23 ++++++++++++----------- trackers/core/ksptracker/tracker.py | 7 +++++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 6494a275..4d4f60ab 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -84,16 +84,12 @@ class KSPSolver: Builds a graph from detections and extracts multiple disjoint paths. """ - def __init__(self, base_penalty: float = 40.0, weight_key: str = "weight"): + def __init__(self): """ Initialize the KSP_Solver. - - Args: - base_penalty (float): Penalty for edge reuse in successive paths. - weight_key (str): Edge attribute to use for weights. """ - self.base_penalty = base_penalty - self.weight_key = weight_key + self.path_overlap_penalty = 40 + self.weight_key = "weight" self.source = "SOURCE" self.sink = "SINK" self.detection_per_frame: List[sv.Detections] = [] @@ -108,7 +104,7 @@ def reset(self): self.graph = nx.DiGraph() def append_config( - self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + self, path_overlap_penalty=40, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 ): """ Update the weights for edge cost calculation. @@ -119,6 +115,9 @@ def append_config( size_weight (float): Weight for size penalty. conf_weight (float): Weight for confidence penalty. """ + if path_overlap_penalty is not None: + self.path_overlap_penalty = path_overlap_penalty + if iou_weight is not None: self.weights["iou"] = iou_weight if dist_weight is not None: @@ -264,12 +263,12 @@ def solve( if k is None: k = max(len(f.xyxy) for f in self.detection_per_frame) - for _ in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): + for _i in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): G_mod = G_base.copy() for u, v, data in G_mod.edges(data=True): base = data[self.weight_key] - penalty = self.base_penalty * edge_reuse[(u, v)] * base + penalty = self.path_overlap_penalty * edge_reuse[(u, v)] * base data[self.weight_key] = base + penalty try: @@ -277,11 +276,13 @@ def solve( G_mod, self.source, self.sink, weight=self.weight_key ) except nx.NetworkXNoPath: - print(f"No path found from source to sink at {_}th iteration") + print(f"No path found from source to sink at {_i}th iteration") break if path[1:-1] in paths: print("Duplicate path found!") + # NOTE: Changed to continue for debugging to extrapolate the track detects to investigate the reason for fewer paths generated + # Change this to break when done continue paths.append(path[1:-1]) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index c26a4927..84b9bb08 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -17,13 +17,14 @@ class KSPTracker(BaseTracker): """ def __init__( - self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + self, path_overlap_penalty: Optional[int] = None, iou_weight: Optional[int] = None, dist_weight: Optional[int] = None, size_weight: Optional[int] = None, conf_weight: Optional[int] = None ) -> None: """ Initialize the KSPTracker and its solver. """ self._solver = KSPSolver() self._solver.append_config( + path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, dist_weight=dist_weight, size_weight=size_weight, @@ -38,7 +39,7 @@ def reset(self) -> None: self._solver.reset() def update_config( - self, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + self, path_overlap_penalty: Optional[int] = None, iou_weight: Optional[int] = None, dist_weight: Optional[int] = None, size_weight: Optional[int] = None, conf_weight: Optional[int] = None ): """ Update the configuration weights for the KSP algorithm. @@ -50,6 +51,7 @@ def update_config( conf_weight (float): Weight for confidence component. """ self._solver.append_config( + path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, dist_weight=dist_weight, size_weight=size_weight, @@ -199,6 +201,7 @@ def process_tracks( self.update(detections) else: raise ValueError(f"{source_path} not found!") + paths = self._solver.solve(num_of_tracks) if not paths: return [] From 69bc783d37accdd87dd514b4b08c4eab0e527068 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 18:33:01 -0400 Subject: [PATCH 069/100] UPDATE: Docstrings --- trackers/core/ksptracker/solver.py | 31 ++++++++++++++++++----------- trackers/core/ksptracker/tracker.py | 22 ++++++++++++-------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 4d4f60ab..4b2ddc45 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -99,6 +99,7 @@ def __init__(self): def reset(self): """ Reset the solver state and clear all detections and graph. + This clears the detection buffer and initializes a new empty graph. """ self.detection_per_frame = [] self.graph = nx.DiGraph() @@ -107,9 +108,10 @@ def append_config( self, path_overlap_penalty=40, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 ): """ - Update the weights for edge cost calculation. + Update the weights for edge cost calculation and path overlap penalty. Args: + path_overlap_penalty (float): Penalty for edge reuse in successive paths. iou_weight (float): Weight for IoU penalty. dist_weight (float): Weight for center distance. size_weight (float): Weight for size penalty. @@ -117,7 +119,6 @@ def append_config( """ if path_overlap_penalty is not None: self.path_overlap_penalty = path_overlap_penalty - if iou_weight is not None: self.weights["iou"] = iou_weight if dist_weight is not None: @@ -129,7 +130,7 @@ def append_config( def append_frame(self, detections: sv.Detections): """ - Add detections for a new frame. + Add detections for a new frame to the buffer. Args: detections (sv.Detections): Detections for the frame. @@ -141,10 +142,10 @@ def _get_center(self, bbox): Compute the center of a bounding box. Args: - bbox (np.ndarray): Bounding box coordinates. + bbox (np.ndarray): Bounding box coordinates (x1, y1, x2, y2). Returns: - np.ndarray: Center coordinates. + np.ndarray: Center coordinates (x, y). """ x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) @@ -158,7 +159,7 @@ def _iou(self, a, b): b (np.ndarray): Second bounding box. Returns: - float: IoU value. + float: IoU value between 0 and 1. """ x1, y1, x2, y2 = ( max(a[0], b[0]), @@ -173,11 +174,12 @@ def _iou(self, a, b): def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): """ - Compute the cost of connecting two detections. + Compute the cost of connecting two detections (nodes) in the graph. + The cost is a weighted sum of IoU penalty, center distance, size penalty, and confidence penalty. Args: - a, b (np.ndarray): Bounding boxes. - conf_a, conf_b (float): Detection confidences. + nodeU (TrackNode): Source node. + nodeV (TrackNode): Target node. Returns: float: Edge cost. @@ -206,6 +208,7 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): def _build_graph(self): """ Build the tracking graph from all buffered detections. + Each detection is a node, and edges connect detections in consecutive frames. """ G = nx.DiGraph() G.add_node(self.source) @@ -246,13 +249,13 @@ def solve( k: Optional[int] = None, ) -> List[List[TrackNode]]: """ - Extract up to k node-disjoint shortest paths from the graph. + Extract up to k node-disjoint shortest paths from the graph using a successive shortest path approach. Args: - k (Optional[int]): Maximum number of paths to extract. + k (Optional[int]): Maximum number of paths to extract. If None, uses the maximum number of detections in any frame. Returns: - List[List[TrackNode]]: List of node-disjoint paths (tracks). + List[List[TrackNode]]: List of node-disjoint paths (tracks), each path is a list of TrackNode objects. """ self._build_graph() @@ -266,12 +269,14 @@ def solve( for _i in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): G_mod = G_base.copy() + # Update edge weights to penalize reused edges for u, v, data in G_mod.edges(data=True): base = data[self.weight_key] penalty = self.path_overlap_penalty * edge_reuse[(u, v)] * base data[self.weight_key] = base + penalty try: + # Find shortest path from source to sink _, path = nx.single_source_dijkstra( G_mod, self.source, self.sink, weight=self.weight_key ) @@ -279,6 +284,7 @@ def solve( print(f"No path found from source to sink at {_i}th iteration") break + # Check for duplicate paths if path[1:-1] in paths: print("Duplicate path found!") # NOTE: Changed to continue for debugging to extrapolate the track detects to investigate the reason for fewer paths generated @@ -287,6 +293,7 @@ def solve( paths.append(path[1:-1]) + # Mark edges in this path as reused for future penalty for u, v in zip(path[:-1], path[1:]): edge_reuse[(u, v)] += 1 diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 84b9bb08..b1bb7602 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -34,21 +34,28 @@ def __init__( def reset(self) -> None: """ - Reset the solver and clear any stored state. + Reset the KSPTracker and its solver state. + This clears all buffered detections and resets the underlying solver. """ self._solver.reset() def update_config( - self, path_overlap_penalty: Optional[int] = None, iou_weight: Optional[int] = None, dist_weight: Optional[int] = None, size_weight: Optional[int] = None, conf_weight: Optional[int] = None + self, + path_overlap_penalty: Optional[int] = None, + iou_weight: Optional[int] = None, + dist_weight: Optional[int] = None, + size_weight: Optional[int] = None, + conf_weight: Optional[int] = None, ): """ Update the configuration weights for the KSP algorithm. Args: - iou_weight (float): Weight for IoU component. - dist_weight (float): Weight for distance component. - size_weight (float): Weight for size component. - conf_weight (float): Weight for confidence component. + path_overlap_penalty (Optional[int]): Penalty for edge reuse in successive paths. + iou_weight (Optional[float]): Weight for IoU component. + dist_weight (Optional[float]): Weight for distance component. + size_weight (Optional[float]): Weight for size component. + conf_weight (Optional[float]): Weight for confidence component. """ self._solver.append_config( path_overlap_penalty=path_overlap_penalty, @@ -82,8 +89,7 @@ def assign_tracker_ids_from_paths( paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. Returns: - Dict[int, sv.Detections]: Mapping from frame index to sv.Detections - with tracker IDs assigned. + List[sv.Detections]: List of sv.Detections with tracker IDs assigned for each frame. """ # Track where each node appears framed_nodes = defaultdict(list) From 203d9368b65a14dfab2d8c6bbc0dc8cffb786d20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:33:54 +0000 Subject: [PATCH 070/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksptracker/solver.py | 7 ++++++- trackers/core/ksptracker/tracker.py | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 4b2ddc45..3d6b1b64 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -105,7 +105,12 @@ def reset(self): self.graph = nx.DiGraph() def append_config( - self, path_overlap_penalty=40, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, conf_weight=0.1 + self, + path_overlap_penalty=40, + iou_weight=0.9, + dist_weight=0.1, + size_weight=0.1, + conf_weight=0.1, ): """ Update the weights for edge cost calculation and path overlap penalty. diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index b1bb7602..b25feb6d 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -17,7 +17,12 @@ class KSPTracker(BaseTracker): """ def __init__( - self, path_overlap_penalty: Optional[int] = None, iou_weight: Optional[int] = None, dist_weight: Optional[int] = None, size_weight: Optional[int] = None, conf_weight: Optional[int] = None + self, + path_overlap_penalty: Optional[int] = None, + iou_weight: Optional[int] = None, + dist_weight: Optional[int] = None, + size_weight: Optional[int] = None, + conf_weight: Optional[int] = None, ) -> None: """ Initialize the KSPTracker and its solver. @@ -207,7 +212,7 @@ def process_tracks( self.update(detections) else: raise ValueError(f"{source_path} not found!") - + paths = self._solver.solve(num_of_tracks) if not paths: return [] From eb61e9822fb374ea266e6a4b6c8addd08c3f99ad Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 18:36:55 -0400 Subject: [PATCH 071/100] Pre-commit --- trackers/core/ksptracker/solver.py | 15 ++++++++++----- trackers/core/ksptracker/tracker.py | 6 ++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 3d6b1b64..4b42579d 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -180,7 +180,8 @@ def _iou(self, a, b): def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): """ Compute the cost of connecting two detections (nodes) in the graph. - The cost is a weighted sum of IoU penalty, center distance, size penalty, and confidence penalty. + The cost is a weighted sum of IoU penalty, center distance, + size penalty, and confidence penalty. Args: nodeU (TrackNode): Source node. @@ -254,13 +255,16 @@ def solve( k: Optional[int] = None, ) -> List[List[TrackNode]]: """ - Extract up to k node-disjoint shortest paths from the graph using a successive shortest path approach. + Extract up to k node-disjoint shortest paths from the graph using a + successive shortest path approach. Args: - k (Optional[int]): Maximum number of paths to extract. If None, uses the maximum number of detections in any frame. + k (Optional[int]): Maximum number of paths to extract. If None, + uses the maximum number of detections in any frame. Returns: - List[List[TrackNode]]: List of node-disjoint paths (tracks), each path is a list of TrackNode objects. + List[List[TrackNode]]: List of node-disjoint paths (tracks), + each path is a list of TrackNode objects. """ self._build_graph() @@ -292,7 +296,8 @@ def solve( # Check for duplicate paths if path[1:-1] in paths: print("Duplicate path found!") - # NOTE: Changed to continue for debugging to extrapolate the track detects to investigate the reason for fewer paths generated + # NOTE: Changed to continue for debugging to extrapolate the + # track detects to investigate the reason for fewer paths generated # Change this to break when done continue diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index b25feb6d..5f1795e0 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -56,7 +56,8 @@ def update_config( Update the configuration weights for the KSP algorithm. Args: - path_overlap_penalty (Optional[int]): Penalty for edge reuse in successive paths. + path_overlap_penalty (Optional[int]): Penalty for edge reuse in + successive paths. iou_weight (Optional[float]): Weight for IoU component. dist_weight (Optional[float]): Weight for distance component. size_weight (Optional[float]): Weight for size component. @@ -94,7 +95,8 @@ def assign_tracker_ids_from_paths( paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. Returns: - List[sv.Detections]: List of sv.Detections with tracker IDs assigned for each frame. + List[sv.Detections]: List of sv.Detections with tracker IDs assigned + for each frame. """ # Track where each node appears framed_nodes = defaultdict(list) From e5397adfadf512850a1a46e8062752ac3aa62318 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 18:55:36 -0400 Subject: [PATCH 072/100] FIX: process_tracks docstrings --- trackers/core/ksptracker/tracker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 5f1795e0..4fc14263 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -160,16 +160,16 @@ def assign_tracker_ids_from_paths( def process_tracks( self, - source_path: Optional[str] = None, - get_model_detections: Optional[Callable[[np.ndarray], sv.Detections]] = None, + source_path: str = None, + get_model_detections: Callable[[np.ndarray], sv.Detections] = None, num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: """ Run the KSP solver and assign tracker IDs to detections. Args: - source_path (Optional[str]): Path to video file or directory of frames. - get_model_detections (Optional[Callable[[np.ndarray], sv.Detections]]): + source_path (str): Path to video file or directory of frames. + get_model_detections (Callable[[np.ndarray], sv.Detections]): Function that takes an image (np.ndarray) and returns sv.Detections. num_of_tracks (Optional[int]): Number of tracks to extract (K). From 048de8cb0240028cf5e3ba4825f511f53e60c679 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 19:11:03 -0400 Subject: [PATCH 073/100] FIX: process_tracks docstrings --- trackers/core/ksptracker/tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 4fc14263..9ab189c3 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -160,8 +160,8 @@ def assign_tracker_ids_from_paths( def process_tracks( self, - source_path: str = None, - get_model_detections: Callable[[np.ndarray], sv.Detections] = None, + source_path: str, + get_model_detections: Callable[[np.ndarray], sv.Detections], num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: """ From 7dcc03cff59038d3ddbd2b103c5d651f5b4b185e Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 19:12:31 -0400 Subject: [PATCH 074/100] Pre-commit check --- trackers/core/ksptracker/tracker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 9ab189c3..3baca684 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -214,7 +214,6 @@ def process_tracks( self.update(detections) else: raise ValueError(f"{source_path} not found!") - paths = self._solver.solve(num_of_tracks) if not paths: return [] From 97eda168b58768dab26a3ebca40c138e305f416b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 2 Jul 2025 19:14:41 -0400 Subject: [PATCH 075/100] Pre-commit --- trackers/core/ksptracker/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 3baca684..50db3188 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -180,7 +180,7 @@ def process_tracks( raise ValueError( "`source_path` must be a string path to a directory or an .mp4 file." ) - if not get_model_detections: + if get_model_detections is None: raise TypeError( "`get_model_detections` must be a callable that returns an " "instance of `sv.Detections`." From 4a14d056b2053b9ff7f3fab7a8a084c30311c745 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 3 Jul 2025 19:54:25 -0400 Subject: [PATCH 076/100] UPDATE: Updates referring to the code review --- trackers/core/base.py | 15 +++++ trackers/core/ksptracker/solver.py | 87 ++++++++--------------------- trackers/core/ksptracker/tracker.py | 56 ++++++------------- 3 files changed, 56 insertions(+), 102 deletions(-) diff --git a/trackers/core/base.py b/trackers/core/base.py index 6db95485..f8ffb6ca 100644 --- a/trackers/core/base.py +++ b/trackers/core/base.py @@ -2,6 +2,7 @@ import numpy as np import supervision as sv +from typing import Any, Callable, List, Optional class BaseTracker(ABC): @@ -22,3 +23,17 @@ def update(self, detections: sv.Detections, frame: np.ndarray) -> sv.Detections: @abstractmethod def reset(self) -> None: pass + +class BaseOfflineTracker(ABC): + @abstractmethod + def reset(self) -> None: + pass + + @abstractmethod + def track(self, + source_path: str, + get_model_detections: Callable[[np.ndarray], sv.Detections], + num_of_tracks: Optional[int] = None + ) -> List[sv.Detections]: + pass + diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index 4b42579d..d9d77977 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -5,7 +5,7 @@ import networkx as nx import numpy as np import supervision as sv -from tqdm import tqdm +from tqdm.auto import tqdm @dataclass(frozen=True) @@ -41,80 +41,22 @@ def __eq__(self, other: Any): def __str__(self): return f"{self.frame_id}:{self.det_idx}@{self.position}" - -def box_iou_batch(boxes_true: np.ndarray, boxes_detection: np.ndarray) -> np.ndarray: - """ - Compute Intersection over Union (IoU) of two sets of bounding boxes - - `boxes_true` and `boxes_detection`. Both sets - of boxes are expected to be in `(x_min, y_min, x_max, y_max)` format. - - Args: - boxes_true (np.ndarray): 2D `np.ndarray` representing ground-truth boxes. - `shape = (N, 4)` where `N` is number of true objects. - boxes_detection (np.ndarray): 2D `np.ndarray` representing detection boxes. - `shape = (M, 4)` where `M` is number of detected objects. - - Returns: - np.ndarray: Pairwise IoU of boxes from `boxes_true` and `boxes_detection`. - `shape = (N, M)` where `N` is number of true objects and - `M` is number of detected objects. - """ - area_true = (boxes_true[:, 2] - boxes_true[:, 0]) * ( - boxes_true[:, 3] - boxes_true[:, 1] - ) - area_detection = (boxes_detection[:, 2] - boxes_detection[:, 0]) * ( - boxes_detection[:, 3] - boxes_detection[:, 1] - ) - - top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2]) - bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:]) - - wh = np.clip(bottom_right - top_left, a_min=0, a_max=None) - area_inter = wh[:, :, 0] * wh[:, :, 1] - - ious = area_inter / (area_true[:, None] + area_detection - area_inter) - - ious = np.nan_to_num(ious) - return ious - - class KSPSolver: """ Solver for the K-Shortest Paths (KSP) tracking problem. Builds a graph from detections and extracts multiple disjoint paths. """ - def __init__(self): - """ - Initialize the KSP_Solver. - """ - self.path_overlap_penalty = 40 - self.weight_key = "weight" - self.source = "SOURCE" - self.sink = "SINK" - self.detection_per_frame: List[sv.Detections] = [] - self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} - self.reset() - - def reset(self): - """ - Reset the solver state and clear all detections and graph. - This clears the detection buffer and initializes a new empty graph. - """ - self.detection_per_frame = [] - self.graph = nx.DiGraph() - - def append_config( - self, + def __init__(self, path_overlap_penalty=40, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, - conf_weight=0.1, - ): + conf_weight=0.1 + ): """ - Update the weights for edge cost calculation and path overlap penalty. - + Initialize the KSPSolver. + Args: path_overlap_penalty (float): Penalty for edge reuse in successive paths. iou_weight (float): Weight for IoU penalty. @@ -122,6 +64,13 @@ def append_config( size_weight (float): Weight for size penalty. conf_weight (float): Weight for confidence penalty. """ + self.path_overlap_penalty = 40 + self.weight_key = "weight" + self.source = "SOURCE" + self.sink = "SINK" + self.detection_per_frame: List[sv.Detections] = [] + self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} + if path_overlap_penalty is not None: self.path_overlap_penalty = path_overlap_penalty if iou_weight is not None: @@ -133,6 +82,16 @@ def append_config( if conf_weight is not None: self.weights["conf"] = conf_weight + self.reset() + + def reset(self): + """ + Reset the solver state and clear all detections and graph. + This clears the detection buffer and initializes a new empty graph. + """ + self.detection_per_frame = [] + self.graph = nx.DiGraph() + def append_frame(self, detections: sv.Detections): """ Add detections for a new frame to the buffer. diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 50db3188..60ab987c 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -5,13 +5,13 @@ import cv2 import numpy as np import supervision as sv -from tqdm import tqdm +from tqdm.auto import tqdm -from trackers.core.base import BaseTracker +from trackers.core.base import BaseOfflineTracker from trackers.core.ksptracker.solver import KSPSolver, TrackNode -class KSPTracker(BaseTracker): +class KSPTracker(BaseOfflineTracker): """ Offline tracker using K-Shortest Paths (KSP) algorithm. """ @@ -26,9 +26,15 @@ def __init__( ) -> None: """ Initialize the KSPTracker and its solver. + + Args: + path_overlap_penalty (Optional[int]): Penalty for reusing the same edge (detection pairing) in multiple tracks. Increasing this value encourages the tracker to produce more distinct, non-overlapping tracks by discouraging shared detections between tracks. + iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) penalty in the edge cost. Higher values make the tracker favor linking detections with greater spatial overlap, which helps maintain track continuity for objects that move smoothly. + dist_weight (Optional[int]): Weight for the Euclidean distance between detection centers in the edge cost. Increasing this value penalizes large jumps between detections in consecutive frames, promoting smoother, more physically plausible tracks. + size_weight (Optional[int]): Weight for the size difference penalty in the edge cost. Higher values penalize linking detections with significantly different bounding box areas, which helps prevent identity switches when object size changes abruptly. + conf_weight (Optional[int]): Weight for the confidence penalty in the edge cost. Higher values penalize edges between detections with lower confidence scores, making the tracker prefer more reliable detections and reducing the impact of false positives. """ - self._solver = KSPSolver() - self._solver.append_config( + self._solver = KSPSolver( path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, dist_weight=dist_weight, @@ -40,38 +46,12 @@ def __init__( def reset(self) -> None: """ Reset the KSPTracker and its solver state. + This clears all buffered detections and resets the underlying solver. """ self._solver.reset() - def update_config( - self, - path_overlap_penalty: Optional[int] = None, - iou_weight: Optional[int] = None, - dist_weight: Optional[int] = None, - size_weight: Optional[int] = None, - conf_weight: Optional[int] = None, - ): - """ - Update the configuration weights for the KSP algorithm. - - Args: - path_overlap_penalty (Optional[int]): Penalty for edge reuse in - successive paths. - iou_weight (Optional[float]): Weight for IoU component. - dist_weight (Optional[float]): Weight for distance component. - size_weight (Optional[float]): Weight for size component. - conf_weight (Optional[float]): Weight for confidence component. - """ - self._solver.append_config( - path_overlap_penalty=path_overlap_penalty, - iou_weight=iou_weight, - dist_weight=dist_weight, - size_weight=size_weight, - conf_weight=conf_weight, - ) - - def update(self, detections: sv.Detections) -> sv.Detections: + def __update(self, detections: sv.Detections) -> sv.Detections: """ Add detections for the current frame to the solver. @@ -84,7 +64,7 @@ def update(self, detections: sv.Detections) -> sv.Detections: self._solver.append_frame(detections) return detections - def assign_tracker_ids_from_paths( + def __assign_tracker_ids_from_paths( self, paths: List[List[TrackNode]] ) -> List[sv.Detections]: """ @@ -158,7 +138,7 @@ def assign_tracker_ids_from_paths( return frame_to_detections - def process_tracks( + def track( self, source_path: str, get_model_detections: Callable[[np.ndarray], sv.Detections], @@ -195,7 +175,7 @@ def process_tracks( dynamic_ncols=True, ): detections = get_model_detections(frame) - self.update(detections) + self.__update(detections) elif os.path.isdir(source_path): frame_paths = sorted( [ @@ -211,10 +191,10 @@ def process_tracks( ): image = cv2.imread(frame_path) detections = get_model_detections(image) - self.update(detections) + self.__update(detections) else: raise ValueError(f"{source_path} not found!") paths = self._solver.solve(num_of_tracks) if not paths: return [] - return self.assign_tracker_ids_from_paths(paths) + return self.__assign_tracker_ids_from_paths(paths) From 44d78aadc11524a3d52ae8bd667822a5ffb58e8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Jul 2025 23:54:44 +0000 Subject: [PATCH 077/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/base.py | 15 ++++++++------- trackers/core/ksptracker/solver.py | 10 ++++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/trackers/core/base.py b/trackers/core/base.py index f8ffb6ca..aeb67d04 100644 --- a/trackers/core/base.py +++ b/trackers/core/base.py @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod +from typing import Callable, List, Optional import numpy as np import supervision as sv -from typing import Any, Callable, List, Optional class BaseTracker(ABC): @@ -24,16 +24,17 @@ def update(self, detections: sv.Detections, frame: np.ndarray) -> sv.Detections: def reset(self) -> None: pass + class BaseOfflineTracker(ABC): @abstractmethod def reset(self) -> None: pass @abstractmethod - def track(self, - source_path: str, - get_model_detections: Callable[[np.ndarray], sv.Detections], - num_of_tracks: Optional[int] = None - ) -> List[sv.Detections]: + def track( + self, + source_path: str, + get_model_detections: Callable[[np.ndarray], sv.Detections], + num_of_tracks: Optional[int] = None, + ) -> List[sv.Detections]: pass - diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py index d9d77977..ad51f1a1 100644 --- a/trackers/core/ksptracker/solver.py +++ b/trackers/core/ksptracker/solver.py @@ -41,22 +41,24 @@ def __eq__(self, other: Any): def __str__(self): return f"{self.frame_id}:{self.det_idx}@{self.position}" + class KSPSolver: """ Solver for the K-Shortest Paths (KSP) tracking problem. Builds a graph from detections and extracts multiple disjoint paths. """ - def __init__(self, + def __init__( + self, path_overlap_penalty=40, iou_weight=0.9, dist_weight=0.1, size_weight=0.1, - conf_weight=0.1 - ): + conf_weight=0.1, + ): """ Initialize the KSPSolver. - + Args: path_overlap_penalty (float): Penalty for edge reuse in successive paths. iou_weight (float): Weight for IoU penalty. From 875acd513cd65fe9e5f393d235f9f8b5934c9112 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Thu, 3 Jul 2025 19:59:43 -0400 Subject: [PATCH 078/100] Pre-commit --- trackers/core/ksptracker/tracker.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py index 60ab987c..c8612d99 100644 --- a/trackers/core/ksptracker/tracker.py +++ b/trackers/core/ksptracker/tracker.py @@ -28,11 +28,30 @@ def __init__( Initialize the KSPTracker and its solver. Args: - path_overlap_penalty (Optional[int]): Penalty for reusing the same edge (detection pairing) in multiple tracks. Increasing this value encourages the tracker to produce more distinct, non-overlapping tracks by discouraging shared detections between tracks. - iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) penalty in the edge cost. Higher values make the tracker favor linking detections with greater spatial overlap, which helps maintain track continuity for objects that move smoothly. - dist_weight (Optional[int]): Weight for the Euclidean distance between detection centers in the edge cost. Increasing this value penalizes large jumps between detections in consecutive frames, promoting smoother, more physically plausible tracks. - size_weight (Optional[int]): Weight for the size difference penalty in the edge cost. Higher values penalize linking detections with significantly different bounding box areas, which helps prevent identity switches when object size changes abruptly. - conf_weight (Optional[int]): Weight for the confidence penalty in the edge cost. Higher values penalize edges between detections with lower confidence scores, making the tracker prefer more reliable detections and reducing the impact of false positives. + path_overlap_penalty (Optional[int]): Penalty for reusing the same edge + (detection pairing) in multiple tracks. Increasing this value encourages + the tracker to produce more distinct, non-overlapping tracks by + discouraging shared detections between tracks. + + iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) + penalty in the edge cost. Higher values make the tracker favor linking + detections with greater spatial overlap, which helps maintain track + continuity for objects that move smoothly. + + dist_weight (Optional[int]): Weight for the Euclidean distance between + detection centers in the edge cost. Increasing this value penalizes + large jumps between detections in consecutive frames, promoting + smoother, more physically plausible tracks. + + size_weight (Optional[int]): Weight for the size difference penalty in the + edge cost. Higher values penalize linking detections with significantly + different bounding box areas, which helps prevent identity switches when + object size changes abruptly. + + conf_weight (Optional[int]): Weight for the confidence penalty in the edge + cost. Higher values penalize edges between detections with lower + confidence scores, making the tracker prefer more reliable detections + and reducing the impact of false positives. """ self._solver = KSPSolver( path_overlap_penalty=path_overlap_penalty, From 19fad3b88f8b7e6ae145d45c182e79173c93cbaf Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 4 Jul 2025 01:21:07 -0400 Subject: [PATCH 079/100] ADD: Added documentation and changed folder name --- docs/trackers/core/ksp/tracker.md | 185 ++++++++++++++++++ trackers/core/ksptracker/__init__.py | 0 trackers/core/ksptracker/solver.py | 271 --------------------------- trackers/core/ksptracker/tracker.py | 219 ---------------------- 4 files changed, 185 insertions(+), 490 deletions(-) create mode 100644 docs/trackers/core/ksp/tracker.md delete mode 100644 trackers/core/ksptracker/__init__.py delete mode 100644 trackers/core/ksptracker/solver.py delete mode 100644 trackers/core/ksptracker/tracker.py diff --git a/docs/trackers/core/ksp/tracker.md b/docs/trackers/core/ksp/tracker.md new file mode 100644 index 00000000..90c0ab14 --- /dev/null +++ b/docs/trackers/core/ksp/tracker.md @@ -0,0 +1,185 @@ +--- +comments: true +--- + +# KSP + +[![IEEE](https://img.shields.io/badge/IEEE-10.1109/TPAMI.2011.21-blue.svg)](https://doi.org/10.1109/TPAMI.2011.21) +[![PDF (Unofficial)](https://img.shields.io/badge/PDF-Stanford--Preprint-red.svg)](http://vision.stanford.edu/teaching/cs231b_spring1415/papers/Berclaz-tracking.pdf) +[![colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-track-objects-with-sort-tracker.ipynb) + +## Overview + +**KSP Tracker** (K-Shortest Paths Tracker) is an offline, tracking-by-detection method that formulates multi-object tracking as a global optimization problem over a directed graph. Each object detection is represented as a node, and feasible transitions between detections are modeled as edges weighted by spatial and temporal consistency. By solving a K-shortest paths problem, the tracker extracts globally optimal trajectories that span the entire sequence. + +Unlike online trackers, which make frame-by-frame decisions, KSP Tracker leverages the full temporal context of a video to achieve greater robustness against occlusions, missed detections, and fragmented tracks. This makes it especially suitable for applications where high tracking accuracy is required, such as surveillance review, sports analytics, or autonomous system evaluation. However, the reliance on global optimization introduces higher computational cost and requires access to the full sequence before tracking can be performed. + +## Examples + +=== "inference" + + ```python hl_lines="2 6 11 15" + import supervision as sv + from trackers import KSPTracker + from inference import get_model + import numpy as np + + tracker = KSPTracker() + model = get_model(model_id="yolo11x") + box_annotator = sv.BoxAnnotator() + label_annotator = sv.LabelAnnotator(text_position=sv.Position.TOP_LEFT) + + def get_model_detections(frame: np.ndarray): + result = model.infer(frame)[0] + return sv.Detections.from_inference(result) + + tracked_dets = tracker.track( + source_path="", + get_model_detections=get_model_detections + ) + + frame_idx_to_dets = {i: tracked_dets[i] for i in range(len(tracked_dets))} + + def annotate_frame(frame: np.ndarray, i: int) -> np.ndarray: + detections = frame_idx_to_dets.get(i, sv.Detections.empty()) + detections.tracker_id = detections.tracker_id or np.zeros(len(detections), dtype=int) + labels = [f"{tid}" for tid in detections.tracker_id] + ann = box_annotator.annotate(frame.copy(), detections) + return label_annotator.annotate(ann, detections, labels=labels) + + sv.process_video( + source_path="", + target_path="", + callback=annotate_frame, + ) + ``` + +=== "rf-detr" + + ```python hl_lines="2 6 11 14" + import supervision as sv + from trackers import KSPTracker + from rfdetr import RFDETRBase + import numpy as np + + tracker = KSPTracker() + model = RFDETRBase() + box_annotator = sv.BoxAnnotator() + label_annotator = sv.LabelAnnotator(text_position=sv.Position.TOP_LEFT) + + def get_model_detections(frame: np.ndarray): + return model.predict(frame) + + tracked_dets = tracker.track( + source_path="", + get_model_detections=get_model_detections + ) + + frame_idx_to_dets = {i: tracked_dets[i] for i in range(len(tracked_dets))} + + def annotate_frame(frame: np.ndarray, i: int) -> np.ndarray: + detections = frame_idx_to_dets.get(i, sv.Detections.empty()) + detections.tracker_id = detections.tracker_id or np.zeros(len(detections), dtype=int) + labels = [f"{tid}" for tid in detections.tracker_id] + ann = box_annotator.annotate(frame.copy(), detections) + return label_annotator.annotate(ann, detections, labels=labels) + + sv.process_video( + source_path="", + target_path="", + callback=annotate_frame, + ) + ``` + +=== "ultralytics" + + ```python hl_lines="2 6 11 16" + import supervision as sv + from trackers import KSPTracker + from ultralytics import YOLO + import numpy as np + + tracker = KSPTracker() + model = YOLO("yolo11m.pt") + box_annotator = sv.BoxAnnotator() + label_annotator = sv.LabelAnnotator(text_position=sv.Position.TOP_LEFT) + + def get_model_detections(frame: np.ndarray): + result = model(frame, imgsz=1280, verbose=False)[0] + detections = sv.Detections.from_ultralytics(result) + return detections[detections.class_id == 0] if not detections.is_empty() else detections + + tracked_dets = tracker.track( + source_path="", + get_model_detections=get_model_detections + ) + + frame_idx_to_dets = {i: tracked_dets[i] for i in range(len(tracked_dets))} + + def annotate_frame(frame: np.ndarray, i: int) -> np.ndarray: + detections = frame_idx_to_dets.get(i, sv.Detections.empty()) + detections.tracker_id = detections.tracker_id or np.zeros(len(detections), dtype=int) + labels = [f"{tid}" for tid in detections.tracker_id] + ann = box_annotator.annotate(frame.copy(), detections) + return label_annotator.annotate(ann, detections, labels=labels) + + sv.process_video( + source_path="", + target_path="", + callback=annotate_frame, + ) + ``` + +=== "transformers" + + ```python hl_lines="3 7 13 27" + import torch + import supervision as sv + from trackers import KSPTracker + from transformers import RTDetrV2ForObjectDetection, RTDetrImageProcessor + import numpy as np + + tracker = KSPTracker() + processor = RTDetrImageProcessor.from_pretrained("PekingU/rtdetr_v2_r18vd") + model = RTDetrV2ForObjectDetection.from_pretrained("PekingU/rtdetr_v2_r18vd") + box_annotator = sv.BoxAnnotator() + label_annotator = sv.LabelAnnotator(text_position=sv.Position.TOP_LEFT) + + def get_model_detections(frame: np.ndarray): + inputs = processor(images=frame, return_tensors="pt") + with torch.no_grad(): + outputs = model(**inputs) + + h, w, _ = frame.shape + results = processor.post_process_object_detection( + outputs, + target_sizes=torch.tensor([(h, w)]), + threshold=0.5 + )[0] + + return sv.Detections.from_transformers(results, id2label=model.config.id2label) + + tracked_dets = tracker.track( + "", + get_model_detections=get_model_detections + ) + + frame_idx_to_dets = {i: tracked_dets[i] for i in range(len(tracked_dets))} + + def annotate_frame(frame: np.ndarray, i: int) -> np.ndarray: + detections = frame_idx_to_dets.get(i, sv.Detections.empty()) + detections.tracker_id = detections.tracker_id or np.zeros(len(detections), dtype=int) + labels = [f"{tid}" for tid in detections.tracker_id] + ann = box_annotator.annotate(frame.copy(), detections) + return label_annotator.annotate(ann, detections, labels=labels) + + sv.process_video( + source_path="", + target_path="", + callback=callback, + ) + ``` + +## API + +::: trackers.core.ksp.tracker.KSPTracker diff --git a/trackers/core/ksptracker/__init__.py b/trackers/core/ksptracker/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/trackers/core/ksptracker/solver.py b/trackers/core/ksptracker/solver.py deleted file mode 100644 index ad51f1a1..00000000 --- a/trackers/core/ksptracker/solver.py +++ /dev/null @@ -1,271 +0,0 @@ -from collections import defaultdict -from dataclasses import dataclass -from typing import Any, List, Optional, Tuple - -import networkx as nx -import numpy as np -import supervision as sv -from tqdm.auto import tqdm - - -@dataclass(frozen=True) -class TrackNode: - """ - Represents a detection node in the tracking graph. - - Attributes: - frame_id (int): Frame index where detection occurred. - det_idx (int): Detection index in the frame. - class_id (int): Class ID of the detection. - position (tuple): Center position of the detection. - bbox (np.ndarray): Bounding box coordinates. - confidence (float): Detection confidence score. - """ - - frame_id: int - det_idx: int - class_id: int - position: tuple - bbox: np.ndarray - confidence: float - - def __hash__(self): - return hash((self.frame_id, self.det_idx)) - - def __eq__(self, other: Any): - return isinstance(other, TrackNode) and (self.frame_id, self.det_idx) == ( - other.frame_id, - other.det_idx, - ) - - def __str__(self): - return f"{self.frame_id}:{self.det_idx}@{self.position}" - - -class KSPSolver: - """ - Solver for the K-Shortest Paths (KSP) tracking problem. - Builds a graph from detections and extracts multiple disjoint paths. - """ - - def __init__( - self, - path_overlap_penalty=40, - iou_weight=0.9, - dist_weight=0.1, - size_weight=0.1, - conf_weight=0.1, - ): - """ - Initialize the KSPSolver. - - Args: - path_overlap_penalty (float): Penalty for edge reuse in successive paths. - iou_weight (float): Weight for IoU penalty. - dist_weight (float): Weight for center distance. - size_weight (float): Weight for size penalty. - conf_weight (float): Weight for confidence penalty. - """ - self.path_overlap_penalty = 40 - self.weight_key = "weight" - self.source = "SOURCE" - self.sink = "SINK" - self.detection_per_frame: List[sv.Detections] = [] - self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} - - if path_overlap_penalty is not None: - self.path_overlap_penalty = path_overlap_penalty - if iou_weight is not None: - self.weights["iou"] = iou_weight - if dist_weight is not None: - self.weights["dist"] = dist_weight - if size_weight is not None: - self.weights["size"] = size_weight - if conf_weight is not None: - self.weights["conf"] = conf_weight - - self.reset() - - def reset(self): - """ - Reset the solver state and clear all detections and graph. - This clears the detection buffer and initializes a new empty graph. - """ - self.detection_per_frame = [] - self.graph = nx.DiGraph() - - def append_frame(self, detections: sv.Detections): - """ - Add detections for a new frame to the buffer. - - Args: - detections (sv.Detections): Detections for the frame. - """ - self.detection_per_frame.append(detections) - - def _get_center(self, bbox): - """ - Compute the center of a bounding box. - - Args: - bbox (np.ndarray): Bounding box coordinates (x1, y1, x2, y2). - - Returns: - np.ndarray: Center coordinates (x, y). - """ - x1, y1, x2, y2 = bbox - return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) - - def _iou(self, a, b): - """ - Compute Intersection over Union (IoU) between two bounding boxes. - - Args: - a (np.ndarray): First bounding box. - b (np.ndarray): Second bounding box. - - Returns: - float: IoU value between 0 and 1. - """ - x1, y1, x2, y2 = ( - max(a[0], b[0]), - max(a[1], b[1]), - min(a[2], b[2]), - min(a[3], b[3]), - ) - inter = max(0, x2 - x1) * max(0, y2 - y1) - area_a = (a[2] - a[0]) * (a[3] - a[1]) - area_b = (b[2] - b[0]) * (b[3] - b[1]) - return inter / (area_a + area_b - inter + 1e-6) - - def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): - """ - Compute the cost of connecting two detections (nodes) in the graph. - The cost is a weighted sum of IoU penalty, center distance, - size penalty, and confidence penalty. - - Args: - nodeU (TrackNode): Source node. - nodeV (TrackNode): Target node. - - Returns: - float: Edge cost. - """ - bboxU, bboxV = nodeU.bbox, nodeV.bbox - conf_u, conf_v = nodeU.confidence, nodeV.confidence - - center_dist = np.linalg.norm(self._get_center(bboxU) - self._get_center(bboxV)) - iou_penalty = 1 - self._iou(bboxU, bboxV) - - area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) - area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) - size_penalty = np.log( - (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 - ) - - conf_penalty = 1 - min(conf_u, conf_v) - - return ( - self.weights["iou"] * iou_penalty - + self.weights["dist"] * center_dist - + self.weights["size"] * size_penalty - + self.weights["conf"] * conf_penalty - ) - - def _build_graph(self): - """ - Build the tracking graph from all buffered detections. - Each detection is a node, and edges connect detections in consecutive frames. - """ - G = nx.DiGraph() - G.add_node(self.source) - G.add_node(self.sink) - - node_frames = [] - - for frame_id, detections in enumerate(self.detection_per_frame): - frame_nodes = [] - for det_idx, bbox in enumerate(detections.xyxy): - node = TrackNode( - frame_id=frame_id, - det_idx=det_idx, - class_id=int(detections.class_id[det_idx]), - position=tuple(self._get_center(bbox)), - bbox=bbox, - confidence=float(detections.confidence[det_idx]), - ) - G.add_node(node) - frame_nodes.append(node) - node_frames.append(frame_nodes) - - for t in range(len(node_frames) - 1): - for node_a in node_frames[t]: - for node_b in node_frames[t + 1]: - cost = self._edge_cost(node_a, node_b) - G.add_edge(node_a, node_b, weight=cost) - - for node in node_frames[0]: - G.add_edge(self.source, node, weight=0.0) - for node in node_frames[-1]: - G.add_edge(node, self.sink, weight=0.0) - - self.graph = G - - def solve( - self, - k: Optional[int] = None, - ) -> List[List[TrackNode]]: - """ - Extract up to k node-disjoint shortest paths from the graph using a - successive shortest path approach. - - Args: - k (Optional[int]): Maximum number of paths to extract. If None, - uses the maximum number of detections in any frame. - - Returns: - List[List[TrackNode]]: List of node-disjoint paths (tracks), - each path is a list of TrackNode objects. - """ - self._build_graph() - - G_base = self.graph.copy() - edge_reuse: defaultdict[Tuple[Any, Any], int] = defaultdict(int) - paths: List[List[TrackNode]] = [] - - if k is None: - k = max(len(f.xyxy) for f in self.detection_per_frame) - - for _i in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): - G_mod = G_base.copy() - - # Update edge weights to penalize reused edges - for u, v, data in G_mod.edges(data=True): - base = data[self.weight_key] - penalty = self.path_overlap_penalty * edge_reuse[(u, v)] * base - data[self.weight_key] = base + penalty - - try: - # Find shortest path from source to sink - _, path = nx.single_source_dijkstra( - G_mod, self.source, self.sink, weight=self.weight_key - ) - except nx.NetworkXNoPath: - print(f"No path found from source to sink at {_i}th iteration") - break - - # Check for duplicate paths - if path[1:-1] in paths: - print("Duplicate path found!") - # NOTE: Changed to continue for debugging to extrapolate the - # track detects to investigate the reason for fewer paths generated - # Change this to break when done - continue - - paths.append(path[1:-1]) - - # Mark edges in this path as reused for future penalty - for u, v in zip(path[:-1], path[1:]): - edge_reuse[(u, v)] += 1 - - return paths diff --git a/trackers/core/ksptracker/tracker.py b/trackers/core/ksptracker/tracker.py deleted file mode 100644 index c8612d99..00000000 --- a/trackers/core/ksptracker/tracker.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -from collections import defaultdict -from typing import Any, Callable, List, Optional - -import cv2 -import numpy as np -import supervision as sv -from tqdm.auto import tqdm - -from trackers.core.base import BaseOfflineTracker -from trackers.core.ksptracker.solver import KSPSolver, TrackNode - - -class KSPTracker(BaseOfflineTracker): - """ - Offline tracker using K-Shortest Paths (KSP) algorithm. - """ - - def __init__( - self, - path_overlap_penalty: Optional[int] = None, - iou_weight: Optional[int] = None, - dist_weight: Optional[int] = None, - size_weight: Optional[int] = None, - conf_weight: Optional[int] = None, - ) -> None: - """ - Initialize the KSPTracker and its solver. - - Args: - path_overlap_penalty (Optional[int]): Penalty for reusing the same edge - (detection pairing) in multiple tracks. Increasing this value encourages - the tracker to produce more distinct, non-overlapping tracks by - discouraging shared detections between tracks. - - iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) - penalty in the edge cost. Higher values make the tracker favor linking - detections with greater spatial overlap, which helps maintain track - continuity for objects that move smoothly. - - dist_weight (Optional[int]): Weight for the Euclidean distance between - detection centers in the edge cost. Increasing this value penalizes - large jumps between detections in consecutive frames, promoting - smoother, more physically plausible tracks. - - size_weight (Optional[int]): Weight for the size difference penalty in the - edge cost. Higher values penalize linking detections with significantly - different bounding box areas, which helps prevent identity switches when - object size changes abruptly. - - conf_weight (Optional[int]): Weight for the confidence penalty in the edge - cost. Higher values penalize edges between detections with lower - confidence scores, making the tracker prefer more reliable detections - and reducing the impact of false positives. - """ - self._solver = KSPSolver( - path_overlap_penalty=path_overlap_penalty, - iou_weight=iou_weight, - dist_weight=dist_weight, - size_weight=size_weight, - conf_weight=conf_weight, - ) - self.reset() - - def reset(self) -> None: - """ - Reset the KSPTracker and its solver state. - - This clears all buffered detections and resets the underlying solver. - """ - self._solver.reset() - - def __update(self, detections: sv.Detections) -> sv.Detections: - """ - Add detections for the current frame to the solver. - - Args: - detections (sv.Detections): Detections for the current frame. - - Returns: - sv.Detections: The same detections passed in. - """ - self._solver.append_frame(detections) - return detections - - def __assign_tracker_ids_from_paths( - self, paths: List[List[TrackNode]] - ) -> List[sv.Detections]: - """ - Assigns each detection a unique tracker ID by preferring the path with - the least motion change (displacement). - - Args: - paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. - - Returns: - List[sv.Detections]: List of sv.Detections with tracker IDs assigned - for each frame. - """ - # Track where each node appears - framed_nodes = defaultdict(list) - node_to_candidates = defaultdict(list) - for tracker_id, path in enumerate(paths, start=1): - for i, node in enumerate(path): - next_node: Any = path[i + 1] if i + 1 < len(path) else None - node_to_candidates[node].append((tracker_id, next_node)) - framed_nodes[node.frame_id].append(node) - - # Select best tracker for each node based on minimal displacement - node_to_tracker = {} - for node, candidates in node_to_candidates.items(): - min_displacement = float("inf") - selected_tracker = -1 - for tracker_id, next_node in candidates: - if next_node is not None: - dx = node.position[0] - next_node.position[0] - dy = node.position[1] - next_node.position[1] - displacement = dx * dx + dy * dy # squared distance - else: - displacement = 0 # last node in path, no penalty - - if displacement < min_displacement: - min_displacement = displacement - selected_tracker = tracker_id - - node_to_tracker[node] = selected_tracker - - # Organize detections by frame - frame_to_dets = defaultdict(list) - - for node, tracker_id in node_to_tracker.items(): - frame_to_dets[node.frame_id].append( - { - "xyxy": node.bbox, - "confidence": node.confidence, - "class_id": node.class_id, - "tracker_id": tracker_id, - } - ) - - # Convert into sv.Detections - frame_to_detections = [] - for frame, dets_list in frame_to_dets.items(): - xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) - confidence = np.array( - [d["confidence"] for d in dets_list], dtype=np.float32 - ) - class_id = np.array([d["class_id"] for d in dets_list], dtype=int) - tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) - detections = sv.Detections( - xyxy=xyxy, - confidence=confidence, - class_id=class_id, - tracker_id=tracker_id, - ) - frame_to_detections.append(detections) - - return frame_to_detections - - def track( - self, - source_path: str, - get_model_detections: Callable[[np.ndarray], sv.Detections], - num_of_tracks: Optional[int] = None, - ) -> List[sv.Detections]: - """ - Run the KSP solver and assign tracker IDs to detections. - - Args: - source_path (str): Path to video file or directory of frames. - get_model_detections (Callable[[np.ndarray], sv.Detections]): - Function that takes an image (np.ndarray) and returns sv.Detections. - num_of_tracks (Optional[int]): Number of tracks to extract (K). - - Returns: - List[sv.Detections]: List of sv.Detections with tracker IDs assigned. - """ - if not source_path: - raise ValueError( - "`source_path` must be a string path to a directory or an .mp4 file." - ) - if get_model_detections is None: - raise TypeError( - "`get_model_detections` must be a callable that returns an " - "instance of `sv.Detections`." - ) - if source_path.lower().endswith(".mp4"): - frames_generator = sv.get_video_frames_generator(source_path=source_path) - video_info = sv.VideoInfo.from_video_path(video_path=source_path) - for frame in tqdm( - frames_generator, - total=video_info.total_frames, - desc="Extracting detections and buffering from video", - dynamic_ncols=True, - ): - detections = get_model_detections(frame) - self.__update(detections) - elif os.path.isdir(source_path): - frame_paths = sorted( - [ - os.path.join(source_path, f) - for f in os.listdir(source_path) - if f.lower().endswith(".jpg") - ] - ) - for frame_path in tqdm( - frame_paths, - desc="Extracting detections and buffering directory", - dynamic_ncols=True, - ): - image = cv2.imread(frame_path) - detections = get_model_detections(image) - self.__update(detections) - else: - raise ValueError(f"{source_path} not found!") - paths = self._solver.solve(num_of_tracks) - if not paths: - return [] - return self.__assign_tracker_ids_from_paths(paths) From e6020c434472f098ea1df554d7645c820569b813 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 4 Jul 2025 05:21:32 +0000 Subject: [PATCH 080/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/trackers/core/ksp/tracker.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/trackers/core/ksp/tracker.md b/docs/trackers/core/ksp/tracker.md index 90c0ab14..94c2d4dd 100644 --- a/docs/trackers/core/ksp/tracker.md +++ b/docs/trackers/core/ksp/tracker.md @@ -34,7 +34,7 @@ Unlike online trackers, which make frame-by-frame decisions, KSP Tracker leverag return sv.Detections.from_inference(result) tracked_dets = tracker.track( - source_path="", + source_path="", get_model_detections=get_model_detections ) @@ -71,7 +71,7 @@ Unlike online trackers, which make frame-by-frame decisions, KSP Tracker leverag return model.predict(frame) tracked_dets = tracker.track( - source_path="", + source_path="", get_model_detections=get_model_detections ) @@ -110,7 +110,7 @@ Unlike online trackers, which make frame-by-frame decisions, KSP Tracker leverag return detections[detections.class_id == 0] if not detections.is_empty() else detections tracked_dets = tracker.track( - source_path="", + source_path="", get_model_detections=get_model_detections ) @@ -160,7 +160,7 @@ Unlike online trackers, which make frame-by-frame decisions, KSP Tracker leverag return sv.Detections.from_transformers(results, id2label=model.config.id2label) tracked_dets = tracker.track( - "", + "", get_model_detections=get_model_detections ) From b80eca7a6202643b173967ed2cdc2f9823bc6caf Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 4 Jul 2025 01:22:54 -0400 Subject: [PATCH 081/100] OOPS: The ksp files were not added --- trackers/core/ksp/__init__.py | 0 trackers/core/ksp/solver.py | 271 ++++++++++++++++++++++++++++++++++ trackers/core/ksp/tracker.py | 219 +++++++++++++++++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 trackers/core/ksp/__init__.py create mode 100644 trackers/core/ksp/solver.py create mode 100644 trackers/core/ksp/tracker.py diff --git a/trackers/core/ksp/__init__.py b/trackers/core/ksp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py new file mode 100644 index 00000000..ad51f1a1 --- /dev/null +++ b/trackers/core/ksp/solver.py @@ -0,0 +1,271 @@ +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, List, Optional, Tuple + +import networkx as nx +import numpy as np +import supervision as sv +from tqdm.auto import tqdm + + +@dataclass(frozen=True) +class TrackNode: + """ + Represents a detection node in the tracking graph. + + Attributes: + frame_id (int): Frame index where detection occurred. + det_idx (int): Detection index in the frame. + class_id (int): Class ID of the detection. + position (tuple): Center position of the detection. + bbox (np.ndarray): Bounding box coordinates. + confidence (float): Detection confidence score. + """ + + frame_id: int + det_idx: int + class_id: int + position: tuple + bbox: np.ndarray + confidence: float + + def __hash__(self): + return hash((self.frame_id, self.det_idx)) + + def __eq__(self, other: Any): + return isinstance(other, TrackNode) and (self.frame_id, self.det_idx) == ( + other.frame_id, + other.det_idx, + ) + + def __str__(self): + return f"{self.frame_id}:{self.det_idx}@{self.position}" + + +class KSPSolver: + """ + Solver for the K-Shortest Paths (KSP) tracking problem. + Builds a graph from detections and extracts multiple disjoint paths. + """ + + def __init__( + self, + path_overlap_penalty=40, + iou_weight=0.9, + dist_weight=0.1, + size_weight=0.1, + conf_weight=0.1, + ): + """ + Initialize the KSPSolver. + + Args: + path_overlap_penalty (float): Penalty for edge reuse in successive paths. + iou_weight (float): Weight for IoU penalty. + dist_weight (float): Weight for center distance. + size_weight (float): Weight for size penalty. + conf_weight (float): Weight for confidence penalty. + """ + self.path_overlap_penalty = 40 + self.weight_key = "weight" + self.source = "SOURCE" + self.sink = "SINK" + self.detection_per_frame: List[sv.Detections] = [] + self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} + + if path_overlap_penalty is not None: + self.path_overlap_penalty = path_overlap_penalty + if iou_weight is not None: + self.weights["iou"] = iou_weight + if dist_weight is not None: + self.weights["dist"] = dist_weight + if size_weight is not None: + self.weights["size"] = size_weight + if conf_weight is not None: + self.weights["conf"] = conf_weight + + self.reset() + + def reset(self): + """ + Reset the solver state and clear all detections and graph. + This clears the detection buffer and initializes a new empty graph. + """ + self.detection_per_frame = [] + self.graph = nx.DiGraph() + + def append_frame(self, detections: sv.Detections): + """ + Add detections for a new frame to the buffer. + + Args: + detections (sv.Detections): Detections for the frame. + """ + self.detection_per_frame.append(detections) + + def _get_center(self, bbox): + """ + Compute the center of a bounding box. + + Args: + bbox (np.ndarray): Bounding box coordinates (x1, y1, x2, y2). + + Returns: + np.ndarray: Center coordinates (x, y). + """ + x1, y1, x2, y2 = bbox + return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + + def _iou(self, a, b): + """ + Compute Intersection over Union (IoU) between two bounding boxes. + + Args: + a (np.ndarray): First bounding box. + b (np.ndarray): Second bounding box. + + Returns: + float: IoU value between 0 and 1. + """ + x1, y1, x2, y2 = ( + max(a[0], b[0]), + max(a[1], b[1]), + min(a[2], b[2]), + min(a[3], b[3]), + ) + inter = max(0, x2 - x1) * max(0, y2 - y1) + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + return inter / (area_a + area_b - inter + 1e-6) + + def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): + """ + Compute the cost of connecting two detections (nodes) in the graph. + The cost is a weighted sum of IoU penalty, center distance, + size penalty, and confidence penalty. + + Args: + nodeU (TrackNode): Source node. + nodeV (TrackNode): Target node. + + Returns: + float: Edge cost. + """ + bboxU, bboxV = nodeU.bbox, nodeV.bbox + conf_u, conf_v = nodeU.confidence, nodeV.confidence + + center_dist = np.linalg.norm(self._get_center(bboxU) - self._get_center(bboxV)) + iou_penalty = 1 - self._iou(bboxU, bboxV) + + area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) + area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) + size_penalty = np.log( + (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 + ) + + conf_penalty = 1 - min(conf_u, conf_v) + + return ( + self.weights["iou"] * iou_penalty + + self.weights["dist"] * center_dist + + self.weights["size"] * size_penalty + + self.weights["conf"] * conf_penalty + ) + + def _build_graph(self): + """ + Build the tracking graph from all buffered detections. + Each detection is a node, and edges connect detections in consecutive frames. + """ + G = nx.DiGraph() + G.add_node(self.source) + G.add_node(self.sink) + + node_frames = [] + + for frame_id, detections in enumerate(self.detection_per_frame): + frame_nodes = [] + for det_idx, bbox in enumerate(detections.xyxy): + node = TrackNode( + frame_id=frame_id, + det_idx=det_idx, + class_id=int(detections.class_id[det_idx]), + position=tuple(self._get_center(bbox)), + bbox=bbox, + confidence=float(detections.confidence[det_idx]), + ) + G.add_node(node) + frame_nodes.append(node) + node_frames.append(frame_nodes) + + for t in range(len(node_frames) - 1): + for node_a in node_frames[t]: + for node_b in node_frames[t + 1]: + cost = self._edge_cost(node_a, node_b) + G.add_edge(node_a, node_b, weight=cost) + + for node in node_frames[0]: + G.add_edge(self.source, node, weight=0.0) + for node in node_frames[-1]: + G.add_edge(node, self.sink, weight=0.0) + + self.graph = G + + def solve( + self, + k: Optional[int] = None, + ) -> List[List[TrackNode]]: + """ + Extract up to k node-disjoint shortest paths from the graph using a + successive shortest path approach. + + Args: + k (Optional[int]): Maximum number of paths to extract. If None, + uses the maximum number of detections in any frame. + + Returns: + List[List[TrackNode]]: List of node-disjoint paths (tracks), + each path is a list of TrackNode objects. + """ + self._build_graph() + + G_base = self.graph.copy() + edge_reuse: defaultdict[Tuple[Any, Any], int] = defaultdict(int) + paths: List[List[TrackNode]] = [] + + if k is None: + k = max(len(f.xyxy) for f in self.detection_per_frame) + + for _i in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): + G_mod = G_base.copy() + + # Update edge weights to penalize reused edges + for u, v, data in G_mod.edges(data=True): + base = data[self.weight_key] + penalty = self.path_overlap_penalty * edge_reuse[(u, v)] * base + data[self.weight_key] = base + penalty + + try: + # Find shortest path from source to sink + _, path = nx.single_source_dijkstra( + G_mod, self.source, self.sink, weight=self.weight_key + ) + except nx.NetworkXNoPath: + print(f"No path found from source to sink at {_i}th iteration") + break + + # Check for duplicate paths + if path[1:-1] in paths: + print("Duplicate path found!") + # NOTE: Changed to continue for debugging to extrapolate the + # track detects to investigate the reason for fewer paths generated + # Change this to break when done + continue + + paths.append(path[1:-1]) + + # Mark edges in this path as reused for future penalty + for u, v in zip(path[:-1], path[1:]): + edge_reuse[(u, v)] += 1 + + return paths diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py new file mode 100644 index 00000000..e180282d --- /dev/null +++ b/trackers/core/ksp/tracker.py @@ -0,0 +1,219 @@ +import os +from collections import defaultdict +from typing import Any, Callable, List, Optional + +import cv2 +import numpy as np +import supervision as sv +from tqdm.auto import tqdm + +from trackers.core.base import BaseOfflineTracker +from trackers.core.ksp.solver import KSPSolver, TrackNode + + +class KSPTracker(BaseOfflineTracker): + """ + Offline tracker using K-Shortest Paths (KSP) algorithm. + """ + + def __init__( + self, + path_overlap_penalty: Optional[int] = None, + iou_weight: Optional[int] = None, + dist_weight: Optional[int] = None, + size_weight: Optional[int] = None, + conf_weight: Optional[int] = None, + ) -> None: + """ + Initialize the KSPTracker and its solver. + + Args: + path_overlap_penalty (Optional[int]): Penalty for reusing the same edge + (detection pairing) in multiple tracks. Increasing this value encourages + the tracker to produce more distinct, non-overlapping tracks by + discouraging shared detections between tracks. + + iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) + penalty in the edge cost. Higher values make the tracker favor linking + detections with greater spatial overlap, which helps maintain track + continuity for objects that move smoothly. + + dist_weight (Optional[int]): Weight for the Euclidean distance between + detection centers in the edge cost. Increasing this value penalizes + large jumps between detections in consecutive frames, promoting + smoother, more physically plausible tracks. + + size_weight (Optional[int]): Weight for the size difference penalty in the + edge cost. Higher values penalize linking detections with significantly + different bounding box areas, which helps prevent identity switches when + object size changes abruptly. + + conf_weight (Optional[int]): Weight for the confidence penalty in the edge + cost. Higher values penalize edges between detections with lower + confidence scores, making the tracker prefer more reliable detections + and reducing the impact of false positives. + """ + self._solver = KSPSolver( + path_overlap_penalty=path_overlap_penalty, + iou_weight=iou_weight, + dist_weight=dist_weight, + size_weight=size_weight, + conf_weight=conf_weight, + ) + self.reset() + + def reset(self) -> None: + """ + Reset the KSPTracker and its solver state. + + This clears all buffered detections and resets the underlying solver. + """ + self._solver.reset() + + def __update(self, detections: sv.Detections) -> sv.Detections: + """ + Add detections for the current frame to the solver. + + Args: + detections (sv.Detections): Detections for the current frame. + + Returns: + sv.Detections: The same detections passed in. + """ + self._solver.append_frame(detections) + return detections + + def __assign_tracker_ids_from_paths( + self, paths: List[List[TrackNode]] + ) -> List[sv.Detections]: + """ + Assigns each detection a unique tracker ID by preferring the path with + the least motion change (displacement). + + Args: + paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. + + Returns: + List[sv.Detections]: List of sv.Detections with tracker IDs assigned + for each frame. + """ + # Track where each node appears + framed_nodes = defaultdict(list) + node_to_candidates = defaultdict(list) + for tracker_id, path in enumerate(paths, start=1): + for i, node in enumerate(path): + next_node: Any = path[i + 1] if i + 1 < len(path) else None + node_to_candidates[node].append((tracker_id, next_node)) + framed_nodes[node.frame_id].append(node) + + # Select best tracker for each node based on minimal displacement + node_to_tracker = {} + for node, candidates in node_to_candidates.items(): + min_displacement = float("inf") + selected_tracker = -1 + for tracker_id, next_node in candidates: + if next_node is not None: + dx = node.position[0] - next_node.position[0] + dy = node.position[1] - next_node.position[1] + displacement = dx * dx + dy * dy # squared distance + else: + displacement = 0 # last node in path, no penalty + + if displacement < min_displacement: + min_displacement = displacement + selected_tracker = tracker_id + + node_to_tracker[node] = selected_tracker + + # Organize detections by frame + frame_to_dets = defaultdict(list) + + for node, tracker_id in node_to_tracker.items(): + frame_to_dets[node.frame_id].append( + { + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": node.class_id, + "tracker_id": tracker_id, + } + ) + + # Convert into sv.Detections + frame_to_detections = [] + for frame, dets_list in frame_to_dets.items(): + xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) + confidence = np.array( + [d["confidence"] for d in dets_list], dtype=np.float32 + ) + class_id = np.array([d["class_id"] for d in dets_list], dtype=int) + tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) + detections = sv.Detections( + xyxy=xyxy, + confidence=confidence, + class_id=class_id, + tracker_id=tracker_id, + ) + frame_to_detections.append(detections) + + return frame_to_detections + + def track( + self, + source_path: str, + get_model_detections: Callable[[np.ndarray], sv.Detections], + num_of_tracks: Optional[int] = None, + ) -> List[sv.Detections]: + """ + Run the KSP solver and assign tracker IDs to detections. + + Args: + source_path (str): Path to video file or directory of frames. + get_model_detections (Callable[[np.ndarray], sv.Detections]): + Function that takes an image (np.ndarray) and returns sv.Detections. + num_of_tracks (Optional[int]): Number of tracks to extract (K). + + Returns: + List[sv.Detections]: List of sv.Detections with tracker IDs assigned. + """ + if not source_path: + raise ValueError( + "`source_path` must be a string path to a directory or an .mp4 file." + ) + if get_model_detections is None: + raise TypeError( + "`get_model_detections` must be a callable that returns an " + "instance of `sv.Detections`." + ) + if source_path.lower().endswith(".mp4"): + frames_generator = sv.get_video_frames_generator(source_path=source_path) + video_info = sv.VideoInfo.from_video_path(video_path=source_path) + for frame in tqdm( + frames_generator, + total=video_info.total_frames, + desc="Extracting detections and buffering from video", + dynamic_ncols=True, + ): + detections = get_model_detections(frame) + self.__update(detections) + elif os.path.isdir(source_path): + frame_paths = sorted( + [ + os.path.join(source_path, f) + for f in os.listdir(source_path) + if f.lower().endswith(".jpg") + ] + ) + for frame_path in tqdm( + frame_paths, + desc="Extracting detections and buffering directory", + dynamic_ncols=True, + ): + image = cv2.imread(frame_path) + detections = get_model_detections(image) + self.__update(detections) + else: + raise ValueError(f"{source_path} not found!") + paths = self._solver.solve(num_of_tracks) + if not paths: + return [] + return self.__assign_tracker_ids_from_paths(paths) From d82f341da3d7633eefb089246d23f568100e4ddd Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Fri, 4 Jul 2025 01:30:23 -0400 Subject: [PATCH 082/100] UPDATE: Updated mkdocs --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index ad896311..e97c69b7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,4 +95,5 @@ nav: - Trackers: - SORT: trackers/core/sort/tracker.md - DeepSORT: trackers/core/deepsort/tracker.md + - KSP: trackers/core/ksp/tracker.md - ReID: trackers/core/reid/reid.md From 6fe4324b564565ee5554926ea53187394b933de6 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sat, 5 Jul 2025 00:04:43 -0400 Subject: [PATCH 083/100] UPDATE: Changes for code review --- trackers/core/ksp/solver.py | 42 +++++++++--------------------------- trackers/core/ksp/tracker.py | 29 +++++++++++++------------ 2 files changed, 25 insertions(+), 46 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index ad51f1a1..af0aee5c 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -50,11 +50,11 @@ class KSPSolver: def __init__( self, - path_overlap_penalty=40, - iou_weight=0.9, - dist_weight=0.1, - size_weight=0.1, - conf_weight=0.1, + path_overlap_penalty: float = 40, + iou_weight: float = 0.9, + dist_weight: float = 0.1, + size_weight: float = 0.1, + conf_weight: float = 0.1, ): """ Initialize the KSPSolver. @@ -86,7 +86,7 @@ def __init__( self.reset() - def reset(self): + def reset(self) -> None: """ Reset the solver state and clear all detections and graph. This clears the detection buffer and initializes a new empty graph. @@ -94,7 +94,7 @@ def reset(self): self.detection_per_frame = [] self.graph = nx.DiGraph() - def append_frame(self, detections: sv.Detections): + def append_frame(self, detections: sv.Detections) -> None: """ Add detections for a new frame to the buffer. @@ -103,7 +103,7 @@ def append_frame(self, detections: sv.Detections): """ self.detection_per_frame.append(detections) - def _get_center(self, bbox): + def _get_center(self, bbox: np.ndarray) -> np.ndarray: """ Compute the center of a bounding box. @@ -116,29 +116,7 @@ def _get_center(self, bbox): x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) - def _iou(self, a, b): - """ - Compute Intersection over Union (IoU) between two bounding boxes. - - Args: - a (np.ndarray): First bounding box. - b (np.ndarray): Second bounding box. - - Returns: - float: IoU value between 0 and 1. - """ - x1, y1, x2, y2 = ( - max(a[0], b[0]), - max(a[1], b[1]), - min(a[2], b[2]), - min(a[3], b[3]), - ) - inter = max(0, x2 - x1) * max(0, y2 - y1) - area_a = (a[2] - a[0]) * (a[3] - a[1]) - area_b = (b[2] - b[0]) * (b[3] - b[1]) - return inter / (area_a + area_b - inter + 1e-6) - - def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): + def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: """ Compute the cost of connecting two detections (nodes) in the graph. The cost is a weighted sum of IoU penalty, center distance, @@ -155,7 +133,7 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode): conf_u, conf_v = nodeU.confidence, nodeV.confidence center_dist = np.linalg.norm(self._get_center(bboxU) - self._get_center(bboxV)) - iou_penalty = 1 - self._iou(bboxU, bboxV) + iou_penalty = 1 - sv.box_iou_batch(np.array([bboxU]), np.array([bboxV])) area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index e180282d..21da5bc6 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -5,6 +5,7 @@ import cv2 import numpy as np import supervision as sv +import PIL from tqdm.auto import tqdm from trackers.core.base import BaseOfflineTracker @@ -70,7 +71,7 @@ def reset(self) -> None: """ self._solver.reset() - def __update(self, detections: sv.Detections) -> sv.Detections: + def _update(self, detections: sv.Detections) -> sv.Detections: """ Add detections for the current frame to the solver. @@ -83,7 +84,7 @@ def __update(self, detections: sv.Detections) -> sv.Detections: self._solver.append_frame(detections) return detections - def __assign_tracker_ids_from_paths( + def _assign_tracker_ids_from_paths( self, paths: List[List[TrackNode]] ) -> List[sv.Detections]: """ @@ -159,7 +160,7 @@ def __assign_tracker_ids_from_paths( def track( self, - source_path: str, + source: str | List[PIL.Image.Image], get_model_detections: Callable[[np.ndarray], sv.Detections], num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: @@ -175,7 +176,7 @@ def track( Returns: List[sv.Detections]: List of sv.Detections with tracker IDs assigned. """ - if not source_path: + if not source: raise ValueError( "`source_path` must be a string path to a directory or an .mp4 file." ) @@ -184,9 +185,9 @@ def track( "`get_model_detections` must be a callable that returns an " "instance of `sv.Detections`." ) - if source_path.lower().endswith(".mp4"): - frames_generator = sv.get_video_frames_generator(source_path=source_path) - video_info = sv.VideoInfo.from_video_path(video_path=source_path) + if source.lower().endswith(".mp4"): + frames_generator = sv.get_video_frames_generator(source_path=source) + video_info = sv.VideoInfo.from_video_path(video_path=source) for frame in tqdm( frames_generator, total=video_info.total_frames, @@ -194,12 +195,12 @@ def track( dynamic_ncols=True, ): detections = get_model_detections(frame) - self.__update(detections) - elif os.path.isdir(source_path): + self._update(detections) + elif os.path.isdir(source): frame_paths = sorted( [ - os.path.join(source_path, f) - for f in os.listdir(source_path) + os.path.join(source, f) + for f in os.listdir(source) if f.lower().endswith(".jpg") ] ) @@ -210,10 +211,10 @@ def track( ): image = cv2.imread(frame_path) detections = get_model_detections(image) - self.__update(detections) + self._update(detections) else: - raise ValueError(f"{source_path} not found!") + raise ValueError(f"{source} not a valid path or list of PIL.Image.Image.") paths = self._solver.solve(num_of_tracks) if not paths: return [] - return self.__assign_tracker_ids_from_paths(paths) + return self._assign_tracker_ids_from_paths(paths) From 0a6869d9fea0bb0a5be7ec6719fe8c0a1b01ff3c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Jul 2025 04:05:03 +0000 Subject: [PATCH 084/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 21da5bc6..e8f80b6f 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -4,8 +4,8 @@ import cv2 import numpy as np -import supervision as sv import PIL +import supervision as sv from tqdm.auto import tqdm from trackers.core.base import BaseOfflineTracker From 35c06e02851e7e795fa47dd6053f2080989b880f Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 01:25:21 -0400 Subject: [PATCH 085/100] ADD: Added doors --- trackers/core/ksp/solver.py | 18 +++++++++- trackers/core/ksp/tracker.py | 69 ++++++++++++------------------------ 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index af0aee5c..66e30200 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -115,6 +115,18 @@ def _get_center(self, bbox: np.ndarray) -> np.ndarray: """ x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + + def _in_door(self, node: TrackNode): + x, y = node.position + width, height = (1920 , 1080) + + border_margin = 40 + in_border = ( + x <= border_margin or x >= width - border_margin or + y <= border_margin or y >= height - border_margin + ) + + return in_border def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: """ @@ -178,6 +190,10 @@ def _build_graph(self): for t in range(len(node_frames) - 1): for node_a in node_frames[t]: + if self._in_door(node_a): + G.add_edge(self.source, node_a, weight=(t) * 2) + G.add_edge(node_a, self.sink, weight=((len(node_frames) - 1) - (t)) * 2) + for node_b in node_frames[t + 1]: cost = self._edge_cost(node_a, node_b) G.add_edge(node_a, node_b, weight=cost) @@ -220,7 +236,7 @@ def solve( # Update edge weights to penalize reused edges for u, v, data in G_mod.edges(data=True): base = data[self.weight_key] - penalty = self.path_overlap_penalty * edge_reuse[(u, v)] * base + penalty = self.path_overlap_penalty * 1000 * edge_reuse[(u, v)] * base data[self.weight_key] = base + penalty try: diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index e8f80b6f..4feef7f5 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -88,8 +88,7 @@ def _assign_tracker_ids_from_paths( self, paths: List[List[TrackNode]] ) -> List[sv.Detections]: """ - Assigns each detection a unique tracker ID by preferring the path with - the least motion change (displacement). + Assigns each detection a unique tracker ID directly from node-disjoint paths. Args: paths (List[List[TrackNode]]): List of tracks, each a list of TrackNode. @@ -98,56 +97,30 @@ def _assign_tracker_ids_from_paths( List[sv.Detections]: List of sv.Detections with tracker IDs assigned for each frame. """ - # Track where each node appears - framed_nodes = defaultdict(list) - node_to_candidates = defaultdict(list) - for tracker_id, path in enumerate(paths, start=1): - for i, node in enumerate(path): - next_node: Any = path[i + 1] if i + 1 < len(path) else None - node_to_candidates[node].append((tracker_id, next_node)) - framed_nodes[node.frame_id].append(node) - - # Select best tracker for each node based on minimal displacement - node_to_tracker = {} - for node, candidates in node_to_candidates.items(): - min_displacement = float("inf") - selected_tracker = -1 - for tracker_id, next_node in candidates: - if next_node is not None: - dx = node.position[0] - next_node.position[0] - dy = node.position[1] - next_node.position[1] - displacement = dx * dx + dy * dy # squared distance - else: - displacement = 0 # last node in path, no penalty - - if displacement < min_displacement: - min_displacement = displacement - selected_tracker = tracker_id - - node_to_tracker[node] = selected_tracker - - # Organize detections by frame + # Map from frame to list of dicts with detection info + tracker_id frame_to_dets = defaultdict(list) - for node, tracker_id in node_to_tracker.items(): - frame_to_dets[node.frame_id].append( - { - "xyxy": node.bbox, - "confidence": node.confidence, - "class_id": node.class_id, - "tracker_id": tracker_id, - } - ) - - # Convert into sv.Detections + # Assign each node a unique tracker ID (path index + 1) + for tracker_id, path in enumerate(paths, start=1): + for node in path: + frame_to_dets[node.frame_id].append( + { + "xyxy": node.bbox, + "confidence": node.confidence, + "class_id": node.class_id, + "tracker_id": tracker_id, + } + ) + + # Convert detections per frame into sv.Detections objects frame_to_detections = [] - for frame, dets_list in frame_to_dets.items(): + for frame in sorted(frame_to_dets.keys()): + dets_list = frame_to_dets[frame] xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) - confidence = np.array( - [d["confidence"] for d in dets_list], dtype=np.float32 - ) + confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) class_id = np.array([d["class_id"] for d in dets_list], dtype=int) tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) + detections = sv.Detections( xyxy=xyxy, confidence=confidence, @@ -202,7 +175,7 @@ def track( os.path.join(source, f) for f in os.listdir(source) if f.lower().endswith(".jpg") - ] + ][:100] ) for frame_path in tqdm( frame_paths, @@ -215,6 +188,8 @@ def track( else: raise ValueError(f"{source} not a valid path or list of PIL.Image.Image.") paths = self._solver.solve(num_of_tracks) + for i in paths: + print(len(i)) if not paths: return [] return self._assign_tracker_ids_from_paths(paths) From ee98e510579d0490c6f1a002b19ddce45b80dcd2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 05:29:55 +0000 Subject: [PATCH 086/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/solver.py | 14 +++++++++----- trackers/core/ksp/tracker.py | 6 ++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index 66e30200..6152e947 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -115,15 +115,17 @@ def _get_center(self, bbox: np.ndarray) -> np.ndarray: """ x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) - + def _in_door(self, node: TrackNode): x, y = node.position - width, height = (1920 , 1080) + width, height = (1920, 1080) border_margin = 40 in_border = ( - x <= border_margin or x >= width - border_margin or - y <= border_margin or y >= height - border_margin + x <= border_margin + or x >= width - border_margin + or y <= border_margin + or y >= height - border_margin ) return in_border @@ -192,7 +194,9 @@ def _build_graph(self): for node_a in node_frames[t]: if self._in_door(node_a): G.add_edge(self.source, node_a, weight=(t) * 2) - G.add_edge(node_a, self.sink, weight=((len(node_frames) - 1) - (t)) * 2) + G.add_edge( + node_a, self.sink, weight=((len(node_frames) - 1) - (t)) * 2 + ) for node_b in node_frames[t + 1]: cost = self._edge_cost(node_a, node_b) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 4feef7f5..4367e8df 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -1,6 +1,6 @@ import os from collections import defaultdict -from typing import Any, Callable, List, Optional +from typing import Callable, List, Optional import cv2 import numpy as np @@ -117,7 +117,9 @@ def _assign_tracker_ids_from_paths( for frame in sorted(frame_to_dets.keys()): dets_list = frame_to_dets[frame] xyxy = np.array([d["xyxy"] for d in dets_list], dtype=np.float32) - confidence = np.array([d["confidence"] for d in dets_list], dtype=np.float32) + confidence = np.array( + [d["confidence"] for d in dets_list], dtype=np.float32 + ) class_id = np.array([d["class_id"] for d in dets_list], dtype=int) tracker_id = np.array([d["tracker_id"] for d in dets_list], dtype=int) From ceb02182649574bb701fdf72f0d8a44725bbb7e6 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 02:52:50 -0400 Subject: [PATCH 087/100] ADD: Added customizable doors and frame doors --- trackers/core/ksp/solver.py | 166 +++++++++++++---------------------- trackers/core/ksp/tracker.py | 83 ++++++++++++++++-- 2 files changed, 137 insertions(+), 112 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index 6152e947..ab63f8e0 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -1,6 +1,6 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Any, List, Optional, Tuple +from typing import Any, List, Optional, Tuple, Set import networkx as nx import numpy as np @@ -10,18 +10,6 @@ @dataclass(frozen=True) class TrackNode: - """ - Represents a detection node in the tracking graph. - - Attributes: - frame_id (int): Frame index where detection occurred. - det_idx (int): Detection index in the frame. - class_id (int): Class ID of the detection. - position (tuple): Center position of the detection. - bbox (np.ndarray): Bounding box coordinates. - confidence (float): Detection confidence score. - """ - frame_id: int det_idx: int class_id: int @@ -43,11 +31,6 @@ def __str__(self): class KSPSolver: - """ - Solver for the K-Shortest Paths (KSP) tracking problem. - Builds a graph from detections and extracts multiple disjoint paths. - """ - def __init__( self, path_overlap_penalty: float = 40, @@ -56,17 +39,7 @@ def __init__( size_weight: float = 0.1, conf_weight: float = 0.1, ): - """ - Initialize the KSPSolver. - - Args: - path_overlap_penalty (float): Penalty for edge reuse in successive paths. - iou_weight (float): Weight for IoU penalty. - dist_weight (float): Weight for center distance. - size_weight (float): Weight for size penalty. - conf_weight (float): Weight for confidence penalty. - """ - self.path_overlap_penalty = 40 + self.path_overlap_penalty = path_overlap_penalty if path_overlap_penalty is not None else 40 self.weight_key = "weight" self.source = "SOURCE" self.sink = "SINK" @@ -84,65 +57,80 @@ def __init__( if conf_weight is not None: self.weights["conf"] = conf_weight + # Entry/exit region settings + self.entry_exit_regions: List[Tuple[int, int, int, int]] = [] # (x1, y1, x2, y2) + + # Border region settings + self.use_border_regions = True + self.active_borders: Set[str] = {"left", "right", "top", "bottom"} + self.border_margin = 40 + self.frame_size = (1920, 1080) + self.reset() def reset(self) -> None: - """ - Reset the solver state and clear all detections and graph. - This clears the detection buffer and initializes a new empty graph. - """ self.detection_per_frame = [] self.graph = nx.DiGraph() def append_frame(self, detections: sv.Detections) -> None: - """ - Add detections for a new frame to the buffer. - - Args: - detections (sv.Detections): Detections for the frame. - """ self.detection_per_frame.append(detections) def _get_center(self, bbox: np.ndarray) -> np.ndarray: + x1, y1, x2, y2 = bbox + return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + + def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: + """ + Set rectangular entry/exit zones (x1, y1, x2, y2). """ - Compute the center of a bounding box. + self.entry_exit_regions = regions - Args: - bbox (np.ndarray): Bounding box coordinates (x1, y1, x2, y2). + def set_border_entry_exit( + self, + use_border: Optional[bool] = True, + borders: Optional[Set[str]] = None, + margin: Optional[int] = 40, + frame_size: Optional[Tuple[int, int]] = (1920, 1080), + ) -> None: + """ + Configure border-based entry/exit zones. - Returns: - np.ndarray: Center coordinates (x, y). + Args: + use_border (bool): Enable/disable border-based entry/exit. + borders (set): Set of borders to use. {"left", "right", "top", "bottom"} + margin (int): Border thickness in pixels. + frame_size (Tuple[int, int]): Size of the image (width, height). """ - x1, y1, x2, y2 = bbox - return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) + self.use_border_regions = use_border + self.active_borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.border_margin = margin + self.frame_size = frame_size - def _in_door(self, node: TrackNode): + def _in_door(self, node: TrackNode) -> bool: x, y = node.position - width, height = (1920, 1080) - - border_margin = 40 - in_border = ( - x <= border_margin - or x >= width - border_margin - or y <= border_margin - or y >= height - border_margin - ) - return in_border + # Check custom rectangular regions + for x1, y1, x2, y2 in self.entry_exit_regions: + if x1 <= x <= x2 and y1 <= y <= y2: + return True - def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: - """ - Compute the cost of connecting two detections (nodes) in the graph. - The cost is a weighted sum of IoU penalty, center distance, - size penalty, and confidence penalty. + # Check image border zones + if self.use_border_regions: + width, height = self.frame_size + m = self.border_margin - Args: - nodeU (TrackNode): Source node. - nodeV (TrackNode): Target node. + if "left" in self.active_borders and x <= m: + return True + if "right" in self.active_borders and x >= width - m: + return True + if "top" in self.active_borders and y <= m: + return True + if "bottom" in self.active_borders and y >= height - m: + return True - Returns: - float: Edge cost. - """ + return False + + def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: bboxU, bboxV = nodeU.bbox, nodeV.bbox conf_u, conf_v = nodeU.confidence, nodeV.confidence @@ -151,9 +139,7 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) - size_penalty = np.log( - (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 - ) + size_penalty = np.log((max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6) conf_penalty = 1 - min(conf_u, conf_v) @@ -165,10 +151,6 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: ) def _build_graph(self): - """ - Build the tracking graph from all buffered detections. - Each detection is a node, and edges connect detections in consecutive frames. - """ G = nx.DiGraph() G.add_node(self.source) G.add_node(self.sink) @@ -193,10 +175,8 @@ def _build_graph(self): for t in range(len(node_frames) - 1): for node_a in node_frames[t]: if self._in_door(node_a): - G.add_edge(self.source, node_a, weight=(t) * 2) - G.add_edge( - node_a, self.sink, weight=((len(node_frames) - 1) - (t)) * 2 - ) + G.add_edge(self.source, node_a, weight=t * 2) + G.add_edge(node_a, self.sink, weight=(len(node_frames) - 1 - t) * 2) for node_b in node_frames[t + 1]: cost = self._edge_cost(node_a, node_b) @@ -209,22 +189,7 @@ def _build_graph(self): self.graph = G - def solve( - self, - k: Optional[int] = None, - ) -> List[List[TrackNode]]: - """ - Extract up to k node-disjoint shortest paths from the graph using a - successive shortest path approach. - - Args: - k (Optional[int]): Maximum number of paths to extract. If None, - uses the maximum number of detections in any frame. - - Returns: - List[List[TrackNode]]: List of node-disjoint paths (tracks), - each path is a list of TrackNode objects. - """ + def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: self._build_graph() G_base = self.graph.copy() @@ -237,32 +202,23 @@ def solve( for _i in tqdm(range(k), desc="Extracting k-shortest paths", leave=True): G_mod = G_base.copy() - # Update edge weights to penalize reused edges for u, v, data in G_mod.edges(data=True): base = data[self.weight_key] penalty = self.path_overlap_penalty * 1000 * edge_reuse[(u, v)] * base data[self.weight_key] = base + penalty try: - # Find shortest path from source to sink - _, path = nx.single_source_dijkstra( - G_mod, self.source, self.sink, weight=self.weight_key - ) + _, path = nx.single_source_dijkstra(G_mod, self.source, self.sink, weight=self.weight_key) except nx.NetworkXNoPath: print(f"No path found from source to sink at {_i}th iteration") break - # Check for duplicate paths if path[1:-1] in paths: print("Duplicate path found!") - # NOTE: Changed to continue for debugging to extrapolate the - # track detects to investigate the reason for fewer paths generated - # Change this to break when done continue paths.append(path[1:-1]) - # Mark edges in this path as reused for future penalty for u, v in zip(path[:-1], path[1:]): edge_reuse[(u, v)] += 1 diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 4367e8df..28441c93 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -1,6 +1,6 @@ import os from collections import defaultdict -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Tuple, Set import cv2 import numpy as np @@ -19,11 +19,16 @@ class KSPTracker(BaseOfflineTracker): def __init__( self, - path_overlap_penalty: Optional[int] = None, - iou_weight: Optional[int] = None, - dist_weight: Optional[int] = None, - size_weight: Optional[int] = None, - conf_weight: Optional[int] = None, + path_overlap_penalty: Optional[int] = 40, + iou_weight: Optional[int] = 0.9, + dist_weight: Optional[int] = 0.1, + size_weight: Optional[int] = 0.1, + conf_weight: Optional[int] = 0.1, + entry_exit_regions: Optional[List[Tuple[int, int, int, int]]] = None, + use_border: Optional[bool] = True, + borders: Optional[Set[str]] = None, + border_margin: Optional[int] = 40, + frame_size: Optional[Tuple[int, int]] = (1920, 1080), ) -> None: """ Initialize the KSPTracker and its solver. @@ -53,7 +58,18 @@ def __init__( cost. Higher values penalize edges between detections with lower confidence scores, making the tracker prefer more reliable detections and reducing the impact of false positives. + + entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of rectangular entry/exit regions. + use_border (Optional[bool]): Enable/disable border-based entry/exit. + borders (Optional[Set[str]]): Set of borders to use. {"left", "right", "top", "bottom"} + border_margin (Optional[int]): Border thickness in pixels. + frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). """ + self.entry_exit_regions = entry_exit_regions if entry_exit_regions is not None else [] + self.use_border = use_border + self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.border_margin = border_margin + self.frame_size = frame_size self._solver = KSPSolver( path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, @@ -61,6 +77,13 @@ def __init__( size_weight=size_weight, conf_weight=conf_weight, ) + self._solver.set_entry_exit_regions(self.entry_exit_regions) + self._solver.set_border_entry_exit( + use_border=self.use_border, + borders=self.borders, + margin=self.border_margin, + frame_size=self.frame_size, + ) self.reset() def reset(self) -> None: @@ -83,6 +106,31 @@ def _update(self, detections: sv.Detections) -> sv.Detections: """ self._solver.append_frame(detections) return detections + + def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: + """ + Set rectangular entry/exit zones (x1, y1, x2, y2). + """ + self.entry_exit_regions = regions + self._solver.set_entry_exit_regions(regions) + + def set_border_entry_exit( + self, + use_border: Optional[bool] = True, + borders: Optional[Set[str]] = None, + margin: Optional[int] = 40, + frame_size: Optional[Tuple[int, int]] = (1920, 1080), + ) -> None: + self.use_border = use_border + self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.border_margin = margin + self.frame_size = frame_size + self._solver.set_border_entry_exit( + use_border=self.use_border, + borders=self.borders, + margin=self.border_margin, + frame_size=self.frame_size, + ) def _assign_tracker_ids_from_paths( self, paths: List[List[TrackNode]] @@ -163,6 +211,14 @@ def track( if source.lower().endswith(".mp4"): frames_generator = sv.get_video_frames_generator(source_path=source) video_info = sv.VideoInfo.from_video_path(video_path=source) + + self._solver.set_border_entry_exit( + self.use_border, + self.borders, + self.border_margin, + (video_info.width, video_info.height) + ) + for frame in tqdm( frames_generator, total=video_info.total_frames, @@ -177,14 +233,27 @@ def track( os.path.join(source, f) for f in os.listdir(source) if f.lower().endswith(".jpg") - ][:100] + ] ) + + has_set_frame_size = False + for frame_path in tqdm( frame_paths, desc="Extracting detections and buffering directory", dynamic_ncols=True, ): image = cv2.imread(frame_path) + height, width = image.shape[:2] + + if not has_set_frame_size: + self._solver.set_border_entry_exit( + self.use_border, + self.borders, + self.border_margin, + (width, height) + ) + detections = get_model_detections(image) self._update(detections) else: From 1bf7d8e848d896b69ea270d8ea16a54ff57b2804 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 06:53:06 +0000 Subject: [PATCH 088/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/solver.py | 22 +++++++++++++++------ trackers/core/ksp/tracker.py | 38 +++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index ab63f8e0..e92c27ca 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -1,6 +1,6 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Any, List, Optional, Tuple, Set +from typing import Any, List, Optional, Set, Tuple import networkx as nx import numpy as np @@ -39,7 +39,9 @@ def __init__( size_weight: float = 0.1, conf_weight: float = 0.1, ): - self.path_overlap_penalty = path_overlap_penalty if path_overlap_penalty is not None else 40 + self.path_overlap_penalty = ( + path_overlap_penalty if path_overlap_penalty is not None else 40 + ) self.weight_key = "weight" self.source = "SOURCE" self.sink = "SINK" @@ -58,7 +60,9 @@ def __init__( self.weights["conf"] = conf_weight # Entry/exit region settings - self.entry_exit_regions: List[Tuple[int, int, int, int]] = [] # (x1, y1, x2, y2) + self.entry_exit_regions: List[ + Tuple[int, int, int, int] + ] = [] # (x1, y1, x2, y2) # Border region settings self.use_border_regions = True @@ -102,7 +106,9 @@ def set_border_entry_exit( frame_size (Tuple[int, int]): Size of the image (width, height). """ self.use_border_regions = use_border - self.active_borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.active_borders = ( + borders if borders is not None else {"left", "right", "top", "bottom"} + ) self.border_margin = margin self.frame_size = frame_size @@ -139,7 +145,9 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: area_a = (bboxU[2] - bboxU[0]) * (bboxU[3] - bboxU[1]) area_b = (bboxV[2] - bboxV[0]) * (bboxV[3] - bboxV[1]) - size_penalty = np.log((max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6) + size_penalty = np.log( + (max(area_a, area_b) / (min(area_a, area_b) + 1e-6)) + 1e-6 + ) conf_penalty = 1 - min(conf_u, conf_v) @@ -208,7 +216,9 @@ def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: data[self.weight_key] = base + penalty try: - _, path = nx.single_source_dijkstra(G_mod, self.source, self.sink, weight=self.weight_key) + _, path = nx.single_source_dijkstra( + G_mod, self.source, self.sink, weight=self.weight_key + ) except nx.NetworkXNoPath: print(f"No path found from source to sink at {_i}th iteration") break diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 28441c93..11050d6c 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -1,6 +1,6 @@ import os from collections import defaultdict -from typing import Callable, List, Optional, Tuple, Set +from typing import Callable, List, Optional, Set, Tuple import cv2 import numpy as np @@ -65,9 +65,13 @@ def __init__( border_margin (Optional[int]): Border thickness in pixels. frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). """ - self.entry_exit_regions = entry_exit_regions if entry_exit_regions is not None else [] + self.entry_exit_regions = ( + entry_exit_regions if entry_exit_regions is not None else [] + ) self.use_border = use_border - self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.borders = ( + borders if borders is not None else {"left", "right", "top", "bottom"} + ) self.border_margin = border_margin self.frame_size = frame_size self._solver = KSPSolver( @@ -106,7 +110,7 @@ def _update(self, detections: sv.Detections) -> sv.Detections: """ self._solver.append_frame(detections) return detections - + def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: """ Set rectangular entry/exit zones (x1, y1, x2, y2). @@ -122,7 +126,9 @@ def set_border_entry_exit( frame_size: Optional[Tuple[int, int]] = (1920, 1080), ) -> None: self.use_border = use_border - self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.borders = ( + borders if borders is not None else {"left", "right", "top", "bottom"} + ) self.border_margin = margin self.frame_size = frame_size self._solver.set_border_entry_exit( @@ -211,12 +217,12 @@ def track( if source.lower().endswith(".mp4"): frames_generator = sv.get_video_frames_generator(source_path=source) video_info = sv.VideoInfo.from_video_path(video_path=source) - + self._solver.set_border_entry_exit( - self.use_border, - self.borders, - self.border_margin, - (video_info.width, video_info.height) + self.use_border, + self.borders, + self.border_margin, + (video_info.width, video_info.height), ) for frame in tqdm( @@ -248,12 +254,12 @@ def track( if not has_set_frame_size: self._solver.set_border_entry_exit( - self.use_border, - self.borders, - self.border_margin, - (width, height) - ) - + self.use_border, + self.borders, + self.border_margin, + (width, height), + ) + detections = get_model_detections(image) self._update(detections) else: From f521b474647954f64da2f79f07e68b2b174b3e01 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:02:26 -0400 Subject: [PATCH 089/100] UPDATE: Removed debug path lens --- trackers/core/ksp/tracker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 11050d6c..4ab0324e 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -265,8 +265,7 @@ def track( else: raise ValueError(f"{source} not a valid path or list of PIL.Image.Image.") paths = self._solver.solve(num_of_tracks) - for i in paths: - print(len(i)) + if not paths: return [] return self._assign_tracker_ids_from_paths(paths) From 8f1cc520dacf97402bd0df4bb9b09ceb122bfbf4 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:07:34 -0400 Subject: [PATCH 090/100] UPDATE: Docstrings --- trackers/core/ksp/solver.py | 66 +++++++++++++++++++++-- trackers/core/ksp/tracker.py | 101 +++++++++++++++++++---------------- 2 files changed, 117 insertions(+), 50 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index e92c27ca..dc2934ed 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -73,19 +73,40 @@ def __init__( self.reset() def reset(self) -> None: + """ + Reset the solver state, clearing all buffered detections and the graph. + """ self.detection_per_frame = [] self.graph = nx.DiGraph() def append_frame(self, detections: sv.Detections) -> None: + """ + Add detections for the current frame to the solver's buffer. + + Args: + detections (sv.Detections): Detections for the current frame. + """ self.detection_per_frame.append(detections) def _get_center(self, bbox: np.ndarray) -> np.ndarray: + """ + Compute the center (x, y) of a bounding box. + + Args: + bbox (np.ndarray): Bounding box as [x1, y1, x2, y2]. + + Returns: + np.ndarray: Center coordinates as (x, y). + """ x1, y1, x2, y2 = bbox return np.array([(x1 + x2) / 2, (y1 + y2) / 2]) def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: """ Set rectangular entry/exit zones (x1, y1, x2, y2). + + Args: + regions (List[Tuple[int, int, int, int]]): List of rectangular regions. """ self.entry_exit_regions = regions @@ -101,9 +122,9 @@ def set_border_entry_exit( Args: use_border (bool): Enable/disable border-based entry/exit. - borders (set): Set of borders to use. {"left", "right", "top", "bottom"} - margin (int): Border thickness in pixels. - frame_size (Tuple[int, int]): Size of the image (width, height). + borders (Optional[Set[str]]): Set of borders to use. + margin (Optional[int]): Border thickness in pixels. + frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). """ self.use_border_regions = use_border self.active_borders = ( @@ -113,6 +134,15 @@ def set_border_entry_exit( self.frame_size = frame_size def _in_door(self, node: TrackNode) -> bool: + """ + Check if a node is inside any entry/exit region (rectangular or border). + + Args: + node (TrackNode): The node to check. + + Returns: + bool: True if in any entry/exit region, else False. + """ x, y = node.position # Check custom rectangular regions @@ -137,6 +167,16 @@ def _in_door(self, node: TrackNode) -> bool: return False def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: + """ + Compute the cost of linking two detections (nodes) in the graph. + + Args: + nodeU (TrackNode): Source node. + nodeV (TrackNode): Destination node. + + Returns: + float: Edge cost based on IoU, distance, size, and confidence weights. + """ bboxU, bboxV = nodeU.bbox, nodeV.bbox conf_u, conf_v = nodeU.confidence, nodeV.confidence @@ -159,6 +199,10 @@ def _edge_cost(self, nodeU: TrackNode, nodeV: TrackNode) -> float: ) def _build_graph(self): + """ + Build the directed graph of detections for KSP computation. + Nodes represent detections; edges represent possible associations. + """ G = nx.DiGraph() G.add_node(self.source) G.add_node(self.sink) @@ -198,6 +242,22 @@ def _build_graph(self): self.graph = G def solve(self, k: Optional[int] = None) -> List[List[TrackNode]]: + """ + Solve the K-Shortest Paths problem on the constructed detection graph. + + This method extracts up to k node-disjoint paths from the source to the sink in + the detection graph, assigning each path as a unique object track. Edge reuse is + penalized to encourage distinct tracks. The cost of each edge is determined by + the configured weights for IoU, distance, size, and confidence. + + Args: + k (Optional[int]): The number of tracks (paths) to extract. If None, uses + the maximum number of detections in any frame as the default. + + Returns: + List[List[TrackNode]]: A list of tracks, each track is a list of TrackNode + objects representing the detections assigned to that track. + """ self._build_graph() G_base = self.graph.copy() diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 4ab0324e..11a78494 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -20,10 +20,10 @@ class KSPTracker(BaseOfflineTracker): def __init__( self, path_overlap_penalty: Optional[int] = 40, - iou_weight: Optional[int] = 0.9, - dist_weight: Optional[int] = 0.1, - size_weight: Optional[int] = 0.1, - conf_weight: Optional[int] = 0.1, + iou_weight: Optional[float] = 0.9, + dist_weight: Optional[float] = 0.1, + size_weight: Optional[float] = 0.1, + conf_weight: Optional[float] = 0.1, entry_exit_regions: Optional[List[Tuple[int, int, int, int]]] = None, use_border: Optional[bool] = True, borders: Optional[Set[str]] = None, @@ -31,49 +31,46 @@ def __init__( frame_size: Optional[Tuple[int, int]] = (1920, 1080), ) -> None: """ - Initialize the KSPTracker and its solver. + Initialize the KSPTracker and its underlying solver with region and cost configuration. Args: - path_overlap_penalty (Optional[int]): Penalty for reusing the same edge - (detection pairing) in multiple tracks. Increasing this value encourages - the tracker to produce more distinct, non-overlapping tracks by - discouraging shared detections between tracks. - - iou_weight (Optional[int]): Weight for the Intersection-over-Union (IoU) - penalty in the edge cost. Higher values make the tracker favor linking - detections with greater spatial overlap, which helps maintain track - continuity for objects that move smoothly. - - dist_weight (Optional[int]): Weight for the Euclidean distance between - detection centers in the edge cost. Increasing this value penalizes - large jumps between detections in consecutive frames, promoting - smoother, more physically plausible tracks. - - size_weight (Optional[int]): Weight for the size difference penalty in the - edge cost. Higher values penalize linking detections with significantly - different bounding box areas, which helps prevent identity switches when - object size changes abruptly. - - conf_weight (Optional[int]): Weight for the confidence penalty in the edge - cost. Higher values penalize edges between detections with lower - confidence scores, making the tracker prefer more reliable detections - and reducing the impact of false positives. - - entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of rectangular entry/exit regions. - use_border (Optional[bool]): Enable/disable border-based entry/exit. - borders (Optional[Set[str]]): Set of borders to use. {"left", "right", "top", "bottom"} - border_margin (Optional[int]): Border thickness in pixels. - frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). + path_overlap_penalty (Optional[int]): Penalty for reusing the same edge (detection pairing) in multiple tracks. + Higher values encourage the tracker to produce more distinct, non-overlapping tracks by discouraging shared + detections between tracks. Default is 40. + iou_weight (Optional[float]): Weight for the Intersection-over-Union (IoU) penalty in the edge cost. + Higher values make the tracker favor linking detections with greater spatial overlap, which helps maintain + track continuity for objects that move smoothly. Default is 0.9. + dist_weight (Optional[float]): Weight for the Euclidean distance between detection centers in the edge cost. + Increasing this value penalizes large jumps between detections in consecutive frames, promoting smoother, + more physically plausible tracks. Default is 0.1. + size_weight (Optional[float]): Weight for the size difference penalty in the edge cost. + Higher values penalize linking detections with significantly different bounding box areas, which helps + prevent identity switches when object size changes abruptly. Default is 0.1. + conf_weight (Optional[float]): Weight for the confidence penalty in the edge cost. + Higher values penalize edges between detections with lower confidence scores, making the tracker prefer + more reliable detections and reducing the impact of false positives. Default is 0.1. + entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of rectangular entry/exit regions, + each defined as (x1, y1, x2, y2) in pixel coordinates. These regions are used to determine when objects + enter or exit the scene. Default is an empty list. + use_border (Optional[bool]): Whether to enable border-based entry/exit logic. If True, objects entering or + exiting through the image borders (as defined by `borders` and `border_margin`) are considered for + entry/exit events. Default is True. + borders (Optional[Set[str]]): Set of border sides to use for entry/exit logic. Valid values are any subset + of {"left", "right", "top", "bottom"}. Default is all four borders. + border_margin (Optional[int]): Thickness of the border region (in pixels) used for entry/exit detection. + Default is 40. + frame_size (Optional[Tuple[int, int]]): Size of the image frames as (width, height). Used to determine + border region extents. Default is (1920, 1080). """ - self.entry_exit_regions = ( + self.entry_exit_regions: List[Tuple[int, int, int, int]] = ( entry_exit_regions if entry_exit_regions is not None else [] ) - self.use_border = use_border - self.borders = ( + self.use_border: bool = use_border if use_border is not None else True + self.borders: Set[str] = ( borders if borders is not None else {"left", "right", "top", "bottom"} ) - self.border_margin = border_margin - self.frame_size = frame_size + self.border_margin: int = border_margin if border_margin is not None else 40 + self.frame_size: Tuple[int, int] = frame_size if frame_size is not None else (1920, 1080) self._solver = KSPSolver( path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, @@ -113,7 +110,10 @@ def _update(self, detections: sv.Detections) -> sv.Detections: def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: """ - Set rectangular entry/exit zones (x1, y1, x2, y2). + Set rectangular entry/exit zones (x1, y1, x2, y2) and update both the tracker and solver. + + Args: + regions (List[Tuple[int, int, int, int]]): List of rectangular regions for entry/exit logic. """ self.entry_exit_regions = regions self._solver.set_entry_exit_regions(regions) @@ -125,12 +125,19 @@ def set_border_entry_exit( margin: Optional[int] = 40, frame_size: Optional[Tuple[int, int]] = (1920, 1080), ) -> None: - self.use_border = use_border - self.borders = ( - borders if borders is not None else {"left", "right", "top", "bottom"} - ) - self.border_margin = margin - self.frame_size = frame_size + """ + Configure border-based entry/exit zones and update both the tracker and solver. + + Args: + use_border (Optional[bool]): Enable/disable border-based entry/exit. + borders (Optional[Set[str]]): Set of borders to use. {"left", "right", "top", "bottom"} + margin (Optional[int]): Border thickness in pixels. + frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). + """ + self.use_border = use_border if use_border is not None else True + self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.border_margin = margin if margin is not None else 40 + self.frame_size = frame_size if frame_size is not None else (1920, 1080) self._solver.set_border_entry_exit( use_border=self.use_border, borders=self.borders, From ab51903211993a83142809b4b5720ba6094c3151 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 07:08:11 +0000 Subject: [PATCH 091/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/tracker.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 11a78494..7b781bea 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -70,7 +70,9 @@ def __init__( borders if borders is not None else {"left", "right", "top", "bottom"} ) self.border_margin: int = border_margin if border_margin is not None else 40 - self.frame_size: Tuple[int, int] = frame_size if frame_size is not None else (1920, 1080) + self.frame_size: Tuple[int, int] = ( + frame_size if frame_size is not None else (1920, 1080) + ) self._solver = KSPSolver( path_overlap_penalty=path_overlap_penalty, iou_weight=iou_weight, @@ -135,7 +137,9 @@ def set_border_entry_exit( frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). """ self.use_border = use_border if use_border is not None else True - self.borders = borders if borders is not None else {"left", "right", "top", "bottom"} + self.borders = ( + borders if borders is not None else {"left", "right", "top", "bottom"} + ) self.border_margin = margin if margin is not None else 40 self.frame_size = frame_size if frame_size is not None else (1920, 1080) self._solver.set_border_entry_exit( @@ -272,7 +276,7 @@ def track( else: raise ValueError(f"{source} not a valid path or list of PIL.Image.Image.") paths = self._solver.solve(num_of_tracks) - + if not paths: return [] return self._assign_tracker_ids_from_paths(paths) From 1ecd0f3a7970ad4bdd4b47d83b2ee5d7a7d72807 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:10:01 -0400 Subject: [PATCH 092/100] Precommit --- trackers/core/ksp/tracker.py | 64 +++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 11a78494..e4415960 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -31,36 +31,46 @@ def __init__( frame_size: Optional[Tuple[int, int]] = (1920, 1080), ) -> None: """ - Initialize the KSPTracker and its underlying solver with region and cost configuration. + Initialize the KSPTracker and its underlying solver with region and cost + configuration. Args: - path_overlap_penalty (Optional[int]): Penalty for reusing the same edge (detection pairing) in multiple tracks. - Higher values encourage the tracker to produce more distinct, non-overlapping tracks by discouraging shared - detections between tracks. Default is 40. - iou_weight (Optional[float]): Weight for the Intersection-over-Union (IoU) penalty in the edge cost. - Higher values make the tracker favor linking detections with greater spatial overlap, which helps maintain - track continuity for objects that move smoothly. Default is 0.9. - dist_weight (Optional[float]): Weight for the Euclidean distance between detection centers in the edge cost. - Increasing this value penalizes large jumps between detections in consecutive frames, promoting smoother, - more physically plausible tracks. Default is 0.1. - size_weight (Optional[float]): Weight for the size difference penalty in the edge cost. - Higher values penalize linking detections with significantly different bounding box areas, which helps - prevent identity switches when object size changes abruptly. Default is 0.1. - conf_weight (Optional[float]): Weight for the confidence penalty in the edge cost. - Higher values penalize edges between detections with lower confidence scores, making the tracker prefer - more reliable detections and reducing the impact of false positives. Default is 0.1. - entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of rectangular entry/exit regions, - each defined as (x1, y1, x2, y2) in pixel coordinates. These regions are used to determine when objects - enter or exit the scene. Default is an empty list. - use_border (Optional[bool]): Whether to enable border-based entry/exit logic. If True, objects entering or - exiting through the image borders (as defined by `borders` and `border_margin`) are considered for + path_overlap_penalty (Optional[int]): Penalty for reusing the same edge + (detection pairing) in multiple tracks. Higher values encourage the + tracker to produce more distinct, non-overlapping tracks by + discouraging shared detections between tracks. Default is 40. + iou_weight (Optional[float]): Weight for the IoU penalty in the edge cost. + Higher values make the tracker favor linking detections with greater + spatial overlap, which helps maintain track continuity for objects + that move smoothly. Default is 0.9. + dist_weight (Optional[float]): Weight for the Euclidean distance between + detection centers in the edge cost. Increasing this value penalizes + large jumps between detections in consecutive frames, promoting + smoother, more physically plausible tracks. Default is 0.1. + size_weight (Optional[float]): Weight for the size difference penalty in + the edge cost. Higher values penalize linking detections with + significantly different bounding box areas, which helps prevent + identity switches when object size changes abruptly. Default is 0.1. + conf_weight (Optional[float]): Weight for the confidence penalty in the + edge cost. Higher values penalize edges between detections with lower + confidence scores, making the tracker prefer more reliable detections + and reducing the impact of false positives. Default is 0.1. + entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of + rectangular entry/exit regions, each as (x1, y1, x2, y2) in pixels. + Used to determine when objects enter or exit the scene. Default is + an empty list. + use_border (Optional[bool]): Whether to enable border-based entry/exit + logic. If True, objects entering or exiting through the image borders + (as defined by `borders` and `border_margin`) are considered for entry/exit events. Default is True. - borders (Optional[Set[str]]): Set of border sides to use for entry/exit logic. Valid values are any subset - of {"left", "right", "top", "bottom"}. Default is all four borders. - border_margin (Optional[int]): Thickness of the border region (in pixels) used for entry/exit detection. - Default is 40. - frame_size (Optional[Tuple[int, int]]): Size of the image frames as (width, height). Used to determine - border region extents. Default is (1920, 1080). + borders (Optional[Set[str]]): Set of border sides to use for entry/exit + logic. Valid values are any subset of {"left", "right", "top", + "bottom"}. Default is all four borders. + border_margin (Optional[int]): Thickness of the border region (in pixels) + used for entry/exit detection. Default is 40. + frame_size (Optional[Tuple[int, int]]): Size of the image frames as + (width, height). Used to determine border region extents. Default is + (1920, 1080). """ self.entry_exit_regions: List[Tuple[int, int, int, int]] = ( entry_exit_regions if entry_exit_regions is not None else [] From 55eed452a2c9df9fff4947adc9619ee63623795f Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:13:30 -0400 Subject: [PATCH 093/100] Precommit --- trackers/core/ksp/tracker.py | 74 +++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 6d6c4352..06b54848 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -1,6 +1,6 @@ import os from collections import defaultdict -from typing import Callable, List, Optional, Set, Tuple +from typing import Callable, List, Optional, Set, Tuple, Union import cv2 import numpy as np @@ -19,39 +19,39 @@ class KSPTracker(BaseOfflineTracker): def __init__( self, - path_overlap_penalty: Optional[int] = 40, - iou_weight: Optional[float] = 0.9, - dist_weight: Optional[float] = 0.1, - size_weight: Optional[float] = 0.1, - conf_weight: Optional[float] = 0.1, + path_overlap_penalty: float = 40, + iou_weight: float = 0.9, + dist_weight: float = 0.1, + size_weight: float = 0.1, + conf_weight: float = 0.1, entry_exit_regions: Optional[List[Tuple[int, int, int, int]]] = None, - use_border: Optional[bool] = True, + use_border: bool = True, borders: Optional[Set[str]] = None, - border_margin: Optional[int] = 40, - frame_size: Optional[Tuple[int, int]] = (1920, 1080), + border_margin: int = 40, + frame_size: Tuple[int, int] = (1920, 1080), ) -> None: """ Initialize the KSPTracker and its underlying solver with region and cost configuration. Args: - path_overlap_penalty (Optional[int]): Penalty for reusing the same edge + path_overlap_penalty (float): Penalty for reusing the same edge (detection pairing) in multiple tracks. Higher values encourage the tracker to produce more distinct, non-overlapping tracks by discouraging shared detections between tracks. Default is 40. - iou_weight (Optional[float]): Weight for the IoU penalty in the edge cost. + iou_weight (float): Weight for the IoU penalty in the edge cost. Higher values make the tracker favor linking detections with greater spatial overlap, which helps maintain track continuity for objects that move smoothly. Default is 0.9. - dist_weight (Optional[float]): Weight for the Euclidean distance between + dist_weight (float): Weight for the Euclidean distance between detection centers in the edge cost. Increasing this value penalizes large jumps between detections in consecutive frames, promoting smoother, more physically plausible tracks. Default is 0.1. - size_weight (Optional[float]): Weight for the size difference penalty in + size_weight (float): Weight for the size difference penalty in the edge cost. Higher values penalize linking detections with significantly different bounding box areas, which helps prevent identity switches when object size changes abruptly. Default is 0.1. - conf_weight (Optional[float]): Weight for the confidence penalty in the + conf_weight (float): Weight for the confidence penalty in the edge cost. Higher values penalize edges between detections with lower confidence scores, making the tracker prefer more reliable detections and reducing the impact of false positives. Default is 0.1. @@ -59,16 +59,16 @@ def __init__( rectangular entry/exit regions, each as (x1, y1, x2, y2) in pixels. Used to determine when objects enter or exit the scene. Default is an empty list. - use_border (Optional[bool]): Whether to enable border-based entry/exit + use_border (bool): Whether to enable border-based entry/exit logic. If True, objects entering or exiting through the image borders (as defined by `borders` and `border_margin`) are considered for entry/exit events. Default is True. borders (Optional[Set[str]]): Set of border sides to use for entry/exit logic. Valid values are any subset of {"left", "right", "top", "bottom"}. Default is all four borders. - border_margin (Optional[int]): Thickness of the border region (in pixels) + border_margin (int): Thickness of the border region (in pixels) used for entry/exit detection. Default is 40. - frame_size (Optional[Tuple[int, int]]): Size of the image frames as + frame_size (Tuple[int, int]): Size of the image frames as (width, height). Used to determine border region extents. Default is (1920, 1080). """ @@ -120,38 +120,44 @@ def _update(self, detections: sv.Detections) -> sv.Detections: self._solver.append_frame(detections) return detections - def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: + def set_entry_exit_regions( + self, regions: List[Tuple[int, int, int, int]] + ) -> None: """ - Set rectangular entry/exit zones (x1, y1, x2, y2) and update both the tracker and solver. + Set rectangular entry/exit zones (x1, y1, x2, y2) and update both the + tracker and solver. Args: - regions (List[Tuple[int, int, int, int]]): List of rectangular regions for entry/exit logic. + regions (List[Tuple[int, int, int, int]]): List of rectangular + regions for entry/exit logic. """ self.entry_exit_regions = regions self._solver.set_entry_exit_regions(regions) def set_border_entry_exit( self, - use_border: Optional[bool] = True, + use_border: bool = True, borders: Optional[Set[str]] = None, - margin: Optional[int] = 40, - frame_size: Optional[Tuple[int, int]] = (1920, 1080), + margin: int = 40, + frame_size: Tuple[int, int] = (1920, 1080), ) -> None: """ - Configure border-based entry/exit zones and update both the tracker and solver. + Configure border-based entry/exit zones and update both the tracker and + solver. Args: - use_border (Optional[bool]): Enable/disable border-based entry/exit. - borders (Optional[Set[str]]): Set of borders to use. {"left", "right", "top", "bottom"} - margin (Optional[int]): Border thickness in pixels. - frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). + use_border (bool): Enable/disable border-based entry/exit. + borders (Optional[Set[str]]): Set of borders to use. Each value should + be one of "left", "right", "top", "bottom". + margin (int): Border thickness in pixels. + frame_size (Tuple[int, int]): Size of the image (width, height). """ - self.use_border = use_border if use_border is not None else True + self.use_border = use_border self.borders = ( borders if borders is not None else {"left", "right", "top", "bottom"} ) - self.border_margin = margin if margin is not None else 40 - self.frame_size = frame_size if frame_size is not None else (1920, 1080) + self.border_margin = margin + self.frame_size = frame_size self._solver.set_border_entry_exit( use_border=self.use_border, borders=self.borders, @@ -210,7 +216,7 @@ def _assign_tracker_ids_from_paths( def track( self, - source: str | List[PIL.Image.Image], + source: Union[str, List[PIL.Image.Image]], get_model_detections: Callable[[np.ndarray], sv.Detections], num_of_tracks: Optional[int] = None, ) -> List[sv.Detections]: @@ -235,7 +241,7 @@ def track( "`get_model_detections` must be a callable that returns an " "instance of `sv.Detections`." ) - if source.lower().endswith(".mp4"): + if isinstance(source, str) and source.lower().endswith(".mp4"): frames_generator = sv.get_video_frames_generator(source_path=source) video_info = sv.VideoInfo.from_video_path(video_path=source) @@ -254,7 +260,7 @@ def track( ): detections = get_model_detections(frame) self._update(detections) - elif os.path.isdir(source): + elif isinstance(source, str) and os.path.isdir(source): frame_paths = sorted( [ os.path.join(source, f) From 5029b88e1a14c3b41ecfb311e5b54f6556eca00c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 07:13:47 +0000 Subject: [PATCH 094/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/tracker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 06b54848..dd94161b 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -120,9 +120,7 @@ def _update(self, detections: sv.Detections) -> sv.Detections: self._solver.append_frame(detections) return detections - def set_entry_exit_regions( - self, regions: List[Tuple[int, int, int, int]] - ) -> None: + def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> None: """ Set rectangular entry/exit zones (x1, y1, x2, y2) and update both the tracker and solver. From b62d140b57dde64106ae0d859d72d88d95d40685 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:15:53 -0400 Subject: [PATCH 095/100] Precommit --- trackers/core/ksp/solver.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index dc2934ed..10dd42b0 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -112,10 +112,10 @@ def set_entry_exit_regions(self, regions: List[Tuple[int, int, int, int]]) -> No def set_border_entry_exit( self, - use_border: Optional[bool] = True, + use_border: bool = True, borders: Optional[Set[str]] = None, - margin: Optional[int] = 40, - frame_size: Optional[Tuple[int, int]] = (1920, 1080), + margin: int = 40, + frame_size: Tuple[int, int] = (1920, 1080), ) -> None: """ Configure border-based entry/exit zones. @@ -123,8 +123,8 @@ def set_border_entry_exit( Args: use_border (bool): Enable/disable border-based entry/exit. borders (Optional[Set[str]]): Set of borders to use. - margin (Optional[int]): Border thickness in pixels. - frame_size (Optional[Tuple[int, int]]): Size of the image (width, height). + margin (int): Border thickness in pixels. + frame_size (Tuple[int, int]): Size of the image (width, height). """ self.use_border_regions = use_border self.active_borders = ( From 784fbb884dc2d6ee4755900fd8f36a911800c83b Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Wed, 9 Jul 2025 03:20:09 -0400 Subject: [PATCH 096/100] UPDATE: Updated docs --- docs/trackers/core/ksp/tracker.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/trackers/core/ksp/tracker.md b/docs/trackers/core/ksp/tracker.md index 94c2d4dd..32c11268 100644 --- a/docs/trackers/core/ksp/tracker.md +++ b/docs/trackers/core/ksp/tracker.md @@ -5,6 +5,7 @@ comments: true # KSP [![IEEE](https://img.shields.io/badge/IEEE-10.1109/TPAMI.2011.21-blue.svg)](https://doi.org/10.1109/TPAMI.2011.21) +[![arXiv](https://img.shields.io/badge/arXiv-1808.01562-b31b1b.svg)](https://arxiv.org/abs/1808.01562) [![PDF (Unofficial)](https://img.shields.io/badge/PDF-Stanford--Preprint-red.svg)](http://vision.stanford.edu/teaching/cs231b_spring1415/papers/Berclaz-tracking.pdf) [![colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-track-objects-with-sort-tracker.ipynb) From 35cea661e807300281d3ba1a790591775fa0f7f1 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sun, 13 Jul 2025 01:30:45 -0400 Subject: [PATCH 097/100] UPDATE: Editable hyper parameters for entry and exit costs --- trackers/core/ksp/solver.py | 8 ++++++-- trackers/core/ksp/tracker.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index 10dd42b0..6cbf25c3 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -38,6 +38,8 @@ def __init__( dist_weight: float = 0.1, size_weight: float = 0.1, conf_weight: float = 0.1, + entry_weight: float = 2.0, + exit_weight: float = 2.0, ): self.path_overlap_penalty = ( path_overlap_penalty if path_overlap_penalty is not None else 40 @@ -47,6 +49,8 @@ def __init__( self.sink = "SINK" self.detection_per_frame: List[sv.Detections] = [] self.weights = {"iou": 0.9, "dist": 0.1, "size": 0.1, "conf": 0.1} + self.entry_weight = entry_weight + self.exit_weight = exit_weight if path_overlap_penalty is not None: self.path_overlap_penalty = path_overlap_penalty @@ -227,8 +231,8 @@ def _build_graph(self): for t in range(len(node_frames) - 1): for node_a in node_frames[t]: if self._in_door(node_a): - G.add_edge(self.source, node_a, weight=t * 2) - G.add_edge(node_a, self.sink, weight=(len(node_frames) - 1 - t) * 2) + G.add_edge(self.source, node_a, weight=t * self.entry_weight) + G.add_edge(node_a, self.sink, weight=(len(node_frames) - 1 - t) * self.exit_weight) for node_b in node_frames[t + 1]: cost = self._edge_cost(node_a, node_b) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index dd94161b..5ad7b43f 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -24,6 +24,8 @@ def __init__( dist_weight: float = 0.1, size_weight: float = 0.1, conf_weight: float = 0.1, + entry_weight: float = 2.0, + exit_weight: float = 2.0, entry_exit_regions: Optional[List[Tuple[int, int, int, int]]] = None, use_border: bool = True, borders: Optional[Set[str]] = None, @@ -55,6 +57,12 @@ def __init__( edge cost. Higher values penalize edges between detections with lower confidence scores, making the tracker prefer more reliable detections and reducing the impact of false positives. Default is 0.1. + entry_weight (float): Weight for entry node connections in the graph. + Higher values make the tracker more conservative about creating new tracks + when objects appear. Default is 2.0. + exit_weight (float): Weight for exit node connections in the graph. + Higher values make the tracker more conservative about ending tracks + when objects disappear. Default is 2.0. entry_exit_regions (Optional[List[Tuple[int, int, int, int]]]): List of rectangular entry/exit regions, each as (x1, y1, x2, y2) in pixels. Used to determine when objects enter or exit the scene. Default is @@ -89,6 +97,8 @@ def __init__( dist_weight=dist_weight, size_weight=size_weight, conf_weight=conf_weight, + entry_weight=entry_weight, + exit_weight=exit_weight, ) self._solver.set_entry_exit_regions(self.entry_exit_regions) self._solver.set_border_entry_exit( From 7eeb8b803115938713495f3f135fd50145e4dbf8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 13 Jul 2025 05:31:00 +0000 Subject: [PATCH 098/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/solver.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/trackers/core/ksp/solver.py b/trackers/core/ksp/solver.py index 6cbf25c3..a51e8850 100644 --- a/trackers/core/ksp/solver.py +++ b/trackers/core/ksp/solver.py @@ -232,7 +232,11 @@ def _build_graph(self): for node_a in node_frames[t]: if self._in_door(node_a): G.add_edge(self.source, node_a, weight=t * self.entry_weight) - G.add_edge(node_a, self.sink, weight=(len(node_frames) - 1 - t) * self.exit_weight) + G.add_edge( + node_a, + self.sink, + weight=(len(node_frames) - 1 - t) * self.exit_weight, + ) for node_b in node_frames[t + 1]: cost = self._edge_cost(node_a, node_b) From b8d8289cd4f7fd54a11892842e5f3151e40c6502 Mon Sep 17 00:00:00 2001 From: Ashp116 Date: Sun, 13 Jul 2025 03:23:08 -0400 Subject: [PATCH 099/100] BUG: Debugging the detections vs tracker Id --- trackers/core/ksp/tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index 5ad7b43f..b375bfef 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -303,4 +303,4 @@ def track( if not paths: return [] - return self._assign_tracker_ids_from_paths(paths) + return self._assign_tracker_ids_from_paths(paths), self._solver.detection_per_frame From a9866bd8bea6aa0b9b9e744fc3daf30571642bf0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 13 Jul 2025 07:23:22 +0000 Subject: [PATCH 100/100] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20aut?= =?UTF-8?q?o=20format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- trackers/core/ksp/tracker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trackers/core/ksp/tracker.py b/trackers/core/ksp/tracker.py index b375bfef..0a2cafae 100644 --- a/trackers/core/ksp/tracker.py +++ b/trackers/core/ksp/tracker.py @@ -303,4 +303,6 @@ def track( if not paths: return [] - return self._assign_tracker_ids_from_paths(paths), self._solver.detection_per_frame + return self._assign_tracker_ids_from_paths( + paths + ), self._solver.detection_per_frame