#!/usr/bin/env python3
"""
Comprehensive Dataset Profiling & Statistical Analysis for YOLOv5-3D.

Analyze training and evaluation datasets to produce a complete "data portrait" covering:
  1. Basic dataset overview (image/label counts, label format distribution)
  2. Per-class object statistics (counts, proportions, 2D/3D coverage)
  3. 2D bounding box analysis (size, aspect ratio, position heatmaps)
  4. 3D geometry analysis (depth, dimensions, rotation distributions)
  5. Face visibility & cut-type analysis (for vehicles/tricycles)
  6. Per-image density analysis (objects per image, class co-occurrence)
  7. Vehicle / date distribution analysis
  8. Data quality checks (invalid labels, NaN fields, outliers)
  9. Train vs Eval distribution comparison

Usage:
    python dataset_profiling.py [--data data/mono3d.yaml] [--split both|train|val]
                                [--max-files 0] [--output-dir dataset_profiling_results]
                                [--img-size 1920 960] [--analysis-mode original|roi]

    --max-files 0      means process all files (default).
    --analysis-mode    'original' uses full image coordinates (default);
                       'roi' computes per-camera ROI from calibration,
                       filters out-of-ROI objects, clips boundary objects,
                       and normalizes to ROI space (matching training pipeline).
"""

import argparse
import json
import math
import os
import sys
from collections import Counter, defaultdict
from multiprocessing import Pool, cpu_count
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import numpy as np
import yaml

# ─────────────────────── Constants ───────────────────────

CLASS_NAMES = {
    0: "vehicle", 1: "pedestrian", 2: "bicycle", 3: "rider",
    4: "roadblock", 5: "head", 6: "tsr", 7: "guideboard",
    8: "plate", 9: "wheel", 10: "tl_border", 11: "tl_wick",
    12: "tl_num", 13: "tricycle",
}

# Classes that use face-based 3D annotation (50-col raw format → 47-dim)
FACE_BASED_CLASSES = {0, 13}  # vehicle, tricycle

# Classes that have complete 3D annotations (18-col → 47-dim)
COMPLETE_3D_CLASSES = {1, 2, 3}  # pedestrian, bicycle, rider

# All classes with 3D info
ALL_3D_CLASSES = FACE_BASED_CLASSES | COMPLETE_3D_CLASSES

FACE_NAMES = ["front", "rear", "left", "right"]

# Vehicle subtype thresholds: 大车 (large vehicle) vs 小车 (small vehicle)
LARGE_VEHICLE_L_THRESH = 6.0   # length > 6m  →  large vehicle
LARGE_VEHICLE_H_THRESH = 2.0   # height > 2m  →  large vehicle
VEH_LARGE = 100                 # virtual class ID for large vehicles
VEH_SMALL = 101                 # virtual class ID for small vehicles
CLASS_NAMES[VEH_LARGE] = "vehicle_large"
CLASS_NAMES[VEH_SMALL] = "vehicle_small"

# Internal 47-dim index map
IDX = {
    "cls": 0,
    "x": 1, "y": 2, "w": 3, "h": 4,
    "x3d": 5, "y3d": 6, "z3d": 7,
    "length": 8, "height": 9, "width": 10,
    "rot_y": 11,
    "xc": 12, "yc": 13,
    "alpha": 14,
    # Each face: [x3d, y3d, z3d, alpha, xc, yc, score, is_visible]
    "face_front": 15,  # 15-22
    "face_rear": 23,   # 23-30
    "face_left": 31,   # 31-38
    "face_right": 39,  # 39-46
}


# ─────────────── Label File Parsing ─────────────────────

def parse_label_file(label_path: str) -> list:
    """Parse a single label file into list of 47-dim numpy arrays.

    Returns list of (label_47dim, raw_col_count) tuples.
    """
    results = []
    try:
        with open(label_path, "r") as f:
            content = f.read()
        if not content:
            return results

        for raw_line in content.split('\n'):
            parts = raw_line.split()
            if not parts:
                continue

            n = len(parts)
            temp = np.full(47, np.nan, dtype=np.float64)

            if n >= 50:
                # 50-column: vehicle/tricycle with face annotations
                # Convert all values at once (C-optimized map)
                all_v = list(map(float, parts))
                temp[0:14] = all_v[0:14]
                temp[14] = all_v[16]   # alpha (skip cols 14, 15, 17)
                temp[15:47] = all_v[18:50]
                results.append((temp, 50))

            elif n >= 18:
                # 18-column: pedestrian/bicycle/rider
                all_v = list(map(float, parts[:17]))
                temp[0:14] = all_v[0:14]
                temp[14] = all_v[16]   # alpha
                results.append((temp, 18))

            elif n >= 6:
                # 6-column: 2D-only
                k = min(n, 6)
                temp[:k] = list(map(float, parts[:k]))
                results.append((temp, 6))
            else:
                results.append((temp, n))

    except FileNotFoundError:
        pass
    except Exception:
        pass
    return results


def image_path_to_label_path(img_path: str) -> str:
    """Convert image path to corresponding label path."""
    return img_path.replace("/images/", "/labels/").replace(".png", ".txt").replace(".jpg", ".txt")


# ─────────── ROI Computation & Label Processing ────────

# Global cache: sequence_dir → (roi_x1, roi_y1, roi_x2, roi_y2)
_roi_cache: dict = {}


def read_calib_for_image(img_path: str) -> dict:
    """Read calibration parameters for an image.

    Derives calib path as: <image_dir>.replace('images','calib')/L2_calib/camera4.json
    Returns dict with keys: focal_u, focal_v, cu, cv, pitch, yaw, distort_coeffs.
    Returns None if calibration file not found.
    """
    base_path = os.path.dirname(img_path)
    calib_path = base_path.replace('images', 'calib') + '/L2_calib/camera4.json'
    if not os.path.exists(calib_path):
        return None
    try:
        with open(calib_path, 'r') as f:
            return json.load(f)
    except Exception:
        return None


def compute_roi_bounds(calib_params: dict, ori_img_size: tuple, roi_size: tuple) -> tuple:
    """Compute ROI bounds from calibration parameters and config.

    Uses the vanishing point formula:
        vanish_y = cv - focal_v * tan(pitch * pi / 180)
    Then crops a region of roi_size centered at (oriW//2, vanish_y), clamped to image.

    Args:
        calib_params: dict with 'focal_v', 'cv', 'pitch'
        ori_img_size: (oriW, oriH) original image dimensions
        roi_size: (roi_w, roi_h) target ROI dimensions from config

    Returns:
        (roi_x1, roi_y1, roi_x2, roi_y2) absolute pixel coordinates, or None on error.
    """
    try:
        fy = calib_params['focal_v']
        cy = calib_params['cv']
        pitch = calib_params['pitch']
    except (KeyError, TypeError):
        return None

    oriW, oriH = ori_img_size
    roi_w, roi_h = roi_size

    vanish_y = cy - fy * math.tan(pitch * math.pi / 180.0)

    crop_center_x = oriW // 2
    crop_center_y = vanish_y

    roi_x1 = int(crop_center_x - roi_w / 2.0)
    roi_y1 = int(crop_center_y - roi_h / 2.0)
    roi_x2 = roi_x1 + roi_w
    roi_y2 = roi_y1 + roi_h

    # Clamp to image bounds
    if roi_y1 < 0:
        roi_y1 = 0
        roi_y2 = roi_h
    if roi_y2 > oriH:
        roi_y2 = oriH
        roi_y1 = oriH - roi_h
    if roi_x1 < 0:
        roi_x1 = 0
        roi_x2 = roi_w
    if roi_x2 > oriW:
        roi_x2 = oriW
        roi_x1 = oriW - roi_w

    return (roi_x1, roi_y1, roi_x2, roi_y2)


def get_roi_for_image(img_path: str, ori_img_size: tuple, roi_size: tuple) -> tuple:
    """Get ROI bounds for an image, with per-sequence caching.

    All images in the same directory share the same calibration → same ROI.
    Returns (roi_x1, roi_y1, roi_x2, roi_y2) or None.
    """
    seq_dir = os.path.dirname(img_path)
    if seq_dir in _roi_cache:
        return _roi_cache[seq_dir]

    calib = read_calib_for_image(img_path)
    if calib is None:
        _roi_cache[seq_dir] = None
        return None

    bounds = compute_roi_bounds(calib, ori_img_size, roi_size)
    _roi_cache[seq_dir] = bounds
    return bounds


def filter_clip_labels_to_roi(labels_47: list, ori_img_size: tuple,
                              roi_x1: int, roi_y1: int,
                              roi_x2: int, roi_y2: int) -> list:
    """Filter and clip parsed labels to ROI region.

    Matches the logic from dataloaders3d.post_process_labels_to_roi():
    - Convert normalized 2D bbox to absolute pixels
    - Shift to ROI-relative coordinates
    - Remove fully-outside objects
    - Clip boundary objects (and mark as cut-in/cut-out)
    - Re-normalize coordinates to ROI dimensions

    Args:
        labels_47: list of (47-dim np.array, raw_col_count) tuples
        ori_img_size: (oriW, oriH)
        roi_x1, roi_y1, roi_x2, roi_y2: ROI bounds in absolute pixels

    Returns:
        Filtered/clipped list of (47-dim np.array, raw_col_count) tuples
    """
    if not labels_47:
        return labels_47

    oriW, oriH = ori_img_size
    roi_w = roi_x2 - roi_x1
    roi_h = roi_y2 - roi_y1

    # Stack all labels into array for vectorized processing
    arr = np.array([l[0] for l in labels_47])
    raw_cols = [l[1] for l in labels_47]

    # --- 2D bbox: convert xywhn → xyxy (absolute pixels) ---
    xn, yn, wn, hn = arr[:, 1], arr[:, 2], arr[:, 3], arr[:, 4]
    abs_x1 = oriW * (xn - wn / 2.0)
    abs_y1 = oriH * (yn - hn / 2.0)
    abs_x2 = oriW * (xn + wn / 2.0)
    abs_y2 = oriH * (yn + hn / 2.0)

    # --- Shift to ROI-relative coordinates ---
    new_x1 = abs_x1 - roi_x1
    new_y1 = abs_y1 - roi_y1
    new_x2 = abs_x2 - roi_x1
    new_y2 = abs_y2 - roi_y1

    # --- Determine inside / outside / partial ---
    fully_outside = ((new_x2 <= 0) | (new_x1 >= roi_w) |
                     (new_y2 <= 0) | (new_y1 >= roi_h))
    fully_inside = ((new_x1 >= 0) & (new_y1 >= 0) &
                    (new_x2 <= roi_w) & (new_y2 <= roi_h))
    partial = ~(fully_inside | fully_outside)

    # --- Handle cut-in/cut-out for partial objects (face-based classes) ---
    if np.any(partial):
        partial_indices = np.where(partial)[0]
        rot_y = arr[partial_indices, 11]  # rot_y field
        is_cut_in = (rot_y >= -np.pi) & (rot_y <= 0)

        # Face attribute indices (excluding score column for each face)
        head_related_idx = np.array([15, 16, 17, 18, 19, 20, 22])
        rear_related_idx = np.array([23, 24, 25, 26, 27, 28, 30])
        left_related_idx = np.array([31, 32, 33, 34, 35, 36, 38])
        right_related_idx = np.array([39, 40, 41, 42, 43, 44, 46])

        # Cut-in: keep front face, invalidate others
        cut_in_idx = partial_indices[is_cut_in]
        if len(cut_in_idx) > 0:
            arr[np.ix_(cut_in_idx, rear_related_idx)] = -1
            arr[np.ix_(cut_in_idx, left_related_idx)] = -1
            arr[np.ix_(cut_in_idx, right_related_idx)] = -1
            arr[cut_in_idx, 21] = 1  # front face score

        # Cut-out: keep rear face, invalidate others
        cut_out_idx = partial_indices[~is_cut_in]
        if len(cut_out_idx) > 0:
            arr[np.ix_(cut_out_idx, head_related_idx)] = -1
            arr[np.ix_(cut_out_idx, left_related_idx)] = -1
            arr[np.ix_(cut_out_idx, right_related_idx)] = -1
            arr[cut_out_idx, 29] = 1  # rear face score

    # --- Clip coordinates to ROI bounds ---
    new_x1 = np.clip(new_x1, 0, roi_w - 1)
    new_y1 = np.clip(new_y1, 0, roi_h - 1)
    new_x2 = np.clip(new_x2, 0, roi_w - 1)
    new_y2 = np.clip(new_y2, 0, roi_h - 1)

    # --- Re-normalize 2D bbox to ROI dimensions ---
    arr[:, 1] = (new_x1 + new_x2) * 0.5 / roi_w
    arr[:, 2] = (new_y1 + new_y2) * 0.5 / roi_h
    arr[:, 3] = (new_x2 - new_x1) / roi_w
    arr[:, 4] = (new_y2 - new_y1) / roi_h

    # --- Re-normalize face center projections (xc, yc for each face) ---
    for xi, yi in [(12, 13), (19, 20), (27, 28), (35, 36), (43, 44)]:
        valid = ~np.isnan(arr[:, xi]) & (arr[:, xi] >= 0)
        if np.any(valid):
            arr[valid, xi] = (arr[valid, xi] * oriW - roi_x1) / roi_w
            arr[valid, yi] = (arr[valid, yi] * oriH - roi_y1) / roi_h

    # --- Remove fully-outside objects ---
    keep_mask = ~fully_outside
    results = []
    for i in range(len(arr)):
        if keep_mask[i]:
            results.append((arr[i], raw_cols[i]))

    return results


