diff --git a/scripts/build_spp_noise_comparison.py b/scripts/build_spp_noise_comparison.py new file mode 100644 index 0000000..7206b72 --- /dev/null +++ b/scripts/build_spp_noise_comparison.py @@ -0,0 +1,276 @@ +"""spp スイープの解析: 計算時間・GPU 率・ノイズ・指標の安定性を spp に対して描く. + +sweep_spp_noise.py の summary.csv(と併走した nvidia-smi のサンプル gpu_util.csv)を読み, + +1. spp vs 計算時間(段階別の積み上げ)と GPU 率(mi.render / 全体) +2. spp vs ノイズ CoV(1/sqrt(spp) の理論線付き)と生の輝度依存 +3. spp vs 指標(構造 CoV・輪帯 min/max・画像全体 min/max の補正あり/なし).seed 2 つの差も + +を出力する(output/spp_noise_sweep/spp_noise.png・spp_summary_table.csv). + +実行方法: + uv run python scripts/build_spp_noise_comparison.py +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from sweep_spp_noise import CONDITIONS, OUTPUT_BASE, SPPS # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +COND_LABEL = { + "sphere_d340_th000": "球 φ340 軸上", + "sphere_d340_th090": "球 φ340 θ=90°", + "cube_L234_th000": "立方体 L=234 軸上", + "cube_L234_th090": "立方体 L=234 θ=90°", + "sphere_d500_th090": "球 φ500 θ=90°", + "cube_L340_th090": "立方体 L=340 θ=90°", +} +STAGES = [("t_load_yaml", "YAML 読込"), ("t_build_scene", "PLY 書き出し"), + ("t_load_dict", "シーン読込"), ("t_render", "mi.render(GPU)"), + ("t_save", "保存"), ("t_evaluate", "評価")] + + +def load_rows() -> list[dict]: + """summary.csv を数値化して読む.""" + rows = [] + with (OUTPUT_BASE / "summary.csv").open(encoding="utf-8") as fh: + for r in csv.DictReader(fh): + row = {"name": r["name"], "condition": r["condition"], + "render_variant": r["render_variant"]} + for k, v in r.items(): + if k not in row: + row[k] = float(v) if v not in ("", None) else float("nan") + rows.append(row) + return rows + + +def by_condition_spp(rows: list[dict], key: str) -> dict[str, dict[int, tuple[float, float]]]: + """条件 → spp → (seed 平均, seed 差の半分) を返す.""" + out: dict[str, dict[int, tuple[float, float]]] = {} + for cond, _ in CONDITIONS: + out[cond] = {} + for spp in SPPS: + vals = [r[key] for r in rows if r["condition"] == cond and int(r["spp"]) == spp] + if vals: + spread = float(np.ptp(vals)) / 2.0 if len(vals) > 1 else 0.0 + out[cond][spp] = (float(np.mean(vals)), spread) + return out + + +def gpu_util_summary() -> str: + """nvidia-smi サンプルの要約(あれば).""" + path = OUTPUT_BASE / "gpu_util.csv" + if not path.exists(): + return "" + utils = [] + with path.open(encoding="utf-8") as fh: + for r in csv.DictReader(fh): + try: + utils.append(float(r["gpu_util"])) + except (ValueError, KeyError): + continue + if not utils: + return "" + arr = np.array(utils) + return (f"nvidia-smi サンプル {len(arr)} 点: 使用率 平均 {arr.mean():.0f}%・" + f"稼働(>0%)の割合 {np.mean(arr > 0) * 100:.0f}%") + + +def draw(rows: list[dict], output: Path) -> None: + """3 行 × 3 列の図を描く.""" + fig, axes = plt.subplots(3, 3, figsize=(20, 15)) + colors = plt.cm.tab10(np.linspace(0, 1, 10))[: len(CONDITIONS)] + + # 1-1 段階別の時間(代表条件 球 φ340 θ=90 の seed 平均を積み上げ) + ax = axes[0, 0] + rep = "sphere_d340_th090" + bottom = np.zeros(len(SPPS)) + for key, label in STAGES: + series = by_condition_spp(rows, key)[rep] + vals = np.array([series.get(s, (np.nan, 0))[0] for s in SPPS]) + ax.bar(range(len(SPPS)), vals, bottom=bottom, label=label) + bottom += np.nan_to_num(vals) + ax.set_xticks(range(len(SPPS))) + ax.set_xticklabels([str(s) for s in SPPS]) + ax.set_xlabel("spp") + ax.set_ylabel("時間 [s]") + ax.set_title(f"段階別の時間({COND_LABEL[rep]})", fontsize=11) + ax.legend(fontsize=8) + ax.grid(alpha=0.3, axis="y") + + # 1-2 全体時間 vs spp(全条件)と render 時間 + ax = axes[0, 1] + total = by_condition_spp(rows, "t_total") + render = by_condition_spp(rows, "t_render") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in total[cond]] + ax.plot(xs, [total[cond][s][0] for s in xs], marker="o", color=c, label=COND_LABEL[cond]) + ax.plot(xs, [render[cond][s][0] for s in xs], marker="x", ls=":", color=c, alpha=0.7) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("時間 [s](実線=全体,点線=mi.render)") + ax.set_title("計算時間 vs spp", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 1-3 GPU 率 + ax = axes[0, 2] + frac = by_condition_spp(rows, "gpu_fraction") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in frac[cond]] + ax.plot(xs, [frac[cond][s][0] * 100 for s in xs], marker="o", color=c, + label=COND_LABEL[cond]) + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel("GPU 率 [%] = mi.render / 全体") + ax.set_title("処理時間に占める GPU(レンダ本体)の割合", fontsize=11) + ax.set_ylim(0, 100) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-1 ノイズ CoV vs spp + 理論線 + ax = axes[1, 0] + noise = by_condition_spp(rows, "noise_cov") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in noise[cond]] + ys = [noise[cond][s][0] for s in xs] + ax.plot(xs, ys, marker="o", color=c, label=COND_LABEL[cond]) + if 1024 in noise[cond]: + ref = noise[cond][1024][0] + ax.plot(xs, [ref * np.sqrt(1024 / s) for s in xs], ls=":", color=c, alpha=0.6) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("残差ノイズ CoV(点線 = 1/√spp の理論線,1024 基準)") + ax.set_title("ノイズ vs spp", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-2 ノイズ CoV × sqrt(spp) の一定性(生の輝度に対して) + ax = axes[1, 1] + mean = by_condition_spp(rows, "raw_mean_roi") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in noise[cond]] + ax.plot(xs, [noise[cond][s][0] * np.sqrt(s) for s in xs], marker="o", color=c, + label=f"{COND_LABEL[cond]}(生 mean {mean[cond].get(1024, (np.nan, 0))[0]:.2f})") + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel("ノイズ CoV × √spp(一定なら 1/√spp 則)") + ax.set_title("1/√spp 則の検証(生の輝度が低いほど大きい)", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-3 ノイズ場の極値(補正量) + ax = axes[1, 2] + nmin = by_condition_spp(rows, "fullimage_noise_min") + nmax = by_condition_spp(rows, "fullimage_noise_max") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in nmin[cond]] + m = [mean[cond][s][0] for s in xs] + spans = [(nmax[cond][s][0] - nmin[cond][s][0]) / mm for s, mm in zip(xs, m, strict=True)] + ax.plot(xs, spans, marker="o", color=c, label=COND_LABEL[cond]) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("ノイズ場の (max − min) / 平均") + ax.set_title("画像全体 min/max のノイズ補正量", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 3-x 指標の安定性 + for ax, key, title in ((axes[2, 0], "structure_cov", "構造 CoV"), + (axes[2, 1], "ring_min_max", "輪帯平均 min/max"), + (axes[2, 2], "fullimage_min_max", "画像全体 min/max(ノイズ補正)")): + series = by_condition_spp(rows, key) + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in series[cond]] + ax.errorbar(xs, [series[cond][s][0] for s in xs], + yerr=[series[cond][s][1] for s in xs], marker="o", capsize=3, + color=c, label=COND_LABEL[cond]) + if key == "fullimage_min_max": + raw = by_condition_spp(rows, "fullimage_raw_min_max") + ax.plot(xs, [raw[cond][s][0] for s in xs], ls=":", color=c, alpha=0.6) + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel(title + ("(点線 = 補正なし)" if key == "fullimage_min_max" else "")) + ax.set_title(f"{title} vs spp(誤差棒 = seed 2 つの差の半分)", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + note = gpu_util_summary() + fig.suptitle("spp スイープ: 計算時間・GPU 率・ノイズ・指標の安定性" + "(Newton GPU 0・cuda_ad_rgb・512²)" + (f"\n{note}" if note else ""), + fontsize=13) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +def print_table(rows: list[dict]) -> None: + """spp ごとの要約表(条件平均)を出す.""" + print(f"\n{'spp':>6} {'全体[s]':>8} {'render[s]':>10} {'固定費[s]':>9} {'GPU率':>6} " + f"{'noise CoV':>10} {'noise×√spp':>11} {'構造CoV seed差':>14} {'min seed差':>11}") + out_rows = [] + for spp in SPPS: + sel = [r for r in rows if int(r["spp"]) == spp] + if not sel: + continue + total = np.mean([r["t_total"] for r in sel]) + render = np.mean([r["t_render"] for r in sel]) + noise = np.mean([r["noise_cov"] for r in sel]) + struct_diff, min_diff = [], [] + for cond, _ in CONDITIONS: + c = [r for r in sel if r["condition"] == cond] + if len(c) >= 2: + struct_diff.append(abs(c[0]["structure_cov"] - c[1]["structure_cov"])) + min_diff.append(abs(c[0]["fullimage_min_max"] - c[1]["fullimage_min_max"])) + row = {"spp": spp, "t_total": total, "t_render": render, "t_fixed": total - render, + "gpu_fraction": render / total, "noise_cov": noise, + "noise_x_sqrt": noise * np.sqrt(spp), + "structure_seed_diff": float(np.mean(struct_diff)) if struct_diff else np.nan, + "min_seed_diff": float(np.mean(min_diff)) if min_diff else np.nan} + out_rows.append(row) + print(f"{spp:>6} {total:8.1f} {render:10.1f} {total - render:9.1f} " + f"{render / total * 100:5.0f}% {noise:10.4f} {noise * np.sqrt(spp):11.3f} " + f"{row['structure_seed_diff']:14.4f} {row['min_seed_diff']:11.4f}") + with (OUTPUT_BASE / "spp_summary_table.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(out_rows[0].keys())) + writer.writeheader() + writer.writerows(out_rows) + note = gpu_util_summary() + if note: + print(note) + + +def main() -> int: + """spp スイープの図と表を出す.""" + parser = argparse.ArgumentParser(description="spp スイープの解析") + parser.parse_args() + rows = load_rows() + if not rows: + print("エラー: summary.csv が空です") + return 1 + draw(rows, OUTPUT_BASE / "spp_noise.png") + print_table(rows) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sample_gpu_util.sh b/scripts/sample_gpu_util.sh new file mode 100644 index 0000000..cfbae7b --- /dev/null +++ b/scripts/sample_gpu_util.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# GPU 使用率を一定間隔でサンプリングして CSV に追記する(spp スイープの GPU 率の裏取り用). +# サーバ上で tmux の中から起動する: +# nohup bash scripts/sample_gpu_util.sh 0 0.5 output/spp_noise_sweep/gpu_util.csv > /dev/null 2>&1 & +# 止めるときは pkill -f sample_gpu_util.sh +set -u +GPU="${1:-0}" +INTERVAL="${2:-0.5}" +OUT="${3:-output/gpu_util.csv}" +mkdir -p "$(dirname "$OUT")" +echo "timestamp,gpu_util,mem_used_mib" >> "$OUT" +while true; do + line=$(nvidia-smi -i "$GPU" --query-gpu=utilization.gpu,memory.used --format=csv,noheader,nounits 2>/dev/null | tr -d ' ') + echo "$(date +%s.%N),$line" >> "$OUT" + sleep "$INTERVAL" +done diff --git a/scripts/sweep_spp_noise.py b/scripts/sweep_spp_noise.py new file mode 100644 index 0000000..8af4d4f --- /dev/null +++ b/scripts/sweep_spp_noise.py @@ -0,0 +1,285 @@ +"""spp スイープ: サンプル数に対する計算時間・GPU 率・ノイズ・指標の安定性を測る. + +TEST_18 までは spp=1024 固定で,大きいキャビティほど生の輝度が低く相対ノイズが 7〜26% に +達していた(指標はノイズ補正で救っている).研究室サーバの GPU で 1 条件 5 秒になったので, +spp を上げたときに (1) 計算時間がどう増えるか(CPU 側の固定費と GPU 側の spp 比例部分), +(2) ノイズが理論どおり 1/sqrt(spp) で下がるか,(3) 各指標(構造 CoV・輪帯 min/max・ +画像全体 min/max)がどの spp で安定するかを測り,今後の標準 spp を決める. + +条件(TEST_18 の既存 YAML を流用し spp と seed だけ書き換える): + 球 φ340(r=170)θ=0/90,立方体 L=234 θ=0/90,球 φ500(r=250)θ=90,立方体 L=340 θ=90 + × spp = 256〜16384(2 倍刻み 7 段階)× seed 2 つ + +計測: + - 段階別の時間(YAML 読込 / build_scene=PLY 書き出し / load_dict / mi.render / 保存 / 評価). + mi.render は dr.sync_thread で GPU の完了を待ってから測る.GPU 率 = render / 全体 + - 残差ノイズ CoV(生 − 平滑化)・生の画素 CoV・構造 CoV・輪帯 min/max・画像全体 min/max + (ノイズ補正あり / なし)・ノイズ場の極値 + - 併走する nvidia-smi のサンプリング(sm-remote run の外で回す)は解析側で突き合わせる + +実行方法: + uv run python scripts/sweep_spp_noise.py --prepare-only + uv run sm-remote run --name spp --gpu 0 --threads 16 -- python scripts/sweep_spp_noise.py +""" + +from __future__ import annotations + +import argparse +import copy +import csv +import json +import os +import shutil +import sys +import time +from pathlib import Path + +import numpy as np +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from build_radial_comparison import radial_stats, ring_min_max_ratio # noqa: E402 +from build_radius_sweep_visual import decompose_cov # noqa: E402 +from sweep_led_placement import TMP_DIR as PLACEMENT_TMP_DIR # noqa: E402 +from sweep_sphere_radius import EVAL_CONFIG_YAML # noqa: E402 + +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402 +from sm_tias_lightsim.evaluation.pipeline import evaluate_image # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 +from sm_tias_lightsim.runtime.overrides import resolve_render_variant # noqa: E402 +from sm_tias_lightsim.scene.schema import load_scene_yaml # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +TMP_DIR = ROOT / "configs" / "_tmp_spp_noise" +OUTPUT_BASE = ROOT / "output" / "spp_noise_sweep" + +# (表示名, TEST_18 の条件名) +CONDITIONS = [ + ("sphere_d340_th000", "sphere_r170_th000_board_n1"), + ("sphere_d340_th090", "sphere_r170_th090_board_n1"), + ("cube_L234_th000", "cube_L234_th000_board_n1"), + ("cube_L234_th090", "cube_L234_th090_board_n1"), + ("sphere_d500_th090", "sphere_r250_th090_board_n1"), + ("cube_L340_th090", "cube_L340_th090_board_n1"), +] +SPPS = [256, 512, 1024, 2048, 4096, 8192, 16384] +SEEDS = [0, 1] + +SUMMARY_COLUMNS = [ + "name", "condition", "spp", "seed", "render_variant", + "t_total", "t_load_yaml", "t_build_scene", "t_load_dict", "t_render", "t_save", "t_evaluate", + "gpu_fraction", "t_render_per_kspp", + "raw_mean_roi", "pixel_cov", "noise_cov", "structure_cov", "ring_min_max", + "fullimage_min_max", "fullimage_raw_min_max", "fullimage_noise_min", "fullimage_noise_max", + "radial_min_max_ratio", "cov", +] + + +def job_name(condition: str, spp: int, seed: int) -> str: + """出力ディレクトリ名.""" + return f"{condition}_spp{spp}_s{seed}" + + +def prepare(log) -> dict[str, Path]: + """TEST_18 の YAML を spp・seed だけ書き換えて書き出す(メッシュは元の場所を相対参照).""" + scenes: dict[str, Path] = {} + TMP_DIR.mkdir(parents=True, exist_ok=True) + for display, source_name in CONDITIONS: + source = PLACEMENT_TMP_DIR / f"scene_{source_name}.yaml" + if not source.exists(): + raise FileNotFoundError( + f"{source} がありません.先に sweep_led_placement.py --prepare-only を実行" + ) + config = yaml.safe_load(source.read_text(encoding="utf-8")) + for mesh in config["geometry"]["meshes"]: + path = Path(mesh["path"]) + if not path.is_absolute(): + # 元 YAML からの相対パスを,新しい YAML からの相対パスに付け替える + target = (source.parent / path).resolve() + mesh["path"] = Path(os.path.relpath(target, TMP_DIR)).as_posix() + for spp in SPPS: + for seed in SEEDS: + cfg = copy.deepcopy(config) + cfg["name"] = f"spp_noise_{job_name(display, spp, seed)}" + cfg["render"]["spp"] = spp + cfg["render"]["seed"] = seed + dest = TMP_DIR / f"scene_{job_name(display, spp, seed)}.yaml" + dest.write_text(yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), + encoding="utf-8") + scenes[job_name(display, spp, seed)] = dest + log(f"=== 幾何流用: {len(CONDITIONS)} 条件 × {len(SPPS)} spp × {len(SEEDS)} seed = " + f"{len(scenes)} レンダ(YAML: {TMP_DIR})===") + return scenes + + +def timed_render(scene_yaml: Path, out_dir: Path, variant: str) -> tuple[dict, np.ndarray]: + """段階別に時間を測りながらレンダし,(時間の dict, HDR 画像) を返す. + + render/pipeline.render_yaml と同じ手順を手でほどいたもの(段階ごとの時間を取るため). + mi.render は非同期に GPU へ投げられるので,dr.sync_thread() で完了を待ってから測る. + """ + from sm_tias_lightsim.render.runner import ensure_variant + from sm_tias_lightsim.scene.builder import build_scene + + times: dict[str, float] = {} + t0 = time.perf_counter() + config = load_scene_yaml(scene_yaml) + render_cfg = config.render.model_copy(update={"variant": variant}) + config = config.model_copy(update={"render": render_cfg}) + t1 = time.perf_counter() + times["t_load_yaml"] = t1 - t0 + + ensure_variant(variant) + import drjit as dr + import mitsuba as mi + + scene_dict, tempdir = build_scene(config, project_root=scene_yaml.parent) + t2 = time.perf_counter() + times["t_build_scene"] = t2 - t1 + try: + scene = mi.load_dict(scene_dict) + dr.sync_thread() + t3 = time.perf_counter() + times["t_load_dict"] = t3 - t2 + image = mi.render(scene, spp=config.render.spp, seed=config.render.seed) + dr.eval(image) + dr.sync_thread() + arr = np.array(image) + t4 = time.perf_counter() + times["t_render"] = t4 - t3 + finally: + shutil.rmtree(tempdir, ignore_errors=True) + from sm_tias_lightsim.render.output import save_luminance, save_png + + out_dir.mkdir(parents=True, exist_ok=True) + save_png(arr, out_dir / "render.png") + save_luminance(arr, out_dir / "luminance.npy") + times["t_save"] = time.perf_counter() - t4 + return times, arr + + +def noise_metrics(luminance_npy: Path) -> dict[str, float]: + """ノイズと均一性の指標を luminance.npy から計算する.""" + arr = np.load(luminance_npy).astype(np.float64) + roi = extract_roi_xywh(arr, SIM_ROI) + pixel_cov, structure, noise = decompose_cov(luminance_npy) + centers, means, _ = radial_stats(luminance_npy, 20) + full = calc_fullimage_min_max_ratio(roi) + from scipy.ndimage import gaussian_filter + + smooth = gaussian_filter(roi, sigma=12.0) + return { + "raw_mean_roi": float(roi.mean()), + "pixel_cov": pixel_cov, + "noise_cov": noise, + "structure_cov": structure, + "ring_min_max": ring_min_max_ratio(centers, means), + "fullimage_min_max": full["fullimage_min_max_ratio"], + "fullimage_raw_min_max": float(smooth.min() / smooth.max()), + "fullimage_noise_min": full["fullimage_noise_min"], + "fullimage_noise_max": full["fullimage_noise_max"], + } + + +def run_job(name: str, condition: str, spp: int, seed: int, scene_yaml: Path, + variant: str, log) -> dict | None: + """1 レンダを計測付きで実行し row.json を書く(既存は再利用).""" + out_dir = OUTPUT_BASE / name + row_json = out_dir / "row.json" + if row_json.exists(): + log(f"--- {name}: 既存結果を再利用") + return json.loads(row_json.read_text(encoding="utf-8")) + log(f"--- {name}: レンダ開始") + t_start = time.perf_counter() + try: + times, _ = timed_render(scene_yaml, out_dir, variant) + t_eval0 = time.perf_counter() + import io + from contextlib import redirect_stdout + + with redirect_stdout(io.StringIO()): + metrics = evaluate_image(out_dir / "luminance.npy", EVAL_CONFIG_YAML, out_dir, + save_figures=False) + times["t_evaluate"] = time.perf_counter() - t_eval0 + except Exception as exc: # noqa: BLE001 - 1 条件の失敗でスイープを止めない + log(f" !! 失敗: {exc}") + return None + times["t_total"] = time.perf_counter() - t_start + row: dict = {"name": name, "condition": condition, "spp": spp, "seed": seed, + "render_variant": variant} + row.update({k: round(v, 4) for k, v in times.items()}) + row["gpu_fraction"] = round(times["t_render"] / times["t_total"], 4) + row["t_render_per_kspp"] = round(times["t_render"] / (spp / 1024.0), 4) + row.update(noise_metrics(out_dir / "luminance.npy")) + row["radial_min_max_ratio"] = float(metrics.get("radial_min_max_ratio", float("nan"))) + row["cov"] = float(metrics["cov"]) + row_json.write_text(json.dumps(row, ensure_ascii=False, indent=1), encoding="utf-8") + log(f" -> 全体 {row['t_total']:.1f}s(render {row['t_render']:.1f}s = " + f"{row['gpu_fraction'] * 100:.0f}%)noise={row['noise_cov']:.4f} " + f"structure={row['structure_cov']:.4f} min={row['fullimage_min_max']:.4f}") + return row + + +def write_summary() -> Path: + """全 row.json から集約 CSV を書く.""" + rows = [json.loads(p.read_text(encoding="utf-8")) + for p in sorted(OUTPUT_BASE.glob("*/row.json"))] + order = {job_name(d, s, k): i for i, (d, s, k) + in enumerate((d, s, k) for d, _ in CONDITIONS for s in SPPS for k in SEEDS)} + rows.sort(key=lambda r: order.get(r["name"], len(order))) + path = OUTPUT_BASE / "summary.csv" + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS) + writer.writeheader() + for r in rows: + writer.writerow({k: r.get(k, "") for k in SUMMARY_COLUMNS}) + return path + + +def main() -> int: + """spp スイープを実行する.""" + parser = argparse.ArgumentParser(description="spp スイープ(計算時間・GPU 率・ノイズ)") + parser.add_argument("--prepare-only", action="store_true", help="YAML の生成だけ行う") + parser.add_argument("--spps", type=int, nargs="*", default=None, help="実行する spp を絞る") + parser.add_argument("--conditions", nargs="*", default=None, help="実行する条件名を絞る") + args = parser.parse_args() + + log_path = ROOT / "output" / "_sweep_spp_noise.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = log_path.open("a", encoding="utf-8") + + def log(message: str) -> None: + print(message, flush=True) + log_file.write(message + "\n") + log_file.flush() + + scenes = prepare(log) + if args.prepare_only: + log_file.close() + return 0 + template = yaml.safe_load(next(iter(scenes.values())).read_text(encoding="utf-8")) + variant = resolve_render_variant(str(template["render"]["variant"])) + log(f"=== variant={variant} ===") + done = 0 + # spp の小さい順に全条件を回す(途中で止めても低 spp 側が揃う) + for spp in SPPS: + if args.spps and spp not in args.spps: + continue + for display, _ in CONDITIONS: + if args.conditions and display not in args.conditions: + continue + for seed in SEEDS: + name = job_name(display, spp, seed) + row = run_job(name, display, spp, seed, scenes[name], variant, log) + if row is not None: + done += 1 + write_summary() + log(f"=== 完了: {done} レンダ.集約 CSV: {write_summary()} ===") + log_file.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main())