rpx_benchmark.metrics.tracking_paper
Paper-protocol metrics for RPX D3 multi-object tracking.
RPX publishes temporally consistent instance masks: a positive pixel value is
the same physical object ID throughout a (scene, phase) clip. D3 models
are initialized from the first-frame mask. For the MOTChallenge metric set we
derive tight boxes from those masks and delegate the sequence calculations to
the official TrackEval implementation.
1"""Paper-protocol metrics for RPX D3 multi-object tracking. 2 3RPX publishes temporally consistent instance masks: a positive pixel value is 4the same physical object ID throughout a ``(scene, phase)`` clip. D3 models 5are initialized from the first-frame mask. For the MOTChallenge metric set we 6derive tight boxes from those masks and delegate the sequence calculations to 7the official TrackEval implementation. 8""" 9 10from __future__ import annotations 11 12from typing import Any, Sequence 13 14import numpy as np 15 16from ..exceptions import MetricError 17 18PAPER_TRACKING_METRICS = ("hota", "deta", "assa", "idf1", "mota", "idsw") 19MOT_IOU_THRESHOLD = 0.5 20 21 22def _validate_mask_sequence(masks: Sequence[np.ndarray], name: str) -> list[np.ndarray]: 23 if not masks: 24 raise MetricError(f"{name} mask sequence is empty.") 25 result: list[np.ndarray] = [] 26 expected_shape: tuple[int, int] | None = None 27 for index, value in enumerate(masks): 28 mask = np.asarray(value) 29 if mask.ndim != 2: 30 raise MetricError(f"{name}[{index}] must be 2-D, got shape {mask.shape}.") 31 if not np.issubdtype(mask.dtype, np.integer): 32 raise MetricError(f"{name}[{index}] must have an integer dtype, got {mask.dtype}.") 33 if np.any(mask < 0): 34 raise MetricError(f"{name}[{index}] contains negative instance IDs.") 35 if expected_shape is None: 36 expected_shape = mask.shape 37 elif mask.shape != expected_shape: 38 raise MetricError( 39 f"{name}[{index}] has shape {mask.shape}; expected {expected_shape}." 40 ) 41 result.append(mask.astype(np.int32, copy=False)) 42 return result 43 44 45def _boxes_and_ids(mask: np.ndarray, id_map: dict[int, int]) -> tuple[np.ndarray, np.ndarray]: 46 source_ids = np.unique(mask) 47 source_ids = source_ids[source_ids > 0] 48 boxes: list[list[float]] = [] 49 ids: list[int] = [] 50 for source_id in source_ids.tolist(): 51 ys, xs = np.nonzero(mask == source_id) 52 if xs.size == 0: 53 continue 54 # Exclusive maxima follow the common xyxy convention and make a 55 # one-pixel-wide object have positive area. 56 boxes.append( 57 [ 58 float(xs.min()), 59 float(ys.min()), 60 float(xs.max() + 1), 61 float(ys.max() + 1), 62 ] 63 ) 64 ids.append(id_map[int(source_id)]) 65 return np.asarray(ids, dtype=np.int64), np.asarray(boxes, dtype=np.float64).reshape(-1, 4) 66 67 68def _box_iou_matrix(gt_boxes: np.ndarray, pred_boxes: np.ndarray) -> np.ndarray: 69 if gt_boxes.size == 0 or pred_boxes.size == 0: 70 return np.zeros((len(gt_boxes), len(pred_boxes)), dtype=np.float64) 71 left_top = np.maximum(gt_boxes[:, None, :2], pred_boxes[None, :, :2]) 72 right_bottom = np.minimum(gt_boxes[:, None, 2:], pred_boxes[None, :, 2:]) 73 intersection_wh = np.maximum(0.0, right_bottom - left_top) 74 intersection = intersection_wh[..., 0] * intersection_wh[..., 1] 75 gt_area = np.prod(np.maximum(0.0, gt_boxes[:, 2:] - gt_boxes[:, :2]), axis=1) 76 pred_area = np.prod(np.maximum(0.0, pred_boxes[:, 2:] - pred_boxes[:, :2]), axis=1) 77 union = gt_area[:, None] + pred_area[None, :] - intersection 78 return np.divide( 79 intersection, 80 union, 81 out=np.zeros_like(intersection), 82 where=union > 0, 83 ) 84 85 86def _trackeval_data( 87 gt_masks: Sequence[np.ndarray], 88 pred_masks: Sequence[np.ndarray], 89) -> dict[str, Any]: 90 gt = _validate_mask_sequence(gt_masks, "ground_truth") 91 pred = _validate_mask_sequence(pred_masks, "prediction") 92 if len(gt) != len(pred): 93 raise MetricError( 94 f"prediction has {len(pred)} frames; ground truth has {len(gt)} frames." 95 ) 96 if pred[0].shape != gt[0].shape: 97 raise MetricError( 98 f"prediction shape {pred[0].shape} does not match GT shape {gt[0].shape}." 99 ) 100 101 gt_source_ids = sorted({int(v) for mask in gt for v in np.unique(mask) if v > 0}) 102 pred_source_ids = sorted({int(v) for mask in pred for v in np.unique(mask) if v > 0}) 103 gt_id_map = {source_id: index for index, source_id in enumerate(gt_source_ids)} 104 pred_id_map = {source_id: index for index, source_id in enumerate(pred_source_ids)} 105 106 gt_ids: list[np.ndarray] = [] 107 tracker_ids: list[np.ndarray] = [] 108 similarity_scores: list[np.ndarray] = [] 109 num_gt_dets = 0 110 num_tracker_dets = 0 111 for gt_mask, pred_mask in zip(gt, pred, strict=True): 112 frame_gt_ids, gt_boxes = _boxes_and_ids(gt_mask, gt_id_map) 113 frame_pred_ids, pred_boxes = _boxes_and_ids(pred_mask, pred_id_map) 114 gt_ids.append(frame_gt_ids) 115 tracker_ids.append(frame_pred_ids) 116 similarity_scores.append(_box_iou_matrix(gt_boxes, pred_boxes)) 117 num_gt_dets += len(frame_gt_ids) 118 num_tracker_dets += len(frame_pred_ids) 119 120 return { 121 "num_timesteps": len(gt), 122 "num_gt_ids": len(gt_source_ids), 123 "num_tracker_ids": len(pred_source_ids), 124 "num_gt_dets": num_gt_dets, 125 "num_tracker_dets": num_tracker_dets, 126 "gt_ids": gt_ids, 127 "tracker_ids": tracker_ids, 128 "similarity_scores": similarity_scores, 129 } 130 131 132def paper_tracking_metrics( 133 pred_masks: Sequence[np.ndarray], 134 gt_masks: Sequence[np.ndarray], 135) -> dict[str, float]: 136 """Compute HOTA, DetA, AssA, IDF1, MOTA and ID switches for one clip. 137 138 TrackEval is intentionally a runtime dependency rather than a locally 139 reimplemented approximation. HOTA is the standard mean over association 140 thresholds 0.05:0.05:0.95; CLEAR and Identity use the MOTChallenge IoU 141 threshold of 0.5. 142 """ 143 144 try: 145 # TrackEval still references NumPy's removed aliases at the pinned 146 # upstream revision. Restore those aliases locally before importing it; 147 # this does not change any calculation. 148 if not hasattr(np, "float"): 149 np.float = float # type: ignore[attr-defined] 150 if not hasattr(np, "int"): 151 np.int = int # type: ignore[attr-defined] 152 from trackeval.metrics import CLEAR, HOTA, Identity 153 except ImportError as exc: 154 raise MetricError( 155 "Paper D3 metrics require the official TrackEval package.", 156 hint="Use the RPX tracking Docker image; it pins TrackEval.", 157 ) from exc 158 159 data = _trackeval_data(gt_masks=gt_masks, pred_masks=pred_masks) 160 clear_result = CLEAR({"THRESHOLD": MOT_IOU_THRESHOLD, "PRINT_CONFIG": False}).eval_sequence( 161 data 162 ) 163 identity_result = Identity( 164 {"THRESHOLD": MOT_IOU_THRESHOLD, "PRINT_CONFIG": False} 165 ).eval_sequence(data) 166 hota_result = HOTA().eval_sequence(data) 167 168 metrics = { 169 "hota": float(np.mean(hota_result["HOTA"])), 170 "deta": float(np.mean(hota_result["DetA"])), 171 "assa": float(np.mean(hota_result["AssA"])), 172 "idf1": float(identity_result["IDF1"]), 173 "mota": float(clear_result["MOTA"]), 174 "idsw": float(clear_result["IDSW"]), 175 } 176 if not all(np.isfinite(value) for value in metrics.values()): 177 raise MetricError(f"TrackEval returned non-finite metrics: {metrics}") 178 return metrics
PAPER_TRACKING_METRICS =
('hota', 'deta', 'assa', 'idf1', 'mota', 'idsw')
MOT_IOU_THRESHOLD =
0.5
def
paper_tracking_metrics( pred_masks: Sequence[numpy.ndarray], gt_masks: Sequence[numpy.ndarray]) -> dict[str, float]:
133def paper_tracking_metrics( 134 pred_masks: Sequence[np.ndarray], 135 gt_masks: Sequence[np.ndarray], 136) -> dict[str, float]: 137 """Compute HOTA, DetA, AssA, IDF1, MOTA and ID switches for one clip. 138 139 TrackEval is intentionally a runtime dependency rather than a locally 140 reimplemented approximation. HOTA is the standard mean over association 141 thresholds 0.05:0.05:0.95; CLEAR and Identity use the MOTChallenge IoU 142 threshold of 0.5. 143 """ 144 145 try: 146 # TrackEval still references NumPy's removed aliases at the pinned 147 # upstream revision. Restore those aliases locally before importing it; 148 # this does not change any calculation. 149 if not hasattr(np, "float"): 150 np.float = float # type: ignore[attr-defined] 151 if not hasattr(np, "int"): 152 np.int = int # type: ignore[attr-defined] 153 from trackeval.metrics import CLEAR, HOTA, Identity 154 except ImportError as exc: 155 raise MetricError( 156 "Paper D3 metrics require the official TrackEval package.", 157 hint="Use the RPX tracking Docker image; it pins TrackEval.", 158 ) from exc 159 160 data = _trackeval_data(gt_masks=gt_masks, pred_masks=pred_masks) 161 clear_result = CLEAR({"THRESHOLD": MOT_IOU_THRESHOLD, "PRINT_CONFIG": False}).eval_sequence( 162 data 163 ) 164 identity_result = Identity( 165 {"THRESHOLD": MOT_IOU_THRESHOLD, "PRINT_CONFIG": False} 166 ).eval_sequence(data) 167 hota_result = HOTA().eval_sequence(data) 168 169 metrics = { 170 "hota": float(np.mean(hota_result["HOTA"])), 171 "deta": float(np.mean(hota_result["DetA"])), 172 "assa": float(np.mean(hota_result["AssA"])), 173 "idf1": float(identity_result["IDF1"]), 174 "mota": float(clear_result["MOTA"]), 175 "idsw": float(clear_result["IDSW"]), 176 } 177 if not all(np.isfinite(value) for value in metrics.values()): 178 raise MetricError(f"TrackEval returned non-finite metrics: {metrics}") 179 return metrics
Compute HOTA, DetA, AssA, IDF1, MOTA and ID switches for one clip.
TrackEval is intentionally a runtime dependency rather than a locally reimplemented approximation. HOTA is the standard mean over association thresholds 0.05:0.05:0.95; CLEAR and Identity use the MOTChallenge IoU threshold of 0.5.