diff --git a/CLAUDE.md b/CLAUDE.md index 27e09ec..ffd1dc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,10 @@ - `scripts/select_white_board.py` — 実測 PNG で白色板境界を OpenCV GUI で選択(Step 8 キャリブ用) - `scripts/visualize_roi_3d.py` — 評価板 STL に ROI を 3D 投影(PyVista,Step 8 キャリブ用) - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) -- `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 +- `src/sm_tias_lightsim/render/runner.py` — variant 管理(`ensure_variant`:プロセス内 1 回だけ設定.遮蔽検証などレンダ以外の前にも呼べる)+ `mi.render` 実行(`render.seed` を渡す) - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 +- `src/sm_tias_lightsim/render/pipeline.py` — `render_yaml(scene_yaml, output_dir, variant=None)`:YAML 読込 → variant 解決 → レンダ → PNG/npy 保存の一括関数.`sm-render` の本体で,スイープはこれをプロセス内で呼ぶ(起動・import の重複を省く) +- `src/sm_tias_lightsim/evaluation/pipeline.py` — `evaluate_image(image, config, output_dir, save_figures=True)`:`sm-evaluate` の本体.指標 dict を返し metrics.csv を書く.`save_figures=False` で図を省略(`--no-figures`) - `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy).`--variant` > 環境変数 `SM_RENDER_VARIANT` > YAML の優先順で variant を上書き(手元 llvm・サーバ cuda を同じ YAML で) - `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用,`normalization.enabled` 時は OETF 前段に linear mean 揃え正規化(OETF と排他).後方互換、シミュ用設定のみ - `src/sm_tias_lightsim/visualize/baker.py` — face 中心の照度ベイク.`mode="direct"`(shadow ray + cosine + 距離減衰、直接光のみ、後方互換)/ `mode="path"`(scalar_rgb・完全シーン・コサイン重点 hemisphere MC、多重反射込み per-face、`exclude_mesh_ids` 対応).`max_depth` 引数で path ベイクの反射回数を render 設定から独立設定(None で render 継承=後方互換) diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index 46f2071..ee3c6cc 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -65,8 +65,9 @@ │ ├── sensors.py # カメラセンサ (perspective / orthographic) の生成 │ └── paths.py # YAML からの相対メッシュパス(スイープ生成スクリプト用) ├── render/ -│ ├── runner.py # mi.set_variant + mi.render の実行 -│ └── output.py # HDR film → PNG / luminance.npy 出力 +│ ├── runner.py # mi.set_variant(ensure_variant)+ mi.render の実行 +│ ├── output.py # HDR film → PNG / luminance.npy 出力 +│ └── pipeline.py # YAML → レンダ → 保存の一括処理(render_yaml.CLI とスイープの共通本体) ├── runtime/ │ ├── llvm_init.py # LLVM JIT 初期化(DRJIT_LIBLLVM_PATH 自動探索・variant 経路ログ) │ └── overrides.py # 環境変数による実行時上書き(SM_RENDER_VARIANT / SM_DRJIT_THREADS) @@ -83,7 +84,8 @@ │ ├── normalize.py # linear mean anchor 正規化(OETF と排他) │ ├── dng_loader.py # DNG(RAW)読込(linear raw 取得,実測リファレンス用) │ ├── shape_metrics.py # 形状指標(輝度重心 / 角度-放射分解 / PCA / SSIM / NCC) -│ └── visualize.py # matplotlib による可視化 PNG 出力 +│ ├── visualize.py # matplotlib による可視化 PNG 出力 +│ └── pipeline.py # 画像 + 評価 YAML → 指標 dict + metrics.csv(+ figures/)の一括処理(evaluate_image) ├── visualize/ │ ├── baker.py # 各 face 中心の照度ベイク(direct / path モード) │ ├── projection.py # orthographic センサ投影の純関数(評価板への輝度投影) @@ -107,6 +109,7 @@ | `geometry/subdivide` | 三角形メッシュ → 最大辺長以下に均一分割した三角形メッシュ | 決定論的(同一入力→同一出力).ベイクの空間解像度を均一化 | | `render/runner` | Mitsuba scene + パラメータ → 輝度配列 | spp / max_depth / variant 切替 | | `render/output` | 輝度配列 → PNG / luminance.npy ファイル | tone mapping 等の選択肢を持つ | +| `render/pipeline` | シーン YAML + 出力先 → render.png / luminance.npy | variant 解決(引数 > 環境変数 > YAML)込み.スイープからプロセス内で直接呼べる | | `runtime/llvm_init` | LLVM JIT 環境初期化 | `DRJIT_LIBLLVM_PATH` 自動探索(Windows / Ubuntu・Debian の `libLLVM-<版>.so` / Fedora 形式)+ variant 経路の INFO/WARNING ログ | | `runtime/overrides` | 環境変数による実行時上書きの解決と適用 | `SM_RENDER_VARIANT`(variant.`sm-render --variant` が優先)/ `SM_DRJIT_THREADS`(Dr.Jit スレッド数上限.不正値は ValueError) | | `scene/paths` | メッシュの絶対パス → YAML からの相対パス(POSIX 区切り) | 手元とサーバで同じ YAML を読めるようにする.別ドライブで相対化できないときは警告して絶対パス | @@ -122,10 +125,11 @@ | `evaluation/dng_loader` | DNG(RAW)→ linear raw 配列 | 実測リファレンスの linear 取得 | | `evaluation/shape_metrics` | 輝度配列(+ 任意の参照画像)→ 形状指標 | 輝度重心 / 角度-放射分解 / PCA / SSIM / NCC | | `evaluation/visualize` | 各種統計 → matplotlib 図 PNG | 可視化のみ.データ加工はしない | +| `evaluation/pipeline` | 画像 + 評価 YAML + 出力先 → 指標 dict(metrics.csv を書く) | `save_figures=False` で図を省略.段階別の `EvaluationStepError` を投げる | | `visualize/baker` | シーン + 光源 → 各 mesh face 中心の照度(W/m²) | direct(直接光)/ path(多重反射込み)モード | | `visualize/projection` | orthographic センサ + 評価板メッシュ → 頂点 / テクスチャ投影 | 純関数.画像 ↔ 物理の向き整合 | | `visualize/viewer` | シーン + 照度 → PyVista インタラクティブ 3D ウィンドウ | メッシュ着色 / 光源マーカー / センサ視線 / スライド表示スタイル | -| `cli/render`, `cli/evaluate`, `cli/visualize` | 各ステップの CLI ラッパー | 引数パース・ファイル I/O | +| `cli/render`, `cli/evaluate`, `cli/visualize` | 各ステップの CLI ラッパー | 引数パースと例外 → 「エラー: …」+ 終了コード 1 の変換だけ.本体は `render/pipeline`・`evaluation/pipeline` | | `cli/remote` | 研究室サーバでの実行(push / run / status / logs / attach / kill / pull) | ssh の起動・サーバ上での実行検出.使い方は ENV_04「研究室サーバでの実行」 | ## エントリポイント (CLI Entry Points) diff --git "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" index bf15bae..5518beb 100644 --- "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" @@ -314,6 +314,10 @@ └── zone_map.png ``` +`--no-figures` を付けると `figures/` を作らず `metrics.csv` だけを出す(指標の値は同一. +スイープで図の描画分 約 2 秒/条件 を省くため).本体は `evaluation/pipeline.py:evaluate_image` +で,スイープスクリプトはこれをプロセス内で呼ぶ(`save_figures=False`). + ### metrics.csv カラム定義(ヘッダ順) ```text diff --git a/scripts/build_led_placement_comparison.py b/scripts/build_led_placement_comparison.py index ad0c252..1b6ee69 100644 --- a/scripts/build_led_placement_comparison.py +++ b/scripts/build_led_placement_comparison.py @@ -46,6 +46,8 @@ COUNT_THETA, DIRECTION_THETAS, OUTPUT_BASE, + REPEAT_PAIR_INDEX, + SHAPES, SIZE_PAIRS, THETA_GRID, collect_rows, @@ -77,11 +79,21 @@ return f"{'r' if shape == 'sphere' else 'L'}={size:g}" +def pair_size(pair: tuple[float, float], shape: str) -> float: + """同内面積ペアから該当形状のサイズ(球=内半径 / 立方体=内寸)を取り出す.""" + return pair[SHAPES.index(shape)] + + +def shape_size_pairs() -> list[tuple[str, int, float]]: + """(形状, ペア番号, サイズ) を全組み合わせで返す(ペア番号は色分けに使う).""" + return [(shape, i, pair_size(pair, shape)) + for shape in SHAPES for i, pair in enumerate(SIZE_PAIRS)] + + def pair_index(shape: str, size: float) -> int: """サイズペアの番号(0=最小)を返す(該当なしは -1).""" - idx = 0 if shape == "sphere" else 1 for i, pair in enumerate(SIZE_PAIRS): - if pair[idx] == size: + if pair_size(pair, shape) == size: return i return -1 @@ -125,11 +137,17 @@ return out -def size_colors(n: int): - """サイズペアごとの色.""" +def size_colors(n: int) -> np.ndarray: + """サイズペアごとの色(0=最小ペア).""" return plt.cm.viridis(np.linspace(0.0, 0.85, n)) +def position_series(results: list[dict], shape: str, size: float) -> list[dict]: + """位置スイープの 1 系列(同じ形状・サイズ)を θ 昇順で取り出す.""" + return sorted(select(results, group="position", shape=shape, size_mm=size), + key=lambda r: r["theta_deg"]) + + # --------------------------------------------------------------------------- # 図 1: 位置の感度曲線 # --------------------------------------------------------------------------- @@ -138,28 +156,25 @@ def robustness_summary(results: list[dict]) -> list[dict]: """位置スイープについて,形状 × ペアごとに配置依存の大きさを集約する.""" rows = [] - for shape in ("sphere", "cube"): - for i, pair in enumerate(SIZE_PAIRS): - size = pair[0 if shape == "sphere" else 1] - pos = sorted(select(results, group="position", shape=shape, size_mm=size), - key=lambda r: r["theta_deg"]) - if len(pos) < 3: - continue - struct = np.array([r["structure_cov"] for r in pos]) - a1 = np.array([r["a1"] for r in pos]) - on_axis = next((r for r in pos if r["theta_deg"] == 0.0), None) - rows.append({ - "shape": shape, "pair": i, "size_mm": size, - "area": pos[0]["cavity_area_mm2"], "n_theta": len(pos), - "struct_onaxis": on_axis["structure_cov"] if on_axis else float("nan"), - "struct_mean": float(struct.mean()), - "struct_max": float(struct.max()), - "struct_range": float(struct.max() - struct.min()), - "struct_std": float(struct.std(ddof=1)), - "a1_absmax": float(np.abs(a1).max()), - "a1_absmean": float(np.abs(a1).mean()), - "theta_worst": float(pos[int(np.argmax(struct))]["theta_deg"]), - }) + for shape, i, size in shape_size_pairs(): + pos = position_series(results, shape, size) + if len(pos) < 3: + continue + struct = np.array([r["structure_cov"] for r in pos]) + a1 = np.array([r["a1"] for r in pos]) + on_axis = next((r for r in pos if r["theta_deg"] == 0.0), None) + rows.append({ + "shape": shape, "pair": i, "size_mm": size, + "area": pos[0]["cavity_area_mm2"], "n_theta": len(pos), + "struct_onaxis": on_axis["structure_cov"] if on_axis else float("nan"), + "struct_mean": float(struct.mean()), + "struct_max": float(struct.max()), + "struct_range": float(struct.max() - struct.min()), + "struct_std": float(struct.std(ddof=1)), + "a1_absmax": float(np.abs(a1).max()), + "a1_absmean": float(np.abs(a1).mean()), + "theta_worst": float(pos[int(np.argmax(struct))]["theta_deg"]), + }) return rows @@ -167,20 +182,17 @@ """θ に対する感度曲線と,配置依存の集約指標の内面積依存を描く.""" fig, axes = plt.subplots(2, 3, figsize=(20, 11)) colors = size_colors(len(SIZE_PAIRS)) - for shape in ("sphere", "cube"): - for i, pair in enumerate(SIZE_PAIRS): - size = pair[0 if shape == "sphere" else 1] - pos = sorted(select(results, group="position", shape=shape, size_mm=size), - key=lambda r: r["theta_deg"]) - if not pos: - continue - theta = [r["theta_deg"] for r in pos] - label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}" - style = dict(SHAPE_STYLE[shape], color=colors[i], ms=4, lw=1.4, label=label) - axes[0, 0].errorbar(theta, [r["a1"] * 100 for r in pos], - yerr=[r["se1"] * 100 for r in pos], capsize=2, **style) - axes[0, 1].plot(theta, [r["structure_cov"] for r in pos], **style) - axes[0, 2].plot(theta, [r["centroid_x"] for r in pos], **style) + for shape, i, size in shape_size_pairs(): + pos = position_series(results, shape, size) + if not pos: + continue + theta = [r["theta_deg"] for r in pos] + label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}" + style = dict(SHAPE_STYLE[shape], color=colors[i], ms=4, lw=1.4, label=label) + axes[0, 0].errorbar(theta, [r["a1"] * 100 for r in pos], + yerr=[r["se1"] * 100 for r in pos], capsize=2, **style) + axes[0, 1].plot(theta, [r["structure_cov"] for r in pos], **style) + axes[0, 2].plot(theta, [r["centroid_x"] for r in pos], **style) axes[0, 0].set_ylabel("a1(輪帯平均比 %・正=LED 側が明るい)") axes[0, 0].set_title("m=1 非対称 vs LED 極角 θ", fontsize=11) axes[0, 1].set_ylabel("構造 CoV") @@ -200,12 +212,12 @@ ("a1_absmax", axes[1, 1], "非対称の最大: max|a1|(θ 全域)", "max|a1| [%]"), ("struct_max", axes[1, 2], "最悪配置の構造 CoV(点線は軸上 θ=0)", "構造 CoV"), ): - for shape in ("sphere", "cube"): + for shape in SHAPES: rows = [r for r in summary if r["shape"] == shape] scale = 100.0 if key == "a1_absmax" else 1.0 ax.plot([r["area"] for r in rows], [r[key] * scale for r in rows], color=SHAPE_COLOR[shape], label=f"{SHAPE_LABEL[shape]}", - **{k: v for k, v in SHAPE_STYLE[shape].items()}) + **SHAPE_STYLE[shape]) if key == "struct_max": ax.plot([r["area"] for r in rows], [r["struct_onaxis"] for r in rows], color=SHAPE_COLOR[shape], marker=SHAPE_STYLE[shape]["marker"], @@ -229,12 +241,14 @@ def draw_position_maps(results: list[dict], output: Path) -> None: """代表ペアの輝度マップを θ ごとに並べる(輪帯ピーク=1.0・共通スケール).""" fig, axes = plt.subplots(2, len(MAP_THETAS), figsize=(3.6 * len(MAP_THETAS), 7.4)) + picked: dict[tuple[str, float], dict] = {} maps: dict[tuple[str, float], np.ndarray] = {} - for shape in ("sphere", "cube"): - size = SIZE_PAIRS[MAP_PAIR_INDEX][0 if shape == "sphere" else 1] + for shape in SHAPES: + size = pair_size(SIZE_PAIRS[MAP_PAIR_INDEX], shape) for theta in MAP_THETAS: rows = select(results, group="position", shape=shape, size_mm=size, theta_deg=theta) if rows: + picked[(shape, theta)] = rows[0] maps[(shape, theta)] = normalized_map(OUTPUT_BASE / rows[0]["name"] / "luminance.npy") if not maps: @@ -242,8 +256,8 @@ return vmin = float(np.percentile(np.concatenate([m.ravel() for m in maps.values()]), 0.5)) image = None - for row_idx, shape in enumerate(("sphere", "cube")): - size = SIZE_PAIRS[MAP_PAIR_INDEX][0 if shape == "sphere" else 1] + for row_idx, shape in enumerate(SHAPES): + size = pair_size(SIZE_PAIRS[MAP_PAIR_INDEX], shape) for col, theta in enumerate(MAP_THETAS): ax = axes[row_idx, col] ax.axis("off") @@ -251,9 +265,9 @@ if data is None: continue image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=1.0) - rows = select(results, group="position", shape=shape, size_mm=size, theta_deg=theta) + row = picked[(shape, theta)] ax.set_title(f"{SHAPE_LABEL[shape]} {size_label(shape, size)} θ={theta:g}°\n" - f"a1={rows[0]['a1'] * 100:+.2f}% 構造CoV={rows[0]['structure_cov']:.4f}", + f"a1={row['a1'] * 100:+.2f}% 構造CoV={row['structure_cov']:.4f}", fontsize=9) if image is not None: fig.colorbar(image, ax=axes.ravel().tolist(), fraction=0.02, label="輪帯ピーク=1.0") @@ -281,7 +295,7 @@ ax = axes[metric_row, col] for a_idx, aim in enumerate(AIMS): for i, pair in enumerate(SIZE_PAIRS): - for s_idx, shape in enumerate(("sphere", "cube")): + for s_idx, shape in enumerate(SHAPES): size = pair[s_idx] rows = select(results, shape=shape, size_mm=size, theta_deg=theta, aim=aim, n_led=1, rep=1) @@ -327,26 +341,23 @@ fig, axes = plt.subplots(1, 3, figsize=(20, 6)) colors = size_colors(len(SIZE_PAIRS)) ns = [1] + COUNT_N - for shape in ("sphere", "cube"): - for i, pair in enumerate(SIZE_PAIRS): - size = pair[0 if shape == "sphere" else 1] - rows = [select(results, shape=shape, size_mm=size, theta_deg=COUNT_THETA, - aim="board", n_led=n, rep=1) for n in ns] - xs = [n for n, r in zip(ns, rows, strict=True) if r] - picked = [r[0] for r in rows if r] - if not picked: - continue - label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}" - style = dict(SHAPE_STYLE[shape], color=colors[i], ms=5, lw=1.4, label=label) - axes[0].plot(xs, [r["structure_cov"] for r in picked], **style) - axes[1].plot(xs, [r["m1_abs"] * 100 for r in picked], **style) - axes[1].plot(xs, [r["mN_abs"] * 100 for r in picked], marker="x", ls=":", - color=colors[i], lw=1.0, alpha=0.8) - n4 = sorted(select(results, shape=shape, size_mm=size, aim="board", n_led=4, rep=1), - key=lambda r: r["theta_deg"]) - if n4: - axes[2].plot([r["theta_deg"] for r in n4], [r["structure_cov"] for r in n4], - **style) + for shape, i, size in shape_size_pairs(): + rows = [select(results, shape=shape, size_mm=size, theta_deg=COUNT_THETA, + aim="board", n_led=n, rep=1) for n in ns] + xs = [n for n, r in zip(ns, rows, strict=True) if r] + picked = [r[0] for r in rows if r] + if not picked: + continue + label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}" + style = dict(SHAPE_STYLE[shape], color=colors[i], ms=5, lw=1.4, label=label) + axes[0].plot(xs, [r["structure_cov"] for r in picked], **style) + axes[1].plot(xs, [r["m1_abs"] * 100 for r in picked], **style) + axes[1].plot(xs, [r["mN_abs"] * 100 for r in picked], marker="x", ls=":", + color=colors[i], lw=1.0, alpha=0.8) + n4 = sorted(select(results, shape=shape, size_mm=size, aim="board", n_led=4, rep=1), + key=lambda r: r["theta_deg"]) + if n4: + axes[2].plot([r["theta_deg"] for r in n4], [r["structure_cov"] for r in n4], **style) axes[0].set_xlabel("灯数 N(θ=90° の輪に等方位・総光束一定)") axes[0].set_ylabel("構造 CoV") axes[0].set_title("灯数 vs 構造 CoV", fontsize=11) @@ -398,9 +409,8 @@ print("\n=== 向き(1 灯)===") print(f"{'条件':>30} {'バッフルR/寸法':>13} {'a1 [%]':>8} {'|m1| [%]':>9} {'構造CoV':>8} " f"{'生mean':>7}") - for r in sorted(select(results, group="direction") + select(results, group="position", - n_led=1), - key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"], r["aim"])): + aimed = select(results, group="direction") + select(results, group="position", n_led=1) + for r in sorted(aimed, key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"], r["aim"])): if r["theta_deg"] not in DIRECTION_THETAS: continue print(f"{r['name']:>30} {r['baffle_ratio']:13.3f} {r['a1'] * 100:8.3f} " @@ -417,10 +427,10 @@ f"{r['mN_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['raw_mean_roi']:7.3f}") print("\n=== 再現性(同一条件の再レンダ)===") - for shape in ("sphere", "cube"): - reps = [r for r in results if r["shape"] == shape and r["theta_deg"] == COUNT_THETA - and r["aim"] == "board" and r["n_led"] == 1 - and pair_index(shape, r["size_mm"]) == 2] + for shape in SHAPES: + # 再現性ペアの同一条件(rep=1 の元条件 + 再レンダ)をまとめて統計を取る + reps = select(results, shape=shape, pair=REPEAT_PAIR_INDEX, theta_deg=COUNT_THETA, + aim="board", n_led=1) if len(reps) < 2: continue struct = np.array([r["structure_cov"] for r in reps]) diff --git a/scripts/sweep_led_placement.py b/scripts/sweep_led_placement.py index e0016b5..5ecfdfc 100644 --- a/scripts/sweep_led_placement.py +++ b/scripts/sweep_led_placement.py @@ -44,6 +44,7 @@ import multiprocessing import sys import time +from collections.abc import Callable from concurrent.futures import ProcessPoolExecutor, as_completed from contextlib import redirect_stdout from dataclasses import dataclass @@ -100,6 +101,9 @@ SPHERE_CAVITY = "similar_radial" # TEST_15 最良・TEST_17 と同じ +# 比較する 2 形状(SIZE_PAIRS の要素順と対応させる) +SHAPES = ("sphere", "cube") + # 同内面積ペア(球の内半径, 立方体の内寸).立方体は 6L^2+594 = 球の内面積 から丸めた SIZE_PAIRS: list[tuple[float, float]] = [ (100.0, 141.0), @@ -172,6 +176,11 @@ return [360.0 * k / self.n_led for k in range(self.n_led)] +def shape_sizes() -> list[tuple[str, float]]: + """(形状, サイズ) を同内面積ペアの順に返す(形状が外側・サイズが内側).""" + return [(shape, pair[i]) for i, shape in enumerate(SHAPES) for pair in SIZE_PAIRS] + + def conditions() -> list[Condition]: """全条件を「重要な順」(位置 → 個数 → 向き → 再現性)に並べて返す. @@ -186,25 +195,19 @@ seen.add(cond.name) result.append(cond) - for shape_index, shape in enumerate(("sphere", "cube")): - for pair in SIZE_PAIRS: - size = pair[shape_index] - for theta in THETA_GRID: - add(Condition("position", shape, size, theta, "board", 1)) - for shape_index, shape in enumerate(("sphere", "cube")): - for pair in SIZE_PAIRS: - size = pair[shape_index] - for n in COUNT_N: - add(Condition("count", shape, size, COUNT_THETA, "board", n)) - for theta in COUNT_N4_THETAS: - add(Condition("count", shape, size, theta, "board", 4)) - for shape_index, shape in enumerate(("sphere", "cube")): - for pair in SIZE_PAIRS: - size = pair[shape_index] - for theta in DIRECTION_THETAS: - for aim in AIMS: - add(Condition("direction", shape, size, theta, aim, 1)) - for shape_index, shape in enumerate(("sphere", "cube")): + for shape, size in shape_sizes(): + for theta in THETA_GRID: + add(Condition("position", shape, size, theta, "board", 1)) + for shape, size in shape_sizes(): + for n in COUNT_N: + add(Condition("count", shape, size, COUNT_THETA, "board", n)) + for theta in COUNT_N4_THETAS: + add(Condition("count", shape, size, theta, "board", 4)) + for shape, size in shape_sizes(): + for theta in DIRECTION_THETAS: + for aim in AIMS: + add(Condition("direction", shape, size, theta, aim, 1)) + for shape_index, shape in enumerate(SHAPES): size = SIZE_PAIRS[REPEAT_PAIR_INDEX][shape_index] for rep in range(2, REPEATS + 2): add(Condition("repeat", shape, size, COUNT_THETA, "board", 1, rep)) @@ -506,7 +509,7 @@ def prepare( - conds: list[Condition], log, report_suffix: str = "" + conds: list[Condition], log: Callable[[str], None], report_suffix: str = "" ) -> dict[str, tuple[Path, dict]]: """条件群の幾何を生成し,置けない条件はスキップして記録する. @@ -606,6 +609,56 @@ return {"occlusion_blocked_frac": float("nan"), "occlusion_total": 0} +def condition_row(cond: Condition, record: dict, variant: str) -> dict: + """レンダ前に決まる列(条件の指定・幾何の記録・variant)だけの集約行を作る.""" + row: dict = { + "name": cond.name, "group": cond.group, "shape": cond.shape, "size_mm": cond.size, + "theta_deg": cond.theta_deg, "aim": cond.aim, "n_led": cond.n_led, "rep": cond.rep, + } + row.update(record) + row["render_variant"] = variant + return row + + +def render_and_evaluate( + scene_yaml: Path, out_dir: Path, variant: str, messages: list[str] +) -> bool: + """1 条件をプロセス内でレンダ → 評価する(失敗しても例外を投げず False を返す). + + sm-render / sm-evaluate をサブプロセスで呼ぶ代わりにプロセス内で実行する + (起動 + import mitsuba + 図の描画で 1 条件あたり 4 秒前後を使っていたため). + 標準出力は条件ごとの評価サマリーで埋まるので取り込んでおき,失敗時だけ末尾を残す. + + Returns: + 成功したら True.失敗したら messages に理由を足して False. + """ + captured = io.StringIO() + try: + with redirect_stdout(captured): + render_yaml(scene_yaml, out_dir, variant) + evaluate_image( + out_dir / "luminance.npy", EVAL_CONFIG_YAML, out_dir, save_figures=False, + ) + except Exception as exc: # noqa: BLE001 - 1 条件の失敗でスイープ全体を止めない + messages.append(f" !! 失敗: {exc}") + tail = captured.getvalue().strip()[-800:] + if tail: + messages.append(f" 出力: {tail}") + return False + return True + + +def result_columns(out_dir: Path) -> dict: + """レンダ結果(metrics.csv / luminance.npy)から集約行の指標列を読む.""" + columns: dict = dict(read_metrics_csv(out_dir / "metrics.csv")) + with (out_dir / "metrics.csv").open(encoding="utf-8") as fh: + metrics = next(csv.DictReader(fh)) + for key in ("intensity_centroid_x_norm", "intensity_centroid_y_norm"): + columns[key] = float(metrics[key]) if metrics.get(key) else float("nan") + columns.update(raw_stats(out_dir / "luminance.npy")) + return columns + + def run_job(cond: Condition, scene_yaml: Path, record: dict) -> tuple[dict | None, list[str]]: """1 条件をレンダ→評価まで実行し row.json を書いて集約行とログ行を返す. @@ -622,41 +675,20 @@ if row_json.exists(): messages.append(f"--- {cond.name}: 既存結果を再利用") return json.loads(row_json.read_text(encoding="utf-8")), messages + variant = ensure_render_variant() - row: dict = { - "name": cond.name, "group": cond.group, "shape": cond.shape, "size_mm": cond.size, - "theta_deg": cond.theta_deg, "aim": cond.aim, "n_led": cond.n_led, "rep": cond.rep, - } - row.update(record) - row["render_variant"] = variant + row = condition_row(cond, record, variant) if record["baffled"]: row.update(verify_occlusion(scene_yaml, messages)) + messages.append(f"--- {cond.name}: レンダ開始") out_dir.mkdir(parents=True, exist_ok=True) start = time.time() - # sm-render / sm-evaluate をサブプロセスで呼ぶ代わりにプロセス内で実行する - # (起動 + import mitsuba + 図の描画で 1 条件あたり 4 秒前後を使っていたため). - # 標準出力は条件ごとの評価サマリーで埋まるので取り込んでおき,失敗時だけ末尾を残す. - captured = io.StringIO() - try: - with redirect_stdout(captured): - render_yaml(scene_yaml, out_dir, variant) - evaluate_image( - out_dir / "luminance.npy", EVAL_CONFIG_YAML, out_dir, save_figures=False, - ) - except Exception as exc: # noqa: BLE001 - 1 条件の失敗でスイープ全体を止めない - messages.append(f" !! 失敗: {exc}") - tail = captured.getvalue().strip()[-800:] - if tail: - messages.append(f" 出力: {tail}") + if not render_and_evaluate(scene_yaml, out_dir, variant, messages): return None, messages + row["elapsed_s"] = round(time.time() - start, 1) - row.update(read_metrics_csv(out_dir / "metrics.csv")) - with (out_dir / "metrics.csv").open(encoding="utf-8") as fh: - metrics = next(csv.DictReader(fh)) - for key in ("intensity_centroid_x_norm", "intensity_centroid_y_norm"): - row[key] = float(metrics[key]) if metrics.get(key) else float("nan") - row.update(raw_stats(out_dir / "luminance.npy")) + row.update(result_columns(out_dir)) row_json.write_text(json.dumps(row, ensure_ascii=False, indent=1), encoding="utf-8") occl = row.get("occlusion_blocked_frac", float("nan")) cx, cy = row["intensity_centroid_x_norm"], row["intensity_centroid_y_norm"] @@ -735,6 +767,16 @@ jobs = [(cond, *prepared[cond.name]) for cond in conds if cond.name in prepared] done = 0 + + def log_result(row: dict | None, messages: list[str]) -> int: + """1 条件の結果をログに流し,成功していれば集約 CSV を書き直して 1 を返す.""" + for message in messages: + log(message) + if row is None: + return 0 + write_summary() + return 1 + if args.workers > 1: # ワーカーは spawn(Windows 互換)で立ち上げ,各プロセスが自前で variant を # 1 回設定してから条件を処理する.集約 CSV の書き出しは主プロセスだけが行う. @@ -742,8 +784,8 @@ context = multiprocessing.get_context("spawn") with ProcessPoolExecutor(max_workers=args.workers, mp_context=context) as pool: futures = { - pool.submit(run_job, cond, scene_yaml, record): cond - for cond, scene_yaml, record in jobs + pool.submit(run_job, cond, scene_yaml, geometry_record): cond + for cond, scene_yaml, geometry_record in jobs } for future in as_completed(futures): cond = futures[future] @@ -752,19 +794,10 @@ except Exception as exc: # noqa: BLE001 - 1 条件の異常で全体を止めない log(f" !! {cond.name}: ワーカーが異常終了(続行): {exc}") continue - for message in messages: - log(message) - if row is not None: - done += 1 - write_summary() + done += log_result(row, messages) else: - for cond, scene_yaml, record in jobs: - row, messages = run_job(cond, scene_yaml, record) - for message in messages: - log(message) - if row is not None: - done += 1 - write_summary() + for cond, scene_yaml, geometry_record in jobs: + done += log_result(*run_job(cond, scene_yaml, geometry_record)) log(f"\n=== 完了: {done} / {len(prepared)} 条件 ===") log(f" 集約 CSV: {write_summary()}") diff --git a/src/sm_tias_lightsim/evaluation/pipeline.py b/src/sm_tias_lightsim/evaluation/pipeline.py index 9ee3f52..f8e89fa 100644 --- a/src/sm_tias_lightsim/evaluation/pipeline.py +++ b/src/sm_tias_lightsim/evaluation/pipeline.py @@ -14,6 +14,8 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path import matplotlib @@ -78,6 +80,38 @@ """ +@contextmanager +def _step( + label: str, + catch: type[Exception] | tuple[type[Exception], ...] = Exception, + keep_message: tuple[type[Exception], ...] = (), +) -> Iterator[None]: + """処理段階の例外を「段階名 — 原因」の EvaluationStepError に包み直す文脈マネージャ. + + 段階ごとに同じ try/except を並べる代わりに使う(段階が増えても書き方が揃う). + + Args: + label: 失敗時に前置きする段階名(例: "ROI 抽出に失敗しました"). + catch: 包む例外の型.段階ごとに想定する型だけを指定すると, + 想定外の例外はそのまま送出される. + keep_message: 例外のメッセージ自体が完結しているため段階名を前置きしない型 + (例: "画像ファイルが見つかりません: " を持つ FileNotFoundError). + + Yields: + None(処理本体を with ブロックに書く). + + Raises: + EvaluationStepError: with ブロック内で catch / keep_message の例外が起きた場合. + """ + try: + yield + except keep_message as exc: + raise EvaluationStepError(str(exc)) from exc + except catch as exc: + msg = f"{label} — {exc}" + raise EvaluationStepError(msg) from exc + + def _apply_oetf_to_luminance( roi_luminance: numpy.ndarray, oetf_cfg: OetfConfig, @@ -358,46 +392,66 @@ result["pca_eccentricity"] = pca["eccentricity"] # SSIM / NCC(shape_reference が指定されている場合のみ) - reference_path_str = metrics_config.shape_reference - if reference_path_str is not None: - ref_path = Path(reference_path_str) - if not ref_path.is_absolute(): - ref_path = config_dir / ref_path - - try: - ref_luminance = _load_reference_luminance(ref_path) - # shape が一致するか確認(一致しない場合は警告してスキップ) - if ref_luminance.shape != roi_luminance.shape: - print( - f" 警告: shape_reference の shape {ref_luminance.shape} が " - f"ROI の shape {roi_luminance.shape} と異なるため SSIM/NCC をスキップします." - ) - result["ssim"] = float("nan") - result["ncc"] = float("nan") - else: - result["ssim"] = calc_ssim(roi_luminance, ref_luminance) - result["ncc"] = calc_ncc(roi_luminance, ref_luminance) - except FileNotFoundError: - print( - f" 警告: shape_reference ファイルが見つかりません: {ref_path}." - "SSIM/NCC をスキップします." - ) - result["ssim"] = float("nan") - result["ncc"] = float("nan") - except Exception as exc: # noqa: BLE001 - print( - f" 警告: shape_reference の読み込みに失敗しました — {exc}." - "SSIM/NCC をスキップします." - ) - result["ssim"] = float("nan") - result["ncc"] = float("nan") - else: - result["ssim"] = float("nan") - result["ncc"] = float("nan") - + result.update(_run_reference_metrics(roi_luminance, metrics_config.shape_reference, config_dir)) return result +def _skipped_reference_metrics(reason: str) -> dict: + """SSIM / NCC を算出できないときの戻り値(警告を出して NaN を返す). + + Args: + reason: 警告として表示する理由(末尾に句点を含めない). + + Returns: + ssim / ncc を float("nan") にした辞書. + """ + print(f" 警告: {reason}.SSIM/NCC をスキップします.") + return {"ssim": float("nan"), "ncc": float("nan")} + + +def _run_reference_metrics( + roi_luminance: numpy.ndarray, + reference_path_str: str | None, + config_dir: Path, +) -> dict: + """参照画像との SSIM / NCC を算出する(参照が使えないときは NaN). + + 参照画像が読めない・ROI と shape が違うといった理由で算出できない場合は, + 警告を出して NaN を返す(指標の列構成を条件によらず一定に保つため). + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + reference_path_str: shape_reference のパス文字列.None なら参照なし(NaN). + config_dir: 評価 YAML のディレクトリ(相対パス解決の基点). + + Returns: + ssim / ncc の 2 キーを持つ辞書. + """ + if reference_path_str is None: + return {"ssim": float("nan"), "ncc": float("nan")} + + ref_path = Path(reference_path_str) + if not ref_path.is_absolute(): + ref_path = config_dir / ref_path + + try: + ref_luminance = _load_reference_luminance(ref_path) + # shape が一致するか確認(一致しない場合は警告してスキップ) + if ref_luminance.shape != roi_luminance.shape: + return _skipped_reference_metrics( + f"shape_reference の shape {ref_luminance.shape} が " + f"ROI の shape {roi_luminance.shape} と異なります" + ) + return { + "ssim": calc_ssim(roi_luminance, ref_luminance), + "ncc": calc_ncc(roi_luminance, ref_luminance), + } + except FileNotFoundError: + return _skipped_reference_metrics(f"shape_reference ファイルが見つかりません: {ref_path}") + except Exception as exc: # noqa: BLE001 + return _skipped_reference_metrics(f"shape_reference の読み込みに失敗しました — {exc}") + + def _save_metrics_csv(metrics: dict, csv_path: Path) -> None: """均一性指標を CSV ファイルとして保存する. @@ -482,7 +536,6 @@ print(f"figures/ : {figures_dir}") - def evaluate_image( image_path: Path, config_path: Path, @@ -524,108 +577,63 @@ config_path = Path(config_path).resolve() output_dir = Path(output_dir).resolve() - try: + with _step("画像の読み込みに失敗しました", keep_message=(FileNotFoundError,)): luminance = _load_luminance(image_path) - except FileNotFoundError as exc: - raise EvaluationStepError(str(exc)) from exc - except Exception as exc: - msg = f"画像の読み込みに失敗しました — {exc}" - raise EvaluationStepError(msg) from exc - try: + with _step("設定 YAML の読み込みに失敗しました", keep_message=(FileNotFoundError,)): eval_config = _load_eval_config(config_path) - except FileNotFoundError as exc: - raise EvaluationStepError(str(exc)) from exc - except Exception as exc: - msg = f"設定 YAML の読み込みに失敗しました — {exc}" - raise EvaluationStepError(msg) from exc figures_dir = output_dir / _FIGURES_DIRNAME if save_figures else None - try: + with _step("出力ディレクトリを作成できません", OSError): output_dir.mkdir(parents=True, exist_ok=True) if figures_dir is not None: figures_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - msg = f"出力ディレクトリを作成できません — {exc}" - raise EvaluationStepError(msg) from exc - try: + with _step("ROI 抽出に失敗しました", (ValueError, NotImplementedError)): roi_luminance = extract_roi(luminance, eval_config.roi) - except (ValueError, NotImplementedError) as exc: - msg = f"ROI 抽出に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc # linear mean anchor 正規化(enabled のとき ROI 抽出後・OETF 前に挟む). # OETF との併用禁止は EvaluationConfig の model_validator で保証されている. norm_cfg = eval_config.normalization if norm_cfg is not None and norm_cfg.enabled: - try: + with _step("linear 正規化に失敗しました", ValueError): roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg) - except ValueError as exc: - msg = f"linear 正規化に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc # OETF 順方向適用(enabled のとき ROI 抽出後・指標算出前に挟む). # eval_real.yaml(実測 PNG)は oetf を設定しないこと: # 実測 PNG は既に sRGB 符号化済みのため,ここで OETF を掛けると二重適用になる. oetf_cfg = eval_config.oetf if oetf_cfg is not None and oetf_cfg.enabled: - try: + with _step("OETF 適用に失敗しました", ValueError): roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg) - except ValueError as exc: - msg = f"OETF 適用に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc - try: - uniformity_stats = calc_uniformity(roi_luminance) - except ValueError as exc: - msg = f"均一性指標の算出に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc + with _step("均一性指標の算出に失敗しました", ValueError): + metrics: dict = dict(calc_uniformity(roi_luminance)) - metrics: dict = dict(uniformity_stats) - - try: + with _step("ゾーン分析に失敗しました", ValueError): zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones) metrics.update(zone_stats) - except ValueError as exc: - msg = f"ゾーン分析に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc - try: + with _step("放射状プロファイルの算出に失敗しました"): radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics) - except Exception as exc: - msg = f"放射状プロファイルの算出に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc - try: - radial_min_max_stats = _run_radial_min_max( - roi_luminance, eval_config.metrics, radial_profile + with _step("動径 min/max 比の算出に失敗しました"): + metrics.update( + _run_radial_min_max(roi_luminance, eval_config.metrics, radial_profile) ) - metrics.update(radial_min_max_stats) - except Exception as exc: - msg = f"動径 min/max 比の算出に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc - try: - shape_stats = _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent) - metrics.update(shape_stats) - except Exception as exc: - msg = f"形状指標の算出に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc + with _step("形状指標の算出に失敗しました"): + metrics.update( + _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent) + ) csv_path = output_dir / _METRICS_FILENAME - try: + with _step("metrics.csv の保存に失敗しました"): _save_metrics_csv(metrics, csv_path) - except Exception as exc: - msg = f"metrics.csv の保存に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc if figures_dir is not None: - try: + with _step("可視化 PNG の保存に失敗しました"): _save_figures(roi_luminance, figures_dir, radial_profile, zone_map) - except Exception as exc: - msg = f"可視化 PNG の保存に失敗しました — {exc}" - raise EvaluationStepError(msg) from exc _print_summary(metrics, csv_path, figures_dir) return metrics