"""Step 8-14 候補 ξ: LED 配光 cutoff_angle 5 値スイープ.
spot emitter の cutoff_angle を変えて配光の鋭さが「形」に効くか感度分析する.
データシート整合は気にしない感度分析.
スイープグリッド:
cutoff_angle ∈ {30°, 60°, 90°, 120°, 150°}
beam_width = cutoff_angle / 3 で連動 (ビーム形状を相似に保つ)
実装方針:
- configs/scene_baseline_spot.yaml をテンプレ (Step 8-10 で作成)
- max_depth を 16 に書き換え (Step 8-14 α 前半の新基準)
- 全 LED の cutoff_angle と beam_width を一括書き換え
- intensity (flux) は補正せず → mean アンカー正規化で吸収
期待する観察:
- 鋭いビーム (cutoff=30°) → 評価板の特定領域に集中 → angular_ratio 増?
- 広いビーム (cutoff=150°) → 多重反射主導でほぼ ambient → angular_ratio 減?
- 実機 (中央ピーク楕円・同心円的) に近づく cutoff を探索
実行方法:
uv run python scripts/sweep_led_distribution.py
出力:
- output/led_distribution_sweep/c{N}/luminance.npy
- output/led_distribution_sweep/c{N}/metrics.csv
- output/led_distribution_sweep/summary.csv
- configs/_tmp_distribution/scene_c{N}.yaml
TECH_10 参照: 残差主因候補 §A2/A3「LED 配光モデル」
"""
from __future__ import annotations
import csv
import subprocess
import sys
import time
from pathlib import Path
import yaml
from sm_tias_lightsim.scene.paths import relative_mesh_path
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_YAML = ROOT / "configs" / "scene_baseline_spot.yaml"
EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml"
TMP_DIR = ROOT / "configs" / "_tmp_distribution"
OUTPUT_BASE = ROOT / "output" / "led_distribution_sweep"
# スイープ値 (degree)
CUTOFFS = [30, 60, 90, 120, 150]
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 指標
"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 _c_tag(c: int) -> str:
"""cutoff 角度をファイル名タグに (90 → c090)."""
return f"c{c:03d}"
def _generate_tmp_yaml(cutoff_deg: int) -> Path:
"""指定 cutoff_angle の一時 YAML を生成 (beam_width=cutoff/3 で連動)."""
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(TMP_DIR)からの相対パスに付け替える
# (絶対パスだと Windows で生成した 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"] = relative_mesh_path(template_base / rel_path, TMP_DIR)
# 全 LED の cutoff_angle と beam_width を書き換え (beam = cutoff/3 で相似)
beam_width_deg = cutoff_deg / 3.0
for light in data["lights"]:
if light.get("type") == "spot":
light["cutoff_angle"] = float(cutoff_deg)
light["beam_width"] = float(beam_width_deg)
# max_depth を 16 に更新 (Step 8-14 α 前半の新基準)
data["render"]["max_depth"] = 16
data["name"] = f"baseline_minitias_distribution_{_c_tag(cutoff_deg)}"
tmp_path = TMP_DIR / f"scene_{_c_tag(cutoff_deg)}.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"=== LED distribution sweep: {len(CUTOFFS)} jobs ===")
print(f" cutoffs: {CUTOFFS} (beam_width = cutoff/3 で連動)")
print(f" output: {OUTPUT_BASE}")
print()
failed: list[str] = []
rows: list[dict] = []
total_start = time.time()
for i, c in enumerate(CUTOFFS, start=1):
job_name = _c_tag(c)
output_dir = OUTPUT_BASE / job_name
metrics_csv = output_dir / "metrics.csv"
print(f"--- Job {i}/{len(CUTOFFS)}: {job_name} (cutoff={c}°, beam={c/3:.1f}°) ---")
job_start = time.time()
tmp_yaml = _generate_tmp_yaml(c)
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")
ar = m.get("angular_ratio", "N/A")
print(f" -> 完了 ({elapsed:.1f}s) CoV={cov}, grad={grad}%, angular_ratio={ar}")
row: dict = {
"job": job_name,
"cutoff_angle": c,
"beam_width": c / 3.0,
"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", "cutoff_angle", "beam_width", "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(CUTOFFS)} 成功, 総時間 {total_elapsed:.1f}s ===")
if failed:
print("失敗ジョブ:", failed, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())