diff --git a/scripts/build_blend_bottom_panel.py b/scripts/build_blend_bottom_panel.py index e55e21b..a89231e 100644 --- a/scripts/build_blend_bottom_panel.py +++ b/scripts/build_blend_bottom_panel.py @@ -1,6 +1,6 @@ -"""TEST_09 実験5: bottom 単灯 blendbsdf スイープ(バグ修正後再検証)の解析とパネル生成. +"""TEST_09 実験5: 単灯 blendbsdf スイープ(バグ修正後再検証)の解析とパネル生成. -sweep レンダ(output/blend_bottom/w{W}_a{A}/luminance.npy)の完了後に実行すること. +sweep レンダ(output/blend_bottom/{job}/luminance.npy)の完了後に実行すること. 旧 blend 実験(scripts/sweep_blend_bsdf.py,3 灯フルシーン・絶対指標)は面光源スケール軸 バグ込みの結果のため,本スクリプトは TEST_09 の「形」評価に統一して再検証する: @@ -8,21 +8,25 @@ - top/bot(ROI) = 上半分 mean / 下半分 mean(実機 bottom ≈ 0.931) - NCC(ROI, 対実機) = シミュ ROI を実機解像度へ resize して正規化相関(area diffuse 基準 0.720) +対象灯は --side で選ぶ(既定 bottom=フルグリッド 4×3.left / right は時間短縮のため +縮小グリッド w{0.05, 0.3}×α{0.1, 0.6} + Phase 2 の w0.6_a0.1). + 生成するファイル: - output/blend_bottom/summary.csv -- 全ジョブの形指標 - output/_slide_panels/blend_bottom_shape_roi.png -- 輝度マップ + 指標パネル(ROI 揃え) + output/blend_bottom/summary{_side}.csv -- 全ジョブの形指標 + output/_slide_panels/blend_{side}_shape_roi.png -- 輝度マップ + 指標パネル(ROI 揃え) 使い方: - uv run python scripts/build_blend_bottom_panel.py + uv run python scripts/build_blend_bottom_panel.py [--side {bottom,left,right}] 前提: - - output/blend_bottom/w{W}_a{A}/luminance.npy が存在すること - - output/depth_bottom/d16/luminance.npy(area diffuse 基準)が存在すること - - lightsim/luminance_maps/bottom_*.npy(実機単灯実測,ROI 適用済み)が存在すること + - output/blend_bottom/ に対象ジョブの luminance.npy が存在すること + - area diffuse 基準(bottom: output/depth_bottom/d16,left/right: output/sanity_fix_1024/) + - lightsim/luminance_maps/{side}_*.npy(実機単灯実測,ROI 適用済み)が存在すること """ from __future__ import annotations +import argparse import csv import sys from pathlib import Path @@ -45,14 +49,36 @@ ROOT = Path(__file__).resolve().parents[1] SWEEP_BASE = ROOT / "output" / "blend_bottom" -BASELINE_NPY = ROOT / "output" / "depth_bottom" / "d16" / "luminance.npy" -REAL_NPY = ROOT / "lightsim" / "luminance_maps" / "bottom_SmTIAS_QM_20260615_141420.npy" -PANEL_PATH = ROOT / "output" / "_slide_panels" / "blend_bottom_shape_roi.png" -SUMMARY_CSV = SWEEP_BASE / "summary.csv" +PANEL_DIR = ROOT / "output" / "_slide_panels" +REAL_DIR = ROOT / "lightsim" / "luminance_maps" -# スイープグリッド(configs/_tmp_blend_bottom/ と整合) -WEIGHTS = [0.05, 0.15, 0.3, 0.6] -ALPHAS = [0.1, 0.3, 0.6] +# 灯ごとの設定(configs/_tmp_blend_bottom/ のジョブ命名・グリッドと整合) +# baseline: area diffuse(純 Lambertian)の同条件出力. +# grid: (weights, alphas).left/right は縮小グリッド+Phase 2 の w0.6_a0.1 のみ存在し, +# 欠けセルはパネル上 [MISSING] 表示になる. +SIDES: dict[str, dict] = { + "bottom": { + "baseline": ROOT / "output" / "depth_bottom" / "d16" / "luminance.npy", + "real": REAL_DIR / "bottom_SmTIAS_QM_20260615_141420.npy", + "job_prefix": "", + "weights": [0.05, 0.15, 0.3, 0.6], + "alphas": [0.1, 0.3, 0.6], + }, + "left": { + "baseline": ROOT / "output" / "sanity_fix_1024" / "left" / "luminance.npy", + "real": REAL_DIR / "left_SmTIAS_QM_20260615_150300.npy", + "job_prefix": "left_", + "weights": [0.05, 0.3, 0.6], + "alphas": [0.1, 0.6], + }, + "right": { + "baseline": ROOT / "output" / "sanity_fix_1024" / "right" / "luminance.npy", + "real": REAL_DIR / "right_SmTIAS_QM_20260615_170948.npy", + "job_prefix": "right_", + "weights": [0.05, 0.3, 0.6], + "alphas": [0.1, 0.6], + }, +} # 共有 colorbar のパーセンタイル(mean=1 正規化後) VMIN_PERCENTILE = 1.0 @@ -113,6 +139,7 @@ def _build_panel( + side: str, job_rois: dict[str, np.ndarray], job_metrics: dict[str, dict[str, float]], baseline_roi: np.ndarray, @@ -121,12 +148,13 @@ real_metrics: dict[str, float], output_path: Path, ) -> None: - """(1 + len(WEIGHTS)) × 3 グリッドの形比較パネルを生成・保存する. + """(1 + len(weights)) × len(alphas) グリッドの形比較パネルを生成・保存する. - 先頭行に基準(area diffuse d16)と実機を置き,以下 weight × alpha グリッド. + 先頭行に基準(area diffuse)と実機を置き,以下 weight × alpha グリッド. 形の比較のため全画像を mean=1 に正規化し共有スケールで描画する. Args: + side: 対象灯("bottom" / "left" / "right").グリッド定義は SIDES から引く. job_rois: ジョブ名 → ROI 輝度配列. job_metrics: ジョブ名 → 形指標辞書. baseline_roi: area diffuse 基準の ROI 輝度配列. @@ -135,25 +163,28 @@ real_metrics: 実機の形指標辞書(NCC は 1.0). output_path: 保存先 PNG パス. """ + cfg = SIDES[side] + weights, alphas, prefix = cfg["weights"], cfg["alphas"], cfg["job_prefix"] + normed = {k: v / v.mean() for k, v in job_rois.items()} baseline_n = baseline_roi / baseline_roi.mean() real_n = real / real.mean() - all_vals = np.concatenate( - [baseline_n.ravel(), real_n.ravel()] + [v.ravel() for v in normed.values()] - ) - vmin = float(np.percentile(all_vals, VMIN_PERCENTILE)) - vmax = float(np.percentile(all_vals, VMAX_PERCENTILE)) - - n_rows = 1 + len(WEIGHTS) - n_cols = len(ALPHAS) + n_rows = 1 + len(weights) + n_cols = max(len(alphas), 2) fig, axes = plt.subplots( n_rows, n_cols, figsize=(n_cols * 4.2, n_rows * 4.0), constrained_layout=True ) + # 各セルを自身の 1–99 パーセンタイルで独立ストレッチする(形の比較専用パネル). + # 共有スケールだとレンジの広いセルに引きずられ個々の形のコントラストが薄まる. + # 厳密 min–max でなくパーセンタイルなのは,裾の外れ画素(実機は裾 2% が + # カラーレンジの約 4 割を占有,シミュは MC ノイズ)にレンジを食われないため. def _show(ax: plt.Axes, img: np.ndarray | None, title: str) -> None: if img is not None: - ax.imshow(img, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + cell_vmin = float(np.percentile(img, VMIN_PERCENTILE)) + cell_vmax = float(np.percentile(img, VMAX_PERCENTILE)) + ax.imshow(img, cmap="hot", vmin=cell_vmin, vmax=cell_vmax, aspect="equal") else: ax.set_facecolor("0.15") ax.set_title(title, fontsize=9, pad=4) @@ -163,23 +194,14 @@ return f"p/m={m['peak_mean']:.3f} t/b={m['top_bot']:.3f} NCC={m['ncc']:.3f}" # 先頭行: 基準 area diffuse / 実機 / 空 - # 実機はシミュよりレンジが狭く共有スケールでは形が潰れるため, - # 実機セルのみ自身の 1–99 パーセンタイルで正規化して形を可視化する - # (厳密 min–max では裾 2% の画素にカラーレンジの約 4 割を食われる. - # 共有スケール側と同じパーセンタイル流儀に揃える) - _show(axes[0][0], baseline_n, f"area diffuse (d16 基準)\n{_fmt(baseline_metrics)}") - real_vmin = float(np.percentile(real_n, VMIN_PERCENTILE)) - real_vmax = float(np.percentile(real_n, VMAX_PERCENTILE)) - axes[0][1].imshow(real_n, cmap="hot", vmin=real_vmin, vmax=real_vmax, aspect="auto") - axes[0][1].set_title( - f"実機 bottom(1–99% 正規化・独立スケール)\n{_fmt(real_metrics)}", fontsize=9, pad=4 - ) - axes[0][1].set_axis_off() - _show(axes[0][2], None, "") + _show(axes[0][0], baseline_n, f"area diffuse(基準)\n{_fmt(baseline_metrics)}") + _show(axes[0][1], real_n, f"実機 {side}\n{_fmt(real_metrics)}") + for col_idx in range(2, n_cols): + _show(axes[0][col_idx], None, "") - for row_idx, weight in enumerate(WEIGHTS, start=1): - for col_idx, alpha in enumerate(ALPHAS): - job = f"w{weight}_a{alpha}" + for row_idx, weight in enumerate(weights, start=1): + for col_idx, alpha in enumerate(alphas): + job = f"{prefix}w{weight}_a{alpha}" if job in normed: _show( axes[row_idx][col_idx], @@ -188,19 +210,14 @@ ) else: _show(axes[row_idx][col_idx], None, f"w={weight}, α={alpha}\n[MISSING]") + for col_idx in range(len(alphas), n_cols): + _show(axes[row_idx][col_idx], None, "") - fig.colorbar( - plt.cm.ScalarMappable(norm=plt.Normalize(vmin=vmin, vmax=vmax), cmap="hot"), - ax=axes, - fraction=0.015, - pad=0.02, - label="Luminance (mean=1 normalized)", - ) fig.suptitle( - "TEST_09 実験5 — bottom 単灯 blend BSDF 再検証(バグ修正後, spp=1024, d=16, ROI 揃え)\n" + f"TEST_09 実験5 — {side} 単灯 blend BSDF 再検証(バグ修正後, spp=1024, d=16, ROI 揃え)\n" + f"各セル独立 1–99% 正規化(形の比較専用・明るさの絶対比較は不可)/ " f"実機目標: peak/mean={real_metrics['peak_mean']:.3f}, " - f"top/bot={real_metrics['top_bot']:.3f} / " - f"area diffuse 基準 NCC={baseline_metrics['ncc']:.3f}", + f"top/bot={real_metrics['top_bot']:.3f}", fontsize=11, y=1.03, ) @@ -222,24 +239,37 @@ Returns: 終了コード(成功なら 0,入力不足なら 1). """ - if not REAL_NPY.exists(): - print(f"エラー: 実機 npy が見つかりません: {REAL_NPY}", file=sys.stderr) + parser = argparse.ArgumentParser(description="単灯 blend BSDF スイープの形指標解析とパネル生成") + parser.add_argument( + "--side", + choices=sorted(SIDES), + default="bottom", + help="対象灯(既定: bottom)", + ) + args = parser.parse_args() + side = args.side + cfg = SIDES[side] + weights, alphas, prefix = cfg["weights"], cfg["alphas"], cfg["job_prefix"] + + real_npy: Path = cfg["real"] + if not real_npy.exists(): + print(f"エラー: 実機 npy が見つかりません: {real_npy}", file=sys.stderr) return 1 - real = np.load(REAL_NPY).astype(np.float64) + real = np.load(real_npy).astype(np.float64) real_metrics = { "peak_mean": float(real.max() / real.mean()), "top_bot": float(real[: real.shape[0] // 2].mean() / real[real.shape[0] // 2 :].mean()), "ncc": 1.0, } - baseline_roi = load_sim_roi(BASELINE_NPY) + baseline_roi = load_sim_roi(cfg["baseline"]) baseline_metrics = calc_shape_metrics(baseline_roi, real) job_rois: dict[str, np.ndarray] = {} job_metrics: dict[str, dict[str, float]] = {} - for weight in WEIGHTS: - for alpha in ALPHAS: - job = f"w{weight}_a{alpha}" + for weight in weights: + for alpha in alphas: + job = f"{prefix}w{weight}_a{alpha}" try: roi = load_sim_roi(SWEEP_BASE / job / "luminance.npy") except FileNotFoundError: @@ -252,41 +282,44 @@ print("エラー: 有効なジョブが 1 件もありません.", file=sys.stderr) return 1 - # summary.csv - SUMMARY_CSV.parent.mkdir(parents=True, exist_ok=True) - with SUMMARY_CSV.open("w", encoding="utf-8", newline="") as f: + # summary CSV(bottom は既存名 summary.csv を維持) + summary_csv = SWEEP_BASE / ("summary.csv" if side == "bottom" else f"summary_{side}.csv") + summary_csv.parent.mkdir(parents=True, exist_ok=True) + with summary_csv.open("w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["job", "weight", "alpha", "peak_mean", "top_bot", "ncc"]) writer.writerow( - ["area_diffuse_d16", "", "", *(f"{baseline_metrics[k]:.4f}" for k in - ("peak_mean", "top_bot", "ncc"))] + ["area_diffuse", "", "", *(f"{baseline_metrics[k]:.4f}" for k in + ("peak_mean", "top_bot", "ncc"))] ) - for weight in WEIGHTS: - for alpha in ALPHAS: - job = f"w{weight}_a{alpha}" + for weight in weights: + for alpha in alphas: + job = f"{prefix}w{weight}_a{alpha}" if job in job_metrics: m = job_metrics[job] writer.writerow( [job, weight, alpha, f"{m['peak_mean']:.4f}", f"{m['top_bot']:.4f}", f"{m['ncc']:.4f}"] ) - print(f"summary 保存: {SUMMARY_CSV}") + print(f"summary 保存: {summary_csv}") # コンソール表 - print(f"\n{'job':<16}{'peak/mean':>10}{'top/bot':>10}{'NCC':>8}") - print(f"{'area diffuse':<16}{baseline_metrics['peak_mean']:>10.3f}" + print(f"\n{'job':<22}{'peak/mean':>10}{'top/bot':>10}{'NCC':>8}") + print(f"{'area diffuse':<22}{baseline_metrics['peak_mean']:>10.3f}" f"{baseline_metrics['top_bot']:>10.3f}{baseline_metrics['ncc']:>8.3f}") - for weight in WEIGHTS: - for alpha in ALPHAS: - job = f"w{weight}_a{alpha}" + for weight in weights: + for alpha in alphas: + job = f"{prefix}w{weight}_a{alpha}" if job in job_metrics: m = job_metrics[job] - print(f"{job:<16}{m['peak_mean']:>10.3f}{m['top_bot']:>10.3f}{m['ncc']:>8.3f}") - print(f"{'実機 bottom':<16}{real_metrics['peak_mean']:>10.3f}" + print(f"{job:<22}{m['peak_mean']:>10.3f}{m['top_bot']:>10.3f}{m['ncc']:>8.3f}") + print(f"{'実機 ' + side:<22}{real_metrics['peak_mean']:>10.3f}" f"{real_metrics['top_bot']:>10.3f}{'-':>8}") + panel_path = PANEL_DIR / f"blend_{side}_shape_roi.png" _build_panel( - job_rois, job_metrics, baseline_roi, baseline_metrics, real, real_metrics, PANEL_PATH + side, job_rois, job_metrics, baseline_roi, baseline_metrics, real, real_metrics, + panel_path, ) return 0 diff --git a/scripts/build_blend_bsdf_panel.py b/scripts/build_blend_bsdf_panel.py index db92d5d..47098a2 100644 --- a/scripts/build_blend_bsdf_panel.py +++ b/scripts/build_blend_bsdf_panel.py @@ -164,14 +164,6 @@ job_metrics: job 名 → metrics 辞書. output_path: 保存先 PNG パス. """ - # 共有 vmin/vmax: 全 9 ジョブ + ベースラインの 1%/99% パーセンタイル - all_vals = np.concatenate( - [baseline_roi.ravel()] - + [img.ravel() for img in job_images.values()] - ) - vmin = float(np.percentile(all_vals, VMIN_PERCENTILE)) - vmax = float(np.percentile(all_vals, VMAX_PERCENTILE)) - n_rows = len(WEIGHTS) n_cols = len(ALPHAS) @@ -192,7 +184,11 @@ cov_str = f"{float(m.get('cov', 0)):.4f}" if m.get("cov") else "N/A" grad_val = m.get("gradient_magnitude") grad_str = f"{float(grad_val):.2f}%" if grad_val else "N/A" - ax.imshow(roi, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + # 各セル独立の 1–99 パーセンタイルでストレッチ(形の比較専用. + # 共有スケールだとレンジの広いセルに引きずられ形のコントラストが薄まる) + cell_vmin = float(np.percentile(roi, VMIN_PERCENTILE)) + cell_vmax = float(np.percentile(roi, VMAX_PERCENTILE)) + ax.imshow(roi, cmap="hot", vmin=cell_vmin, vmax=cell_vmax, aspect="equal") title = f"w={weight}, α={alpha}\nCoV={cov_str} grad={grad_str}" else: # ジョブが失敗していた場合は空セル @@ -202,21 +198,10 @@ ax.set_title(title, fontsize=9, pad=4) ax.set_axis_off() - # 共有 colorbar - fig.colorbar( - plt.cm.ScalarMappable( - norm=plt.Normalize(vmin=vmin, vmax=vmax), cmap="hot" - ), - ax=axes, - fraction=0.015, - pad=0.02, - label="Luminance (mean-anchored)", - ) - fig.suptitle( - f"Step 8-14 blend BSDF sweep — luminance maps\n" - f"shared vmin/vmax = [{vmin:.2f}, {vmax:.2f}] (1%–99% of all 9 jobs + baseline)\n" - f"rows=weight (specular ratio), cols=alpha (GGX roughness)", + "Step 8-14 blend BSDF sweep — luminance maps\n" + "per-cell 1%-99% normalization (shape comparison only; no absolute scale)\n" + "rows=weight (specular ratio), cols=alpha (GGX roughness)", fontsize=10, y=1.01, )