单目3D初始代码

This commit is contained in:
zhao.zhu
2026-06-24 09:35:46 +08:00
commit 04a5895b6b
1153 changed files with 340700 additions and 0 deletions

View 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

File diff suppressed because it is too large Load Diff

155
eval_tools/evaluator/matcher.py Executable file
View 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
}

View 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

View 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
View 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

View 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