Files
yolov26_3d/tools/scripts_for_gt/profiling/calib_profiling.py
2026-06-24 09:35:46 +08:00

1686 lines
72 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 (k1k4)
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 (k1k4)", 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()