"""
Main evaluator module that orchestrates the evaluation process.
"""
import os
import json
import glob
import numpy as np
from pathlib import Path
from tqdm import tqdm
from PIL import Image
from multiprocessing import Pool, cpu_count
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial

from .parser import GroundTruthParser, DetectionParser
from .matcher import Matcher2D
from .metrics_2d import Metrics2D
from .metrics_3d import Metrics3D, normalize_angle
from .roi_processor import ROIProcessor
from ..class_config import NUM_CLASSES


class Evaluator:
    """Main evaluator class for 2D and 3D detection metrics."""
    
    def __init__(self, config=None, iou_threshold=0.5, num_workers=None, save_detailed_matches=False):
        """
        Initialize evaluator.
        
        Args:
            config: dict, configuration parameters (optional)
            iou_threshold: float, IoU threshold for matching
            num_workers: int, number of parallel workers (default: cpu_count())
            save_detailed_matches: bool, whether to save detailed 3D match information
        """
        self.config = config or {}
        self.iou_threshold = iou_threshold
        self.save_detailed_matches = save_detailed_matches
        self.coord_system = config.get('metrics_3d', {}).get('coordinate_system', 'camera')
        if self.coord_system not in ('camera', 'ego'):
            raise ValueError(f"Unsupported 3D coordinate system: {self.coord_system}")
        self.multi_roi_eval_config = self._build_multi_roi_eval_config(config)
        self.multi_roi_enabled = bool(self.multi_roi_eval_config)
        
        # Initialize ROI processor if config provided
        self.roi_processor = None
        roi_gt_config = config.get('roi_gt', {})
        if roi_gt_config.get('enabled', False) and not self.multi_roi_enabled and roi_gt_config.get('roi_config') is not None:
            calib_root = roi_gt_config.get('calib_root')
            roi_config = roi_gt_config.get('roi_config')
            roi_bottom_offset = roi_gt_config.get('roi_bottom_offset', 0)
            roi_right_offset = roi_gt_config.get('roi_right_offset', 0)
            roi_use_true_vp_x = roi_gt_config.get('roi_use_true_vp_x', False)
            ori_img_size = config.get('image', {}).get('width', 1920), config.get('image', {}).get('height', 1080)
            self.roi_processor = ROIProcessor(
                calib_root=calib_root,
                roi_config=roi_config,
                ori_img_size=ori_img_size,
                roi_bottom_offset=roi_bottom_offset,
                roi_right_offset=roi_right_offset,
                roi_use_true_vp_x=roi_use_true_vp_x,
            )
            print(
                "ROI processor enabled for GT filtering with config: "
                f"{roi_config}, roi_bottom_offset: {roi_bottom_offset}, "
                f"roi_right_offset: {roi_right_offset}, roi_use_true_vp_x: {roi_use_true_vp_x}"
            )
        
        # Calculate minimum box size for GT filtering based on ROI and model input size
        self.min_box_size = self._calculate_min_box_size(config, roi_gt_config)
        self.default_min_box_size = self.min_box_size
        if self.multi_roi_enabled:
            self.default_min_box_size = min(roi_cfg['min_box_size'] for roi_cfg in self.multi_roi_eval_config.values())
            print("Multi-ROI merged evaluation enabled with per-ROI min box sizes:")
            for roi_id, roi_cfg in sorted(self.multi_roi_eval_config.items()):
                print(
                    f"  ROI{roi_id}: min_box_size={roi_cfg['min_box_size']:.2f}, "
                    f"roi_config={roi_cfg['roi_config']}, input_size={roi_cfg['input_size']}"
                )
        
        # Initialize parsers
        parser_min_box_size = 0 if self.multi_roi_enabled else self.min_box_size
        self.gt_parser = GroundTruthParser(min_box_size=parser_min_box_size, coord_system=self.coord_system)
        self.det_parser = DetectionParser(min_box_size=parser_min_box_size, coord_system=self.coord_system)
        
        # Initialize matcher
        self.matcher = Matcher2D(iou_threshold=iou_threshold)
        
        # Initialize metrics calculators
        distance_ranges_2d = config.get('metrics_2d', {}).get('distance_ranges', None)
        lateral_roi_2d = config.get('metrics_2d', {}).get('lateral_roi', None)
        self.metrics_2d = Metrics2D(num_classes=NUM_CLASSES, distance_ranges=distance_ranges_2d,
                                    lateral_roi=lateral_roi_2d, coord_system=self.coord_system)
        self.distance_ranges_2d = distance_ranges_2d
        self.lateral_roi_2d = lateral_roi_2d
        
        # Get distance ranges from config
        distance_ranges = config.get('metrics_3d', {}).get('distance_ranges', None)
        lateral_distance_ranges = config.get('metrics_3d', {}).get('lateral_distance_ranges', None)
        heading_tolerance = config.get('metrics_3d', {}).get('heading_tolerance', 'strict')
        self.vehicle_size_split_config = config.get('metrics_3d', {}).get('vehicle_size_split', {})
        self.metrics_3d = Metrics3D(
            distance_ranges=distance_ranges, 
            lateral_distance_ranges=lateral_distance_ranges,
            heading_tolerance=heading_tolerance,
            coord_system=self.coord_system,
            vehicle_size_split=self.vehicle_size_split_config,
        )
        self.heading_tolerance = heading_tolerance
        
        # Per-case metrics storage
        self.per_case_metrics_2d = {}
        self.per_case_metrics_3d = {}
        self.per_case_frame_stats = {}
        self.per_case_aggregate_stats = {}
        self.distance_ranges = distance_ranges
        self.lateral_distance_ranges = lateral_distance_ranges
        
        # Detailed 3D matches storage (for common match comparison)
        self.detailed_3d_matches = {} if save_detailed_matches else None
        
        # Image pairs to evaluate
        self.image_pairs = []
        
        # Multiprocessing settings
        self.num_workers = num_workers if num_workers is not None else max(1, cpu_count() - 1)
        print(f"Using {self.num_workers} worker(s) for parallel processing")

    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

    @staticmethod
    def _build_class_count_stats(match_result, require_3d=False):
        gts_filtered = match_result['gts_filtered']
        dets_sorted = match_result['dets_sorted']
        matches = match_result['matches']

        def _obj_id(obj, fallback_prefix, fallback_idx):
            object_id = obj.get('id')
            if object_id is None:
                return f"{fallback_prefix}_{fallback_idx}"
            return str(object_id)

        if not require_3d:
            gt_ids = [_obj_id(gt, 'gt', idx) for idx, gt in enumerate(gts_filtered)]
            det_ids = [_obj_id(det, 'det', idx) for idx, det in enumerate(dets_sorted)]
            matched_pairs = [
                {
                    'gt_id': _obj_id(gts_filtered[gt_idx], 'gt', gt_idx),
                    'det_id': _obj_id(dets_sorted[det_idx], 'det', det_idx),
                    'iou': float(iou),
                }
                for gt_idx, det_idx, iou in matches
            ]
            matched_gt_ids = {pair['gt_id'] for pair in matched_pairs}
            matched_det_ids = {pair['det_id'] for pair in matched_pairs}
            return {
                'gt_count': len(gts_filtered),
                'det_count': len(dets_sorted),
                'match_count': len(matches),
                'gt_ids': gt_ids,
                'det_ids': det_ids,
                'matched_pairs': matched_pairs,
                'unmatched_gt_ids': [gt_id for gt_id in gt_ids if gt_id not in matched_gt_ids],
                'unmatched_det_ids': [det_id for det_id in det_ids if det_id not in matched_det_ids],
            }

        valid_gt = [(idx, gt) for idx, gt in enumerate(gts_filtered) if gt.get('has_3d', False)]
        valid_det = [(idx, det) for idx, det in enumerate(dets_sorted) if det.get('3d_info') is not None]
        gt_ids = [_obj_id(gt, 'gt', idx) for idx, gt in valid_gt]
        det_ids = [_obj_id(det, 'det', idx) for idx, det in valid_det]
        matched_pairs = [
            {
                'gt_id': _obj_id(gts_filtered[gt_idx], 'gt', gt_idx),
                'det_id': _obj_id(dets_sorted[det_idx], 'det', det_idx),
                'iou': float(iou),
            }
            for gt_idx, det_idx, iou in matches
            if gts_filtered[gt_idx].get('has_3d', False) and dets_sorted[det_idx].get('3d_info') is not None
        ]
        matched_gt_ids = {pair['gt_id'] for pair in matched_pairs}
        matched_det_ids = {pair['det_id'] for pair in matched_pairs}
        return {
            'gt_count': len(valid_gt),
            'det_count': len(valid_det),
            'match_count': len(matched_pairs),
            'gt_ids': gt_ids,
            'det_ids': det_ids,
            'matched_pairs': matched_pairs,
            'unmatched_gt_ids': [gt_id for gt_id in gt_ids if gt_id not in matched_gt_ids],
            'unmatched_det_ids': [det_id for det_id in det_ids if det_id not in matched_det_ids],
        }

    @staticmethod
    def _aggregate_case_stats(frame_stats_by_frame):
        aggregate = {'2d': {}, '3d': {}}
        for frame_stats in frame_stats_by_frame.values():
            for eval_type in ('2d', '3d'):
                for class_name, stats in frame_stats.get(eval_type, {}).items():
                    class_total = aggregate[eval_type].setdefault(
                        class_name,
                        {'gt_count': 0, 'det_count': 0, 'match_count': 0, 'num_frames': 0,
                         'gt_ids': [], 'det_ids': [], 'matched_pairs': []}
                    )
                    class_total['gt_count'] += stats['gt_count']
                    class_total['det_count'] += stats['det_count']
                    class_total['match_count'] += stats['match_count']
                    class_total['num_frames'] += 1
                    class_total['gt_ids'].extend(stats.get('gt_ids', []))
                    class_total['det_ids'].extend(stats.get('det_ids', []))
                    class_total['matched_pairs'].extend(stats.get('matched_pairs', []))
        return aggregate

    @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

    @classmethod
    def _infer_source_roi_from_path(cls, path_obj):
        """Infer ROI id from a directory path like predictions/roi0 or json_results/roi1."""
        if path_obj is None:
            return None

        for part in reversed(Path(path_obj).parts):
            normalized = cls._normalize_roi_id(part)
            if normalized is not None and str(part).strip().lower().startswith('roi'):
                return normalized
        return None

    @staticmethod
    def _is_empty_gt_json_file(file_path):
        """
        Check whether a GT JSON file is structurally empty.

        Empty GT frames should be skipped entirely during evaluation so their
        paired detections do not get counted as false positives.

        Returns:
            tuple[bool, str | None]: (is_empty, reason)
        """
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                raw_content = f.read()
        except OSError:
            return False, None

        if not raw_content.strip():
            return True, "blank file"

        try:
            data = json.loads(raw_content)
        except json.JSONDecodeError:
            return False, None

        if data is None:
            return True, "null root"
        if isinstance(data, dict) and not data:
            return True, "empty object"
        if isinstance(data, list) and not data:
            return True, "empty list"
        return False, None

    @staticmethod
    def _extract_roi_width(roi_config):
        """Extract ROI width from ROI config in either size or bounds form."""
        if isinstance(roi_config, (list, tuple)):
            if len(roi_config) == 2:
                return roi_config[0]
            if len(roi_config) == 4:
                return roi_config[2] - roi_config[0]
            raise ValueError(f"Invalid ROI config format: {roi_config}")

        if isinstance(roi_config, dict):
            mode = roi_config.get('mode')
            if mode == 'size':
                return roi_config.get('width', 1920)
            if mode == 'bounds':
                return roi_config.get('x2', 1920) - roi_config.get('x1', 0)
            raise ValueError(f"Invalid ROI config mode: {mode}")

        raise ValueError(f"Unknown ROI config type: {type(roi_config)}")

    @classmethod
    def _calculate_min_box_size_from_params(cls, roi_config, model_input_width, min_box_at_input):
        """Calculate minimum box size at original image scale for one ROI branch."""
        roi_width = cls._extract_roi_width(roi_config)
        return min_box_at_input * roi_width / model_input_width

    @classmethod
    def _build_multi_roi_eval_config(cls, config):
        """Build normalized per-ROI evaluation config for merged-result evaluation."""
        roi_gt_config = config.get('roi_gt', {})
        roi_entries = roi_gt_config.get('rois')
        if not isinstance(roi_entries, dict) or len(roi_entries) < 2:
            return None

        model_config = config.get('model', {})
        input_size_by_roi = (
            model_config.get('input_size_by_roi')
            or model_config.get('input_sizes_by_roi')
            or {}
        )
        min_box_at_input_by_roi = model_config.get('min_box_size_at_input_scale_by_roi', {})

        normalized = {}
        for raw_roi_id, roi_cfg in roi_entries.items():
            roi_id = cls._normalize_roi_id(raw_roi_id)
            if roi_id is None:
                continue

            roi_config = roi_cfg.get('roi_config')
            if roi_config is None:
                raise ValueError(f"Missing roi_config for ROI entry: {raw_roi_id}")

            input_size = input_size_by_roi.get(raw_roi_id, input_size_by_roi.get(roi_id, model_config.get('input_size', 704)))
            min_box_at_input = min_box_at_input_by_roi.get(
                raw_roi_id,
                min_box_at_input_by_roi.get(roi_id, model_config.get('min_box_size_at_input_scale', 8))
            )
            min_box_size = cls._calculate_min_box_size_from_params(roi_config, input_size, min_box_at_input)

            normalized[roi_id] = {
                'roi_config': roi_config,
                'roi_bottom_offset': roi_cfg.get('roi_bottom_offset', 0),
                'roi_right_offset': roi_cfg.get('roi_right_offset', 0),
                'roi_use_true_vp_x': roi_cfg.get('roi_use_true_vp_x', False),
                'calib_root': roi_cfg.get('calib_root', roi_gt_config.get('calib_root')),
                'input_size': input_size,
                'min_box_at_input': min_box_at_input,
                'min_box_size': min_box_size,
            }

        return normalized or None

    @staticmethod
    def _bbox_meets_min_size(bbox, min_box_size):
        """Check whether a bbox is large enough under the configured min-box threshold."""
        if min_box_size is None or min_box_size <= 0:
            return True
        return (bbox[2] - bbox[0]) >= min_box_size and (bbox[3] - bbox[1]) >= min_box_size

    @staticmethod
    def _create_roi_processor(roi_cfg, img_width, img_height):
        """Build a ROIProcessor instance from serialized config."""
        return ROIProcessor(
            calib_root=roi_cfg.get('calib_root'),
            roi_config=roi_cfg.get('roi_config'),
            ori_img_size=(img_width, img_height),
            roi_bottom_offset=roi_cfg.get('roi_bottom_offset', 0),
            roi_right_offset=roi_cfg.get('roi_right_offset', 0),
            roi_use_true_vp_x=roi_cfg.get('roi_use_true_vp_x', False),
        )

    @classmethod
    def _parse_ground_truths_for_pair(cls, pair, coord_system):
        """Parse and ROI-filter GTs for either single-ROI or merged multi-ROI evaluation."""
        if 'multi_roi_eval_config' not in pair:
            gt_parser = GroundTruthParser(min_box_size=pair.get('min_box_size', 8), coord_system=coord_system)
            gts = gt_parser.parse_file(pair['gt_file'], pair['img_width'], pair['img_height'])
            if 'roi_processor_config' in pair:
                roi_processor = cls._create_roi_processor(pair['roi_processor_config'], pair['img_width'], pair['img_height'])
                gts, _ = roi_processor.process_case_frame(
                    pair['case'],
                    pair['frame'],
                    gts,
                    level1_name=pair.get('level1_name')
                )
            return gts

        gt_parser = GroundTruthParser(min_box_size=0, coord_system=coord_system)
        raw_gts = gt_parser.parse_file(pair['gt_file'], pair['img_width'], pair['img_height'])
        for source_index, gt in enumerate(raw_gts):
            gt['_source_index'] = source_index

        merged_gts = {}
        for roi_id, roi_cfg in sorted(pair['multi_roi_eval_config'].items()):
            roi_processor = cls._create_roi_processor(roi_cfg, pair['img_width'], pair['img_height'])
            roi_gts, _ = roi_processor.process_case_frame(
                pair['case'],
                pair['frame'],
                raw_gts,
                level1_name=pair.get('level1_name')
            )
            for ann in roi_gts:
                if not cls._bbox_meets_min_size(ann['bbox_2d'], roi_cfg['min_box_size']):
                    continue

                source_index = ann.get('_source_index')
                if source_index is None:
                    continue

                merged_ann = merged_gts.get(source_index)
                if merged_ann is None:
                    merged_ann = ann.copy()
                    merged_ann['bbox_2d_by_roi'] = {}
                    merged_ann['valid_roi_ids'] = []
                    merged_gts[source_index] = merged_ann

                merged_ann['bbox_2d_by_roi'][roi_id] = ann['bbox_2d']
                if roi_id not in merged_ann['valid_roi_ids']:
                    merged_ann['valid_roi_ids'].append(roi_id)
                merged_ann['bbox_2d'] = ann['bbox_2d']
                merged_ann['roi_filtered'] = True
                merged_ann['was_clipped'] = merged_ann.get('was_clipped', False) or ann.get('was_clipped', False)

        result = []
        for source_index in sorted(merged_gts):
            ann = merged_gts[source_index]
            ann.pop('_source_index', None)
            result.append(ann)
        return result

    @classmethod
    def _parse_detections_for_pair(cls, pair, coord_system):
        """Parse and ROI-aware-filter detections for either single-ROI or merged evaluation."""
        if 'multi_roi_eval_config' not in pair:
            det_parser = DetectionParser(min_box_size=pair.get('min_box_size', 8), coord_system=coord_system)
            dets = det_parser.parse_file(pair['det_file'])
            det_source_roi = pair.get('det_source_roi')
            if det_source_roi is not None:
                for det in dets:
                    if cls._normalize_roi_id(det.get('roi_id')) is None:
                        det['roi_id'] = det_source_roi
            det_roi_filter = pair.get('det_roi_filter')
            if det_roi_filter is not None:
                dets = [d for d in dets if cls._normalize_roi_id(d.get('roi_id')) == det_roi_filter]
            return dets

        det_parser = DetectionParser(min_box_size=0, coord_system=coord_system)
        dets = det_parser.parse_file(pair['det_file'])
        filtered_dets = []
        default_min_box_size = pair.get('default_min_box_size', 0)

        for det in dets:
            roi_id = cls._normalize_roi_id(det.get('roi_id'))
            roi_cfg = pair['multi_roi_eval_config'].get(roi_id)
            min_box_size = roi_cfg['min_box_size'] if roi_cfg is not None else default_min_box_size
            if cls._bbox_meets_min_size(det['bbox_2d'], min_box_size):
                det['roi_id'] = roi_id
                filtered_dets.append(det)

        return filtered_dets
    
    def _calculate_min_box_size(self, config, roi_gt_config):
        """
        Calculate minimum box size for GT filtering based on ROI and model configuration.
        
        Formula: min_box_size = min_box_size_at_input_scale * roi_width / model_input_width
        
        Examples:
        - ROI0 (1920->704): 8 * 1920 / 704 ≈ 21.82
        - ROI1 (704->704):  8 * 704 / 704 = 8.0
        
        Args:
            config: dict, full configuration
            roi_gt_config: dict, ROI ground truth configuration
            
        Returns:
            float, minimum box size in pixels at original image scale
        """
        model_config = config.get('model', {})
        model_input_width = model_config.get('input_size', 704)
        min_box_at_input = model_config.get('min_box_size_at_input_scale', 8)
        
        # If ROI is not enabled, use default
        if not roi_gt_config.get('enabled', False):
            print(f"ROI GT disabled, using default min_box_size: {min_box_at_input}")
            return min_box_at_input
        
        # Get ROI width from roi_config
        roi_config = roi_gt_config.get('roi_config')
        if roi_config is None:
            print(f"ROI config not found, using default min_box_size: {min_box_at_input}")
            return min_box_at_input
        
        try:
            roi_width = self._extract_roi_width(roi_config)
        except ValueError:
            print(f"Invalid ROI config, using default min_box_size: {min_box_at_input}")
            return min_box_at_input
        
        # Calculate minimum box size at original image scale
        min_box_size = min_box_at_input * roi_width / model_input_width
        print(f"Calculated min_box_size for GT filtering: {min_box_size:.2f} pixels "
              f"(ROI width: {roi_width}, Model input: {model_input_width}, "
              f"Min at input scale: {min_box_at_input})")
        
        return min_box_size
    
    def load_data_from_paths(self, det_root, gt_root, img_width=1920, img_height=1080, path_depth=1,
                               det_format='auto', gt_format='auto'):
        """
        Load detection and ground truth data from directory structure.

        Directory structure (path_depth=1):
        det_root/
            case1/
                json_results/       # JSON format
                    frame001.json
                txt_results/        # TXT format
                    frame001.txt
            case2/
                ...

        gt_root/
            case1/
                labels_json/        # JSON format
                    frame001.json
                labels/             # TXT format
                    frame001.txt
            case2/
                ...

        Directory structure (path_depth=2):
        det_root/
            level1/
                case1/
                    json_results/
                        frame001.json
                case2/
                    ...

        gt_root/
            level1/
                case1/
                    labels_json/
                        frame001.json
                case2/
                    ...

        Args:
            det_root: str, root directory containing detection results
            gt_root: str, root directory containing ground truth labels
            img_width: int, image width (default 1920)
            img_height: int, image height (default 1080)
            path_depth: int, directory depth (1 or 2, default 1)
            det_format: str, detection file format: 'auto' (default), 'json', or 'txt'.
                        'auto' probes json_results/ first then txt_results/.
            gt_format: str, ground truth file format: 'auto' (default), 'json', or 'txt'.
                       'auto' probes labels_json/ first then labels/.
        """
        self.image_pairs = []

        if self.coord_system == 'ego' and (det_format == 'txt' or gt_format == 'txt'):
            raise ValueError("Ego-coordinate evaluation requires JSON detection and JSON ground-truth inputs.")

        # Find all case directories based on path depth
        if path_depth == 1:
            # 1-level: det_root/case/txt_results
            det_cases = [d for d in Path(det_root).iterdir() if d.is_dir()]
            case_info_list = [(d, d.name, None) for d in det_cases]  # (path, case_name, level1_name)
        elif path_depth == 2:
            # 2-level: det_root/level1/case/txt_results
            case_info_list = []
            level1_dirs = [d for d in Path(det_root).iterdir() if d.is_dir()]
            for level1_dir in level1_dirs:
                level1_name = level1_dir.name
                case_dirs = [d for d in level1_dir.iterdir() if d.is_dir()]
                for case_dir in case_dirs:
                    case_info_list.append((case_dir, case_dir.name, level1_name))
        else:
            raise ValueError(f"Unsupported path_depth: {path_depth}. Must be 1 or 2.")

        print(f"Found {len(case_info_list)} case(s) in detection root: {det_root} (path_depth={path_depth})")

        skip_cases = frozenset([
            '019b6bf2-01a6-7029-9232-fce2bbcd2d73',
            '019b6bf2-0124-7820-91b7-c5eb42150cd2',
            '019b8ddb-ae8a-70f3-b86f-055894c79724',
        ])

        # Build ROI processor config once (passed into each worker)
        roi_processor_config = None
        if self.roi_processor is not None:
            roi_gt_config = self.config.get('roi_gt', {})
            roi_processor_config = {
                'calib_root': roi_gt_config.get('calib_root'),
                'roi_config': roi_gt_config.get('roi_config'),
                'roi_bottom_offset': roi_gt_config.get('roi_bottom_offset', 0),
                'roi_right_offset': roi_gt_config.get('roi_right_offset', 0),
                'roi_use_true_vp_x': roi_gt_config.get('roi_use_true_vp_x', False),
            }
        multi_roi_eval_config = self.multi_roi_eval_config if self.multi_roi_enabled else None
        det_subdir = self.config.get('dataset', {}).get('det_subdir')
        if det_subdir is not None:
            det_subdir = str(det_subdir).strip()
            if not det_subdir:
                det_subdir = None
        det_roi_filter = self.config.get('dataset', {}).get('det_roi_filter')
        if det_roi_filter is not None:
            det_roi_filter = str(det_roi_filter).strip()
            print(f"ROI filter: evaluating only roi_id={det_roi_filter!r} detections")
        if det_subdir is not None:
            print(f"Detection subdirectory override: {det_subdir}")

        # Parallel case scanning with threads (I/O-bound)
        scan_workers = min(self.num_workers, len(case_info_list)) if case_info_list else 1
        all_pairs = []
        with ThreadPoolExecutor(max_workers=scan_workers) as executor:
            futures = {
                executor.submit(
                    self._scan_case,
                    ci, gt_root, path_depth, img_width, img_height,
                    self.min_box_size, skip_cases, roi_processor_config, multi_roi_eval_config,
                    self.default_min_box_size, self.coord_system,
                    det_format, gt_format, det_roi_filter, det_subdir
                ): ci
                for ci in case_info_list
            }
            for future in tqdm(as_completed(futures), total=len(futures), desc="Scanning cases"):
                pairs, warnings = future.result()
                for w in warnings:
                    print(w)
                all_pairs.extend(pairs)

        # Sort for deterministic order (threads return out-of-order)
        self.image_pairs = sorted(all_pairs, key=lambda p: (p['level1_name'] or '', p['case'], p['frame']))
        
        print(f"Loaded {len(self.image_pairs)} image pairs for evaluation")

    @staticmethod
    def _scan_case(case_info, gt_root, path_depth, img_width, img_height, min_box_size, skip_cases,
                   roi_processor_config, multi_roi_eval_config=None, default_min_box_size=8,
                   coord_system='camera', det_format='auto', gt_format='auto', det_roi_filter=None,
                   det_subdir=None):
        """
        Scan a single case directory and return its image pairs (thread worker).

        Supports both TXT and JSON file formats:
          - Detection: case/json_results/*.json  or  case/txt_results/*.txt
          - Ground truth: case/labels_json/*.json  or  case/labels/*.txt

        Args:
            det_format: 'auto' | 'json' | 'txt'  — explicit format overrides auto-detection
            gt_format:  'auto' | 'json' | 'txt'  — explicit format overrides auto-detection

        Returns:
            list of image pair dicts, and list of warning strings
        """
        det_case_dir, case_name, level1_name = case_info
        pairs = []
        warnings = []

        if case_name in skip_cases:
            return pairs, warnings

        # Corresponding GT case directory
        if path_depth == 1:
            gt_case_dir = Path(gt_root) / case_name
        else:
            gt_case_dir = Path(gt_root) / level1_name / case_name

        if not gt_case_dir.exists():
            warnings.append(f"Warning: GT case directory not found for {case_name}, skipping")
            return pairs, warnings

        # -------- Resolve detection directory / glob --------
        det_json_dirs = []
        if det_subdir is not None:
            det_json_dirs.append(Path(det_subdir) if Path(det_subdir).is_absolute() else det_case_dir / det_subdir)
        if det_roi_filter is not None:
            det_json_dirs.extend([
                det_case_dir / "json_results" / f"roi{det_roi_filter}",
                det_case_dir / "predictions" / f"roi{det_roi_filter}",
            ])
        det_json_dirs.extend([
            det_case_dir / "json_results",
            det_case_dir / "predictions",
        ])
        deduped_det_json_dirs = []
        seen_det_dirs = set()
        for candidate_dir in det_json_dirs:
            candidate_key = str(candidate_dir)
            if candidate_key in seen_det_dirs:
                continue
            seen_det_dirs.add(candidate_key)
            deduped_det_json_dirs.append(candidate_dir)
        det_json_dirs = deduped_det_json_dirs
        det_txt_dir  = det_case_dir / "txt_results"

        det_results_dir = None
        det_glob = None
        det_source_roi = None

        def _pick_json_dir():
            nonlocal det_results_dir, det_glob, det_source_roi
            empty_existing_dirs = []
            for candidate_dir in det_json_dirs:
                if not candidate_dir.exists():
                    continue
                matches = sorted(candidate_dir.glob("*.json"))
                if matches:
                    det_results_dir = candidate_dir
                    det_glob = "*.json"
                    det_source_roi = Evaluator._infer_source_roi_from_path(candidate_dir)
                    return empty_existing_dirs
                empty_existing_dirs.append(candidate_dir)
            return empty_existing_dirs

        if det_format == 'json':
            empty_existing_dirs = _pick_json_dir()
            if det_results_dir is None:
                if empty_existing_dirs:
                    empty_dirs_str = ", ".join(str(d) for d in empty_existing_dirs)
                    warnings.append(
                        f"Warning: JSON detection directories exist but contain no *.json files for {case_name}: "
                        f"{empty_dirs_str}, skipping"
                    )
                else:
                    warnings.append(
                        f"Warning: no JSON detection directory found for {case_name} "
                        f"(checked: {', '.join(str(d) for d in det_json_dirs)}), skipping"
                    )
                return pairs, warnings
        elif det_format == 'txt':
            det_results_dir, det_glob = det_txt_dir, "*.txt"
            if not det_results_dir.exists():
                warnings.append(f"Warning: txt_results not found in {case_name} (det_format=txt), skipping")
                return pairs, warnings
        else:  # auto
            empty_existing_dirs = _pick_json_dir()
            if det_results_dir is not None:
                pass
            elif det_txt_dir.exists() and coord_system != 'ego':
                det_results_dir, det_glob = det_txt_dir, "*.txt"
            else:
                if coord_system == 'ego':
                    if empty_existing_dirs:
                        empty_dirs_str = ", ".join(str(d) for d in empty_existing_dirs)
                        warnings.append(
                            f"Warning: JSON detection directories exist but contain no *.json files for "
                            f"{case_name} in ego-coordinate evaluation: {empty_dirs_str}, skipping"
                        )
                    else:
                        warnings.append(
                            f"Warning: no JSON detection directory found in {case_name} for ego-coordinate "
                            f"evaluation (checked: {', '.join(str(d) for d in det_json_dirs)}), skipping"
                        )
                else:
                    if empty_existing_dirs:
                        empty_dirs_str = ", ".join(str(d) for d in empty_existing_dirs)
                        warnings.append(
                            f"Warning: JSON detection directories exist but contain no *.json files for {case_name}: "
                            f"{empty_dirs_str}; txt_results also not usable, skipping"
                        )
                    else:
                        warnings.append(
                            f"Warning: neither JSON detection directories "
                            f"({', '.join(str(d) for d in det_json_dirs)}) nor txt_results found in {case_name}, skipping"
                        )
                return pairs, warnings

        # -------- Resolve ground-truth directory / extension --------
        gt_json_dirs = [
            # gt_case_dir / "labels_20260320_with_ego",
            # gt_case_dir / "labels_20260320_json",
            gt_case_dir / "labels_json",
        ]
        gt_txt_dir  = gt_case_dir / "labels"

        if gt_format == 'json':
            gt_labels_dir = next((d for d in gt_json_dirs if d.exists()), None)
            gt_ext = ".json"
            if gt_labels_dir is None:
                warnings.append(f"Warning: labels_json not found in {case_name} (gt_format=json), skipping")
                return pairs, warnings
        elif gt_format == 'txt':
            gt_labels_dir, gt_ext = gt_txt_dir, ".txt"
            if not gt_labels_dir.exists():
                warnings.append(f"Warning: labels not found in {case_name} (gt_format=txt), skipping")
                return pairs, warnings
        else:  # auto
            gt_labels_dir = next((d for d in gt_json_dirs if d.exists()), None)
            if gt_labels_dir is not None:
                gt_ext = ".json"
            elif gt_txt_dir.exists() and coord_system != 'ego':
                gt_labels_dir, gt_ext = gt_txt_dir, ".txt"
            else:
                if coord_system == 'ego':
                    warnings.append(f"Warning: labels_20260320 not found in {case_name} for ego-coordinate evaluation, skipping")
                else:
                    warnings.append(f"Warning: Neither labels_json nor labels directory found in {case_name}, skipping")
                return pairs, warnings

        # Pre-build a set of available GT frames to avoid per-file exists() calls
        gt_frames = {p.stem for p in gt_labels_dir.iterdir() if p.suffix == gt_ext}

        det_files = sorted(det_results_dir.glob(det_glob))
        if not det_files:
            warnings.append(
                f"Warning: no detection files matched {det_glob} in {det_results_dir} for {case_name}, skipping"
            )
            return pairs, warnings

        skipped_empty_gt_frames = []

        for det_file in det_files:
            frame_name = det_file.stem
            if frame_name not in gt_frames:
                warnings.append(f"Warning: GT file not found for {frame_name} in {case_name}, skipping")
                continue

            gt_file = gt_labels_dir / f"{frame_name}{gt_ext}"
            if gt_ext == ".json":
                is_empty_gt_json, empty_reason = Evaluator._is_empty_gt_json_file(gt_file)
                if is_empty_gt_json:
                    skipped_empty_gt_frames.append((frame_name, empty_reason))
                    continue

            pair = {
                'case': case_name,
                'frame': frame_name,
                'det_file': str(det_file),
                'gt_file': str(gt_file),
                'img_width': img_width,
                'img_height': img_height,
                'min_box_size': min_box_size,
                'level1_name': level1_name,
                'coord_system': coord_system,
            }
            if roi_processor_config is not None:
                pair['roi_processor_config'] = roi_processor_config
            if multi_roi_eval_config is not None:
                pair['multi_roi_eval_config'] = multi_roi_eval_config
                pair['default_min_box_size'] = default_min_box_size
            if det_source_roi is not None:
                pair['det_source_roi'] = det_source_roi
            if det_roi_filter is not None:
                pair['det_roi_filter'] = det_roi_filter
            pairs.append(pair)

        if skipped_empty_gt_frames:
            preview = ", ".join(
                f"{frame} ({reason})" if reason else frame
                for frame, reason in skipped_empty_gt_frames[:5]
            )
            remaining = len(skipped_empty_gt_frames) - min(len(skipped_empty_gt_frames), 5)
            if remaining > 0:
                preview = f"{preview}, ... (+{remaining} more)"
            warnings.append(
                f"Warning: skipped {len(skipped_empty_gt_frames)} frame(s) with empty GT JSON in "
                f"{case_name}: {preview}"
            )

        return pairs, warnings

    @staticmethod
    def _process_frame_2d(pair, iou_threshold):
        """
        Process a single frame for 2D evaluation (worker function).

        Args:
            pair: dict, image pair information (may contain roi_processor_config and min_box_size)
            iou_threshold: float, IoU threshold

        Returns:
            tuple: (case_name, frame_name, per_class_results)
        """
        coord_system = pair.get('coord_system', 'camera')
        matcher = Matcher2D(iou_threshold=iou_threshold)
        gts = Evaluator._parse_ground_truths_for_pair(pair, coord_system)
        dets = Evaluator._parse_detections_for_pair(pair, coord_system)
        
        # Match and compute for each class
        class_results = {}
        for class_id in range(NUM_CLASSES):
            match_result = matcher.match(gts, dets, class_id)
            class_results[class_id] = {
                'match_result': match_result,
                'gts': gts,
                'dets': dets
            }
        
        return (pair['case'], pair['frame'], class_results)
    
    @staticmethod
    def _process_frame_3d(pair, iou_threshold, distance_ranges, lateral_distance_ranges=None,
                          save_detailed_matches=False, coord_system='camera', conf_threshold=0.0):
        """
        Process a single frame for 3D evaluation (worker function).
        
        Args:
            pair: dict, image pair information (may contain roi_processor_config and min_box_size)
            iou_threshold: float, IoU threshold
            distance_ranges: list, distance ranges for 3D metrics
            lateral_distance_ranges: list, lateral distance ranges for 3D metrics
            save_detailed_matches: bool, whether to save detailed match information
            coord_system: str, coordinate system ('camera' or 'ego')
            conf_threshold: float, minimum confidence score for detections (default: 0.0, no filtering)
            
        Returns:
            tuple: (case_name, frame_name, 3d_samples, detailed_matches, frame_class_stats)
        """
        import hashlib

        matcher = Matcher2D(iou_threshold=iou_threshold)
        lateral_axis = 0 if coord_system == 'camera' else 1
        longitudinal_axis = 2 if coord_system == 'camera' else 0
        gt_parser = GroundTruthParser(min_box_size=0 if 'multi_roi_eval_config' in pair else pair.get('min_box_size', 8),
                                      coord_system=coord_system)
        gts = Evaluator._parse_ground_truths_for_pair(pair, coord_system)
        dets = Evaluator._parse_detections_for_pair(pair, coord_system)
        if conf_threshold > 0.0:
            dets = [d for d in dets if d.get('confidence', 0.0) >= conf_threshold]
        
        # Collect 3D samples
        samples = []
        detailed_matches = {} if save_detailed_matches else None
        frame_class_stats = {}
        
        for class_id in Metrics3D.CLASSES_3D:
            match_result = matcher.match(gts, dets, class_id)
            class_name = gt_parser.get_class_name(class_id)
            frame_class_stats[class_name] = Evaluator._build_class_count_stats(match_result, require_3d=True)
            
            if save_detailed_matches:
                detailed_matches[class_name] = []
            
            for gt_idx, det_idx, iou in match_result['matches']:
                gt = match_result['gts_filtered'][gt_idx]
                det = match_result['dets_sorted'][det_idx]
                
                if gt['has_3d'] and det['3d_info'] is not None:
                    samples.append((gt, det, class_id))
                    
                    # Save detailed match information
                    if save_detailed_matches:
                        # Generate unique GT ID based on bbox coordinates
                        gt_bbox_str = f"{gt['bbox_2d'][0]:.2f}_{gt['bbox_2d'][1]:.2f}_{gt['bbox_2d'][2]:.2f}_{gt['bbox_2d'][3]:.2f}"
                        gt_id = hashlib.md5(gt_bbox_str.encode()).hexdigest()[:16]
                        
                        # Compute 3D errors
                        # For vehicles, use the face center matching the detected face type
                        # (same reference point as metrics_3d.add_sample uses)
                        if class_id == 0:
                            face_type = det['3d_info'].get('face_type', 'front')
                            normalized_face = face_type.lower()
                            if normalized_face in ('rear', 'tail'):
                                normalized_face = 'back'
                            faces = gt['3d_info'].get('faces') or {}
                            face_data = faces.get(normalized_face)
                            # face_data[7] is is_visible_from_camera; when 0, x3d/y3d/z3d
                            # are sentinel -1.0 and must not be used as real coordinates.
                            face_valid = (
                                face_data is not None and
                                face_data[2] > 0 and
                                not (len(face_data) >= 8 and face_data[7] == 0)
                            )
                            gt_center = face_data[:3] if face_valid else gt['3d_info']['center']
                        else:
                            gt_center = gt['3d_info']['center']
                        det_center = det['3d_info']['center']
                        lateral_error = abs(det_center[lateral_axis] - gt_center[lateral_axis])
                        longitudinal_error = abs(det_center[longitudinal_axis] - gt_center[longitudinal_axis])
                        # Use whole-object center z for relative error denominator and
                        # distance routing, so that face centers with near-zero/negative z
                        # (e.g. side faces at close range) don't produce astronomical
                        # relative errors or get silently dropped from distance segments.
                        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
                        
                        # Heading error (using rotation field)
                        gt_rotation = gt['3d_info'].get('rotation', 0)
                        det_rotation = det['3d_info'].get('rotation', 0)
                        heading_error = abs(normalize_angle(det_rotation - gt_rotation))
                        
                        # Compute relaxed heading error if needed
                        heading_error_relaxed = None
                        is_reversal = False
                        if heading_error > np.pi / 2:
                            heading_error_relaxed = np.pi - heading_error
                            is_reversal = True
                        else:
                            heading_error_relaxed = heading_error
                        
                        error_dict = {
                            'lateral': float(lateral_error),
                            'longitudinal': float(longitudinal_error),
                            'longitudinal_relative': float(longitudinal_relative_error),
                            'heading': float(heading_error),
                            'heading_relaxed': float(heading_error_relaxed),
                            'is_reversal': bool(is_reversal)
                        }
                        
                        detailed_matches[class_name].append({
                            'gt_id': gt_id,
                            'gt_bbox': gt['bbox_2d'],
                            'gt_center_3d': gt_center,
                            'gt_rotation': float(gt_rotation),
                            'det_bbox': det['bbox_2d'],
                            'det_center_3d': det_center,
                            'det_rotation': float(det_rotation),
                            'iou': float(iou),
                            'confidence': float(det.get('confidence', 0)),
                            'errors': error_dict,
                            'distance': {
                                'longitudinal': float(gt_whole_longitudinal),
                                'lateral': float(gt_center[lateral_axis])
                            }
                        })
        
        return (pair['case'], pair['frame'], samples, detailed_matches, frame_class_stats)
    
    def evaluate_2d(self):
        """
        Evaluate 2D detection metrics with multiprocessing support.
        
        Returns:
            dict, 2D evaluation summary
        """
        print("\n" + "="*50)
        print("Evaluating 2D Detection Metrics")
        print("="*50)
        
        # Group pairs by case (use unique case identifier for 2-level paths)
        cases = {}
        for pair in self.image_pairs:
            # Create unique case identifier: "level1/case" for 2-level, "case" for 1-level
            level1_name = pair.get('level1_name')
            case_name = pair['case']
            if level1_name:
                case_key = f"{level1_name}/{case_name}"
            else:
                case_key = case_name

            if case_key not in cases:
                cases[case_key] = []
            cases[case_key].append(pair)
        
        # Evaluate each case separately
        for case_idx, (case_key, case_pairs) in enumerate(cases.items(), 1):
            print(f"\nProcessing case [{case_idx}/{len(cases)}]: {case_key} ({len(case_pairs)} frames)")

            # Create metrics for this case
            case_metrics_2d = Metrics2D(num_classes=NUM_CLASSES, distance_ranges=self.distance_ranges_2d,
                                        lateral_roi=self.lateral_roi_2d, coord_system=self.coord_system)

            case_frame_stats = {}
            if self.num_workers > 1 and len(case_pairs) > 1:
                # Multiprocessing
                worker_func = partial(
                    self._process_frame_2d,
                    iou_threshold=self.iou_threshold
                )

                with Pool(processes=self.num_workers) as pool:
                    results = list(tqdm(
                        pool.imap(worker_func, case_pairs),
                        total=len(case_pairs),
                        desc=f"  {case_key}"
                    ))

                # Aggregate results
                for case, frame, class_results in results:
                    frame_stats = {'2d': {}, '3d': {}}
                    for class_id, result_data in class_results.items():
                        match_result = result_data['match_result']
                        gts = result_data['gts']
                        dets = result_data['dets']
                        class_name = self.gt_parser.get_class_name(class_id)
                        frame_stats['2d'][class_name] = self._build_class_count_stats(match_result, require_3d=False)
                        case_metrics_2d.add_image_results(match_result, gts, dets, class_id)
                        self.metrics_2d.add_image_results(match_result, gts, dets, class_id)
                    case_frame_stats[frame] = frame_stats
            else:
                # Single process fallback
                for pair in tqdm(case_pairs, desc=f"  {case_key}"):
                    _, frame, class_results = self._process_frame_2d(
                        pair,
                        self.iou_threshold
                    )

                    frame_stats = {'2d': {}, '3d': {}}
                    for class_id, result_data in class_results.items():
                        match_result = result_data['match_result']
                        gts = result_data['gts']
                        dets = result_data['dets']
                        class_name = self.gt_parser.get_class_name(class_id)
                        frame_stats['2d'][class_name] = self._build_class_count_stats(match_result, require_3d=False)
                        case_metrics_2d.add_image_results(match_result, gts, dets, class_id)
                        self.metrics_2d.add_image_results(match_result, gts, dets, class_id)
                    case_frame_stats[frame] = frame_stats

            # Store case results (use case_key as identifier)
            self.per_case_metrics_2d[case_key] = case_metrics_2d.get_summary(
                conf_threshold=self.config.get('metrics_2d', {}).get('conf_threshold', 0.5),
                ap_method=self.config.get('metrics_2d', {}).get('ap_method', 'voc2010')
            )
            existing = self.per_case_frame_stats.get(case_key, {})
            for frame, stats in case_frame_stats.items():
                existing.setdefault(frame, {'2d': {}, '3d': {}})
                existing[frame]['2d'] = stats.get('2d', {})
            self.per_case_frame_stats[case_key] = existing
            self.per_case_aggregate_stats[case_key] = self._aggregate_case_stats(existing)
        
        # Get overall summary
        summary = self.metrics_2d.get_summary(
            conf_threshold=self.config.get('metrics_2d', {}).get('conf_threshold', 0.5),
            ap_method=self.config.get('metrics_2d', {}).get('ap_method', 'voc2010')
        )
        
        return summary
    
    def evaluate_3d(self):
        """
        Evaluate 3D detection metrics with multiprocessing support.
        
        Returns:
            dict, 3D evaluation summary
        """
        print("\n" + "="*50)
        print("Evaluating 3D Detection Metrics")
        print("="*50)
        
        # Group pairs by case (use unique case identifier for 2-level paths)
        cases = {}
        for pair in self.image_pairs:
            # Create unique case identifier: "level1/case" for 2-level, "case" for 1-level
            level1_name = pair.get('level1_name')
            case_name = pair['case']
            if level1_name:
                case_key = f"{level1_name}/{case_name}"
            else:
                case_key = case_name

            if case_key not in cases:
                cases[case_key] = []
            cases[case_key].append(pair)
        
        # Evaluate each case separately
        for case_idx, (case_key, case_pairs) in enumerate(cases.items(), 1):
            print(f"\nProcessing case [{case_idx}/{len(cases)}]: {case_key} ({len(case_pairs)} frames)")

            # Create metrics for this case
            case_metrics_3d = Metrics3D(
                distance_ranges=self.distance_ranges,
                lateral_distance_ranges=self.lateral_distance_ranges,
                heading_tolerance=self.heading_tolerance,
                coord_system=self.coord_system,
                vehicle_size_split=self.vehicle_size_split_config,
            )

            case_frame_stats = self.per_case_frame_stats.get(case_key, {})
            conf_threshold_3d = self.config.get('metrics_3d', {}).get(
                'conf_threshold',
                self.config.get('metrics_2d', {}).get('conf_threshold', 0.0),
            )
            if self.num_workers > 1 and len(case_pairs) > 1:
                # Multiprocessing
                worker_func = partial(
                    self._process_frame_3d,
                    iou_threshold=self.iou_threshold,
                    distance_ranges=self.distance_ranges,
                    lateral_distance_ranges=self.lateral_distance_ranges,
                    save_detailed_matches=self.save_detailed_matches,
                    coord_system=self.coord_system,
                    conf_threshold=conf_threshold_3d,
                )

                with Pool(processes=self.num_workers) as pool:
                    results = list(tqdm(
                        pool.imap(worker_func, case_pairs),
                        total=len(case_pairs),
                        desc=f"  {case_key}"
                    ))

                # Aggregate results
                if self.save_detailed_matches:
                    self.detailed_3d_matches[case_key] = {}

                for case, frame, samples, detailed_matches, frame_class_stats in results:
                    for gt, det, class_id in samples:
                        case_metrics_3d.add_sample(gt, det, class_id)
                        self.metrics_3d.add_sample(gt, det, class_id)
                    case_frame_stats.setdefault(frame, {'2d': {}, '3d': {}})
                    case_frame_stats[frame]['3d'] = frame_class_stats

                    # Save detailed matches
                    if self.save_detailed_matches and detailed_matches:
                        self.detailed_3d_matches[case_key][frame] = detailed_matches
            else:
                # Single process fallback
                if self.save_detailed_matches:
                    self.detailed_3d_matches[case_key] = {}

                for pair in tqdm(case_pairs, desc=f"  {case_key}"):
                    _, frame, samples, detailed_matches, frame_class_stats = self._process_frame_3d(
                        pair,
                        self.iou_threshold,
                        self.distance_ranges,
                        self.lateral_distance_ranges,
                        self.save_detailed_matches,
                        self.coord_system,
                        conf_threshold_3d,
                    )

                    for gt, det, class_id in samples:
                        case_metrics_3d.add_sample(gt, det, class_id)
                        self.metrics_3d.add_sample(gt, det, class_id)
                    case_frame_stats.setdefault(frame, {'2d': {}, '3d': {}})
                    case_frame_stats[frame]['3d'] = frame_class_stats

                    # Save detailed matches
                    if self.save_detailed_matches and detailed_matches:
                        self.detailed_3d_matches[case_key][frame] = detailed_matches

            # Store case results (use case_key as identifier)
            self.per_case_metrics_3d[case_key] = case_metrics_3d.get_summary()
            self.per_case_frame_stats[case_key] = case_frame_stats
            self.per_case_aggregate_stats[case_key] = self._aggregate_case_stats(case_frame_stats)
        
        # Get overall summary
        summary = self.metrics_3d.get_summary()
        
        return summary
    
    def evaluate(self):
        """
        Run complete evaluation (2D and 3D).
        
        Returns:
            dict with both 2d and 3d evaluation results
        """
        results = {}
        
        # Evaluate 2D
        if self.config.get('eval_2d', True):
            results['2d_evaluation'] = self.evaluate_2d()
        
        # Evaluate 3D
        if self.config.get('eval_3d', True):
            results['3d_evaluation'] = self.evaluate_3d()
        
        return results
    
    def generate_report(self, results, output_dir):
        """
        Generate evaluation report.
        
        Args:
            results: dict, evaluation results
            output_dir: str, output directory path
        """
        os.makedirs(output_dir, exist_ok=True)
        
        # Add per-case results to the report
        if self.per_case_metrics_2d:
            results['per_case_2d'] = self.per_case_metrics_2d
        if self.per_case_metrics_3d:
            results['per_case_3d'] = self.per_case_metrics_3d
        # Add evaluation configuration to the report
        results['evaluation_config'] = {
            'conf_threshold': self.config.get('metrics_2d', {}).get('conf_threshold', 0.5),
            'iou_threshold': self.iou_threshold,
            'ap_method': self.config.get('metrics_2d', {}).get('ap_method', 'voc2010'),
            'vehicle_size_split_3d': {
                'enabled': self.metrics_3d.vehicle_size_split_enabled,
                'large_length_threshold': self.metrics_3d.vehicle_large_length_threshold,
                'large_height_threshold': self.metrics_3d.vehicle_large_height_threshold,
            },
            'per_case_reports_dir': 'per_case_reports',
            'main_json_excludes_detailed_case_stats': True,
        }
        
        # Save JSON report
        json_path = os.path.join(output_dir, 'evaluation_report.json')
        with open(json_path, 'w') as f:
            json.dump(results, f, indent=2)
        
        # Save detailed 3D matches if enabled
        if self.save_detailed_matches and self.detailed_3d_matches:
            matches_path = os.path.join(output_dir, 'detailed_3d_matches.json')
            with open(matches_path, 'w') as f:
                json.dump(self.detailed_3d_matches, f, indent=2)
            print(f"\nDetailed 3D matches saved to: {matches_path}")
        print(f"\nJSON report saved to: {json_path}")
        
        # Save text report
        txt_path = os.path.join(output_dir, 'evaluation_report.txt')
        with open(txt_path, 'w') as f:
            self._write_text_report(results, f)
        print(f"Text report saved to: {txt_path}")
        
        # Save per-case reports
        if self.per_case_metrics_2d or self.per_case_metrics_3d:
            self._write_per_case_reports(output_dir)
        
        # Print summary to console
        self._print_summary(results)
    
    def _write_text_report(self, results, file):
        """Write human-readable text report."""
        file.write("=" * 80 + "\n")
        file.write("EVALUATION REPORT\n")
        file.write("=" * 80 + "\n\n")
        
        # Evaluation Configuration
        file.write("EVALUATION CONFIGURATION\n")
        file.write("-" * 80 + "\n")
        conf_threshold = self.config.get('metrics_2d', {}).get('conf_threshold', 0.5)
        file.write(f"Confidence Threshold (for P/R/F1): {conf_threshold:.3f}\n")
        file.write("Note: Precision, Recall, and F1 Score are calculated at the specified\n")
        file.write("      confidence threshold. AP (Average Precision) is computed from the\n")
        file.write("      complete Precision-Recall curve and is independent of this threshold.\n")
        file.write("\n")
        
        # 2D Results
        if '2d_evaluation' in results:
            file.write("2D DETECTION METRICS\n")
            file.write("-" * 80 + "\n")
            
            eval_2d = results['2d_evaluation']
            
            # Per-class results
            file.write("\nPer-Class Metrics:\n")
            file.write(f"{'Class':<15} {'Precision':<12} {'Recall':<12} {'F1 Score':<12} {'AP':<12} {'TP':<8} {'FP':<8} {'FN':<8} {'GT':<8}\n")
            file.write("-" * 100 + "\n")
            
            for class_name, metrics in sorted(eval_2d['per_class'].items()):
                file.write(f"{class_name:<15} "
                          f"{metrics['precision']:<12.4f} "
                          f"{metrics['recall']:<12.4f} "
                          f"{metrics['f1_score']:<12.4f} "
                          f"{metrics['ap']:<12.4f} "
                          f"{metrics['tp']:<8} "
                          f"{metrics['fp']:<8} "
                          f"{metrics['fn']:<8} "
                          f"{metrics['num_gt']:<8}\n")
            
            # Overall results
            file.write("\nOverall Metrics:\n")
            overall = eval_2d['overall']
            file.write(f"  Precision: {overall['precision']:.4f}\n")
            file.write(f"  Recall:    {overall['recall']:.4f}\n")
            file.write(f"  F1 Score:  {overall['f1_score']:.4f}\n")
            file.write(f"  mAP:       {overall['map']:.4f}\n")
            file.write(f"  Total TP:  {overall['tp']}\n")
            file.write(f"  Total FP:  {overall['fp']}\n")
            file.write(f"  Total FN:  {overall['fn']}\n")
            file.write("\n")

            # Per-distance-range 2D metrics — helper
            def _write_dist_table_2d(dist_data, header_lines):
                for h in header_lines:
                    file.write(h)
                all_range_keys = []
                for cls_data in dist_data.values():
                    for rk in cls_data:
                        if rk not in all_range_keys:
                            all_range_keys.append(rk)
                for class_name_str in sorted(dist_data.keys()):
                    cls_data = dist_data[class_name_str]
                    file.write(f"  {class_name_str.upper()}:\n")
                    file.write(f"    {'Range':<14} {'AP':<10} {'Precision':<12} {'Recall':<10} "
                               f"{'F1':<10} {'TP':<6} {'FP':<6} {'FN':<6} {'GT':<6}\n")
                    file.write(f"    {'-'*80}\n")
                    for rk in all_range_keys:
                        if rk not in cls_data:
                            continue
                        m = cls_data[rk]
                        file.write(f"    {rk:<14} {m['ap']:<10.4f} {m['precision']:<12.4f} "
                                   f"{m['recall']:<10.4f} {m['f1_score']:<10.4f} "
                                   f"{m['tp']:<6} {m['fp']:<6} {m['fn']:<6} {m['num_gt']:<6}\n")
                    file.write("\n")

            if 'per_class_by_distance' in eval_2d and eval_2d['per_class_by_distance']:
                _write_dist_table_2d(
                    eval_2d['per_class_by_distance'],
                    [
                        "\n全横向 + 纵向分段 2D Metrics (3D-capable classes only):\n",
                        "Note: TPs are bucketed by matched GT z3d; FPs are bucketed by the\n",
                        "      detection's own predicted z3d. 2D-only classes are not shown.\n\n",
                    ]
                )

            if 'per_class_by_distance_lat_roi' in eval_2d and eval_2d['per_class_by_distance_lat_roi']:
                lat_roi = eval_2d.get('lateral_roi', self.lateral_roi_2d)
                lat_str = f"{lat_roi[0]}m ~ {lat_roi[1]}m" if lat_roi else ""
                _write_dist_table_2d(
                    eval_2d['per_class_by_distance_lat_roi'],
                    [
                        f"\n限定横向范围 [{lat_str}] + 纵向分段 2D Metrics (3D-capable classes only):\n",
                        "Note: Objects outside the lateral ROI are excluded from all counts.\n\n",
                    ]
                )

        # 3D Results
        if '3d_evaluation' in results:
            file.write("\n3D DETECTION METRICS\n")
            file.write("-" * 80 + "\n")
            
            eval_3d = results['3d_evaluation']
            
            for class_name, class_metrics in sorted(eval_3d.items()):
                file.write(f"\n{class_name.upper()}:\n")
                
                # Check if this is distance-range based or simple format
                if 'overall' in class_metrics:
                    # Distance range based format
                    for range_key, metrics in sorted(class_metrics.items()):
                        if metrics['num_samples'] == 0 and range_key != 'overall':
                            continue
                        
                        range_label = "OVERALL" if range_key == 'overall' else range_key
                        file.write(f"\n  [{range_label}]:\n")
                        file.write(f"    Samples: {metrics['num_samples']}\n")
                        
                        if metrics['num_samples'] > 0:
                            file.write(f"    Lateral Error (m):\n")
                            file.write(f"      Mean:   {metrics['lateral_error']['mean']:.4f}\n")
                            file.write(f"      Median: {metrics['lateral_error']['median']:.4f}\n")
                            file.write(f"      Std:    {metrics['lateral_error']['std']:.4f}\n")
                            file.write(f"      90%:    {metrics['lateral_error']['percentile_90']:.4f}\n")
                            
                            file.write(f"    Longitudinal Error (m):\n")
                            file.write(f"      Mean:   {metrics['longitudinal_error']['mean']:.4f}\n")
                            file.write(f"      Median: {metrics['longitudinal_error']['median']:.4f}\n")
                            file.write(f"      Std:    {metrics['longitudinal_error']['std']:.4f}\n")
                            file.write(f"      90%:    {metrics['longitudinal_error']['percentile_90']:.4f}\n")
                            
                            file.write(f"    Longitudinal Relative Error:\n")
                            file.write(f"      Mean:   {metrics['longitudinal_relative_error']['mean']:.4f}\n")
                            file.write(f"      Median: {metrics['longitudinal_relative_error']['median']:.4f}\n")
                            file.write(f"      Std:    {metrics['longitudinal_relative_error']['std']:.4f}\n")
                            file.write(f"      90%:    {metrics['longitudinal_relative_error']['percentile_90']:.4f}\n")
                            
                            file.write(f"    Heading Error (rad):\n")
                            file.write(f"      Mean:   {metrics['heading_error']['mean']:.4f}\n")
                            file.write(f"      Median: {metrics['heading_error']['median']:.4f}\n")
                            file.write(f"      Std:    {metrics['heading_error']['std']:.4f}\n")
                            file.write(f"      90%:    {metrics['heading_error']['percentile_90']:.4f}\n")
                            
                            # Add relaxed heading error if available
                            if 'heading_error_relaxed' in metrics:
                                file.write(f"    Heading Error Relaxed (rad) [180° symmetry]:\n")
                                file.write(f"      Mean:   {metrics['heading_error_relaxed']['mean']:.4f}\n")
                                file.write(f"      Median: {metrics['heading_error_relaxed']['median']:.4f}\n")
                                file.write(f"      Std:    {metrics['heading_error_relaxed']['std']:.4f}\n")
                                file.write(f"      90%:    {metrics['heading_error_relaxed']['percentile_90']:.4f}\n")
                                file.write(f"      Reversal Cases: {metrics['reversal_count']} ({metrics['reversal_percentage']:.1f}%)\n")
                else:
                    # Legacy simple format (backward compatibility)
                    metrics = class_metrics
                    file.write(f"  Samples: {metrics['num_samples']}\n")
                    
                    if metrics['num_samples'] > 0:
                        file.write(f"  Lateral Error (m):\n")
                        file.write(f"    Mean:   {metrics['lateral_error']['mean']:.4f}\n")
                        file.write(f"    Median: {metrics['lateral_error']['median']:.4f}\n")
                        file.write(f"    Std:    {metrics['lateral_error']['std']:.4f}\n")
                        file.write(f"    90%:    {metrics['lateral_error']['percentile_90']:.4f}\n")
                        
                        file.write(f"  Longitudinal Error (m):\n")
                        file.write(f"    Mean:   {metrics['longitudinal_error']['mean']:.4f}\n")
                        file.write(f"    Median: {metrics['longitudinal_error']['median']:.4f}\n")
                        file.write(f"    Std:    {metrics['longitudinal_error']['std']:.4f}\n")
                        file.write(f"    90%:    {metrics['longitudinal_error']['percentile_90']:.4f}\n")
                        
                        file.write(f"  Longitudinal Relative Error:\n")
                        file.write(f"    Mean:   {metrics['longitudinal_relative_error']['mean']:.4f}\n")
                        file.write(f"    Median: {metrics['longitudinal_relative_error']['median']:.4f}\n")
                        file.write(f"    Std:    {metrics['longitudinal_relative_error']['std']:.4f}\n")
                        file.write(f"    90%:    {metrics['longitudinal_relative_error']['percentile_90']:.4f}\n")
                        
                        file.write(f"  Heading Error (rad):\n")
                        file.write(f"    Mean:   {metrics['heading_error']['mean']:.4f}\n")
                        file.write(f"    Median: {metrics['heading_error']['median']:.4f}\n")
                        file.write(f"    Std:    {metrics['heading_error']['std']:.4f}\n")
                        file.write(f"    90%:    {metrics['heading_error']['percentile_90']:.4f}\n")
                        
                        # Add relaxed heading error if available
                        if 'heading_error_relaxed' in metrics:
                            file.write(f"  Heading Error Relaxed (rad) [180° symmetry]:\n")
                            file.write(f"    Mean:   {metrics['heading_error_relaxed']['mean']:.4f}\n")
                            file.write(f"    Median: {metrics['heading_error_relaxed']['median']:.4f}\n")
                            file.write(f"    Std:    {metrics['heading_error_relaxed']['std']:.4f}\n")
                            file.write(f"    90%:    {metrics['heading_error_relaxed']['percentile_90']:.4f}\n")
                            file.write(f"    Reversal Cases: {metrics['reversal_count']} ({metrics['reversal_percentage']:.1f}%)\n")
    
    def _write_per_case_reports(self, output_dir):
        """Write per-case evaluation reports."""
        per_case_dir = os.path.join(output_dir, 'per_case_reports')
        os.makedirs(per_case_dir, exist_ok=True)
        
        # Get all case names
        case_names = set()
        if self.per_case_metrics_2d:
            case_names.update(self.per_case_metrics_2d.keys())
        if self.per_case_metrics_3d:
            case_names.update(self.per_case_metrics_3d.keys())
        
        for case_name in sorted(case_names):
            # Replace "/" with "_" in case_name for file system compatibility
            safe_case_name = case_name.replace('/', '_')
            case_report_path = os.path.join(per_case_dir, f'{safe_case_name}_report.txt')
            case_stats_json_path = os.path.join(per_case_dir, f'{safe_case_name}_frame_stats.json')
            case_stats_txt_path = os.path.join(per_case_dir, f'{safe_case_name}_frame_stats.txt')
            
            with open(case_report_path, 'w') as f:
                f.write("=" * 80 + "\n")
                f.write(f"EVALUATION REPORT - {case_name}\n")
                f.write("=" * 80 + "\n\n")
                
                # 2D metrics for this case
                if case_name in self.per_case_metrics_2d:
                    eval_2d = self.per_case_metrics_2d[case_name]
                    f.write("2D DETECTION METRICS\n")
                    f.write("-" * 80 + "\n\n")
                    
                    overall = eval_2d['overall']
                    f.write(f"Overall Metrics:\n")
                    f.write(f"  Precision: {overall['precision']:.4f}\n")
                    f.write(f"  Recall:    {overall['recall']:.4f}\n")
                    f.write(f"  mAP:       {overall['map']:.4f}\n")
                    f.write(f"  Total TP:  {overall['tp']}\n")
                    f.write(f"  Total FP:  {overall['fp']}\n")
                    f.write(f"  Total FN:  {overall['fn']}\n\n")
                    
                    f.write("Per-Class Metrics:\n")
                    f.write(f"{'Class':<15} {'Precision':<12} {'Recall':<12} {'F1 Score':<12} {'AP':<12} {'TP':<8} {'FP':<8} {'FN':<8}\n")
                    f.write("-" * 100 + "\n")
                    
                    for class_name_str, metrics in sorted(eval_2d['per_class'].items()):
                        if metrics['num_gt'] > 0 or metrics['tp'] > 0 or metrics['fp'] > 0:
                            f.write(f"{class_name_str:<15} "
                                  f"{metrics['precision']:<12.4f} "
                                  f"{metrics['recall']:<12.4f} "
                                  f"{metrics['f1_score']:<12.4f} "
                                  f"{metrics['ap']:<12.4f} "
                                  f"{metrics['tp']:<8} "
                                  f"{metrics['fp']:<8} "
                                  f"{metrics['fn']:<8}\n")
                
                # 3D metrics for this case
                if case_name in self.per_case_metrics_3d:
                    eval_3d = self.per_case_metrics_3d[case_name]
                    f.write("\n\n3D DETECTION METRICS\n")
                    f.write("-" * 80 + "\n")
                    
                    for class_name_str, class_metrics in sorted(eval_3d.items()):
                        # Check if this is distance-range based or simple format
                        if 'overall' in class_metrics:
                            overall_metrics = class_metrics['overall']
                            if overall_metrics['num_samples'] > 0:
                                f.write(f"\n{class_name_str.upper()} [overall]:\n")
                                f.write(f"  Samples: {overall_metrics['num_samples']}\n")
                                f.write(f"  Lateral Error:               {overall_metrics['lateral_error']['mean']:.4f}m (±{overall_metrics['lateral_error']['std']:.4f})\n")
                                f.write(f"  Longitudinal Error:          {overall_metrics['longitudinal_error']['mean']:.4f}m (±{overall_metrics['longitudinal_error']['std']:.4f})\n")
                                f.write(f"  Longitudinal Relative Error: {overall_metrics['longitudinal_relative_error']['mean']:.4f} (±{overall_metrics['longitudinal_relative_error']['std']:.4f})\n")
                                f.write(f"  Heading Error:               {overall_metrics['heading_error']['mean']:.4f}rad (±{overall_metrics['heading_error']['std']:.4f})\n")
                                if 'heading_error_relaxed' in overall_metrics:
                                    f.write(f"  Heading Error Relaxed: {overall_metrics['heading_error_relaxed']['mean']:.4f}rad (±{overall_metrics['heading_error_relaxed']['std']:.4f})\n")
                                    f.write(f"  Reversal Cases: {overall_metrics['reversal_count']} ({overall_metrics['reversal_percentage']:.1f}%)\n")
                                
                                # Show distance ranges if any
                                for range_key, metrics in sorted(class_metrics.items()):
                                    if range_key != 'overall' and metrics['num_samples'] > 0:
                                        f.write(f"\n  [{range_key}]: n={metrics['num_samples']}\n")
                                        f.write(f"    Lateral:                     {metrics['lateral_error']['mean']:.4f}m\n")
                                        f.write(f"    Longitudinal:                {metrics['longitudinal_error']['mean']:.4f}m\n")
                                        f.write(f"    Longitudinal Relative Error: {metrics['longitudinal_relative_error']['mean']:.4f}\n")
                                        f.write(f"    Heading:                     {metrics['heading_error']['mean']:.4f}rad\n")
                                        if 'heading_error_relaxed' in metrics:
                                            f.write(f"    Heading Relaxed: {metrics['heading_error_relaxed']['mean']:.4f}rad\n")
                                            f.write(f"    Reversals: {metrics['reversal_count']} ({metrics['reversal_percentage']:.1f}%)\n")
                        else:
                            # Legacy simple format
                            if class_metrics['num_samples'] > 0:
                                f.write(f"\n{class_name_str.upper()}:\n")
                                f.write(f"  Samples: {class_metrics['num_samples']}\n")
                                f.write(f"  Lateral Error:               {class_metrics['lateral_error']['mean']:.4f}m (±{class_metrics['lateral_error']['std']:.4f})\n")
                                f.write(f"  Longitudinal Error:          {class_metrics['longitudinal_error']['mean']:.4f}m (±{class_metrics['longitudinal_error']['std']:.4f})\n")
                                f.write(f"  Longitudinal Relative Error: {class_metrics['longitudinal_relative_error']['mean']:.4f} (±{class_metrics['longitudinal_relative_error']['std']:.4f})\n")
                                f.write(f"  Heading Error:               {class_metrics['heading_error']['mean']:.4f}rad (±{class_metrics['heading_error']['std']:.4f})\n")
                                if 'heading_error_relaxed' in class_metrics:
                                    f.write(f"  Heading Error Relaxed: {class_metrics['heading_error_relaxed']['mean']:.4f}rad (±{class_metrics['heading_error_relaxed']['std']:.4f})\n")
                                    f.write(f"  Reversal Cases: {class_metrics['reversal_count']} ({class_metrics['reversal_percentage']:.1f}%)\n")

            case_frame_stats = self.per_case_frame_stats.get(case_name, {})
            case_aggregate_stats = self.per_case_aggregate_stats.get(case_name, {'2d': {}, '3d': {}})
            with open(case_stats_json_path, 'w') as f:
                json.dump({
                    'case_name': case_name,
                    'frame_stats': case_frame_stats,
                    'aggregate_stats': case_aggregate_stats,
                }, f, indent=2)

            with open(case_stats_txt_path, 'w') as f:
                f.write("=" * 80 + "\n")
                f.write(f"FRAME-LEVEL EVALUATION STATS - {case_name}\n")
                f.write("=" * 80 + "\n\n")

                f.write("CASE AGGREGATE STATS\n")
                f.write("-" * 80 + "\n")
                for eval_type in ('2d', '3d'):
                    f.write(f"\n[{eval_type.upper()}]\n")
                    f.write(f"{'Class':<15} {'GT':<10} {'Det':<10} {'Match':<10} {'Frames':<10}\n")
                    f.write("-" * 60 + "\n")
                    for class_name_str, stats in sorted(case_aggregate_stats.get(eval_type, {}).items()):
                        f.write(
                                f"{class_name_str:<15} {stats['gt_count']:<10} {stats['det_count']:<10} "
                                f"{stats['match_count']:<10} {stats['num_frames']:<10}\n"
                            )
                        if stats.get('gt_ids'):
                            f.write(f"  GT IDs: {stats['gt_ids']}\n")
                        if stats.get('det_ids'):
                            f.write(f"  Det IDs: {stats['det_ids']}\n")
                        if stats.get('matched_pairs'):
                            f.write(f"  Matched Pairs: {stats['matched_pairs']}\n")

                f.write("\n\nFRAME STATS\n")
                f.write("-" * 80 + "\n")
                for frame_name in sorted(case_frame_stats.keys()):
                    f.write(f"\n[{frame_name}]\n")
                    for eval_type in ('2d', '3d'):
                        f.write(f"  {eval_type.upper()}:\n")
                        f.write(f"  {'Class':<13} {'GT':<8} {'Det':<8} {'Match':<8}\n")
                        f.write(f"  {'-' * 45}\n")
                        for class_name_str, stats in sorted(case_frame_stats[frame_name].get(eval_type, {}).items()):
                            f.write(
                                f"  {class_name_str:<13} {stats['gt_count']:<8} {stats['det_count']:<8} "
                                f"{stats['match_count']:<8}\n"
                            )
                            if stats.get('gt_ids'):
                                f.write(f"    GT IDs: {stats['gt_ids']}\n")
                            if stats.get('det_ids'):
                                f.write(f"    Det IDs: {stats['det_ids']}\n")
                            if stats.get('matched_pairs'):
                                f.write(f"    Matched Pairs: {stats['matched_pairs']}\n")
                            if stats.get('unmatched_gt_ids'):
                                f.write(f"    Unmatched GT IDs: {stats['unmatched_gt_ids']}\n")
                            if stats.get('unmatched_det_ids'):
                                f.write(f"    Unmatched Det IDs: {stats['unmatched_det_ids']}\n")
        
        print(f"Per-case reports saved to: {per_case_dir}/ ({len(case_names)} cases)")
    
    def _print_summary(self, results):
        """Print summary to console."""
        print("\n" + "=" * 80)
        print("EVALUATION SUMMARY - OVERALL")
        print("=" * 80)
        
        # Print configuration
        conf_threshold = self.config.get('metrics_2d', {}).get('conf_threshold', 0.5)
        print(f"\nConfiguration:")
        print(f"  Confidence Threshold (for P/R/F1): {conf_threshold:.3f}")
        print(f"  Note: AP is independent of confidence threshold")
        
        if '2d_evaluation' in results:
            overall = results['2d_evaluation']['overall']
            print(f"\n2D Metrics:")
            print(f"  Precision: {overall['precision']:.4f}")
            print(f"  Recall:    {overall['recall']:.4f}")
            print(f"  F1 Score:  {overall['f1_score']:.4f}")
            print(f"  mAP:       {overall['map']:.4f}")

            # Per-distance-range 2D summary (console)
            def _print_dist_table_2d(dist_data, title):
                print(f"\n  {title}")
                for class_name_str in sorted(dist_data.keys()):
                    cls_data = dist_data[class_name_str]
                    parts = []
                    for rk, m in cls_data.items():
                        if m['num_gt'] > 0:
                            parts.append(f"{rk}: R={m['recall']:.3f} AP={m['ap']:.3f} (n={m['num_gt']})")
                    if parts:
                        print(f"    {class_name_str}: " + " | ".join(parts))

            dist_data = results['2d_evaluation'].get('per_class_by_distance', {})
            if dist_data:
                _print_dist_table_2d(dist_data, "2D 全横向 + 纵向分段 (Recall | AP):")

            dist_data_lat = results['2d_evaluation'].get('per_class_by_distance_lat_roi', {})
            if dist_data_lat:
                lat_roi = results['2d_evaluation'].get('lateral_roi', self.lateral_roi_2d)
                lat_str = f"{lat_roi[0]}m ~ {lat_roi[1]}m" if lat_roi else ""
                _print_dist_table_2d(dist_data_lat, f"2D 限定横向 [{lat_str}] + 纵向分段 (Recall | AP):")
        
        if '3d_evaluation' in results:
            print(f"\n3D Metrics:")
            for class_name, class_metrics in sorted(results['3d_evaluation'].items()):
                # Check if this is distance-range based or simple format
                if 'overall' in class_metrics:
                    # Distance range based: show overall first
                    overall_metrics = class_metrics['overall']
                    if overall_metrics['num_samples'] > 0:
                        heading_str = f"Head={overall_metrics['heading_error']['mean']:.3f}rad"
                        if 'heading_error_relaxed' in overall_metrics:
                            heading_str += f" (relaxed={overall_metrics['heading_error_relaxed']['mean']:.3f}rad, rev={overall_metrics['reversal_count']})"
                        print(f"  {class_name} [overall]: "
                              f"Lat={overall_metrics['lateral_error']['mean']:.3f}m, "
                              f"Long={overall_metrics['longitudinal_error']['mean']:.3f}m, "
                              f"LongRel={overall_metrics['longitudinal_relative_error']['mean']:.3f}, "
                              f"{heading_str} "
                              f"(n={overall_metrics['num_samples']})")
                    
                    # Show distance ranges
                    for range_key, metrics in sorted(class_metrics.items()):
                        if range_key != 'overall' and metrics['num_samples'] > 0:
                            print(f"    [{range_key}]: "
                                  f"Lat={metrics['lateral_error']['mean']:.3f}m, "
                                  f"Long={metrics['longitudinal_error']['mean']:.3f}m, "
                                  f"LongRel={metrics['longitudinal_relative_error']['mean']:.3f}, "
                                  f"Head={metrics['heading_error']['mean']:.3f}rad "
                                  f"(n={metrics['num_samples']})")
                else:
                    # Legacy simple format
                    if class_metrics['num_samples'] > 0:
                        heading_str = f"Head={class_metrics['heading_error']['mean']:.3f}rad"
                        if 'heading_error_relaxed' in class_metrics:
                            heading_str += f" (relaxed={class_metrics['heading_error_relaxed']['mean']:.3f}rad, rev={class_metrics['reversal_count']})"
                        print(f"  {class_name}: "
                              f"Lat={class_metrics['lateral_error']['mean']:.3f}m, "
                              f"Long={class_metrics['longitudinal_error']['mean']:.3f}m, "
                              f"LongRel={class_metrics['longitudinal_relative_error']['mean']:.3f}, "
                              f"{heading_str} "
                              f"(n={class_metrics['num_samples']})")
        
        # Print per-case summary if available
        if 'per_case_2d' in results and results['per_case_2d']:
            print("\n" + "=" * 80)
            print("PER-CASE SUMMARY")
            print("=" * 80)
            
            for case_name in sorted(results['per_case_2d'].keys()):
                case_2d = results['per_case_2d'].get(case_name, {})
                case_3d = results.get('per_case_3d', {}).get(case_name, {})
                
                print(f"\n[{case_name}]")
                
                if case_2d and 'overall' in case_2d:
                    overall = case_2d['overall']
                    print(f"  2D: P={overall['precision']:.3f}, R={overall['recall']:.3f}, F1={overall['f1_score']:.3f}, mAP={overall['map']:.3f}")
                
                if case_3d:
                    # Show 3D metrics for vehicle class as representative
                    for class_name in ['vehicle', 'vehicle_large', 'vehicle_small', 'bus', 'truck', 'tanker',
                                        'unknown', 'pedestrian', 'bicycle', 'motorcyclist', 'tricycle']:
                        if class_name in case_3d:
                            class_metrics = case_3d[class_name]
                            if 'overall' in class_metrics:
                                metrics = class_metrics['overall']
                            else:
                                metrics = class_metrics
                            
                            if metrics['num_samples'] > 0:
                                print(f"  3D {class_name}: "
                                      f"Lat={metrics['lateral_error']['mean']:.3f}m, "
                                      f"Long={metrics['longitudinal_error']['mean']:.3f}m "
                                      f"(n={metrics['num_samples']})")
        
        print("=" * 80 + "\n")
