单目3D初始代码
This commit is contained in:
20
eval_tools/evaluator/__init__.py
Executable file
20
eval_tools/evaluator/__init__.py
Executable file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Evaluation toolkit for YOLOv5-3D model outputs.
|
||||
"""
|
||||
|
||||
from .parser import GroundTruthParser, DetectionParser
|
||||
from .matcher import Matcher2D
|
||||
from .metrics_2d import Metrics2D
|
||||
from .metrics_3d import Metrics3D
|
||||
from .evaluator import Evaluator
|
||||
from .roi_processor import ROIProcessor
|
||||
|
||||
__all__ = [
|
||||
'GroundTruthParser',
|
||||
'DetectionParser',
|
||||
'Matcher2D',
|
||||
'Metrics2D',
|
||||
'Metrics3D',
|
||||
'Evaluator',
|
||||
'ROIProcessor'
|
||||
]
|
||||
1763
eval_tools/evaluator/evaluator.py
Executable file
1763
eval_tools/evaluator/evaluator.py
Executable file
File diff suppressed because it is too large
Load Diff
155
eval_tools/evaluator/matcher.py
Executable file
155
eval_tools/evaluator/matcher.py
Executable file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
348
eval_tools/evaluator/metrics_2d.py
Executable file
348
eval_tools/evaluator/metrics_2d.py
Executable file
@@ -0,0 +1,348 @@
|
||||
"""2D metrics calculation module.
|
||||
|
||||
Supports optional per-distance-range evaluation for 3D-capable classes
|
||||
(vehicle, pedestrian, bicycle, rider) that carry z3d / x3d coordinates.
|
||||
|
||||
Storage per detection: (confidence, is_tp, z, x)
|
||||
z = GT z3d for TPs; detection's own predicted z3d for FPs.
|
||||
x = GT x3d for TPs; detection's own predicted x3d for FPs.
|
||||
z, x = None when no 3D output (2D-only classes).
|
||||
|
||||
Two evaluation views are produced when ``distance_ranges`` is configured:
|
||||
per_class_by_distance - all lateral positions + longitudinal bins
|
||||
per_class_by_distance_lat_roi - lateral pre-filter (``lateral_roi``) + longitudinal bins
|
||||
only produced when ``lateral_roi`` is also set.
|
||||
"""
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class Metrics2D:
|
||||
"""Calculate 2D detection metrics (Precision, Recall, AP, mAP).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
num_classes : int
|
||||
distance_ranges : list of [z_min, z_max] pairs (metres), optional
|
||||
Longitudinal distance bins for per-range evaluation.
|
||||
lateral_roi : [x_min, x_max] pair (metres), optional
|
||||
Lateral region-of-interest filter applied on top of ``distance_ranges``
|
||||
to produce a second "lat-filtered" section in the summary.
|
||||
e.g. [-15, 15] keeps only targets within 15 m of the vehicle centre line.
|
||||
"""
|
||||
|
||||
def __init__(self, num_classes=14, distance_ranges=None, lateral_roi=None, coord_system='camera'):
|
||||
self.num_classes = num_classes
|
||||
self.distance_ranges = distance_ranges # [[z0,z1], [z1,z2], ...]
|
||||
self.lateral_roi = lateral_roi # [x_min, x_max] or None
|
||||
if coord_system not in ('camera', 'ego'):
|
||||
raise ValueError(f"Unsupported coord_system: {coord_system}")
|
||||
self.coord_system = coord_system
|
||||
|
||||
# Per detection: (confidence, is_tp, z, x)
|
||||
self.all_detections = defaultdict(list)
|
||||
|
||||
# Per GT: (z, x) -- both None for 2D-only classes
|
||||
self.all_gt_coords = defaultdict(list)
|
||||
|
||||
def _get_lateral_axis(self):
|
||||
return 0 if self.coord_system == 'camera' else 1
|
||||
|
||||
def _get_longitudinal_axis(self):
|
||||
return 2 if self.coord_system == 'camera' else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _gt_count(self, class_id, z_range=None, x_range=None):
|
||||
"""Count GTs matching optional z_range and/or x_range filters."""
|
||||
count = 0
|
||||
for z, x in self.all_gt_coords[class_id]:
|
||||
if z_range is not None:
|
||||
if z is None or not (z_range[0] <= z < z_range[1]):
|
||||
continue
|
||||
if x_range is not None:
|
||||
if x is None or not (x_range[0] <= x < x_range[1]):
|
||||
continue
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _filter_dets(self, class_id, z_range=None, x_range=None):
|
||||
"""Return detections matching optional z_range and/or x_range filters.
|
||||
|
||||
TPs use matched GT (z, x); FPs use detection's own predicted (z, x).
|
||||
Detections without coordinates are excluded from any range query.
|
||||
"""
|
||||
dets = self.all_detections[class_id]
|
||||
if z_range is None and x_range is None:
|
||||
return dets
|
||||
result = []
|
||||
for conf, is_tp, z, x in dets:
|
||||
if z_range is not None:
|
||||
if z is None or not (z_range[0] <= z < z_range[1]):
|
||||
continue
|
||||
if x_range is not None:
|
||||
if x is None or not (x_range[0] <= x < x_range[1]):
|
||||
continue
|
||||
result.append((conf, is_tp, z, x))
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_image_results(self, match_result, all_gts, all_dets, class_id):
|
||||
"""Add matching results from one image.
|
||||
|
||||
Args:
|
||||
match_result : dict from Matcher2D.match()
|
||||
all_gts : unused (kept for API compatibility)
|
||||
all_dets : unused
|
||||
class_id : int
|
||||
"""
|
||||
gts_filtered = match_result['gts_filtered']
|
||||
dets_sorted = match_result['dets_sorted']
|
||||
matches = match_result['matches']
|
||||
|
||||
# TP: map det_idx -> (z, x) from matched GT
|
||||
det_to_gt_coords = {}
|
||||
for gt_idx, det_idx, _iou in matches:
|
||||
gt = gts_filtered[gt_idx]
|
||||
d3d = gt.get('3d_info')
|
||||
if d3d is not None:
|
||||
long_axis = self._get_longitudinal_axis()
|
||||
lat_axis = self._get_lateral_axis()
|
||||
z, x = d3d['center'][long_axis], d3d['center'][lat_axis]
|
||||
else:
|
||||
z, x = None, None
|
||||
det_to_gt_coords[det_idx] = (z, x)
|
||||
|
||||
# Record all GT coordinates (for GT-count denominator)
|
||||
for gt in gts_filtered:
|
||||
d3d = gt.get('3d_info')
|
||||
if d3d is not None:
|
||||
long_axis = self._get_longitudinal_axis()
|
||||
lat_axis = self._get_lateral_axis()
|
||||
self.all_gt_coords[class_id].append((d3d['center'][long_axis], d3d['center'][lat_axis]))
|
||||
else:
|
||||
self.all_gt_coords[class_id].append((None, None))
|
||||
|
||||
# Record each detection
|
||||
for det_idx, det in enumerate(dets_sorted):
|
||||
is_tp = det_idx in det_to_gt_coords
|
||||
if is_tp:
|
||||
z, x = det_to_gt_coords[det_idx]
|
||||
else:
|
||||
# FP: use detection's own predicted 3D coordinates
|
||||
d3d = det.get('3d_info')
|
||||
if d3d is not None:
|
||||
long_axis = self._get_longitudinal_axis()
|
||||
lat_axis = self._get_lateral_axis()
|
||||
z, x = d3d['center'][long_axis], d3d['center'][lat_axis]
|
||||
else:
|
||||
z, x = None, None
|
||||
self.all_detections[class_id].append((det['confidence'], is_tp, z, x))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core metric computation (all accept optional spatial filters)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compute_precision_recall(self, class_id, z_range=None, x_range=None):
|
||||
"""Compute precision-recall curve for a class.
|
||||
|
||||
Returns (precisions, recalls, confidences) as numpy arrays.
|
||||
"""
|
||||
if class_id not in self.all_detections or not self.all_detections[class_id]:
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
|
||||
detections = self._filter_dets(class_id, z_range=z_range, x_range=x_range)
|
||||
gt_count = self._gt_count(class_id, z_range=z_range, x_range=x_range)
|
||||
|
||||
if not detections:
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
|
||||
detections = sorted(detections, key=lambda d: d[0], reverse=True)
|
||||
confidences = np.array([d[0] for d in detections])
|
||||
is_tp = np.array([d[1] for d in detections])
|
||||
|
||||
tp_cumsum = np.cumsum(is_tp)
|
||||
fp_cumsum = np.cumsum(~is_tp)
|
||||
precisions = tp_cumsum / (tp_cumsum + fp_cumsum)
|
||||
recalls = tp_cumsum / max(gt_count, 1)
|
||||
|
||||
return precisions, recalls, confidences
|
||||
|
||||
def compute_ap(self, class_id, method='voc2010', z_range=None, x_range=None):
|
||||
"""Compute Average Precision for a class."""
|
||||
precisions, recalls, _ = self.compute_precision_recall(
|
||||
class_id, z_range=z_range, x_range=x_range)
|
||||
|
||||
if len(precisions) == 0:
|
||||
return 0.0
|
||||
|
||||
if method == 'voc2010':
|
||||
ap = 0.0
|
||||
for t in np.linspace(0, 1, 11):
|
||||
mask = recalls >= t
|
||||
ap += (float(np.max(precisions[mask])) if mask.any() else 0.0) / 11.0
|
||||
return ap
|
||||
elif method == 'coco':
|
||||
mrec = np.concatenate(([0.], recalls, [1.]))
|
||||
mpre = np.concatenate(([0.], precisions, [0.]))
|
||||
for i in range(mpre.size - 1, 0, -1):
|
||||
mpre[i - 1] = np.maximum(mpre[i - 1], mpre[i])
|
||||
i = np.where(mrec[1:] != mrec[:-1])[0]
|
||||
return float(np.sum((mrec[i + 1] - mrec[i]) * mpre[i + 1]))
|
||||
else:
|
||||
raise ValueError(f"Unknown AP method: {method}")
|
||||
|
||||
def compute_map(self, method='voc2010', z_range=None, x_range=None):
|
||||
"""Compute mAP across all classes."""
|
||||
return float(np.mean([
|
||||
self.compute_ap(c, method, z_range=z_range, x_range=x_range)
|
||||
for c in range(self.num_classes)
|
||||
]))
|
||||
|
||||
def get_class_metrics(self, class_id, conf_threshold=0.5, z_range=None, x_range=None):
|
||||
"""Get Precision/Recall/F1/TP/FP/FN at conf_threshold for one class."""
|
||||
gt_count = self._gt_count(class_id, z_range=z_range, x_range=x_range)
|
||||
|
||||
if class_id not in self.all_detections:
|
||||
return dict(precision=0., recall=0., f1_score=0., tp=0, fp=0, fn=gt_count)
|
||||
|
||||
dets = self._filter_dets(class_id, z_range=z_range, x_range=x_range)
|
||||
filtered = [(conf, is_tp) for conf, is_tp, *_ in dets if conf >= conf_threshold]
|
||||
|
||||
if not filtered:
|
||||
return dict(precision=0., recall=0., f1_score=0., tp=0, fp=0, fn=gt_count)
|
||||
|
||||
tp = sum(1 for _, is_tp in filtered if is_tp)
|
||||
fp = len(filtered) - tp
|
||||
fn = gt_count - tp
|
||||
|
||||
p = tp / (tp + fp) if (tp + fp) > 0 else 0.
|
||||
r = tp / (tp + fn) if (tp + fn) > 0 else 0.
|
||||
f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.
|
||||
return dict(precision=p, recall=r, f1_score=f1, tp=tp, fp=fp, fn=fn)
|
||||
|
||||
def get_overall_metrics(self, conf_threshold=0.5, z_range=None, x_range=None):
|
||||
"""Aggregate Precision/Recall/F1 across all classes."""
|
||||
total_tp = total_fp = total_fn = 0
|
||||
for c in range(self.num_classes):
|
||||
m = self.get_class_metrics(c, conf_threshold, z_range=z_range, x_range=x_range)
|
||||
total_tp += m['tp']
|
||||
total_fp += m['fp']
|
||||
total_fn += m['fn']
|
||||
|
||||
p = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0.
|
||||
r = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0.
|
||||
f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.
|
||||
return dict(precision=p, recall=r, f1_score=f1,
|
||||
tp=total_tp, fp=total_fp, fn=total_fn)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary builder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_dist_table(self, conf_threshold, ap_method, x_range=None):
|
||||
"""Build {class_name: {range_key: metrics_dict}} for given x_range filter.
|
||||
|
||||
Args:
|
||||
x_range : None -> all lateral positions (full-lateral view)
|
||||
tuple -> restricted lateral ROI
|
||||
"""
|
||||
from .parser import GroundTruthParser
|
||||
|
||||
table = {}
|
||||
for class_id in range(self.num_classes):
|
||||
class_name = GroundTruthParser.CLASS_NAMES.get(class_id, f"class_{class_id}")
|
||||
|
||||
# Skip classes with no 3D coordinates
|
||||
if not any(z is not None for z, _ in self.all_gt_coords.get(class_id, [])):
|
||||
continue
|
||||
|
||||
rows = {}
|
||||
for r in self.distance_ranges:
|
||||
z_range = (r[0], r[1])
|
||||
range_key = f"{r[0]}-{r[1]}m"
|
||||
|
||||
gt_count = self._gt_count(class_id, z_range=z_range, x_range=x_range)
|
||||
metrics = self.get_class_metrics(
|
||||
class_id, conf_threshold, z_range=z_range, x_range=x_range)
|
||||
ap = self.compute_ap(
|
||||
class_id, ap_method, z_range=z_range, x_range=x_range)
|
||||
|
||||
rows[range_key] = dict(
|
||||
precision=metrics['precision'],
|
||||
recall=metrics['recall'],
|
||||
f1_score=metrics['f1_score'],
|
||||
ap=ap,
|
||||
num_gt=gt_count,
|
||||
tp=metrics['tp'],
|
||||
fp=metrics['fp'],
|
||||
fn=metrics['fn'],
|
||||
)
|
||||
table[class_name] = rows
|
||||
return table
|
||||
|
||||
def get_summary(self, conf_threshold=0.5, ap_method='voc2010'):
|
||||
"""Return the complete evaluation summary dict.
|
||||
|
||||
Keys always present:
|
||||
per_class - overall per-class metrics (all objects, no spatial filter)
|
||||
overall - micro-aggregated overall metrics
|
||||
|
||||
Keys present when ``distance_ranges`` is configured:
|
||||
per_class_by_distance - longitudinal bins, all lateral positions
|
||||
per_class_by_distance_lat_roi - longitudinal bins inside lateral_roi
|
||||
(only when ``lateral_roi`` is also set)
|
||||
lateral_roi - the configured [x_min, x_max] value
|
||||
"""
|
||||
from .parser import GroundTruthParser
|
||||
|
||||
summary = {'per_class': {}, 'overall': {}}
|
||||
|
||||
# Per-class overall (no spatial filter)
|
||||
for class_id in range(self.num_classes):
|
||||
class_name = GroundTruthParser.CLASS_NAMES.get(class_id, f"class_{class_id}")
|
||||
metrics = self.get_class_metrics(class_id, conf_threshold)
|
||||
ap = self.compute_ap(class_id, ap_method)
|
||||
summary['per_class'][class_name] = dict(
|
||||
precision=metrics['precision'],
|
||||
recall=metrics['recall'],
|
||||
f1_score=metrics['f1_score'],
|
||||
ap=ap,
|
||||
num_gt=self._gt_count(class_id),
|
||||
num_det=len(self.all_detections.get(class_id, [])),
|
||||
tp=metrics['tp'],
|
||||
fp=metrics['fp'],
|
||||
fn=metrics['fn'],
|
||||
)
|
||||
|
||||
# Overall aggregated
|
||||
overall = self.get_overall_metrics(conf_threshold)
|
||||
summary['overall'] = dict(
|
||||
precision=overall['precision'],
|
||||
recall=overall['recall'],
|
||||
f1_score=overall['f1_score'],
|
||||
map=self.compute_map(ap_method),
|
||||
num_classes=self.num_classes,
|
||||
tp=overall['tp'],
|
||||
fp=overall['fp'],
|
||||
fn=overall['fn'],
|
||||
)
|
||||
|
||||
if self.distance_ranges:
|
||||
# View 1: all lateral positions + longitudinal bins
|
||||
summary['per_class_by_distance'] = self._build_dist_table(
|
||||
conf_threshold, ap_method, x_range=None)
|
||||
|
||||
# View 2: lateral ROI + longitudinal bins
|
||||
if self.lateral_roi is not None:
|
||||
x_range = (self.lateral_roi[0], self.lateral_roi[1])
|
||||
summary['per_class_by_distance_lat_roi'] = self._build_dist_table(
|
||||
conf_threshold, ap_method, x_range=x_range)
|
||||
summary['lateral_roi'] = self.lateral_roi
|
||||
|
||||
return summary
|
||||
410
eval_tools/evaluator/metrics_3d.py
Executable file
410
eval_tools/evaluator/metrics_3d.py
Executable file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
3D metrics calculation module.
|
||||
"""
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
|
||||
from ..class_config import CLASSES_3D as _CLASSES_3D
|
||||
|
||||
|
||||
def normalize_angle(angle):
|
||||
"""
|
||||
Normalize angle to [-π, π].
|
||||
|
||||
Args:
|
||||
angle: float, angle in radians
|
||||
|
||||
Returns:
|
||||
float, normalized angle
|
||||
"""
|
||||
while angle > np.pi:
|
||||
angle -= 2 * np.pi
|
||||
while angle < -np.pi:
|
||||
angle += 2 * np.pi
|
||||
return angle
|
||||
|
||||
|
||||
class Metrics3D:
|
||||
"""Calculate 3D detection metrics (lateral/longitudinal errors, heading error)."""
|
||||
|
||||
# 3D class IDs — sourced from eval_tools/class_config.py
|
||||
CLASSES_3D = _CLASSES_3D # vehicle, bus, truck, tanker, unknown, pedestrian, bicycle, motorcyclist, tricycle
|
||||
VEHICLE_LARGE_CLASS_NAME = 'vehicle_large'
|
||||
VEHICLE_SMALL_CLASS_NAME = 'vehicle_small'
|
||||
VEHICLE_SIZE_CLASS_NAMES = [VEHICLE_LARGE_CLASS_NAME, VEHICLE_SMALL_CLASS_NAME]
|
||||
|
||||
# Face mapping for vehicle class
|
||||
FACE_MAPPING = {
|
||||
'front': [18, 19, 20], # Indices in GT values for front face center
|
||||
'back': [26, 27, 28],
|
||||
'rear': [26, 27, 28], # Alias for back (some models use 'rear')
|
||||
'tail': [26, 27, 28], # Alias for back (some models use 'tail')
|
||||
'left': [34, 35, 36],
|
||||
'right': [42, 43, 44]
|
||||
}
|
||||
|
||||
def __init__(self, distance_ranges=None, lateral_distance_ranges=None, heading_tolerance='strict',
|
||||
coord_system='camera', vehicle_size_split=None):
|
||||
"""Initialize 3D metrics calculator.
|
||||
|
||||
Args:
|
||||
distance_ranges: list of [min, max] longitudinal distance ranges in meters (z-axis).
|
||||
e.g., [[0, 30], [30, 60], [60, 100]]
|
||||
If None, only overall statistics are computed.
|
||||
lateral_distance_ranges: list of [min, max] lateral distance ranges in meters (x-axis).
|
||||
e.g., [[-10, -5], [-5, 0], [0, 5], [5, 10]]
|
||||
If None, no lateral distance grouping is used.
|
||||
heading_tolerance: str, heading error calculation mode
|
||||
'strict': Standard calculation (default)
|
||||
'relaxed': Consider 180° symmetry (for symmetric objects)
|
||||
'both': Calculate both strict and relaxed metrics
|
||||
vehicle_size_split: dict, optional vehicle GT size split config:
|
||||
{
|
||||
'enabled': True,
|
||||
'large_length_threshold': 6.0,
|
||||
'large_height_threshold': 3.0,
|
||||
}
|
||||
"""
|
||||
if coord_system not in ('camera', 'ego'):
|
||||
raise ValueError(f"Unsupported coord_system: {coord_system}")
|
||||
self.coord_system = coord_system
|
||||
vehicle_size_split = vehicle_size_split or {}
|
||||
self.vehicle_size_split_enabled = vehicle_size_split.get('enabled', True)
|
||||
self.vehicle_large_length_threshold = float(
|
||||
vehicle_size_split.get('large_length_threshold', 6.0)
|
||||
)
|
||||
self.vehicle_large_height_threshold = float(
|
||||
vehicle_size_split.get('large_height_threshold', 3.0)
|
||||
)
|
||||
|
||||
# Store errors for each class and distance range
|
||||
# Format: {class_id: {range_key: {'lateral': [...], 'longitudinal': [...], 'heading': [...], 'heading_relaxed': [...], 'heading_reversal': [...]}}}
|
||||
self.errors = defaultdict(lambda: defaultdict(lambda: {
|
||||
'lateral': [],
|
||||
'longitudinal': [],
|
||||
'longitudinal_relative': [],
|
||||
'heading': [],
|
||||
'heading_relaxed': [],
|
||||
'heading_reversal': []
|
||||
}))
|
||||
|
||||
# Distance ranges configuration
|
||||
self.distance_ranges = distance_ranges
|
||||
self.lateral_distance_ranges = lateral_distance_ranges
|
||||
|
||||
# Heading tolerance mode
|
||||
self.heading_tolerance = heading_tolerance
|
||||
|
||||
# Build range keys
|
||||
self.range_keys = ['overall']
|
||||
if self.distance_ranges:
|
||||
self.range_keys.extend([f"long_{r[0]}-{r[1]}m" for r in self.distance_ranges])
|
||||
if self.lateral_distance_ranges:
|
||||
self.range_keys.extend([f"lat_{r[0]}-{r[1]}m" for r in self.lateral_distance_ranges])
|
||||
|
||||
def _get_lateral_axis(self):
|
||||
return 0 if self.coord_system == 'camera' else 1
|
||||
|
||||
def _get_longitudinal_axis(self):
|
||||
return 2 if self.coord_system == 'camera' else 0
|
||||
|
||||
def _get_vehicle_size_class_name(self, gt):
|
||||
"""Classify vehicle GT as large/small by GT 3D box dimensions."""
|
||||
dimensions = (gt.get('3d_info') or {}).get('dimensions')
|
||||
if dimensions is None or len(dimensions) < 2:
|
||||
return None
|
||||
|
||||
length = float(dimensions[0])
|
||||
height = float(dimensions[1])
|
||||
if length > self.vehicle_large_length_threshold and height > self.vehicle_large_height_threshold:
|
||||
return self.VEHICLE_LARGE_CLASS_NAME
|
||||
return self.VEHICLE_SMALL_CLASS_NAME
|
||||
|
||||
def _get_storage_class_keys(self, gt, class_id):
|
||||
"""Return all metric buckets that should receive this sample."""
|
||||
class_keys = [class_id]
|
||||
if class_id == 0 and self.vehicle_size_split_enabled:
|
||||
vehicle_size_class = self._get_vehicle_size_class_name(gt)
|
||||
if vehicle_size_class is not None:
|
||||
class_keys.append(vehicle_size_class)
|
||||
return class_keys
|
||||
|
||||
def _append_error_sample(self, class_key, range_key, lateral_error, longitudinal_error,
|
||||
longitudinal_relative_error, heading_error_strict,
|
||||
heading_error_relaxed, is_reversal):
|
||||
self.errors[class_key][range_key]['lateral'].append(lateral_error)
|
||||
self.errors[class_key][range_key]['longitudinal'].append(longitudinal_error)
|
||||
self.errors[class_key][range_key]['longitudinal_relative'].append(longitudinal_relative_error)
|
||||
self.errors[class_key][range_key]['heading'].append(heading_error_strict)
|
||||
self.errors[class_key][range_key]['heading_relaxed'].append(heading_error_relaxed)
|
||||
self.errors[class_key][range_key]['heading_reversal'].append(1 if is_reversal else 0)
|
||||
|
||||
def add_sample(self, gt, det, class_id):
|
||||
"""
|
||||
Add a matched GT-detection pair for 3D metric calculation.
|
||||
|
||||
Args:
|
||||
gt: dict, ground truth with 3d_info
|
||||
det: dict, detection with 3d_info
|
||||
class_id: int, class ID
|
||||
"""
|
||||
if class_id not in self.CLASSES_3D:
|
||||
return
|
||||
|
||||
if gt['3d_info'] is None or det['3d_info'] is None:
|
||||
return
|
||||
|
||||
# Get GT 3D center based on class type
|
||||
if class_id == 0: # Vehicle class
|
||||
# Use face-specific center
|
||||
gt_center = self._get_vehicle_face_center(gt['3d_info'], det['3d_info']['face_type'])
|
||||
else:
|
||||
# Non-vehicle: use overall 3D box center
|
||||
gt_center = gt['3d_info']['center']
|
||||
|
||||
# Get detection 3D center
|
||||
det_center = det['3d_info']['center']
|
||||
|
||||
lateral_axis = self._get_lateral_axis()
|
||||
longitudinal_axis = self._get_longitudinal_axis()
|
||||
|
||||
# Calculate lateral / longitudinal error in the configured coordinate system.
|
||||
lateral_error = abs(det_center[lateral_axis] - gt_center[lateral_axis])
|
||||
longitudinal_error = abs(det_center[longitudinal_axis] - gt_center[longitudinal_axis])
|
||||
|
||||
# Calculate longitudinal relative error (relative to GT depth).
|
||||
# Use whole-object center z as the reference depth so that face centers
|
||||
# with near-zero or negative z (e.g. side faces at close range) don't
|
||||
# produce astronomically large relative errors.
|
||||
gt_whole_longitudinal = gt['3d_info']['center'][longitudinal_axis]
|
||||
gt_depth = max(abs(gt_whole_longitudinal), 1e-6)
|
||||
longitudinal_relative_error = longitudinal_error / gt_depth
|
||||
|
||||
# Calculate heading error
|
||||
gt_rot = gt['3d_info']['rotation']
|
||||
det_rot = det['3d_info']['rotation']
|
||||
heading_error_strict = abs(normalize_angle(det_rot - gt_rot))
|
||||
|
||||
# Calculate relaxed heading error (considering 180° symmetry)
|
||||
heading_error_relaxed = heading_error_strict
|
||||
is_reversal = False
|
||||
if heading_error_strict > np.pi - 0.2: # Close to 180° (within ~170°)
|
||||
heading_error_relaxed = np.pi - heading_error_strict
|
||||
is_reversal = True
|
||||
|
||||
# Get distance for range classification.
|
||||
# Always use whole-object center z so that samples with negative/near-zero
|
||||
# face center z (side faces at close range) still fall into the correct
|
||||
# longitudinal bucket instead of being silently dropped from all segments
|
||||
# while remaining in 'overall' with enormous relative errors.
|
||||
longitudinal_distance = gt['3d_info']['center'][longitudinal_axis]
|
||||
lateral_distance = gt_center[lateral_axis]
|
||||
|
||||
class_keys = self._get_storage_class_keys(gt, class_id)
|
||||
|
||||
# Store errors in overall
|
||||
for class_key in class_keys:
|
||||
self._append_error_sample(
|
||||
class_key,
|
||||
'overall',
|
||||
lateral_error,
|
||||
longitudinal_error,
|
||||
longitudinal_relative_error,
|
||||
heading_error_strict,
|
||||
heading_error_relaxed,
|
||||
is_reversal,
|
||||
)
|
||||
|
||||
# Store errors by longitudinal distance range if configured
|
||||
if self.distance_ranges:
|
||||
range_key = self._get_longitudinal_distance_range(longitudinal_distance)
|
||||
if range_key:
|
||||
for class_key in class_keys:
|
||||
self._append_error_sample(
|
||||
class_key,
|
||||
range_key,
|
||||
lateral_error,
|
||||
longitudinal_error,
|
||||
longitudinal_relative_error,
|
||||
heading_error_strict,
|
||||
heading_error_relaxed,
|
||||
is_reversal,
|
||||
)
|
||||
|
||||
# Store errors by lateral distance range if configured
|
||||
if self.lateral_distance_ranges:
|
||||
range_key = self._get_lateral_distance_range(lateral_distance)
|
||||
if range_key:
|
||||
for class_key in class_keys:
|
||||
self._append_error_sample(
|
||||
class_key,
|
||||
range_key,
|
||||
lateral_error,
|
||||
longitudinal_error,
|
||||
longitudinal_relative_error,
|
||||
heading_error_strict,
|
||||
heading_error_relaxed,
|
||||
is_reversal,
|
||||
)
|
||||
|
||||
def _get_vehicle_face_center(self, gt_3d_info, face_type):
|
||||
"""
|
||||
Get vehicle face center from GT based on predicted face type.
|
||||
|
||||
Args:
|
||||
gt_3d_info: dict, GT 3D info with faces
|
||||
face_type: str, predicted face type (front/back/rear/tail/left/right)
|
||||
|
||||
Returns:
|
||||
list, [x3d, y3d, z3d] of the specified face center
|
||||
"""
|
||||
if gt_3d_info['faces'] is None:
|
||||
# Fallback to overall center if no face info
|
||||
return gt_3d_info['center']
|
||||
|
||||
# Normalize face_type: rear/tail -> back
|
||||
normalized_face_type = face_type.lower()
|
||||
if normalized_face_type in ['rear', 'tail']:
|
||||
normalized_face_type = 'back'
|
||||
|
||||
face_data = gt_3d_info['faces'].get(normalized_face_type)
|
||||
# face_data[7] is is_visible_from_camera; when 0 the x3d/y3d/z3d are set to
|
||||
# the sentinel -1.0, which must not be used as a real coordinate.
|
||||
if face_data is None or (len(face_data) >= 8 and face_data[7] == 0) or face_data[2] <= 0:
|
||||
# Fallback to overall center if face not found or not visible
|
||||
return gt_3d_info['center']
|
||||
|
||||
# Extract x3d, y3d, z3d from face data (first 3 elements)
|
||||
return face_data[:3]
|
||||
|
||||
def _get_longitudinal_distance_range(self, distance):
|
||||
"""Get longitudinal distance range key for a given distance.
|
||||
|
||||
Args:
|
||||
distance: float, longitudinal distance in meters (z-axis)
|
||||
|
||||
Returns:
|
||||
str, range key (e.g., 'long_0-30m') or None if not in any range
|
||||
"""
|
||||
if not self.distance_ranges:
|
||||
return None
|
||||
|
||||
for range_min, range_max in self.distance_ranges:
|
||||
if range_min <= distance < range_max:
|
||||
return f"long_{range_min}-{range_max}m"
|
||||
|
||||
return None
|
||||
|
||||
def _get_lateral_distance_range(self, distance):
|
||||
"""Get lateral distance range key for a given distance.
|
||||
|
||||
Args:
|
||||
distance: float, lateral distance in meters (x-axis)
|
||||
|
||||
Returns:
|
||||
str, range key (e.g., 'lat_-10--5m') or None if not in any range
|
||||
"""
|
||||
if not self.lateral_distance_ranges:
|
||||
return None
|
||||
|
||||
for range_min, range_max in self.lateral_distance_ranges:
|
||||
if range_min <= distance < range_max:
|
||||
return f"lat_{range_min}-{range_max}m"
|
||||
|
||||
return None
|
||||
|
||||
def compute_statistics(self, class_id, range_key='overall'):
|
||||
"""
|
||||
Compute statistical metrics for a class and distance range.
|
||||
|
||||
Args:
|
||||
class_id: int | str, class ID or synthetic class key
|
||||
range_key: str, distance range key (e.g., 'overall', '0-30m')
|
||||
|
||||
Returns:
|
||||
dict with mean, median, std, 90th percentile for each error type
|
||||
"""
|
||||
if class_id not in self.errors or range_key not in self.errors[class_id] or \
|
||||
len(self.errors[class_id][range_key]['lateral']) == 0:
|
||||
result = {
|
||||
'lateral_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
|
||||
'longitudinal_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
|
||||
'longitudinal_relative_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
|
||||
'heading_error': {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0},
|
||||
'num_samples': 0
|
||||
}
|
||||
if self.heading_tolerance in ['relaxed', 'both']:
|
||||
result['heading_error_relaxed'] = {'mean': 0, 'median': 0, 'std': 0, 'percentile_90': 0}
|
||||
result['reversal_count'] = 0
|
||||
result['reversal_percentage'] = 0.0
|
||||
return result
|
||||
|
||||
lateral = np.array(self.errors[class_id][range_key]['lateral'])
|
||||
longitudinal = np.array(self.errors[class_id][range_key]['longitudinal'])
|
||||
longitudinal_relative = np.array(self.errors[class_id][range_key]['longitudinal_relative'])
|
||||
heading = np.array(self.errors[class_id][range_key]['heading'])
|
||||
heading_relaxed = np.array(self.errors[class_id][range_key]['heading_relaxed'])
|
||||
heading_reversal = np.array(self.errors[class_id][range_key]['heading_reversal'])
|
||||
|
||||
def stats(arr):
|
||||
return {
|
||||
'mean': float(np.mean(arr)),
|
||||
'median': float(np.median(arr)),
|
||||
'std': float(np.std(arr)),
|
||||
'percentile_90': float(np.percentile(arr, 90))
|
||||
}
|
||||
|
||||
result = {
|
||||
'lateral_error': stats(lateral),
|
||||
'longitudinal_error': stats(longitudinal),
|
||||
'longitudinal_relative_error': stats(longitudinal_relative),
|
||||
'num_samples': len(lateral)
|
||||
}
|
||||
|
||||
# Add heading error based on tolerance mode
|
||||
if self.heading_tolerance == 'strict':
|
||||
result['heading_error'] = stats(heading)
|
||||
elif self.heading_tolerance == 'relaxed':
|
||||
result['heading_error'] = stats(heading_relaxed)
|
||||
result['reversal_count'] = int(np.sum(heading_reversal))
|
||||
result['reversal_percentage'] = float(np.sum(heading_reversal) / len(heading_reversal) * 100)
|
||||
elif self.heading_tolerance == 'both':
|
||||
result['heading_error'] = stats(heading)
|
||||
result['heading_error_relaxed'] = stats(heading_relaxed)
|
||||
result['reversal_count'] = int(np.sum(heading_reversal))
|
||||
result['reversal_percentage'] = float(np.sum(heading_reversal) / len(heading_reversal) * 100)
|
||||
|
||||
return result
|
||||
|
||||
def get_summary(self):
|
||||
"""
|
||||
Get complete 3D evaluation summary.
|
||||
|
||||
Returns:
|
||||
dict with per-class 3D metrics, including distance ranges if configured
|
||||
"""
|
||||
from .parser import GroundTruthParser
|
||||
|
||||
summary = {}
|
||||
|
||||
summary_class_keys = list(self.CLASSES_3D)
|
||||
if self.vehicle_size_split_enabled:
|
||||
summary_class_keys.extend(self.VEHICLE_SIZE_CLASS_NAMES)
|
||||
|
||||
for class_id in summary_class_keys:
|
||||
if isinstance(class_id, str):
|
||||
class_name = class_id
|
||||
else:
|
||||
class_name = GroundTruthParser.CLASS_NAMES.get(class_id, f"class_{class_id}")
|
||||
|
||||
# Compute statistics for each distance range
|
||||
class_summary = {}
|
||||
for range_key in self.range_keys:
|
||||
stats = self.compute_statistics(class_id, range_key)
|
||||
if stats['num_samples'] > 0 or range_key == 'overall':
|
||||
class_summary[range_key] = stats
|
||||
|
||||
if class_summary:
|
||||
summary[class_name] = class_summary
|
||||
|
||||
return summary
|
||||
588
eval_tools/evaluator/parser.py
Executable file
588
eval_tools/evaluator/parser.py
Executable file
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
Data parser for ground truth and detection results.
|
||||
Supports both TXT and JSON formats.
|
||||
|
||||
TXT GT format: normalized xywh bbox + 47-dim 3D labels (47-dim label format per CLAUDE.md)
|
||||
JSON GT format: absolute pixel box2d, 3d_ori=[x3d,y3d,z3d,l,h,w,rot_y,xc,yc,...], 3d_front/back/left/right=[x3d,y3d,z3d,alpha,xc,yc,score,is_visible]
|
||||
|
||||
TXT Det format: class_name conf x1 y1 x2 y2 coord_sys x3d y3d z3d l h w rot_y face_type
|
||||
JSON Det format: type/type_name, score, box2d (absolute pixels), xyzlhwyaw=[x3d,y3d,z3d,l,h,w,rot_y], face_cls
|
||||
"""
|
||||
import json
|
||||
import numpy as np
|
||||
|
||||
from ..class_config import CLASS_NAMES, CLASS_NAME_TO_ID, CLASSES_3D, FACE_3D_CLASSES
|
||||
|
||||
|
||||
def yaw_to_radians(yaw_value, coord_system):
|
||||
"""Convert parsed yaw to radians for downstream evaluation."""
|
||||
yaw = float(yaw_value)
|
||||
if coord_system == 'ego':
|
||||
return float(np.deg2rad(yaw))
|
||||
return yaw
|
||||
|
||||
|
||||
class GroundTruthParser:
|
||||
"""Parse ground truth annotation files."""
|
||||
|
||||
# Class ID to name mapping — imported from eval_tools/class_config.py
|
||||
CLASS_NAMES = CLASS_NAMES
|
||||
|
||||
# 3D classes — imported from eval_tools/class_config.py
|
||||
CLASSES_3D = CLASSES_3D
|
||||
VALID_COORD_SYSTEMS = {"camera", "ego"}
|
||||
|
||||
def __init__(self, min_box_size=8, coord_system='camera'):
|
||||
"""
|
||||
Initialize ground truth parser.
|
||||
|
||||
Args:
|
||||
min_box_size: float, minimum bbox width or height in pixels.
|
||||
Boxes smaller than this will be filtered out.
|
||||
Default is 8. Should be calculated based on ROI config:
|
||||
- ROI0 (1920->704): 8 * 1920 / 704 ≈ 21.8
|
||||
- ROI1 (704->704): 8 * 704 / 704 = 8
|
||||
"""
|
||||
self.min_box_size = min_box_size
|
||||
if coord_system not in self.VALID_COORD_SYSTEMS:
|
||||
raise ValueError(f"Unsupported coord_system: {coord_system}")
|
||||
self.coord_system = coord_system
|
||||
|
||||
def parse_line(self, line, img_width, img_height):
|
||||
"""
|
||||
Parse a single line of ground truth annotation.
|
||||
|
||||
Args:
|
||||
line: str, annotation line
|
||||
img_width: int, image width
|
||||
img_height: int, image height
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- label: int
|
||||
- bbox_2d: [x1, y1, x2, y2] in pixel coordinates
|
||||
- has_3d: bool
|
||||
- 3d_info: dict or None
|
||||
"""
|
||||
values = [float(x) for x in line.strip().split()]
|
||||
|
||||
if len(values) < 6:
|
||||
return None
|
||||
|
||||
label = int(values[0])
|
||||
|
||||
# Parse 2D bbox (normalized center + width/height to pixel corners)
|
||||
x_center_norm, y_center_norm = values[1], values[2]
|
||||
w_norm, h_norm = values[3], values[4]
|
||||
|
||||
x_center_px = x_center_norm * img_width
|
||||
y_center_px = y_center_norm * img_height
|
||||
w_px = w_norm * img_width
|
||||
h_px = h_norm * img_height
|
||||
|
||||
x1 = x_center_px - w_px / 2
|
||||
y1 = y_center_px - h_px / 2
|
||||
x2 = x_center_px + w_px / 2
|
||||
y2 = y_center_px + h_px / 2
|
||||
|
||||
bbox_2d = [x1, y1, x2, y2]
|
||||
|
||||
# Filter out small objects based on configured minimum size
|
||||
bbox_width = x2 - x1
|
||||
bbox_height = y2 - y1
|
||||
if bbox_width < self.min_box_size or bbox_height < self.min_box_size:
|
||||
return None
|
||||
|
||||
# Check if has 3D annotation
|
||||
has_3d = self.is_3d_annotated(values)
|
||||
|
||||
result = {
|
||||
'label': label,
|
||||
'bbox_2d': bbox_2d,
|
||||
'has_3d': has_3d,
|
||||
'3d_info': None
|
||||
}
|
||||
|
||||
if has_3d:
|
||||
result['3d_info'] = self._parse_3d_info(values, label)
|
||||
|
||||
return result
|
||||
|
||||
def is_3d_annotated(self, values):
|
||||
"""Check if the annotation contains 3D information."""
|
||||
if len(values) == 6 and values[5] == -1:
|
||||
return False
|
||||
if len(values) >= 18:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _parse_3d_info(self, values, label):
|
||||
"""Parse 3D information from annotation values."""
|
||||
info = {
|
||||
'center': [values[5], values[6], values[7]], # x3d_ori, y3d_ori, z3d_ori
|
||||
'dimensions': [values[8], values[9], values[10]], # l3d, h3d, w3d
|
||||
'rotation': values[11], # rot_y
|
||||
'faces': None
|
||||
}
|
||||
|
||||
# For face_3d_classes, parse face information
|
||||
if label in FACE_3D_CLASSES and len(values) == 50:
|
||||
info['faces'] = {
|
||||
'front': values[18:26], # x3d, y3d, z3d, alpha, xc, yc, score, is_occ
|
||||
'back': values[26:34],
|
||||
'left': values[34:42],
|
||||
'right': values[42:50]
|
||||
}
|
||||
|
||||
return info
|
||||
|
||||
def get_class_name(self, label_id):
|
||||
"""Get class name from label ID."""
|
||||
return self.CLASS_NAMES.get(label_id, "unknown")
|
||||
|
||||
def _should_filter_negative_id_gt(self, entry, label):
|
||||
"""
|
||||
Filter JSON GT objects that should not participate in 3D-class evaluation.
|
||||
|
||||
Rule:
|
||||
- Only applies to 3D classes: vehicle, pedestrian, bicycle, rider
|
||||
- If GT carries an `id` field and id < 0, drop this GT entirely
|
||||
"""
|
||||
if label not in self.CLASSES_3D:
|
||||
return False
|
||||
|
||||
object_id = entry.get('id')
|
||||
if object_id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return int(object_id) < 0
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def parse_gt_json_entry(self, entry, img_width, img_height):
|
||||
"""
|
||||
Parse a single entry from a GT JSON file.
|
||||
|
||||
GT JSON entry format:
|
||||
{
|
||||
"type": "0", # class id string
|
||||
"type_name": "vehicle",
|
||||
"roi_id": "1",
|
||||
"box2d": ["x1","y1","x2","y2"], # absolute pixel coords
|
||||
"3d_ori": ["x3d","y3d","z3d","l","h","w","rot_y","xc","yc",...,"alpha","flag"],
|
||||
"3d_front": ["x3d","y3d","z3d","alpha","xc","yc","score","is_visible"],
|
||||
"3d_back": [...],
|
||||
"3d_left": [...],
|
||||
"3d_right": [...]
|
||||
}
|
||||
|
||||
Args:
|
||||
entry: dict, single GT JSON entry
|
||||
img_width: int, image width (unused for JSON, bbox already in pixels)
|
||||
img_height: int, image height (unused for JSON, bbox already in pixels)
|
||||
|
||||
Returns:
|
||||
dict or None
|
||||
"""
|
||||
raw_type = entry.get('type')
|
||||
if raw_type is None or str(raw_type).strip().lower() in ('', 'none', 'null'):
|
||||
return None
|
||||
try:
|
||||
label = int(raw_type)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if self._should_filter_negative_id_gt(entry, label):
|
||||
return None
|
||||
|
||||
box2d = entry.get('box2d', [])
|
||||
if len(box2d) < 4:
|
||||
return None
|
||||
x1, y1, x2, y2 = float(box2d[0]), float(box2d[1]), float(box2d[2]), float(box2d[3])
|
||||
bbox_2d = [x1, y1, x2, y2]
|
||||
|
||||
# Filter small objects
|
||||
if (x2 - x1) < self.min_box_size or (y2 - y1) < self.min_box_size:
|
||||
return None
|
||||
|
||||
# Check whether 3D annotation is present and valid
|
||||
ori_key = '3d_ori_ego' if self.coord_system == 'ego' else '3d_ori'
|
||||
if self.coord_system == 'ego' and ori_key not in entry and '3d_ori' in entry:
|
||||
has_camera_3d = False
|
||||
try:
|
||||
has_camera_3d = len(entry['3d_ori']) >= 7 and float(entry['3d_ori'][0]) != -1
|
||||
except (ValueError, TypeError):
|
||||
has_camera_3d = False
|
||||
if has_camera_3d:
|
||||
raise ValueError(
|
||||
"GT JSON is missing ego-coordinate fields (3d_ori_ego). "
|
||||
"Please regenerate ground truth with ego fields before running ego-coordinate evaluation."
|
||||
)
|
||||
d3_ori = entry.get(ori_key)
|
||||
has_3d = False
|
||||
d3_info = None
|
||||
if d3_ori is not None and len(d3_ori) >= 7:
|
||||
# x3d is d3_ori[0]; -1 indicates no 3D annotation
|
||||
try:
|
||||
has_3d = float(d3_ori[0]) != -1
|
||||
except (ValueError, TypeError):
|
||||
has_3d = False
|
||||
|
||||
if has_3d:
|
||||
d3_info = self._parse_3d_info_from_json(entry, label)
|
||||
|
||||
return {
|
||||
'label': label,
|
||||
'bbox_2d': bbox_2d,
|
||||
'has_3d': has_3d,
|
||||
'3d_info': d3_info,
|
||||
'id': entry.get('id'),
|
||||
}
|
||||
|
||||
def _parse_3d_info_from_json(self, entry, label):
|
||||
"""Parse 3D information from a JSON GT entry."""
|
||||
ori_key = '3d_ori_ego' if self.coord_system == 'ego' else '3d_ori'
|
||||
d3_ori = entry[ori_key]
|
||||
info = {
|
||||
'center': [float(d3_ori[0]), float(d3_ori[1]), float(d3_ori[2])], # x3d, y3d, z3d
|
||||
'dimensions': [float(d3_ori[3]), float(d3_ori[4]), float(d3_ori[5])], # l, h, w
|
||||
'rotation': yaw_to_radians(d3_ori[6], self.coord_system), # rot_y
|
||||
'faces': None,
|
||||
'coord_system': self.coord_system,
|
||||
}
|
||||
|
||||
# Parse face information for face_3d_classes (vehicle, bus, truck, tanker, unknown)
|
||||
face_keys = {'front': '3d_front', 'back': '3d_back', 'left': '3d_left', 'right': '3d_right'}
|
||||
if self.coord_system == 'ego':
|
||||
face_keys = {name: f"{key}_ego" for name, key in face_keys.items()}
|
||||
if label in FACE_3D_CLASSES and all(k in entry for k in face_keys.values()):
|
||||
info['faces'] = {}
|
||||
for face_name, json_key in face_keys.items():
|
||||
face_data = entry[json_key]
|
||||
if len(face_data) >= 8:
|
||||
info['faces'][face_name] = [float(v) for v in face_data[:8]]
|
||||
else:
|
||||
info['faces'][face_name] = [float(v) for v in face_data]
|
||||
|
||||
return info
|
||||
|
||||
def parse_gt_json_file(self, file_path, img_width, img_height):
|
||||
"""
|
||||
Parse an entire GT JSON file.
|
||||
|
||||
The JSON file is a dict keyed by object index ("0", "1", ...).
|
||||
|
||||
Returns:
|
||||
list of parsed annotation dicts
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: File not found: {file_path}")
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Warning: JSON decode error in {file_path}: {e}")
|
||||
return []
|
||||
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
if isinstance(data, dict):
|
||||
items = sorted(data.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else str(item[0]))
|
||||
elif isinstance(data, list):
|
||||
items = list(enumerate(data))
|
||||
else:
|
||||
print(f"Warning: unsupported GT JSON root type {type(data).__name__} in {file_path}")
|
||||
return []
|
||||
|
||||
annotations = []
|
||||
for key, entry in items:
|
||||
if not isinstance(entry, dict):
|
||||
print(f"Warning: skipping non-dict GT entry at key={key!r} in {file_path}")
|
||||
continue
|
||||
raw_type = entry.get('type')
|
||||
if raw_type is None or str(raw_type).strip().lower() in ('', 'none', 'null'):
|
||||
print(f"Warning: skipping entry with invalid type={raw_type!r} "
|
||||
f"(key={key!r}) in {file_path}")
|
||||
continue
|
||||
parsed = self.parse_gt_json_entry(entry, img_width, img_height)
|
||||
if parsed is not None:
|
||||
annotations.append(parsed)
|
||||
return annotations
|
||||
|
||||
def parse_file(self, file_path, img_width, img_height):
|
||||
"""
|
||||
Parse entire annotation file. Dispatches to JSON or TXT parser based on extension.
|
||||
|
||||
Args:
|
||||
file_path: str, path to annotation file (.txt or .json)
|
||||
img_width: int, image width
|
||||
img_height: int, image height
|
||||
|
||||
Returns:
|
||||
list of parsed annotations
|
||||
"""
|
||||
if str(file_path).endswith('.json'):
|
||||
return self.parse_gt_json_file(file_path, img_width, img_height)
|
||||
|
||||
annotations = []
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parsed = self.parse_line(line, img_width, img_height)
|
||||
if parsed is not None:
|
||||
annotations.append(parsed)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: File not found: {file_path}")
|
||||
return []
|
||||
|
||||
return annotations
|
||||
|
||||
|
||||
class DetectionParser:
|
||||
"""Parse detection result files."""
|
||||
|
||||
# Class name to ID mapping — imported from eval_tools/class_config.py
|
||||
CLASS_NAME_TO_ID = CLASS_NAME_TO_ID
|
||||
|
||||
# 3D classes — imported from eval_tools/class_config.py
|
||||
CLASSES_3D = CLASSES_3D
|
||||
VALID_COORD_SYSTEMS = {"camera", "ego"}
|
||||
|
||||
def __init__(self, min_box_size=0, coord_system='camera'):
|
||||
"""
|
||||
Initialize detection parser.
|
||||
|
||||
Args:
|
||||
min_box_size: float, minimum bbox width or height in pixels.
|
||||
Detections smaller than this will be filtered out.
|
||||
Should match the GT min_box_size to ensure
|
||||
symmetric filtering. Default is 0 (no filtering).
|
||||
"""
|
||||
self.min_box_size = min_box_size
|
||||
if coord_system not in self.VALID_COORD_SYSTEMS:
|
||||
raise ValueError(f"Unsupported coord_system: {coord_system}")
|
||||
self.coord_system = coord_system
|
||||
|
||||
def parse_line(self, line):
|
||||
"""
|
||||
Parse a single line of detection result.
|
||||
|
||||
Args:
|
||||
line: str, detection line
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- label: int
|
||||
- confidence: float
|
||||
- bbox_2d: [x1, y1, x2, y2] in pixel coordinates
|
||||
- 3d_info: dict or None
|
||||
"""
|
||||
parts = line.strip().split()
|
||||
|
||||
if len(parts) < 6:
|
||||
return None
|
||||
|
||||
class_name = parts[0]
|
||||
label = self.map_class_name(class_name)
|
||||
confidence = float(parts[1])
|
||||
|
||||
# 2D bbox
|
||||
x1, y1, x2, y2 = float(parts[2]), float(parts[3]), float(parts[4]), float(parts[5])
|
||||
bbox_2d = [x1, y1, x2, y2]
|
||||
|
||||
# Filter small detections
|
||||
if self.min_box_size > 0 and ((x2 - x1) < self.min_box_size or (y2 - y1) < self.min_box_size):
|
||||
return None
|
||||
|
||||
result = {
|
||||
'label': label,
|
||||
'confidence': confidence,
|
||||
'bbox_2d': bbox_2d,
|
||||
'3d_info': None
|
||||
}
|
||||
|
||||
# Check if this is a 3D class and has 3D info
|
||||
if label in self.CLASSES_3D and len(parts) >= 15:
|
||||
result['3d_info'] = self._parse_3d_info(parts)
|
||||
|
||||
return result
|
||||
|
||||
def _parse_3d_info(self, parts):
|
||||
"""Parse 3D information from detection parts."""
|
||||
if self.coord_system == 'ego':
|
||||
raise ValueError("TXT detection format does not support ego-coordinate 3D evaluation.")
|
||||
# Format: label conf x1 y1 x2 y2 coord_sys x3d y3d z3d l3d h3d w3d rot_y face_type
|
||||
# Index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
||||
|
||||
# Get face_type and normalize it
|
||||
face_type = parts[14] if len(parts) > 14 else 'whole'
|
||||
# Normalize rear/tail to back for consistency
|
||||
if face_type.lower() in ['rear', 'tail']:
|
||||
face_type = 'back'
|
||||
|
||||
info = {
|
||||
'center': [float(parts[7]), float(parts[8]), float(parts[9])], # x3d, y3d, z3d
|
||||
'dimensions': [float(parts[10]), float(parts[11]), float(parts[12])], # l3d, h3d, w3d
|
||||
'rotation': float(parts[13]), # rot_y
|
||||
'face_type': face_type,
|
||||
'coord_system': 'camera',
|
||||
}
|
||||
|
||||
return info
|
||||
|
||||
def map_class_name(self, name_str):
|
||||
"""Map class name string to class ID."""
|
||||
return self.CLASS_NAME_TO_ID.get(name_str.lower(), -1)
|
||||
|
||||
def parse_det_json_entry(self, entry):
|
||||
"""
|
||||
Parse a single entry from a detection JSON file.
|
||||
|
||||
Det JSON entry format:
|
||||
{
|
||||
"type": "0", # class id string
|
||||
"type_name": "vehicle",
|
||||
"score": "0.93",
|
||||
"roi_id": "0",
|
||||
"box2d": ["x1","y1","x2","y2"], # absolute pixel coords
|
||||
"xyzlhwyaw": ["x3d","y3d","z3d","l","h","w","rot_y"],
|
||||
"face_cls": "front", # front/tail/rear/left/right/whole/none
|
||||
"cut_cls": "0",
|
||||
"cut_cls_name": "nocut"
|
||||
}
|
||||
|
||||
Returns:
|
||||
dict or None
|
||||
"""
|
||||
try:
|
||||
label = int(entry['type'])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
class_name = entry.get('type_name', '')
|
||||
label = self.map_class_name(class_name)
|
||||
|
||||
try:
|
||||
confidence = float(entry['score'])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
confidence = 0.0
|
||||
|
||||
box2d = entry.get('box2d', [])
|
||||
if len(box2d) < 4:
|
||||
return None
|
||||
x1, y1, x2, y2 = float(box2d[0]), float(box2d[1]), float(box2d[2]), float(box2d[3])
|
||||
bbox_2d = [x1, y1, x2, y2]
|
||||
|
||||
# Filter small detections
|
||||
if self.min_box_size > 0 and ((x2 - x1) < self.min_box_size or (y2 - y1) < self.min_box_size):
|
||||
return None
|
||||
|
||||
result = {
|
||||
'label': label,
|
||||
'confidence': confidence,
|
||||
'bbox_2d': bbox_2d,
|
||||
'3d_info': None,
|
||||
'id': entry.get('track_id', entry.get('id')),
|
||||
'roi_id': self._normalize_roi_id(entry.get('roi_id')),
|
||||
}
|
||||
|
||||
# Parse 3D info for 3D classes
|
||||
if label in self.CLASSES_3D:
|
||||
xyz_key = 'xyzlhwyaw_ego' if self.coord_system == 'ego' else 'xyzlhwyaw'
|
||||
xyzlhwyaw = entry.get(xyz_key, [])
|
||||
using_coord_system = self.coord_system
|
||||
if len(xyzlhwyaw) < 7 and self.coord_system == 'ego':
|
||||
has_camera_3d = False
|
||||
camera_xyz = entry.get('xyzlhwyaw', [])
|
||||
try:
|
||||
has_camera_3d = len(camera_xyz) >= 7 and str(camera_xyz[0]) != '-1'
|
||||
except (ValueError, TypeError):
|
||||
has_camera_3d = False
|
||||
if has_camera_3d:
|
||||
raise ValueError(
|
||||
"Detection JSON is missing ego-coordinate fields (xyzlhwyaw_ego). "
|
||||
"Please export ego-coordinate detection results before running ego-coordinate evaluation."
|
||||
)
|
||||
if len(xyzlhwyaw) >= 7 and str(xyzlhwyaw[0]) != '-1':
|
||||
face_type = entry.get('face_cls', 'whole') or 'whole'
|
||||
if face_type.lower() in ('rear', 'tail'):
|
||||
face_type = 'back'
|
||||
result['3d_info'] = {
|
||||
'center': [float(xyzlhwyaw[0]), float(xyzlhwyaw[1]), float(xyzlhwyaw[2])],
|
||||
'dimensions': [float(xyzlhwyaw[3]), float(xyzlhwyaw[4]), float(xyzlhwyaw[5])],
|
||||
'rotation': yaw_to_radians(xyzlhwyaw[6], using_coord_system),
|
||||
'face_type': face_type,
|
||||
'coord_system': using_coord_system,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@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 parse_det_json_file(self, file_path):
|
||||
"""
|
||||
Parse an entire detection JSON file.
|
||||
|
||||
The JSON file is a dict keyed by object index ("0", "1", ...).
|
||||
|
||||
Returns:
|
||||
list of parsed detection dicts
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: File not found: {file_path}")
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Warning: JSON decode error in {file_path}: {e}")
|
||||
return []
|
||||
|
||||
detections = []
|
||||
for key in sorted(data.keys(), key=lambda k: int(k) if k.isdigit() else k):
|
||||
parsed = self.parse_det_json_entry(data[key])
|
||||
if parsed is not None:
|
||||
detections.append(parsed)
|
||||
return detections
|
||||
|
||||
def parse_file(self, file_path):
|
||||
"""
|
||||
Parse entire detection file. Dispatches to JSON or TXT parser based on extension.
|
||||
|
||||
Args:
|
||||
file_path: str, path to detection file (.txt or .json)
|
||||
|
||||
Returns:
|
||||
list of parsed detections
|
||||
"""
|
||||
if str(file_path).endswith('.json'):
|
||||
return self.parse_det_json_file(file_path)
|
||||
|
||||
detections = []
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parsed = self.parse_line(line)
|
||||
if parsed is not None:
|
||||
detections.append(parsed)
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: File not found: {file_path}")
|
||||
return []
|
||||
|
||||
return detections
|
||||
343
eval_tools/evaluator/roi_processor.py
Executable file
343
eval_tools/evaluator/roi_processor.py
Executable file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
ROI (Region of Interest) processor for ground truth labels.
|
||||
|
||||
This module handles ROI computation and ground truth filtering/clipping
|
||||
to match the training-time ROI processing logic.
|
||||
"""
|
||||
import numpy as np
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ROIProcessor:
|
||||
"""Process ground truth labels with ROI filtering and clipping."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calib_root=None,
|
||||
roi_config=None,
|
||||
ori_img_size=(1920, 1080),
|
||||
roi_bottom_offset=0,
|
||||
roi_right_offset=0,
|
||||
roi_use_true_vp_x=False,
|
||||
):
|
||||
"""
|
||||
Initialize ROI processor.
|
||||
|
||||
Args:
|
||||
calib_root: str or Path, root directory containing calibration files
|
||||
roi_config: dict or list, ROI configuration
|
||||
- If dict: {'mode': 'size', 'width': 1920, 'height': 960} or
|
||||
{'mode': 'bounds', 'x1': 0, 'y1': 120, 'x2': 1920, 'y2': 1080}
|
||||
- If list of 2 values [width, height]: ROI size mode
|
||||
- If list of 4 values [x1, y1, x2, y2]: ROI bounds mode
|
||||
ori_img_size: tuple, original image size (width, height)
|
||||
roi_bottom_offset: int, pixels to trim from the bottom edge of the ROI (shifts y2 upward)
|
||||
roi_right_offset: int, pixels to trim from the right edge of the ROI (shifts x2 leftward)
|
||||
roi_use_true_vp_x: bool, use geometric vanishing point X as crop center for ROI1-style crop
|
||||
"""
|
||||
self.calib_root = Path(calib_root) if calib_root else None
|
||||
self.roi_config = self._parse_roi_config(roi_config)
|
||||
self.ori_img_size = ori_img_size
|
||||
self.roi_bottom_offset = roi_bottom_offset
|
||||
self.roi_right_offset = roi_right_offset
|
||||
self.roi_use_true_vp_x = roi_use_true_vp_x
|
||||
self.calib_cache = {} # Cache calibration parameters
|
||||
|
||||
def _parse_roi_config(self, roi_config):
|
||||
"""Parse ROI configuration into standardized format."""
|
||||
if roi_config is None:
|
||||
return None
|
||||
|
||||
if isinstance(roi_config, dict):
|
||||
return roi_config
|
||||
|
||||
if isinstance(roi_config, (list, tuple)):
|
||||
if len(roi_config) == 2:
|
||||
return {'mode': 'size', 'width': roi_config[0], 'height': roi_config[1]}
|
||||
elif len(roi_config) == 4:
|
||||
return {'mode': 'bounds', 'x1': roi_config[0], 'y1': roi_config[1],
|
||||
'x2': roi_config[2], 'y2': roi_config[3]}
|
||||
|
||||
raise ValueError(f"Invalid ROI config: {roi_config}")
|
||||
|
||||
def load_calibration(self, case_name, frame_name=None, level1_name=None):
|
||||
"""
|
||||
Load calibration parameters for a case.
|
||||
|
||||
Args:
|
||||
case_name: str, case identifier
|
||||
frame_name: str, optional frame name (if calibration is per-frame)
|
||||
level1_name: str, optional level1 directory name for 2-level path structure
|
||||
|
||||
Returns:
|
||||
dict with calibration parameters: focal_u, focal_v, cu, cv, yaw, pitch, etc.
|
||||
"""
|
||||
if self.calib_root is None:
|
||||
return None
|
||||
|
||||
# Try case-level calibration first
|
||||
cache_key = f"{level1_name}/{case_name}" if level1_name else f"{case_name}"
|
||||
if cache_key in self.calib_cache:
|
||||
return self.calib_cache[cache_key]
|
||||
|
||||
# Look for calibration file.
|
||||
# Supported layouts:
|
||||
# - calib_root/level1/case/calib/L2_calib/camera4.json
|
||||
# - calib_root/level1/case/calib/camera4.json
|
||||
# - calib_root/level1/case/calibration.json
|
||||
# - calib_root/case/calib/L2_calib/camera4.json
|
||||
# - calib_root/case/calib/camera4.json
|
||||
# - calib_root/case/calibration.json
|
||||
case_root = self.calib_root / level1_name / case_name if level1_name else self.calib_root / case_name
|
||||
calib_candidates = [
|
||||
case_root / "calib/L2_calib/camera4.json",
|
||||
case_root / "calib/camera4.json",
|
||||
case_root / "calibration.json",
|
||||
]
|
||||
case_calib_path = next((path for path in calib_candidates if path.exists()), None)
|
||||
if case_calib_path is None:
|
||||
print(f"Warning: Calibration file not found for case {case_name}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(case_calib_path, 'r') as f:
|
||||
calib_data = json.load(f)
|
||||
|
||||
# Extract relevant parameters
|
||||
calib_params = {
|
||||
'focal_u': calib_data.get('focal_u', calib_data.get('fx')),
|
||||
'focal_v': calib_data.get('focal_v', calib_data.get('fy')),
|
||||
'cu': calib_data.get('cu', calib_data.get('cx')),
|
||||
'cv': calib_data.get('cv', calib_data.get('cy')),
|
||||
'yaw': calib_data.get('yaw', 0.0),
|
||||
'pitch': calib_data.get('pitch', 0.0),
|
||||
'distort_coeffs': calib_data.get('distort_coeffs', [])
|
||||
}
|
||||
|
||||
self.calib_cache[cache_key] = calib_params
|
||||
return calib_params
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading calibration for {case_name}: {e}")
|
||||
return None
|
||||
|
||||
def compute_roi(self, calib_params):
|
||||
"""
|
||||
Compute ROI bounds based on calibration and configuration.
|
||||
|
||||
Matches the logic in LoadImages3D / LoadImagesAndLabels3D.
|
||||
|
||||
Args:
|
||||
calib_params: dict, calibration parameters
|
||||
|
||||
Returns:
|
||||
tuple: (roi_x1, roi_y1, roi_x2, roi_y2) or None if ROI disabled
|
||||
"""
|
||||
if self.roi_config is None:
|
||||
return None
|
||||
|
||||
oriW, oriH = self.ori_img_size
|
||||
|
||||
# Compute vanishing point (crop center)
|
||||
fx = calib_params['focal_u']
|
||||
fy = calib_params['focal_v']
|
||||
cx = calib_params['cu']
|
||||
cy = calib_params['cv']
|
||||
c_pitch = calib_params['pitch']
|
||||
c_yaw = calib_params.get('yaw', 0.0)
|
||||
|
||||
# Vanishing point coordinates
|
||||
vanish_x = cx + fx * np.tan(c_yaw * np.pi / 180)
|
||||
vanish_y = cy - fy * np.tan(c_pitch * np.pi / 180)
|
||||
|
||||
# ROI0 uses image center X; ROI1 uses the true geometric vanishing point X.
|
||||
crop_center_x = vanish_x if self.roi_use_true_vp_x else oriW // 2
|
||||
crop_center_y = vanish_y
|
||||
|
||||
if self.roi_config['mode'] == 'size':
|
||||
# ROI defined by [width, height]
|
||||
roi_width = self.roi_config['width']
|
||||
roi_height = self.roi_config['height']
|
||||
|
||||
roi_x1 = int(crop_center_x - roi_width / 2.0)
|
||||
roi_y1 = int(crop_center_y - roi_height / 2.0)
|
||||
roi_x2 = roi_x1 + roi_width - self.roi_right_offset
|
||||
roi_y2 = roi_y1 + roi_height - self.roi_bottom_offset
|
||||
|
||||
elif self.roi_config['mode'] == 'bounds':
|
||||
# ROI defined by [x1, y1, x2, y2]
|
||||
roi_x1 = self.roi_config['x1']
|
||||
roi_y1 = self.roi_config['y1']
|
||||
roi_x2 = self.roi_config['x2']
|
||||
roi_y2 = self.roi_config['y2']
|
||||
else:
|
||||
return None
|
||||
|
||||
# Clip to image bounds
|
||||
roi_x1 = max(0, roi_x1)
|
||||
roi_y1 = max(0, roi_y1)
|
||||
roi_x2 = min(oriW, roi_x2)
|
||||
roi_y2 = min(oriH, roi_y2)
|
||||
|
||||
return (roi_x1, roi_y1, roi_x2, roi_y2)
|
||||
|
||||
def xywhn2xyxy(self, boxes, img_w, img_h):
|
||||
"""
|
||||
Convert normalized [x_center, y_center, width, height] to [x1, y1, x2, y2].
|
||||
|
||||
Args:
|
||||
boxes: np.array of shape (N, 4), normalized boxes
|
||||
img_w: int, image width
|
||||
img_h: int, image height
|
||||
|
||||
Returns:
|
||||
np.array of shape (N, 4), absolute pixel coordinates
|
||||
"""
|
||||
x_center = boxes[:, 0] * img_w
|
||||
y_center = boxes[:, 1] * img_h
|
||||
width = boxes[:, 2] * img_w
|
||||
height = boxes[:, 3] * img_h
|
||||
|
||||
x1 = x_center - width / 2
|
||||
y1 = y_center - height / 2
|
||||
x2 = x_center + width / 2
|
||||
y2 = y_center + height / 2
|
||||
|
||||
return np.stack([x1, y1, x2, y2], axis=1)
|
||||
|
||||
def xyxy2xywhn(self, boxes, img_w, img_h):
|
||||
"""
|
||||
Convert [x1, y1, x2, y2] to normalized [x_center, y_center, width, height].
|
||||
|
||||
Args:
|
||||
boxes: np.array of shape (N, 4), absolute pixel coordinates
|
||||
img_w: int, image width
|
||||
img_h: int, image height
|
||||
|
||||
Returns:
|
||||
np.array of shape (N, 4), normalized boxes
|
||||
"""
|
||||
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
|
||||
|
||||
x_center = (x1 + x2) / 2 / img_w
|
||||
y_center = (y1 + y2) / 2 / img_h
|
||||
width = (x2 - x1) / img_w
|
||||
height = (y2 - y1) / img_h
|
||||
|
||||
return np.stack([x_center, y_center, width, height], axis=1)
|
||||
|
||||
def process_annotations_with_roi(self, annotations, roi_bounds):
|
||||
"""
|
||||
Process annotations with ROI filtering and clipping.
|
||||
|
||||
Matches the logic in post_process_labels_to_roi from dataloaders3d.py.
|
||||
|
||||
Args:
|
||||
annotations: list of annotation dicts from GroundTruthParser
|
||||
roi_bounds: tuple (roi_x1, roi_y1, roi_x2, roi_y2)
|
||||
|
||||
Returns:
|
||||
list of processed annotations (some may be filtered out)
|
||||
"""
|
||||
if roi_bounds is None or len(annotations) == 0:
|
||||
return annotations
|
||||
|
||||
roi_x1, roi_y1, roi_x2, roi_y2 = roi_bounds
|
||||
roi_width = roi_x2 - roi_x1
|
||||
roi_height = roi_y2 - roi_y1
|
||||
|
||||
oriW, oriH = self.ori_img_size
|
||||
|
||||
processed_annotations = []
|
||||
|
||||
for ann in annotations:
|
||||
# Get original bbox in pixel coordinates [x1, y1, x2, y2]
|
||||
bbox_orig = ann['bbox_2d']
|
||||
x1, y1, x2, y2 = bbox_orig
|
||||
|
||||
# Shift to ROI-relative coordinates
|
||||
new_x1 = x1 - roi_x1
|
||||
new_y1 = y1 - roi_y1
|
||||
new_x2 = x2 - roi_x1
|
||||
new_y2 = y2 - roi_y1
|
||||
|
||||
# Check if box is completely outside ROI
|
||||
if ((new_x1 < 0 and new_x2 < 0) or
|
||||
(new_x1 >= roi_width and new_x2 >= roi_width) or
|
||||
(new_y1 < 0 and new_y2 < 0) or
|
||||
(new_y1 >= roi_height and new_y2 >= roi_height)):
|
||||
# Box is completely outside, skip it
|
||||
continue
|
||||
|
||||
# Check if box is completely inside (before clipping)
|
||||
still_inside = (new_x1 >= 0 and new_y1 >= 0 and
|
||||
new_x2 < roi_width and new_y2 < roi_height)
|
||||
|
||||
# Clip to ROI bounds
|
||||
new_x1 = np.clip(new_x1, 0, roi_width - 1)
|
||||
new_y1 = np.clip(new_y1, 0, roi_height - 1)
|
||||
new_x2 = np.clip(new_x2, 0, roi_width - 1)
|
||||
new_y2 = np.clip(new_y2, 0, roi_height - 1)
|
||||
|
||||
# Check if box still has valid size after clipping
|
||||
if new_x2 <= new_x1 or new_y2 <= new_y1:
|
||||
continue
|
||||
|
||||
# Convert back to original image coordinates (to match detection results)
|
||||
# Detection results are saved in original image coordinates after ROI processing
|
||||
final_x1 = new_x1 + roi_x1
|
||||
final_y1 = new_y1 + roi_y1
|
||||
final_x2 = new_x2 + roi_x1
|
||||
final_y2 = new_y2 + roi_y1
|
||||
|
||||
# Update bbox to original image coordinates (filtered and clipped by ROI)
|
||||
new_ann = ann.copy()
|
||||
new_ann['bbox_2d'] = [final_x1, final_y1, final_x2, final_y2]
|
||||
new_ann['roi_filtered'] = True # Indicates GT has been filtered by ROI
|
||||
new_ann['roi_bounds'] = roi_bounds
|
||||
new_ann['was_clipped'] = not still_inside
|
||||
|
||||
# If has 3D info and box was clipped, mark it
|
||||
# (may need special handling for 3D evaluation)
|
||||
if new_ann['has_3d'] and not still_inside:
|
||||
# For partially visible objects, the 3D center may be less reliable
|
||||
# This matches the cut-in/cut-out logic in training
|
||||
if new_ann['3d_info']:
|
||||
new_ann['3d_info']['partially_visible'] = True
|
||||
|
||||
processed_annotations.append(new_ann)
|
||||
|
||||
return processed_annotations
|
||||
|
||||
def process_case_frame(self, case_name, frame_name, annotations, level1_name=None):
|
||||
"""
|
||||
Process annotations for a specific case and frame.
|
||||
|
||||
Args:
|
||||
case_name: str, case identifier
|
||||
frame_name: str, frame identifier
|
||||
annotations: list, annotations from GroundTruthParser
|
||||
level1_name: str, optional level1 directory name for 2-level path structure
|
||||
|
||||
Returns:
|
||||
tuple: (processed_annotations, roi_bounds) or (annotations, None) if no ROI
|
||||
"""
|
||||
if self.roi_config is None:
|
||||
return annotations, None
|
||||
|
||||
# Load calibration
|
||||
calib_params = self.load_calibration(case_name, frame_name, level1_name)
|
||||
if calib_params is None:
|
||||
print(f"Warning: Cannot compute ROI without calibration for {case_name}/{frame_name}")
|
||||
return annotations, None
|
||||
|
||||
# Compute ROI bounds
|
||||
roi_bounds = self.compute_roi(calib_params)
|
||||
if roi_bounds is None:
|
||||
return annotations, None
|
||||
|
||||
# Process annotations with ROI
|
||||
processed = self.process_annotations_with_roi(annotations, roi_bounds)
|
||||
|
||||
return processed, roi_bounds
|
||||
Reference in New Issue
Block a user