def resolve_image_paths(data_cfg: dict, split: str) -> list:
    """Resolve image paths from data config for a given split (train/val).

    Relative paths inside txt list files are resolved against the txt file's
    parent directory (not the YAML 'path' key), matching the convention used
    by the training dataloader.
    """
    raw = data_cfg.get(split, [])
    base = data_cfg.get("path", "")

    if isinstance(raw, str):
        raw = [raw]

    all_paths = []
    for entry in raw:
        entry = str(entry).strip()
        if not entry:
            continue
        p = Path(entry)
        if p.is_file():
            # text file listing image paths — resolve relative to txt file's dir
            txt_parent = str(p.parent)
            with open(p, "r") as f:
                lines = [l.strip() for l in f if l.strip()]
            for line in lines:
                if line.startswith("./"):
                    line = str(Path(txt_parent) / line[2:])
                elif not line.startswith("/"):
                    line = str(Path(txt_parent) / line)
                all_paths.append(line)
        elif p.is_dir():
            for ext in ("*.png", "*.jpg", "*.jpeg"):
                all_paths.extend(str(x) for x in p.rglob(ext))
        else:
            # might be a glob or template
            all_paths.append(entry)
    return all_paths


def _looks_like_date_token(token: str) -> bool:
    """Return True when token resembles a YYYYMMDD date string."""
    return isinstance(token, str) and len(token) == 8 and token.isdigit()


def extract_vehicle_date_from_path(path: str) -> tuple:
    """Extract vehicle ID and date from an image/label path.

    Preferred rule:
      - Find the nearest 'images'/'labels' directory and search backwards for
        the closest YYYYMMDD token; its previous segment is treated as vehicle.

    Fallback rule:
      - For paths rooted at driving_png* directories, use the next two segments
        as <vehicle>/<date>.

    Returns:
        (vehicle_id, date_str, vehicle_date_key)
        Missing values are returned as empty strings.
    """
    norm_path = path.replace("\\", "/")
    parts = [p for p in norm_path.split("/") if p and p != "."]

    anchor_idx = None
    for i, seg in enumerate(parts):
        if seg in ("images", "labels"):
            anchor_idx = i

    if anchor_idx is not None:
        search_start = max(anchor_idx - 4, 0)
        for j in range(anchor_idx - 1, search_start - 1, -1):
            if _looks_like_date_token(parts[j]):
                vehicle_id = parts[j - 1] if j - 1 >= 0 else ""
                date_str = parts[j]
                vehicle_date = f"{vehicle_id}/{date_str}" if vehicle_id else date_str
                return vehicle_id, date_str, vehicle_date

    for i, seg in enumerate(parts):
        if seg.startswith("driving_png") and i + 2 < len(parts):
            vehicle_id = parts[i + 1]
            date_str = parts[i + 2] if _looks_like_date_token(parts[i + 2]) else ""
            vehicle_date = f"{vehicle_id}/{date_str}" if vehicle_id and date_str else ""
            return vehicle_id, date_str, vehicle_date

    return "", "", ""


# ─────────────── Statistics Collectors ──────────────────


def _default_zeros4():
    """Picklable default factory: returns np.zeros(4)."""
    return np.zeros(4)


def _default_4lists():
    """Picklable default factory: returns 4 empty lists."""
    return [[] for _ in range(4)]


def _default_int_zeros4():
    """Picklable default factory: returns int np.zeros(4)."""
    return np.zeros(4, dtype=np.int64)


def encode_yaw_bins_numpy(rot_y: float) -> dict:
    """Encode one rot_y angle with the same 4-bin soft-label logic used in training."""
    delta_0 = rot_y
    delta_1 = rot_y - np.pi / 2
    delta_2 = rot_y + np.pi / 2
    delta_3 = rot_y - np.pi if abs(rot_y - np.pi) < abs(rot_y + np.pi) else rot_y + np.pi
    angles = np.array([delta_0, delta_1, delta_2, delta_3], dtype=np.float64)

    ang_cls = (np.pi * 0.5 - np.abs(angles)) / (np.pi * 0.5)
    ang_cls = np.clip(ang_cls, 0.0, 1.0)

    hard_bin = int(np.argmax(ang_cls))
    sorted_cls = np.sort(ang_cls)[::-1]
    top1 = float(sorted_cls[0])
    top2 = float(sorted_cls[1]) if len(sorted_cls) > 1 else 0.0

    return {
        'angles': angles,
        'ang_cls': ang_cls,
        'hard_bin': hard_bin,
        'hard_delta': float(angles[hard_bin]),
        'positive_bin_count': int(np.sum(ang_cls > 0.0)),
        'strong_bin_count': int(np.sum(ang_cls >= 0.5)),
        'bin_margin': float(top1 - top2),
    }


