#!/usr/bin/env python3
"""
Calibration Parameter Profiling for YOLOv5-3D Training Dataset.

Scans all unique calibration files referenced by a dataset (via data YAML),
computes descriptive statistics, and produces visualisation plots for:

  1. Focal lengths (focal_u / focal_v)
  2. Principal point (cu / cv)
  3. Camera angles (pitch, yaw, roll)
  4. Fisheye distortion coefficients (k1–k4)
  5. Field of view (fov)
  6. Camera position (pos_x, pos_y, pos_z)
  7. Vanishing point (vanish_x, vanish_y) — derived from pitch/yaw/calibration
  8. ROI crop bounds derived from vanishing point (when roi config is supplied)

Usage:
    python calib_profiling.py [--data data/mono3d.yaml] [--split train]
                              [--max-files 0] [--output-dir calib_profiling_results]
                              [--workers 0]

    --max-files 0   means process all image files (unique calib dirs are deduplicated).
    --workers 0     auto-detect CPU count; use 1 for sequential processing.
"""

import argparse
import json
import math
import os
import sys
from collections import 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


# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────

def _dist_stats(arr: np.ndarray) -> dict:
    """Return compact descriptive statistics for a 1-D numeric array."""
    if len(arr) == 0:
        return {"count": 0}
    return {
        "count": int(len(arr)),
        "min": float(np.min(arr)),
        "p5":  float(np.percentile(arr, 5)),
        "p25": float(np.percentile(arr, 25)),
        "median": float(np.median(arr)),
        "mean": float(np.mean(arr)),
        "p75": float(np.percentile(arr, 75)),
        "p95": float(np.percentile(arr, 95)),
        "max": float(np.max(arr)),
        "std": float(np.std(arr)),
    }


def _fmt(d: dict) -> str:
    """Format a _dist_stats dict to a short human-readable string."""
    if d.get("count", 0) == 0:
        return "N/A"
    return (
        f"n={d['count']:,}  mean={d['mean']:.3f}  std={d['std']:.3f}  "
        f"[{d['min']:.3f}, {d['p5']:.3f}, {d['median']:.3f}, {d['p95']:.3f}, {d['max']:.3f}]"
    )


# ──────────────────────────────────────────────────────────────────────────────
# Data loading helpers  (same convention as dataset_profiling.py)
# ──────────────────────────────────────────────────────────────────────────────

def resolve_image_paths(data_cfg: dict, split: str) -> list:
    """Resolve image paths from *data_cfg* for the given split.

    Text-file entries are resolved relative to the txt file's parent directory,
    matching the training dataloader convention.
    """
    raw = data_cfg.get(split, [])
    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():
            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:
            all_paths.append(entry)
    return all_paths


def calib_path_for_image(img_path: str) -> str:
    """Derive the camera4.json calibration path from an image path.

    Convention:  <base_dir>.replace('images', 'calib') / L2_calib / camera4.json
    """
    base = os.path.dirname(img_path)
    return base.replace("images", "calib") + "/L2_calib/camera4.json"


def unique_calib_paths(img_paths: list) -> list:
    """Return deduplicated calibration file paths, preserving first-seen order."""
    seen = set()
    result = []
    for p in img_paths:
        cp = calib_path_for_image(p)
        if cp not in seen:
            seen.add(cp)
            result.append(cp)
    return result


def find_calib_paths_in_dir(root_dir: str) -> list:
    """Recursively find all camera4.json calibration files under *root_dir*.

    Used for test-case directories that do not have associated image lists
    (e.g. cases_coding / cases_feishu). Preserves insertion order and
    deduplicates by absolute path.
    """
    seen = set()
    result = []
    for dirpath, _dirs, filenames in os.walk(root_dir):
        if "camera4.json" in filenames:
            p = os.path.normpath(os.path.join(dirpath, "camera4.json"))
            if p not in seen:
                seen.add(p)
                result.append(p)
    return result


# ──────────────────────────────────────────────────────────────────────────────
# Vanishing-point computation  (mirrors dataloaders3d.py __getitem__)
# ──────────────────────────────────────────────────────────────────────────────

def compute_vanishing_point(calib: dict):
    """Return (vanish_x, vanish_y) in original image pixel coordinates.

    Formula (from dataloaders3d.py):
        vanish_x = cx + fx * tan(yaw   * π/180)
        vanish_y = cy - fy * tan(pitch * π/180)
    """
    fx = calib["focal_u"]
    fy = calib["focal_v"]
    cx = calib["cu"]
    cy = calib["cv"]
    yaw   = calib.get("yaw",   0.0)
    pitch = calib.get("pitch", 0.0)
    vanish_x = cx + fx * math.tan(yaw   * math.pi / 180.0)
    vanish_y = cy - fy * math.tan(pitch * math.pi / 180.0)
    return vanish_x, vanish_y


def compute_roi_bounds(calib: dict, ori_img_size: tuple, roi_size: tuple,
                       roi_bottom_offset: int = 0):
    """Compute ROI crop bounds from calibration + config (mirrors dataloaders3d.py).

    Returns (roi_x1, roi_y1, roi_x2, roi_y2).
    """
    oriW, oriH = ori_img_size
    roi_w, roi_h = roi_size

    _, vanish_y = compute_vanishing_point(calib)
    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 - roi_bottom_offset

    return roi_x1, roi_y1, roi_x2, roi_y2


# ──────────────────────────────────────────────────────────────────────────────
# Per-file worker (for multiprocessing)
# ──────────────────────────────────────────────────────────────────────────────

def _load_calib(path: str) -> dict | None:
    """Load a single calibration JSON; return None on any error."""
    if not os.path.exists(path):
        return None
    try:
        with open(path, "r") as f:
            return json.load(f)
    except Exception:
        return None


def _process_calib_path(args):
    """Worker: load one calib file and return a flat record dict (or None)."""
    calib_path, ori_img_size, roi_size, roi_bottom_offset = args
    calib = _load_calib(calib_path)
    if calib is None:
        return None

    record = {
        "path": calib_path,
        "focal_u": calib.get("focal_u"),
        "focal_v": calib.get("focal_v"),
        "cu":      calib.get("cu"),
        "cv":      calib.get("cv"),
        "pitch":   calib.get("pitch"),
        "yaw":     calib.get("yaw", 0.0),
        "roll":    calib.get("roll"),
        "fov":     calib.get("fov"),
        "pos_x":   calib.get("pos", [None, None, None])[0] if calib.get("pos") else None,
        "pos_y":   calib.get("pos", [None, None, None])[1] if calib.get("pos") else None,
        "pos_z":   calib.get("pos", [None, None, None])[2] if calib.get("pos") else None,
        "distort_coeffs": calib.get("distort_coeffs", []),
        "is_valid": calib.get("is_valid", None),
        "reprojection_error": calib.get("reprojection_error", None),
    }

    # Vanishing point
    try:
        vx, vy = compute_vanishing_point(calib)
        record["vanish_x"] = vx
        record["vanish_y"] = vy
    except Exception:
        record["vanish_x"] = None
        record["vanish_y"] = None

    # ROI bounds (if config supplied)
    if roi_size is not None and ori_img_size is not None:
        try:
            rx1, ry1, rx2, ry2 = compute_roi_bounds(
                calib, ori_img_size, roi_size, roi_bottom_offset)
            record["roi_x1"] = rx1
            record["roi_y1"] = ry1
            record["roi_x2"] = rx2
            record["roi_y2"] = ry2
        except Exception:
            pass

    return record


# ──────────────────────────────────────────────────────────────────────────────
# Plotting helpers
# ──────────────────────────────────────────────────────────────────────────────

def _hist(ax, data, title, xlabel, color="steelblue", bins=50, vlines=None):
    """Draw a clean histogram with median and mean lines."""
    if len(data) == 0:
        ax.set_title(f"{title}\n(no data)")
        return
    ax.hist(data, bins=bins, color=color, edgecolor="none", alpha=0.8)
    med = float(np.median(data))
    mn  = float(np.mean(data))
    ax.axvline(med, color="red",    linestyle="--", linewidth=1.2, label=f"median={med:.2f}")
    ax.axvline(mn,  color="orange", linestyle=":",  linewidth=1.2, label=f"mean={mn:.2f}")
    if vlines:
        for v, lbl, lc in vlines:
            ax.axvline(v, color=lc, linestyle="-.", linewidth=1.0, label=lbl)
    ax.set_title(f"{title}  (n={len(data):,})", fontsize=9)
    ax.set_xlabel(xlabel, fontsize=8)
    ax.set_ylabel("count", fontsize=8)
    ax.legend(fontsize=7)
    ax.tick_params(labelsize=7)


