Newer
Older
SmTIAS-LightSim / scripts / sweep_bsdf_grid_fix.py
"""面法線修正後の ρ × ω × α 再スイープ(TEST_12 後の条件引き直し).

面法線バグ(TEST_12)修正前に実施した `sweep_reflectance.py` / `sweep_blend_bsdf.py`
の結論は,評価板のシェーディング法線アーティファクトが不均一性の 6 割を占めていた
状態で得たものであり無効.本スクリプトは同じ 3 軸を **修正後の条件で引き直し**,
実機(全灯)に最も近い組合せを探す.

スイープ軸:
    ρ  : diffuse reflectance(内壁 printer_white の拡散反射率)
    ω  : blendbsdf weight(roughconductor 成分の混合比.0 なら純 diffuse)
    α  : roughconductor の GGX 粗さ

固定条件(既存レンダと完全に同一に保つ):
    spp=1024 / max_depth=16 / 512x512 / llvm_ad_rgb / path / orthographic
    → configs/_tmp_fn_full_base.yaml をテンプレートとし materials のみ書き換える

探索戦略(無人実行を想定):
    ρ×ω×α の全数グリッドを,粗い ρ(COARSE_RHO)→細かい ρ(FINE_RHO)の 2 パスで流す.
    時間予算で打ち切られても ρ 軸が粗いなりに全域カバーされている状態を保つための順序.
    1 ジョブ完了ごとに summary.csv を書き直し,既にレンダ済みのジョブは再利用する
    (基準点 ρ=0.85/ω=0.05/α=0.1 は output/full_base_fix/ を再利用し再レンダしない).

出力(1 ジョブ完了ごとに逐次追記するので途中終了しても結果は残る):
    output/bsdf_grid_fix/<job>/luminance.npy, metrics.csv
    output/bsdf_grid_fix/summary.csv
    configs/_tmp_bsdf_grid/scene_<job>.yaml

実行方法:
    uv run python scripts/sweep_bsdf_grid_fix.py [--budget-hours 5.5]
"""

from __future__ import annotations

import argparse
import copy
import csv
import subprocess
import sys
import time
from pathlib import Path

import numpy as np
import yaml
from scipy.ndimage import gaussian_filter, median_filter
from skimage.transform import resize

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
from sm_tias_lightsim.evaluation.shape_metrics import calc_ncc  # noqa: E402

ROOT = Path(__file__).resolve().parents[1]

TEMPLATE_YAML = ROOT / "configs" / "_tmp_fn_full_base.yaml"
EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target.yaml"
TMP_DIR = ROOT / "configs" / "_tmp_bsdf_grid"
OUTPUT_BASE = ROOT / "output" / "bsdf_grid_fix"
SUMMARY_CSV = OUTPUT_BASE / "summary.csv"

REAL_FULL = ROOT / "lightsim" / "luminance_maps" / "full_MiniTIAS_QM_20260601_124500.npy"
REFERENCE_JOB_DIR = ROOT / "output" / "full_base_fix"

# 基準点(= output/full_base_fix/ を生成した条件)
REF_RHO, REF_OMEGA, REF_ALPHA = 0.85, 0.05, 0.1

# 全数グリッド.1 ジョブ約 200 秒(実測)なので 5.5h でおよそ 90 ジョブ入る.
# 粗パス(COARSE_RHO)を先に流し切ってから細パス(FINE_RHO)で ρ を埋める順序にし,
# 時間予算で打ち切られても ρ 軸が粗いなりに全域カバーされている状態を保つ.
COARSE_RHO = [0.75, 0.85, 0.90]
FINE_RHO = [0.78, 0.82, 0.88]
OMEGA_VALUES = [0.0, 0.05, 0.15, 0.30]
ALPHA_VALUES = [0.1, 0.3, 0.6]

# 実機(全灯)の指標: configs/eval_baseline_dng_target.yaml ヘッダの記録値
REAL_COV = 0.0695
REAL_CP = 1.0911
REAL_GRADIENT = 8.35
REAL_RADIAL = 0.860

SUMMARY_COLUMNS = [
    "job", "rho", "omega", "alpha", "stage", "elapsed_s",
    "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
    "ncc_smooth", "peak_mean_smooth", "top_bot_smooth",
    "e_cov", "e_cp", "e_grad", "e_radial", "e_ncc", "score",
    "truncation_loss_pct",
]