class DatasetProfiler:
    """Collect and compute comprehensive statistics for a dataset split."""

    def __init__(self, split_name: str, img_w: int = 1920, img_h: int = 960,
                 analysis_mode: str = "original",
                 ori_img_size: tuple = (1920, 1080),
                 roi_size: tuple = (1920, 960)):
        self.split_name = split_name
        self.img_w = img_w
        self.img_h = img_h
        self.analysis_mode = analysis_mode  # "original" or "roi"
        self.ori_img_size = ori_img_size    # original image size (W, H)
        self.roi_size = roi_size            # target ROI size (W, H)

        # ROI statistics
        self.roi_calib_found = 0
        self.roi_calib_missing = 0
        self.roi_filtered_objects = 0  # objects removed (fully outside ROI)
        self.roi_clipped_objects = 0   # objects clipped at ROI boundary

        # ── Overview counters ──
        self.total_images = 0
        self.total_labels_found = 0
        self.total_labels_missing = 0
        self.total_objects = 0
        self.empty_label_count = 0  # label files with 0 objects

        # ── Per-format counters ──
        self.format_counts = Counter()  # {50: N, 18: N, 6: N, ...}

        # ── Per-class counters ──
        self.class_counts = Counter()

        # ── Per-image object count ──
        self.objects_per_image = []
        self.classes_per_image = []  # set sizes

        # ── Vehicle / date distribution ──
        self.images_per_vehicle = Counter()
        self.images_per_date = Counter()
        self.images_per_vehicle_date = Counter()
        self.objects_per_vehicle = Counter()
        self.objects_per_date = Counter()
        self.objects_per_vehicle_date = Counter()
        self.images_missing_vehicle_date = 0

        # ── 2D bbox stats (all classes) ──
        self.bbox2d_w = defaultdict(list)  # class -> list of widths (pixels)
        self.bbox2d_h = defaultdict(list)
        self.bbox2d_cx = defaultdict(list)  # center x (pixels)
        self.bbox2d_cy = defaultdict(list)
        self.bbox2d_area = defaultdict(list)
        self.bbox2d_aspect = defaultdict(list)  # w/h

        # ── 3D stats (classes with 3D info) ──
        self.depth = defaultdict(list)       # z3d per class
        self.dim_l = defaultdict(list)       # length
        self.dim_h = defaultdict(list)       # height
        self.dim_w = defaultdict(list)       # width
        self.rot_y = defaultdict(list)       # yaw
        self.alpha = defaultdict(list)       # observation angle
        self.x3d = defaultdict(list)
        self.y3d = defaultdict(list)

        # ── Yaw-bin profiling (same 4-bin encoding as training) ──
        self.yaw_bin_hard_counts = defaultdict(_default_int_zeros4)
        self.yaw_bin_soft_mass = defaultdict(_default_zeros4)
        self.yaw_bin_positive_count_hist = defaultdict(Counter)
        self.yaw_bin_strong_count_hist = defaultdict(Counter)
        self.yaw_bin_margin = defaultdict(list)
        self.yaw_bin_hard_delta = defaultdict(_default_4lists)

        # ── Face visibility (vehicles/tricycles only) ──
        self.face_visible_count = defaultdict(_default_zeros4)  # class->[front,rear,left,right]
        self.face_total_count = defaultdict(int)
        self.face_scores = defaultdict(_default_4lists)  # per-face scores

        # ── Cut-type analysis ──
        self.cut_type_counts = Counter()  # {noncut, cut_in, cut_out}

        # ── Vehicle subtype counts (大车 vs 小车) ──
        self.vehicle_subtype_counts = Counter()  # {"vehicle_large": N, "vehicle_small": N}
        self.vehicle_total_counts_per_vehicle = Counter()   # vehicle_id -> all cls-0 objects
        self.vehicle_subtype_counts_per_vehicle = defaultdict(Counter)  # vehicle_id -> subtype counts

        # ── Data quality ──
        self.nan_3d_objects = 0  # Objects with 3D class but NaN 3D fields
        self.invalid_depth_count = 0  # z3d <= 0
        self.outlier_depth_count = 0  # z3d > 200m
        self.tiny_box_count = 0  # 2D box < min_box_size

    def process_image(self, img_path: str) -> None:
        """Process one image ↔ label pair."""
        self.total_images += 1
        vehicle_id, date_str, vehicle_date = extract_vehicle_date_from_path(img_path)
        if vehicle_id:
            self.images_per_vehicle[vehicle_id] += 1
        if date_str:
            self.images_per_date[date_str] += 1
        if vehicle_date:
            self.images_per_vehicle_date[vehicle_date] += 1
        if not vehicle_date:
            self.images_missing_vehicle_date += 1

        label_path = image_path_to_label_path(img_path)
        entries = parse_label_file(label_path)

        if not os.path.exists(label_path):
            self.total_labels_missing += 1
            self.objects_per_image.append(0)
            self.classes_per_image.append(0)
            return

        self.total_labels_found += 1
        if len(entries) == 0:
            self.empty_label_count += 1
            self.objects_per_image.append(0)
            self.classes_per_image.append(0)
            return

        # ── ROI filtering/clipping (when analysis_mode == "roi") ──
        if self.analysis_mode == "roi":
            roi_bounds = get_roi_for_image(img_path, self.ori_img_size, self.roi_size)
            if roi_bounds is None:
                self.roi_calib_missing += 1
                # Fall back to original image analysis for this image
            else:
                self.roi_calib_found += 1
                roi_x1, roi_y1, roi_x2, roi_y2 = roi_bounds
                n_before = len(entries)
                entries = filter_clip_labels_to_roi(
                    entries, self.ori_img_size, roi_x1, roi_y1, roi_x2, roi_y2)
                n_after = len(entries)
                self.roi_filtered_objects += (n_before - n_after)
                # Count partial/clipped objects: objects that had their bbox
                # coordinates changed (partial objects that survived filtering)
                # We track this approximately from the partial mask computed inside
                # filter_clip_labels_to_roi - for efficiency, count difference

        n_objs = len(entries)
        self.total_objects += n_objs
        self.objects_per_image.append(n_objs)
        if vehicle_id:
            self.objects_per_vehicle[vehicle_id] += n_objs
        if date_str:
            self.objects_per_date[date_str] += n_objs
        if vehicle_date:
            self.objects_per_vehicle_date[vehicle_date] += n_objs
        cls_set = set()

        for label, raw_cols in entries:
            self.format_counts[raw_cols] += 1
            cls_id = int(label[IDX["cls"]]) if not np.isnan(label[IDX["cls"]]) else -1
            self.class_counts[cls_id] += 1
            cls_set.add(cls_id)
            if cls_id == 0 and vehicle_id:
                self.vehicle_total_counts_per_vehicle[vehicle_id] += 1

            # ── 2D bbox (all objects) ──
            x_n, y_n, w_n, h_n = label[1], label[2], label[3], label[4]
            if not any(np.isnan(v) for v in [x_n, y_n, w_n, h_n]):
                w_px = w_n * self.img_w
                h_px = h_n * self.img_h
                cx_px = x_n * self.img_w
                cy_px = y_n * self.img_h
                area_px = w_px * h_px
                aspect = w_px / max(h_px, 1e-6)

                self.bbox2d_w[cls_id].append(w_px)
                self.bbox2d_h[cls_id].append(h_px)
                self.bbox2d_cx[cls_id].append(cx_px)
                self.bbox2d_cy[cls_id].append(cy_px)
                self.bbox2d_area[cls_id].append(area_px)
                self.bbox2d_aspect[cls_id].append(aspect)

                if w_px < 8 or h_px < 8:
                    self.tiny_box_count += 1

            # ── 3D stats ──
            if cls_id in ALL_3D_CLASSES:
                z3d = label[IDX["z3d"]]
                x3d = label[IDX["x3d"]]
                y3d = label[IDX["y3d"]]
                l = label[IDX["length"]]
                h = label[IDX["height"]]
                w = label[IDX["width"]]
                ry = label[IDX["rot_y"]]
                al = label[IDX["alpha"]]

                if np.isnan(z3d) or np.isnan(l):
                    self.nan_3d_objects += 1
                    continue

                if z3d <= 0:
                    self.invalid_depth_count += 1
                elif z3d > 200:
                    self.outlier_depth_count += 1

                self.depth[cls_id].append(z3d)
                self.x3d[cls_id].append(x3d)
                self.y3d[cls_id].append(y3d)
                self.dim_l[cls_id].append(l)
                self.dim_h[cls_id].append(h)
                self.dim_w[cls_id].append(w)
                if not np.isnan(ry):
                    self.rot_y[cls_id].append(ry)
                    yaw_bin_info = encode_yaw_bins_numpy(float(ry))
                    self.yaw_bin_hard_counts[cls_id][yaw_bin_info['hard_bin']] += 1
                    self.yaw_bin_soft_mass[cls_id] += yaw_bin_info['ang_cls']
                    self.yaw_bin_positive_count_hist[cls_id][yaw_bin_info['positive_bin_count']] += 1
                    self.yaw_bin_strong_count_hist[cls_id][yaw_bin_info['strong_bin_count']] += 1
                    self.yaw_bin_margin[cls_id].append(yaw_bin_info['bin_margin'])
                    self.yaw_bin_hard_delta[cls_id][yaw_bin_info['hard_bin']].append(yaw_bin_info['hard_delta'])
                if not np.isnan(al):
                    self.alpha[cls_id].append(al)

                # ── Vehicle subtype: 大车 (large) vs 小车 (small) ──
                if cls_id == 0 and not np.isnan(h):
                    veh_sub_id = (VEH_LARGE
                                  if (l > LARGE_VEHICLE_L_THRESH and h > LARGE_VEHICLE_H_THRESH)
                                  else VEH_SMALL)
                    sub_name = CLASS_NAMES[veh_sub_id]
                    self.vehicle_subtype_counts[sub_name] += 1
                    if vehicle_id:
                        self.vehicle_subtype_counts_per_vehicle[vehicle_id][sub_name] += 1
                    self.depth[veh_sub_id].append(z3d)
                    self.x3d[veh_sub_id].append(x3d)
                    self.y3d[veh_sub_id].append(y3d)
                    self.dim_l[veh_sub_id].append(l)
                    self.dim_h[veh_sub_id].append(h)
                    self.dim_w[veh_sub_id].append(w)
                    if not np.isnan(ry):
                        self.rot_y[veh_sub_id].append(ry)
                        self.yaw_bin_hard_counts[veh_sub_id][yaw_bin_info['hard_bin']] += 1
                        self.yaw_bin_soft_mass[veh_sub_id] += yaw_bin_info['ang_cls']
                        self.yaw_bin_positive_count_hist[veh_sub_id][yaw_bin_info['positive_bin_count']] += 1
                        self.yaw_bin_strong_count_hist[veh_sub_id][yaw_bin_info['strong_bin_count']] += 1
                        self.yaw_bin_margin[veh_sub_id].append(yaw_bin_info['bin_margin'])
                        self.yaw_bin_hard_delta[veh_sub_id][yaw_bin_info['hard_bin']].append(yaw_bin_info['hard_delta'])
                    if not np.isnan(al):
                        self.alpha[veh_sub_id].append(al)
                    # 2D bbox (x_n, y_n, w_n, h_n are in scope from the 2D block above)
                    if not any(np.isnan(v) for v in [x_n, y_n, w_n, h_n]):
                        _w = w_n * self.img_w
                        _h = h_n * self.img_h
                        self.bbox2d_w[veh_sub_id].append(_w)
                        self.bbox2d_h[veh_sub_id].append(_h)
                        self.bbox2d_cx[veh_sub_id].append(x_n * self.img_w)
                        self.bbox2d_cy[veh_sub_id].append(y_n * self.img_h)
                        self.bbox2d_area[veh_sub_id].append(_w * _h)
                        self.bbox2d_aspect[veh_sub_id].append(_w / max(_h, 1e-6))

                # ── Face analysis (face-based classes only) ──
                if cls_id in FACE_BASED_CLASSES and raw_cols >= 50:
                    self.face_total_count[cls_id] += 1
                    face_offsets = [IDX["face_front"], IDX["face_rear"],
                                    IDX["face_left"], IDX["face_right"]]

                    visible_faces = []
                    for fi, offset in enumerate(face_offsets):
                        score = label[offset + 6]      # face score
                        is_vis = label[offset + 7]      # is_visible flag
                        if not np.isnan(score):
                            self.face_scores[cls_id][fi].append(score)
                        if not np.isnan(is_vis) and is_vis >= 0:
                            # is_visible: 1=visible, 0=not visible, -1=invalid
                            if is_vis >= 0.5:
                                self.face_visible_count[cls_id][fi] += 1
                                visible_faces.append(fi)

                    # Determine cut type
                    # Check if spatial values are -1 (cut indicator)
                    def _is_face_cut(offset):
                        """Face is cut if x3d,y3d,z3d,alpha,score are all -1 or score is 0."""
                        vals = label[offset:offset + 5]  # x3d,y3d,z3d,alpha,xc
                        s = label[offset + 6]
                        if np.isnan(s):
                            return True
                        return (np.nansum(np.abs(vals + 1)) < 0.01) and s < 0.01

                    front_cut = _is_face_cut(IDX["face_front"])
                    rear_cut = _is_face_cut(IDX["face_rear"])
                    left_cut = _is_face_cut(IDX["face_left"])
                    right_cut = _is_face_cut(IDX["face_right"])

                    if rear_cut and left_cut and right_cut and not front_cut:
                        self.cut_type_counts["cut_in"] += 1
                    elif front_cut and left_cut and right_cut and not rear_cut:
                        self.cut_type_counts["cut_out"] += 1
                    elif not (front_cut or rear_cut or left_cut or right_cut):
                        self.cut_type_counts["noncut"] += 1
                    else:
                        self.cut_type_counts["partial_cut"] += 1

            elif raw_cols == 6:
                # 2D-only object with 3D class
                pass

        self.classes_per_image.append(len(cls_set))

    def merge(self, other: 'DatasetProfiler'):
        """Merge statistics from another DatasetProfiler into this one.

        Used to combine results from parallel workers.
        """
        # ── Scalar counters ──
        self.total_images += other.total_images
        self.total_labels_found += other.total_labels_found
        self.total_labels_missing += other.total_labels_missing
        self.total_objects += other.total_objects
        self.empty_label_count += other.empty_label_count
        self.roi_calib_found += other.roi_calib_found
        self.roi_calib_missing += other.roi_calib_missing
        self.roi_filtered_objects += other.roi_filtered_objects
        self.roi_clipped_objects += other.roi_clipped_objects
        self.nan_3d_objects += other.nan_3d_objects
        self.invalid_depth_count += other.invalid_depth_count
        self.outlier_depth_count += other.outlier_depth_count
        self.tiny_box_count += other.tiny_box_count
        self.images_missing_vehicle_date += other.images_missing_vehicle_date

        # ── Counter objects ──
        self.format_counts += other.format_counts
        self.class_counts += other.class_counts
        self.cut_type_counts += other.cut_type_counts
        self.vehicle_subtype_counts += other.vehicle_subtype_counts
        self.vehicle_total_counts_per_vehicle += other.vehicle_total_counts_per_vehicle
        self.images_per_vehicle += other.images_per_vehicle
        self.images_per_date += other.images_per_date
        self.images_per_vehicle_date += other.images_per_vehicle_date
        self.objects_per_vehicle += other.objects_per_vehicle
        self.objects_per_date += other.objects_per_date
        self.objects_per_vehicle_date += other.objects_per_vehicle_date
        for vehicle_id, subtype_counter in other.vehicle_subtype_counts_per_vehicle.items():
            self.vehicle_subtype_counts_per_vehicle[vehicle_id] += subtype_counter

        # ── Per-image lists ──
        self.objects_per_image.extend(other.objects_per_image)
        self.classes_per_image.extend(other.classes_per_image)

        # ── Per-class 2D bbox lists ──
        for cls_id in other.bbox2d_w:
            self.bbox2d_w[cls_id].extend(other.bbox2d_w[cls_id])
            self.bbox2d_h[cls_id].extend(other.bbox2d_h[cls_id])
            self.bbox2d_cx[cls_id].extend(other.bbox2d_cx[cls_id])
            self.bbox2d_cy[cls_id].extend(other.bbox2d_cy[cls_id])
            self.bbox2d_area[cls_id].extend(other.bbox2d_area[cls_id])
            self.bbox2d_aspect[cls_id].extend(other.bbox2d_aspect[cls_id])

        # ── Per-class 3D lists ──
        for cls_id in other.depth:
            self.depth[cls_id].extend(other.depth[cls_id])
        for cls_id in other.x3d:
            self.x3d[cls_id].extend(other.x3d[cls_id])
        for cls_id in other.y3d:
            self.y3d[cls_id].extend(other.y3d[cls_id])
        for cls_id in other.dim_l:
            self.dim_l[cls_id].extend(other.dim_l[cls_id])
        for cls_id in other.dim_h:
            self.dim_h[cls_id].extend(other.dim_h[cls_id])
        for cls_id in other.dim_w:
            self.dim_w[cls_id].extend(other.dim_w[cls_id])
        for cls_id in other.rot_y:
            self.rot_y[cls_id].extend(other.rot_y[cls_id])
        for cls_id in other.alpha:
            self.alpha[cls_id].extend(other.alpha[cls_id])

        # ── Yaw-bin profiling ──
        for cls_id in other.yaw_bin_hard_counts:
            self.yaw_bin_hard_counts[cls_id] += other.yaw_bin_hard_counts[cls_id]
        for cls_id in other.yaw_bin_soft_mass:
            self.yaw_bin_soft_mass[cls_id] += other.yaw_bin_soft_mass[cls_id]
        for cls_id in other.yaw_bin_positive_count_hist:
            self.yaw_bin_positive_count_hist[cls_id] += other.yaw_bin_positive_count_hist[cls_id]
        for cls_id in other.yaw_bin_strong_count_hist:
            self.yaw_bin_strong_count_hist[cls_id] += other.yaw_bin_strong_count_hist[cls_id]
        for cls_id in other.yaw_bin_margin:
            self.yaw_bin_margin[cls_id].extend(other.yaw_bin_margin[cls_id])
        for cls_id in other.yaw_bin_hard_delta:
            for bin_idx in range(4):
                self.yaw_bin_hard_delta[cls_id][bin_idx].extend(other.yaw_bin_hard_delta[cls_id][bin_idx])

        # ── Face visibility ──
        for cls_id in other.face_total_count:
            self.face_total_count[cls_id] += other.face_total_count[cls_id]
            self.face_visible_count[cls_id] += other.face_visible_count[cls_id]
            for fi in range(4):
                self.face_scores[cls_id][fi].extend(other.face_scores[cls_id][fi])

    def summarize(self) -> dict:
        """Compute summary statistics and return as dict."""
        summary = {}

        # ── 1. Overview ──
        summary["overview"] = {
            "split": self.split_name,
            "analysis_mode": self.analysis_mode,
            "total_images": self.total_images,
            "labels_found": self.total_labels_found,
            "labels_missing": self.total_labels_missing,
            "empty_labels": self.empty_label_count,
            "total_objects": self.total_objects,
            "format_distribution": dict(self.format_counts),
        }

        # ── ROI info (when in ROI mode) ──
        if self.analysis_mode == "roi":
            summary["overview"]["roi_info"] = {
                "ori_img_size": list(self.ori_img_size),
                "roi_size": list(self.roi_size),
                "img_size_used": [self.img_w, self.img_h],
                "calib_found": self.roi_calib_found,
                "calib_missing": self.roi_calib_missing,
                "objects_filtered_outside_roi": self.roi_filtered_objects,
            }

        # ── 2. Per-image density ──
        opi = np.array(self.objects_per_image) if self.objects_per_image else np.array([0])
        cpi = np.array(self.classes_per_image) if self.classes_per_image else np.array([0])
        summary["per_image_density"] = {
            "objects_per_image": _dist_stats(opi),
            "classes_per_image": _dist_stats(cpi),
        }

        # ── 2.5 Vehicle / date distribution ──
        dates_per_vehicle = {}
        for vehicle_id in sorted(self.images_per_vehicle.keys()):
            date_counter = Counter()
            prefix = f"{vehicle_id}/"
            for vehicle_date, image_count in self.images_per_vehicle_date.items():
                if vehicle_date.startswith(prefix):
                    date_str = vehicle_date[len(prefix):]
                    if date_str:
                        date_counter[date_str] = image_count
            dates_per_vehicle[vehicle_id] = {
                "unique_dates": len(date_counter),
                "images_per_date_stats": _counter_value_stats(date_counter),
                "image_counts": _counter_to_sorted_dict(date_counter, sort_mode="label_asc"),
            }

        vehicle_subtype_per_vehicle = {}
        for vehicle_id in sorted(set(self.images_per_vehicle.keys()) |
                                 set(self.vehicle_total_counts_per_vehicle.keys()) |
                                 set(self.vehicle_subtype_counts_per_vehicle.keys())):
            total_vehicle = self.vehicle_total_counts_per_vehicle.get(vehicle_id, 0)
            large_n = self.vehicle_subtype_counts_per_vehicle[vehicle_id].get("vehicle_large", 0)
            small_n = self.vehicle_subtype_counts_per_vehicle[vehicle_id].get("vehicle_small", 0)
            classifiable_n = large_n + small_n
            vehicle_subtype_per_vehicle[vehicle_id] = {
                "total_vehicle_objects": total_vehicle,
                "classifiable_vehicle_objects": classifiable_n,
                "unclassified_vehicle_objects": total_vehicle - classifiable_n,
                "large_vehicle": {
                    "count": large_n,
                    "proportion_of_classifiable": large_n / max(classifiable_n, 1),
                },
                "small_vehicle": {
                    "count": small_n,
                    "proportion_of_classifiable": small_n / max(classifiable_n, 1),
                },
            }

        summary["vehicle_date_distribution"] = {
            "missing_vehicle_date_images": self.images_missing_vehicle_date,
            "vehicle": {
                "unique_count": len(self.images_per_vehicle),
                "images_per_vehicle_stats": _counter_value_stats(self.images_per_vehicle),
                "objects_per_vehicle_stats": _counter_value_stats(self.objects_per_vehicle),
                "image_counts": _counter_to_sorted_dict(self.images_per_vehicle),
                "object_counts": _counter_to_sorted_dict(self.objects_per_vehicle),
            },
            "date": {
                "unique_count": len(self.images_per_date),
                "images_per_date_stats": _counter_value_stats(self.images_per_date),
                "objects_per_date_stats": _counter_value_stats(self.objects_per_date),
                "image_counts": _counter_to_sorted_dict(self.images_per_date, sort_mode="label_asc"),
                "object_counts": _counter_to_sorted_dict(self.objects_per_date, sort_mode="label_asc"),
            },
            "vehicle_date": {
                "unique_count": len(self.images_per_vehicle_date),
                "images_per_vehicle_date_stats": _counter_value_stats(self.images_per_vehicle_date),
                "objects_per_vehicle_date_stats": _counter_value_stats(self.objects_per_vehicle_date),
                "image_counts": _counter_to_sorted_dict(self.images_per_vehicle_date),
                "object_counts": _counter_to_sorted_dict(self.objects_per_vehicle_date),
            },
            "dates_per_vehicle": dates_per_vehicle,
            "vehicle_subtype_per_vehicle": vehicle_subtype_per_vehicle,
        }

        # ── 3. Per-class breakdown ──
        class_info = {}
        for cls_id in sorted(self.class_counts.keys()):
            name = CLASS_NAMES.get(cls_id, f"class_{cls_id}")
            info = {
                "count": self.class_counts[cls_id],
                "proportion": self.class_counts[cls_id] / max(self.total_objects, 1),
            }

            # 2D bbox stats
            if self.bbox2d_w.get(cls_id):
                info["bbox2d"] = {
                    "width_px": _dist_stats(np.array(self.bbox2d_w[cls_id])),
                    "height_px": _dist_stats(np.array(self.bbox2d_h[cls_id])),
                    "area_px": _dist_stats(np.array(self.bbox2d_area[cls_id])),
                    "aspect_ratio": _dist_stats(np.array(self.bbox2d_aspect[cls_id])),
                }

            # 3D stats
            if self.depth.get(cls_id):
                d = np.array(self.depth[cls_id])
                info["depth_m"] = _dist_stats(d)
                info["depth_ranges"] = {
                    "0-10m": int(np.sum(d < 10)),
                    "10-20m": int(np.sum((d >= 10) & (d < 20))),
                    "20-30m": int(np.sum((d >= 20) & (d < 30))),
                    "30-50m": int(np.sum((d >= 30) & (d < 50))),
                    "50-80m": int(np.sum((d >= 50) & (d < 80))),
                    "80-120m": int(np.sum((d >= 80) & (d < 120))),
                    ">120m": int(np.sum(d >= 120)),
                }

            if self.dim_l.get(cls_id):
                info["dimensions_m"] = {
                    "length": _dist_stats(np.array(self.dim_l[cls_id])),
                    "height": _dist_stats(np.array(self.dim_h[cls_id])),
                    "width": _dist_stats(np.array(self.dim_w[cls_id])),
                }

            if self.rot_y.get(cls_id):
                info["rot_y_rad"] = _dist_stats(np.array(self.rot_y[cls_id]))
                info["yaw_bin_profile"] = _summarize_yaw_bin_profile(
                    self.yaw_bin_hard_counts[cls_id],
                    self.yaw_bin_soft_mass[cls_id],
                    self.yaw_bin_positive_count_hist[cls_id],
                    self.yaw_bin_strong_count_hist[cls_id],
                    self.yaw_bin_margin[cls_id],
                    self.yaw_bin_hard_delta[cls_id],
                )

            if self.alpha.get(cls_id):
                info["alpha_rad"] = _dist_stats(np.array(self.alpha[cls_id]))

            if self.x3d.get(cls_id):
                info["lateral_x3d_m"] = _dist_stats(np.array(self.x3d[cls_id]))

            class_info[name] = info

        # ── Vehicle subtype breakdown (大车 vs 小车) ──
        # Pre-compute classifiable count so it's available inside the loop.
        lv_n_pre = self.vehicle_subtype_counts.get("vehicle_large", 0)
        sv_n_pre = self.vehicle_subtype_counts.get("vehicle_small", 0)
        _classifiable = max(lv_n_pre + sv_n_pre, 1)
        for veh_sub_id in [VEH_LARGE, VEH_SMALL]:
            sub_name = CLASS_NAMES[veh_sub_id]
            count = self.vehicle_subtype_counts.get(sub_name, 0)
            if count == 0:
                continue
            info = {
                "count": count,
                "proportion": count / max(self.total_objects, 1),
                "proportion_of_vehicle": count / _classifiable,
            }
            if self.bbox2d_w.get(veh_sub_id):
                info["bbox2d"] = {
                    "width_px": _dist_stats(np.array(self.bbox2d_w[veh_sub_id])),
                    "height_px": _dist_stats(np.array(self.bbox2d_h[veh_sub_id])),
                    "area_px": _dist_stats(np.array(self.bbox2d_area[veh_sub_id])),
                    "aspect_ratio": _dist_stats(np.array(self.bbox2d_aspect[veh_sub_id])),
                }
            if self.depth.get(veh_sub_id):
                d = np.array(self.depth[veh_sub_id])
                info["depth_m"] = _dist_stats(d)
                info["depth_ranges"] = {
                    "0-10m":   int(np.sum(d < 10)),
                    "10-20m":  int(np.sum((d >= 10) & (d < 20))),
                    "20-30m":  int(np.sum((d >= 20) & (d < 30))),
                    "30-50m":  int(np.sum((d >= 30) & (d < 50))),
                    "50-80m":  int(np.sum((d >= 50) & (d < 80))),
                    "80-120m": int(np.sum((d >= 80) & (d < 120))),
                    ">120m":   int(np.sum(d >= 120)),
                }
            if self.dim_l.get(veh_sub_id):
                info["dimensions_m"] = {
                    "length": _dist_stats(np.array(self.dim_l[veh_sub_id])),
                    "height": _dist_stats(np.array(self.dim_h[veh_sub_id])),
                    "width":  _dist_stats(np.array(self.dim_w[veh_sub_id])),
                }
            if self.rot_y.get(veh_sub_id):
                info["rot_y_rad"] = _dist_stats(np.array(self.rot_y[veh_sub_id]))
                info["yaw_bin_profile"] = _summarize_yaw_bin_profile(
                    self.yaw_bin_hard_counts[veh_sub_id],
                    self.yaw_bin_soft_mass[veh_sub_id],
                    self.yaw_bin_positive_count_hist[veh_sub_id],
                    self.yaw_bin_strong_count_hist[veh_sub_id],
                    self.yaw_bin_margin[veh_sub_id],
                    self.yaw_bin_hard_delta[veh_sub_id],
                )
            if self.x3d.get(veh_sub_id):
                info["lateral_x3d_m"] = _dist_stats(np.array(self.x3d[veh_sub_id]))
            class_info[sub_name] = info

        summary["per_class"] = class_info

        # ── 3.5 Yaw-bin overview (real 3D classes only, excluding virtual subtypes) ──
        overall_yaw_hard_counts = np.zeros(4, dtype=np.int64)
        overall_yaw_soft_mass = np.zeros(4, dtype=np.float64)
        overall_positive_hist = Counter()
        overall_strong_hist = Counter()
        overall_yaw_margin = []
        overall_hard_delta = [[] for _ in range(4)]
        for cls_id in sorted(ALL_3D_CLASSES):
            if cls_id not in self.yaw_bin_hard_counts:
                continue
            overall_yaw_hard_counts += self.yaw_bin_hard_counts[cls_id]
            overall_yaw_soft_mass += self.yaw_bin_soft_mass[cls_id]
            overall_positive_hist += self.yaw_bin_positive_count_hist[cls_id]
            overall_strong_hist += self.yaw_bin_strong_count_hist[cls_id]
            overall_yaw_margin.extend(self.yaw_bin_margin[cls_id])
            for bin_idx in range(4):
                overall_hard_delta[bin_idx].extend(self.yaw_bin_hard_delta[cls_id][bin_idx])
        summary["yaw_bin_overview"] = _summarize_yaw_bin_profile(
            overall_yaw_hard_counts,
            overall_yaw_soft_mass,
            overall_positive_hist,
            overall_strong_hist,
            overall_yaw_margin,
            overall_hard_delta,
        )

        # ── Vehicle subtype summary (dedicated entry) ──
        # Denominator = only vehicles with valid 3D annotations (classifiable ones).
        # Vehicles with 2D-only labels (6-col) have no length/height and cannot be
        # classified as large/small; they are reported separately as "unclassified".
        total_vehicles_all = self.class_counts.get(0, 0)
        lv_n = self.vehicle_subtype_counts.get("vehicle_large", 0)
        sv_n = self.vehicle_subtype_counts.get("vehicle_small", 0)
        classifiable = max(lv_n + sv_n, 1)
        unclassified_n = total_vehicles_all - lv_n - sv_n
        summary["vehicle_subtype"] = {
            "threshold": {
                "length_gt_m": LARGE_VEHICLE_L_THRESH,
                "height_gt_m": LARGE_VEHICLE_H_THRESH,
            },
            "total_vehicles": total_vehicles_all,
            "classifiable_vehicles": lv_n + sv_n,
            "unclassified_vehicles": unclassified_n,   # 2D-only labels, no 3D dims
            "large_vehicle": {
                "count": lv_n,
                "proportion_of_classifiable": lv_n / classifiable,
            },
            "small_vehicle": {
                "count": sv_n,
                "proportion_of_classifiable": sv_n / classifiable,
            },
        }

        # ── 4. Face visibility (vehicles/tricycles) ──
        face_info = {}
        for cls_id in sorted(self.face_total_count.keys()):
            name = CLASS_NAMES.get(cls_id, f"class_{cls_id}")
            total = self.face_total_count[cls_id]
            vis = self.face_visible_count[cls_id]
            face_info[name] = {
                "total_objects": total,
                "face_visibility_rate": {
                    fn: float(vis[i] / max(total, 1))
                    for i, fn in enumerate(FACE_NAMES)
                },
                "face_score_stats": {
                    fn: _dist_stats(np.array(self.face_scores[cls_id][i]))
                    if self.face_scores[cls_id][i] else None
                    for i, fn in enumerate(FACE_NAMES)
                },
            }
        summary["face_visibility"] = face_info

        # ── 5. Cut type distribution ──
        summary["cut_type"] = dict(self.cut_type_counts)

        # ── 6. Data quality ──
        summary["data_quality"] = {
            "nan_3d_objects": self.nan_3d_objects,
            "invalid_depth_le0": self.invalid_depth_count,
            "outlier_depth_gt200m": self.outlier_depth_count,
            "tiny_box_lt8px": self.tiny_box_count,
        }

        return summary


