diff --git a/scripts/build_led_placement_slides.py b/scripts/build_led_placement_slides.py new file mode 100644 index 0000000..291a393 --- /dev/null +++ b/scripts/build_led_placement_slides.py @@ -0,0 +1,304 @@ +"""照明配置スイープ(TEST_18)の発表スライド用の図を英語で生成する. + +既存の解析図(build_led_placement_comparison.py / build_led_placement_maps.py)は日本語で +情報量が多いので,スライドに貼れる体裁(題・枠なし,ラベルは英語,右にカラーバー)で +次の 5 枚を作る. + + slide_led_geometry.png LED 位置の定義(球 φ340 と立方体 L=234 の断面に θ=0〜120° の LED) + slide_led_position_maps.png θ=0/30/60/90/120° の輝度マップ(上: 球 φ340,下: 立方体 L=234) + slide_led_position_curves.png 構造 CoV と min の θ 依存(4 ペア) + slide_led_count_maps.png 灯数 N=1〜4(θ=90°)の輝度マップ + 軸上 1 灯の参照 + slide_led_count_curves.png 構造 CoV と min の N 依存 + +輝度マップは build_led_placement_maps.py と同じ処理(メディアン 5 → ガウス σ=4 → 99 パーセンタイル += 1)で,各マップの下に θ または N と min(ノイズ補正付き最小/最大比)を書く. + +実行方法: + uv run python scripts/build_led_placement_slides.py + uv run python scripts/build_led_placement_slides.py --material calibrated +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import matplotlib +import numpy as np +import pandas as pd + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import build_led_placement_maps as maps # noqa: E402 +from sweep_sphere_radius import center_z, cut_radius # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +THETAS = [0.0, 30.0, 60.0, 90.0, 120.0] +COUNTS = [1, 2, 3, 4] +REP_PAIR = 2 # 球 φ340 / 立方体 L=234 +PAIR_LABELS = { + "sphere": {100.0: "Sphere φ200", 135.0: "Sphere φ270", 170.0: "Sphere φ340", + 250.0: "Sphere φ500"}, + "cube": {141.0: "Cube L=141", 187.0: "Cube L=187", 234.0: "Cube L=234", 340.0: "Cube L=340"}, +} +PAIR_COLORS = ["#4b1d6b", "#2c6e9e", "#1fa187", "#8fd744"] +SHAPE_STYLE = {"sphere": ("-", "o"), "cube": ("--", "s")} +VMIN, VMAX = 0.80, 1.00 +PLATE_HALF = 50.0 # 評価板の表示半幅 [mm](orthographic センサの ortho_scale と同程度) +BAFFLE_DIST = 16.0 # 模式図でのバッフルの LED からの距離 [mm](実際は視線上 10mm) +BAFFLE_HALF = 9.0 # 模式図でのバッフルの半幅 [mm] +COLOR_WALL = "#3f6f95" +COLOR_PLATE = "#d8232a" +COLOR_LED = "#f2a900" + +plt.rcParams["font.family"] = ["DejaVu Sans"] # 英語図.θ・φ は DejaVu に含まれる + + +# --------------------------------------------------------------------------- 幾何の模式図 +def sphere_outline(radius: float) -> tuple[np.ndarray, np.ndarray, float]: + """球ロフト(similar_radial)キャビティの断面輪郭 (x, z) と球中心 z を返す.""" + cz = center_z(radius, maps.sweep.SPHERE_CAVITY) + rc = cut_radius(radius, maps.sweep.SPHERE_CAVITY) + # 接続平面より下の球面(右回りに一周) + phi0 = np.arcsin(rc / radius) # 接続円の極角(上向きから) + phi = np.linspace(phi0, 2 * np.pi - phi0, 400) + xs = radius * np.sin(phi) + zs = cz + radius * np.cos(phi) + # ロフト: 接続円 → 評価板の縁(直線で近似) + x = np.concatenate([[PLATE_HALF * 1.15], xs, [-PLATE_HALF * 1.15]]) + z = np.concatenate([[0.0], zs, [0.0]]) + return x, z, cz + + +def cube_outline(size: float) -> tuple[np.ndarray, np.ndarray, float]: + """立方体キャビティの断面輪郭 (x, z) と中心 z を返す(天井 z=0).""" + h = size / 2 + x = np.array([-h, h, h, -h, -h]) + z = np.array([0.0, 0.0, -size, -size, 0.0]) + return x, z, -h + + +def led_point(shape: str, size: float, theta_deg: float) -> tuple[float, float]: + """キャビティ中心から (sinθ, −cosθ) に伸ばした半直線と壁の交点(断面 x, z).""" + t = np.radians(theta_deg) + d = np.array([np.sin(t), -np.cos(t)]) + if shape == "sphere": + cz = center_z(size, maps.sweep.SPHERE_CAVITY) + return float(size * d[0]), float(cz + size * d[1]) + h = size / 2 + cz = -h + # 正方形の壁との交点(底面 or 側壁) + ts = [] + if d[1] < 0: + ts.append(h / -d[1]) # 底面 z=-size + if d[0] > 0: + ts.append(h / d[0]) # 側壁 x=h + s = min(ts) + return float(s * d[0]), float(cz + s * d[1]) + + +def draw_geometry(out: Path) -> None: + """LED 位置 θ の定義を球と立方体の断面で示す.""" + r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR] + fig, axes = plt.subplots(1, 2, figsize=(9.5, 4.6)) + specs = [("sphere", r_sphere, "Sphere φ340", sphere_outline), + ("cube", l_cube, "Cube L=234", cube_outline)] + for ax, (shape, size, title, outline) in zip(axes, specs, strict=True): + x, z, cz = outline(size) + ax.plot(x, z, color=COLOR_WALL, lw=2) + ax.plot([-PLATE_HALF, PLATE_HALF], [0, 0], color=COLOR_PLATE, lw=6, solid_capstyle="butt") + ax.text(0, -10, "Evaluation plate", ha="center", va="top", fontsize=10) + ax.plot(0, cz, marker="+", color="k", ms=10, mew=1.5) + ax.text(-6, cz + 4, "center", fontsize=9, va="bottom", ha="right") + for th in THETAS: + px, pz = led_point(shape, size, th) + ax.plot([0, px], [cz, pz], color="0.6", lw=0.8, ls=":") + # LED の向き(板中心へ) + vx, vz = -px, -4.5 - pz + n = np.hypot(vx, vz) + ax.annotate("", xy=(px + vx / n * 30, pz + vz / n * 30), xytext=(px, pz), + arrowprops={"arrowstyle": "-|>", "color": COLOR_LED, "lw": 1.5}) + ax.plot(px, pz, marker="^", color=COLOR_LED, ms=9, mec="k", mew=0.5) + # バッフル: LED の視線上(板側)に置いた小円板を,視線に直交する短い線で表す + ux, uz = vx / n, vz / n + bx, bz = px + ux * BAFFLE_DIST, pz + uz * BAFFLE_DIST + ax.plot([bx - uz * BAFFLE_HALF, bx + uz * BAFFLE_HALF], + [bz + ux * BAFFLE_HALF, bz - ux * BAFFLE_HALF], color="k", lw=2.5, + solid_capstyle="butt", zorder=5) + if th == 90.0: + ax.text(bx - 6, bz + 14, "Baffle", fontsize=9, ha="right", va="bottom") + on_floor = shape == "cube" and th < 45 + if on_floor: + ax.text(px, pz - 14, f"θ={th:g}°", fontsize=10, ha="center", va="top") + else: + off = (12, -4) if th < 100 else (10, 8) + ax.text(px + off[0], pz + off[1], f"θ={th:g}°", fontsize=10, ha="left", + va="center") + # θ の弧(中心のまわり,0° から 120°) + arc = np.linspace(0, np.radians(120), 60) + ax.plot(35 * np.sin(arc), cz - 35 * np.cos(arc), color="0.3", lw=1) + ax.text(38 * np.sin(np.radians(60)) + 3, cz - 38 * np.cos(np.radians(60)), "θ", + fontsize=12, ha="left", va="center") + ax.set_title(title, fontsize=13, pad=14) + ax.set_aspect("equal") + ax.axis("off") + pad = 0.15 * size + ax.set_xlim(-size - pad if shape == "sphere" else -size / 2 - pad, + size + pad + 40 if shape == "sphere" else size / 2 + pad + 40) + ax.set_ylim((cz - size if shape == "sphere" else -size) - 28, 25) + fig.tight_layout() + fig.savefig(out, dpi=200, facecolor="white") + plt.close(fig) + print(f"保存: {out}") + + +# --------------------------------------------------------------------------- マップパネル +def map_grid(cells: list[list[tuple[str, str]]], row_labels: list[str], out: Path, + material: str, label_fs: float = 12) -> None: + """cells[row][col] = (ラベル, 条件名) のマップ格子を描く(スライド体裁).""" + n_rows, n_cols = len(cells), len(cells[0]) + fig, axes = plt.subplots(n_rows, n_cols, figsize=(2.55 * n_cols + 1.4, 2.75 * n_rows)) + axes = np.atleast_2d(axes) + im = None + for r, row in enumerate(cells): + for c, (label, name) in enumerate(row): + ax = axes[r, c] + data = maps.load_map(name, material) + if data is None: + ax.axis("off") + ax.set_title(f"{label}\n(missing)", fontsize=9) + continue + im = ax.imshow(data, cmap=maps.CMAP, vmin=VMIN, vmax=VMAX) + ratio = maps.min_ratio(name, material) + ax.set_xlabel(f"{label} (min={ratio:.3f})", fontsize=label_fs, labelpad=5) + ax.set_xticks([]) + ax.set_yticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + axes[r, 0].set_ylabel(row_labels[r], fontsize=13, labelpad=8) + fig.tight_layout(rect=(0, 0, 0.9, 1.0), h_pad=1.2, w_pad=0.8) + cbar_ax = fig.add_axes((0.905, 0.25, 0.015, 0.5)) + cbar = fig.colorbar(im, cax=cbar_ax, format="%.2f") + cbar.set_label("Luminance\n(normalized by max of each)", fontsize=11) + cbar.ax.tick_params(labelsize=10) + fig.savefig(out, dpi=200, facecolor="white") + plt.close(fig) + print(f"保存: {out}") + + +def draw_position_maps(out: Path, material: str) -> None: + """θ=0〜120° の輝度マップ(球 φ340 / 立方体 L=234).""" + r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR] + cells = [[(f"θ={th:g}°", maps.cond_name(shape, size, th, "board", 1)) for th in THETAS] + for shape, size in (("sphere", r_sphere), ("cube", l_cube))] + map_grid(cells, ["Sphere φ340", "Cube L=234"], out, material) + + +def draw_count_maps(out: Path, material: str) -> None: + """灯数 N=1〜4(θ=90°)の輝度マップ + 軸上 1 灯(球 φ340 / 立方体 L=234).""" + r_sphere, l_cube = maps.SIZE_PAIRS[REP_PAIR] + cells = [] + for shape, size in (("sphere", r_sphere), ("cube", l_cube)): + row = [("On-axis, N=1", maps.cond_name(shape, size, 0.0, "board", 1))] + row += [(f"θ=90°, N={n}", maps.cond_name(shape, size, 90.0, "board", n)) for n in COUNTS] + cells.append(row) + map_grid(cells, ["Sphere φ340", "Cube L=234"], out, material, label_fs=11) + + +# --------------------------------------------------------------------------- 曲線 +def load_analysis(material: str) -> pd.DataFrame: + """analysis.csv を読む.""" + return pd.read_csv(maps.output_root(material) / "analysis.csv") + + +def pair_label(shape: str, size: float) -> str: + """凡例のラベル.""" + return PAIR_LABELS[shape][float(size)] + + +def draw_position_curves(out: Path, material: str) -> None: + """構造 CoV と min の θ 依存(4 ペア・球は実線・立方体は破線).""" + df = load_analysis(material) + sel = df[(df["group"] == "position") & (df["aim"] == "board") & (df["n_led"] == 1) + & (df["rep"] == 1) & (df["azimuth_deg"] == 0)] + fig, axes = plt.subplots(1, 2, figsize=(11, 4.3)) + for pair, (r_sphere, l_cube) in enumerate(maps.SIZE_PAIRS): + for shape, size in (("sphere", r_sphere), ("cube", l_cube)): + d = sel[(sel["shape"] == shape) & (sel["size_mm"] == size)].sort_values("theta_deg") + if d.empty: + continue + ls, mk = SHAPE_STYLE[shape] + kw = {"color": PAIR_COLORS[pair], "ls": ls, "marker": mk, "ms": 5, "lw": 1.6, + "label": pair_label(shape, size)} + axes[0].plot(d["theta_deg"], d["structure_cov"], **kw) + axes[1].plot(d["theta_deg"], d["fullimage_min_max"], **kw) + axes[0].set_ylabel("Structural CoV (lower = more uniform)", fontsize=11) + axes[1].set_ylabel("min (max = 1, higher = more uniform)", fontsize=11) + axes[0].set_ylim(bottom=0) + for ax in axes: + ax.set_xlabel("LED polar angle θ [deg]", fontsize=11) + ax.set_xticks(range(0, 121, 30)) + ax.grid(alpha=0.3) + axes[0].legend(fontsize=8, ncol=2, title="solid: sphere, dashed: cube", title_fontsize=8) + fig.suptitle("θ = 0: bottom center (on-axis), 90: side wall, 120: just below the plate", + fontsize=10, y=0.995) + fig.tight_layout() + fig.savefig(out, dpi=200, facecolor="white") + plt.close(fig) + print(f"保存: {out}") + + +def draw_count_curves(out: Path, material: str) -> None: + """構造 CoV と min の N 依存(θ=90°・板向き・総光束一定).""" + df = load_analysis(material) + sel = df[(df["theta_deg"] == 90.0) & (df["aim"] == "board") & (df["rep"] == 1) + & (df["azimuth_deg"] == 0) & (df["n_led"].isin(COUNTS))] + fig, axes = plt.subplots(1, 2, figsize=(11, 4.3)) + for pair, (r_sphere, l_cube) in enumerate(maps.SIZE_PAIRS): + for shape, size in (("sphere", r_sphere), ("cube", l_cube)): + d = sel[(sel["shape"] == shape) & (sel["size_mm"] == size)].sort_values("n_led") + if len(d) < 2: + continue + ls, mk = SHAPE_STYLE[shape] + kw = {"color": PAIR_COLORS[pair], "ls": ls, "marker": mk, "ms": 5, "lw": 1.6, + "label": pair_label(shape, size)} + axes[0].plot(d["n_led"], d["structure_cov"], **kw) + axes[1].plot(d["n_led"], d["fullimage_min_max"], **kw) + axes[0].set_ylabel("Structural CoV (lower = more uniform)", fontsize=11) + axes[1].set_ylabel("min (max = 1, higher = more uniform)", fontsize=11) + axes[0].set_ylim(bottom=0) + for ax in axes: + ax.set_xlabel("Number of LEDs N (θ=90°, equally spaced, total flux constant)", + fontsize=10) + ax.set_xticks(COUNTS) + ax.grid(alpha=0.3) + axes[0].legend(fontsize=8, ncol=2, title="solid: sphere, dashed: cube", title_fontsize=8) + fig.tight_layout() + fig.savefig(out, dpi=200, facecolor="white") + plt.close(fig) + print(f"保存: {out}") + + +def main() -> int: + """5 枚の図を出力する.""" + parser = argparse.ArgumentParser(description="TEST_18 のスライド用図(英語)") + parser.add_argument("--material", choices=("legacy", "calibrated"), default="legacy") + parser.add_argument("--output", type=Path, default=None) + args = parser.parse_args() + out_dir = args.output or maps.output_root(args.material) / "slides" + out_dir.mkdir(parents=True, exist_ok=True) + draw_geometry(out_dir / "slide_led_geometry.png") + draw_position_maps(out_dir / "slide_led_position_maps.png", args.material) + draw_position_curves(out_dir / "slide_led_position_curves.png", args.material) + draw_count_maps(out_dir / "slide_led_count_maps.png", args.material) + draw_count_curves(out_dir / "slide_led_count_curves.png", args.material) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_spp_noise_maps.py b/scripts/build_spp_noise_maps.py new file mode 100644 index 0000000..93d5bcd --- /dev/null +++ b/scripts/build_spp_noise_maps.py @@ -0,0 +1,269 @@ +"""spp とノイズの関係を輝度マップで見せるパネル(TEST_19 の補助図・スライド用). + +TEST_19 の spp スイープ(output/spp_noise_sweep/)から,条件ごとに spp 256〜8192 の輝度マップを +横に並べる.各マップは σ=20 平滑化の 99 パーセンタイル(ロバスト最大)を 1 とし,同じ +カラースケールで描くので,spp が上がるにつれてノイズだけが減っていく様子が見える. +各セルの下にノイズ CoV と GPU 時間を書く. + +実行方法: + uv run python scripts/build_spp_noise_maps.py + uv run python scripts/build_spp_noise_maps.py --conditions sphere_d340_th000 sphere_d500_th090 + uv run python scripts/build_spp_noise_maps.py --normalize minmax # 各マップの min=0・max=1 + uv run python scripts/build_spp_noise_maps.py --normalize percentile # 1% 点=0・99% 点=1 + uv run python scripts/build_spp_noise_maps.py --normalize mean --lang en # 平均=1・英語版 + uv run python scripts/build_spp_noise_maps.py --normalize mean --lang en --slide \ + --conditions sphere_d340_th000 # スライド用 3×2(題・指標なし,spp だけ) + uv run python scripts/build_spp_noise_maps.py --lang en --slide --min-metric raw \ + --conditions sphere_d340_th000 # 最大=1 で正規化し,spp の横に画像全体 min を併記 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import matplotlib +import numpy as np +import pandas as pd +from scipy.ndimage import gaussian_filter, median_filter + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +SWEEP_DIR = ROOT / "output" / "spp_noise_sweep" +SPPS = [256, 512, 1024, 2048, 4096, 8192] +SEED = 0 +LABELS = { + "ja": { + "sphere_d340_th000": "球 φ340 軸上", + "sphere_d340_th090": "球 φ340 θ=90°", + "sphere_d500_th090": "球 φ500 θ=90°", + "cube_L234_th000": "立方体 L=234 軸上", + "cube_L234_th090": "立方体 L=234 θ=90°", + "cube_L340_th090": "立方体 L=340 θ=90°", + }, + "en": { + "sphere_d340_th000": "Sphere φ340, on-axis", + "sphere_d340_th090": "Sphere φ340, θ=90°", + "sphere_d500_th090": "Sphere φ500, θ=90°", + "cube_L234_th000": "Cube L=234, on-axis", + "cube_L234_th090": "Cube L=234, θ=90°", + "cube_L340_th090": "Cube L=340, θ=90°", + }, +} +TEXT = { + "ja": { + "cell": "ノイズ CoV {noise:.3f}\nGPU {t:.1f} s", + "cbar": "相対輝度", + "cbar_slide": {"mean": "輝度\n(ROI 平均で正規化)", + "robust_peak": "輝度\n(各マップの最大で正規化)", + "max": "輝度\n(各マップの最大で正規化)", + "minmax": "輝度\n(各マップの min/max で正規化)", + "percentile": "輝度\n(各マップの 1〜99% 点で正規化)"}, + "title": "spp と MC ノイズ({norm}・seed 0)", + "norm": {"minmax": "各マップの画素の最小 = 0・最大 = 1", + "percentile": "各マップの画素の 1% 点 = 0・99% 点 = 1", + "mean": "ROI 平均 = 1・共通スケール", + "robust_peak": "ロバスト最大 = 1 で正規化・共通スケール"}, + }, + "en": { + "cell": "noise CoV {noise:.3f}\nGPU {t:.1f} s", + "cbar": "relative luminance", + "cbar_slide": {"mean": "Luminance\n(normalized by mean)", + "robust_peak": "Luminance\n(normalized by max of each)", + "max": "Luminance\n(normalized by max of each)", + "minmax": "Luminance\n(normalized by min/max of each)", + "percentile": "Luminance\n(normalized by 1-99% of each)"}, + "title": "Luminance maps vs spp ({norm}, seed 0)", + "norm": {"minmax": "each map: pixel min = 0, max = 1", + "percentile": "each map: 1st percentile = 0, 99th percentile = 1", + "mean": "ROI mean = 1, common scale", + "robust_peak": "robust peak = 1, common scale"}, + }, +} + +plt.rcParams["font.family"] = ["Meiryo", "Yu Gothic", "MS Gothic", "sans-serif"] + + +def load_roi(condition: str, spp: int) -> np.ndarray: + """条件・spp の luminance.npy を読んで ROI を返す(seed 0).""" + path = SWEEP_DIR / f"{condition}_spp{spp}_s{SEED}" / "luminance.npy" + if not path.exists(): + raise FileNotFoundError(f"レンダ結果がありません: {path}") + return extract_roi_xywh(np.load(path).astype(np.float64), SIM_ROI) + + +def light_smooth(roi: np.ndarray, sigma: float = 4.0) -> np.ndarray: + """TEST_18 のマップパネルと同じ軽い平滑化(メディアン 5 px → ガウス σ px). + + σ=12 にすると画像全体 min/max 比(``fullimage_raw_min_max``)と同じ平滑化になり, + マップの見た目とラベルの min が一致する. + """ + return gaussian_filter(median_filter(roi, size=5), sigma=sigma) + + +def normalized(roi: np.ndarray, mode: str) -> np.ndarray: + """ROI を正規化して返す. + + Args: + roi: 輝度 ROI(生,または平滑化後). + mode: ``robust_peak`` は σ=20 平滑化の 99 パーセンタイルを 1 に,``minmax`` は各マップの + 画素の最小を 0・最大を 1 に(ノイズの裾まで含めた値域.低 spp はファイアフライで + ほぼ真っ黒になる),``percentile`` は画素の 1% 点を 0・99% 点を 1,``mean`` は + ROI 平均を 1 にする(共通スケール). + """ + if mode == "minmax": + return (roi - roi.min()) / (roi.max() - roi.min()) + if mode == "percentile": + lo, hi = np.percentile(roi, [1.0, 99.0]) + return (roi - lo) / (hi - lo) + if mode == "mean": + return roi / roi.mean() + if mode == "max": + return roi / roi.max() # 平滑化後のマップに使う(生の画素だとファイアフライで決まる) + return roi / np.percentile(gaussian_filter(roi, 20.0), 99.0) + + +def summary_rows(condition: str) -> pd.DataFrame: + """summary.csv から条件・seed 0 の行を spp 順で返す.""" + df = pd.read_csv(SWEEP_DIR / "summary.csv") + rows = df[(df["condition"] == condition) & (df["seed"] == SEED)] + return rows.set_index("spp").sort_index() + + +def draw(conditions: list[str], output: Path, vmin: float, vmax: float, mode: str, + lang: str = "ja") -> None: + """条件を行,spp を列にして輝度マップを描く.""" + text = TEXT[lang] + n_rows = len(conditions) + fig, axes = plt.subplots(n_rows, len(SPPS), figsize=(2.6 * len(SPPS), 2.9 * n_rows)) + axes = np.atleast_2d(axes) + for ci, condition in enumerate(conditions): + rows = summary_rows(condition) + for si, spp in enumerate(SPPS): + ax = axes[ci, si] + img = normalized(load_roi(condition, spp), mode) + im = ax.imshow(img, cmap="hot", vmin=vmin, vmax=vmax) + noise = rows.loc[spp, "noise_cov"] if spp in rows.index else np.nan + t_render = rows.loc[spp, "t_render"] if spp in rows.index else np.nan + if ci == 0: + ax.set_title(f"spp {spp}", fontsize=11) + ax.set_xlabel(text["cell"].format(noise=noise, t=t_render), fontsize=9) + ax.set_xticks([]) + ax.set_yticks([]) + axes[ci, 0].set_ylabel(LABELS[lang].get(condition, condition), fontsize=10) + fig.suptitle(text["title"].format(norm=text["norm"][mode]), fontsize=12) + fig.tight_layout(rect=(0, 0, 0.96, 0.96), h_pad=3.0) + cbar_ax = fig.add_axes((0.965, 0.15, 0.012, 0.7)) + fig.colorbar(im, cax=cbar_ax, label=text["cbar"]) + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=150) + plt.close(fig) + print(f"保存: {output}") + + +def draw_slide(condition: str, output: Path, vmin: float, vmax: float, mode: str, + lang: str = "en", n_cols: int = 3, min_col: str | None = None, + smooth: bool = False, smooth_sigma: float = 4.0) -> None: + """スライド貼り付け用: 1 筐体の spp 6 段階を n_cols 列 × 2 行に並べる(題・枠・指標なし). + + 各マップの下に ``spp=N`` を書き(``min_col`` を指定すると summary.csv のその列を + ``(min=0.93)`` として併記),右にカラーバーを置く(既存スライドの + 「Comparison by cube edge lengths」と同じ体裁). + """ + text = TEXT[lang] + rows = summary_rows(condition) + n_rows = int(np.ceil(len(SPPS) / n_cols)) + fig, axes = plt.subplots(n_rows, n_cols, figsize=(2.9 * n_cols + 1.3, 2.9 * n_rows)) + axes = np.atleast_2d(axes) + im = None + for i, spp in enumerate(SPPS): + ax = axes[i // n_cols, i % n_cols] + roi = load_roi(condition, spp) + img = normalized(light_smooth(roi, smooth_sigma) if smooth else roi, mode) + if smooth and mode == "robust_peak": + img = img / img.max() # 表示するマップ自身の最大を 1 に(max of each を文字どおりに) + im = ax.imshow(img, cmap="hot", vmin=vmin, vmax=vmax) + label = f"spp={spp}" + if min_col == "display": + label += f" (min={img.min():.3f})" # 表示しているマップそのものの最小(max=1) + elif min_col is not None and spp in rows.index: + label += f" (min={rows.loc[spp, min_col]:.3f})" + ax.set_xlabel(label, fontsize=14, labelpad=6) + ax.set_xticks([]) + ax.set_yticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + for ax in axes.flat[len(SPPS):]: + ax.axis("off") + fig.tight_layout(rect=(0, 0, 0.87, 1.0), h_pad=1.0, w_pad=1.0) + cbar_ax = fig.add_axes((0.875, 0.25, 0.016, 0.5)) + cbar = fig.colorbar(im, cax=cbar_ax, format="%.2f") + cbar.set_label(text["cbar_slide"][mode], fontsize=12) + cbar.ax.tick_params(labelsize=11) + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=200, facecolor="white") + plt.close(fig) + print(f"保存: {output}") + + +def main() -> int: + """パネルを描く.""" + parser = argparse.ArgumentParser(description="spp とノイズの輝度マップパネル") + parser.add_argument("--conditions", nargs="+", + default=["sphere_d340_th000", "sphere_d500_th090"]) + parser.add_argument("--normalize", + choices=("robust_peak", "minmax", "percentile", "mean", "max"), + default="robust_peak") + parser.add_argument("--vmin", type=float, default=None, + help="カラースケール下限(既定: robust_peak 0.5 / mean 0.5 / 他 0)") + parser.add_argument("--vmax", type=float, default=None, + help="カラースケール上限(既定: robust_peak 1.3 / mean 1.5 / 他 1)") + parser.add_argument("--lang", choices=("ja", "en"), default="ja", help="図中の文字の言語") + parser.add_argument("--slide", action="store_true", + help="スライド貼り付け用(--conditions の先頭 1 条件を 3×2 に並べる)") + parser.add_argument("--smooth", action="store_true", + help="--slide でマップに TEST_18 と同じ軽い平滑化を掛ける") + parser.add_argument("--smooth-sigma", type=float, default=4.0, + help="--smooth のガウス σ [px](12 にすると min ラベルの定義と一致)") + parser.add_argument("--min-metric", choices=("none", "corrected", "raw", "ring", "display"), + default="none", + help="--slide で spp の横に書く min/max 比: corrected=画像全体" + "(ノイズ補正あり)/ raw=画像全体(補正なし)/ ring=輪帯平均" + "/ display=表示マップ自身の最小(図と一致する)") + parser.add_argument("--output", type=Path, default=None, + help="出力 PNG(既定: spp_noise_maps.png / spp_noise_maps_minmax.png)") + args = parser.parse_args() + # スライド用は「最大 = 1」の意味を保つため上端を 1.0 に固定(超えた画素は白に飽和) + slide_lo = 0.85 if args.smooth else 0.5 + defaults = {"robust_peak": (slide_lo, 1.0) if args.slide else (0.5, 1.3), + "mean": (0.5, 1.5)}.get(args.normalize, (0.0, 1.0)) + vmin = args.vmin if args.vmin is not None else defaults[0] + vmax = args.vmax if args.vmax is not None else defaults[1] + suffix = ("" if args.normalize == "robust_peak" else f"_{args.normalize}") + suffix += "_en" if args.lang == "en" else "" + if args.slide: + condition = args.conditions[0] + min_col = {"corrected": "fullimage_min_max", "raw": "fullimage_raw_min_max", + "ring": "ring_min_max", "display": "display"}.get(args.min_metric) + if min_col is not None: + suffix += f"_min-{args.min_metric}" + if args.smooth: + suffix += f"_smooth{args.smooth_sigma:g}" + output = args.output or SWEEP_DIR / f"spp_noise_slide_{condition}{suffix}.png" + draw_slide(condition, output, vmin, vmax, args.normalize, args.lang, min_col=min_col, + smooth=args.smooth, smooth_sigma=args.smooth_sigma) + return 0 + output = args.output or SWEEP_DIR / f"spp_noise_maps{suffix}.png" + draw(args.conditions, output, vmin, vmax, args.normalize, args.lang) + return 0 + + +if __name__ == "__main__": + sys.exit(main())