def job_name(rho: float, omega: float, alpha: float) -> str:
    """ρ/ω/α からジョブ名(ディレクトリ名)を作る."""
    return f"r{rho:.2f}_w{omega:.2f}_a{alpha:.1f}"


def truncation_loss_pct(rho: float, omega: float) -> float:
    """max_depth=16 で取りこぼす相対エネルギー [%] を実効反射率から見積もる.

    blendbsdf の roughconductor 成分は material="none"(反射率 1.0 の完全反射体)なので
    実効的な壁の反射率は rho*(1-omega) + omega となり,rho 単体より高くなる.
    TEST_13 の check_truncation_bias と同じ rho_eff^(max_depth-1) を用いる.
    """
    rho_eff = rho * (1.0 - omega) + omega
    return round(100.0 * rho_eff ** 15, 2)


def build_scene_yaml(rho: float, omega: float, alpha: float, dest: Path) -> None:
    """テンプレートの printer_white だけを差し替えたシーン YAML を書き出す.

    ω=0 のときは blendbsdf を使わず純 diffuse にする(weight=0 の縮退を避ける).
    他のセクション(geometry / lights / sensor / render)は一切変更しない.
    """
    config = yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8"))
    config = copy.deepcopy(config)
    if omega <= 0.0:
        material = {"type": "diffuse", "reflectance": [rho, rho, rho]}
    else:
        material = {
            "type": "blendbsdf",
            "weight": omega,
            "components": [
                {"type": "diffuse", "reflectance": [rho, rho, rho]},
                {"type": "roughconductor", "alpha": alpha, "distribution": "ggx"},
            ],
        }
    config["materials"]["printer_white"] = material
    config["name"] = f"bsdf_grid_{job_name(rho, omega, alpha)}"
    # メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため,
    # テンプレート(configs/ 直下)から別ディレクトリへ移す時点で絶対パス化する.
    for mesh in config["geometry"]["meshes"]:
        mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve())
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8")


def _smooth(arr: np.ndarray, is_sim: bool) -> np.ndarray:
    """build_full_vs_singles_panel.py と同一の平滑化(median5 → gauss σ4)."""
    if arr.ndim == 3:
        arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2]
    arr = arr.astype(np.float64)
    if is_sim:
        arr = extract_roi_xywh(arr, SIM_ROI)
    return gaussian_filter(median_filter(arr, size=5), sigma=4)


def _shape_stats(sim_path: Path, real_smooth: np.ndarray) -> dict[str, float]:
    """平滑化後の NCC 対実機・peak/mean・top/bot を返す(MC ノイズ支配を避けるため平滑後)."""
    sim = _smooth(np.load(sim_path), True)
    resized = resize(sim, real_smooth.shape, anti_aliasing=True)
    half = sim.shape[0] // 2
    return {
        "ncc_smooth": float(calc_ncc(resized, real_smooth)),
        "peak_mean_smooth": float(sim.max() / sim.mean()),
        "top_bot_smooth": float(sim[:half].mean() / sim[half:].mean()),
    }


def _score(metrics: dict[str, float]) -> dict[str, float]:
    """実機との相対乖離から総合スコアを組む(小さいほど実機に近い).

    形(NCC)と振幅系 4 指標(CoV / C-P / 勾配 / 動径 min-max)を半々で重み付けする.
    C/P と動径 min/max は「1 からの超過分」で相対化する(1 近傍で比が鈍るため).
    """
    e_cov = abs(metrics["cov"] - REAL_COV) / REAL_COV
    e_cp = abs((metrics["center_periphery_ratio"] - 1.0) - (REAL_CP - 1.0)) / (REAL_CP - 1.0)
    e_grad = abs(metrics["gradient_magnitude"] - REAL_GRADIENT) / REAL_GRADIENT
    e_radial = (abs((1.0 - metrics["radial_min_max_ratio"]) - (1.0 - REAL_RADIAL))
                / (1.0 - REAL_RADIAL))
    e_ncc = 1.0 - metrics["ncc_smooth"]
    amplitude = (e_cov + e_cp + e_grad + e_radial) / 4.0
    return {
        "e_cov": e_cov, "e_cp": e_cp, "e_grad": e_grad, "e_radial": e_radial,
        "e_ncc": e_ncc, "score": 0.5 * amplitude + 0.5 * e_ncc,
    }