def _dist_stats(arr: np.ndarray) -> dict:
    """Compute descriptive statistics for a numeric array."""
    if len(arr) == 0:
        return {"count": 0}
    return {
        "count": int(len(arr)),
        "mean": float(np.nanmean(arr)),
        "std": float(np.nanstd(arr)),
        "min": float(np.nanmin(arr)),
        "p5": float(np.nanpercentile(arr, 5)),
        "p25": float(np.nanpercentile(arr, 25)),
        "median": float(np.nanmedian(arr)),
        "p75": float(np.nanpercentile(arr, 75)),
        "p95": float(np.nanpercentile(arr, 95)),
        "max": float(np.nanmax(arr)),
    }


def _counter_value_stats(counter: Counter) -> dict:
    """Compute descriptive statistics for Counter values."""
    if not counter:
        return {"count": 0}
    return _dist_stats(np.array(list(counter.values()), dtype=np.float64))


def _counter_items(counter: Counter, sort_mode: str = "count_desc", limit: int = None) -> list:
    """Return Counter items in a deterministic, presentation-friendly order."""
    items = list(counter.items())
    if sort_mode == "label_asc":
        items.sort(key=lambda kv: kv[0])
    else:
        items.sort(key=lambda kv: (-kv[1], kv[0]))
    if limit is not None:
        items = items[:limit]
    return items


