1686 lines
72 KiB
Python
Executable File
1686 lines
72 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Calibration Parameter Profiling for YOLOv5-3D Training Dataset.
|
||
|
||
Scans all unique calibration files referenced by a dataset (via data YAML),
|
||
computes descriptive statistics, and produces visualisation plots for:
|
||
|
||
1. Focal lengths (focal_u / focal_v)
|
||
2. Principal point (cu / cv)
|
||
3. Camera angles (pitch, yaw, roll)
|
||
4. Fisheye distortion coefficients (k1–k4)
|
||
5. Field of view (fov)
|
||
6. Camera position (pos_x, pos_y, pos_z)
|
||
7. Vanishing point (vanish_x, vanish_y) — derived from pitch/yaw/calibration
|
||
8. ROI crop bounds derived from vanishing point (when roi config is supplied)
|
||
|
||
Usage:
|
||
python calib_profiling.py [--data data/mono3d.yaml] [--split train]
|
||
[--max-files 0] [--output-dir calib_profiling_results]
|
||
[--workers 0]
|
||
|
||
--max-files 0 means process all image files (unique calib dirs are deduplicated).
|
||
--workers 0 auto-detect CPU count; use 1 for sequential processing.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
from collections import defaultdict
|
||
from multiprocessing import Pool, cpu_count
|
||
from pathlib import Path
|
||
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.gridspec as gridspec
|
||
import numpy as np
|
||
import yaml
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Helpers
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _dist_stats(arr: np.ndarray) -> dict:
|
||
"""Return compact descriptive statistics for a 1-D numeric array."""
|
||
if len(arr) == 0:
|
||
return {"count": 0}
|
||
return {
|
||
"count": int(len(arr)),
|
||
"min": float(np.min(arr)),
|
||
"p5": float(np.percentile(arr, 5)),
|
||
"p25": float(np.percentile(arr, 25)),
|
||
"median": float(np.median(arr)),
|
||
"mean": float(np.mean(arr)),
|
||
"p75": float(np.percentile(arr, 75)),
|
||
"p95": float(np.percentile(arr, 95)),
|
||
"max": float(np.max(arr)),
|
||
"std": float(np.std(arr)),
|
||
}
|
||
|
||
|
||
def _fmt(d: dict) -> str:
|
||
"""Format a _dist_stats dict to a short human-readable string."""
|
||
if d.get("count", 0) == 0:
|
||
return "N/A"
|
||
return (
|
||
f"n={d['count']:,} mean={d['mean']:.3f} std={d['std']:.3f} "
|
||
f"[{d['min']:.3f}, {d['p5']:.3f}, {d['median']:.3f}, {d['p95']:.3f}, {d['max']:.3f}]"
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Data loading helpers (same convention as dataset_profiling.py)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def resolve_image_paths(data_cfg: dict, split: str) -> list:
|
||
"""Resolve image paths from *data_cfg* for the given split.
|
||
|
||
Text-file entries are resolved relative to the txt file's parent directory,
|
||
matching the training dataloader convention.
|
||
"""
|
||
raw = data_cfg.get(split, [])
|
||
if isinstance(raw, str):
|
||
raw = [raw]
|
||
|
||
all_paths = []
|
||
for entry in raw:
|
||
entry = str(entry).strip()
|
||
if not entry:
|
||
continue
|
||
p = Path(entry)
|
||
if p.is_file():
|
||
txt_parent = str(p.parent)
|
||
with open(p, "r") as f:
|
||
lines = [l.strip() for l in f if l.strip()]
|
||
for line in lines:
|
||
if line.startswith("./"):
|
||
line = str(Path(txt_parent) / line[2:])
|
||
elif not line.startswith("/"):
|
||
line = str(Path(txt_parent) / line)
|
||
all_paths.append(line)
|
||
elif p.is_dir():
|
||
for ext in ("*.png", "*.jpg", "*.jpeg"):
|
||
all_paths.extend(str(x) for x in p.rglob(ext))
|
||
else:
|
||
all_paths.append(entry)
|
||
return all_paths
|
||
|
||
|
||
def calib_path_for_image(img_path: str) -> str:
|
||
"""Derive the camera4.json calibration path from an image path.
|
||
|
||
Convention: <base_dir>.replace('images', 'calib') / L2_calib / camera4.json
|
||
"""
|
||
base = os.path.dirname(img_path)
|
||
return base.replace("images", "calib") + "/L2_calib/camera4.json"
|
||
|
||
|
||
def unique_calib_paths(img_paths: list) -> list:
|
||
"""Return deduplicated calibration file paths, preserving first-seen order."""
|
||
seen = set()
|
||
result = []
|
||
for p in img_paths:
|
||
cp = calib_path_for_image(p)
|
||
if cp not in seen:
|
||
seen.add(cp)
|
||
result.append(cp)
|
||
return result
|
||
|
||
|
||
def find_calib_paths_in_dir(root_dir: str) -> list:
|
||
"""Recursively find all camera4.json calibration files under *root_dir*.
|
||
|
||
Used for test-case directories that do not have associated image lists
|
||
(e.g. cases_coding / cases_feishu). Preserves insertion order and
|
||
deduplicates by absolute path.
|
||
"""
|
||
seen = set()
|
||
result = []
|
||
for dirpath, _dirs, filenames in os.walk(root_dir):
|
||
if "camera4.json" in filenames:
|
||
p = os.path.normpath(os.path.join(dirpath, "camera4.json"))
|
||
if p not in seen:
|
||
seen.add(p)
|
||
result.append(p)
|
||
return result
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Vanishing-point computation (mirrors dataloaders3d.py __getitem__)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def compute_vanishing_point(calib: dict):
|
||
"""Return (vanish_x, vanish_y) in original image pixel coordinates.
|
||
|
||
Formula (from dataloaders3d.py):
|
||
vanish_x = cx + fx * tan(yaw * π/180)
|
||
vanish_y = cy - fy * tan(pitch * π/180)
|
||
"""
|
||
fx = calib["focal_u"]
|
||
fy = calib["focal_v"]
|
||
cx = calib["cu"]
|
||
cy = calib["cv"]
|
||
yaw = calib.get("yaw", 0.0)
|
||
pitch = calib.get("pitch", 0.0)
|
||
vanish_x = cx + fx * math.tan(yaw * math.pi / 180.0)
|
||
vanish_y = cy - fy * math.tan(pitch * math.pi / 180.0)
|
||
return vanish_x, vanish_y
|
||
|
||
|
||
def compute_roi_bounds(calib: dict, ori_img_size: tuple, roi_size: tuple,
|
||
roi_bottom_offset: int = 0):
|
||
"""Compute ROI crop bounds from calibration + config (mirrors dataloaders3d.py).
|
||
|
||
Returns (roi_x1, roi_y1, roi_x2, roi_y2).
|
||
"""
|
||
oriW, oriH = ori_img_size
|
||
roi_w, roi_h = roi_size
|
||
|
||
_, vanish_y = compute_vanishing_point(calib)
|
||
crop_center_x = oriW // 2
|
||
crop_center_y = vanish_y
|
||
|
||
roi_x1 = int(crop_center_x - roi_w / 2.0)
|
||
roi_y1 = int(crop_center_y - roi_h / 2.0)
|
||
roi_x2 = roi_x1 + roi_w
|
||
roi_y2 = roi_y1 + roi_h - roi_bottom_offset
|
||
|
||
return roi_x1, roi_y1, roi_x2, roi_y2
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Per-file worker (for multiprocessing)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _load_calib(path: str) -> dict | None:
|
||
"""Load a single calibration JSON; return None on any error."""
|
||
if not os.path.exists(path):
|
||
return None
|
||
try:
|
||
with open(path, "r") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _process_calib_path(args):
|
||
"""Worker: load one calib file and return a flat record dict (or None)."""
|
||
calib_path, ori_img_size, roi_size, roi_bottom_offset = args
|
||
calib = _load_calib(calib_path)
|
||
if calib is None:
|
||
return None
|
||
|
||
record = {
|
||
"path": calib_path,
|
||
"focal_u": calib.get("focal_u"),
|
||
"focal_v": calib.get("focal_v"),
|
||
"cu": calib.get("cu"),
|
||
"cv": calib.get("cv"),
|
||
"pitch": calib.get("pitch"),
|
||
"yaw": calib.get("yaw", 0.0),
|
||
"roll": calib.get("roll"),
|
||
"fov": calib.get("fov"),
|
||
"pos_x": calib.get("pos", [None, None, None])[0] if calib.get("pos") else None,
|
||
"pos_y": calib.get("pos", [None, None, None])[1] if calib.get("pos") else None,
|
||
"pos_z": calib.get("pos", [None, None, None])[2] if calib.get("pos") else None,
|
||
"distort_coeffs": calib.get("distort_coeffs", []),
|
||
"is_valid": calib.get("is_valid", None),
|
||
"reprojection_error": calib.get("reprojection_error", None),
|
||
}
|
||
|
||
# Vanishing point
|
||
try:
|
||
vx, vy = compute_vanishing_point(calib)
|
||
record["vanish_x"] = vx
|
||
record["vanish_y"] = vy
|
||
except Exception:
|
||
record["vanish_x"] = None
|
||
record["vanish_y"] = None
|
||
|
||
# ROI bounds (if config supplied)
|
||
if roi_size is not None and ori_img_size is not None:
|
||
try:
|
||
rx1, ry1, rx2, ry2 = compute_roi_bounds(
|
||
calib, ori_img_size, roi_size, roi_bottom_offset)
|
||
record["roi_x1"] = rx1
|
||
record["roi_y1"] = ry1
|
||
record["roi_x2"] = rx2
|
||
record["roi_y2"] = ry2
|
||
except Exception:
|
||
pass
|
||
|
||
return record
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Plotting helpers
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _hist(ax, data, title, xlabel, color="steelblue", bins=50, vlines=None):
|
||
"""Draw a clean histogram with median and mean lines."""
|
||
if len(data) == 0:
|
||
ax.set_title(f"{title}\n(no data)")
|
||
return
|
||
ax.hist(data, bins=bins, color=color, edgecolor="none", alpha=0.8)
|
||
med = float(np.median(data))
|
||
mn = float(np.mean(data))
|
||
ax.axvline(med, color="red", linestyle="--", linewidth=1.2, label=f"median={med:.2f}")
|
||
ax.axvline(mn, color="orange", linestyle=":", linewidth=1.2, label=f"mean={mn:.2f}")
|
||
if vlines:
|
||
for v, lbl, lc in vlines:
|
||
ax.axvline(v, color=lc, linestyle="-.", linewidth=1.0, label=lbl)
|
||
ax.set_title(f"{title} (n={len(data):,})", fontsize=9)
|
||
ax.set_xlabel(xlabel, fontsize=8)
|
||
ax.set_ylabel("count", fontsize=8)
|
||
ax.legend(fontsize=7)
|
||
ax.tick_params(labelsize=7)
|
||
|
||
|
||
def _scatter(ax, x, y, title, xlabel, ylabel, color="steelblue", alpha=0.3, s=4):
|
||
"""Draw a scatter plot."""
|
||
if len(x) == 0:
|
||
ax.set_title(title + "\n(no data)")
|
||
return
|
||
x = np.array(x)
|
||
y = np.array(y)
|
||
ax.scatter(x, y, c=color, s=s, alpha=alpha, edgecolors="none")
|
||
ax.set_title(title, fontsize=9)
|
||
ax.set_xlabel(xlabel, fontsize=8)
|
||
ax.set_ylabel(ylabel, fontsize=8)
|
||
ax.tick_params(labelsize=7)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Main profiling class
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
class CalibProfiler:
|
||
"""Collect and visualise calibration statistics across all unique sequences."""
|
||
|
||
def __init__(self, ori_img_size, roi_size, roi_bottom_offset=0):
|
||
self.ori_img_size = ori_img_size # (W, H)
|
||
self.roi_size = roi_size # (roi_w, roi_h) or None
|
||
self.roi_bottom_offset = roi_bottom_offset
|
||
|
||
# raw records — one dict per unique calib file
|
||
self.records: list[dict] = []
|
||
self.n_missing = 0
|
||
self.n_invalid = 0 # is_valid == False
|
||
|
||
# ── Data ingestion ────────────────────────────────────────────────────────
|
||
|
||
def process_batch(self, calib_paths: list, workers: int = 1):
|
||
"""Load all calibration files (parallel or sequential) and accumulate records."""
|
||
args = [
|
||
(cp, self.ori_img_size, self.roi_size, self.roi_bottom_offset)
|
||
for cp in calib_paths
|
||
]
|
||
|
||
if workers == 1:
|
||
results = [_process_calib_path(a) for a in args]
|
||
else:
|
||
with Pool(processes=workers) as pool:
|
||
results = pool.map(_process_calib_path, args)
|
||
|
||
for rec in results:
|
||
if rec is None:
|
||
self.n_missing += 1
|
||
else:
|
||
if rec.get("is_valid") is False:
|
||
self.n_invalid += 1
|
||
self.records.append(rec)
|
||
|
||
# ── Field extraction helpers ──────────────────────────────────────────────
|
||
|
||
def _field(self, key: str) -> np.ndarray:
|
||
"""Extract a numeric field across all records, dropping None/NaN."""
|
||
vals = [r[key] for r in self.records if r.get(key) is not None]
|
||
arr = np.array(vals, dtype=float)
|
||
return arr[~np.isnan(arr)]
|
||
|
||
def _distort_k(self, idx: int) -> np.ndarray:
|
||
"""Extract distortion coefficient k_{idx} (0-based)."""
|
||
vals = []
|
||
for r in self.records:
|
||
dc = r.get("distort_coeffs", [])
|
||
if dc and len(dc) > idx:
|
||
vals.append(dc[idx])
|
||
return np.array(vals, dtype=float)
|
||
|
||
# ── Summary statistics ────────────────────────────────────────────────────
|
||
|
||
def summarize(self) -> dict:
|
||
n = len(self.records)
|
||
s = {
|
||
"n_unique_calib_files": n,
|
||
"n_missing_calib_files": self.n_missing,
|
||
"n_invalid_calib_files": self.n_invalid,
|
||
}
|
||
|
||
for key in ("focal_u", "focal_v", "cu", "cv",
|
||
"pitch", "yaw", "roll", "fov",
|
||
"pos_x", "pos_y", "pos_z",
|
||
"vanish_x", "vanish_y",
|
||
"roi_x1", "roi_y1", "roi_x2", "roi_y2",
|
||
"reprojection_error"):
|
||
arr = self._field(key)
|
||
if len(arr):
|
||
s[key] = _dist_stats(arr)
|
||
|
||
for idx, name in enumerate(["k1", "k2", "k3", "k4"]):
|
||
arr = self._distort_k(idx)
|
||
if len(arr):
|
||
s[f"distort_{name}"] = _dist_stats(arr)
|
||
|
||
return s
|
||
|
||
# ── Plotting ──────────────────────────────────────────────────────────────
|
||
|
||
def plot_intrinsics(self, output_path: str):
|
||
"""Figure 1: Focal lengths & principal point distributions."""
|
||
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
|
||
fig.suptitle("Camera Intrinsics Distribution", fontsize=12, fontweight="bold")
|
||
|
||
pairs = [
|
||
(axes[0, 0], "focal_u", "focal_u (pixel)", "royalblue"),
|
||
(axes[0, 1], "focal_v", "focal_v (pixel)", "steelblue"),
|
||
(axes[1, 0], "cu", "cu — principal pt X (pixel)", "darkorange"),
|
||
(axes[1, 1], "cv", "cv — principal pt Y (pixel)", "chocolate"),
|
||
]
|
||
for ax, key, xlabel, color in pairs:
|
||
_hist(ax, self._field(key), title=key, xlabel=xlabel, color=color)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
def plot_angles(self, output_path: str):
|
||
"""Figure 2: Camera installation angles (pitch, yaw, roll)."""
|
||
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
||
fig.suptitle("Camera Installation Angles (degrees)", fontsize=12, fontweight="bold")
|
||
|
||
combos = [
|
||
(axes[0], "pitch", "pitch (°)", "mediumseagreen"),
|
||
(axes[1], "yaw", "yaw (°)", "slateblue"),
|
||
(axes[2], "roll", "roll (°)", "tomato"),
|
||
]
|
||
for ax, key, xlabel, color in combos:
|
||
_hist(ax, self._field(key), title=key, xlabel=xlabel, color=color, bins=40)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
def plot_distortion(self, output_path: str):
|
||
"""Figure 3: Fisheye distortion coefficient distributions."""
|
||
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
|
||
fig.suptitle("Fisheye Distortion Coefficients (k1–k4)", fontsize=12, fontweight="bold")
|
||
|
||
colors = ["indianred", "sandybrown", "mediumaquamarine", "cornflowerblue"]
|
||
for i, (ax, name, color) in enumerate(
|
||
zip(axes.flat, ["k1", "k2", "k3", "k4"], colors)):
|
||
_hist(ax, self._distort_k(i),
|
||
title=f"distort_coeffs[{i}] = {name}",
|
||
xlabel=name, color=color)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
def plot_vanishing_point(self, output_path: str, ori_img_size: tuple):
|
||
"""Figure 4: Vanishing-point distribution — histograms + 2-D scatter."""
|
||
vx = self._field("vanish_x")
|
||
vy = self._field("vanish_y")
|
||
|
||
oriW, oriH = ori_img_size
|
||
|
||
fig = plt.figure(figsize=(14, 10))
|
||
fig.suptitle("Vanishing Point Distribution\n"
|
||
r"vanish_x = cx + fx·tan(yaw·π/180), "
|
||
r"vanish_y = cy − fy·tan(pitch·π/180)",
|
||
fontsize=11, fontweight="bold")
|
||
|
||
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
|
||
|
||
# Histogram vanish_x
|
||
ax_hx = fig.add_subplot(gs[0, 0])
|
||
_hist(ax_hx, vx, "Vanishing Point X", "vanish_x (pixel)", color="royalblue",
|
||
vlines=[(oriW / 2, f"img_center_x={oriW/2:.0f}", "gray")])
|
||
|
||
# Histogram vanish_y
|
||
ax_hy = fig.add_subplot(gs[0, 1])
|
||
_hist(ax_hy, vy, "Vanishing Point Y", "vanish_y (pixel)", color="darkorange",
|
||
vlines=[(oriH / 2, f"img_center_y={oriH/2:.0f}", "gray")])
|
||
|
||
# 2-D scatter
|
||
ax_sc = fig.add_subplot(gs[1, :])
|
||
if len(vx) and len(vy):
|
||
# density-coloured scatter
|
||
from matplotlib.colors import Normalize
|
||
from matplotlib.cm import ScalarMappable
|
||
|
||
# Use 2D histogram to compute point density for colouring
|
||
hh, xe, ye = np.histogram2d(vx, vy, bins=80)
|
||
xi = np.clip(np.searchsorted(xe[:-1], vx) - 1, 0, hh.shape[0] - 1)
|
||
yi = np.clip(np.searchsorted(ye[:-1], vy) - 1, 0, hh.shape[1] - 1)
|
||
density = hh[xi, yi]
|
||
|
||
sc = ax_sc.scatter(vx, vy, c=density, s=6, alpha=0.6,
|
||
cmap="turbo", norm=Normalize(vmin=0, vmax=np.percentile(density, 98)))
|
||
plt.colorbar(sc, ax=ax_sc, label="density")
|
||
|
||
# Image boundary reference
|
||
ax_sc.axvline(0, color="black", linewidth=0.5, linestyle=":")
|
||
ax_sc.axvline(oriW, color="black", linewidth=0.5, linestyle=":")
|
||
ax_sc.axhline(0, color="black", linewidth=0.5, linestyle=":")
|
||
ax_sc.axhline(oriH, color="black", linewidth=0.5, linestyle=":")
|
||
ax_sc.axvline(oriW / 2, color="gray", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_x={oriW/2:.0f}")
|
||
ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_y={oriH/2:.0f}")
|
||
ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
|
||
ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05) # y-axis: image coords (top=0)
|
||
|
||
med_vx = np.median(vx)
|
||
med_vy = np.median(vy)
|
||
ax_sc.axvline(med_vx, color="cyan", linewidth=1.0, linestyle="-.",
|
||
label=f"median_x={med_vx:.1f}")
|
||
ax_sc.axhline(med_vy, color="yellow", linewidth=1.0, linestyle="-.",
|
||
label=f"median_y={med_vy:.1f}")
|
||
|
||
ax_sc.set_title("Vanishing Point Scatter (image coordinates, n={:,})".format(len(vx)),
|
||
fontsize=9)
|
||
ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
|
||
ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
|
||
ax_sc.legend(fontsize=7, loc="upper right")
|
||
ax_sc.tick_params(labelsize=7)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
def plot_roi_bounds(self, output_path: str, ori_img_size: tuple):
|
||
"""Figure 5: ROI crop bound statistics (when roi_size is configured)."""
|
||
rx1 = self._field("roi_x1")
|
||
ry1 = self._field("roi_y1")
|
||
rx2 = self._field("roi_x2")
|
||
ry2 = self._field("roi_y2")
|
||
|
||
if not len(ry1):
|
||
print(" (no ROI bounds to plot — skipping figure 5)")
|
||
return
|
||
|
||
oriW, oriH = ori_img_size
|
||
|
||
fig, axes = plt.subplots(2, 3, figsize=(16, 8))
|
||
fig.suptitle("ROI Crop Bounds Distribution", fontsize=12, fontweight="bold")
|
||
|
||
_hist(axes[0, 0], rx1, "roi_x1", "pixel", color="royalblue")
|
||
_hist(axes[0, 1], ry1, "roi_y1", "pixel", color="tomato",
|
||
vlines=[(0, "y=0", "gray"), (oriH // 2, f"img_mid_y={oriH//2}", "silver")])
|
||
_hist(axes[0, 2], rx2, "roi_x2", "pixel", color="steelblue")
|
||
|
||
# ry2 (effective bottom of crop after roi_bottom_offset)
|
||
_hist(axes[1, 0], ry2, "roi_y2 (after bottom_offset)", "pixel", color="chocolate",
|
||
vlines=[(oriH, f"oriH={oriH}", "gray")])
|
||
|
||
# roi_height = ry2 - ry1
|
||
roi_h_arr = ry2 - ry1
|
||
_hist(axes[1, 1], roi_h_arr, "roi_height = roi_y2 − roi_y1", "pixel",
|
||
color="mediumseagreen")
|
||
|
||
# 2-D: roi_y1 vs roi_y2
|
||
ax = axes[1, 2]
|
||
if len(ry1) and len(ry2):
|
||
ax.scatter(ry1, ry2, s=4, alpha=0.4, color="slateblue", edgecolors="none")
|
||
ax.set_title("roi_y1 vs roi_y2", fontsize=9)
|
||
ax.set_xlabel("roi_y1", fontsize=8)
|
||
ax.set_ylabel("roi_y2", fontsize=8)
|
||
ax.tick_params(labelsize=7)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
def plot_misc(self, output_path: str):
|
||
"""Figure 6: FOV, camera position, reprojection error."""
|
||
fig, axes = plt.subplots(2, 3, figsize=(16, 8))
|
||
fig.suptitle("Miscellaneous Calibration Parameters", fontsize=12, fontweight="bold")
|
||
|
||
_hist(axes[0, 0], self._field("fov"), "Field of View", "fov (°)", color="darkorchid")
|
||
_hist(axes[0, 1], self._field("pos_x"), "Camera Position X", "pos_x (m)", color="royalblue")
|
||
_hist(axes[0, 2], self._field("pos_y"), "Camera Position Y", "pos_y (m)", color="steelblue")
|
||
_hist(axes[1, 0], self._field("pos_z"), "Camera Position Z", "pos_z (m)", color="mediumseagreen")
|
||
_hist(axes[1, 1], self._field("reprojection_error"),
|
||
"Reprojection Error", "error (pixel)", color="tomato")
|
||
|
||
# Pitch vs vanish_y (sanity check)
|
||
ax = axes[1, 2]
|
||
pitch_arr = self._field("pitch")
|
||
vy_arr = self._field("vanish_y")
|
||
if len(pitch_arr) and len(vy_arr) and len(pitch_arr) == len(vy_arr):
|
||
ax.scatter(pitch_arr, vy_arr, s=4, alpha=0.4,
|
||
color="darkorange", edgecolors="none")
|
||
ax.set_title("pitch vs vanish_y (sanity check)", fontsize=9)
|
||
ax.set_xlabel("pitch (°)", fontsize=8)
|
||
ax.set_ylabel("vanish_y (pixel)", fontsize=8)
|
||
ax.tick_params(labelsize=7)
|
||
|
||
plt.tight_layout()
|
||
fig.savefig(output_path, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
# ── Text report ───────────────────────────────────────────────────────────
|
||
|
||
def write_report(self, output_path: str, summary: dict):
|
||
"""Write a human-readable text report."""
|
||
lines = []
|
||
sep = "=" * 72
|
||
|
||
lines.append(sep)
|
||
lines.append("CALIBRATION PARAMETER PROFILING REPORT")
|
||
lines.append(sep)
|
||
lines.append(f" Unique calib files loaded : {summary['n_unique_calib_files']:,}")
|
||
lines.append(f" Missing calib files : {summary['n_missing_calib_files']:,}")
|
||
lines.append(f" Invalid calib files : {summary['n_invalid_calib_files']:,}")
|
||
lines.append("")
|
||
|
||
groups = [
|
||
("── Camera Intrinsics ──", ["focal_u", "focal_v", "cu", "cv"]),
|
||
("── Camera Angles (degrees) ──", ["pitch", "yaw", "roll"]),
|
||
("── Fisheye Distortion Coefficients ──",
|
||
["distort_k1", "distort_k2", "distort_k3", "distort_k4"]),
|
||
("── Field of View ──", ["fov"]),
|
||
("── Camera Position ──", ["pos_x", "pos_y", "pos_z"]),
|
||
("── Reprojection Error ──", ["reprojection_error"]),
|
||
("── Vanishing Point (pixels) ──", ["vanish_x", "vanish_y"]),
|
||
("── ROI Crop Bounds (pixels) ──", ["roi_x1", "roi_y1", "roi_x2", "roi_y2"]),
|
||
]
|
||
|
||
for header, keys in groups:
|
||
lines.append(header)
|
||
for k in keys:
|
||
if k in summary:
|
||
lines.append(f" {k:30s}: {_fmt(summary[k])}")
|
||
lines.append("")
|
||
|
||
with open(output_path, "w") as f:
|
||
f.write("\n".join(lines))
|
||
print(f" Report → {output_path}")
|
||
|
||
# ── Per-sequence CSV table ──────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _extract_vehicle_date(calib_path: str) -> str:
|
||
"""Extract a concise label from a calibration file path.
|
||
|
||
Handles three path conventions:
|
||
|
||
1. Training data (.../driving_png[_YYYYMMDD]/<vehicle_id>/<date>/...):
|
||
Returns 'vehicle_id/date', e.g. 'G1M3_FDL2232/20251201'.
|
||
|
||
2. Test cases (.../cases_coding/<case_id>/<clip>/... or
|
||
.../cases_feishu/<folder>/<clip>/...):
|
||
Returns 'case_id/clip_name' (clip truncated at first '/').
|
||
|
||
3. Fallback: look for 'calib' or 'calibs' directory and step back
|
||
three levels to get a meaningful parent/grandparent pair.
|
||
"""
|
||
parts = calib_path.replace("\\", "/").split("/")
|
||
# 1. Training data — segment starts with 'driving_png'
|
||
for i, seg in enumerate(parts):
|
||
if seg.startswith("driving_png"):
|
||
if i + 2 < len(parts):
|
||
return f"{parts[i + 1]}/{parts[i + 2]}"
|
||
break
|
||
# 2. Test-case directories — segment is 'cases_coding' or 'cases_feishu'
|
||
for i, seg in enumerate(parts):
|
||
if seg in ("cases_coding", "cases_feishu"):
|
||
case_id = parts[i + 1] if i + 1 < len(parts) else seg
|
||
clip_raw = parts[i + 2] if i + 2 < len(parts) else ""
|
||
# Truncate very long clip names to keep labels readable
|
||
clip = clip_raw[:60] if clip_raw else ""
|
||
return f"{case_id}/{clip}" if clip else case_id
|
||
# 3. Fallback: find 'calib' or 'calibs' dir and step back 3 levels
|
||
for i, seg in enumerate(parts):
|
||
if seg in ("calib", "calibs") and i >= 3:
|
||
return f"{parts[i - 3]}/{parts[i - 2]}"
|
||
return calib_path
|
||
|
||
def write_csv_table(self, output_path: str):
|
||
"""Export deduplicated calibration values as a CSV table.
|
||
|
||
Rows are deduplicated by unique calibration parameter values.
|
||
The first column shows 'vehicle_id/date' (e.g. 'G1M3_FDL2232/20251201').
|
||
When multiple vehicle/date sequences share identical parameters, their
|
||
labels are merged into a single cell separated by ' | '.
|
||
"""
|
||
import csv
|
||
|
||
FIELDS = [
|
||
"focal_u", "focal_v", "cu", "cv",
|
||
"pitch", "yaw", "roll", "fov",
|
||
"pos_x", "pos_y", "pos_z",
|
||
"vanish_x", "vanish_y",
|
||
"roi_x1", "roi_y1", "roi_x2", "roi_y2",
|
||
"reprojection_error", "is_valid",
|
||
]
|
||
header = ["vehicle/date"] + FIELDS + [f"distort_k{i+1}" for i in range(4)]
|
||
|
||
def _rec_key(rec):
|
||
"""Tuple key for deduplication: all numeric fields rounded to 6 dp."""
|
||
parts = []
|
||
for key in FIELDS:
|
||
v = rec.get(key)
|
||
if isinstance(v, float):
|
||
parts.append(round(v, 6))
|
||
else:
|
||
parts.append(v)
|
||
dc = rec.get("distort_coeffs", [])
|
||
for i in range(4):
|
||
v = dc[i] if dc and len(dc) > i else None
|
||
parts.append(round(v, 6) if isinstance(v, float) else v)
|
||
return tuple(parts)
|
||
|
||
# Group by vehicle_id → calib_key → (rec, [dates])
|
||
# Preserves per-vehicle insertion order via regular dicts (Python 3.7+)
|
||
vehicle_groups: dict = {} # vehicle_id -> {rec_key -> (rec, [date, ...])}
|
||
for rec in self.records:
|
||
vd = self._extract_vehicle_date(rec.get("path", ""))
|
||
parts = vd.split("/", 1)
|
||
vehicle_id = parts[0]
|
||
date = parts[1] if len(parts) > 1 else ""
|
||
k = _rec_key(rec)
|
||
if vehicle_id not in vehicle_groups:
|
||
vehicle_groups[vehicle_id] = {}
|
||
if k not in vehicle_groups[vehicle_id]:
|
||
vehicle_groups[vehicle_id][k] = (rec, [date])
|
||
else:
|
||
if date not in vehicle_groups[vehicle_id][k][1]:
|
||
vehicle_groups[vehicle_id][k][1].append(date)
|
||
|
||
# Stage 1: build (label, rec, rec_key) per vehicle group.
|
||
# - vehicle has 1 unique calibration → label = vehicle_id
|
||
# - vehicle has N>1 unique calibrations → label = vehicle_id/first_date
|
||
stage1 = [] # list of (label, rec, rec_key)
|
||
for vehicle_id, calib_map in vehicle_groups.items():
|
||
only_one = len(calib_map) == 1
|
||
for k, (rec, dates) in calib_map.items():
|
||
if only_one:
|
||
label = vehicle_id
|
||
else:
|
||
first_date = sorted(dates)[0]
|
||
label = f"{vehicle_id}/{first_date}"
|
||
stage1.append((label, rec, k))
|
||
|
||
# Stage 2: global dedup across vehicle groups — collapse rows that share
|
||
# the same rec_key (identical calibration parameters) by merging labels.
|
||
seen_key_idx: dict = {} # rec_key -> index in rows_to_write
|
||
rows_to_write: list = [] # list of [label, rec] (label is mutable str)
|
||
all_labels_per_row: list = [] # list of [label1, label2, ...] for merging
|
||
for label, rec, k in stage1:
|
||
if k not in seen_key_idx:
|
||
seen_key_idx[k] = len(rows_to_write)
|
||
rows_to_write.append([label, rec])
|
||
all_labels_per_row.append([label])
|
||
else:
|
||
idx = seen_key_idx[k]
|
||
if label not in all_labels_per_row[idx]:
|
||
all_labels_per_row[idx].append(label)
|
||
|
||
# Format merged labels: show up to MAX_SHOWN; summarise the rest as "(+N more)"
|
||
MAX_SHOWN = 3
|
||
for i, labels in enumerate(all_labels_per_row):
|
||
if len(labels) <= MAX_SHOWN:
|
||
rows_to_write[i][0] = " | ".join(labels)
|
||
else:
|
||
shown = " | ".join(labels[:MAX_SHOWN])
|
||
rows_to_write[i][0] = f"{shown} | (+{len(labels) - MAX_SHOWN} more)"
|
||
|
||
n_unique_total = len(rows_to_write)
|
||
|
||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow(header)
|
||
for label, rec in rows_to_write:
|
||
row = [label]
|
||
for key in FIELDS:
|
||
val = rec.get(key)
|
||
row.append("" if val is None else val)
|
||
dc = rec.get("distort_coeffs", [])
|
||
for i in range(4):
|
||
row.append(dc[i] if dc and len(dc) > i else "")
|
||
writer.writerow(row)
|
||
|
||
print(f" CSV → {output_path} ({n_unique_total:,} unique calibrations)")
|
||
|
||
# ── Statistics table figure ─────────────────────────────────────────────────
|
||
|
||
def plot_stats_table(self, output_path: str):
|
||
"""Figure 7: All calibration parameters in a color-coded statistics table.
|
||
|
||
Rows = parameters (focal lengths, principal point, angles, distortion,
|
||
vanishing point, ROI bounds, misc).
|
||
Columns = n | min | p5 | median | mean | p95 | max | std | CV (%).
|
||
The CV(%) column is color-coded (YlOrRd) to highlight variability across
|
||
sequences — red cells indicate parameters that vary a lot.
|
||
"""
|
||
import matplotlib.cm as cm
|
||
from matplotlib.colors import Normalize
|
||
|
||
SCALAR_PARAMS = [
|
||
("focal_u", "focal_u (px)"),
|
||
("focal_v", "focal_v (px)"),
|
||
("cu", "cu (px)"),
|
||
("cv", "cv (px)"),
|
||
("pitch", "pitch (°)"),
|
||
("yaw", "yaw (°)"),
|
||
("roll", "roll (°)"),
|
||
("vanish_x", "vanish_x (px)"),
|
||
("vanish_y", "vanish_y (px)"),
|
||
("fov", "fov (°)"),
|
||
("pos_x", "pos_x (m)"),
|
||
("pos_y", "pos_y (m)"),
|
||
("pos_z", "pos_z (m)"),
|
||
("reprojection_error", "reproj_err (px)"),
|
||
("roi_x1", "roi_x1 (px)"),
|
||
("roi_y1", "roi_y1 (px)"),
|
||
("roi_x2", "roi_x2 (px)"),
|
||
("roi_y2", "roi_y2 (px)"),
|
||
]
|
||
DISTORT_PARAMS = [(i, f"distort_k{i+1}") for i in range(4)]
|
||
|
||
COL_HEADERS = [
|
||
"Parameter", "n", "min", "p5", "median", "mean", "p95", "max", "std", "CV (%)"
|
||
]
|
||
|
||
def _row_from_arr(label, arr):
|
||
if len(arr) == 0:
|
||
return None, None
|
||
s = _dist_stats(arr)
|
||
cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
|
||
return [
|
||
label,
|
||
f"{s['count']:,}",
|
||
f"{s['min']:.3f}",
|
||
f"{s['p5']:.3f}",
|
||
f"{s['median']:.3f}",
|
||
f"{s['mean']:.3f}",
|
||
f"{s['p95']:.3f}",
|
||
f"{s['max']:.3f}",
|
||
f"{s['std']:.4f}",
|
||
f"{cv:.2f}",
|
||
], cv
|
||
|
||
rows, cv_vals = [], []
|
||
for key, label in SCALAR_PARAMS:
|
||
row, cv = _row_from_arr(label, self._field(key))
|
||
if row:
|
||
rows.append(row)
|
||
cv_vals.append(cv)
|
||
for idx, name in DISTORT_PARAMS:
|
||
row, cv = _row_from_arr(name, self._distort_k(idx))
|
||
if row:
|
||
rows.append(row)
|
||
cv_vals.append(cv)
|
||
|
||
if not rows:
|
||
print(" (stats table: no data)")
|
||
return
|
||
|
||
n_rows = len(rows)
|
||
fig_h = max(5, 0.42 * n_rows + 1.5)
|
||
fig, ax = plt.subplots(figsize=(22, fig_h))
|
||
ax.axis("off")
|
||
fig.suptitle(
|
||
f"Calibration Parameter Statistics (n={len(self.records):,} unique sequences)",
|
||
fontsize=12, fontweight="bold", y=0.99,
|
||
)
|
||
|
||
tbl = ax.table(cellText=rows, colLabels=COL_HEADERS, loc="center", cellLoc="center")
|
||
tbl.auto_set_font_size(False)
|
||
tbl.set_fontsize(8.5)
|
||
tbl.auto_set_column_width(col=list(range(len(COL_HEADERS))))
|
||
|
||
HDR_COLOR = "#2c3e50"
|
||
for j in range(len(COL_HEADERS)):
|
||
cell = tbl[0, j]
|
||
cell.set_facecolor(HDR_COLOR)
|
||
cell.set_text_props(color="white", fontweight="bold")
|
||
|
||
cv_arr = np.array(cv_vals, dtype=float)
|
||
p95_cv = np.percentile(cv_arr, 95) if cv_arr.max() > 0 else 1.0
|
||
norm_cv = Normalize(vmin=0, vmax=max(p95_cv, 1e-6))
|
||
cmap_cv = cm.get_cmap("YlOrRd")
|
||
|
||
ALT_COLORS = ["#f0f4f8", "#ffffff"]
|
||
for i, (row, cv) in enumerate(zip(rows, cv_vals)):
|
||
bg = ALT_COLORS[i % 2]
|
||
for j in range(len(COL_HEADERS)):
|
||
tbl[i + 1, j].set_facecolor(bg)
|
||
tbl[i + 1, 0].set_text_props(fontweight="bold")
|
||
# CV column: color by variability
|
||
tbl[i + 1, len(COL_HEADERS) - 1].set_facecolor(cmap_cv(norm_cv(cv)))
|
||
|
||
fig.savefig(output_path, dpi=130, bbox_inches="tight")
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Cross-split comparison histograms & table (module-level)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Consistent color palette for multi-split overlays (index 0 = train, 1 = cases, …)
|
||
_COMPARE_PALETTE = [
|
||
"#2196F3", # blue — train
|
||
"#FF5722", # deep-orange — cases
|
||
"#4CAF50", # green
|
||
"#9C27B0", # purple
|
||
"#FF9800", # orange
|
||
"#00BCD4", # cyan
|
||
]
|
||
|
||
# Lighter tints (for table row background per-group)
|
||
_COMPARE_TINTS = [
|
||
"#E3F2FD", # blue tint
|
||
"#FBE9E7", # orange tint
|
||
"#E8F5E9", # green tint
|
||
"#F3E5F5", # purple tint
|
||
"#FFF8E1", # amber tint
|
||
"#E0F7FA", # cyan tint
|
||
]
|
||
|
||
|
||
def _hist_multi(ax, datasets, title, xlabel, bins=50):
|
||
"""Overlay semi-transparent histograms + median lines for multiple datasets.
|
||
|
||
Args:
|
||
datasets: list of (label, np.ndarray, color) tuples.
|
||
"""
|
||
if not datasets:
|
||
ax.set_title(f"{title}\n(no data)")
|
||
return
|
||
any_data = False
|
||
for label, arr, color in datasets:
|
||
if len(arr) == 0:
|
||
continue
|
||
any_data = True
|
||
ax.hist(arr, bins=bins, color=color, alpha=0.45, edgecolor="none",
|
||
label=f"{label} (n={len(arr):,})")
|
||
med = float(np.median(arr))
|
||
ax.axvline(med, color=color, linestyle="--", linewidth=1.5,
|
||
label=f"{label} median={med:.2f}")
|
||
if not any_data:
|
||
ax.set_title(f"{title}\n(no data)")
|
||
return
|
||
ax.set_title(title, fontsize=9)
|
||
ax.set_xlabel(xlabel, fontsize=8)
|
||
ax.set_ylabel("count", fontsize=8)
|
||
ax.legend(fontsize=7)
|
||
ax.tick_params(labelsize=7)
|
||
|
||
|
||
def plot_comparison_histograms(profilers: dict, out_dir):
|
||
"""Overlay calibration parameter histograms for multiple splits/groups.
|
||
|
||
Generates four comparison figures:
|
||
comparison_01_intrinsics.png
|
||
comparison_02_angles.png
|
||
comparison_03_distortion.png
|
||
comparison_04_vanishing_point.png
|
||
|
||
Args:
|
||
profilers: Ordered dict of {split_name: CalibProfiler}.
|
||
out_dir: pathlib.Path for the output directory.
|
||
"""
|
||
out_dir = Path(out_dir)
|
||
names = list(profilers.keys())
|
||
colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]
|
||
|
||
def _ds(key=None, distort_idx=None):
|
||
"""Build datasets list for _hist_multi."""
|
||
result = []
|
||
for name, color in zip(names, colors):
|
||
p = profilers[name]
|
||
arr = p._distort_k(distort_idx) if distort_idx is not None else p._field(key)
|
||
result.append((name, arr, color))
|
||
return result
|
||
|
||
title_suffix = " vs ".join(names)
|
||
|
||
# ── Figure 1: Intrinsics ──────────────────────────────────────────────────
|
||
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
|
||
fig.suptitle(f"Camera Intrinsics — {title_suffix}", fontsize=12, fontweight="bold")
|
||
_hist_multi(axes[0, 0], _ds("focal_u"), "focal_u", "pixel (px)")
|
||
_hist_multi(axes[0, 1], _ds("focal_v"), "focal_v", "pixel (px)")
|
||
_hist_multi(axes[1, 0], _ds("cu"), "cu — principal pt X", "pixel (px)")
|
||
_hist_multi(axes[1, 1], _ds("cv"), "cv — principal pt Y", "pixel (px)")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_01_intrinsics.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 2: Angles ─────────────────────────────────────────────────────
|
||
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
|
||
fig.suptitle(f"Camera Angles — {title_suffix}", fontsize=12, fontweight="bold")
|
||
_hist_multi(axes[0], _ds("pitch"), "pitch", "degrees (°)")
|
||
_hist_multi(axes[1], _ds("yaw"), "yaw", "degrees (°)")
|
||
_hist_multi(axes[2], _ds("roll"), "roll", "degrees (°)")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_02_angles.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 3: Distortion coefficients ────────────────────────────────────
|
||
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
|
||
fig.suptitle(f"Fisheye Distortion Coefficients — {title_suffix}",
|
||
fontsize=12, fontweight="bold")
|
||
for i, ax in enumerate(axes.flat):
|
||
_hist_multi(ax, _ds(distort_idx=i), f"distort_k{i+1}", f"k{i+1}")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_03_distortion.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 4: Vanishing point ─────────────────────────────────────────────
|
||
fig = plt.figure(figsize=(16, 11))
|
||
fig.suptitle(f"Vanishing Point — {title_suffix}",
|
||
fontsize=12, fontweight="bold")
|
||
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
|
||
ax_hx = fig.add_subplot(gs[0, 0])
|
||
ax_hy = fig.add_subplot(gs[0, 1])
|
||
ax_sc = fig.add_subplot(gs[1, :])
|
||
|
||
_hist_multi(ax_hx, _ds("vanish_x"), "Vanishing Point X", "vanish_x (pixel)")
|
||
_hist_multi(ax_hy, _ds("vanish_y"), "Vanishing Point Y", "vanish_y (pixel)")
|
||
|
||
# 2-D scatter: one color per split
|
||
first_p = next(iter(profilers.values()))
|
||
oriW, oriH = first_p.ori_img_size
|
||
for name, color in zip(names, colors):
|
||
p = profilers[name]
|
||
vx = p._field("vanish_x")
|
||
vy = p._field("vanish_y")
|
||
if len(vx) == 0:
|
||
continue
|
||
ax_sc.scatter(vx, vy, c=color, s=14, alpha=0.55,
|
||
edgecolors="none", label=f"{name} (n={len(vx):,})")
|
||
med_vx, med_vy = float(np.median(vx)), float(np.median(vy))
|
||
ax_sc.scatter([med_vx], [med_vy], c=color, s=120, marker="*",
|
||
edgecolors="black", linewidths=0.7, zorder=6,
|
||
label=f"{name} median ({med_vx:.0f}, {med_vy:.0f})")
|
||
|
||
ax_sc.axvline(oriW / 2, color="gray", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_x={oriW//2}")
|
||
ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_y={oriH//2}")
|
||
ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
|
||
ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05) # image coords: top=0
|
||
ax_sc.set_title("Vanishing Point Scatter (image coordinates)", fontsize=9)
|
||
ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
|
||
ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
|
||
ax_sc.legend(fontsize=7, loc="upper right")
|
||
ax_sc.tick_params(labelsize=7)
|
||
|
||
out = str(out_dir / "comparison_04_vanishing_point.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
|
||
def plot_comparison_stats_table(profilers: dict, output_path: str):
|
||
"""Combined statistics table showing all profiler groups side-by-side.
|
||
|
||
Rows are grouped by parameter. Each group of rows shows one profiler's
|
||
stats, with a colored background tint per profiler so they are visually
|
||
distinct. A thin separator row is inserted between parameters.
|
||
|
||
Columns: Parameter | split | n | min | p5 | median | mean | p95 | max | std | CV(%)
|
||
"""
|
||
import matplotlib.cm as cm
|
||
from matplotlib.colors import Normalize
|
||
|
||
SCALAR_PARAMS = [
|
||
("focal_u", "focal_u (px)"),
|
||
("focal_v", "focal_v (px)"),
|
||
("cu", "cu (px)"),
|
||
("cv", "cv (px)"),
|
||
("pitch", "pitch (°)"),
|
||
("yaw", "yaw (°)"),
|
||
("roll", "roll (°)"),
|
||
("vanish_x", "vanish_x (px)"),
|
||
("vanish_y", "vanish_y (px)"),
|
||
("fov", "fov (°)"),
|
||
("pos_x", "pos_x (m)"),
|
||
("pos_y", "pos_y (m)"),
|
||
("pos_z", "pos_z (m)"),
|
||
("reprojection_error", "reproj_err (px)"),
|
||
("roi_x1", "roi_x1 (px)"),
|
||
("roi_y1", "roi_y1 (px)"),
|
||
("roi_x2", "roi_x2 (px)"),
|
||
("roi_y2", "roi_y2 (px)"),
|
||
]
|
||
DISTORT_PARAMS = [(i, f"distort_k{i+1}") for i in range(4)]
|
||
ALL_PARAMS = [(k, lbl, None) for k, lbl in SCALAR_PARAMS] + \
|
||
[(None, lbl, i) for i, lbl in DISTORT_PARAMS]
|
||
|
||
COL_HEADERS = ["Parameter", "split", "n", "min", "p5",
|
||
"median", "mean", "p95", "max", "std", "CV (%)"]
|
||
|
||
names = list(profilers.keys())
|
||
colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]
|
||
tints = [_COMPARE_TINTS[i % len(_COMPARE_TINTS)] for i in range(len(names))]
|
||
|
||
# Collect cv values across ALL rows for a global color scale
|
||
def _get_arr(profiler, key, distort_idx):
|
||
return profiler._distort_k(distort_idx) if distort_idx is not None \
|
||
else profiler._field(key)
|
||
|
||
def _make_row(param_label, split_name, arr):
|
||
if len(arr) == 0:
|
||
return None, None
|
||
s = _dist_stats(arr)
|
||
cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
|
||
return [
|
||
param_label,
|
||
split_name,
|
||
f"{s['count']:,}",
|
||
f"{s['min']:.3f}",
|
||
f"{s['p5']:.3f}",
|
||
f"{s['median']:.3f}",
|
||
f"{s['mean']:.3f}",
|
||
f"{s['p95']:.3f}",
|
||
f"{s['max']:.3f}",
|
||
f"{s['std']:.4f}",
|
||
f"{cv:.2f}",
|
||
], cv
|
||
|
||
# Build rows grouped by parameter, interleaving splits
|
||
row_data = [] # (row_cells, cv, split_idx, is_first_in_group)
|
||
for key, lbl, distort_idx in ALL_PARAMS:
|
||
first_in_group = True
|
||
for si, (name, profiler) in enumerate(profilers.items()):
|
||
arr = _get_arr(profiler, key, distort_idx)
|
||
row, cv = _make_row(lbl, name, arr)
|
||
if row is None:
|
||
continue
|
||
# Show parameter label only in the first split row of each group
|
||
if not first_in_group:
|
||
row[0] = "" # blank param label for subsequent splits
|
||
row_data.append((row, cv, si, first_in_group))
|
||
first_in_group = False
|
||
|
||
if not row_data:
|
||
print(" (comparison stats table: no data)")
|
||
return
|
||
|
||
all_cv = np.array([cv for _, cv, _, _ in row_data], dtype=float)
|
||
p95_cv = np.percentile(all_cv, 95) if all_cv.max() > 0 else 1.0
|
||
norm_cv = Normalize(vmin=0, vmax=max(p95_cv, 1e-6))
|
||
cmap_cv = cm.get_cmap("YlOrRd")
|
||
|
||
n_rows = len(row_data)
|
||
fig_h = max(6, 0.38 * n_rows + 1.8)
|
||
fig, ax = plt.subplots(figsize=(24, fig_h))
|
||
ax.axis("off")
|
||
|
||
split_n = {name: len(p.records) for name, p in profilers.items()}
|
||
title_parts = [f"{n} (n={split_n[n]:,})" for n in names]
|
||
fig.suptitle(
|
||
"Calibration Parameter Statistics — " + " vs ".join(title_parts),
|
||
fontsize=12, fontweight="bold", y=0.99,
|
||
)
|
||
|
||
cells = [r for r, _, _, _ in row_data]
|
||
tbl = ax.table(cellText=cells, colLabels=COL_HEADERS,
|
||
loc="center", cellLoc="center")
|
||
tbl.auto_set_font_size(False)
|
||
tbl.set_fontsize(8)
|
||
tbl.auto_set_column_width(col=list(range(len(COL_HEADERS))))
|
||
|
||
# Header row
|
||
HDR_COLOR = "#2c3e50"
|
||
for j in range(len(COL_HEADERS)):
|
||
tbl[0, j].set_facecolor(HDR_COLOR)
|
||
tbl[0, j].set_text_props(color="white", fontweight="bold")
|
||
# Color the split header cell to match palette
|
||
tbl[0, 1].set_facecolor("#455a64")
|
||
|
||
# Data rows
|
||
for i, (row, cv, si, is_first) in enumerate(row_data):
|
||
tint = tints[si]
|
||
for j in range(len(COL_HEADERS)):
|
||
tbl[i + 1, j].set_facecolor(tint)
|
||
# Parameter name bold + colored left border via param cell text color
|
||
if is_first:
|
||
tbl[i + 1, 0].set_text_props(fontweight="bold")
|
||
# Split name cell: colored text matching palette
|
||
tbl[i + 1, 1].set_text_props(color=colors[si], fontweight="bold")
|
||
# CV column: YlOrRd heatmap on top of tint
|
||
tbl[i + 1, len(COL_HEADERS) - 1].set_facecolor(cmap_cv(norm_cv(cv)))
|
||
|
||
fig.savefig(output_path, dpi=130, bbox_inches="tight")
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
|
||
def plot_comparison_histograms(profilers: dict, out_dir):
|
||
"""Overlay calibration parameter histograms for multiple splits/groups.
|
||
|
||
Generates four comparison figures:
|
||
comparison_01_intrinsics.png
|
||
comparison_02_angles.png
|
||
comparison_03_distortion.png
|
||
comparison_04_vanishing_point.png
|
||
|
||
Args:
|
||
profilers: Ordered dict of {split_name: CalibProfiler}.
|
||
out_dir: pathlib.Path for the output directory.
|
||
"""
|
||
out_dir = Path(out_dir)
|
||
names = list(profilers.keys())
|
||
colors = [_COMPARE_PALETTE[i % len(_COMPARE_PALETTE)] for i in range(len(names))]
|
||
|
||
def _ds(key=None, distort_idx=None):
|
||
"""Build datasets list for _hist_multi."""
|
||
result = []
|
||
for name, color in zip(names, colors):
|
||
p = profilers[name]
|
||
arr = p._distort_k(distort_idx) if distort_idx is not None else p._field(key)
|
||
result.append((name, arr, color))
|
||
return result
|
||
|
||
title_suffix = " vs ".join(names)
|
||
|
||
# ── Figure 1: Intrinsics ─────────────────────────────────────────────────
|
||
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
|
||
fig.suptitle(f"Camera Intrinsics — {title_suffix}", fontsize=12, fontweight="bold")
|
||
_hist_multi(axes[0, 0], _ds("focal_u"), "focal_u", "pixel (px)")
|
||
_hist_multi(axes[0, 1], _ds("focal_v"), "focal_v", "pixel (px)")
|
||
_hist_multi(axes[1, 0], _ds("cu"), "cu — principal pt X", "pixel (px)")
|
||
_hist_multi(axes[1, 1], _ds("cv"), "cv — principal pt Y", "pixel (px)")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_01_intrinsics.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 2: Angles ─────────────────────────────────────────────────────
|
||
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
|
||
fig.suptitle(f"Camera Angles — {title_suffix}", fontsize=12, fontweight="bold")
|
||
_hist_multi(axes[0], _ds("pitch"), "pitch", "degrees (°)")
|
||
_hist_multi(axes[1], _ds("yaw"), "yaw", "degrees (°)")
|
||
_hist_multi(axes[2], _ds("roll"), "roll", "degrees (°)")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_02_angles.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 3: Distortion coefficients ────────────────────────────────────
|
||
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
|
||
fig.suptitle(f"Fisheye Distortion Coefficients — {title_suffix}",
|
||
fontsize=12, fontweight="bold")
|
||
for i, ax in enumerate(axes.flat):
|
||
_hist_multi(ax, _ds(distort_idx=i), f"distort_k{i+1}", f"k{i+1}")
|
||
plt.tight_layout()
|
||
out = str(out_dir / "comparison_03_distortion.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
# ── Figure 4: Vanishing point ─────────────────────────────────────────────
|
||
fig = plt.figure(figsize=(16, 11))
|
||
fig.suptitle(f"Vanishing Point — {title_suffix}",
|
||
fontsize=12, fontweight="bold")
|
||
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.35, wspace=0.3)
|
||
ax_hx = fig.add_subplot(gs[0, 0])
|
||
ax_hy = fig.add_subplot(gs[0, 1])
|
||
ax_sc = fig.add_subplot(gs[1, :])
|
||
|
||
_hist_multi(ax_hx, _ds("vanish_x"), "Vanishing Point X", "vanish_x (pixel)")
|
||
_hist_multi(ax_hy, _ds("vanish_y"), "Vanishing Point Y", "vanish_y (pixel)")
|
||
|
||
# 2-D scatter: one color per split
|
||
first_p = next(iter(profilers.values()))
|
||
oriW, oriH = first_p.ori_img_size
|
||
for name, color in zip(names, colors):
|
||
p = profilers[name]
|
||
vx = p._field("vanish_x")
|
||
vy = p._field("vanish_y")
|
||
if len(vx) == 0:
|
||
continue
|
||
ax_sc.scatter(vx, vy, c=color, s=14, alpha=0.55,
|
||
edgecolors="none", label=f"{name} (n={len(vx):,})")
|
||
med_vx, med_vy = float(np.median(vx)), float(np.median(vy))
|
||
ax_sc.scatter([med_vx], [med_vy], c=color, s=120, marker="*",
|
||
edgecolors="black", linewidths=0.7, zorder=6,
|
||
label=f"{name} median ({med_vx:.0f}, {med_vy:.0f})")
|
||
|
||
ax_sc.axvline(oriW / 2, color="gray", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_x={oriW//2}")
|
||
ax_sc.axhline(oriH / 2, color="silver", linewidth=0.8, linestyle="--",
|
||
label=f"img_center_y={oriH//2}")
|
||
ax_sc.set_xlim(-oriW * 0.05, oriW * 1.05)
|
||
ax_sc.set_ylim(oriH * 1.05, -oriH * 0.05) # image coords: top=0
|
||
ax_sc.set_title("Vanishing Point Scatter (image coordinates)", fontsize=9)
|
||
ax_sc.set_xlabel("vanish_x (pixel)", fontsize=8)
|
||
ax_sc.set_ylabel("vanish_y (pixel)", fontsize=8)
|
||
ax_sc.legend(fontsize=7, loc="upper right")
|
||
ax_sc.tick_params(labelsize=7)
|
||
|
||
out = str(out_dir / "comparison_04_vanishing_point.png")
|
||
fig.savefig(out, dpi=130)
|
||
plt.close(fig)
|
||
print(f" Saved → {out}")
|
||
|
||
|
||
def plot_split_comparison_table(profilers: dict, output_path: str):
|
||
"""Compare calibration statistics across multiple splits in a single table.
|
||
|
||
Args:
|
||
profilers: Ordered dict of {split_name: CalibProfiler}.
|
||
output_path: Path for the saved figure.
|
||
|
||
Table layout:
|
||
Rows = calibration parameters.
|
||
Columns = Parameter | [split1: median / std / CV%] | [split2: ...] | Δmedian.
|
||
The Δmedian column (split0 − split1) is color-coded with a diverging RdBu colormap
|
||
so that positive/negative shifts are immediately visible.
|
||
"""
|
||
import matplotlib.cm as cm
|
||
from matplotlib.colors import TwoSlopeNorm
|
||
|
||
SCALAR_PARAMS = [
|
||
("focal_u", "focal_u (px)"),
|
||
("focal_v", "focal_v (px)"),
|
||
("cu", "cu (px)"),
|
||
("cv", "cv (px)"),
|
||
("pitch", "pitch (°)"),
|
||
("yaw", "yaw (°)"),
|
||
("roll", "roll (°)"),
|
||
("vanish_x", "vanish_x (px)"),
|
||
("vanish_y", "vanish_y (px)"),
|
||
("fov", "fov (°)"),
|
||
("pos_x", "pos_x (m)"),
|
||
("pos_y", "pos_y (m)"),
|
||
("pos_z", "pos_z (m)"),
|
||
("reprojection_error", "reproj_err (px)"),
|
||
("roi_x1", "roi_x1 (px)"),
|
||
("roi_y1", "roi_y1 (px)"),
|
||
("roi_x2", "roi_x2 (px)"),
|
||
("roi_y2", "roi_y2 (px)"),
|
||
]
|
||
|
||
split_names = list(profilers.keys())
|
||
n_splits = len(split_names)
|
||
has_delta = n_splits == 2
|
||
SUB_COLS = ["median", "std", "CV (%)"]
|
||
|
||
# Header row
|
||
col_headers = ["Parameter"]
|
||
SPLIT_COLORS = ["#1a5276", "#1e8449", "#6e2f8a", "#7b241c"]
|
||
for sp in split_names:
|
||
n = len(profilers[sp].records)
|
||
for sc in SUB_COLS:
|
||
col_headers.append(f"{sp} {sc}\n(n={n:,})")
|
||
if has_delta:
|
||
delta_label = f"Δmedian\n({split_names[0]}−{split_names[1]})"
|
||
col_headers.append(delta_label)
|
||
|
||
def _get_stats(profiler, key, distort_idx=None):
|
||
arr = (
|
||
profiler._distort_k(distort_idx)
|
||
if distort_idx is not None
|
||
else profiler._field(key)
|
||
)
|
||
if len(arr) == 0:
|
||
return None
|
||
s = _dist_stats(arr)
|
||
cv = abs(s["std"] / s["mean"] * 100) if abs(s.get("mean", 0)) > 1e-9 else 0.0
|
||
return s["median"], s["std"], cv
|
||
|
||
def _build_row(label, key, distort_idx=None):
|
||
row = [label]
|
||
medians = []
|
||
for sp in split_names:
|
||
res = _get_stats(profilers[sp], key, distort_idx)
|
||
if res is None:
|
||
row.extend(["—", "—", "—"])
|
||
medians.append(None)
|
||
else:
|
||
med, std, cv = res
|
||
row += [f"{med:.3f}", f"{std:.4f}", f"{cv:.2f}"]
|
||
medians.append(med)
|
||
if has_delta:
|
||
m0, m1 = medians[0], medians[1]
|
||
row.append(
|
||
f"{m0 - m1:+.3f}" if (m0 is not None and m1 is not None) else "—"
|
||
)
|
||
return row
|
||
|
||
rows = []
|
||
for key, label in SCALAR_PARAMS:
|
||
rows.append(_build_row(label, key))
|
||
for idx in range(4):
|
||
rows.append(_build_row(f"distort_k{idx+1}", None, distort_idx=idx))
|
||
|
||
# Drop rows where every split cell is missing
|
||
n_data = n_splits * len(SUB_COLS)
|
||
rows = [r for r in rows if not all(v == "—" for v in r[1: 1 + n_data])]
|
||
|
||
if not rows:
|
||
print(" (comparison table: no data)")
|
||
return
|
||
|
||
n_rows = len(rows)
|
||
fig_w = max(16, 3.5 + 3.2 * n_splits * len(SUB_COLS) + (2.5 if has_delta else 0))
|
||
fig_h = max(5, 0.42 * n_rows + 1.5)
|
||
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
|
||
ax.axis("off")
|
||
fig.suptitle(
|
||
"Calibration Parameter Comparison — " + " vs ".join(split_names),
|
||
fontsize=12, fontweight="bold", y=0.99,
|
||
)
|
||
|
||
tbl = ax.table(cellText=rows, colLabels=col_headers, loc="center", cellLoc="center")
|
||
tbl.auto_set_font_size(False)
|
||
tbl.set_fontsize(8)
|
||
tbl.auto_set_column_width(col=list(range(len(col_headers))))
|
||
|
||
# Dark base header
|
||
HDR_BASE = "#2c3e50"
|
||
for j in range(len(col_headers)):
|
||
tbl[0, j].set_facecolor(HDR_BASE)
|
||
tbl[0, j].set_text_props(color="white", fontweight="bold", fontsize=7.5)
|
||
|
||
# Per-split colored group headers
|
||
for j_sp, sp in enumerate(split_names):
|
||
col_start = 1 + j_sp * len(SUB_COLS)
|
||
grp_color = SPLIT_COLORS[j_sp % len(SPLIT_COLORS)]
|
||
for sc_i in range(len(SUB_COLS)):
|
||
tbl[0, col_start + sc_i].set_facecolor(grp_color)
|
||
|
||
ALT_COLORS = ["#f0f4f8", "#ffffff"]
|
||
for i, row in enumerate(rows):
|
||
bg = ALT_COLORS[i % 2]
|
||
for j in range(len(col_headers)):
|
||
tbl[i + 1, j].set_facecolor(bg)
|
||
tbl[i + 1, 0].set_text_props(fontweight="bold")
|
||
|
||
# Color the Δmedian column with a diverging colormap
|
||
if has_delta:
|
||
delta_col = len(col_headers) - 1
|
||
delta_vals = []
|
||
for row in rows:
|
||
try:
|
||
delta_vals.append(float(row[delta_col]))
|
||
except Exception:
|
||
delta_vals.append(0.0)
|
||
max_abs = max(abs(v) for v in delta_vals) if delta_vals else 1.0
|
||
if max_abs > 0:
|
||
norm_d = TwoSlopeNorm(vmin=-max_abs, vcenter=0.0, vmax=max_abs)
|
||
cmap_d = cm.get_cmap("RdBu_r")
|
||
for i, dv in enumerate(delta_vals):
|
||
tbl[i + 1, delta_col].set_facecolor(cmap_d(norm_d(dv)))
|
||
|
||
fig.savefig(output_path, dpi=130, bbox_inches="tight")
|
||
plt.close(fig)
|
||
print(f" Saved → {output_path}")
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Entry point
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Profile calibration parameters for a YOLOv5-3D dataset split.")
|
||
parser.add_argument("--data", default="data/mono3d.yaml",
|
||
help="Path to dataset YAML config (default: data/mono3d.yaml)")
|
||
parser.add_argument("--split", default="train", choices=["train", "val", "both"],
|
||
help="Which split to analyse (default: train)")
|
||
parser.add_argument("--max-files", type=int, default=0,
|
||
help="Maximum number of image files to scan (0 = all)")
|
||
parser.add_argument("--output-dir", default="calib_profiling_results",
|
||
help="Output directory for plots and report")
|
||
parser.add_argument("--workers", type=int, default=0,
|
||
help="Number of parallel workers (0 = auto, 1 = sequential)")
|
||
parser.add_argument(
|
||
"--cases-dirs", nargs="+", default=[],
|
||
metavar="NAME:PATH",
|
||
help=(
|
||
"One or more test-case directories to profile alongside the training/val "
|
||
"splits. Each entry should be 'name:path' (e.g. "
|
||
"'cases_coding:/data1/dongying/Mono3d/G1M3/cases_coding'). "
|
||
"If no colon is present the directory basename is used as the name. "
|
||
"All directories are merged into a single 'cases' profile. "
|
||
"Results are included in the cross-split comparison table."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--cases-name", default="cases",
|
||
help="Label for the merged test-cases profile (default: 'cases').",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
# ── Load dataset YAML ─────────────────────────────────────────────────────
|
||
data_path = Path(args.data)
|
||
if not data_path.exists():
|
||
print(f"ERROR: data file not found: {data_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
with open(data_path, "r") as f:
|
||
data_cfg = yaml.safe_load(f)
|
||
|
||
ori_img_size = tuple(data_cfg.get("ori_img_size", [1920, 1080])) # (W, H)
|
||
roi_cfg = data_cfg.get("roi", None)
|
||
roi_size = tuple(roi_cfg) if roi_cfg else None
|
||
roi_bottom_offset = int(data_cfg.get("roi_bottom_offset", 0))
|
||
|
||
# ── Resolve workers ───────────────────────────────────────────────────────
|
||
n_workers = args.workers if args.workers > 0 else min(cpu_count(), 16)
|
||
|
||
# ── Output directory ──────────────────────────────────────────────────────
|
||
out_dir = Path(args.output_dir)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
splits = ["train", "val"] if args.split == "both" else [args.split]
|
||
profilers_by_split = {} # {split_name: CalibProfiler} — for cross-split comparison
|
||
|
||
for split in splits:
|
||
print(f"\n{'='*60}")
|
||
print(f" Split: {split}")
|
||
print(f"{'='*60}")
|
||
|
||
img_paths = resolve_image_paths(data_cfg, split)
|
||
if not img_paths:
|
||
print(f" WARNING: no image paths found for split '{split}'.")
|
||
continue
|
||
|
||
if args.max_files > 0:
|
||
img_paths = img_paths[: args.max_files]
|
||
|
||
calib_paths = unique_calib_paths(img_paths)
|
||
print(f" Total image paths : {len(img_paths):,}")
|
||
print(f" Unique calib files : {len(calib_paths):,}")
|
||
print(f" Workers : {n_workers}")
|
||
print(f" ori_img_size : {ori_img_size}")
|
||
print(f" roi_size : {roi_size}")
|
||
print(f" roi_bottom_offset : {roi_bottom_offset}")
|
||
print()
|
||
|
||
profiler = CalibProfiler(
|
||
ori_img_size=ori_img_size,
|
||
roi_size=roi_size,
|
||
roi_bottom_offset=roi_bottom_offset,
|
||
)
|
||
|
||
print(" Loading calibration files…")
|
||
profiler.process_batch(calib_paths, workers=n_workers)
|
||
|
||
n_loaded = len(profiler.records)
|
||
print(f" Loaded : {n_loaded:,} / {len(calib_paths):,} "
|
||
f"(missing={profiler.n_missing}, invalid={profiler.n_invalid})")
|
||
|
||
if n_loaded == 0:
|
||
print(" No calibration data found — skipping this split.")
|
||
continue
|
||
|
||
print("\n Generating summary statistics…")
|
||
summary = profiler.summarize()
|
||
|
||
# ── Save JSON summary ─────────────────────────────────────────────────
|
||
json_path = out_dir / f"{split}_calib_summary.json"
|
||
with open(json_path, "w") as f:
|
||
json.dump(summary, f, indent=2)
|
||
print(f" JSON → {json_path}")
|
||
|
||
# ── Text report ────────────────────────────────────────────────────────
|
||
report_path = out_dir / f"{split}_calib_report.txt"
|
||
profiler.write_report(str(report_path), summary)
|
||
|
||
# ── Figures ───────────────────────────────────────────────────────────
|
||
print("\n Generating plots…")
|
||
profiler.plot_intrinsics(str(out_dir / f"{split}_01_intrinsics.png"))
|
||
profiler.plot_angles(str(out_dir / f"{split}_02_angles.png"))
|
||
profiler.plot_distortion(str(out_dir / f"{split}_03_distortion.png"))
|
||
profiler.plot_vanishing_point(
|
||
str(out_dir / f"{split}_04_vanishing_point.png"), ori_img_size)
|
||
if roi_size is not None:
|
||
profiler.plot_roi_bounds(
|
||
str(out_dir / f"{split}_05_roi_bounds.png"), ori_img_size)
|
||
profiler.plot_misc(str(out_dir / f"{split}_06_misc.png"))
|
||
profiler.plot_stats_table(str(out_dir / f"{split}_07_stats_table.png"))
|
||
|
||
# ── Per-sequence CSV table ─────────────────────────────────────────────
|
||
profiler.write_csv_table(str(out_dir / f"{split}_calib_table.csv"))
|
||
|
||
# ── Track profiler for cross-split comparison ─────────────────────────
|
||
profilers_by_split[split] = profiler
|
||
|
||
# ── Console synopsis ──────────────────────────────────────────────────
|
||
print(f"\n ── Vanishing Point Synopsis ──")
|
||
for key in ("vanish_x", "vanish_y"):
|
||
if key in summary:
|
||
print(f" {key}: {_fmt(summary[key])}")
|
||
if "roi_y1" in summary:
|
||
print(f"\n ── ROI y1 (crop top) Synopsis ──")
|
||
print(f" roi_y1: {_fmt(summary['roi_y1'])}")
|
||
|
||
# ── Test-case directories (all merged into one combined profile) ─────────
|
||
if args.cases_dirs:
|
||
cases_entries = []
|
||
for entry in args.cases_dirs:
|
||
if ":" in entry:
|
||
n, p = entry.split(":", 1)
|
||
else:
|
||
n = os.path.basename(entry.rstrip("/"))
|
||
p = entry
|
||
cases_entries.append((n, p))
|
||
|
||
combined_name = args.cases_name
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" Test cases (merged as '{combined_name}')")
|
||
for n, p in cases_entries:
|
||
print(f" [{n}] {p}")
|
||
print(f"{'='*60}")
|
||
|
||
# Collect + deduplicate calib paths across all directories
|
||
all_calib_paths: list = []
|
||
seen_paths: set = set()
|
||
for n, p in cases_entries:
|
||
if not Path(p).exists():
|
||
print(f" WARNING: cases dir not found: {p!r} — skipping.")
|
||
continue
|
||
found = find_calib_paths_in_dir(p)
|
||
print(f" [{n}] Found {len(found):,} calib files")
|
||
for cp in found:
|
||
if cp not in seen_paths:
|
||
seen_paths.add(cp)
|
||
all_calib_paths.append(cp)
|
||
|
||
print(f" Total (deduplicated) : {len(all_calib_paths):,} calib files")
|
||
print(f" Workers : {n_workers}")
|
||
print(f" ori_img_size : {ori_img_size}")
|
||
print(f" roi_size : {roi_size}")
|
||
|
||
if all_calib_paths:
|
||
profiler = CalibProfiler(
|
||
ori_img_size=ori_img_size,
|
||
roi_size=roi_size,
|
||
roi_bottom_offset=roi_bottom_offset,
|
||
)
|
||
profiler.process_batch(all_calib_paths, workers=n_workers)
|
||
|
||
n_loaded = len(profiler.records)
|
||
print(f" Loaded : {n_loaded:,} / {len(all_calib_paths):,} "
|
||
f"(missing={profiler.n_missing}, invalid={profiler.n_invalid})")
|
||
|
||
if n_loaded > 0:
|
||
print("\n Generating summary statistics…")
|
||
summary = profiler.summarize()
|
||
|
||
json_path = out_dir / f"{combined_name}_calib_summary.json"
|
||
with open(json_path, "w") as f:
|
||
json.dump(summary, f, indent=2)
|
||
print(f" JSON → {json_path}")
|
||
|
||
report_path = out_dir / f"{combined_name}_calib_report.txt"
|
||
profiler.write_report(str(report_path), summary)
|
||
|
||
print("\n Generating plots…")
|
||
profiler.plot_intrinsics(str(out_dir / f"{combined_name}_01_intrinsics.png"))
|
||
profiler.plot_angles(str(out_dir / f"{combined_name}_02_angles.png"))
|
||
profiler.plot_distortion(str(out_dir / f"{combined_name}_03_distortion.png"))
|
||
profiler.plot_vanishing_point(
|
||
str(out_dir / f"{combined_name}_04_vanishing_point.png"), ori_img_size)
|
||
if roi_size is not None:
|
||
profiler.plot_roi_bounds(
|
||
str(out_dir / f"{combined_name}_05_roi_bounds.png"), ori_img_size)
|
||
profiler.plot_misc(str(out_dir / f"{combined_name}_06_misc.png"))
|
||
profiler.plot_stats_table(str(out_dir / f"{combined_name}_07_stats_table.png"))
|
||
|
||
profiler.write_csv_table(str(out_dir / f"{combined_name}_calib_table.csv"))
|
||
|
||
profilers_by_split[combined_name] = profiler
|
||
|
||
# ── Cross-split comparison plots + table (generated when ≥2 profiles) ───
|
||
if len(profilers_by_split) >= 2:
|
||
print("\n" + "="*60)
|
||
print(" Generating cross-split comparison histograms…")
|
||
plot_comparison_histograms(profilers_by_split, out_dir)
|
||
|
||
print("\n Generating cross-split combined stats table…")
|
||
plot_comparison_stats_table(
|
||
profilers_by_split,
|
||
str(out_dir / "comparison_stats_table.png"),
|
||
)
|
||
|
||
print("\n Generating cross-split median comparison table…")
|
||
plot_split_comparison_table(
|
||
profilers_by_split,
|
||
str(out_dir / "comparison_table.png"),
|
||
)
|
||
|
||
print("\nDone.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|