#!/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: .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]///...): Returns 'vehicle_id/date', e.g. 'G1M3_FDL2232/20251201'. 2. Test cases (.../cases_coding///... or .../cases_feishu///...): 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()