""" 2D matching module for ground truth and detection boxes. """ import numpy as np class Matcher2D: """Match 2D bounding boxes between ground truth and detections.""" def __init__(self, iou_threshold=0.5): """ Initialize matcher. Args: iou_threshold: float, IoU threshold for matching """ self.iou_threshold = iou_threshold def compute_iou(self, box1, box2): """ Compute IoU between two boxes. Args: box1: [x1, y1, x2, y2] box2: [x1, y1, x2, y2] Returns: float, IoU value """ x1 = max(box1[0], box2[0]) y1 = max(box1[1], box2[1]) x2 = min(box1[2], box2[2]) y2 = min(box1[3], box2[3]) if x2 < x1 or y2 < y1: return 0.0 intersection = (x2 - x1) * (y2 - y1) area1 = (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 = (box2[2] - box2[0]) * (box2[3] - box2[1]) union = area1 + area2 - intersection return intersection / union if union > 0 else 0.0 @staticmethod def _normalize_roi_id(roi_id): """Normalize ROI identifiers like 'roi0'/'0' to plain numeric strings.""" if roi_id is None: return None roi_id_str = str(roi_id).strip().lower() if roi_id_str.startswith('roi'): roi_id_str = roi_id_str[3:] return roi_id_str or None def compute_pair_iou(self, gt, det): """Compute IoU for one GT/detection pair, honoring ROI-specific GT boxes when present.""" gt_boxes_by_roi = gt.get('bbox_2d_by_roi') det_box = det.get('bbox_2d') if det_box is None: return 0.0 if not gt_boxes_by_roi: gt_box = gt.get('bbox_2d') return self.compute_iou(gt_box, det_box) if gt_box is not None else 0.0 det_roi_id = self._normalize_roi_id(det.get('roi_id')) if det_roi_id is not None: gt_box = gt_boxes_by_roi.get(det_roi_id) return self.compute_iou(gt_box, det_box) if gt_box is not None else 0.0 return max((self.compute_iou(gt_box, det_box) for gt_box in gt_boxes_by_roi.values()), default=0.0) def compute_iou_matrix(self, gts, dets): """ Compute IoU matrix between all GT and detection pairs. Args: gts: list of ground truth dicts dets: list of detection dicts Returns: numpy array of shape (len(gts), len(dets)) """ if len(gts) == 0 or len(dets) == 0: return np.zeros((len(gts), len(dets))) iou_matrix = np.zeros((len(gts), len(dets))) for i, gt in enumerate(gts): for j, det in enumerate(dets): iou_matrix[i, j] = self.compute_pair_iou(gt, det) return iou_matrix def match(self, gts, dets, class_id): """ Match detections to ground truths using greedy algorithm. Args: gts: list of ground truth dicts for a specific class dets: list of detection dicts for a specific class class_id: int, class ID to match Returns: dict with keys: - matches: list of (gt_idx, det_idx, iou) tuples - unmatched_gts: list of unmatched GT indices - unmatched_dets: list of unmatched detection indices """ # Filter by class gts_filtered = [gt for gt in gts if gt['label'] == class_id] dets_filtered = [det for det in dets if det['label'] == class_id] # Sort detections by confidence (highest first) det_indices = np.argsort([-det['confidence'] for det in dets_filtered]) dets_sorted = [dets_filtered[i] for i in det_indices] # Compute IoU matrix iou_matrix = self.compute_iou_matrix(gts_filtered, dets_sorted) matches = [] matched_gt_indices = set() matched_det_indices = set() # Greedy matching: for each detection (sorted by confidence), find best GT for det_idx in range(len(dets_sorted)): best_iou = 0.0 best_gt_idx = -1 for gt_idx in range(len(gts_filtered)): if gt_idx in matched_gt_indices: continue iou = iou_matrix[gt_idx, det_idx] if iou >= self.iou_threshold and iou > best_iou: best_iou = iou best_gt_idx = gt_idx if best_gt_idx >= 0: matches.append((best_gt_idx, det_idx, best_iou)) matched_gt_indices.add(best_gt_idx) matched_det_indices.add(det_idx) # Find unmatched GTs and detections unmatched_gts = [i for i in range(len(gts_filtered)) if i not in matched_gt_indices] unmatched_dets = [i for i in range(len(dets_sorted)) if i not in matched_det_indices] return { 'matches': matches, 'unmatched_gts': unmatched_gts, 'unmatched_dets': unmatched_dets, 'gts_filtered': gts_filtered, 'dets_sorted': dets_sorted }