def _counter_to_sorted_dict(counter: Counter, sort_mode: str = "count_desc", limit: int = None) -> dict:
    """Convert Counter to an ordered dict representation for JSON/report output."""
    return {k: int(v) for k, v in _counter_items(counter, sort_mode=sort_mode, limit=limit)}


def _summarize_yaw_bin_profile(hard_counts: np.ndarray, soft_mass: np.ndarray,
                               positive_hist: Counter, strong_hist: Counter,
                               margins: list, hard_delta_lists: list) -> dict:
    """Summarize yaw-bin distribution statistics for one class/group."""
    hard_counts = np.asarray(hard_counts, dtype=np.int64)
    soft_mass = np.asarray(soft_mass, dtype=np.float64)
    hard_total = int(np.sum(hard_counts))
    soft_total = float(np.sum(soft_mass))

    def _ratio_dict(values, denom):
        denom = max(float(denom), 1e-12)
        return {f'bin_{i}': float(values[i] / denom) for i in range(4)}

    return {
        'hard_bin_counts': {f'bin_{i}': int(hard_counts[i]) for i in range(4)},
        'hard_bin_ratio': _ratio_dict(hard_counts.astype(np.float64), max(hard_total, 1)),
        'soft_bin_mass': {f'bin_{i}': float(soft_mass[i]) for i in range(4)},
        'soft_bin_mass_ratio': _ratio_dict(soft_mass, max(soft_total, 1e-12)),
        'positive_bin_count_hist': {str(k): int(v) for k, v in sorted(positive_hist.items())},
        'strong_bin_count_hist': {str(k): int(v) for k, v in sorted(strong_hist.items())},
        'bin_margin': _dist_stats(np.array(margins, dtype=np.float64)) if margins else {'count': 0},
        'hard_delta_per_bin': {
            f'bin_{i}': _dist_stats(np.array(hard_delta_lists[i], dtype=np.float64))
            if hard_delta_lists[i] else {'count': 0}
            for i in range(4)
        },
    }


def _process_chunk(args):
    """Worker function for multiprocessing: process a chunk of images.

    Args is a tuple of:
        (paths, split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size)
    Returns a DatasetProfiler with statistics for this chunk.
    """
    paths, split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size = args
    profiler = DatasetProfiler(split_name, img_w, img_h, analysis_mode, ori_img_size, roi_size)
    for p in paths:
        profiler.process_image(p)
    return profiler


# ─────────────────── Visualization ──────────────────────