def read_metrics_csv(path: Path) -> dict[str, float]:
    """sm-evaluate が出す metrics.csv の 1 行目を辞書で返す."""
    with path.open(encoding="utf-8") as fh:
        row = next(csv.DictReader(fh))
    out = {}
    for key in ("cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio"):
        out[key] = float(row[key])
    return out


def run_job(rho: float, omega: float, alpha: float, stage: str,
            real_smooth: np.ndarray, log) -> dict[str, float] | None:
    """1 条件をレンダ→評価→指標算出まで実行して集約行を返す(失敗時 None).

    既に metrics.csv と luminance.npy が揃っているジョブは再レンダせず再利用する
    (中断・再開や,グリッドを広げて流し直すときに無駄な計算をしないため).
    """
    name = job_name(rho, omega, alpha)
    out_dir = OUTPUT_BASE / name
    scene_yaml = TMP_DIR / f"scene_{name}.yaml"
    if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists():
        row = {"job": name, "rho": rho, "omega": omega, "alpha": alpha,
               "stage": stage + "(再利用)", "elapsed_s": 0.0}
        row.update(read_metrics_csv(out_dir / "metrics.csv"))
        row.update(_shape_stats(out_dir / "luminance.npy", real_smooth))
        row.update(_score(row))
        row["truncation_loss_pct"] = truncation_loss_pct(rho, omega)
        log(f"--- {stage}: {name} → 既存結果を再利用 (score={row['score']:.4f})")
        return row
    log(f"--- {stage}: {name} (rho={rho}, omega={omega}, alpha={alpha}) ---")
    build_scene_yaml(rho, omega, alpha, scene_yaml)
    out_dir.mkdir(parents=True, exist_ok=True)
    start = time.time()
    for cmd in (
        ["uv", "run", "sm-render", "--scene", str(scene_yaml), "--output", str(out_dir)],
        ["uv", "run", "sm-evaluate", "--image", str(out_dir / "luminance.npy"),
         "--config", str(EVAL_CONFIG_YAML), "--output", str(out_dir)],
    ):
        result = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
        if result.returncode != 0:
            log(f"  !! 失敗 ({cmd[2]}): {result.stderr[-600:]}")
            return None
    elapsed = time.time() - start
    row = {"job": name, "rho": rho, "omega": omega, "alpha": alpha,
           "stage": stage, "elapsed_s": round(elapsed, 1)}
    row.update(read_metrics_csv(out_dir / "metrics.csv"))
    row.update(_shape_stats(out_dir / "luminance.npy", real_smooth))
    row.update(_score(row))
    row["truncation_loss_pct"] = truncation_loss_pct(rho, omega)
    log(f"  -> {elapsed:.0f}s  CoV={row['cov']:.4f} (実機 {REAL_COV})  "
        f"NCC={row['ncc_smooth']:.4f}  勾配={row['gradient_magnitude']:.2f}%  "
        f"score={row['score']:.4f}")
    return row


def append_summary(rows: list[dict[str, float]]) -> None:
    """集約 CSV を毎回まるごと書き直す(途中終了でも最新状態が残る)."""
    OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
    ordered = sorted(rows, key=lambda r: r["score"])
    with SUMMARY_CSV.open("w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS)
        writer.writeheader()
        for row in ordered:
            writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS})


def reference_row(real_smooth: np.ndarray, log) -> dict[str, float] | None:
    """基準点は既存の output/full_base_fix/ を再利用する(再レンダしない)."""
    metrics_csv = REFERENCE_JOB_DIR / "metrics.csv"
    luminance = REFERENCE_JOB_DIR / "luminance.npy"
    if not (metrics_csv.exists() and luminance.exists()):
        log("  基準点 output/full_base_fix/ が無いため再レンダします")
        return run_job(REF_RHO, REF_OMEGA, REF_ALPHA, "stage1", real_smooth, log)
    row = {"job": job_name(REF_RHO, REF_OMEGA, REF_ALPHA), "rho": REF_RHO,
           "omega": REF_OMEGA, "alpha": REF_ALPHA, "stage": "reference", "elapsed_s": 0.0}
    row.update(read_metrics_csv(metrics_csv))
    row.update(_shape_stats(luminance, real_smooth))
    row.update(_score(row))
    row["truncation_loss_pct"] = truncation_loss_pct(REF_RHO, REF_OMEGA)
    log(f"  基準点(既存 full_base_fix 再利用): CoV={row['cov']:.4f} "
        f"NCC={row['ncc_smooth']:.4f} score={row['score']:.4f}")
    return row


