Newer
Older
SmTIAS-LightSim / scripts / build_blend_bsdf_panel.py
"""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

# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------

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"

# ROI 設定(configs/eval_baseline.yaml / eval_baseline_dng_target.yaml と整合)
SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450}

# 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 _extract_roi(arr: np.ndarray, roi: dict) -> np.ndarray:
    """ROI 矩形を切り出して float64 に変換する."""
    x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"]
    return arr[y: y + h, x: x + w].astype(np.float64)


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(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())