411 lines
17 KiB
Python
Executable File
411 lines
17 KiB
Python
Executable File
"""
|
|
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
|