Newer
Older
SmTIAS-LightSim / scripts / sweep_reflectance.py
"""Step 8-14 候補 α 後半: 反射率 5 値スイープ.

d=16 ベース (Step 8-14 α 前半で確定) で R ∈ {0.75, 0.80, 0.85, 0.89, 0.92} を
スイープし,反射率変化が形状指標と新指標 (shape_metrics) に与える影響を観察する.

過去 Step 8-2 で同様のスイープを d=8 / 旧評価方針で実施済.本スイープは:
    - d=16 (Step 8-14 α 前半の収束値)
    - 評価 config: eval_baseline_dng_target_shape.yaml (新 5 指標を含む)
    - mean アンカー正規化で形状指標は線形ゲイン不変,shape_metrics で形不変を定量確認

実行方法:
    uv run python scripts/sweep_reflectance.py

出力:
    - output/reflectance_sweep/r{N}/luminance.npy
    - output/reflectance_sweep/r{N}/metrics.csv
    - output/reflectance_sweep/summary.csv
    - configs/_tmp_reflectance/scene_r{N}.yaml (一時 YAML)

TECH_10 参照: 残差主因候補 §A8「反射率 R=0.85→0.89」
"""

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.yaml"
EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml"
TMP_DIR = ROOT / "configs" / "_tmp_reflectance"
OUTPUT_BASE = ROOT / "output" / "reflectance_sweep"

REFLECTANCES = [0.75, 0.80, 0.85, 0.89, 0.92]

SUMMARY_EXTRA_COLS = [
    # 既存 4 指標
    "cov", "std", "max_min_ratio", "mean", "max", "min",
    "center_periphery_ratio", "gradient_magnitude",
    "radial_min_max_ratio", "radial_min_distance", "radial_min", "radial_max",
    # 新 5 指標 (shape_metrics)
    "intensity_centroid_x_px", "intensity_centroid_y_px",
    "intensity_centroid_x_norm", "intensity_centroid_y_norm",
    "intensity_centroid_offset_norm",
    "angular_variance", "radial_variance", "angular_ratio",
    "pca_primary_axis_angle_deg", "pca_eccentricity",
    "ssim", "ncc",
]


def _r_tag(r: float) -> str:
    """R 値をファイル名タグに変換 (0.85 → r085)."""
    return f"r{int(round(r * 100)):03d}"


def _generate_tmp_yaml(reflectance: float) -> Path:
    """指定 reflectance の一時 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 相対パスを絶対化
    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())

    # materials.printer_white.reflectance を書き換え
    data["materials"]["printer_white"]["reflectance"] = [reflectance, reflectance, reflectance]
    data["name"] = f"baseline_minitias_{_r_tag(reflectance)}"

    tmp_path = TMP_DIR / f"scene_{_r_tag(reflectance)}.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(cmd: list[str], label: str) -> int:
    """外部コマンドを実行."""
    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_path: Path) -> dict:
    with csv_path.open(encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))
    if not rows:
        msg = f"metrics.csv が空: {csv_path}"
        raise ValueError(msg)
    return rows[0]


def main() -> int:
    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)

    print(f"=== reflectance sweep: {len(REFLECTANCES)} jobs ===")
    print(f"  reflectances: {REFLECTANCES}")
    print(f"  output: {OUTPUT_BASE}")
    print()

    failed: list[str] = []
    rows: list[dict] = []
    total_start = time.time()

    for i, r in enumerate(REFLECTANCES, start=1):
        job_name = _r_tag(r)
        output_dir = OUTPUT_BASE / job_name
        metrics_csv = output_dir / "metrics.csv"

        print(f"--- Job {i}/{len(REFLECTANCES)}: {job_name} (R={r}) ---")
        job_start = time.time()

        tmp_yaml = _generate_tmp_yaml(r)
        print(f"  tmp YAML: {tmp_yaml}")

        if _run(["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)],
                f"sm-render ({job_name})") != 0:
            failed.append(f"{job_name} [render]")
            print("  -> スキップ: render 失敗\n")
            continue

        npy = output_dir / "luminance.npy"
        if _run(["sm-evaluate", "--image", str(npy), "--config", str(EVAL_CONFIG_YAML),
                 "--output", str(output_dir)], f"sm-evaluate ({job_name})") != 0:
            failed.append(f"{job_name} [evaluate]")
            print("  -> スキップ: evaluate 失敗\n")
            continue

        try:
            m = _read_metrics(metrics_csv)
        except (FileNotFoundError, ValueError) as exc:
            print(f"  [ERROR] metrics.csv 読み込み失敗: {exc}", file=sys.stderr)
            failed.append(f"{job_name} [metrics_read]")
            print()
            continue

        elapsed = time.time() - job_start
        cov = m.get("cov", "N/A")
        grad = m.get("gradient_magnitude", "N/A")
        a_ratio = m.get("angular_ratio", "N/A")
        print(f"  -> 完了 ({elapsed:.1f}s)  CoV={cov}, grad={grad}%, angular_ratio={a_ratio}")

        row: dict = {"job": job_name, "reflectance": r, "elapsed_s": f"{elapsed:.1f}"}
        for col in SUMMARY_EXTRA_COLS:
            row[col] = m.get(col, "")
        rows.append(row)
        print()

    summary_csv = OUTPUT_BASE / "summary.csv"
    if rows:
        fieldnames = ["job", "reflectance", "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(rows)
        print(f"集約 CSV 保存: {summary_csv}")

    total_elapsed = time.time() - total_start
    print(f"\n=== 完了: {len(rows)}/{len(REFLECTANCES)} 成功, 総時間 {total_elapsed:.1f}s ===")

    if failed:
        print("失敗ジョブ:", failed, file=sys.stderr)
        return 1
    return 0


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