def _scatter(ax, x, y, title, xlabel, ylabel, color="steelblue", alpha=0.3, s=4):
    """Draw a scatter plot."""
    if len(x) == 0:
        ax.set_title(title + "\n(no data)")
        return
    x = np.array(x)
    y = np.array(y)
    ax.scatter(x, y, c=color, s=s, alpha=alpha, edgecolors="none")
    ax.set_title(title, fontsize=9)
    ax.set_xlabel(xlabel, fontsize=8)
    ax.set_ylabel(ylabel, fontsize=8)
    ax.tick_params(labelsize=7)


# ──────────────────────────────────────────────────────────────────────────────
# Main profiling class
# ──────────────────────────────────────────────────────────────────────────────

class CalibProfiler:
    """Collect and visualise calibration statistics across all unique sequences."""

    def __init__(self, ori_img_size, roi_size, roi_bottom_offset=0):
        self.ori_img_size    = ori_img_size     # (W, H)
        self.roi_size        = roi_size          # (roi_w, roi_h) or None
        self.roi_bottom_offset = roi_bottom_offset

        # raw records — one dict per unique calib file
        self.records: list[dict] = []
        self.n_missing = 0
        self.n_invalid = 0  # is_valid == False

    # ── Data ingestion ────────────────────────────────────────────────────────

    def process_batch(self, calib_paths: list, workers: int = 1):
        """Load all calibration files (parallel or sequential) and accumulate records."""
        args = [
            (cp, self.ori_img_size, self.roi_size, self.roi_bottom_offset)
            for cp in calib_paths
        ]

        if workers == 1:
            results = [_process_calib_path(a) for a in args]
        else:
            with Pool(processes=workers) as pool:
                results = pool.map(_process_calib_path, args)

        for rec in results:
            if rec is None:
                self.n_missing += 1
            else:
                if rec.get("is_valid") is False:
                    self.n_invalid += 1
                self.records.append(rec)

    # ── Field extraction helpers ──────────────────────────────────────────────

    def _field(self, key: str) -> np.ndarray:
        """Extract a numeric field across all records, dropping None/NaN."""
        vals = [r[key] for r in self.records if r.get(key) is not None]
        arr = np.array(vals, dtype=float)
        return arr[~np.isnan(arr)]

    def _distort_k(self, idx: int) -> np.ndarray:
        """Extract distortion coefficient k_{idx} (0-based)."""
        vals = []
        for r in self.records:
            dc = r.get("distort_coeffs", [])
            if dc and len(dc) > idx:
                vals.append(dc[idx])
        return np.array(vals, dtype=float)

    # ── Summary statistics ────────────────────────────────────────────────────

    def summarize(self) -> dict:
        n = len(self.records)
        s = {
            "n_unique_calib_files":   n,
            "n_missing_calib_files":  self.n_missing,
            "n_invalid_calib_files":  self.n_invalid,
        }

        for key in ("focal_u", "focal_v", "cu", "cv",
                    "pitch", "yaw", "roll", "fov",
                    "pos_x", "pos_y", "pos_z",
                    "vanish_x", "vanish_y",
                    "roi_x1", "roi_y1", "roi_x2", "roi_y2",
                    "reprojection_error"):
            arr = self._field(key)
            if len(arr):
                s[key] = _dist_stats(arr)

        for idx, name in enumerate(["k1", "k2", "k3", "k4"]):
            arr = self._distort_k(idx)
            if len(arr):
                s[f"distort_{name}"] = _dist_stats(arr)

        return s

    # ── Plotting ──────────────────────────────────────────────────────────────

    def plot_intrinsics(self, output_path: str):
        """Figure 1: Focal lengths & principal point distributions."""
        fig, axes = plt.subplots(2, 2, figsize=(12, 8))
        fig.suptitle("Camera Intrinsics Distribution", fontsize=12, fontweight="bold")

        pairs = [
            (axes[0, 0], "focal_u", "focal_u (pixel)", "royalblue"),
            (axes[0, 1], "focal_v", "focal_v (pixel)", "steelblue"),
            (axes[1, 0], "cu",      "cu — principal pt X (pixel)", "darkorange"),
            (axes[1, 1], "cv",      "cv — principal pt Y (pixel)", "chocolate"),
        ]
        for ax, key, xlabel, color in pairs:
            _hist(ax, self._field(key), title=key, xlabel=xlabel, color=color)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    def plot_angles(self, output_path: str):
        """Figure 2: Camera installation angles (pitch, yaw, roll)."""
        fig, axes = plt.subplots(1, 3, figsize=(15, 4))
        fig.suptitle("Camera Installation Angles (degrees)", fontsize=12, fontweight="bold")

        combos = [
            (axes[0], "pitch", "pitch (°)", "mediumseagreen"),
            (axes[1], "yaw",   "yaw (°)",   "slateblue"),
            (axes[2], "roll",  "roll (°)",  "tomato"),
        ]
        for ax, key, xlabel, color in combos:
            _hist(ax, self._field(key), title=key, xlabel=xlabel, color=color, bins=40)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    def plot_distortion(self, output_path: str):
        """Figure 3: Fisheye distortion coefficient distributions."""
        fig, axes = plt.subplots(2, 2, figsize=(12, 8))
        fig.suptitle("Fisheye Distortion Coefficients (k1–k4)", fontsize=12, fontweight="bold")

        colors = ["indianred", "sandybrown", "mediumaquamarine", "cornflowerblue"]
        for i, (ax, name, color) in enumerate(
                zip(axes.flat, ["k1", "k2", "k3", "k4"], colors)):
            _hist(ax, self._distort_k(i),
                  title=f"distort_coeffs[{i}] = {name}",
                  xlabel=name, color=color)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    def plot_vanishing_point(self, output_path: str, ori_img_size: tuple):
        """Figure 4: Vanishing-point distribution — histograms + 2-D scatter."""
        vx = self._field("vanish_x")
        vy = self._field("vanish_y")

        oriW, oriH = ori_img_size

        fig = plt.figure(figsize=(14, 10))
        fig.suptitle("Vanishing Point Distribution\n"
                     r"vanish_x = cx + fx·tan(yaw·π/180),  "
                     r"vanish_y = cy − fy·tan(pitch·π/180)",
                     fontsize=11, fontweight="bold")

        gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)

        # Histogram vanish_x
        ax_hx = fig.add_subplot(gs[0, 0])
        _hist(ax_hx, vx, "Vanishing Point X", "vanish_x (pixel)", color="royalblue",
              vlines=[(oriW / 2, f"img_center_x={oriW/2:.0f}", "gray")])

        # Histogram vanish_y
        ax_hy = fig.add_subplot(gs[0, 1])
        _hist(ax_hy, vy, "Vanishing Point Y", "vanish_y (pixel)", color="darkorange",
              vlines=[(oriH / 2, f"img_center_y={oriH/2:.0f}", "gray")])

        # 2-D scatter
        ax_sc = fig.add_subplot(gs[1, :])
        if len(vx) and len(vy):
            # density-coloured scatter
            from matplotlib.colors import Normalize
            from matplotlib.cm import ScalarMappable

            # Use 2D histogram to compute point density for colouring
            hh, xe, ye = np.histogram2d(vx, vy, bins=80)
            xi = np.clip(np.searchsorted(xe[:-1], vx) - 1, 0, hh.shape[0] - 1)
            yi = np.clip(np.searchsorted(ye[:-1], vy) - 1, 0, hh.shape[1] - 1)
            density = hh[xi, yi]

            sc = ax_sc.scatter(vx, vy, c=density, s=6, alpha=0.6,
                               cmap="turbo", norm=Normalize(vmin=0, vmax=np.percentile(density, 98)))
            plt.colorbar(sc, ax=ax_sc, label="density")

            # Image boundary reference
            ax_sc.axvline(0, color="black", linewidth=0.5, linestyle=":")
            ax_sc.axvline(oriW, color="black", linewidth=0.5, linestyle=":")
            ax_sc.axhline(0, color="black", linewidth=0.5, linestyle=":")
            ax_sc.axhline(oriH, color="black", linewidth=0.5, linestyle=":")
            ax_sc.axvline(oriW / 2, color="gray", linewidth=0.8, linestyle="--",
                          label=f"img_center_x={oriW/2:.0f}")
            ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
                          label=f"img_center_y={oriH/2:.0f}")
            ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
            ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05)   # y-axis: image coords (top=0)

            med_vx = np.median(vx)
            med_vy = np.median(vy)
            ax_sc.axvline(med_vx, color="cyan", linewidth=1.0, linestyle="-.",
                          label=f"median_x={med_vx:.1f}")
            ax_sc.axhline(med_vy, color="yellow", linewidth=1.0, linestyle="-.",
                          label=f"median_y={med_vy:.1f}")

        ax_sc.set_title("Vanishing Point Scatter (image coordinates, n={:,})".format(len(vx)),
                        fontsize=9)
        ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
        ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
        ax_sc.legend(fontsize=7, loc="upper right")
        ax_sc.tick_params(labelsize=7)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    def plot_roi_bounds(self, output_path: str, ori_img_size: tuple):
        """Figure 5: ROI crop bound statistics (when roi_size is configured)."""
        rx1 = self._field("roi_x1")
        ry1 = self._field("roi_y1")
        rx2 = self._field("roi_x2")
        ry2 = self._field("roi_y2")

        if not len(ry1):
            print("  (no ROI bounds to plot — skipping figure 5)")
            return

        oriW, oriH = ori_img_size

        fig, axes = plt.subplots(2, 3, figsize=(16, 8))
        fig.suptitle("ROI Crop Bounds Distribution", fontsize=12, fontweight="bold")

        _hist(axes[0, 0], rx1, "roi_x1", "pixel", color="royalblue")
        _hist(axes[0, 1], ry1, "roi_y1", "pixel", color="tomato",
              vlines=[(0, "y=0", "gray"), (oriH // 2, f"img_mid_y={oriH//2}", "silver")])
        _hist(axes[0, 2], rx2, "roi_x2", "pixel", color="steelblue")

        # ry2 (effective bottom of crop after roi_bottom_offset)
        _hist(axes[1, 0], ry2, "roi_y2 (after bottom_offset)", "pixel", color="chocolate",
              vlines=[(oriH, f"oriH={oriH}", "gray")])

        # roi_height = ry2 - ry1
        roi_h_arr = ry2 - ry1
        _hist(axes[1, 1], roi_h_arr, "roi_height = roi_y2 − roi_y1", "pixel",
              color="mediumseagreen")

        # 2-D: roi_y1 vs roi_y2
        ax = axes[1, 2]
        if len(ry1) and len(ry2):
            ax.scatter(ry1, ry2, s=4, alpha=0.4, color="slateblue", edgecolors="none")
        ax.set_title("roi_y1 vs roi_y2", fontsize=9)
        ax.set_xlabel("roi_y1", fontsize=8)
        ax.set_ylabel("roi_y2", fontsize=8)
        ax.tick_params(labelsize=7)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    def plot_misc(self, output_path: str):
        """Figure 6: FOV, camera position, reprojection error."""
        fig, axes = plt.subplots(2, 3, figsize=(16, 8))
        fig.suptitle("Miscellaneous Calibration Parameters", fontsize=12, fontweight="bold")

        _hist(axes[0, 0], self._field("fov"),   "Field of View",       "fov (°)",   color="darkorchid")
        _hist(axes[0, 1], self._field("pos_x"),  "Camera Position X",   "pos_x (m)", color="royalblue")
        _hist(axes[0, 2], self._field("pos_y"),  "Camera Position Y",   "pos_y (m)", color="steelblue")
        _hist(axes[1, 0], self._field("pos_z"),  "Camera Position Z",   "pos_z (m)", color="mediumseagreen")
        _hist(axes[1, 1], self._field("reprojection_error"),
              "Reprojection Error", "error (pixel)", color="tomato")

        # Pitch vs vanish_y (sanity check)
        ax = axes[1, 2]
        pitch_arr = self._field("pitch")
        vy_arr    = self._field("vanish_y")
        if len(pitch_arr) and len(vy_arr) and len(pitch_arr) == len(vy_arr):
            ax.scatter(pitch_arr, vy_arr, s=4, alpha=0.4,
                       color="darkorange", edgecolors="none")
        ax.set_title("pitch vs vanish_y  (sanity check)", fontsize=9)
        ax.set_xlabel("pitch (°)", fontsize=8)
        ax.set_ylabel("vanish_y (pixel)", fontsize=8)
        ax.tick_params(labelsize=7)

        plt.tight_layout()
        fig.savefig(output_path, dpi=130)
        plt.close(fig)
        print(f"  Saved → {output_path}")

    # ── Text report ───────────────────────────────────────────────────────────

    def write_report(self, output_path: str, summary: dict):
        """Write a human-readable text report."""
        lines = []
        sep = "=" * 72

        lines.append(sep)
        lines.append("CALIBRATION PARAMETER PROFILING REPORT")
        lines.append(sep)
        lines.append(f"  Unique calib files loaded   : {summary['n_unique_calib_files']:,}")
        lines.append(f"  Missing calib files         : {summary['n_missing_calib_files']:,}")
        lines.append(f"  Invalid calib files         : {summary['n_invalid_calib_files']:,}")
        lines.append("")

        groups = [
            ("── Camera Intrinsics ──", ["focal_u", "focal_v", "cu", "cv"]),
            ("── Camera Angles (degrees) ──", ["pitch", "yaw", "roll"]),
            ("── Fisheye Distortion Coefficients ──",
             ["distort_k1", "distort_k2", "distort_k3", "distort_k4"]),
            ("── Field of View ──", ["fov"]),
            ("── Camera Position ──", ["pos_x", "pos_y", "pos_z"]),
            ("── Reprojection Error ──", ["reprojection_error"]),
            ("── Vanishing Point (pixels) ──", ["vanish_x", "vanish_y"]),
            ("── ROI Crop Bounds (pixels) ──", ["roi_x1", "roi_y1", "roi_x2", "roi_y2"]),
        ]

        for header, keys in groups:
            lines.append(header)
            for k in keys:
                if k in summary:
                    lines.append(f"  {k:30s}: {_fmt(summary[k])}")
            lines.append("")

        with open(output_path, "w") as f:
            f.write("\n".join(lines))
        print(f"  Report → {output_path}")

    # ── Per-sequence CSV table ──────────────────────────────────────────────────

    @staticmethod
    def _extract_vehicle_date(calib_path: str) -> str:
        """Extract a concise label from a calibration file path.

        Handles three path conventions:

        1. Training data  (.../driving_png[_YYYYMMDD]/<vehicle_id>/<date>/...):
              Returns 'vehicle_id/date', e.g. 'G1M3_FDL2232/20251201'.

        2. Test cases  (.../cases_coding/<case_id>/<clip>/... or
                        .../cases_feishu/<folder>/<clip>/...):
              Returns 'case_id/clip_name' (clip truncated at first '/').

        3. Fallback: look for 'calib' or 'calibs' directory and step back
           three levels to get a meaningful parent/grandparent pair.
        """
        parts = calib_path.replace("\\", "/").split("/")
        # 1. Training data — segment starts with 'driving_png'
        for i, seg in enumerate(parts):
            if seg.startswith("driving_png"):
                if i + 2 < len(parts):
                    return f"{parts[i + 1]}/{parts[i + 2]}"
                break
        # 2. Test-case directories — segment is 'cases_coding' or 'cases_feishu'
        for i, seg in enumerate(parts):
            if seg in ("cases_coding", "cases_feishu"):
                case_id  = parts[i + 1] if i + 1 < len(parts) else seg
                clip_raw = parts[i + 2] if i + 2 < len(parts) else ""
                # Truncate very long clip names to keep labels readable
                clip = clip_raw[:60] if clip_raw else ""
                return f"{case_id}/{clip}" if clip else case_id
        # 3. Fallback: find 'calib' or 'calibs' dir and step back 3 levels
        for i, seg in enumerate(parts):
            if seg in ("calib", "calibs") and i >= 3:
                return f"{parts[i - 3]}/{parts[i - 2]}"
        return calib_path

    def write_csv_table(self, output_path: str):
        """Export deduplicated calibration values as a CSV table.

        Rows are deduplicated by unique calibration parameter values.
        The first column shows 'vehicle_id/date' (e.g. 'G1M3_FDL2232/20251201').
        When multiple vehicle/date sequences share identical parameters, their
        labels are merged into a single cell separated by ' | '.
        """
        import csv

        FIELDS = [
            "focal_u", "focal_v", "cu", "cv",
            "pitch", "yaw", "roll", "fov",
            "pos_x", "pos_y", "pos_z",
            "vanish_x", "vanish_y",
            "roi_x1", "roi_y1", "roi_x2", "roi_y2",
            "reprojection_error", "is_valid",
        ]
        header = ["vehicle/date"] + FIELDS + [f"distort_k{i+1}" for i in range(4)]

        def _rec_key(rec):
            """Tuple key for deduplication: all numeric fields rounded to 6 dp."""
            parts = []
            for key in FIELDS:
                v = rec.get(key)
                if isinstance(v, float):
                    parts.append(round(v, 6))
                else:
                    parts.append(v)
            dc = rec.get("distort_coeffs", [])
            for i in range(4):
                v = dc[i] if dc and len(dc) > i else None
                parts.append(round(v, 6) if isinstance(v, float) else v)
            return tuple(parts)

        # Group by vehicle_id → calib_key → (rec, [dates])
        # Preserves per-vehicle insertion order via regular dicts (Python 3.7+)
        vehicle_groups: dict = {}  # vehicle_id -> {rec_key -> (rec, [date, ...])}
        for rec in self.records:
            vd = self._extract_vehicle_date(rec.get("path", ""))
            parts = vd.split("/", 1)
            vehicle_id = parts[0]
            date = parts[1] if len(parts) > 1 else ""
            k = _rec_key(rec)
            if vehicle_id not in vehicle_groups:
                vehicle_groups[vehicle_id] = {}
            if k not in vehicle_groups[vehicle_id]:
                vehicle_groups[vehicle_id][k] = (rec, [date])
            else:
                if date not in vehicle_groups[vehicle_id][k][1]:
                    vehicle_groups[vehicle_id][k][1].append(date)

        # Stage 1: build (label, rec, rec_key) per vehicle group.
        #   - vehicle has 1 unique calibration  → label = vehicle_id
        #   - vehicle has N>1 unique calibrations → label = vehicle_id/first_date
        stage1 = []  # list of (label, rec, rec_key)
        for vehicle_id, calib_map in vehicle_groups.items():
            only_one = len(calib_map) == 1
            for k, (rec, dates) in calib_map.items():
                if only_one:
                    label = vehicle_id
                else:
                    first_date = sorted(dates)[0]
                    label = f"{vehicle_id}/{first_date}"
                stage1.append((label, rec, k))

        # Stage 2: global dedup across vehicle groups — collapse rows that share
        # the same rec_key (identical calibration parameters) by merging labels.
        seen_key_idx: dict = {}        # rec_key -> index in rows_to_write
        rows_to_write: list = []       # list of [label, rec]  (label is mutable str)
        all_labels_per_row: list = []  # list of [label1, label2, ...] for merging
        for label, rec, k in stage1:
            if k not in seen_key_idx:
                seen_key_idx[k] = len(rows_to_write)
                rows_to_write.append([label, rec])
                all_labels_per_row.append([label])
            else:
                idx = seen_key_idx[k]
                if label not in all_labels_per_row[idx]:
                    all_labels_per_row[idx].append(label)

        # Format merged labels: show up to MAX_SHOWN; summarise the rest as "(+N more)"
        MAX_SHOWN = 3
        for i, labels in enumerate(all_labels_per_row):
            if len(labels) <= MAX_SHOWN:
                rows_to_write[i][0] = " | ".join(labels)
            else:
                shown = " | ".join(labels[:MAX_SHOWN])
                rows_to_write[i][0] = f"{shown} | (+{len(labels) - MAX_SHOWN} more)"

        n_unique_total = len(rows_to_write)

        with open(output_path, "w", newline="", encoding="utf-8") as f:
            writer = csv.writer(f)
            writer.writerow(header)
            for label, rec in rows_to_write:
                row = [label]
                for key in FIELDS:
                    val = rec.get(key)
                    row.append("" if val is None else val)
                dc = rec.get("distort_coeffs", [])
                for i in range(4):
                    row.append(dc[i] if dc and len(dc) > i else "")
                writer.writerow(row)

        print(f"  CSV    → {output_path}  ({n_unique_total:,} unique calibrations)")

    # ── Statistics table figure ─────────────────────────────────────────────────

    def plot_stats_table(self, output_path: str):
        """Figure 7: All calibration parameters in a color-coded statistics table.

        Rows = parameters (focal lengths, principal point, angles, distortion,
        vanishing point, ROI bounds, misc).
        Columns = n | min | p5 | median | mean | p95 | max | std | CV (%).
        The CV(%) column is color-coded (YlOrRd) to highlight variability across
        sequences — red cells indicate parameters that vary a lot.
        """
        import matplotlib.cm as cm
        from matplotlib.colors import Normalize

        SCALAR_PARAMS = [
            ("focal_u",            "focal_u (px)"),
            ("focal_v",            "focal_v (px)"),
            ("cu",                 "cu (px)"),
            ("cv",                 "cv (px)"),
            ("pitch",              "pitch (°)"),
            ("yaw",                "yaw (°)"),
            ("roll",               "roll (°)"),
            ("vanish_x",           "vanish_x (px)"),
            ("vanish_y",           "vanish_y (px)"),
            ("fov",                "fov (°)"),
            ("pos_x",              "pos_x (m)"),
            ("pos_y",              "pos_y (m)"),
            ("pos_z",              "pos_z (m)"),
            ("reprojection_error", "reproj_err (px)"),
            ("roi_x1",             "roi_x1 (px)"),
            ("roi_y1",             "roi_y1 (px)"),
            ("roi_x2",             "roi_x2 (px)"),
            ("roi_y2",             "roi_y2 (px)"),
        ]
        DISTORT_PARAMS = [(i, f"distort_k{i+1}") for i in range(4)]

        COL_HEADERS = [
            "Parameter", "n", "min", "p5", "median", "mean", "p95", "max", "std", "CV (%)"
        ]

        def _row_from_arr(label, arr):
            if len(arr) == 0:
                return None, None
            s = _dist_stats(arr)
            cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
            return [
                label,
                f"{s['count']:,}",
                f"{s['min']:.3f}",
                f"{s['p5']:.3f}",
                f"{s['median']:.3f}",
                f"{s['mean']:.3f}",
                f"{s['p95']:.3f}",
                f"{s['max']:.3f}",
                f"{s['std']:.4f}",
                f"{cv:.2f}",
            ], cv

        rows, cv_vals = [], []
        for key, label in SCALAR_PARAMS:
            row, cv = _row_from_arr(label, self._field(key))
            if row:
                rows.append(row)
                cv_vals.append(cv)
        for idx, name in DISTORT_PARAMS:
            row, cv = _row_from_arr(name, self._distort_k(idx))
            if row:
                rows.append(row)
                cv_vals.append(cv)

        if not rows:
            print("  (stats table: no data)")
            return

        n_rows = len(rows)
        fig_h = max(5, 0.42 * n_rows + 1.5)
        fig, ax = plt.subplots(figsize=(22, fig_h))
        ax.axis("off")
        fig.suptitle(
            f"Calibration Parameter Statistics  (n={len(self.records):,} unique sequences)",
            fontsize=12, fontweight="bold", y=0.99,
        )

        tbl = ax.table(cellText=rows, colLabels=COL_HEADERS, loc="center", cellLoc="center")
        tbl.auto_set_font_size(False)
        tbl.set_fontsize(8.5)
        tbl.auto_set_column_width(col=list(range(len(COL_HEADERS))))

        HDR_COLOR = "#2c3e50"
        for j in range(len(COL_HEADERS)):
            cell = tbl[0, j]
            cell.set_facecolor(HDR_COLOR)
            cell.set_text_props(color="white", fontweight="bold")

        cv_arr = np.array(cv_vals, dtype=float)
        p95_cv = np.percentile(cv_arr, 95) if cv_arr.max() > 0 else 1.0
        norm_cv = Normalize(vmin=0, vmax=max(p95_cv, 1e-6))
        cmap_cv = cm.get_cmap("YlOrRd")

        ALT_COLORS = ["#f0f4f8", "#ffffff"]
        for i, (row, cv) in enumerate(zip(rows, cv_vals)):
            bg = ALT_COLORS[i % 2]
            for j in range(len(COL_HEADERS)):
                tbl[i + 1, j].set_facecolor(bg)
            tbl[i + 1, 0].set_text_props(fontweight="bold")
            # CV column: color by variability
            tbl[i + 1, len(COL_HEADERS) - 1].set_facecolor(cmap_cv(norm_cv(cv)))

        fig.savefig(output_path, dpi=130, bbox_inches="tight")
        plt.close(fig)
        print(f"  Saved → {output_path}")


# ──────────────────────────────────────────────────────────────────────────────
# Cross-split comparison histograms & table (module-level)
# ──────────────────────────────────────────────────────────────────────────────

# Consistent color palette for multi-split overlays (index 0 = train, 1 = cases, …)
_COMPARE_PALETTE = [
    "#2196F3",  # blue       — train
    "#FF5722",  # deep-orange — cases
    "#4CAF50",  # green
    "#9C27B0",  # purple
    "#FF9800",  # orange
    "#00BCD4",  # cyan
]

# Lighter tints (for table row background per-group)
_COMPARE_TINTS = [
    "#E3F2FD",  # blue tint
    "#FBE9E7",  # orange tint
    "#E8F5E9",  # green tint
    "#F3E5F5",  # purple tint
    "#FFF8E1",  # amber tint
    "#E0F7FA",  # cyan tint
]


def _hist_multi(ax, datasets, title, xlabel, bins=50):
    """Overlay semi-transparent histograms + median lines for multiple datasets.

    Args:
        datasets: list of (label, np.ndarray, color) tuples.
    """
    if not datasets:
        ax.set_title(f"{title}\n(no data)")
        return
    any_data = False
    for label, arr, color in datasets:
        if len(arr) == 0:
            continue
        any_data = True
        ax.hist(arr, bins=bins, color=color, alpha=0.45, edgecolor="none",
                label=f"{label} (n={len(arr):,})")
        med = float(np.median(arr))
        ax.axvline(med, color=color, linestyle="--", linewidth=1.5,
                   label=f"{label} median={med:.2f}")
    if not any_data:
        ax.set_title(f"{title}\n(no data)")
        return
    ax.set_title(title, fontsize=9)
    ax.set_xlabel(xlabel, fontsize=8)
    ax.set_ylabel("count", fontsize=8)
    ax.legend(fontsize=7)
    ax.tick_params(labelsize=7)


def plot_comparison_histograms(profilers: dict, out_dir):
    """Overlay calibration parameter histograms for multiple splits/groups.

    Generates four comparison figures:
      comparison_01_intrinsics.png
      comparison_02_angles.png
      comparison_03_distortion.png
      comparison_04_vanishing_point.png

    Args:
        profilers: Ordered dict of {split_name: CalibProfiler}.
        out_dir:   pathlib.Path for the output directory.
    """
    out_dir = Path(out_dir)
    names  = list(profilers.keys())
    colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]

    def _ds(key=None, distort_idx=None):
        """Build datasets list for _hist_multi."""
        result = []
        for name, color in zip(names, colors):
            p = profilers[name]
            arr = p._distort_k(distort_idx) if distort_idx is not None else p._field(key)
            result.append((name, arr, color))
        return result

    title_suffix = "  vs  ".join(names)

    # ── Figure 1: Intrinsics ──────────────────────────────────────────────────
    fig, axes = plt.subplots(2, 2, figsize=(14, 9))
    fig.suptitle(f"Camera Intrinsics — {title_suffix}", fontsize=12, fontweight="bold")
    _hist_multi(axes[0, 0], _ds("focal_u"), "focal_u", "pixel (px)")
    _hist_multi(axes[0, 1], _ds("focal_v"), "focal_v", "pixel (px)")
    _hist_multi(axes[1, 0], _ds("cu"),      "cu — principal pt X", "pixel (px)")
    _hist_multi(axes[1, 1], _ds("cv"),      "cv — principal pt Y", "pixel (px)")
    plt.tight_layout()
    out = str(out_dir / "comparison_01_intrinsics.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 2: Angles ─────────────────────────────────────────────────────
    fig, axes = plt.subplots(1, 3, figsize=(16, 5))
    fig.suptitle(f"Camera Angles — {title_suffix}", fontsize=12, fontweight="bold")
    _hist_multi(axes[0], _ds("pitch"), "pitch", "degrees (°)")
    _hist_multi(axes[1], _ds("yaw"),   "yaw",   "degrees (°)")
    _hist_multi(axes[2], _ds("roll"),  "roll",  "degrees (°)")
    plt.tight_layout()
    out = str(out_dir / "comparison_02_angles.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 3: Distortion coefficients ────────────────────────────────────
    fig, axes = plt.subplots(2, 2, figsize=(14, 9))
    fig.suptitle(f"Fisheye Distortion Coefficients — {title_suffix}",
                 fontsize=12, fontweight="bold")
    for i, ax in enumerate(axes.flat):
        _hist_multi(ax, _ds(distort_idx=i), f"distort_k{i+1}", f"k{i+1}")
    plt.tight_layout()
    out = str(out_dir / "comparison_03_distortion.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 4: Vanishing point ─────────────────────────────────────────────
    fig = plt.figure(figsize=(16, 11))
    fig.suptitle(f"Vanishing Point — {title_suffix}",
                 fontsize=12, fontweight="bold")
    gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
    ax_hx = fig.add_subplot(gs[0, 0])
    ax_hy = fig.add_subplot(gs[0, 1])
    ax_sc = fig.add_subplot(gs[1, :])

    _hist_multi(ax_hx, _ds("vanish_x"), "Vanishing Point X", "vanish_x (pixel)")
    _hist_multi(ax_hy, _ds("vanish_y"), "Vanishing Point Y", "vanish_y (pixel)")

    # 2-D scatter: one color per split
    first_p = next(iter(profilers.values()))
    oriW, oriH = first_p.ori_img_size
    for name, color in zip(names, colors):
        p = profilers[name]
        vx = p._field("vanish_x")
        vy = p._field("vanish_y")
        if len(vx) == 0:
            continue
        ax_sc.scatter(vx, vy, c=color, s=14, alpha=0.55,
                      edgecolors="none", label=f"{name} (n={len(vx):,})")
        med_vx, med_vy = float(np.median(vx)), float(np.median(vy))
        ax_sc.scatter([med_vx], [med_vy], c=color, s=120, marker="*",
                      edgecolors="black", linewidths=0.7, zorder=6,
                      label=f"{name} median ({med_vx:.0f}, {med_vy:.0f})")

    ax_sc.axvline(oriW / 2, color="gray",   linewidth=0.8, linestyle="--",
                  label=f"img_center_x={oriW//2}")
    ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
                  label=f"img_center_y={oriH//2}")
    ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
    ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05)   # image coords: top=0
    ax_sc.set_title("Vanishing Point Scatter (image coordinates)", fontsize=9)
    ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
    ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
    ax_sc.legend(fontsize=7, loc="upper right")
    ax_sc.tick_params(labelsize=7)

    out = str(out_dir / "comparison_04_vanishing_point.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")


def plot_comparison_stats_table(profilers: dict, output_path: str):
    """Combined statistics table showing all profiler groups side-by-side.

    Rows are grouped by parameter.  Each group of rows shows one profiler's
    stats, with a colored background tint per profiler so they are visually
    distinct.  A thin separator row is inserted between parameters.

    Columns: Parameter | split | n | min | p5 | median | mean | p95 | max | std | CV(%)
    """
    import matplotlib.cm as cm
    from matplotlib.colors import Normalize

    SCALAR_PARAMS = [
        ("focal_u",            "focal_u (px)"),
        ("focal_v",            "focal_v (px)"),
        ("cu",                 "cu (px)"),
        ("cv",                 "cv (px)"),
        ("pitch",              "pitch (°)"),
        ("yaw",                "yaw (°)"),
        ("roll",               "roll (°)"),
        ("vanish_x",           "vanish_x (px)"),
        ("vanish_y",           "vanish_y (px)"),
        ("fov",                "fov (°)"),
        ("pos_x",              "pos_x (m)"),
        ("pos_y",              "pos_y (m)"),
        ("pos_z",              "pos_z (m)"),
        ("reprojection_error", "reproj_err (px)"),
        ("roi_x1",             "roi_x1 (px)"),
        ("roi_y1",             "roi_y1 (px)"),
        ("roi_x2",             "roi_x2 (px)"),
        ("roi_y2",             "roi_y2 (px)"),
    ]
    DISTORT_PARAMS = [(i, f"distort_k{i+1}") for i in range(4)]
    ALL_PARAMS = [(k, lbl, None) for k, lbl in SCALAR_PARAMS] + \
                 [(None, lbl, i) for i, lbl in DISTORT_PARAMS]

    COL_HEADERS = ["Parameter", "split", "n", "min", "p5",
                   "median", "mean", "p95", "max", "std", "CV (%)"]

    names  = list(profilers.keys())
    colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]
    tints  = [_COMPARE_TINTS[i % len(_COMPARE_TINTS)]  for i in range(len(names))]

    # Collect cv values across ALL rows for a global color scale
    def _get_arr(profiler, key, distort_idx):
        return profiler._distort_k(distort_idx) if distort_idx is not None \
               else profiler._field(key)

    def _make_row(param_label, split_name, arr):
        if len(arr) == 0:
            return None, None
        s = _dist_stats(arr)
        cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
        return [
            param_label,
            split_name,
            f"{s['count']:,}",
            f"{s['min']:.3f}",
            f"{s['p5']:.3f}",
            f"{s['median']:.3f}",
            f"{s['mean']:.3f}",
            f"{s['p95']:.3f}",
            f"{s['max']:.3f}",
            f"{s['std']:.4f}",
            f"{cv:.2f}",
        ], cv

    # Build rows grouped by parameter, interleaving splits
    row_data   = []   # (row_cells, cv, split_idx, is_first_in_group)
    for key, lbl, distort_idx in ALL_PARAMS:
        first_in_group = True
        for si, (name, profiler) in enumerate(profilers.items()):
            arr = _get_arr(profiler, key, distort_idx)
            row, cv = _make_row(lbl, name, arr)
            if row is None:
                continue
            # Show parameter label only in the first split row of each group
            if not first_in_group:
                row[0] = ""   # blank param label for subsequent splits
            row_data.append((row, cv, si, first_in_group))
            first_in_group = False

    if not row_data:
        print("  (comparison stats table: no data)")
        return

    all_cv = np.array([cv for _, cv, _, _ in row_data], dtype=float)
    p95_cv = np.percentile(all_cv, 95) if all_cv.max() > 0 else 1.0
    norm_cv = Normalize(vmin=0, vmax=max(p95_cv, 1e-6))
    cmap_cv = cm.get_cmap("YlOrRd")

    n_rows = len(row_data)
    fig_h = max(6, 0.38 * n_rows + 1.8)
    fig, ax = plt.subplots(figsize=(24, fig_h))
    ax.axis("off")

    split_n = {name: len(p.records) for name, p in profilers.items()}
    title_parts = [f"{n} (n={split_n[n]:,})" for n in names]
    fig.suptitle(
        "Calibration Parameter Statistics — " + "  vs  ".join(title_parts),
        fontsize=12, fontweight="bold", y=0.99,
    )

    cells = [r for r, _, _, _ in row_data]
    tbl = ax.table(cellText=cells, colLabels=COL_HEADERS,
                   loc="center", cellLoc="center")
    tbl.auto_set_font_size(False)
    tbl.set_fontsize(8)
    tbl.auto_set_column_width(col=list(range(len(COL_HEADERS))))

    # Header row
    HDR_COLOR = "#2c3e50"
    for j in range(len(COL_HEADERS)):
        tbl[0, j].set_facecolor(HDR_COLOR)
        tbl[0, j].set_text_props(color="white", fontweight="bold")
    # Color the split header cell to match palette
    tbl[0, 1].set_facecolor("#455a64")

    # Data rows
    for i, (row, cv, si, is_first) in enumerate(row_data):
        tint = tints[si]
        for j in range(len(COL_HEADERS)):
            tbl[i + 1, j].set_facecolor(tint)
        # Parameter name bold + colored left border via param cell text color
        if is_first:
            tbl[i + 1, 0].set_text_props(fontweight="bold")
        # Split name cell: colored text matching palette
        tbl[i + 1, 1].set_text_props(color=colors[si], fontweight="bold")
        # CV column: YlOrRd heatmap on top of tint
        tbl[i + 1, len(COL_HEADERS) - 1].set_facecolor(cmap_cv(norm_cv(cv)))

    fig.savefig(output_path, dpi=130, bbox_inches="tight")
    plt.close(fig)
    print(f"  Saved → {output_path}")


def plot_comparison_histograms(profilers: dict, out_dir):
    """Overlay calibration parameter histograms for multiple splits/groups.

    Generates four comparison figures:
      comparison_01_intrinsics.png
      comparison_02_angles.png
      comparison_03_distortion.png
      comparison_04_vanishing_point.png

    Args:
        profilers: Ordered dict of {split_name: CalibProfiler}.
        out_dir:   pathlib.Path for the output directory.
    """
    out_dir = Path(out_dir)
    names  = list(profilers.keys())
    colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]

    def _ds(key=None, distort_idx=None):
        """Build datasets list for _hist_multi."""
        result = []
        for name, color in zip(names, colors):
            p = profilers[name]
            arr = p._distort_k(distort_idx) if distort_idx is not None else p._field(key)
            result.append((name, arr, color))
        return result

    title_suffix = "  vs  ".join(names)

    # ── Figure 1: Intrinsics ─────────────────────────────────────────────────
    fig, axes = plt.subplots(2, 2, figsize=(14, 9))
    fig.suptitle(f"Camera Intrinsics — {title_suffix}", fontsize=12, fontweight="bold")
    _hist_multi(axes[0, 0], _ds("focal_u"), "focal_u", "pixel (px)")
    _hist_multi(axes[0, 1], _ds("focal_v"), "focal_v", "pixel (px)")
    _hist_multi(axes[1, 0], _ds("cu"),      "cu — principal pt X", "pixel (px)")
    _hist_multi(axes[1, 1], _ds("cv"),      "cv — principal pt Y", "pixel (px)")
    plt.tight_layout()
    out = str(out_dir / "comparison_01_intrinsics.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 2: Angles ─────────────────────────────────────────────────────
    fig, axes = plt.subplots(1, 3, figsize=(16, 5))
    fig.suptitle(f"Camera Angles — {title_suffix}", fontsize=12, fontweight="bold")
    _hist_multi(axes[0], _ds("pitch"), "pitch", "degrees (°)")
    _hist_multi(axes[1], _ds("yaw"),   "yaw",   "degrees (°)")
    _hist_multi(axes[2], _ds("roll"),  "roll",  "degrees (°)")
    plt.tight_layout()
    out = str(out_dir / "comparison_02_angles.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 3: Distortion coefficients ────────────────────────────────────
    fig, axes = plt.subplots(2, 2, figsize=(14, 9))
    fig.suptitle(f"Fisheye Distortion Coefficients — {title_suffix}",
                 fontsize=12, fontweight="bold")
    for i, ax in enumerate(axes.flat):
        _hist_multi(ax, _ds(distort_idx=i), f"distort_k{i+1}", f"k{i+1}")
    plt.tight_layout()
    out = str(out_dir / "comparison_03_distortion.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")

    # ── Figure 4: Vanishing point ─────────────────────────────────────────────
    fig = plt.figure(figsize=(16, 11))
    fig.suptitle(f"Vanishing Point — {title_suffix}",
                 fontsize=12, fontweight="bold")
    gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
    ax_hx = fig.add_subplot(gs[0, 0])
    ax_hy = fig.add_subplot(gs[0, 1])
    ax_sc = fig.add_subplot(gs[1, :])

    _hist_multi(ax_hx, _ds("vanish_x"), "Vanishing Point X", "vanish_x (pixel)")
    _hist_multi(ax_hy, _ds("vanish_y"), "Vanishing Point Y", "vanish_y (pixel)")

    # 2-D scatter: one color per split
    first_p = next(iter(profilers.values()))
    oriW, oriH = first_p.ori_img_size
    for name, color in zip(names, colors):
        p = profilers[name]
        vx = p._field("vanish_x")
        vy = p._field("vanish_y")
        if len(vx) == 0:
            continue
        ax_sc.scatter(vx, vy, c=color, s=14, alpha=0.55,
                      edgecolors="none", label=f"{name} (n={len(vx):,})")
        med_vx, med_vy = float(np.median(vx)), float(np.median(vy))
        ax_sc.scatter([med_vx], [med_vy], c=color, s=120, marker="*",
                      edgecolors="black", linewidths=0.7, zorder=6,
                      label=f"{name} median ({med_vx:.0f}, {med_vy:.0f})")

    ax_sc.axvline(oriW / 2, color="gray",   linewidth=0.8, linestyle="--",
                  label=f"img_center_x={oriW//2}")
    ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
                  label=f"img_center_y={oriH//2}")
    ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
    ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05)   # image coords: top=0
    ax_sc.set_title("Vanishing Point Scatter (image coordinates)", fontsize=9)
    ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
    ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
    ax_sc.legend(fontsize=7, loc="upper right")
    ax_sc.tick_params(labelsize=7)

    out = str(out_dir / "comparison_04_vanishing_point.png")
    fig.savefig(out, dpi=130)
    plt.close(fig)
    print(f"  Saved → {out}")


def plot_split_comparison_table(profilers: dict, output_path: str):
    """Compare calibration statistics across multiple splits in a single table.

    Args:
        profilers: Ordered dict of {split_name: CalibProfiler}.
        output_path: Path for the saved figure.

    Table layout:
        Rows = calibration parameters.
        Columns = Parameter | [split1: median / std / CV%] | [split2: ...] | Δmedian.
    The Δmedian column (split0 − split1) is color-coded with a diverging RdBu colormap
    so that positive/negative shifts are immediately visible.
    """
    import matplotlib.cm as cm
    from matplotlib.colors import TwoSlopeNorm

    SCALAR_PARAMS = [
        ("focal_u",            "focal_u (px)"),
        ("focal_v",            "focal_v (px)"),
        ("cu",                 "cu (px)"),
        ("cv",                 "cv (px)"),
        ("pitch",              "pitch (°)"),
        ("yaw",                "yaw (°)"),
        ("roll",               "roll (°)"),
        ("vanish_x",           "vanish_x (px)"),
        ("vanish_y",           "vanish_y (px)"),
        ("fov",                "fov (°)"),
        ("pos_x",              "pos_x (m)"),
        ("pos_y",              "pos_y (m)"),
        ("pos_z",              "pos_z (m)"),
        ("reprojection_error", "reproj_err (px)"),
        ("roi_x1",             "roi_x1 (px)"),
        ("roi_y1",             "roi_y1 (px)"),
        ("roi_x2",             "roi_x2 (px)"),
        ("roi_y2",             "roi_y2 (px)"),
    ]

    split_names = list(profilers.keys())
    n_splits = len(split_names)
    has_delta = n_splits == 2
    SUB_COLS = ["median", "std", "CV (%)"]

    # Header row
    col_headers = ["Parameter"]
    SPLIT_COLORS = ["#1a5276", "#1e8449", "#6e2f8a", "#7b241c"]
    for sp in split_names:
        n = len(profilers[sp].records)
        for sc in SUB_COLS:
            col_headers.append(f"{sp}  {sc}\n(n={n:,})")
    if has_delta:
        delta_label = f"Δmedian\n({split_names[0]}−{split_names[1]})"
        col_headers.append(delta_label)

    def _get_stats(profiler, key, distort_idx=None):
        arr = (
            profiler._distort_k(distort_idx)
            if distort_idx is not None
            else profiler._field(key)
        )
        if len(arr) == 0:
            return None
        s = _dist_stats(arr)
        cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
        return s["median"], s["std"], cv

    def _build_row(label, key, distort_idx=None):
        row = [label]
        medians = []
        for sp in split_names:
            res = _get_stats(profilers[sp], key, distort_idx)
            if res is None:
                row.extend(["—", "—", "—"])
                medians.append(None)
            else:
                med, std, cv = res
                row += [f"{med:.3f}", f"{std:.4f}", f"{cv:.2f}"]
                medians.append(med)
        if has_delta:
            m0, m1 = medians[0], medians[1]
            row.append(
                f"{m0 - m1:+.3f}" if (m0 is not None and m1 is not None) else "—"
            )
        return row

    rows = []
    for key, label in SCALAR_PARAMS:
        rows.append(_build_row(label, key))
    for idx in range(4):
        rows.append(_build_row(f"distort_k{idx+1}", None, distort_idx=idx))

    # Drop rows where every split cell is missing
    n_data = n_splits * len(SUB_COLS)
    rows = [r for r in rows if not all(v == "—" for v in r[1: 1 + n_data])]

    if not rows:
        print("  (comparison table: no data)")
        return

    n_rows = len(rows)
    fig_w = max(16, 3.5 + 3.2 * n_splits * len(SUB_COLS) + (2.5 if has_delta else 0))
    fig_h = max(5, 0.42 * n_rows + 1.5)
    fig, ax = plt.subplots(figsize=(fig_w, fig_h))
    ax.axis("off")
    fig.suptitle(
        "Calibration Parameter Comparison — " + "  vs  ".join(split_names),
        fontsize=12, fontweight="bold", y=0.99,
    )

    tbl = ax.table(cellText=rows, colLabels=col_headers, loc="center", cellLoc="center")
    tbl.auto_set_font_size(False)
    tbl.set_fontsize(8)
    tbl.auto_set_column_width(col=list(range(len(col_headers))))

    # Dark base header
    HDR_BASE = "#2c3e50"
    for j in range(len(col_headers)):
        tbl[0, j].set_facecolor(HDR_BASE)
        tbl[0, j].set_text_props(color="white", fontweight="bold", fontsize=7.5)

    # Per-split colored group headers
    for j_sp, sp in enumerate(split_names):
        col_start = 1 + j_sp * len(SUB_COLS)
        grp_color = SPLIT_COLORS[j_sp % len(SPLIT_COLORS)]
        for sc_i in range(len(SUB_COLS)):
            tbl[0, col_start + sc_i].set_facecolor(grp_color)

    ALT_COLORS = ["#f0f4f8", "#ffffff"]
    for i, row in enumerate(rows):
        bg = ALT_COLORS[i % 2]
        for j in range(len(col_headers)):
            tbl[i + 1, j].set_facecolor(bg)
        tbl[i + 1, 0].set_text_props(fontweight="bold")

    # Color the Δmedian column with a diverging colormap
    if has_delta:
        delta_col = len(col_headers) - 1
        delta_vals = []
        for row in rows:
            try:
                delta_vals.append(float(row[delta_col]))
            except Exception:
                delta_vals.append(0.0)
        max_abs = max(abs(v) for v in delta_vals) if delta_vals else 1.0
        if max_abs > 0:
            norm_d = TwoSlopeNorm(vmin=-max_abs, vcenter=0.0, vmax=max_abs)
            cmap_d = cm.get_cmap("RdBu_r")
            for i, dv in enumerate(delta_vals):
                tbl[i + 1, delta_col].set_facecolor(cmap_d(norm_d(dv)))

    fig.savefig(output_path, dpi=130, bbox_inches="tight")
    plt.close(fig)
    print(f"  Saved → {output_path}")


# ──────────────────────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="Profile calibration parameters for a YOLOv5-3D dataset split.")
    parser.add_argument("--data", default="data/mono3d.yaml",
                        help="Path to dataset YAML config (default: data/mono3d.yaml)")
    parser.add_argument("--split", default="train", choices=["train", "val", "both"],
                        help="Which split to analyse (default: train)")
    parser.add_argument("--max-files", type=int, default=0,
                        help="Maximum number of image files to scan (0 = all)")
    parser.add_argument("--output-dir", default="calib_profiling_results",
                        help="Output directory for plots and report")
    parser.add_argument("--workers", type=int, default=0,
                        help="Number of parallel workers (0 = auto, 1 = sequential)")
    parser.add_argument(
        "--cases-dirs", nargs="+", default=[],
        metavar="NAME:PATH",
        help=(
            "One or more test-case directories to profile alongside the training/val "
            "splits.  Each entry should be 'name:path' (e.g. "
            "'cases_coding:/data1/dongying/Mono3d/G1M3/cases_coding').  "
            "If no colon is present the directory basename is used as the name.  "
            "All directories are merged into a single 'cases' profile.  "
            "Results are included in the cross-split comparison table."
        ),
    )
    parser.add_argument(
        "--cases-name", default="cases",
        help="Label for the merged test-cases profile (default: 'cases').",
    )
    args = parser.parse_args()

    # ── Load dataset YAML ─────────────────────────────────────────────────────
    data_path = Path(args.data)
    if not data_path.exists():
        print(f"ERROR: data file not found: {data_path}", file=sys.stderr)
        sys.exit(1)

    with open(data_path, "r") as f:
        data_cfg = yaml.safe_load(f)

    ori_img_size = tuple(data_cfg.get("ori_img_size", [1920, 1080]))  # (W, H)
    roi_cfg      = data_cfg.get("roi", None)
    roi_size     = tuple(roi_cfg) if roi_cfg else None
    roi_bottom_offset = int(data_cfg.get("roi_bottom_offset", 0))

    # ── Resolve workers ───────────────────────────────────────────────────────
    n_workers = args.workers if args.workers > 0 else min(cpu_count(), 16)

    # ── Output directory ──────────────────────────────────────────────────────
    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    splits = ["train", "val"] if args.split == "both" else [args.split]
    profilers_by_split = {}  # {split_name: CalibProfiler} — for cross-split comparison

    for split in splits:
        print(f"\n{'='*60}")
        print(f"  Split: {split}")
        print(f"{'='*60}")

        img_paths = resolve_image_paths(data_cfg, split)
        if not img_paths:
            print(f"  WARNING: no image paths found for split '{split}'.")
            continue

        if args.max_files > 0:
            img_paths = img_paths[: args.max_files]

        calib_paths = unique_calib_paths(img_paths)
        print(f"  Total image paths  : {len(img_paths):,}")
        print(f"  Unique calib files : {len(calib_paths):,}")
        print(f"  Workers            : {n_workers}")
        print(f"  ori_img_size       : {ori_img_size}")
        print(f"  roi_size           : {roi_size}")
        print(f"  roi_bottom_offset  : {roi_bottom_offset}")
        print()

        profiler = CalibProfiler(
            ori_img_size=ori_img_size,
            roi_size=roi_size,
            roi_bottom_offset=roi_bottom_offset,
        )

        print("  Loading calibration files…")
        profiler.process_batch(calib_paths, workers=n_workers)

        n_loaded = len(profiler.records)
        print(f"  Loaded  : {n_loaded:,} / {len(calib_paths):,}  "
              f"(missing={profiler.n_missing}, invalid={profiler.n_invalid})")

        if n_loaded == 0:
            print("  No calibration data found — skipping this split.")
            continue

        print("\n  Generating summary statistics…")
        summary = profiler.summarize()

        # ── Save JSON summary ─────────────────────────────────────────────────
        json_path = out_dir / f"{split}_calib_summary.json"
        with open(json_path, "w") as f:
            json.dump(summary, f, indent=2)
        print(f"  JSON   → {json_path}")

        # ── Text report ────────────────────────────────────────────────────────
        report_path = out_dir / f"{split}_calib_report.txt"
        profiler.write_report(str(report_path), summary)

        # ── Figures ───────────────────────────────────────────────────────────
        print("\n  Generating plots…")
        profiler.plot_intrinsics(str(out_dir / f"{split}_01_intrinsics.png"))
        profiler.plot_angles(str(out_dir / f"{split}_02_angles.png"))
        profiler.plot_distortion(str(out_dir / f"{split}_03_distortion.png"))
        profiler.plot_vanishing_point(
            str(out_dir / f"{split}_04_vanishing_point.png"), ori_img_size)
        if roi_size is not None:
            profiler.plot_roi_bounds(
                str(out_dir / f"{split}_05_roi_bounds.png"), ori_img_size)
        profiler.plot_misc(str(out_dir / f"{split}_06_misc.png"))
        profiler.plot_stats_table(str(out_dir / f"{split}_07_stats_table.png"))

        # ── Per-sequence CSV table ─────────────────────────────────────────────
        profiler.write_csv_table(str(out_dir / f"{split}_calib_table.csv"))

        # ── Track profiler for cross-split comparison ─────────────────────────
        profilers_by_split[split] = profiler

        # ── Console synopsis ──────────────────────────────────────────────────
        print(f"\n  ── Vanishing Point Synopsis ──")
        for key in ("vanish_x", "vanish_y"):
            if key in summary:
                print(f"    {key}: {_fmt(summary[key])}")
        if "roi_y1" in summary:
            print(f"\n  ── ROI y1 (crop top) Synopsis ──")
            print(f"    roi_y1: {_fmt(summary['roi_y1'])}")

    # ── Test-case directories (all merged into one combined profile) ─────────
    if args.cases_dirs:
        cases_entries = []
        for entry in args.cases_dirs:
            if ":" in entry:
                n, p = entry.split(":", 1)
            else:
                n = os.path.basename(entry.rstrip("/"))
                p = entry
            cases_entries.append((n, p))

        combined_name = args.cases_name

        print(f"\n{'='*60}")
        print(f"  Test cases (merged as '{combined_name}')")
        for n, p in cases_entries:
            print(f"    [{n}] {p}")
        print(f"{'='*60}")

        # Collect + deduplicate calib paths across all directories
        all_calib_paths: list = []
        seen_paths: set = set()
        for n, p in cases_entries:
            if not Path(p).exists():
                print(f"  WARNING: cases dir not found: {p!r} — skipping.")
                continue
            found = find_calib_paths_in_dir(p)
            print(f"  [{n}] Found {len(found):,} calib files")
            for cp in found:
                if cp not in seen_paths:
                    seen_paths.add(cp)
                    all_calib_paths.append(cp)

        print(f"  Total (deduplicated) : {len(all_calib_paths):,} calib files")
        print(f"  Workers              : {n_workers}")
        print(f"  ori_img_size         : {ori_img_size}")
        print(f"  roi_size             : {roi_size}")

        if all_calib_paths:
            profiler = CalibProfiler(
                ori_img_size=ori_img_size,
                roi_size=roi_size,
                roi_bottom_offset=roi_bottom_offset,
            )
            profiler.process_batch(all_calib_paths, workers=n_workers)

            n_loaded = len(profiler.records)
            print(f"  Loaded  : {n_loaded:,} / {len(all_calib_paths):,}  "
                  f"(missing={profiler.n_missing}, invalid={profiler.n_invalid})")

            if n_loaded > 0:
                print("\n  Generating summary statistics…")
                summary = profiler.summarize()

                json_path = out_dir / f"{combined_name}_calib_summary.json"
                with open(json_path, "w") as f:
                    json.dump(summary, f, indent=2)
                print(f"  JSON   → {json_path}")

                report_path = out_dir / f"{combined_name}_calib_report.txt"
                profiler.write_report(str(report_path), summary)

                print("\n  Generating plots…")
                profiler.plot_intrinsics(str(out_dir / f"{combined_name}_01_intrinsics.png"))
                profiler.plot_angles(str(out_dir / f"{combined_name}_02_angles.png"))
                profiler.plot_distortion(str(out_dir / f"{combined_name}_03_distortion.png"))
                profiler.plot_vanishing_point(
                    str(out_dir / f"{combined_name}_04_vanishing_point.png"), ori_img_size)
                if roi_size is not None:
                    profiler.plot_roi_bounds(
                        str(out_dir / f"{combined_name}_05_roi_bounds.png"), ori_img_size)
                profiler.plot_misc(str(out_dir / f"{combined_name}_06_misc.png"))
                profiler.plot_stats_table(str(out_dir / f"{combined_name}_07_stats_table.png"))

                profiler.write_csv_table(str(out_dir / f"{combined_name}_calib_table.csv"))

                profilers_by_split[combined_name] = profiler

    # ── Cross-split comparison plots + table (generated when ≥2 profiles) ───
    if len(profilers_by_split) >= 2:
        print("\n" + "="*60)
        print("  Generating cross-split comparison histograms…")
        plot_comparison_histograms(profilers_by_split, out_dir)

        print("\n  Generating cross-split combined stats table…")
        plot_comparison_stats_table(
            profilers_by_split,
            str(out_dir / "comparison_stats_table.png"),
        )

        print("\n  Generating cross-split median comparison table…")
        plot_split_comparison_table(
            profilers_by_split,
            str(out_dir / "comparison_table.png"),
        )

    print("\nDone.")


if __name__ == "__main__":
    main()
