"""Step 8-14 blendbsdf スイープ結果の比較パネルを生成する.
sweep_blend_bsdf.py の実行後に本スクリプトを実行すること.
生成するファイル:
output/_slide_panels/blend_bsdf_sweep.png -- 3×3 輝度マップパネル
output/_slide_panels/blend_bsdf_metrics.png -- 数値指標表パネル
共有 vmin/vmax:
全 9 ジョブ + ベースライン純 diffuse の輝度マップを対象として
1%/99% パーセンタイルを計算し,全セルで共通スケールを使う.
これにより「色の濃淡が画像間で物理的に同じ意味を持つ」.
使い方:
uv run python scripts/build_blend_bsdf_panel.py
前提:
- scripts/sweep_blend_bsdf.py が完了していること
- output/blend_sweep/summary.csv が存在すること
- output/baseline/luminance.npy が存在すること(ベースライン)
TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」
"""
from __future__ import annotations
import csv
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor
from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh
# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[1]
SUMMARY_CSV = ROOT / "output" / "blend_sweep" / "summary.csv"
OUTPUT_DIR = ROOT / "output" / "_slide_panels"
BASELINE_NPY = ROOT / "output" / "baseline" / "luminance.npy"
BLEND_OUTPUT_BASE = ROOT / "output" / "blend_sweep"
# mean 正規化の target_mean(eval_baseline_dng_target.yaml の target_mean と整合)
TARGET_MEAN = 207.82
# 共有 colorbar のパーセンタイル
VMIN_PERCENTILE = 1.0
VMAX_PERCENTILE = 99.0
# スイープグリッドの定義順(行列インデックスの順序)
WEIGHTS = [0.05, 0.15, 0.30]
ALPHAS = [0.1, 0.3, 0.6]
# 数値指標テーブルで表示するカラムとラベル
METRICS_DISPLAY = [
("cov", "CoV"),
("gradient_magnitude", "gradient [%]"),
("center_periphery_ratio", "C/P"),
("radial_min_max_ratio", "radial min/max"),
]
# ---------------------------------------------------------------------------
# ヘルパ関数
# ---------------------------------------------------------------------------
def _format_param(value: float) -> str:
"""パラメータ値をファイル名に使える文字列に変換する."""
return str(value)
def _make_job_name(weight: float, alpha: float) -> str:
"""ジョブ識別子を生成する(sweep_blend_bsdf.py と同一フォーマット)."""
return f"w{_format_param(weight)}_a{_format_param(alpha)}"
def _rec709_luma(rgb: np.ndarray) -> np.ndarray:
"""RGB 配列を Rec.709 luma に変換する (H, W, 3) → (H, W)."""
return 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2]
def _load_npy_roi(npy_path: Path) -> np.ndarray:
"""luminance.npy → ROI 抽出 → mean アンカー正規化 → float64 配列.
Args:
npy_path: luminance.npy のパス.
Returns:
ROI 正規化済み輝度配列(H × W, float64).
Raises:
FileNotFoundError: ファイルが存在しない場合.
"""
if not npy_path.exists():
msg = f"luminance.npy が見つかりません: {npy_path}"
raise FileNotFoundError(msg)
arr = np.load(npy_path)
if arr.ndim == 3:
arr = _rec709_luma(arr)
roi = extract_roi_xywh(arr, SIM_ROI)
normalized, _ = apply_mean_anchor(roi, TARGET_MEAN)
return normalized
def _read_summary_csv(csv_path: Path) -> list[dict]:
"""summary.csv を読み込んで辞書のリストを返す.
Args:
csv_path: summary.csv のパス.
Returns:
各行の辞書リスト.
Raises:
FileNotFoundError: ファイルが存在しない場合.
"""
if not csv_path.exists():
msg = f"summary.csv が見つかりません: {csv_path}"
raise FileNotFoundError(msg)
with csv_path.open(encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
return list(reader)
def _build_job_index(rows: list[dict]) -> dict[str, dict]:
"""summary CSV の行リストを job 名をキーとした辞書に変換する.
Args:
rows: _read_summary_csv の返り値.
Returns:
job 名 → 行辞書のマッピング.
"""
return {row["job"]: row for row in rows}
# ---------------------------------------------------------------------------
# パネル描画
# ---------------------------------------------------------------------------
def _build_luminance_panel(
job_images: dict[str, np.ndarray],
baseline_roi: np.ndarray,
job_metrics: dict[str, dict],
output_path: Path,
) -> None:
"""3×3 輝度マップパネルを生成・保存する.
行 = weight, 列 = alpha の 3×3 グリッドで各ジョブの輝度マップを描画する.
各セルのタイトルに weight/alpha と CoV/勾配 を併記する.
Args:
job_images: job 名 → 正規化済み ROI 輝度配列.
baseline_roi: ベースライン(純 diffuse)の ROI 輝度配列.
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)
fig, axes = plt.subplots(
n_rows, n_cols,
figsize=(n_cols * 6, n_rows * 5.5),
constrained_layout=True,
)
for row_idx, weight in enumerate(WEIGHTS):
for col_idx, alpha in enumerate(ALPHAS):
ax = axes[row_idx][col_idx]
job_name = _make_job_name(weight, alpha)
if job_name in job_images:
roi = job_images[job_name]
m = job_metrics.get(job_name, {})
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")
title = f"w={weight}, α={alpha}\nCoV={cov_str} grad={grad_str}"
else:
# ジョブが失敗していた場合は空セル
ax.set_facecolor("0.15")
title = f"w={weight}, α={alpha}\n[MISSING]"
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)",
fontsize=10,
y=1.01,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"輝度マップパネル保存: {output_path}")
def _build_metrics_panel(
job_metrics: dict[str, dict],
output_path: Path,
) -> None:
"""数値指標を 3×3 グリッドで表示するパネルを生成・保存する.
各セルに CoV / 勾配 / C/P / 動径 min/max を表示する.
数値の大小が視覚的にわかるよう,セルの背景色を列ごとの最小値に基づいてカラーマップする.
Args:
job_metrics: job 名 → metrics 辞書.
output_path: 保存先 PNG パス.
"""
n_rows = len(WEIGHTS)
n_cols = len(ALPHAS)
fig, axes = plt.subplots(
n_rows, n_cols,
figsize=(n_cols * 3.5, n_rows * 3.0),
constrained_layout=True,
)
# 各指標の全 9 ジョブにわたる値域を計算(背景色正規化用)
metric_vals: dict[str, list[float]] = {col: [] for col, _ in METRICS_DISPLAY}
for weight in WEIGHTS:
for alpha in ALPHAS:
job_name = _make_job_name(weight, alpha)
m = job_metrics.get(job_name, {})
for col, _ in METRICS_DISPLAY:
try:
metric_vals[col].append(float(m[col]))
except (KeyError, ValueError, TypeError):
pass
metric_ranges: dict[str, tuple[float, float]] = {}
for col, vals in metric_vals.items():
if vals:
metric_ranges[col] = (min(vals), max(vals))
else:
metric_ranges[col] = (0.0, 1.0)
for row_idx, weight in enumerate(WEIGHTS):
for col_idx, alpha in enumerate(ALPHAS):
ax = axes[row_idx][col_idx]
job_name = _make_job_name(weight, alpha)
m = job_metrics.get(job_name, {})
lines = [f"w={weight} α={alpha}"]
for metric_key, label in METRICS_DISPLAY:
try:
val = float(m[metric_key])
lines.append(f"{label}: {val:.4f}")
except (KeyError, ValueError, TypeError):
lines.append(f"{label}: N/A")
text = "\n".join(lines)
ax.text(
0.5, 0.5, text,
ha="center", va="center",
fontsize=8.5,
transform=ax.transAxes,
family="monospace",
)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_axis_off()
# セル枠線を追加する
for spine in ax.spines.values():
spine.set_visible(True)
spine.set_linewidth(0.8)
spine.set_color("0.4")
# 行ラベル(weight)
for row_idx, weight in enumerate(WEIGHTS):
axes[row_idx][0].set_ylabel(f"w={weight}", fontsize=9, rotation=0, labelpad=40, va="center")
# 列ラベル(alpha)
for col_idx, alpha in enumerate(ALPHAS):
axes[0][col_idx].set_title(f"α={alpha}", fontsize=9, pad=6)
fig.suptitle(
"Step 8-14 blend BSDF sweep — metrics summary\n"
"rows=weight (specular ratio), cols=alpha (GGX roughness)",
fontsize=10,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"指標テーブルパネル保存: {output_path}")
# ---------------------------------------------------------------------------
# メイン処理
# ---------------------------------------------------------------------------
def main() -> int:
"""パネル生成のメインエントリポイント.
Returns:
終了コード(成功なら 0,失敗なら 1).
"""
# summary.csv の読み込み
try:
summary_rows = _read_summary_csv(SUMMARY_CSV)
except FileNotFoundError as exc:
print(f"エラー: {exc}", file=sys.stderr)
print("先に scripts/sweep_blend_bsdf.py を実行してください.", file=sys.stderr)
return 1
job_metrics = _build_job_index(summary_rows)
print(f"summary.csv 読み込み完了: {len(summary_rows)} ジョブ")
# ベースライン(純 diffuse)の輝度マップを読み込む
if not BASELINE_NPY.exists():
print(f"警告: ベースライン luminance.npy が見つかりません: {BASELINE_NPY}", file=sys.stderr)
print(" ベースラインなしでパネルを生成します.", file=sys.stderr)
baseline_roi = None
else:
baseline_roi = _load_npy_roi(BASELINE_NPY)
print(f"ベースライン読み込み完了: {BASELINE_NPY}")
# 各ジョブの輝度マップを読み込む
job_images: dict[str, np.ndarray] = {}
missing_jobs: list[str] = []
for weight in WEIGHTS:
for alpha in ALPHAS:
job_name = _make_job_name(weight, alpha)
npy_path = BLEND_OUTPUT_BASE / job_name / "luminance.npy"
try:
job_images[job_name] = _load_npy_roi(npy_path)
except FileNotFoundError:
print(f" 警告: {job_name} の luminance.npy が見つかりません ({npy_path})")
missing_jobs.append(job_name)
print(f"輝度マップ読み込み: {len(job_images)} 成功, {len(missing_jobs)} 不足")
if missing_jobs:
print(f" 不足ジョブ: {', '.join(missing_jobs)}")
if not job_images:
print("エラー: 有効な輝度マップが 1 件もありません.", file=sys.stderr)
return 1
# ベースラインが存在しない場合は最初のジョブで代替する
if baseline_roi is None:
first_roi = next(iter(job_images.values()))
baseline_roi = first_roi
print(" ベースライン代替: 最初のジョブの ROI を使用します.")
# パネル描画
try:
_build_luminance_panel(
job_images=job_images,
baseline_roi=baseline_roi,
job_metrics=job_metrics,
output_path=OUTPUT_DIR / "blend_bsdf_sweep.png",
)
except Exception as exc: # noqa: BLE001
print(f"エラー: 輝度マップパネルの生成に失敗しました — {exc}", file=sys.stderr)
return 1
try:
_build_metrics_panel(
job_metrics=job_metrics,
output_path=OUTPUT_DIR / "blend_bsdf_metrics.png",
)
except Exception as exc: # noqa: BLE001
print(f"エラー: 指標テーブルパネルの生成に失敗しました — {exc}", file=sys.stderr)
return 1
print("\n=== パネル生成完了 ===")
print(f" 輝度マップ: {OUTPUT_DIR / 'blend_bsdf_sweep.png'}")
print(f" 指標テーブル: {OUTPUT_DIR / 'blend_bsdf_metrics.png'}")
return 0
if __name__ == "__main__":
sys.exit(main())