def plot_overview(summary: dict, output_dir: str):
    """Plot 1: Dataset overview bar charts."""
    fig, axes = plt.subplots(1, 3, figsize=(18, 5))
    fig.suptitle(f"Dataset Overview — {summary['overview']['split']}", fontsize=14, fontweight="bold")

    # A) Per-class object count
    ax = axes[0]
    cls_data = summary["per_class"]
    names = list(cls_data.keys())
    counts = [cls_data[n]["count"] for n in names]
    if names:
        colors = plt.cm.tab20(np.linspace(0, 1, len(names)))
        bars = ax.barh(names, counts, color=colors)
        ax.set_xlabel("Object Count")
        ax.set_title("Per-Class Object Count")
        for bar, c in zip(bars, counts):
            ax.text(bar.get_width() + max(counts) * 0.01, bar.get_y() + bar.get_height() / 2,
                    f"{c:,}", va="center", fontsize=8)
    else:
        ax.set_title("Per-Class Object Count (no data)")
        ax.axis("off")

    # B) Label format distribution
    ax = axes[1]
    fmt_data = summary["overview"]["format_distribution"]
    fmt_labels = [f"{k}-col" for k in sorted(fmt_data.keys())]
    fmt_vals = [fmt_data[k] for k in sorted(fmt_data.keys())]
    if fmt_vals:
        ax.pie(fmt_vals, labels=fmt_labels, autopct="%1.1f%%", startangle=90)
        ax.set_title("Label Format Distribution")
    else:
        ax.set_title("Label Format Distribution (no data)")
        ax.axis("off")

    # C) Objects per image histogram
    ax = axes[2]
    opi = summary["per_image_density"]["objects_per_image"]
    if opi.get("mean") is not None:
        ax.text(0.5, 0.95, f"mean={opi['mean']:.1f}  median={opi['median']:.1f}  "
                f"max={opi['max']:.0f}  p95={opi['p95']:.0f}",
                transform=ax.transAxes, ha="center", va="top", fontsize=9,
                bbox=dict(boxstyle="round", facecolor="lightyellow"))
    ax.set_title("Objects per Image (stats)")
    ax.axis("off")

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "01_overview.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_2d_bbox_analysis(profiler: DatasetProfiler, output_dir: str):
    """Plot 2: 2D bounding box distributions and position heatmaps."""

    # Collect top classes by count
    top_classes = sorted(profiler.class_counts.keys(),
                         key=lambda c: profiler.class_counts[c], reverse=True)[:6]
    if not top_classes:
        return

    fig = plt.figure(figsize=(20, 14))
    gs = gridspec.GridSpec(3, len(top_classes), hspace=0.35, wspace=0.3)
    fig.suptitle(f"2D Bounding Box Analysis — {profiler.split_name}", fontsize=14, fontweight="bold")

    for ci, cls_id in enumerate(top_classes):
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")

        # Row 0: Width-Height scatter
        ax = fig.add_subplot(gs[0, ci])
        w_arr = np.array(profiler.bbox2d_w.get(cls_id, []))
        h_arr = np.array(profiler.bbox2d_h.get(cls_id, []))
        if len(w_arr) > 0:
            # Subsample for scatter
            idx = np.random.choice(len(w_arr), min(5000, len(w_arr)), replace=False)
            ax.scatter(w_arr[idx], h_arr[idx], s=1, alpha=0.3)
            ax.set_xlabel("Width (px)")
            ax.set_ylabel("Height (px)")
        ax.set_title(f"{name} (N={profiler.class_counts[cls_id]:,})", fontsize=10)

        # Row 1: Area histogram
        ax = fig.add_subplot(gs[1, ci])
        area_arr = np.array(profiler.bbox2d_area.get(cls_id, []))
        if len(area_arr) > 0:
            ax.hist(np.clip(area_arr, 0, np.percentile(area_arr, 99)), bins=50, color="steelblue", edgecolor="none")
            ax.set_xlabel("Area (px²)")
            ax.set_ylabel("Count")
            ax.set_title("Area distribution", fontsize=9)

        # Row 2: Position heatmap
        ax = fig.add_subplot(gs[2, ci])
        cx_arr = np.array(profiler.bbox2d_cx.get(cls_id, []))
        cy_arr = np.array(profiler.bbox2d_cy.get(cls_id, []))
        if len(cx_arr) > 0:
            ax.hist2d(cx_arr, cy_arr, bins=50, cmap="YlOrRd",
                      range=[[0, profiler.img_w], [0, profiler.img_h]])
            ax.set_xlabel("X (px)")
            ax.set_ylabel("Y (px)")
            ax.set_title("Center heatmap", fontsize=9)
            ax.invert_yaxis()
            ax.set_aspect("equal")

    plt.savefig(os.path.join(output_dir, "02_bbox2d_analysis.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_3d_depth_analysis(profiler: DatasetProfiler, output_dir: str):
    """Plot 3: Depth distributions per class."""
    cls_ids = sorted([c for c in profiler.depth.keys() if profiler.depth[c]])
    if not cls_ids:
        return

    n = len(cls_ids)
    fig, axes = plt.subplots(2, n, figsize=(5 * n, 9))
    if n == 1:
        axes = axes.reshape(2, 1)
    fig.suptitle(f"Depth (Z3D) Analysis — {profiler.split_name}", fontsize=14, fontweight="bold")

    for ci, cls_id in enumerate(cls_ids):
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")
        d = np.array(profiler.depth[cls_id])

        # Row 0: Histogram
        ax = axes[0, ci]
        ax.hist(np.clip(d, 0, 150), bins=80, color="teal", edgecolor="none", alpha=0.8)
        ax.axvline(np.median(d), color="red", linestyle="--", label=f"median={np.median(d):.1f}m")
        ax.axvline(np.mean(d), color="orange", linestyle="--", label=f"mean={np.mean(d):.1f}m")
        ax.set_xlabel("Depth (m)")
        ax.set_ylabel("Count")
        ax.set_title(f"{name} (N={len(d):,})")
        ax.legend(fontsize=8)

        # Row 1: Cumulative distribution (CDF)
        ax = axes[1, ci]
        sorted_d = np.sort(d)
        cdf = np.arange(1, len(sorted_d) + 1) / len(sorted_d)
        ax.plot(sorted_d, cdf, color="teal", linewidth=1.5)
        ax.set_xlabel("Depth (m)")
        ax.set_ylabel("CDF")
        ax.set_title("Cumulative distribution")
        ax.grid(True, alpha=0.3)
        # Mark key percentiles
        for p in [50, 90, 95]:
            val = np.percentile(d, p)
            ax.axhline(p / 100, color="gray", linestyle=":", alpha=0.4)
            ax.axvline(val, color="gray", linestyle=":", alpha=0.4)
            ax.text(val, p / 100 + 0.02, f"p{p}={val:.0f}m", fontsize=7)

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "03_depth_analysis.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_3d_dimensions(profiler: DatasetProfiler, output_dir: str):
    """Plot 4: 3D dimension distributions (LxHxW) per class."""
    cls_ids = sorted([c for c in profiler.dim_l.keys() if profiler.dim_l[c]])
    if not cls_ids:
        return

    n = len(cls_ids)
    fig, axes = plt.subplots(3, n, figsize=(5 * n, 12))
    if n == 1:
        axes = axes.reshape(3, 1)
    fig.suptitle(f"3D Dimensions (L×H×W) — {profiler.split_name}", fontsize=14, fontweight="bold")

    dim_names = ["Length", "Height", "Width"]
    dim_data = [profiler.dim_l, profiler.dim_h, profiler.dim_w]
    colors = ["#e74c3c", "#2ecc71", "#3498db"]

    for ci, cls_id in enumerate(cls_ids):
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")
        for di, (dname, ddict, color) in enumerate(zip(dim_names, dim_data, colors)):
            ax = axes[di, ci]
            vals = np.array(ddict.get(cls_id, []))
            if len(vals) > 0:
                ax.hist(vals, bins=60, color=color, edgecolor="none", alpha=0.8)
                ax.axvline(np.mean(vals), color="black", linestyle="--",
                           label=f"μ={np.mean(vals):.2f} σ={np.std(vals):.2f}")
                ax.legend(fontsize=7)
            ax.set_xlabel(f"{dname} (m)")
            if di == 0:
                ax.set_title(name)

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "04_dimensions_3d.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_rotation_analysis(profiler: DatasetProfiler, output_dir: str):
    """Plot 5: Rotation (rot_y) and alpha distributions."""
    cls_ids = sorted([c for c in profiler.rot_y.keys() if profiler.rot_y[c]])
    if not cls_ids:
        return

    n = len(cls_ids)
    fig, axes = plt.subplots(2, n, figsize=(5 * n, 9))
    if n == 1:
        axes = axes.reshape(2, 1)
    fig.suptitle(f"Rotation Analysis — {profiler.split_name}", fontsize=14, fontweight="bold")

    for ci, cls_id in enumerate(cls_ids):
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")

        # Row 0: rot_y polar histogram
        ax = axes[0, ci]
        ry = np.array(profiler.rot_y[cls_id])
        ax.hist(ry, bins=72, range=(-np.pi, np.pi), color="purple", edgecolor="none", alpha=0.8)
        ax.set_xlabel("rot_y (rad)")
        ax.set_ylabel("Count")
        ax.set_title(f"{name} — rot_y")
        # Mark key orientations
        for val, lbl in [(-np.pi / 2, "forward"), (np.pi / 2, "backward"), (0, "right"), (np.pi, "left")]:
            ax.axvline(val, color="gray", linestyle=":", alpha=0.5)
            ax.text(val, ax.get_ylim()[1] * 0.95, lbl, fontsize=7, ha="center", va="top")

        # Row 1: alpha histogram
        ax = axes[1, ci]
        al = np.array(profiler.alpha.get(cls_id, []))
        if len(al) > 0:
            ax.hist(al, bins=72, range=(-np.pi, np.pi), color="teal", edgecolor="none", alpha=0.8)
            ax.set_xlabel("alpha (rad)")
            ax.set_ylabel("Count")
            ax.set_title(f"{name} — alpha")

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "05_rotation_analysis.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_yaw_bin_analysis(profiler: DatasetProfiler, output_dir: str):
    """Plot 5b: Yaw-bin hard/soft distributions and bin-overlap statistics."""
    cls_ids = sorted([c for c in profiler.rot_y.keys() if profiler.rot_y[c]])
    if not cls_ids:
        return

    n = len(cls_ids)
    fig, axes = plt.subplots(4, n, figsize=(5 * n, 16))
    if n == 1:
        axes = axes.reshape(4, 1)
    fig.suptitle(f"Yaw-Bin Analysis — {profiler.split_name}", fontsize=14, fontweight="bold")

    bin_labels = ["bin0", "bin1", "bin2", "bin3"]
    count_labels = ["0", "1", "2", "3", "4"]
    count_x = np.arange(len(count_labels))

    for ci, cls_id in enumerate(cls_ids):
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")
        hard_counts = np.asarray(profiler.yaw_bin_hard_counts[cls_id], dtype=np.float64)
        hard_ratios = hard_counts / max(np.sum(hard_counts), 1.0)
        soft_mass = np.asarray(profiler.yaw_bin_soft_mass[cls_id], dtype=np.float64)
        soft_ratios = soft_mass / max(np.sum(soft_mass), 1e-12)

        ax = axes[0, ci]
        ax.bar(bin_labels, hard_ratios * 100.0, color=["#3498db", "#2ecc71", "#e67e22", "#9b59b6"])
        ax.set_ylim(0, 100)
        ax.set_ylabel("Hard Ratio (%)")
        ax.set_title(f"{name} — hard bin")

        ax = axes[1, ci]
        ax.bar(bin_labels, soft_ratios * 100.0, color=["#5dade2", "#58d68d", "#f5b041", "#af7ac5"])
        ax.set_ylim(0, 100)
        ax.set_ylabel("Soft Mass Ratio (%)")
        ax.set_title(f"{name} — soft bin mass")

        ax = axes[2, ci]
        positive_vals = [profiler.yaw_bin_positive_count_hist[cls_id].get(i, 0) for i in range(5)]
        strong_vals = [profiler.yaw_bin_strong_count_hist[cls_id].get(i, 0) for i in range(5)]
        width = 0.38
        ax.bar(count_x - width / 2, positive_vals, width=width, label=">0", color="#3498db")
        ax.bar(count_x + width / 2, strong_vals, width=width, label=">=0.5", color="#e74c3c")
        ax.set_xticks(count_x)
        ax.set_xticklabels(count_labels)
        ax.set_xlabel("Active Bin Count")
        ax.set_ylabel("Samples")
        ax.set_title(f"{name} — bin overlap")
        ax.legend(fontsize=7)

        ax = axes[3, ci]
        delta_lists = profiler.yaw_bin_hard_delta[cls_id]
        has_delta = any(len(vals) > 0 for vals in delta_lists)
        if has_delta:
            ax.boxplot([vals if vals else [np.nan] for vals in delta_lists], labels=bin_labels, showfliers=False)
            ax.set_ylabel("Hard Delta (rad)")
        else:
            ax.text(0.5, 0.5, "No hard-delta data", ha="center", va="center", transform=ax.transAxes)
            ax.set_xticks([])
        ax.set_title(f"{name} — hard delta")

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "05b_yaw_bin_analysis.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_face_visibility(profiler: DatasetProfiler, summary: dict, output_dir: str):
    """Plot 6: Face visibility rates and cut-type distribution."""
    face_data = summary.get("face_visibility", {})
    cut_data = summary.get("cut_type", {})
    if not face_data and not cut_data:
        return

    ncols = len(face_data) + (1 if cut_data else 0)
    if ncols == 0:
        return
    fig, axes = plt.subplots(1, ncols, figsize=(6 * ncols, 5))
    if ncols == 1:
        axes = [axes]
    fig.suptitle(f"Face Visibility & Cut Analysis — {profiler.split_name}", fontsize=14, fontweight="bold")

    ci = 0
    for name, finfo in face_data.items():
        ax = axes[ci]
        rates = finfo["face_visibility_rate"]
        face_labels = list(rates.keys())
        face_vals = [rates[k] * 100 for k in face_labels]
        colors = ["#e74c3c", "#2ecc71", "#3498db", "#f39c12"]
        bars = ax.bar(face_labels, face_vals, color=colors)
        for bar, v in zip(bars, face_vals):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,
                    f"{v:.1f}%", ha="center", fontsize=9)
        ax.set_ylabel("Visibility Rate (%)")
        ax.set_title(f"{name} — Face Visibility")
        ax.set_ylim(0, 110)
        ci += 1

    if cut_data:
        ax = axes[ci]
        labels = list(cut_data.keys())
        vals = [cut_data[k] for k in labels]
        ax.pie(vals, labels=labels, autopct="%1.1f%%", startangle=90,
               colors=["#2ecc71", "#e74c3c", "#f39c12", "#9b59b6"])
        ax.set_title("Cut-Type Distribution")

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "06_face_visibility_cut.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_lateral_depth_scatter(profiler: DatasetProfiler, output_dir: str):
    """Plot 7: Lateral (X3D) vs Depth (Z3D) bird's-eye view scatter."""
    cls_ids = sorted([c for c in profiler.x3d.keys() if profiler.x3d[c]])
    if not cls_ids:
        return

    fig, axes = plt.subplots(1, len(cls_ids), figsize=(6 * len(cls_ids), 6))
    if len(cls_ids) == 1:
        axes = [axes]
    fig.suptitle(f"Bird's Eye View (X vs Z) — {profiler.split_name}", fontsize=14, fontweight="bold")

    for ci, cls_id in enumerate(cls_ids):
        ax = axes[ci]
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")
        x = np.array(profiler.x3d[cls_id])
        z = np.array(profiler.depth[cls_id])
        # Subsample
        idx = np.random.choice(len(x), min(10000, len(x)), replace=False)
        ax.scatter(x[idx], z[idx], s=1, alpha=0.3, c="teal")
        ax.set_xlabel("Lateral X (m)")
        ax.set_ylabel("Depth Z (m)")
        ax.set_title(f"{name} (N={len(x):,})")
        ax.set_xlim(-60, 60)
        ax.set_ylim(0, 150)
        ax.grid(True, alpha=0.3)
        ax.set_aspect("equal")

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "07_bev_scatter.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_depth_vs_box_size(profiler: DatasetProfiler, output_dir: str):
    """Plot 8: Depth vs 2D box size (proxy for size-distance relationship)."""
    cls_ids = sorted([c for c in ALL_3D_CLASSES if profiler.depth.get(c) and profiler.bbox2d_area.get(c)])
    if not cls_ids:
        return

    fig, axes = plt.subplots(1, len(cls_ids), figsize=(6 * len(cls_ids), 5))
    if len(cls_ids) == 1:
        axes = [axes]
    fig.suptitle(f"Depth vs 2D Box Area — {profiler.split_name}", fontsize=14, fontweight="bold")

    for ci, cls_id in enumerate(cls_ids):
        ax = axes[ci]
        name = CLASS_NAMES.get(cls_id, f"cls{cls_id}")
        # Align arrays (both from same objects in order)
        d = np.array(profiler.depth[cls_id])
        a = np.array(profiler.bbox2d_area[cls_id])
        n = min(len(d), len(a))
        d, a = d[:n], a[:n]
        idx = np.random.choice(n, min(5000, n), replace=False)
        ax.scatter(d[idx], a[idx], s=1, alpha=0.3, c="coral")
        ax.set_xlabel("Depth (m)")
        ax.set_ylabel("2D Box Area (px²)")
        ax.set_title(name)
        ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "08_depth_vs_area.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_train_vs_val_comparison(train_summary: dict, val_summary: dict, output_dir: str):
    """Plot 9: Side-by-side comparison of train vs val distributions."""
    train_cls = train_summary.get("per_class", {})
    val_cls = val_summary.get("per_class", {})
    all_names = sorted(set(list(train_cls.keys()) + list(val_cls.keys())))
    if not all_names:
        return

    fig, axes = plt.subplots(2, 3, figsize=(18, 10))
    fig.suptitle("Train vs Val Distribution Comparison", fontsize=14, fontweight="bold")

    # A) Class proportion comparison
    ax = axes[0, 0]
    x = np.arange(len(all_names))
    w = 0.35
    train_props = [train_cls.get(n, {}).get("proportion", 0) * 100 for n in all_names]
    val_props = [val_cls.get(n, {}).get("proportion", 0) * 100 for n in all_names]
    ax.barh(x - w / 2, train_props, w, label="Train", color="steelblue")
    ax.barh(x + w / 2, val_props, w, label="Val", color="coral")
    ax.set_yticks(x)
    ax.set_yticklabels(all_names, fontsize=8)
    ax.set_xlabel("Proportion (%)")
    ax.set_title("Class Proportion")
    ax.legend()

    # B) Depth comparison (vehicle)
    ax = axes[0, 1]
    for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]:
        veh = s.get("vehicle", {})
        dr = veh.get("depth_ranges", {})
        if dr:
            labels = list(dr.keys())
            vals = [dr[k] for k in labels]
            total = max(sum(vals), 1)
            props = [v / total * 100 for v in vals]
            ax.plot(labels, props, "o-", label=split_name, color=color)
    ax.set_xlabel("Depth Range")
    ax.set_ylabel("% of vehicles")
    ax.set_title("Vehicle Depth Distribution")
    ax.legend()
    ax.tick_params(axis="x", rotation=30)

    # C) Depth comparison (pedestrian)
    ax = axes[0, 2]
    for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]:
        ped = s.get("pedestrian", {})
        dr = ped.get("depth_ranges", {})
        if dr:
            labels = list(dr.keys())
            vals = [dr[k] for k in labels]
            total = max(sum(vals), 1)
            props = [v / total * 100 for v in vals]
            ax.plot(labels, props, "o-", label=split_name, color=color)
    ax.set_xlabel("Depth Range")
    ax.set_ylabel("% of pedestrians")
    ax.set_title("Pedestrian Depth Distribution")
    ax.legend()
    ax.tick_params(axis="x", rotation=30)

    # D-F) Dimension comparison for key classes
    for idx, cls_name in enumerate(["vehicle", "pedestrian", "bicycle"]):
        ax = axes[1, idx]
        for split_name, s, color in [("Train", train_cls, "steelblue"), ("Val", val_cls, "coral")]:
            cls_info = s.get(cls_name, {})
            dims = cls_info.get("dimensions_m", {})
            if dims:
                dim_labels = ["Length", "Height", "Width"]
                means = [dims[d.lower()]["mean"] for d in dim_labels]
                stds = [dims[d.lower()]["std"] for d in dim_labels]
                x_pos = np.arange(len(dim_labels))
                offset = -0.15 if split_name == "Train" else 0.15
                ax.bar(x_pos + offset, means, 0.3, yerr=stds, label=split_name,
                       color=color, alpha=0.8, capsize=3)
        ax.set_xticks(np.arange(3))
        ax.set_xticklabels(["Length", "Height", "Width"])
        ax.set_ylabel("Meters")
        ax.set_title(f"{cls_name} Dimensions")
        ax.legend(fontsize=8)

    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, "09_train_vs_val.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_vehicle_subtype(profiler: 'DatasetProfiler', summary: dict, output_dir: str):
    """Plot 10: Large vehicle (大车) vs Small vehicle (小车) comparative analysis."""
    vs = summary.get("vehicle_subtype", {})
    lv_count = vs.get("large_vehicle", {}).get("count", 0)
    sv_count = vs.get("small_vehicle", {}).get("count", 0)
    if lv_count + sv_count == 0:
        return

    fig = plt.figure(figsize=(20, 14))
    gs = gridspec.GridSpec(3, 4, hspace=0.4, wspace=0.35)
    fig.suptitle(
        f"Vehicle Subtype Analysis (Large vs Small) — {profiler.split_name}\n"
        f"Threshold: length > {LARGE_VEHICLE_L_THRESH:.0f}m AND height > {LARGE_VEHICLE_H_THRESH:.0f}m",
        fontsize=13, fontweight="bold")

    subtypes = [(VEH_LARGE, "vehicle_large", "Large vehicle", "#e74c3c"),
                (VEH_SMALL, "vehicle_small", "Small vehicle", "#3498db")]

    # ── Row 0, Col 0: Count comparison pie ──
    ax = fig.add_subplot(gs[0, 0])
    pie_vals = [lv_count, sv_count]
    pie_labels = [f"Large\n{lv_count:,}", f"Small\n{sv_count:,}"]
    ax.pie(pie_vals, labels=pie_labels, autopct="%1.1f%%",
           colors=["#e74c3c", "#3498db"], startangle=90)
    ax.set_title("Count Distribution")

    # ── Row 0, Cols 1-3: Dimension histograms (L, H, W) ──
    dim_configs = [
        ("dim_l", "Length (m)", 1),
        ("dim_h", "Height (m)", 2),
        ("dim_w", "Width  (m)", 3),
    ]
    for attr, xlabel, col in dim_configs:
        ax = fig.add_subplot(gs[0, col])
        for sub_id, sub_name, label, color in subtypes:
            vals = getattr(profiler, attr).get(sub_id, [])
            if vals:
                ax.hist(np.array(vals), bins=50, color=color, alpha=0.6,
                        label=f"{label} (μ={np.mean(vals):.2f}m)", density=True)
        ax.set_xlabel(xlabel)
        ax.set_ylabel("Density")
        ax.set_title(f"{xlabel} Distribution")
        ax.legend(fontsize=7)
        # Draw threshold lines
        if attr == "dim_l":
            ax.axvline(LARGE_VEHICLE_L_THRESH, color="gray", linestyle="--", alpha=0.7,
                       label=f"threshold ({LARGE_VEHICLE_L_THRESH}m)")
        elif attr == "dim_h":
            ax.axvline(LARGE_VEHICLE_H_THRESH, color="gray", linestyle="--", alpha=0.7,
                       label=f"threshold ({LARGE_VEHICLE_H_THRESH}m)")

    # ── Row 1: Depth distributions ──
    for ci, (sub_id, sub_name, label, color) in enumerate(subtypes):
        ax = fig.add_subplot(gs[1, ci * 2: ci * 2 + 2])
        d = profiler.depth.get(sub_id, [])
        if d:
            d = np.array(d)
            ax.hist(np.clip(d, 0, 150), bins=80, color=color, edgecolor="none", alpha=0.8)
            ax.axvline(np.median(d), color="black", linestyle="--",
                       label=f"median={np.median(d):.1f}m")
            ax.axvline(np.mean(d), color="orange", linestyle="--",
                       label=f"mean={np.mean(d):.1f}m")
            ax.legend(fontsize=8)
        ax.set_xlabel("Depth (m)")
        ax.set_ylabel("Count")
        ax.set_title(f"{label} — Depth (N={len(d):,})")

    # ── Row 2, Cols 0-1: Depth range bar comparison ──
    ax = fig.add_subplot(gs[2, 0:2])
    depth_range_keys = ["0-10m", "10-20m", "20-30m", "30-50m", "50-80m", "80-120m", ">120m"]
    x_pos = np.arange(len(depth_range_keys))
    bar_w = 0.35
    for offset, (sub_id, sub_name, label, color) in enumerate(subtypes):
        info = summary["per_class"].get(sub_name, {})
        dr = info.get("depth_ranges", {})
        total = max(sum(dr.values()), 1) if dr else 1
        vals = [dr.get(k, 0) / total * 100 for k in depth_range_keys]
        ax.bar(x_pos + offset * bar_w, vals, bar_w, label=label, color=color, alpha=0.8)
    ax.set_xticks(x_pos + bar_w / 2)
    ax.set_xticklabels(depth_range_keys, rotation=20, fontsize=8)
    ax.set_ylabel("% within subtype")
    ax.set_title("Depth Range Distribution")
    ax.legend(fontsize=8)

    # ── Row 2, Cols 2-3: Bird's eye view scatter ──
    ax = fig.add_subplot(gs[2, 2:4])
    for sub_id, sub_name, label, color in subtypes:
        x = profiler.x3d.get(sub_id, [])
        z = profiler.depth.get(sub_id, [])
        if x and z:
            x, z = np.array(x), np.array(z)
            idx = np.random.choice(len(x), min(5000, len(x)), replace=False)
            ax.scatter(x[idx], z[idx], s=1, alpha=0.3, c=color, label=label)
    ax.set_xlabel("Lateral X (m)")
    ax.set_ylabel("Depth Z (m)")
    ax.set_title("Bird's Eye View (X vs Z)")
    ax.set_xlim(-60, 60)
    ax.set_ylim(0, 150)
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8, markerscale=6)

    plt.savefig(os.path.join(output_dir, "10_vehicle_subtype.png"), dpi=150, bbox_inches="tight")
    plt.close()