def build_grid(rho_values: list[float]) -> list[tuple[float, float, float]]:
    """指定した ρ 値集合について ω×α の全組合せを返す.

    ω=0(純 diffuse)は α に依存しないので α は 1 値だけ回す.
    """
    combos = []
    for rho in rho_values:
        for omega in OMEGA_VALUES:
            for alpha in (ALPHA_VALUES[:1] if omega <= 0.0 else ALPHA_VALUES):
                combos.append((rho, omega, alpha))
    return combos


def main() -> int:
    """段階スイープを時間予算内で実行するメインエントリポイント."""
    parser = argparse.ArgumentParser(description="面法線修正後の ρ×ω×α 再スイープ")
    parser.add_argument("--budget-hours", type=float, default=5.5,
                        help="全体の時間予算(時間).超過が見込まれる時点で打ち切る")
    args = parser.parse_args()

    deadline = time.time() + args.budget_hours * 3600.0
    log_path = ROOT / "output" / "_sweep_bsdf_grid_fix.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    log_file = log_path.open("w", encoding="utf-8")

    def log(message: str) -> None:
        print(message, flush=True)
        log_file.write(message + "\n")
        log_file.flush()

    if not REAL_FULL.exists():
        log(f"エラー: 実機マップ {REAL_FULL} が見つかりません")
        return 1
    real_smooth = _smooth(np.load(REAL_FULL), False)

    log(f"=== ρ×ω×α 再スイープ(面法線修正後)予算 {args.budget_hours}h ===")
    log(f"  固定条件: spp=1024 / max_depth=16 / 512^2({TEMPLATE_YAML.name} 準拠)")
    log(f"  基準点: rho={REF_RHO}, omega={REF_OMEGA}, alpha={REF_ALPHA}")

    rows: list[dict[str, float]] = []
    ref = reference_row(real_smooth, log)
    if ref is not None:
        rows.append(ref)
        append_summary(rows)

    durations: list[float] = []

    def budget_ok() -> bool:
        """残り時間が想定 1 ジョブ分を確保できるか(実測の最大所要で判定)."""
        expected = max(durations) if durations else 900.0
        return time.time() + expected < deadline

    done = {(r["rho"], r["omega"], r["alpha"]) for r in rows}
    passes = (("粗パス", build_grid(COARSE_RHO)), ("細パス", build_grid(FINE_RHO)))
    for pass_name, combos in passes:
        pending = [c for c in combos if c not in done]
        log(f"\n=== {pass_name}: {len(pending)} ジョブ(ρ×ω×α 全組合せ) ===")
        for rho, omega, alpha in pending:
            if not budget_ok():
                log(f"  時間予算のため {pass_name} を打ち切ります")
                break
            row = run_job(rho, omega, alpha, pass_name, real_smooth, log)
            if row is not None:
                rows.append(row)
                done.add((rho, omega, alpha))
                if row["elapsed_s"] > 0:
                    durations.append(row["elapsed_s"])
                append_summary(rows)
        else:
            continue
        break

    log(f"\n=== 完了: {len(rows)} 条件 ===")
    log(f"  実機: CoV={REAL_COV:.4f}, C/P={REAL_CP:.4f}, "
        f"勾配={REAL_GRADIENT:.2f}%, 動径 min/max={REAL_RADIAL:.3f}")
    log("  スコア上位 5:")
    for row in sorted(rows, key=lambda r: r["score"])[:5]:
        log(f"    {row['job']:22s} score={row['score']:.4f}  CoV={row['cov']:.4f}  "
            f"NCC={row['ncc_smooth']:.4f}  勾配={row['gradient_magnitude']:.2f}%")
    log(f"  集約 CSV: {SUMMARY_CSV}")
    log_file.close()
    return 0


if __name__ == "__main__":
    sys.exit(main())