import argparse import json from pathlib import Path import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np FEATURE_LABELS = { "angle": "Incidence angle (degrees)", "distance": "View-to-point distance (meters)", "contrast": "Local contrast", "blur": "Blur response", "snr": "Signal-to-noise ratio", "saturation": "Saturation", } FEATURE_ORDER = ["angle", "distance", "contrast", "blur", "snr", "saturation"] FEATURE_GROUPS = { "geometry": ["angle", "distance"], "image": ["contrast", "blur"], } FEATURE_COLORS = { "angle": "#1f77b4", "distance": "#d62728", "contrast": "#2ca02c", "blur": "#9467bd", "snr": "#8c564b", "saturation": "#ff7f0e", } def setup_style(): mpl.rcParams.update( { "figure.dpi": 180, "savefig.dpi": 300, "font.family": "sans-serif", "font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"], "mathtext.fontset": "dejavusans", "axes.spines.top": False, "axes.spines.right": False, "axes.linewidth": 0.8, "axes.labelsize": 8, "axes.titlesize": 9, "xtick.labelsize": 7, "ytick.labelsize": 7, "legend.fontsize": 7, "grid.alpha": 0.12, "grid.linewidth": 0.4, "lines.linewidth": 1.2, } ) def _load_summary(path): with open(path, "r", encoding="utf-8") as handle: return json.load(handle) def _load_curve_json(path): with open(path, "r", encoding="utf-8") as handle: return json.load(handle) def _feature_curve(feature_payload): curve = feature_payload["curve"] x = np.array([row["x_center"] for row in curve], dtype=np.float64) y = np.array([row["weight_mean"] for row in curve], dtype=np.float64) if curve and "weight_q25" in curve[0] and "weight_q75" in curve[0]: low = np.array([row["weight_q25"] for row in curve], dtype=np.float64) high = np.array([row["weight_q75"] for row in curve], dtype=np.float64) else: err = np.array([row["weight_stderr"] for row in curve], dtype=np.float64) low = y - err high = y + err return x, y, low, high def _global_y_limits(summary): y_values = [] for payload in summary["features"].values(): for row in payload["curve"]: y_values.append(float(row["weight_mean"])) if not y_values: return 0.0, 1.0 ymin = min(y_values) ymax = max(y_values) pad = max((ymax - ymin) * 0.08, 0.01) return max(0.0, ymin - pad), min(1.0, ymax + pad) def _resolve_y_limits(summary, ymin=None, ymax=None): auto_ymin, auto_ymax = _global_y_limits(summary) return ( auto_ymin if ymin is None else float(ymin), auto_ymax if ymax is None else float(ymax), ) def _plot_panel(ax, feature_name, feature_payload, show_ylabel=True): x, y, low, high = _feature_curve(feature_payload) color = FEATURE_COLORS[feature_name] xlabel = FEATURE_LABELS.get(feature_name, feature_name.title()) line = ax.plot( x, y, color=color, marker="o", markersize=1.8, markeredgewidth=0.0, label="Mean", )[0] band = ax.fill_between( x, low, high, color=color, alpha=0.10, linewidth=0, label="IQR (25--75%)", ) ax.set_xlabel(xlabel) if show_ylabel: ax.set_ylabel("Mean learned weight") ax.grid(True, axis="y") ax.margins(x=0.01) ax.legend(handles=[line, band], loc="best", frameon=False, handlelength=2.0) def _save_individual_figure(feature_name, feature_payload, output_dir, y_limits): fig, ax = plt.subplots(figsize=(3.35, 2.45), constrained_layout=True) _plot_panel(ax, feature_name, feature_payload) ax.set_ylim(*y_limits) pdf_path = output_dir / f"{feature_name}.pdf" png_path = output_dir / f"{feature_name}.png" fig.savefig(pdf_path, bbox_inches="tight") fig.savefig(png_path, bbox_inches="tight") plt.close(fig) return pdf_path, png_path def _save_group_figure(summary, feature_names, output_prefix, y_limits): fig, axes = plt.subplots(1, len(feature_names), figsize=(7.0, 2.6), constrained_layout=True) if len(feature_names) == 1: axes = [axes] plotted = 0 for idx, (ax, feature_name) in enumerate(zip(axes, feature_names)): if feature_name not in summary["features"]: ax.axis("off") continue feature_payload = summary["features"][feature_name] _plot_panel(ax, feature_name, feature_payload, show_ylabel=(idx == 0)) ax.set_ylim(*y_limits) plotted += 1 pdf_path = output_prefix.with_suffix(".pdf") png_path = output_prefix.with_suffix(".png") fig.savefig(pdf_path, bbox_inches="tight") fig.savefig(png_path, bbox_inches="tight") plt.close(fig) return pdf_path, png_path, plotted def _save_geometry_paper_figure(summary, output_prefix, y_limits, angle_payload=None): feature_names = ["angle", "distance"] fig, axes = plt.subplots(1, 2, figsize=(6.8, 2.55), constrained_layout=True) for idx, (ax, feature_name) in enumerate(zip(axes, feature_names)): if feature_name == "angle" and angle_payload is not None: feature_payload = angle_payload else: feature_payload = summary["features"][feature_name] _plot_panel(ax, feature_name, feature_payload, show_ylabel=(idx == 0)) ax.set_ylim(*y_limits) pdf_path = output_prefix.with_suffix(".pdf") png_path = output_prefix.with_suffix(".png") fig.savefig(pdf_path, bbox_inches="tight") fig.savefig(png_path, bbox_inches="tight") plt.close(fig) return pdf_path, png_path def _load_angle_override(summary_path, class_id): if class_id is None: return None curve_path = summary_path.parent / "per_class" / f"class_{class_id}" / "angle_curve.json" if not curve_path.exists(): raise FileNotFoundError(f"Missing per-class angle curve: {curve_path}") payload = _load_curve_json(curve_path) return {"curve": payload["curve"]} def build_figure(summary, summary_path, output_prefix, ymin=None, ymax=None, angle_class=None): setup_style() fig, axes = plt.subplots(2, 3, figsize=(10.6, 5.9), constrained_layout=True) axes = axes.ravel() individual_dir = output_prefix.parent / f"{output_prefix.stem}_individual" individual_dir.mkdir(parents=True, exist_ok=True) y_limits = _resolve_y_limits(summary, ymin=ymin, ymax=ymax) angle_override = _load_angle_override(summary_path, angle_class) plotted = 0 for ax, feature_name in zip(axes, FEATURE_ORDER): if feature_name not in summary["features"]: ax.axis("off") continue if feature_name == "angle" and angle_override is not None: feature_payload = angle_override else: feature_payload = summary["features"][feature_name] _plot_panel(ax, feature_name, feature_payload) ax.set_ylim(*y_limits) _save_individual_figure(feature_name, feature_payload, individual_dir, y_limits) plotted += 1 pdf_path = output_prefix.with_suffix(".pdf") png_path = output_prefix.with_suffix(".png") fig.savefig(pdf_path, bbox_inches="tight") fig.savefig(png_path, bbox_inches="tight") plt.close(fig) group_outputs = {} for group_name, feature_names in FEATURE_GROUPS.items(): group_prefix = output_prefix.parent / f"{output_prefix.stem}_{group_name}" group_pdf, group_png, _ = _save_group_figure(summary, feature_names, group_prefix, y_limits) group_outputs[group_name] = {"pdf": str(group_pdf), "png": str(group_png)} geometry_clean_prefix = output_prefix.parent / f"{output_prefix.stem}_geometry_clean" geometry_clean_pdf, geometry_clean_png = _save_geometry_paper_figure( summary, geometry_clean_prefix, y_limits, angle_payload=angle_override, ) group_outputs["geometry_clean"] = {"pdf": str(geometry_clean_pdf), "png": str(geometry_clean_png)} return pdf_path, png_path, plotted def main(): parser = argparse.ArgumentParser() parser.add_argument("--summary", type=Path, required=True) parser.add_argument("--output_prefix", type=Path, required=True) parser.add_argument("--ymin", type=float, default=None) parser.add_argument("--ymax", type=float, default=None) parser.add_argument("--angle_class", type=int, default=None) args = parser.parse_args() summary = _load_summary(args.summary) args.output_prefix.parent.mkdir(parents=True, exist_ok=True) pdf_path, png_path, plotted = build_figure( summary, args.summary, args.output_prefix, ymin=args.ymin, ymax=args.ymax, angle_class=args.angle_class, ) print(f"Saved {plotted} panels to {pdf_path} and {png_path}") if __name__ == "__main__": main()