"""Step 8-14 感度分析: blendbsdf weight × alpha の 3×3 グリッドスイープ.
内壁の非 Lambertian 性(光沢成分混合)が輝度分布「形」と数値指標に
与える影響を観察する感度分析スクリプト.
スイープグリッド:
weight ∈ {0.05, 0.15, 0.30} (specular 混合比)
alpha ∈ {0.1, 0.3, 0.6 } (GGX 粗さ)
→ 9 組合せ × (sm-render + sm-evaluate) を順次実行
実行方法:
uv run python scripts/sweep_blend_bsdf.py
前提条件:
- configs/scene_baseline_blend.yaml が存在すること
- configs/eval_baseline_dng_target.yaml が存在すること
- sm-render / sm-evaluate が PATH に通っていること
出力:
- output/blend_sweep/w{w}_a{a}/luminance.npy (各ジョブのレンダリング結果)
- output/blend_sweep/w{w}_a{a}/metrics.csv (各ジョブの評価指標)
- output/blend_sweep/summary.csv (全 9 ジョブの集約結果)
- configs/_tmp_blend/scene_w{w}_a{a}.yaml (一時 YAML,実行後も残す)
TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」
"""
from __future__ import annotations
import csv
import subprocess
import sys
import time
from pathlib import Path
import yaml
# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_YAML = ROOT / "configs" / "scene_baseline_blend.yaml"
EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target.yaml"
TMP_DIR = ROOT / "configs" / "_tmp_blend"
OUTPUT_BASE = ROOT / "output" / "blend_sweep"
# スイープグリッド
WEIGHTS = [0.05, 0.15, 0.30]
ALPHAS = [0.1, 0.3, 0.6]
# 集約 CSV のカラム定義(metrics.csv に存在するキー)
SUMMARY_EXTRA_COLS = [
"cov",
"std",
"max_min_ratio",
"mean",
"max",
"min",
"center_periphery_ratio",
"gradient_magnitude",
"radial_min_max_ratio",
"radial_min_distance",
"radial_min",
"radial_max",
]
# ---------------------------------------------------------------------------
# ヘルパ関数
# ---------------------------------------------------------------------------
def _format_param(value: float) -> str:
"""パラメータ値をファイル名に使える文字列に変換する.
例: 0.05 → "0.05", 0.3 → "0.3"
Args:
value: float のパラメータ値.
Returns:
ファイル名に使える文字列.
"""
return str(value)
def _make_job_name(weight: float, alpha: float) -> str:
"""ジョブ識別子を生成する.
Args:
weight: blendbsdf の weight 値.
alpha: roughconductor の alpha 値.
Returns:
ジョブ識別子文字列(例: "w0.05_a0.1").
"""
return f"w{_format_param(weight)}_a{_format_param(alpha)}"
def _generate_tmp_yaml(weight: float, alpha: float) -> Path:
"""一時 YAML ファイルを生成して返す.
scene_baseline_blend.yaml のテンプレートを読み込み,
materials.printer_white.weight と components[1].alpha を書き換えて
一時 YAML として保存する.
Note:
UTF-8 BOM なしで書き込む(Mitsuba YAML パーサは BOM 付きで失敗するため).
Args:
weight: blendbsdf の weight 値(specular 混合比).
alpha: roughconductor の alpha 値(GGX 粗さ).
Returns:
生成した一時 YAML ファイルのパス.
"""
TMP_DIR.mkdir(parents=True, exist_ok=True)
with TEMPLATE_YAML.open(encoding="utf-8") as f:
data = yaml.safe_load(f)
# geometry.meshes の STL 相対パスを絶対化(一時 YAML の配置場所が変わっても解決可能にする)
template_base = TEMPLATE_YAML.parent
for item in data.get("geometry", {}).get("meshes", []):
rel_path = item.get("path")
if rel_path and not Path(rel_path).is_absolute():
item["path"] = str((template_base / rel_path).resolve())
# printer_white の blendbsdf パラメータを書き換える
printer_white = data["materials"]["printer_white"]
printer_white["weight"] = weight
# components[1] が roughconductor(スキーマ順: [0]=diffuse, [1]=roughconductor)
printer_white["components"][1]["alpha"] = alpha
# name を識別しやすい値に更新する
job_name = _make_job_name(weight, alpha)
data["name"] = f"baseline_minitias_blend_{job_name}"
tmp_path = TMP_DIR / f"scene_{job_name}.yaml"
with tmp_path.open("w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
return tmp_path
def _run_command(cmd: list[str], label: str) -> int:
"""外部コマンドを実行して終了コードを返す.
Args:
cmd: 実行するコマンドリスト.
label: ログ出力用のラベル.
Returns:
終了コード(0 = 成功).
"""
print(f" [run] {' '.join(cmd)}")
result = subprocess.run(cmd, check=False) # noqa: S603
if result.returncode != 0:
print(f" [ERROR] {label} failed (returncode={result.returncode})", file=sys.stderr)
return result.returncode
def _read_metrics_csv(csv_path: Path) -> dict:
"""metrics.csv を読み込んで辞書を返す.
Args:
csv_path: 読み込む metrics.csv のパス.
Returns:
指標名 → 値の辞書.1 行目のヘッダ + 2 行目の値を読む.
Raises:
FileNotFoundError: ファイルが存在しない場合.
ValueError: ファイルが空または形式不正の場合.
"""
if not csv_path.exists():
msg = f"metrics.csv が見つかりません: {csv_path}"
raise FileNotFoundError(msg)
with csv_path.open(encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
rows = list(reader)
if not rows:
msg = f"metrics.csv が空です: {csv_path}"
raise ValueError(msg)
return rows[0]
# ---------------------------------------------------------------------------
# メイン処理
# ---------------------------------------------------------------------------
def main() -> int:
"""スイープのメインエントリポイント.
Returns:
終了コード(全ジョブ成功なら 0,失敗があれば 1).
"""
# 前提ファイルの確認
if not TEMPLATE_YAML.exists():
print(f"エラー: テンプレート YAML が見つかりません: {TEMPLATE_YAML}", file=sys.stderr)
return 1
if not EVAL_CONFIG_YAML.exists():
print(f"エラー: 評価 config YAML が見つかりません: {EVAL_CONFIG_YAML}", file=sys.stderr)
return 1
OUTPUT_BASE.mkdir(parents=True, exist_ok=True)
# ジョブリスト生成
jobs = [(w, a) for w in WEIGHTS for a in ALPHAS]
total = len(jobs)
print(f"=== blend BSDF sweep: {total} jobs ({len(WEIGHTS)} weights × {len(ALPHAS)} alphas) ===")
print(f" weights: {WEIGHTS}")
print(f" alphas: {ALPHAS}")
print(f" output: {OUTPUT_BASE}")
print()
failed_jobs: list[str] = []
summary_rows: list[dict] = []
total_start = time.time()
for job_idx, (weight, alpha) in enumerate(jobs, start=1):
job_name = _make_job_name(weight, alpha)
output_dir = OUTPUT_BASE / job_name
metrics_csv = output_dir / "metrics.csv"
print(f"--- Job {job_idx}/{total}: {job_name} ---")
job_start = time.time()
# 一時 YAML 生成
tmp_yaml = _generate_tmp_yaml(weight, alpha)
print(f" tmp YAML: {tmp_yaml}")
# sm-render 実行
render_ret = _run_command(
["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)],
label=f"sm-render ({job_name})",
)
if render_ret != 0:
failed_jobs.append(f"{job_name} [render]")
print(" -> スキップ: render 失敗\n")
continue
# sm-evaluate 実行
npy_path = output_dir / "luminance.npy"
evaluate_ret = _run_command(
[
"sm-evaluate",
"--image", str(npy_path),
"--config", str(EVAL_CONFIG_YAML),
"--output", str(output_dir),
],
label=f"sm-evaluate ({job_name})",
)
if evaluate_ret != 0:
failed_jobs.append(f"{job_name} [evaluate]")
print(" -> スキップ: evaluate 失敗\n")
continue
# metrics 読み込み
try:
metrics = _read_metrics_csv(metrics_csv)
except (FileNotFoundError, ValueError) as exc:
print(f" [ERROR] metrics.csv 読み込み失敗: {exc}", file=sys.stderr)
failed_jobs.append(f"{job_name} [metrics_read]")
print()
continue
elapsed = time.time() - job_start
print(f" -> 完了 ({elapsed:.1f}s)")
cov_val = metrics.get("cov", "N/A")
grad_val = metrics.get("gradient_magnitude", "N/A")
print(f" CoV={cov_val}, gradient={grad_val}%")
row: dict = {
"job": job_name,
"weight": weight,
"alpha": alpha,
"elapsed_s": f"{elapsed:.1f}",
}
for col in SUMMARY_EXTRA_COLS:
row[col] = metrics.get(col, "")
summary_rows.append(row)
print()
# 集約 CSV 保存
summary_csv = OUTPUT_BASE / "summary.csv"
if summary_rows:
fieldnames = ["job", "weight", "alpha", "elapsed_s"] + SUMMARY_EXTRA_COLS
with summary_csv.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(summary_rows)
print(f"集約 CSV 保存: {summary_csv}")
else:
print(
"警告: 成功したジョブが 0 件のため summary.csv を保存しませんでした.",
file=sys.stderr,
)
total_elapsed = time.time() - total_start
print(f"\n=== スイープ完了: {len(summary_rows)}/{total} 成功, 総時間 {total_elapsed:.1f}s ===")
if failed_jobs:
print(f"失敗ジョブ ({len(failed_jobs)}件):", file=sys.stderr)
for j in failed_jobs:
print(f" - {j}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())