def plot_vehicle_date_distribution(profiler: 'DatasetProfiler', summary: dict, output_dir: str):
    """Plot 11: Vehicle frame counts + per-vehicle date frame counts."""
    dist = summary.get("vehicle_date_distribution", {})
    if not dist:
        return

    vehicle_counts = list(dist.get("vehicle", {}).get("image_counts", {}).items())
    dates_per_vehicle = dist.get("dates_per_vehicle", {})
    subtype_per_vehicle = dist.get("vehicle_subtype_per_vehicle", {})
    if not vehicle_counts:
        return

    vehicle_names = [k for k, _ in vehicle_counts]
    n_vehicles = len(vehicle_names)
    ncols = 2 if n_vehicles > 1 else 1
    n_vehicle_rows = max(1, math.ceil(n_vehicles / ncols))
    n_pie_rows = max(1, math.ceil(n_vehicles / ncols))
    fig_h = 6.0 + 3.8 * n_vehicle_rows + 3.6 * n_pie_rows
    fig = plt.figure(figsize=(8 * ncols, fig_h))
    gs = gridspec.GridSpec(2 + n_pie_rows + n_vehicle_rows, ncols, hspace=0.60, wspace=0.30)
    missing_n = dist.get("missing_vehicle_date_images", 0)
    fig.suptitle(
        f"Vehicle / Date Distribution — {profiler.split_name}\n"
        f"missing vehicle/date paths: {missing_n:,}",
        fontsize=14,
        fontweight="bold",
    )

    def _plot_bar(ax, items, title, ylabel, color, rotate=30):
        if not items:
            ax.set_title(f"{title} (no data)")
            ax.axis("off")
            return
        labels = [k for k, _ in items]
        values = [v for _, v in items]
        xpos = np.arange(len(items))
        ax.bar(xpos, values, color=color, alpha=0.88)
        ax.set_xticks(xpos)
        ax.set_xticklabels(labels, rotation=rotate, ha="right", fontsize=8)
        ax.set_ylabel(ylabel)
        ax.set_title(title)
        ax.grid(axis="y", alpha=0.25)
        y_pad = max(values) * 0.01 if max(values) > 0 else 0.1
        for x, val in enumerate(values):
            ax.text(x, val + y_pad, f"{val:,}", ha="center", va="bottom", fontsize=8)

    ax_frames = fig.add_subplot(gs[0, :])
    _plot_bar(
        ax_frames,
        vehicle_counts,
        "Image Frames per Vehicle",
        "Image Frames",
        "#4c78a8",
        rotate=20 if len(vehicle_counts) <= 8 else 35,
    )

    for idx, vehicle_id in enumerate(vehicle_names):
        row = 1 + idx // ncols
        col = idx % ncols
        ax_pie = fig.add_subplot(gs[row, col])
        subtype_info = subtype_per_vehicle.get(vehicle_id, {})
        lv = subtype_info.get("large_vehicle", {}).get("count", 0)
        sv = subtype_info.get("small_vehicle", {}).get("count", 0)
        uv = subtype_info.get("unclassified_vehicle_objects", 0)
        pie_vals = [v for v in [lv, sv, uv] if v > 0]
        pie_labels = []
        pie_colors = []
        if lv > 0:
            pie_labels.append(f"Large\n{lv:,}")
            pie_colors.append("#e74c3c")
        if sv > 0:
            pie_labels.append(f"Small\n{sv:,}")
            pie_colors.append("#3498db")
        if uv > 0:
            pie_labels.append(f"Unclassified\n{uv:,}")
            pie_colors.append("#b0b7c3")
        if pie_vals:
            ax_pie.pie(
                pie_vals,
                labels=pie_labels,
                autopct="%1.1f%%",
                startangle=90,
                colors=pie_colors,
                textprops={"fontsize": 8},
            )
        else:
            ax_pie.text(0.5, 0.5, "no subtype data", ha="center", va="center", fontsize=9)
        ax_pie.set_title(f"{vehicle_id} — Large/Small Vehicle Mix", fontsize=10)

    palette = plt.cm.tab20(np.linspace(0, 1, max(n_vehicles, 2)))
    for idx, vehicle_id in enumerate(vehicle_names):
        row = 1 + n_pie_rows + idx // ncols
        col = idx % ncols
        ax = fig.add_subplot(gs[row, col])
        date_items = list(dates_per_vehicle.get(vehicle_id, {}).get("image_counts", {}).items())
        _plot_bar(
            ax,
            date_items,
            f"{vehicle_id} — Image Frames by Date",
            "Image Frames",
            palette[idx % len(palette)],
            rotate=35 if len(date_items) <= 10 else 45,
        )
        subtype_info = subtype_per_vehicle.get(vehicle_id, {})
        lv = subtype_info.get("large_vehicle", {}).get("count", 0)
        sv = subtype_info.get("small_vehicle", {}).get("count", 0)
        uv = subtype_info.get("unclassified_vehicle_objects", 0)
        ax.text(
            0.98, 0.96,
            f"large={lv:,}  small={sv:,}  unclassified={uv:,}",
            transform=ax.transAxes,
            ha="right",
            va="top",
            fontsize=8,
            bbox=dict(boxstyle="round", facecolor="white", alpha=0.85),
        )

    plt.savefig(os.path.join(output_dir, "11_vehicle_date_distribution.png"), dpi=150, bbox_inches="tight")
    plt.close()


# ────────────────── Report Generation ───────────────────

def generate_text_report(summary: dict, output_path: str):
    """Generate a human-readable text report."""
    lines = []
    lines.append("=" * 90)
    lines.append(f"  DATASET PROFILING REPORT — {summary['overview']['split']}")
    lines.append("=" * 90)

    # 1. Overview
    ov = summary["overview"]
    lines.append(f"\n{'─' * 40} Overview {'─' * 40}")
    lines.append(f"  Analysis mode:         {ov.get('analysis_mode', 'original'):>10}")
    lines.append(f"  Total images:          {ov['total_images']:>10,}")
    lines.append(f"  Labels found:          {ov['labels_found']:>10,}")
    lines.append(f"  Labels missing:        {ov['labels_missing']:>10,}")
    lines.append(f"  Empty labels:          {ov['empty_labels']:>10,}")
    lines.append(f"  Total objects:         {ov['total_objects']:>10,}")
    lines.append(f"  Format distribution:   {ov['format_distribution']}")

    # ROI info (if present)
    roi_info = ov.get("roi_info")
    if roi_info:
        lines.append(f"\n{'─' * 40} ROI Info {'─' * 40}")
        lines.append(f"  Original image size:   {roi_info['ori_img_size'][0]}×{roi_info['ori_img_size'][1]}")
        lines.append(f"  ROI size (config):     {roi_info['roi_size'][0]}×{roi_info['roi_size'][1]}")
        lines.append(f"  Pixel stats size:      {roi_info['img_size_used'][0]}×{roi_info['img_size_used'][1]}")
        lines.append(f"  Calib found:           {roi_info['calib_found']:>10,}")
        lines.append(f"  Calib missing:         {roi_info['calib_missing']:>10,}")
        lines.append(f"  Objects filtered (outside ROI): {roi_info['objects_filtered_outside_roi']:>8,}")

    # 2. Per-image density
    pid = summary["per_image_density"]
    lines.append(f"\n{'─' * 38} Per-Image Density {'─' * 33}")
    for k, v in pid.items():
        lines.append(f"  {k}: mean={v['mean']:.1f} median={v['median']:.1f} "
                      f"p5={v['p5']:.0f} p95={v['p95']:.0f} max={v['max']:.0f}")

    # 2.5 Vehicle / date distribution
    vdd = summary.get("vehicle_date_distribution")
    if vdd:
        veh = vdd.get("vehicle", {})
        dates_per_vehicle = vdd.get("dates_per_vehicle", {})
        subtype_per_vehicle = vdd.get("vehicle_subtype_per_vehicle", {})
        lines.append(f"\n{'─' * 34} Vehicle / Date Distribution {'─' * 25}")
        lines.append(f"  Unique vehicles:        {veh.get('unique_count', 0):>10,}")
        lines.append(f"  Missing vehicle/date:   {vdd.get('missing_vehicle_date_images', 0):>10,} images")
        lines.append("  Image frames per vehicle:")
        for vehicle_id, image_count in veh.get("image_counts", {}).items():
            unique_dates = dates_per_vehicle.get(vehicle_id, {}).get("unique_dates", 0)
            subtype_info = subtype_per_vehicle.get(vehicle_id, {})
            lv = subtype_info.get("large_vehicle", {}).get("count", 0)
            sv = subtype_info.get("small_vehicle", {}).get("count", 0)
            uv = subtype_info.get("unclassified_vehicle_objects", 0)
            lines.append(
                f"    {vehicle_id}: images={image_count:,}, dates={unique_dates}, "
                f"large={lv:,}, small={sv:,}, unclassified={uv:,}"
            )

        if dates_per_vehicle:
            lines.append("  Image frames per date within each vehicle:")
            for vehicle_id, info in dates_per_vehicle.items():
                lines.append(f"    {vehicle_id}:")
                image_counts = info.get("image_counts", {})
                if not image_counts:
                    lines.append("      (no valid date extracted)")
                    continue
                for date_str, image_count in image_counts.items():
                    lines.append(f"      {date_str}: {image_count:,}")

    # 3. Per-class
    lines.append(f"\n{'─' * 38} Per-Class Statistics {'─' * 31}")
    for name, info in summary["per_class"].items():
        lines.append(f"\n  ▶ {name} (N={info['count']:,}, proportion={info['proportion']:.2%})")

        if "bbox2d" in info:
            bb = info["bbox2d"]
            lines.append(f"    2D box:   W={bb['width_px']['mean']:.1f}±{bb['width_px']['std']:.1f}px "
                          f"H={bb['height_px']['mean']:.1f}±{bb['height_px']['std']:.1f}px "
                          f"AR={bb['aspect_ratio']['mean']:.2f}±{bb['aspect_ratio']['std']:.2f}")
        if "depth_m" in info:
            dp = info["depth_m"]
            lines.append(f"    Depth:    mean={dp['mean']:.1f}m median={dp['median']:.1f}m "
                          f"std={dp['std']:.1f}m range=[{dp['min']:.1f}, {dp['max']:.1f}]m")
        if "depth_ranges" in info:
            dr = info["depth_ranges"]
            lines.append(f"    Depth ranges: {dr}")
        if "dimensions_m" in info:
            dm = info["dimensions_m"]
            lines.append(f"    3D dims:  L={dm['length']['mean']:.2f}±{dm['length']['std']:.2f}m "
                          f"H={dm['height']['mean']:.2f}±{dm['height']['std']:.2f}m "
                          f"W={dm['width']['mean']:.2f}±{dm['width']['std']:.2f}m")
        if "rot_y_rad" in info:
            ry = info["rot_y_rad"]
            lines.append(f"    Rot_y:    mean={ry['mean']:.2f} std={ry['std']:.2f} "
                          f"range=[{ry['min']:.2f}, {ry['max']:.2f}] rad")
        if "lateral_x3d_m" in info:
            x = info["lateral_x3d_m"]
            lines.append(f"    Lateral:  mean={x['mean']:.1f}m std={x['std']:.1f}m "
                          f"range=[{x['min']:.1f}, {x['max']:.1f}]m")

    # 4. Vehicle subtype (大车 vs 小车)
    vs = summary.get("vehicle_subtype")
    if vs:
        thresh = vs["threshold"]
        lv = vs["large_vehicle"]
        sv = vs["small_vehicle"]
        total_veh   = vs["total_vehicles"]
        classifiable = vs["classifiable_vehicles"]
        unclassified = vs["unclassified_vehicles"]
        lines.append(f"\n{'─' * 34} Vehicle Subtype (大车 vs 小车) {'─' * 23}")
        lines.append(f"  Threshold:    length > {thresh['length_gt_m']:.1f}m  AND  "
                     f"height > {thresh['height_gt_m']:.1f}m → large vehicle (大车)")
        lines.append(f"  Total vehicles:          {total_veh:>10,}")
        lines.append(f"  3D-classifiable (50-col):{classifiable:>10,}  ({classifiable/max(total_veh,1):.1%} of vehicles)")
        lines.append(f"  2D-only (unclassified):  {unclassified:>10,}  ({unclassified/max(total_veh,1):.1%} of vehicles)")
        lines.append(f"  大车 (large):  {lv['count']:>10,}  ({lv['proportion_of_classifiable']:.1%} of 3D-classifiable)")
        lines.append(f"  小车 (small):  {sv['count']:>10,}  ({sv['proportion_of_classifiable']:.1%} of 3D-classifiable)")
        # Detailed stats from per_class
        for sub_name in ["vehicle_large", "vehicle_small"]:
            info = summary["per_class"].get(sub_name)
            if info is None:
                continue
            label = "大车" if sub_name == "vehicle_large" else "小车"
            lines.append(f"\n  ▶ {sub_name} [{label}] (N={info['count']:,}, "
                         f"{info.get('proportion_of_vehicle', 0):.1%} of 3D-classifiable)")
            if "bbox2d" in info:
                bb = info["bbox2d"]
                lines.append(f"    2D box:   W={bb['width_px']['mean']:.1f}±{bb['width_px']['std']:.1f}px "
                              f"H={bb['height_px']['mean']:.1f}±{bb['height_px']['std']:.1f}px "
                              f"AR={bb['aspect_ratio']['mean']:.2f}±{bb['aspect_ratio']['std']:.2f}")
            if "depth_m" in info:
                dp = info["depth_m"]
                lines.append(f"    Depth:    mean={dp['mean']:.1f}m median={dp['median']:.1f}m "
                              f"std={dp['std']:.1f}m range=[{dp['min']:.1f}, {dp['max']:.1f}]m")
            if "depth_ranges" in info:
                lines.append(f"    Depth ranges: {info['depth_ranges']}")
            if "dimensions_m" in info:
                dm = info["dimensions_m"]
                lines.append(f"    3D dims:  L={dm['length']['mean']:.2f}±{dm['length']['std']:.2f}m "
                              f"H={dm['height']['mean']:.2f}±{dm['height']['std']:.2f}m "
                              f"W={dm['width']['mean']:.2f}±{dm['width']['std']:.2f}m")

    # 5. Face visibility
    if summary.get("face_visibility"):
        lines.append(f"\n{'─' * 37} Face Visibility {'─' * 36}")
        for name, finfo in summary["face_visibility"].items():
            lines.append(f"\n  ▶ {name} (N={finfo['total_objects']:,})")
            rates = finfo["face_visibility_rate"]
            lines.append(f"    Visibility: front={rates['front']:.1%} rear={rates['rear']:.1%} "
                          f"left={rates['left']:.1%} right={rates['right']:.1%}")

    # 5. Cut type
    if summary.get("cut_type"):
        lines.append(f"\n{'─' * 38} Cut Type {'─' * 41}")
        for k, v in summary["cut_type"].items():
            lines.append(f"  {k}: {v:,}")

    # 6. Data quality
    dq = summary["data_quality"]
    lines.append(f"\n{'─' * 38} Data Quality {'─' * 38}")
    lines.append(f"  NaN 3D objects:        {dq['nan_3d_objects']:>8,}")
    lines.append(f"  Invalid depth (≤0):    {dq['invalid_depth_le0']:>8,}")
    lines.append(f"  Outlier depth (>200m): {dq['outlier_depth_gt200m']:>8,}")
    lines.append(f"  Tiny box (<8px):       {dq['tiny_box_lt8px']:>8,}")
    lines.append("")

    report_text = "\n".join(lines)
    with open(output_path, "w") as f:
        f.write(report_text)
    print(report_text)


# ──────────────────── Main ──────────────────────────────

def run_profiling(data_cfg: dict, split: str, max_files: int,
                  img_size: tuple, output_dir: str,
                  analysis_mode: str = "original",
                  ori_img_size: tuple = (1920, 1080),
                  roi_size: tuple = (1920, 960),
                  num_workers: int = 1) -> dict:
    """Run profiling for a single split."""
    img_w, img_h = img_size
    paths = resolve_image_paths(data_cfg, split)
    if max_files > 0:
        paths = paths[:max_files]

    mode_str = f"mode={analysis_mode}"
    if analysis_mode == "roi":
        mode_str += f" ori={ori_img_size[0]}×{ori_img_size[1]} roi={roi_size[0]}×{roi_size[1]}"
    print(f"\n{'=' * 70}")
    print(f"  Profiling [{split}] — {len(paths):,} images, img_size={img_w}×{img_h}, {mode_str}")
    print(f"{'=' * 70}")

    # Clear global ROI cache for a fresh run
    global _roi_cache
    _roi_cache = {}

    profiler = DatasetProfiler(split, img_w, img_h,
                               analysis_mode=analysis_mode,
                               ori_img_size=ori_img_size,
                               roi_size=roi_size)

    if num_workers > 1 and len(paths) > 100:
        # ── Parallel processing with multiprocessing ──
        chunk_size = min(max(len(paths) // (num_workers * 4), 500), 50000)
        chunks = [paths[i:i + chunk_size] for i in range(0, len(paths), chunk_size)]
        args_list = [
            (chunk, split, img_w, img_h, analysis_mode, ori_img_size, roi_size)
            for chunk in chunks
        ]
        print(f"  Using {num_workers} workers, {len(chunks)} chunks "
              f"(~{chunk_size:,} images/chunk)")

        with Pool(num_workers) as pool:
            for ci, partial_profiler in enumerate(pool.imap_unordered(_process_chunk, args_list)):
                profiler.merge(partial_profiler)
                done_approx = min((ci + 1) * chunk_size, len(paths))
                pct = min(done_approx / len(paths) * 100, 100)
                print(f"  [{split}] Progress: ~{done_approx:>8,}/{len(paths):,} ({pct:.0f}%)"
                      f"  objects so far: {profiler.total_objects:,}")
    else:
        # ── Sequential processing ──
        report_interval = max(len(paths) // 20, 1)
        for i, p in enumerate(paths):
            profiler.process_image(p)
            if (i + 1) % report_interval == 0:
                pct = (i + 1) / len(paths) * 100
                print(f"  [{split}] Progress: {i + 1:>8,}/{len(paths):,} ({pct:.0f}%)"
                      f"  objects so far: {profiler.total_objects:,}")

    summary = profiler.summarize()

    # Generate outputs
    split_dir = os.path.join(output_dir, split)
    os.makedirs(split_dir, exist_ok=True)

    # Text report
    generate_text_report(summary, os.path.join(split_dir, "report.txt"))

    # JSON summary
    with open(os.path.join(split_dir, "summary.json"), "w") as f:
        json.dump(summary, f, indent=2, default=str)

    # Plots
    plot_overview(summary, split_dir)
    plot_2d_bbox_analysis(profiler, split_dir)
    plot_3d_depth_analysis(profiler, split_dir)
    plot_3d_dimensions(profiler, split_dir)
    plot_rotation_analysis(profiler, split_dir)
    plot_yaw_bin_analysis(profiler, split_dir)
    plot_face_visibility(profiler, summary, split_dir)
    plot_lateral_depth_scatter(profiler, split_dir)
    plot_depth_vs_box_size(profiler, split_dir)
    plot_vehicle_subtype(profiler, summary, split_dir)
    plot_vehicle_date_distribution(profiler, summary, split_dir)

    return summary, profiler


def main():
    parser = argparse.ArgumentParser(description="Dataset Profiling for YOLOv5-3D")
    parser.add_argument("--data", type=str, default="data/mono3d.yaml", help="Path to data YAML config")
    parser.add_argument("--split", type=str, default="both", choices=["train", "val", "both"],
                        help="Which split(s) to profile")
    parser.add_argument("--max-files", type=int, default=0, help="Max files per split (0=all)")
    parser.add_argument("--output-dir", type=str, default="dataset_profiling_results",
                        help="Output directory for reports and plots")
    parser.add_argument("--img-size", type=int, nargs=2, default=None,
                        help="Image size [width height] for pixel-unit conversion. "
                             "In 'original' mode defaults to ori_img_size from YAML (1920 1080); "
                             "in 'roi' mode defaults to roi size from YAML (1920 960).")
    parser.add_argument("--analysis-mode", type=str, default="original",
                        choices=["original", "roi"],
                        help="Analysis mode: 'original' analyzes labels against the full original "
                             "image; 'roi' computes per-camera ROI from calibration, filters objects "
                             "outside ROI, clips boundary objects, and re-normalizes to ROI space.")
    parser.add_argument("--workers", type=int, default=1,
                        help="Number of parallel workers (default: 1 = sequential). "
                             "Use --workers 8 or --workers 0 (auto-detect CPU count) "
                             "for faster processing on large datasets.")
    args = parser.parse_args()

    # Load data config
    with open(args.data, "r") as f:
        data_cfg = yaml.safe_load(f)

    # Read ROI config from YAML (with sensible defaults)
    ori_img_size = tuple(data_cfg.get("ori_img_size", [1920, 1080]))
    roi_size = tuple(data_cfg.get("roi", [1920, 960]))

    # Determine img_size for pixel conversion
    if args.img_size is not None:
        img_size = tuple(args.img_size)
    elif args.analysis_mode == "roi":
        img_size = roi_size  # In ROI mode, stats use ROI dimensions
    else:
        img_size = ori_img_size  # In original mode, stats use original image dimensions

    # Append analysis mode to output dir to avoid overwriting
    output_dir = args.output_dir
    if not output_dir.endswith(f"_{args.analysis_mode}"):
        output_dir = f"{output_dir}_{args.analysis_mode}"

    os.makedirs(output_dir, exist_ok=True)

    print(f"  Analysis mode:    {args.analysis_mode}")
    print(f"  Original img:     {ori_img_size[0]}×{ori_img_size[1]}")
    if args.analysis_mode == "roi":
        print(f"  ROI size (cfg):   {roi_size[0]}×{roi_size[1]}")
    print(f"  Pixel conversion: {img_size[0]}×{img_size[1]}")
    print(f"  Output dir:       {output_dir}")

    # Determine number of workers
    num_workers = args.workers
    if num_workers <= 0:
        num_workers = cpu_count() or 1
    if num_workers > 1:
        print(f"  Workers:          {num_workers}")

    summaries = {}
    profilers = {}

    splits = ["train", "val"] if args.split == "both" else [args.split]

    for split in splits:
        if not data_cfg.get(split):
            print(f"Skipping [{split}] — not defined in {args.data}")
            continue
        s, p = run_profiling(data_cfg, split, args.max_files, img_size, output_dir,
                             analysis_mode=args.analysis_mode,
                             ori_img_size=ori_img_size,
                             roi_size=roi_size,
                             num_workers=num_workers)
        summaries[split] = s
        profilers[split] = p

    # Comparison plot
    if "train" in summaries and "val" in summaries:
        plot_train_vs_val_comparison(summaries["train"], summaries["val"], output_dir)
        print(f"\n  ✓ Train vs Val comparison → {output_dir}/09_train_vs_val.png")

    print(f"\n{'=' * 70}")
    print(f"  All outputs saved to: {output_dir}/")
    print(f"{'=' * 70}")


if __name__ == "__main__":
    main()
