diff --git a/CLAUDE.md b/CLAUDE.md index 56d7a8e..ef5f3b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 7(実装)— **研究室サーバ(Newton)で計算する仕組みを整備**.`sm-remote` で手元からコード送信・tmux でジョブ起動・結果回収ができ,GPU(cuda_ad_rgb)は手元 CPU の 37〜72 倍で結果も一致(初回セットアップは ENV_02「研究室サーバ編」,日常操作は ENV_04「研究室サーバでの実行」).**環境**: mitsuba は **3.7.1 に固定**(テストは `uv run python -m pytest`).2026-09-13 に開発機の Smart App Control が評価モードからオンに切り替わり mitsuba がブロックされたため,**SAC はオフ**にした(ENV_04 参照).直前の研究ステップは [TEST_17](docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md) オフ軸 LED 配置(**未実施**: ずれ量の感度曲線・非対称ピーク非単調性の原因・フラット天井版・直方体 L×L×D・ノイズフロア).詳細は [docs/PROGRESS.md](docs/PROGRESS.md). +最新: フェーズ 7(実装)— **TEST_18 照明配置依存性の球と立方体の比較**(LED の位置 θ・向き・個数を同内面積ペアで振った 255 条件.配置依存は球が立方体の 1/2〜1/3,対向 2 灯以上で形状差が消える.校正材質 ρ=0.81 でも結論不変(軸上は両形状とも最良になる).真下向き多灯はバッフル無しで成立するが板向き多灯に劣る)と,**スイープの高速化**(`sm-render`/`sm-evaluate` の本体を `render/pipeline`・`evaluation/pipeline` に関数化してプロセス内実行,`--no-figures`,遮蔽検証を cuda で,`--workers 2`.1 条件 球 40 秒 → 6 秒).サーバは `sm-remote`(学外からは `SM_REMOTE_HOST=Newton-via-lab`).**環境**: mitsuba は **3.7.1 に固定**(テストは `uv run python -m pytest`),開発機の **SAC はオフ**(ENV_04 参照).同一 seed の再レンダは集計指標が一致するので,ノイズフロアは `render.seed` を変えて測る.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -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 継承=後方互換) @@ -56,6 +58,7 @@ - `src/sm_tias_lightsim/remote/{commands,files,transfer}.py` + `src/sm_tias_lightsim/cli/remote.py` — `sm-remote` CLI(研究室サーバ Newton での実行).push(tar over ssh.`.venv`/`output`/`.git`/`configs/_tmp_*` 除外)/ run(tmux でデタッチ起動.既定 `cuda_ad_rgb`・`--gpu`・`--threads`)/ status / logs / attach / kill / pull(`output/` 配下のみ.要求パス外・不正な名前・リンクを拒否).サーバ上で実行すると止まって案内.使い方は ENV_04「研究室サーバでの実行」 - `src/sm_tias_lightsim/evaluation/dng_loader.py` — DNG 読み込みモジュール.SmTIAS-Evaluation `src/io/dng_loader.py` を SmTIAS-LightSim にコピーしたもの (オリジナルは SmTIAS-Evaluation 側で管理.バグ修正・機能追加はそちらで反映).主要 API: `load_image_dng(path, demosaic, black_level, output_format)`.`output_format` は `rgb_float32` / `luma_float32` / `rgb_uint8`.linear 系出力 (`rgb_float32` / `luma_float32`) は `output_color=raw` でカラーマトリクス非適用・per-channel 比例性保持.`gamma=(1,1) + no_auto_bright=True + use_camera_wb=False + user_wb=[1,1,1,1]` で linear raw を保つ.依存: `rawpy>=0.21.0` - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル.既存 `calc_radial_profile` に加えて SmTIAS-Evaluation `src/analysis/spatial.py:calc_radial_min_max_ratio` をマージ.動径 min/max 比 (20 点輪帯平均) は画素単位 max/min よりノイズに対しロバスト.nan ビン除外を移植時に補強 (既存 `calc_radial_profile` は空ビンを nan で返す) +- `src/sm_tias_lightsim/evaluation/fullimage.py` — 画像全体で評価するノイズ補正付き最小/最大比(`calc_fullimage_min_max_ratio`).σ=12 平滑化の min/max からノイズ場(残差の空間シャッフルを同じ平滑化)の極値を差し引く.動径 min/max 比が方位平均で非対称を見逃す問題(TEST_18)と,画素・パーセンタイルがノイズの裾を拾う問題の両方を避ける.`MetricsConfig.fullimage_min_max` / `fullimage_sigma` で有効化(既定 False).偏りとノイズ床の注意は SPEC_04 - `src/sm_tias_lightsim/evaluation/shape_metrics.py` — 輝度分布「形」評価指標モジュール(新規).現状 CoV/勾配/C/P/動径 min/max は不均一性の絶対量しか測れず,Y 軸単調勾配と中央ピーク楕円を構造的に区別できない問題を解決する 5 つの純関数: `calc_intensity_centroid`(輝度重心,5 値)/ `calc_angular_radial_decomposition`(極座標分解,angular_ratio が 0=同心円〜1=方向依存,3 値)/ `calc_ssim`(構造的類似度,scikit-image 依存,shape_reference 指定時のみ)/ `calc_ncc`(正規化相関係数,絶対輝度不変)/ `calc_intensity_pca`(weighted PCA で主軸角度と eccentricity,4 値).mask 省略可能,完全均一画像で angular_ratio/NCC は NaN を返す.`MetricsConfig.shape_metrics=true` / `shape_reference` フィールドで有効化,後方互換保持.依存追加: `scikit-image>=0.21.0`.詳細: [TECH_11 形状指標の見直し](docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). @@ -153,3 +156,4 @@ - バッフル導入と球半径スイープ: docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md(LED 壁面密着+バッフルで直接光を遮る新構成と,球内半径 88〜250mm のスイープ.ロフト接合の 2 段階の作り直しで「大きいほど悪化」が逆転し,板直下の喉が最大のレバーと判明した経緯) - 立方体キャビティ導入と球との比較: docs/06_TEST/TEST_16_立方体キャビティ導入と球との比較.md(ロフト・接合・喉の無い立方体を L=120〜360mm でスイープし内面積で球と対応づけ.同面積なら立方体が一貫して均一で,四隅の写り込みは m=4 方位フーリエで検出限界以下と確認) - オフ軸 LED 配置の影響とサイズ依存: docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(LED を側壁に置いた非対称の定量.サイズとともに減衰するが,頑健性は球 > 立方体で軸上の優劣と逆転.明るくなるのは LED の反対側) +- 照明配置依存性の球と立方体の比較: docs/06_TEST/TEST_18_照明配置依存性の球と立方体の比較.md(LED の位置 θ・向き・個数を同内面積ペアで振った 198 条件.配置依存は球が立方体の 1/2〜1/3,対向 2 灯以上で形状差が消える.追加実験: 立方体は面の稜へ寄せると悪化,真下向き多灯はバッフル無しで成立するが板向き多灯には劣る,校正材質 ρ=0.81 でも結論不変だが軸上は両形状とも最良になる) diff --git a/configs/eval_baseline_dng_target_shape.yaml b/configs/eval_baseline_dng_target_shape.yaml index 5cea129..e35cbdc 100644 --- a/configs/eval_baseline_dng_target_shape.yaml +++ b/configs/eval_baseline_dng_target_shape.yaml @@ -37,6 +37,7 @@ max_min: true radial_profile: true radial_min_max: true + fullimage_min_max: true # 画像全体のノイズ補正付き min/max(方位依存の不均一も測る) shape_metrics: true shape_reference: null # 実機 DNG 由来の参照輝度マップを指定すると SSIM/NCC が有効化される normalization: diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index e3ba27a..3237bfa 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -15,7 +15,7 @@ | コマンド | 役割 | 実装 | | --- | --- | --- | | `uv run sm-render ` | シーン YAML をレンダリングし PNG / luminance.npy を出力(`--variant` で Mitsuba variant を上書き可) | `cli/render.py` | -| `uv run sm-evaluate <入力> ` | PNG / luminance.npy を評価し metrics.csv + figures/ を出力 | `cli/evaluate.py` | +| `uv run sm-evaluate <入力> ` | PNG / luminance.npy を評価し metrics.csv + figures/ を出力(`--no-figures` で図を省き指標だけ出す.描画分 約 2 秒が省ける) | `cli/evaluate.py`(本体は `evaluation/pipeline.py:evaluate_image`) | | `uv run sm-visualize ` | シーンを 3D 表示 / スクリーンショット出力 | `cli/visualize.py` | | `uv run sm-remote <サブコマンド>` | 研究室サーバでの計算(コード送信・tmux でのジョブ起動・状態確認・結果取得).下記「研究室サーバでの実行」参照 | `cli/remote.py` | @@ -89,6 +89,7 @@ uv run sm-remote run --name offaxis --gpu 0 -- python scripts/sweep_offaxis_led.py uv run sm-remote run --name r100 --gpu 0 -- sm-render --scene configs/scene_sphere_r100.yaml --output output/r100 uv run sm-remote run --name cpu --yaml-variant --threads 16 -- sm-render --scene configs/scene_baseline.yaml --output output/cpu +uv run sm-remote run --name place --gpu 0 --threads 16 -- python scripts/sweep_led_placement.py --workers 2 # スイープ(GPU 1 枚・2 並列) # 様子を見る・止める uv run sm-remote logs offaxis # ログ末尾(-n 行数) @@ -105,7 +106,9 @@ - `run` は既定で `SM_RENDER_VARIANT=cuda_ad_rgb` を渡す(sm-render が YAML の variant より優先して使う.GPU は手元の CPU の 37〜72 倍速く,Newton の `llvm_ad_rgb` と結果が一致することを 2026-09-13 に確認済み).YAML の variant のまま回すときは `--yaml-variant` - レンダ結果は,同じコード・同じ YAML・同じ seed でも**実行ごとに画素単位で変わる**(GPU・CPU とも.2026-09-13 に Newton で確認).同じシーンオブジェクトを 2 回レンダした差は丸め誤差(並列加算の順序)だけで平均も一致するが,シーンを読み込み直すと約 97% の画素が MC ノイズ程度に変わる(平均の差 3〜7e-4).コーネルボックスでは読み込み直しても丸め誤差だけなので,このシーン固有の現象.読み込み時に並列で作られる加速構造かサンプリング用の表の丸め差が,最大 16 回の反射で経路の分岐として増幅されていると推定している(どちらかは未切り分け).sm-render は毎回シーンを読み込むので,1 回ごとに別のノイズ実現と考える.集計指標の条件比較には問題ないが,画素単位で過去のレンダと突き合わせる用途には向かない - CPU(`llvm_*` / `scalar_*`)で回すときは `--threads N` で Dr.Jit のスレッド数に上限を付ける(共用サーバの 64 スレッドを占有しないため) -- GPU で回すときの CPU 負荷は小さい(球 10 条件のスイープで CPU は平均 156%・最大 342%=64 スレッド中 1.5〜3.4 スレッド分.2026-09-13 実測).スイープ全体の時間の大半は CPU 側の形状生成・シーン読み込み・評価で GPU 使用率は平均 3% なので,1 本のスイープに GPU は 1 枚(`--gpu`)で足りる +- GPU で回すときの CPU 負荷は小さい(球 10 条件のスイープで CPU は平均 156%・最大 342%=64 スレッド中 1.5〜3.4 スレッド分.2026-09-13 実測).1 本のスイープに GPU は 1 枚(`--gpu`)で足りる +- **スイープの 1 条件あたりの内訳**(`scripts/profile_sweep_step.py` で計測.2026-09-14 Newton GPU 0・spp=1024・512²): GPU のレンダ本体 4.4〜5.2 秒が床.旧方式(`uv run sm-render` / `sm-evaluate` をサブプロセスで呼ぶ)はそれに起動・import・YAML・PLY 書き出しで約 2.5 秒,図の描画で約 2 秒が乗り,さらに遮蔽検証(`verify_baffle_occlusion`)を YAML の llvm で回すと球で約 25 秒かかっていた.`scripts/sweep_led_placement.py` 以降のスイープは `render/pipeline.py:render_yaml` と `evaluation/pipeline.py:evaluate_image(save_figures=False)` をプロセス内で呼び,`runner.ensure_variant` で先に cuda を確定してから遮蔽検証する(球 40 秒 → 約 6 秒,立方体 13 秒 → 5.5 秒).`--workers N` で同じ GPU に複数プロセスを載せられるが,GPU 律速なので実測は 1→5.5 秒・2→5.0 秒・3→4.5〜5.8 秒(1 条件あたり).**`--workers 2` を既定の推奨**とする(CPU 側の処理を隠す分だけ得.3 以上は効果なし) +- 同一シーンを seed を変えずに再レンダしても集計指標は 4 桁まで一致する(画素はノイズ程度に変わるが集計では消える).**ノイズフロアを測るときは `render.seed` を変える**(`RenderConfig.seed`.`sweep_led_placement.py` の再現性条件は rep ごとに seed を変えた YAML を書く) - GPU は `status` で空きを見てから `--gpu` で指定する(未指定だと CUDA は 0 番を使う).その他の環境変数は `--env KEY=VALUE` で渡せる - サーバで pytest を回すときは `--yaml-variant` を付ける(variant の上書きをテストに持ち込まないため): `uv run sm-remote run --name pytest-all --yaml-variant -- python -m pytest -m ""` - 各サブコマンドの詳細は `uv run sm-remote <サブコマンド> --help`.接続先は `--host` / `--remote-dir` で変えられる 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..b54e6e0 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 を読めるようにする.別ドライブで相対化できないときは警告して絶対パス | @@ -121,11 +124,13 @@ | `evaluation/normalize` | linear 輝度 → mean を target_mean に揃えた輝度 | 線形ゲイン.形状指標は不変.OETF と排他 | | `evaluation/dng_loader` | DNG(RAW)→ linear raw 配列 | 実測リファレンスの linear 取得 | | `evaluation/shape_metrics` | 輝度配列(+ 任意の参照画像)→ 形状指標 | 輝度重心 / 角度-放射分解 / PCA / SSIM / NCC | +| `evaluation/fullimage` | 輝度配列 → 画像全体のノイズ補正付き min/max 比 | 方位に依らない.σ 平滑化 + ノイズ場の極値で補正.純関数 | | `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_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index d56c026..12ebb4d 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -186,9 +186,12 @@ integrator: path # path | direct | volpath | ptracer max_depth: 8 spp: 256 # サンプル数 + seed: 0 # 乱数シード(省略時 0・0 以上) ``` `integrator` の `ptracer` はフォワード光線追跡(光源→センサ)の粒子トレーサー. +`seed` は `mi.render` に渡す乱数シード.同じ seed のまま再レンダしても集計指標は実質同じ値になるため, +ノイズフロアを測る再レンダでは seed を変える(ENV_04「研究室サーバでの実行」参照). ### evaluation(任意) @@ -209,6 +212,8 @@ max_min: true radial_profile: true radial_min_max: false # 動径 min/max 比の 4 列を metrics.csv に追加(既定 false) + fullimage_min_max: false # 画像全体のノイズ補正付き min/max 比の 5 列を追加(既定 false) + fullimage_sigma: 12.0 # 上記の平滑化 σ [px](> 0.ROI の短辺が 4σ 以上必要) shape_metrics: false # 形状指標(輝度重心 / 角度-放射分解 / PCA 主軸)を追加(既定 false) shape_reference: null # SSIM / NCC 算出用の参照画像パス(.npy / .png).shape_metrics=true 時のみ有効 oetf: # 任意.省略時 OETF 無効(linear のまま評価=後方互換) 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..9a7155f 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" @@ -246,6 +246,47 @@ `metrics.csv` に `radial_min_max_ratio` / `radial_min_distance` / `radial_min` / `radial_max` の 4 列を末尾に追加.既定 False(既存挙動互換).`metrics.radial_profile` の有無に関わらず内部で動径プロファイルを算出するため独立して有効化可能.実装: `evaluation/radial.py:calc_radial_min_max_ratio`(SmTIAS-Evaluation の `src/analysis/spatial.py` からマージ). +### 画像全体 min/max 比(オプション,2026-09-14 導入) + +`metrics.fullimage_min_max: true` で有効化.**画像全体で評価する,ノイズ補正付きの最小/最大比**. +動径 min/max 比は方位方向に平均するため,同心円状でない輝度分布(オフ軸 LED 配置など)では +明るい側と暗い側が同じ輪帯で混ざり非対称を過小評価する(TEST_18 では非対称が強い条件で +0.97〜0.99 に張り付き,差は非対称の大きさ \|a1\| と相関 0.93〜0.95).一方,画素単位や平滑化後の +パーセンタイルは MC ノイズの裾を拾い,生の輝度が低い(相対ノイズが大きい)条件で頭打ちになる. +本指標はその両方を避ける. + +定義(`evaluation/fullimage.py:calc_fullimage_min_max_ratio`): + +1. ROI 輝度を σ = `fullimage_sigma`(既定 12 px)のガウス平滑化して `smooth` を得る. +2. ノイズ場を推定する: 残差(ROI − ガウス(σ=4)(メディアン 5 px)(ROI))を空間シャッフルして同じ + σ で平滑化し,その最小値 `noise_min`・最大値 `noise_max` を 4 回の平均で求める(構造を壊して + ノイズの統計だけ残す.構造 CoV と同じ発想). +3. `fullimage_min = min(smooth) − noise_min`,`fullimage_max = max(smooth) − noise_max`, + `fullimage_min_max_ratio = fullimage_min / fullimage_max`. + +| 指標 | 定義 | 値域 | +| --- | --- | --- | +| **画像全体 min/max 比** | ノイズ補正後の `min / max` | 0〜1(1.0 で均一.小さいほどムラ大) | + +`metrics.csv` に `fullimage_min_max_ratio` / `fullimage_min` / `fullimage_max` / `fullimage_noise_min` / +`fullimage_noise_max` の 5 列を追加(動径 min/max の後・形状指標の前).既定 False(既存挙動互換). +線形ゲインに不変(mean anchor 正規化の前後で同じ値).同じ画像・同じ設定なら常に同じ値(乱数は +関数内で seed 固定).入力が 2D でない・σ ≤ 0・`min(H, W) < 4σ`・補正後の max ≤ 0 は ValueError. + +検証(`scripts/build_fullimage_minmax.py`,軸上スイープ 20 条件): 対称な分布では動径 min/max 比と +±0.01 以内で一致し,一様像+同量のノイズで測ったノイズ床は ±0.004.ブロック平均・多項式フィット +・補正なし平滑化の各候補より偏りとノイズ床の両方で優れた. + +**使用上の注意**: + +- **平滑化による偏り**: σ の平滑化が構造をなますため,比は真値より少し大きく(均一寄りに)出る + (256² の線形勾配で真値 0.800 → 0.813,ガウス山で 0.857 → 0.865).偏りは σ と ROI サイズの比で + 決まるので,**σ を固定して条件間を比較する**用途に使い,絶対値を他の定義と直接比べない. +- **ノイズ床はノイズ量に比例**: 一様像で相対ノイズ 5% → 0.003,10% → 0.006,20% → 0.012. + 構造のある像ではノイズが増えると比が均一寄りに上振れするので,**同じ spp(同じノイズ水準) + の条件同士で比較する**. +- ROI が小さいときは σ を下げる(`fullimage_sigma`).4σ 未満の辺は受け付けない. + ### 形状指標 (Shape Metrics — オプション、2026-06-03 導入) **動機**: 既存の 4 指標 (CoV/勾配/C/P/動径 min/max) は **不均一性の絶対量しか測れず、「形」を構造的に区別できない**.シミュ「Y 軸単調勾配」と実機「中央ピーク楕円」のように形が違う 2 つの画像でも、数値だけは似通うことがある.詳細は [TECH_11 形状指標の見直しと新指標導入](../05_TECH/TECH_11_形状指標の見直しと新指標導入.md). @@ -314,6 +355,10 @@ └── zone_map.png ``` +`--no-figures` を付けると `figures/` を作らず `metrics.csv` だけを出す(指標の値は同一. +スイープで図の描画分 約 2 秒/条件 を省くため).本体は `evaluation/pipeline.py:evaluate_image` +で,スイープスクリプトはこれをプロセス内で呼ぶ(`save_figures=False`). + ### metrics.csv カラム定義(ヘッダ順) ```text diff --git "a/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" "b/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" index 79a1a5c..18a2a5d 100644 --- "a/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" +++ "b/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" @@ -91,6 +91,9 @@ | `output/cube_size_sweep/_anchor_check_mitsuba371/L120/` | TEST_16 | `configs/_tmp_cube_size/scene_board_baffle_L120.yaml`(本体スイープと同一.mitsuba **3.7.1** でレンダ) | 同上 | 3.8.0 版と比較して構造 CoV 一致(0.0379)・輪帯 min/max 差 +0.0002・輪帯プロファイル最大差 1.17σ | mitsuba 3.8.0→3.7.1 固定(SAC が `IlmThread-mitsuba.dll` をブロック)の等価性検証アンカー.バージョン混在の妥当性の根拠 | | `output/offaxis_led_sweep/` (10 ディレクトリ) | TEST_17 | `configs/_tmp_offaxis/scene_offaxis_*.yaml`(`scripts/sweep_offaxis_led.py` が生成.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心 (0,0,-4.5) へ向けた board_baffle 構成.球 similar_radial r ∈ {88, 100, 135, 170, 250} + 立方体 L ∈ {120, 160, 200, 280, 360}.バッフルは視線上 10mm・数値サイジング.材質・spp=1024・d=16 据え置き・mitsuba 3.7.1) | `configs/eval_baseline_dng_target_shape.yaml` | m=1 符号付き a1 が全条件で負(LED 側が暗い)= -1.6〜-7.2%.サイズとともに約 4 割まで減衰.位置ずれの Δ構造CoV は球 ±0.006 以内・立方体 +0.010〜+0.033 — [TEST_17](TEST_17_オフ軸LED配置の影響とサイズ依存.md) 参照 | **オフ軸頑健性は球 > 立方体(軸上と逆転)**を実証.遮蔽は全 10 条件 55,473 本 100%(傾き追従の LED サンプルに一般化済み) | | `output/offaxis_led_sweep/{summary.csv,offaxis_comparison.png}`・`_placement_check/cutaway_*.png` | TEST_17 | — | — | 条件別集約 CSV/解析図(輝度マップ 2 行 + a1・重心・構造 CoV の内面積依存を軸上と重ね描き)/レンダ前 3D 確認の半割り断面 10 枚 | `scripts/build_offaxis_comparison.py`・`show_scene_cutaway.py` の出力.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | +| `output/led_placement_sweep/` (255 ディレクトリ) | TEST_18 | `configs/_tmp_led_placement/scene_*.yaml`(`scripts/sweep_led_placement.py` が生成.同内面積ペア 球 φ ∈ {200,270,340,500}(r ∈ {100,135,170,250}) ↔ 立方体 L ∈ {141,187,234,340} で,LED をキャビティ中心から見た極角 θ=0〜120°(13 段階)に置く位置スイープ・θ=60/90/120° の向き 3 通り(板向き+バッフル/壁法線向き+バッフル/真下向き)・θ=90° の輪に N=1〜4 灯(総光束保存)・seed 違いの再レンダ,追加実験の方位角 φ=15〜40°(立方体)と真下向き N=2/4/8 灯.材質・spp=1024・d=16 据え置き.Newton cuda_ad_rgb・mitsuba 3.7.1・`--workers 2` で 1 条件 5〜6 秒) | `configs/eval_baseline_dng_target_shape.yaml` | 配置依存の幅(θ 全域の構造 CoV max−min)は球 0.015〜0.023・立方体 0.025〜0.071.球は θ≈30° が軸上より均一(φ340: 0.0193→0.0057).対向 2 灯で構造 CoV 0.005〜0.010 と形状差が消える.真下向きはバッフル無しで板向きと同等 — [TEST_18](TEST_18_照明配置依存性の球と立方体の比較.md) 参照 | **配置依存は球が立方体の 1/2〜1/3**(仮説成立).図なし評価(`--no-figures`)なので各条件に figures/ は無い.`row.json` が条件ごとの集約行(並列・再開用) | +| `output/led_placement_sweep_rho081/` (60 ディレクトリ + 解析出力) | TEST_18 | `configs/_tmp_led_placement_rho081/scene_*.yaml`(`sweep_led_placement.py --material calibrated --key-subset` が生成.内壁を TEST_14 の校正材質 純 diffuse ρ=0.81 に置き換えた主要 60 条件=位置スイープ ペア 1・3,多灯 N=2/4・真下向き ペア 3) | 同上 | 配置依存の幅 球 0.030 vs 立方体 0.077〜0.098.軸上は両形状とも大きく改善(旧材質の光沢が作るリングが消える)— [TEST_18](TEST_18_照明配置依存性の球と立方体の比較.md) 結果 7 | 主結論が材質に依らないことの確認.解析は `build_led_placement_comparison.py --material calibrated` | +| `output/led_placement_sweep/{summary.csv,analysis.csv,position_summary.csv,position_sensitivity.png,position_maps.png,direction_comparison.png,count_comparison.png,azimuth_comparison.png,ring_comparison.png,analysis_report.txt}`・`_placement_check/cutaway_*.png` | TEST_18 | — | — | 条件別集約 CSV/方位フーリエ m=1〜4・構造 CoV・重心の解析 CSV/位置・向き・灯数・方位角・真下向き多灯の解析図 6 枚/レンダ前 3D 確認の半割り断面 23 枚 | `scripts/build_led_placement_comparison.py`・`show_scene_cutaway.py` の出力.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | | `output/cube_size_sweep/board_baffle/{summary.csv,cube_vs_sphere.png,fourfold_component.png}`・`output/cube_size_sweep/_placement_check/cutaway_L*.png` | TEST_16 | — | — | 条件別集約 CSV/球との比較図(輝度マップ 5 枚 + 輪帯プロファイル + 内面積軸の指標カーブ + 直接ペア 3 組)/m=4(cos 4φ)成分の定量図/レンダ前 3D 確認の半割り断面 5 枚 | `scripts/build_cube_sphere_comparison.py`・`analyze_fourfold_component.py`・`show_scene_cutaway.py` の出力.m=4 は全条件 \|a4\| ≤ 0.26%(系統誤差床 ~0.1%)=四隅の写り込みは実質ゼロ.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | | `output/_slide_panels/sphere_loft_disk_before_after.png`・`normal_artifact_explainer.png` | TEST_12 | — | — | 球ロフト円形光源の修正前後 1×2 比較(相対輝度・共通スケール)/法線アーティファクトの機構説明図(模式図+実物メッシュの補間法線再現+修正前マップの 4 パネル) | 発表スライド素材(アドホック生成.修正前後比較は CoV 0.1205→0.0628 の視覚版) | @@ -150,6 +153,7 @@ | レンダ前 3D 配置確認 (幾何変更時必須) | `uv run sm-visualize --scene --no-bake --screenshot ` + `uv run python scripts/show_led_geometry.py --scene --screenshot ` | | 球ロフト円形光源版レンダ・評価 (TEST_11 追補) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/scene_sphere_r100_loft_disk.yaml --output output/sphere_r100_loft_disk/` → `uv run sm-evaluate --image output/sphere_r100_loft_disk/luminance.npy --config configs/eval_baseline_dng_target_shape.yaml --output output/sphere_r100_loft_disk/` | | 単灯再検証レンダ・集計 (TEST_12 追補) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/_tmp_fix_{bottom,left,right}.yaml --output output/singles_fix/{bottom,left,right}/` → `uv run python scripts/build_singles_fix_summary.py` | +| 照明配置スイープ (TEST_18) | `uv run sm-remote push` → `uv run sm-remote run --name place --gpu 0 --threads 16 -- python scripts/sweep_led_placement.py --workers 2` → `uv run sm-remote pull led_placement_sweep` → `uv run python scripts/build_led_placement_comparison.py`(手元で幾何だけ確認するなら `--prepare-only`) | ## 過去の整理実績 (Cleanup History) diff --git "a/docs/06_TEST/TEST_18_\347\205\247\346\230\216\351\205\215\347\275\256\344\276\235\345\255\230\346\200\247\343\201\256\347\220\203\343\201\250\347\253\213\346\226\271\344\275\223\343\201\256\346\257\224\350\274\203.md" "b/docs/06_TEST/TEST_18_\347\205\247\346\230\216\351\205\215\347\275\256\344\276\235\345\255\230\346\200\247\343\201\256\347\220\203\343\201\250\347\253\213\346\226\271\344\275\223\343\201\256\346\257\224\350\274\203.md" new file mode 100644 index 0000000..4831032 --- /dev/null +++ "b/docs/06_TEST/TEST_18_\347\205\247\346\230\216\351\205\215\347\275\256\344\276\235\345\255\230\346\200\247\343\201\256\347\220\203\343\201\250\347\253\213\346\226\271\344\275\223\343\201\256\346\257\224\350\274\203.md" @@ -0,0 +1,347 @@ +# 照明配置依存性の球と立方体の比較 (Placement Dependence: Sphere vs Cube) + +TEST_16 で「軸上配置なら同内面積の立方体が球より均一」,TEST_17 で「側壁 1 点への +オフ軸配置では球のほうが頑健」と分かった.本実験は「積分球が使われる理由は照明配置への +依存が小さいからではないか」というユーザーの仮説を,**LED の位置(極角 θ を 0〜120° で +13 段階)・向き(3 通り)・個数(1〜4 灯)** を系統的に振って検証した(2026-09-14). +仮説は成立した.**球の配置依存は同内面積の立方体の 1/2〜1/3** で,立方体の優位は軸上の +1 点に限られる.一方,**対向 2 灯以上の対称配置にすると形状差はほぼ消え**,どちらも単灯軸上より +均一になる.校正材質(純 diffuse ρ=0.81)で主要条件を引き直しても(結果 7)この結論は変わらない. +※ 旧材質(光沢 5% の blendbsdf)では「球は軸上より θ≈30° が均一」に見えたが,これは光沢成分が +作る軸上のリング構造によるもので,校正材質では両形状とも軸上が最良になる. + +関連: + +- 軸上の優劣と m 次方位フーリエ解析: [TEST_16_立方体キャビティ導入と球との比較](TEST_16_立方体キャビティ導入と球との比較.md) +- 側壁 1 点のオフ軸配置: [TEST_17_オフ軸LED配置の影響とサイズ依存](TEST_17_オフ軸LED配置の影響とサイズ依存.md) +- 実行記録: [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md) +- サーバでの実行と高速化: [ENV_04 開発コマンド](../02_ENV/ENV_04_開発コマンド.md)「研究室サーバでの実行」 + +## 背景・動機 (Motivation) + +TEST_17 は側壁の 1 点(軸中点の高さ)だけを測ったので,「ずれ量に対する感度曲線」が +無く,向きや灯数の影響も不明だった.実機では LED の位置・向き・個数は基板やケースの +都合で決まるため,「どの配置でもそこそこ均一」=配置依存の小ささは筐体形状の実用上の +性能である.本実験はそれを 3 軸で定量し,球と立方体のどちらが配置に寛容かを決める. + +## 実験条件 (Setup) + +固定条件(材質 ρ=0.85 blendbsdf・spp=1024・max_depth=16・512²・評価板・LED 面積と +総光束・評価 YAML)は TEST_15〜17 と同一.球は similar_radial キャビティ. +レンダは研究室サーバ Newton の cuda_ad_rgb(mitsuba 3.7.1).主実験 198 条件 + 追加実験 57 条件(結果 5・6). + +### 同内面積ペア + +| ペア | 球 内径 φ [mm] | 立方体 内寸 L [mm] | 内面積 [mm²] | +| --- | --- | --- | --- | +| 1 | 200 | 141 | 120,607 / 119,880 | +| 2 | 270 | 187 | 211,286 / 210,408 | +| 3 | 340 | 234 | 328,508 / 329,130 | +| 4 | 500 | 340 | 696,005 / 694,194 | + +球のサイズは内径 φ で表す(TEST_15/17 の半径 r との対応: φ200=r100,φ270=r135,φ340=r170, +φ500=r250.条件名・出力ディレクトリ名は内部識別子として `sphere_r100` 等の半径表記のまま). + +### LED 位置の定義(両形状共通) + +キャビティ中心(球: 球中心,立方体: 立方体中心)から方向 (sinθ, 0, −cosθ) に伸ばした +半直線が内壁と交わる点に LED を置く.θ=0° が底の中心(=従来の軸上配置),90° が中心と +同じ高さの側壁,120° が評価板の直下(球は loft 接続の約 1° 下).立方体では θ<45° が +底面,θ>45° が側壁で,稜 45° はグリッドから外した.LED 円板は壁法線から発光方向へ傾け, +縁が壁を貫かない分だけ浮かせる(余裕 0.5mm を全条件で数値確認). + +### 条件群 + +| 群 | 条件 | 数 | +| --- | --- | --- | +| A 位置 | θ = 0, 10, …, 40, 50, …, 120°(13 段階)× 1 灯・板中心向き・バッフル | 104 | +| B 向き | θ = 60/90/120° × {板中心向き+バッフル, 壁法線向き+バッフル, 真下向き(バッフル無し)} | 48(うち 24 は A と共通) | +| C 個数 | θ=90° の輪に N=1/2/3/4 灯を等方位(総光束を保存=radiance/N)+ N=4 を θ=60/120° | 40(うち 8 は A と共通) | +| D 再現性 | ペア 3・θ=90°・1 灯を seed を変えて 3 回再レンダ | 6 | +| E 方位角(追加) | θ=90°・1 灯・板中心向きで方位角 φ = 15/30/40°(立方体: 面の中央→稜寄り).球は φ=45° が対照,立方体 L=234 φ=90° は符号較正 | 17 | +| F 真下向き多灯(追加) | 真下向き(バッフル無し)N=2/4/8 灯を θ=90° の輪に,N=4 を θ=60° にも.比較用に板中心向き N=8 | 40 | + +バッフルは TEST_17 と同じ数値サイジング(発光面の前方にある板の全点を隠す最小半径 +×1.05).板の近く(θ が大きい)や壁法線向きでは円板が壁を貫かざるを得ないので,本実験は +外殻の貫通を許容して食い込み量を記録した(外側に出た部分はキャビティ内から見えない). +真下向き(−Z)は板が発光面の背後になるので直接光がゼロでバッフル不要.遮蔽率は +バッフル付き全条件で 55,473〜221,892 本 100%(cuda でレイキャスト). + +### 均一性の指標 + +- **構造 CoV**: MC ノイズを差し引いた CoV(TEST_15 以降と同じ). +- **a1**: 板軸フレームの m=1 方位フーリエ係数(正=LED 側が明るい.TEST_17 と同じ). +- **min**(本実験で導入): **最大を 1 としたときの最小値**=ノイズ補正付き最小/最大比 + (`evaluation/fullimage.py`).σ=12 px のガウス平滑化後の min/max から,ノイズだけの場 + (残差を空間シャッフルして同じ平滑化を掛けたもの)の極値を差し引く.画像全体で評価するので + 同心円状でない分布でも正しく,対称な分布では従来の輪帯平均 min/max と ±0.01 以内で一致し, + ノイズ床は ±0.004(軸上スイープ 20 条件で検証.`scripts/build_fullimage_minmax.py`). + 従来の輪帯平均 min/max は方位方向に平均するため,オフ軸配置では非対称を見逃して 0.97〜0.99 に + 張り付く(`minmax_asymmetry.png`.差と \|a1\| の相関 0.93〜0.95)ので本実験では使わない. + 輝度マップパネル(`maps/`)の各マップに書いた min もこの値. + +## 結果 1 — 位置依存: 球は立方体の 1/2〜1/3 (Position Dependence) + +`position_sensitivity.png`.非対称は TEST_17 と同じ m=1 符号付き係数 a1(正=LED 側 +(+X)が明るい)で,直交成分 b1 ≤0.33% がノイズ対照. + +| 形状・サイズ | 構造 CoV 軸上 | θ 全域の平均 | 最大(最悪 θ) | 幅 max−min | max\|a1\| | min 軸上 | min 最良(θ) | min 最悪(θ) | min の幅 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 球 φ200 | 0.0311 | 0.0244 | 0.0311(0°) | 0.0153 | 4.1% | 0.866 | 0.922(40°) | 0.866(0°) | 0.056 | +| 球 φ270 | 0.0231 | 0.0199 | 0.0321(110°) | 0.0229 | 4.1% | 0.909 | 0.958(30°) | 0.886(110°) | 0.072 | +| 球 φ340 | 0.0193 | 0.0164 | 0.0289(120°) | 0.0232 | 3.8% | 0.931 | 0.978(30°) | 0.898(120°) | 0.080 | +| 球 φ500 | 0.0145 | 0.0121 | 0.0244(120°) | 0.0202 | 3.1% | 0.951 | 0.980(20°) | 0.905(120°) | 0.075 | +| 立方体 L=141 | 0.0280 | 0.0513 | 0.0951(120°) | 0.0671 | 9.6% | 0.887 | 0.887(0°) | 0.646(120°) | 0.240 | +| 立方体 L=187 | 0.0178 | 0.0403 | 0.0888(120°) | 0.0710 | 11.2% | 0.932 | 0.932(0°) | 0.732(120°) | 0.200 | +| 立方体 L=234 | 0.0139 | 0.0293 | 0.0652(120°) | 0.0514 | 8.3% | 0.948 | 0.948(0°) | 0.790(120°) | 0.158 | +| 立方体 L=340 | 0.0096 | 0.0182 | 0.0348(120°) | 0.0252 | 4.5% | 0.964 | 0.964(0°) | 0.875(120°) | 0.088 | + +- **min でも同じ結論**: θ 全域での min の幅は球 0.056〜0.080,立方体 0.088〜0.240(1.1〜4.3 倍). + 最悪配置の min は球 0.87〜0.91 に対し立方体 0.65〜0.88.立方体は全サイズで軸上(θ=0)が最良で, + 球は θ=20〜40° が最良(旧材質.結果 7 参照) + +- **配置依存の幅は立方体が球の 1.2〜3.1 倍,非対称の最大は 1.5〜2.7 倍**.θ 全域の平均 + 構造 CoV も全ペアで立方体が悪い(1.5〜2.1 倍) +- **立方体は θ とともに単調に悪化**し,板の直下(θ=120°)で軸上の 3.4〜5.0 倍.底面上 + (θ≤40°)でも a1 は既に −2〜−6% ある(平らな床に置いた LED は近い側壁を強く照らす) +- **球は(旧材質では)軸上が最良ではない**.θ=20〜40° で構造 CoV が最小(φ340: 0.0193 → + 0.0057,φ500: 0.0145 → 0.0043)になり,軸上の同心円状のリング構造(TEST_15 のドーム)が + 崩れて平坦になる.θ≥90° で軸上と同程度に戻り,最悪でも軸上の 1.0〜1.7 倍. + **ただしこのリング構造は旧材質の光沢成分が作るもので,校正材質では消える**(結果 7) +- 立方体の軸上の優位(TEST_16)は確認できる(ペア 2〜4 で球の 0.66〜0.77 倍)が, + **軸上の 1 点だけ**であり,θ=10° でも立方体は a1 が −1.7〜−2.5% 出る +- a1 の符号: 球は θ=10〜20°(r≥135)で **正**(LED 側が明るい)になり,θ が大きくなると + 負に転じる.小さな傾きでは LED に近い壁の一次照射が勝ち,傾きが増すと反対側の壁の + 一次照射キャップ(TEST_11/17)が支配する,と解釈できる(未検証) + +## 結果 2 — 向き: 真下向き(バッフル無し)は板向き+バッフルと同等 (Direction) + +`direction_comparison.png`.θ=60/90/120° × 3 向き × 4 ペア × 2 形状. + +- **真下向き(−Z・バッフル無し)は板中心向き+バッフルと同等**(構造 CoV の比 0.6〜1.3. + 球の大サイズではむしろ良く,φ500・θ=120° で 0.0244 → 0.0138).生 mean も 5〜30% + 明るい(バッフルが吸う分が無い).**LED を板と反対側へ向けるだけで,バッフル無しで + 板向きバッフル構成と同じ均一性が得られる** +- **壁法線向き(キャビティ中心向き)は最悪**.構造 CoV は板向きの 2〜5 倍(立方体 L=141 + で 0.19〜0.23).反対側の壁にできる一次照射キャップが板から正面に見えるうえ,それを + 遮るバッフルが大きく(半径がキャビティ寸法の 5〜44%),キャビティ内に影を作る. + 球 φ200・θ=120° の壁法線向きはバッフル半径が球の半径の 59% になり板への光がほぼ消えた + (生 mean 0.000)ため無効条件として除外する +- 向きの影響は形状差より大きい: 同じ位置でも向きで 2〜5 倍変わる +- min で見ても同じ: ペア 3(φ340 / L=234)の θ=90° で板向き 0.933 / 0.897,真下向き 0.922 / 0.869, + 壁法線向き 0.856 / 0.721.立方体 L=141 の壁法線向きは 0.47〜0.52 まで落ちる + +## 結果 3 — 個数: 対向 2 灯以上で形状差は消える (Count) + +`count_comparison.png`.θ=90° の輪に等方位・総光束一定. + +| 形状・サイズ | N=1 | N=2 | N=3 | N=4 | 参考: 軸上 1 灯 | +| --- | --- | --- | --- | --- | --- | +| 球 φ200 | 0.0308 | 0.0084 | 0.0057 | 0.0059 | 0.0311 | +| 球 φ340 | 0.0202 | 0.0053 | 0.0044 | 0.0043 | 0.0193 | +| 球 φ500 | 0.0136 | 0.0054 | 0.0052 | 0.0054 | 0.0145 | +| 立方体 L=141 | 0.0554 | 0.0098 | 0.0208 | 0.0089 | 0.0280 | +| 立方体 L=234 | 0.0323 | 0.0049 | 0.0089 | 0.0047 | 0.0139 | +| 立方体 L=340 | 0.0208 | 0.0067 | 0.0062 | 0.0064 | 0.0096 | + +同じ条件の min(最大を 1 としたときの最小値): + +| 形状・サイズ | N=1 | N=2 | N=3 | N=4 | 参考: 軸上 1 灯 | +| --- | --- | --- | --- | --- | --- | +| 球 φ200 | 0.884 | 0.961 | 0.959 | 0.967 | 0.866 | +| 球 φ340 | 0.933 | 0.958 | 0.976 | 0.988 | 0.931 | +| 球 φ500 | 0.942 | 0.975 | 0.976 | 0.979 | 0.951 | +| 立方体 L=141 | 0.822 | 0.959 | 0.926 | 0.969 | 0.887 | +| 立方体 L=234 | 0.897 | 0.978 | 0.962 | 0.974 | 0.948 | +| 立方体 L=340 | 0.905 | 0.982 | 0.943 | 0.970 | 0.964 | + +- **対向 2 灯で m=1 は ≤0.3% に消え**,構造 CoV は 1 灯の 1/4〜1/6.**単灯軸上より均一** + になる(球・立方体とも全サイズで) +- N=4 でも N=2 とほぼ同じ.**2 灯以上では球と立方体の差は 0.001〜0.004 に縮み**, + 最大サイズでは立方体が僅かに良い +- **N=3 は立方体で崩れる**(\|m1\| 0.4〜2.4% 残る.3 回対称の配置が 4 回対称の壁と + 噛み合わない)が球では問題ない(≤0.12%).**立方体は灯数を壁の対称性に合わせる必要** + がある +- N=4 を θ=120°(板の直下)に置くと立方体は再び悪化する(L=141: 0.0089 → 0.0313)が + 球は ≤0.008 に留まる.多灯でも位置の寛容さは球が上 + +## 結果 4 — ノイズフロア (Noise Floor) + +seed を変えた 3 回の再レンダ(ペア 3・θ=90°・1 灯): 構造 CoV は球 0.0207±0.0007, +立方体 0.0326±0.0005,a1 は ±0.08%,min は球 0.933±0.007・立方体 0.891±0.007. +本実験で論じた差はすべてこの 10 倍以上(min は 5 倍以上). +**seed を変えずに再レンダしても指標は 4 桁まで一致する**(画素はノイズ程度に変わるが +集計で消える)ため,再現性条件は `render.seed` を変えた YAML にした. + +## 結果 5 — 方位角: 立方体は面の中央から稜へ寄せるほど悪化する (Azimuth) + +`azimuth_comparison.png`.θ=90°・1 灯・板中心向きのまま,方位角 φ を面の中央(0°)から +稜寄り(40°.稜は 45°)へ振った.m=1 は LED の方位へ投影した符号付き成分 a1_led で見る +(較正: φ=90° の投影 −4.10% が φ=0° の −4.10% と一致=投影の符号は正しい). + +| 立方体 | φ=0° | 15° | 30° | 40° | 40°/0° | +| --- | --- | --- | --- | --- | --- | +| L=141 構造 CoV | 0.0554 | 0.0649 | 0.0855 | 0.0972 | 1.75 | +| L=187 | 0.0459 | 0.0507 | 0.0596 | 0.0654 | 1.42 | +| L=234 | 0.0323 | 0.0355 | 0.0421 | 0.0460 | 1.42 | +| L=340 | 0.0208 | 0.0221 | 0.0247 | 0.0277 | 1.33 | +| L=141 a1_led | −7.1% | −7.7% | −9.6% | −11.2% | | +| L=141 min | 0.822 | 0.734 | 0.663 | 0.629 | 0.77 | +| L=187 min | 0.850 | 0.808 | 0.766 | 0.731 | 0.86 | +| L=234 min | 0.897 | 0.847 | 0.801 | 0.797 | 0.89 | +| L=340 min | 0.905 | 0.910 | 0.896 | 0.885 | 0.98 | + +- 立方体は**面の中央が最良で,稜へ寄せるほど単調に悪化**(構造 CoV で 1.3〜1.75 倍, + 非対称で 1.3〜1.6 倍).稜の近くでは隣の面の一次照射も加わり,暗い側が広がる +- 球の対照(φ=45°)は φ=0° と 2〜9% 以内で一致(軸対称.ずれは板の 99×102 の + 非正方形分.min は 0.871〜0.948 で φ=0° と ±0.04 以内).**位置依存に加えて「面のどこか」にも + 依存するのが立方体**(min は稜寄りで 0.63〜0.80 まで落ちる) + +## 結果 6 — 真下向き多灯(バッフル無し)は成立するが板向き多灯には劣る (Downward Ring) + +`ring_comparison.png`.真下向き(板が発光面の背後=バッフル無し)で N=2/4/8 灯を +θ=90° の輪に等方位に置いた(立方体 N=8 は稜を避けて 22.5° ずらす). + +| 構造 CoV | N=1 | N=2 | N=4 | N=8 | 板向き N=8(バッフル付き) | 単灯軸上 | +| --- | --- | --- | --- | --- | --- | --- | +| 球 φ200 | 0.0268 | 0.0118 | 0.0100 | 0.0093 | 0.0052 | 0.0311 | +| 球 φ340 | 0.0183 | 0.0101 | 0.0078 | 0.0072 | 0.0045 | 0.0193 | +| 球 φ500 | 0.0145 | 0.0118 | 0.0110 | 0.0092 | 0.0050 | 0.0145 | +| 立方体 L=141 | 0.0494 | 0.0239 | 0.0220 | 0.0186 | 0.0076 | 0.0280 | +| 立方体 L=234 | 0.0332 | 0.0106 | 0.0095 | 0.0075 | 0.0053 | 0.0139 | +| 立方体 L=340 | 0.0242 | 0.0109 | 0.0098 | 0.0094 | 0.0066 | 0.0096 | + +同じ条件の min: + +| min | N=1 | N=2 | N=4 | N=8 | 板向き N=8(バッフル付き) | +| --- | --- | --- | --- | --- | --- | +| 球 φ200 | 0.889 | 0.942 | 0.956 | 0.950 | 0.981 | +| 球 φ340 | 0.922 | 0.958 | 0.979 | 0.970 | 0.978 | +| 球 φ500 | 0.902 | 0.928 | 0.929 | 0.956 | 0.983 | +| 立方体 L=141 | 0.821 | 0.912 | 0.910 | 0.915 | 0.972 | +| 立方体 L=234 | 0.869 | 0.957 | 0.954 | 0.973 | 0.973 | +| 立方体 L=340 | 0.900 | 0.941 | 0.945 | 0.953 | 0.977 | + +- 真下向きでも **2 灯以上で m=1 は ≤0.5% に消え**,構造 CoV は 1 灯の 1/2〜1/4. + 球は全サイズで単灯軸上より均一,立方体はペア 1〜3 で単灯軸上より均一・最大サイズで同等 +- ただし**板向き+バッフルの多灯(0.004〜0.008)には 1.5〜2.5 倍劣る**.真下向きは + N を増やしても 0.007〜0.010(球)/0.008〜0.019(立方体)で頭打ちになる.一次照射が + LED 側の壁の下半分に集中し,多灯にしても「下が明るく上(板側)が暗い」動径構造が + 残るため(輪帯 min/max 0.91〜0.98 vs 板向き多灯 0.97〜0.99) +- 生 mean は真下向きが板向きより 5〜35% 明るく,N によらず一定(総光束保存) +- 小さい立方体(L=141)は真下向きだと N=8 でも 0.019 と悪い.壁際で下を向いた LED が + 床の隅を強く照らすため + +## 結果 7 — 校正材質(純 diffuse ρ=0.81)でも結論は変わらない (Calibrated Material) + +TEST_14 で確定した内壁材質(純 diffuse ρ=0.81.旧材質は blendbsdf ρ=0.85・光沢 5%)で +主要 60 条件(位置スイープをペア 1・3,多灯 N=2/4・真下向きをペア 3)を引き直した +(`output/led_placement_sweep_rho081/`). + +| 位置スイープ(θ 全域) | 旧材質 → 校正材質 | +| --- | --- | +| 球 φ200: 軸上 / 平均 / 幅 / max\|a1\| | 0.0311 / 0.0244 / 0.0153 / 4.1% → **0.0117** / 0.0267 / 0.0296 / 5.7% | +| 球 φ340 | 0.0193 / 0.0164 / 0.0232 / 3.8% → **0.0039** / 0.0172 / 0.0298 / 4.5% | +| 立方体 L=141 | 0.0280 / 0.0513 / 0.0671 / 9.6% → 0.0199 / 0.0522 / 0.0975 / 11.3% | +| 立方体 L=234 | 0.0139 / 0.0293 / 0.0514 / 8.3% → **0.0056** / 0.0294 / 0.0773 / 10.8% | + +同じ条件の min(軸上 / θ=30° / θ=90° / θ=120° / θ 全域の幅): + +| 位置スイープ | 旧材質 → 校正材質 | +| --- | --- | +| 球 φ200 | 0.866 / 0.907 / 0.884 / 0.907 / 0.056 → **0.940** / 0.923 / 0.864 / 0.866 / 0.085 | +| 球 φ340 | 0.931 / 0.978 / 0.933 / 0.898 / 0.080 → **0.987** / 0.968 / 0.913 / 0.884 / 0.103 | +| 立方体 L=141 | 0.887 / 0.849 / 0.822 / 0.646 / 0.240 → 0.913 / 0.862 / 0.797 / 0.593 / 0.320 | +| 立方体 L=234 | 0.948 / 0.925 / 0.897 / 0.790 / 0.158 → **0.980** / 0.952 / 0.866 / 0.751 / 0.230 | + +多灯(ペア 3・θ=90°)の min: 板向き N=2 は球 0.958 → 0.970・立方体 0.978 → 0.970,N=4 は +球 0.988 → 0.988・立方体 0.974 → 0.981,真下向き N=4 は球 0.979 → 0.979・立方体 0.954 → 0.958. + +- **配置依存の幅は球 0.030 vs 立方体 0.077〜0.098(2.6〜3.3 倍),max\|a1\| は 4.5〜5.7% vs + 10.8〜11.3%(2〜2.4 倍)で,球の優位はむしろ広がる**(ρ が下がり多重反射の混合が弱まる分, + 立方体の劣化が大きい) +- **軸上の構造 CoV は両形状で大きく下がる**(球 φ340: 0.0193 → 0.0039,立方体 L=234: + 0.0139 → 0.0056).旧材質の軸上にあったリング構造は光沢 5% の roughconductor 成分が作って + いたもので,純 diffuse では消える.その結果,**校正材質では両形状とも軸上が最良**になり, + 球の「θ≈30° が最良」は消える(φ340: θ=0° 0.0039 → 30° 0.0075 → 90° 0.0250). + オフ軸の悪化は球のほうが緩やか(θ=90° で軸上の 6 倍 vs 立方体 7 倍,絶対値で 0.025 vs 0.039) +- **多灯は材質に依らない**: 対向 2 灯・4 灯(板向き)は球 0.0057/0.0039,立方体 0.0055/0.0053, + 真下向き 4 灯は球 0.0079・立方体 0.0116 と旧材質と同じ水準 +- 生 mean は ρ の低下で約 30% 暗くなる(絶対輝度は校正材質の値を使うこと) + +## 結論 (Conclusions) + +- **仮説成立**: 照明位置への依存は球が同内面積の立方体の 1/2〜1/3(校正材質では 1/2.6〜1/3.3). + 球は「どこに置いてもそこそこ」,立方体は軸上の 1 点だけが良く,板に近づくほど急激に悪化する. + 旧材質で見えた「球は θ≈30° が軸上より均一」は光沢成分によるもので,校正材質では両形状とも + 軸上が最良 +- **向き**: 板と反対(真下)に向ければバッフル無しで板向きバッフル構成と同等. + キャビティ中心へ向ける(壁法線向き)のは最悪 +- **個数**: 対向 2 灯以上の対称配置で形状差は消え,どちらも単灯軸上より均一.立方体は + 灯数を 2 か 4 にする(3 は不可) +- **方位角**(追加): 立方体は面の中央が最良で稜へ寄せるほど 1.3〜1.75 倍悪化.球は方位に依らない +- **真下向き多灯**(追加): バッフル無しでも 2 灯以上で非対称は消え単灯軸上より均一になるが, + 板向き+バッフルの多灯には 1.5〜2.5 倍劣り,N を増やしても頭打ち +- **設計への含意**: 単灯なら球.多灯にできるなら「側壁の対向 2〜4 灯・板向き・各灯にバッフル」が + 最良で形状差も消える.バッフルを省きたいなら「真下向き 2〜4 灯」が次善(球なら 0.008〜0.012, + 中〜大の立方体なら 0.010〜0.013).立方体なら灯数は 2 か 4 で面の中央に置く + +## 限界・注意点 (Limitations) + +- 方位角は結果 5 で θ=90° の 1 灯だけ測った.N=3 の方位依存・θ を変えた方位依存は未測定 +- 壁法線向きのバッフルはキャビティ寸法の 5〜44% と大きく,実物の設計として妥当かは別問題 + (結果は「その向きに置くと大きなバッフルが要る」ことも含めた評価) +- 主実験は旧材質(ρ=0.85 blendbsdf).校正材質での引き直しは主要 60 条件のみ(結果 7). + 絶対輝度の実機予測には校正材質の値を使う +- 真下向きの球 θ=60° は発光面の半分が壁を向く(発光方向と壁法線が 120°).物理的には + 「壁際に伏せた LED」で成立するが,実装の都合で選んだ向きである + +## 失敗パターン・方法論の学び (Pitfalls and Methodology) + +- **同一 seed の再レンダはノイズフロアにならない**.シーンを読み直しても集計指標は 4 桁 + まで一致した.ノイズフロアを測るなら `render.seed` を変える(本実験で schema に追加) +- **遮蔽検証は cuda で回す**.YAML の llvm のまま呼ぶと球で 1 条件 25 秒かかり, + スイープの主犯だった(`runner.ensure_variant` で先に variant を確定してから呼ぶ) +- **バッフルの上限を設けること**.壁法線向きで LED が板に近いと,板を掠める直接光を + 遮る円板がキャビティ寸法の半分を超え,条件として無意味になる(半径 0.6×寸法で打ち切り, + 0.3 超は図に † を付けた) +- 立方体の θ は稜(45°)を避ける.半直線が稜に当たると法線が定義できず LED 円板が + 両壁に掛かる + +## 次にやること (Next Steps) + +1. a1 の符号反転(球 θ=10〜20°)の機構の確認(一次照射キャップの位置) +2. 校正材質での全条件の引き直し(主要 60 条件では結論不変を確認済み.向き・方位角・N=8 は未) +3. 直方体 L×L×D(深さだけ変える)と天井を平らにした球(TEST_17 の宿題) +4. 真下向き多灯の頭打ちを解く配置(LED を少し上向き/板側の壁の反射率を上げる等) + +## 再現コマンド (Reproduction) + +```bash +# 幾何生成のみ(3D 配置確認用) +uv run python scripts/sweep_led_placement.py --prepare-only +uv run python scripts/show_scene_cutaway.py --scene configs/_tmp_led_placement/scene_cube_L234_th090_board_n4.yaml \ + --output output/led_placement_sweep/_placement_check/cutaway_cube_L234_th090_board_n4.png + +# スイープ本体(研究室サーバ・GPU 1 枚・2 並列.1 条件 5〜6 秒) +# 校正材質で主要条件だけ: ... -- python scripts/sweep_led_placement.py --material calibrated --key-subset --workers 2 +# → uv run sm-remote pull led_placement_sweep_rho081 → build_led_placement_comparison.py --material calibrated +uv run sm-remote push +uv run sm-remote run --name place --gpu 0 --threads 16 -- python scripts/sweep_led_placement.py --workers 2 +uv run sm-remote pull led_placement_sweep + +# 解析図 + 集約表 +uv run python scripts/build_led_placement_comparison.py +``` + +出力: `output/led_placement_sweep/`(条件別 255 ディレクトリ + `summary.csv` + `analysis.csv` + +`position_summary.csv` + `position_sensitivity.png` / `position_maps.png` / +`direction_comparison.png` / `count_comparison.png` / `azimuth_comparison.png` / +`ring_comparison.png`).レンダ前 3D 確認は `_placement_check/cutaway_*.png`(代表 23 条件). + +## 参照 (References) + +- [TEST_16_立方体キャビティ導入と球との比較](TEST_16_立方体キャビティ導入と球との比較.md): 軸上の優劣と方位フーリエ解析 +- [TEST_17_オフ軸LED配置の影響とサイズ依存](TEST_17_オフ軸LED配置の影響とサイズ依存.md): 側壁 1 点のオフ軸配置とバッフルの数値サイジング +- [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md): 一次照射キャップの機構 +- [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md): 出力ディレクトリの索引 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 243bb0f..39c2d5c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -98,3 +98,9 @@ - **学外からの接続**: Tailscale に登録済みの研究室 PC(サーバとは別)に OpenSSH Server を入れて Tailscale からのみ受け付け,SSH の踏み台(ProxyJump)にして Newton に入れるようにした(Newton 側は無変更.大学 VPN やサブネットルーターより影響範囲が自分の PC に閉じる).失敗パターン: 研究室 PC は管理者アカウントのため鍵を `C:\ProgramData\ssh\administrators_authorized_keys` に置く必要があるうえ,両 PC の管理者 PowerShell のプロンプトが同一で鍵を手元 PC に登録してしまった → `hostname` で確かめてから実行する(手順は ENV_02「学外から接続する」). - **レンダは画素単位では再現しない(GPU・CPU とも)**: 最終確認で基準シーンを同条件で繰り返すと,約 97% の画素が MC ノイズ程度に異なった(平均の差 3〜7e-4・CoV の差 1e-5).切り分け実験: ① 同じシーンオブジェクトを 2 回レンダ → 丸め誤差だけ(並列加算の順序.平均は一致)/ ② シーンを読み込み直す → ノイズ程度の差 / ③ コーネルボックスは読み込み直しても丸め誤差だけ.=このシーン固有で,読み込み時に並列で作られる加速構造かサンプリング用の表の丸め差が,最大 16 回の反射で経路の分岐として増幅されると推定(どちらかは未切り分け).過去の「seed 固定で決定論的」という前提は厳密には成り立たないが,集計指標の比較には影響しない.裏返せば,同じ条件の繰り返しで未実施の**ノイズフロア**を測れる. - **サーバでのスイープ実測**: 球 10 条件のスイープ全体が 5 分 49 秒(手元 2 時間 13 分).レンダ+評価だけなら 1 条件約 10 秒で 79 倍.GPU 1 枚(`--gpu 0`)で CPU は平均 156%・最大 342%(64 スレッド中)と共用サーバへの負荷は小さく,GPU 使用率の平均は 3%=時間の大半は CPU 側の形状生成・シーン読み込み・評価(GPU を増やしても速くならない).研究室 PC 経由の pull は 31.8 MB を 16 秒. + +- **スイープの高速化 — レンダ/評価の関数化・プロセス内実行・cuda 遮蔽検証・2 並列 (2026-09-14)**: ユーザー指示「効率化を先に」を受け,`scripts/profile_sweep_step.py` で 1 条件の内訳を実測してから手を入れた.**計測結果**: GPU のレンダ本体(spp=1024・512²)が 4.4〜5.2 秒で床.その上に `uv run sm-render`/`sm-evaluate` の起動・import・YAML・PLY 書き出しで約 2.5 秒,図の描画で約 2 秒,さらに遮蔽検証を YAML の llvm で回していたため球で約 25 秒(頂点 21 万個のメッシュを CPU で読み直す)が乗り,球 1 条件 40 秒・立方体 13 秒だった.**対策**: ① `sm-evaluate`/`sm-render` の本体を `evaluation/pipeline.py:evaluate_image`・`render/pipeline.py:render_yaml` に関数化し CLI は引数パースと例外変換だけに(CLI 挙動は不変)/ ② `--no-figures`(`save_figures=False`)/ ③ `runner.ensure_variant` を切り出し,遮蔽検証の前に cuda を確定(25 秒 → 0.5 秒)/ ④ スイープはサブプロセスをやめプロセス内で呼ぶ / ⑤ `--workers N`(spawn のプロセスプール).**実測**: 立方体 13 → 5.5 秒,球 40 → 約 6 秒.`--workers` は GPU 律速のため 1→5.5・2→5.0・3→4.5〜5.8 秒でほぼ変わらず,**2 を推奨**(CPU 側処理を隠す分だけ).残り 75 条件が 8 分で終わった.**発見**: 同一 seed の再レンダは集計指標が 4 桁まで一致し(画素はノイズ程度に変わるが集計で消える)ノイズフロアにならない → `RenderConfig.seed` を追加し `mi.render` に渡す(再現性条件は rep ごとに seed を変えた YAML).`/implement` の 4 フェーズ(coder → tester 101 件追加 → refactorer → docs)を,ユーザー就寝中の指示に基づき Phase 1 の動作確認をサーバ実走で代行して完走.GPU は 0 番のみ使用(ユーザー指示). + +- **TEST_18 照明配置依存性の球と立方体の比較 — 配置依存は球が立方体の 1/2〜1/3 (2026-09-14)**: ユーザーの仮説「積分球が使われる理由は照明配置への依存が小さいから」を,同内面積 4 ペア(球 φ200/270/340/500(r=100/135/170/250)↔ 立方体 L=141/187/234/340)で **LED の位置(キャビティ中心から見た極角 θ=0〜120° を 13 段階)・向き(板向き+バッフル/壁法線向き+バッフル/真下向き)・個数(θ=90° の輪に N=1〜4)・再現性(seed 違い)** の 198 条件で検証した(Newton cuda・1 条件 5〜6 秒).**結果 ①**: θ 全域での構造 CoV の幅は球 0.015〜0.023・立方体 0.025〜0.071(1.2〜3.1 倍),max\|a1\| は 3〜4% vs 4.5〜11%.立方体は θ とともに単調に悪化し板の直下で軸上の 3.4〜5 倍.**球は θ≈30° が軸上より均一**(φ340: 0.0193 → 0.0057.軸上のリング構造が崩れる)で「どこに置いてもそこそこ」.立方体の優位は軸上の 1 点だけ.**結果 ②**: 真下向き(板が背後=バッフル無し)は板向き+バッフルと同等で 5〜30% 明るい.壁法線向き(キャビティ中心向き)は最悪(2〜5 倍).**結果 ③**: 対向 2 灯で m=1 は ≤0.3% に消え構造 CoV は 0.005〜0.010=単灯軸上より均一で形状差が消える.立方体は N=3 で崩れる(3 回対称が 4 回対称の壁と噛み合わない).**ノイズフロア**: 構造 CoV ±0.0007・a1 ±0.08%.設計への含意は「単灯なら球,多灯にできるなら側壁の対向 2〜4 灯・真下向き・バッフル無しで立方体でも球と同等」.**方法論**: 極角 θ を両形状共通のパラメータにし,立方体の稜(45°)は外す.板の近くのバッフルは外殻の貫通を許容し食い込み量を記録(壁法線向きは半径がキャビティ寸法の 0.6 を超えたら無効).解析は TEST_16/17 の方位フーリエ(m=1〜4)+構造 CoV+重心.**追加実験(同日夜)**: ① 方位角: 立方体は面の中央が最良で稜へ寄せるほど構造 CoV が 1.3〜1.75 倍に悪化(球は方位に依らない)/ ② 真下向き多灯(バッフル無し): 2 灯以上で非対称は消え単灯軸上より均一だが,板向き+バッフルの多灯には 1.5〜2.5 倍劣り N を増やしても頭打ち(一次照射が壁の下半分に集中し動径構造が残る).③ 校正材質(純 diffuse ρ=0.81)で主要 60 条件を引き直し: 配置依存の幅は球 0.030 vs 立方体 0.077〜0.098 と結論は不変(むしろ差が広がる).ただし旧材質の光沢 5% が作っていた軸上のリング構造が消え,**校正材質では両形状とも軸上が最良**(「球は θ≈30° が最良」は旧材質の産物).全 255 + 60 条件(TEST_18 結果 5〜7).**失敗パターン**: spawn のワーカーには主プロセスで切り替えた出力ディレクトリ(材質)が伝わらず,旧材質の row.json を再利用して 60 条件が空振りした → 材質を run_job の引数で渡す. + +- **正規化と最小/最大比の定義の見直し — 画像全体・ノイズ補正付きの min/max 比を指標に追加 (2026-09-14)**: ユーザー指摘「輪帯ピーク正規化は同心円状でない分布に向かない」から,輝度マップの正規化と min/max 比の定義を軸上スイープ(TEST_15 球 10・TEST_16 立方体 10)で系統的に比べた(`scripts/build_normalization_comparison.py`・`build_minmax_definition_comparison.py`・`build_fullimage_minmax.py`,出力 `output/_normalization_comparison/`).**発見**: ① 表示の正規化は対称な分布なら輪帯ピークとロバスト最大(平滑化後 99 パーセンタイル)が 1% 以内で同じ.非対称では輪帯ピークが破綻するので表示は後者に統一 / ② min/max 比は「最小」側がノイズの裾に敏感で,平滑化後のパーセンタイルは大きいキャビティ(生輝度が低くノイズ 26%)で 0.91〜0.92 に頭打ち.輪帯平均は数千画素の平均でノイズに勝つが方位を平均する / ③ 画像全体で使える定義 7 候補のうち「σ=12 平滑化の min/max をノイズ場(残差の空間シャッフル)の極値で補正」が対称分布で輪帯平均と ±0.01 で一致しノイズ床 ±0.004 と最良.ブロック平均は粗さと床のトレードオフ,多項式フィットは形を追えない.**実装**: `/implement` で `evaluation/fullimage.py` と `MetricsConfig.fullimage_min_max` を追加(既定 False.TEST_18 の評価 YAML で有効化).TEST_18 に適用すると輪帯平均は非対称条件で 0.97〜0.99 に張り付き,新指標は球 0.87〜0.91・立方体 0.65 まで下がる(差と \|a1\| の相関 0.93〜0.95).TEST_18 の全結果表と輝度マップパネルに min(最大=1 のときの最小値)を併記.**注意(テスターの指摘)**: 平滑化で真値より均一寄りに出る(線形勾配で +0.013)・ノイズ床はノイズ量に比例(相対 20% で 0.012)ため,σ と spp を揃えた条件間比較に使う.球のサイズ表記は内径 φ に統一(TEST_18 の文書・図.条件名は半径のまま). diff --git a/scripts/analyze_fourfold_component.py b/scripts/analyze_fourfold_component.py index 59c72c1..a5ac3ea 100644 --- a/scripts/analyze_fourfold_component.py +++ b/scripts/analyze_fourfold_component.py @@ -69,9 +69,15 @@ return extract_roi_xywh(arr, SIM_ROI) -def ring_harmonics(roi: np.ndarray) -> list[dict[str, float]]: +def ring_harmonics( + roi: np.ndarray, harmonics: tuple[int, ...] = HARMONICS +) -> list[dict[str, float]]: """全方位カバーの各輪帯について方位フーリエ係数(ring mean 比)を返す. + Args: + roi: ROI の輝度 2 次元配列. + harmonics: 計算する次数 m の組(既定は HARMONICS.多灯配置の TEST_18 では m=3 も使う). + Returns: 輪帯ごとの dict のリスト.キーは d_center / n_pixels と,各 m について am / bm / se_m @@ -112,7 +118,7 @@ "d_center": float((ring_edges[ring - 1] + ring_edges[ring]) / 2.0), "n_pixels": float(mask.sum()), } - for m in HARMONICS: + for m in harmonics: cos_m, sin_m = np.cos(m * sector_phi), np.sin(m * sector_phi) row[f"a{m}"] = float(2.0 / N_SECTORS * np.sum(means * cos_m) / ring_mean) row[f"b{m}"] = float(2.0 / N_SECTORS * np.sum(means * sin_m) / ring_mean) diff --git a/scripts/build_fullimage_minmax.py b/scripts/build_fullimage_minmax.py new file mode 100644 index 0000000..0cb4560 --- /dev/null +++ b/scripts/build_fullimage_minmax.py @@ -0,0 +1,225 @@ +"""画像全体で評価する,ノイズに強い最小/最大比の定義を軸上スイープで検証する. + +輪帯平均の min/max は方位方向に平均するので同心円状でない分布(TEST_18)を正しく評価できない. +一方,画素や平滑化後のパーセンタイルは MC ノイズの裾を拾って大きいキャビティで頭打ちになる +(build_minmax_definition_comparison.py).本スクリプトは「画像全体を使い,かつノイズに +引きずられない」定義を 3 つ用意し,対称な分布(軸上スイープ)では輪帯平均と一致することを +確かめる(対称ならどの定義も同じ値になるはずなので,輪帯平均が基準になる). + +定義: + block_ : ROI を約 px 角のブロックに分けて平均し,その min / max. + ノイズは 1/sqrt(ブロック画素数) に落ちる.方位には依らない + smooth_corr : σ=12 で平滑化した min / max を,ノイズ場の極値で補正する. + ノイズ場 = 残差(生 − σ=4 平滑化)を空間シャッフルして同じ σ=12 平滑化を + 掛けたもの(構造を壊しノイズの統計だけ残す.decompose_cov と同じ発想). + min には +E[ノイズ場の最小],max には −E[ノイズ場の最大] を掛ける + poly : n 次の 2 次元多項式を最小二乗で当てはめ,フィット面の min / max + ring : 輪帯平均(基準.同心円状でのみ正しい) + +各定義について「ノイズ床」もシャッフル法で見積もる(一様な像に同じノイズを載せたときに +定義が返す min/max 比.1 からのずれが小さいほどノイズに強い). + +出力(output/_normalization_comparison/): + fullimage_minmax.csv / fullimage_minmax.png + +実行方法: + uv run python scripts/build_fullimage_minmax.py +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import matplotlib +import numpy as np +from scipy.ndimage import gaussian_filter, median_filter + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from build_normalization_comparison import OUT_DIR, conditions # noqa: E402 +from build_radial_comparison import MAX_DISTANCE # noqa: E402 + +from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +BLOCK_SIZES = (20, 40, 80) +SMOOTH_SIGMA = 12.0 +RESIDUAL_SIGMA = 4.0 +POLY_DEGREES = (2, 4) +N_SHUFFLE = 4 +SEED = 0 + +DEFINITIONS = ["ring"] + [f"block_{b}" for b in BLOCK_SIZES] + ["smooth_raw", "smooth_corr"] \ + + [f"poly{d}" for d in POLY_DEGREES] +DEF_LABEL = { + "ring": "輪帯平均(基準)", + "smooth_raw": f"平滑化 σ={SMOOTH_SIGMA:g} 補正なし", + "smooth_corr": f"平滑化 σ={SMOOTH_SIGMA:g} ノイズ補正", + **{f"block_{b}": f"ブロック平均 {b}px" for b in BLOCK_SIZES}, + **{f"poly{d}": f"多項式 {d} 次フィット" for d in POLY_DEGREES}, +} +DEF_STYLE = { + "ring": dict(marker="o", ls="-", color="black"), + "block_20": dict(marker="s", ls="--", color="tab:blue", alpha=0.6), + "block_40": dict(marker="s", ls="-", color="tab:blue"), + "block_80": dict(marker="s", ls=":", color="tab:blue", alpha=0.6), + "smooth_raw": dict(marker="D", ls=":", color="tab:red", alpha=0.6), + "smooth_corr": dict(marker="D", ls="-", color="tab:red"), + "poly2": dict(marker="^", ls=":", color="tab:green", alpha=0.6), + "poly4": dict(marker="^", ls="-", color="tab:green"), +} + + +def load_roi(path: Path) -> np.ndarray: + """luminance.npy から ROI(生の輝度)を返す.""" + arr = np.load(path).astype(np.float64) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + return extract_roi_xywh(arr, SIM_ROI) + + +def block_means(roi: np.ndarray, block: int) -> np.ndarray: + """ROI をおよそ block 画素角のブロックに等分して平均した 2 次元配列を返す(端は切り捨て).""" + h, w = roi.shape + nh, nw = max(h // block, 1), max(w // block, 1) + bh, bw = h // nh, w // nw + trimmed = roi[: nh * bh, : nw * bw] + return trimmed.reshape(nh, bh, nw, bw).mean(axis=(1, 3)) + + +def poly_fit(roi: np.ndarray, degree: int) -> np.ndarray: + """2 次元多項式(全次数 ≤ degree)を最小二乗で当てはめたフィット面を返す.""" + h, w = roi.shape + y, x = np.mgrid[0:h, 0:w] + x = (x - w / 2.0) / (w / 2.0) + y = (y - h / 2.0) / (h / 2.0) + columns = [x**i * y**j for i in range(degree + 1) for j in range(degree + 1 - i)] + design = np.stack([c.ravel() for c in columns], axis=1) + coeffs, *_ = np.linalg.lstsq(design, roi.ravel(), rcond=None) + return (design @ coeffs).reshape(h, w) + + +def noise_field_extremes(roi: np.ndarray, rng: np.random.Generator) -> tuple[float, float]: + """ノイズだけの場に σ=SMOOTH_SIGMA の平滑化を掛けたときの (min, max) の期待値を返す.""" + residual = roi - gaussian_filter(median_filter(roi, size=5), sigma=RESIDUAL_SIGMA) + mins, maxs = [], [] + for _ in range(N_SHUFFLE): + flat = residual.ravel().copy() + rng.shuffle(flat) + field = gaussian_filter(flat.reshape(roi.shape), sigma=SMOOTH_SIGMA) + mins.append(field.min()) + maxs.append(field.max()) + return float(np.mean(mins)), float(np.mean(maxs)) + + +def min_max_all(roi: np.ndarray, rng: np.random.Generator) -> dict[str, float]: + """全定義の最小/最大比を返す.""" + result: dict[str, float] = {} + profile = calc_radial_profile(roi, n_bins=20) + valid = (~np.isnan(profile[:, 1])) & (profile[:, 0] <= MAX_DISTANCE) + result["ring"] = float(profile[valid, 1].min() / profile[valid, 1].max()) + for b in BLOCK_SIZES: + means = block_means(roi, b) + result[f"block_{b}"] = float(means.min() / means.max()) + smooth = gaussian_filter(roi, sigma=SMOOTH_SIGMA) + result["smooth_raw"] = float(smooth.min() / smooth.max()) + n_min, n_max = noise_field_extremes(roi, rng) + result["smooth_corr"] = float((smooth.min() - n_min) / (smooth.max() - n_max)) + for d in POLY_DEGREES: + fit = poly_fit(roi, d) + result[f"poly{d}"] = float(fit.min() / fit.max()) + return result + + +def noise_floor(roi: np.ndarray, rng: np.random.Generator) -> dict[str, float]: + """一様な像に同じノイズを載せたときの各定義の min/max 比(1 からのずれ = ノイズの影響).""" + residual = roi - gaussian_filter(median_filter(roi, size=5), sigma=RESIDUAL_SIGMA) + flat = residual.ravel().copy() + rng.shuffle(flat) + uniform = roi.mean() + flat.reshape(roi.shape) + floor = min_max_all(uniform, rng) + return {f"floor_{k}": v for k, v in floor.items()} + + +def draw(rows: list[dict], output: Path) -> None: + """定義ごとの min/max 比とノイズ床をサイズに対して描く.""" + fig, axes = plt.subplots(2, 2, figsize=(15, 10)) + for col, (shape, xlabel) in enumerate((("sphere", "球 内径 φ [mm]"), + ("cube", "立方体 内寸 L [mm]"))): + sel = [r for r in rows if r["shape"] == shape] + scale = 2.0 if shape == "sphere" else 1.0 # 球は内径で描く + sizes = [r["size_mm"] * scale for r in sel] + ax = axes[0, col] + for d in DEFINITIONS: + ax.plot(sizes, [r[d] for r in sel], label=DEF_LABEL[d], ms=5, lw=1.4, **DEF_STYLE[d]) + ax.set_xlabel(xlabel) + ax.set_ylabel("最小/最大比") + ax.set_title(("球 similar_radial(TEST_15)" if shape == "sphere" else "立方体(TEST_16)") + + ":各定義の値", fontsize=11) + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + ax = axes[1, col] + for d in DEFINITIONS: + ax.plot(sizes, [1.0 - r[f"floor_{d}"] for r in sel], label=DEF_LABEL[d], ms=5, + lw=1.4, **DEF_STYLE[d]) + ax.set_yscale("log") + ax.set_xlabel(xlabel) + ax.set_ylabel("ノイズ床 = 1 − (一様像 + ノイズ の min/max)(対数)") + ax.set_title("ノイズだけで生じる見かけの不均一(小さいほどノイズに強い)", fontsize=11) + ax.grid(alpha=0.3, which="both") + ax.legend(fontsize=7, ncol=2) + fig.suptitle("画像全体で評価する最小/最大比の定義 — 軸上スイープ(対称な分布)での検証." + "対称なら輪帯平均(黒)と一致するのが正しい", fontsize=13) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=110) + plt.close(fig) + print(f"出力: {output}") + + +def main() -> int: + """全定義の min/max 比とノイズ床を計算し,表と図を出す.""" + parser = argparse.ArgumentParser(description="画像全体で評価する min/max 比の定義を検証") + parser.parse_args() + rng = np.random.default_rng(SEED) + rows = [] + for shape, items in conditions().items(): + for label, path in items: + roi = load_roi(path) + size = float(label[1:]) / 2.0 if label.startswith("φ") else float(label.split("=")[1]) + row = {"shape": shape, "size_mm": size, + "raw_mean": float(roi.mean())} + row.update(min_max_all(roi, rng)) + row.update(noise_floor(roi, rng)) + rows.append(row) + if not rows: + print("エラー: TEST_15/16 の結果が見つかりません") + return 1 + OUT_DIR.mkdir(parents=True, exist_ok=True) + with (OUT_DIR / "fullimage_minmax.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + draw(rows, OUT_DIR / "fullimage_minmax.png") + + print("\n=== 最小/最大比(上段)とノイズ床 1−floor(下段)===") + print(f"{'形状':>7} {'サイズ':>6} " + " ".join(f"{d:>11}" for d in DEFINITIONS)) + for r in rows: + print(f"{r['shape']:>7} {r['size_mm']:6g} " + + " ".join(f"{r[d]:11.4f}" for d in DEFINITIONS)) + print(f"{'':>7} {'床':>6} " + + " ".join(f"{1.0 - r['floor_' + d]:11.4f}" for d in DEFINITIONS)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_led_placement_comparison.py b/scripts/build_led_placement_comparison.py new file mode 100644 index 0000000..0afc739 --- /dev/null +++ b/scripts/build_led_placement_comparison.py @@ -0,0 +1,630 @@ +"""照明配置スイープの解析: 位置・向き・個数への依存を球と立方体で比較する(TEST_18). + +sweep_led_placement.py の出力(output/led_placement_sweep/<条件>/)を読み,条件ごとに + +1. **方位フーリエ成分**(輪帯ごとの逆分散重み集約): 1 灯目が +X 方位にあるので + m=1 の符号付き係数 a1 が「LED 側がどれだけ明るい/暗いか」,N 灯の等方位配置では + m=N の係数 aN が「灯数の痕跡」を直接与える.直交成分 b_m はノイズ対照 +2. **構造 CoV**(MC ノイズを差し引いた CoV)と輪帯 min/max +3. **輝度重心**(metrics.csv) + +を求め,次の 3 つの図と集約表を出力する. + +- position_sensitivity.png : θ に対する感度曲線(形状 × サイズ)と,配置依存の + 大きさをまとめた指標(θ 全域での構造 CoV の幅・|a1| の最大)の内面積依存 +- direction_comparison.png : 向き(板向き / 壁法線向き / 真下向き)の比較 +- count_comparison.png : 灯数の比較(構造 CoV・|m1|・|mN|) +- position_maps.png : 代表ペアの輝度マップ(θ を横に並べる) + +実行方法: + uv run python scripts/build_led_placement_comparison.py +""" + +from __future__ import annotations + +import argparse +import csv +import json +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)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import sweep_led_placement as sweep # noqa: E402 +from analyze_fourfold_component import aggregate, load_roi, ring_harmonics # noqa: E402 +from build_radial_comparison import radial_stats, ring_min_max_ratio # noqa: E402 +from build_radius_sweep_visual import decompose_cov, normalized_map # noqa: E402 +from sweep_led_placement import ( # noqa: E402 + AIMS, + AZIMUTH_CALIBRATION, + AZIMUTH_CUBE, + AZIMUTH_SPHERE, + AZIMUTH_THETA, + COUNT_N, + COUNT_THETA, + CUBE_N8_AZIMUTH, + DIRECTION_THETAS, + MATERIALS, + REPEAT_PAIR_INDEX, + RING_SPECS, + SHAPES, + SIZE_PAIRS, + THETA_GRID, + collect_rows, + set_material, + write_summary, +) + +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402 + +OUTPUT_BASE = sweep.OUTPUT_BASE + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +HARMONICS = (1, 2, 3, 4, 8) # 8 は真下向き多灯(N=8)の痕跡用 +SHAPE_COLOR = {"sphere": "tab:blue", "cube": "tab:red"} +SHAPE_LABEL = {"sphere": "球", "cube": "立方体"} +SHAPE_STYLE = {"sphere": dict(marker="o", ls="-"), "cube": dict(marker="s", ls="--")} +AIM_LABEL = {"board": "板中心向き", "normal": "壁法線向き", "down": "真下向き"} +MAP_THETAS = [0.0, 30.0, 60.0, 90.0, 120.0] +MAP_PAIR_INDEX = 2 # 輝度マップに使う代表ペア(球 φ340 / 立方体 L=234) + +ANALYSIS_COLUMNS = [ + "name", "group", "shape", "size_mm", "pair", "theta_deg", "aim", "n_led", "rep", + "azimuth_deg", + "cavity_area_mm2", "baffle_radius_mm", "baffle_ratio", "occlusion_blocked_frac", + "raw_mean_roi", "cov", "structure_cov", "noise_cov", "ring_min_max", "fullimage_min_max", + "centroid_x", "centroid_y", "centroid_offset", + "a1", "b1", "se1", "m1_abs", "a1_led", "a2", "b2", "se2", "a3", "b3", "se3", + "a4", "b4", "se4", "a8", "b8", "se8", + "aN", "mN_abs", +] + + +def size_label(shape: str, size: float) -> str: + """条件ラベル用のサイズ表記(球は内径 φ=2r で表す.内部パラメータは半径のまま).""" + return f"φ{2 * size:g}" if shape == "sphere" else f"L={size:g}" + + +def pair_size(pair: tuple[float, float], shape: str) -> float: + """同内面積ペアから該当形状のサイズ(球=内半径 r / 立方体=内寸 L)を取り出す(表示は φ=2r).""" + 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).""" + for i, pair in enumerate(SIZE_PAIRS): + if pair_size(pair, shape) == size: + return i + return -1 + + +def analyze_condition(row: dict) -> dict | None: + """1 条件の輝度マップから非対称・均一性の指標を集める.""" + directory = OUTPUT_BASE / row["name"] + lum = directory / "luminance.npy" + if not lum.exists(): + return None + result = {k: row.get(k) for k in ("name", "group", "shape", "size_mm", "theta_deg", "aim", + "n_led", "rep", "cavity_area_mm2", "baffle_radius_mm", + "occlusion_blocked_frac", "raw_mean_roi", "cov")} + result["pair"] = pair_index(row["shape"], row["size_mm"]) + result["baffle_ratio"] = row["baffle_radius_mm"] / row["size_mm"] + roi = load_roi(lum) + rings = ring_harmonics(roi, HARMONICS) + for m in HARMONICS: + result[f"a{m}"], result[f"se{m}"] = aggregate(rings, f"a{m}") + result[f"b{m}"], _ = aggregate(rings, f"b{m}") + result["m1_abs"] = float(np.hypot(result["a1"], result["b1"])) + # 1 灯目の方位 φ への m=1 投影(φ=0 なら a1 そのもの.符号は較正条件 φ=90° で確認する) + result["azimuth_deg"] = float(row.get("azimuth_deg", 0.0) or 0.0) + phi = np.radians(result["azimuth_deg"]) + result["a1_led"] = float(result["a1"] * np.cos(phi) + result["b1"] * np.sin(phi)) + n = int(row["n_led"]) + result["aN"] = result[f"a{n}"] + result["mN_abs"] = float(np.hypot(result[f"a{n}"], result[f"b{n}"])) + _, structure, noise = decompose_cov(lum) + centers, means, _ = radial_stats(lum, 20) + result["structure_cov"] = structure + result["noise_cov"] = noise + result["ring_min_max"] = ring_min_max_ratio(centers, means) + # 輪帯平均は方位方向に平均するのでオフ軸の非対称を打ち消す.画像全体の定義を併記する + result["fullimage_min_max"] = calc_fullimage_min_max_ratio(roi)["fullimage_min_max_ratio"] + result["centroid_x"] = row.get("intensity_centroid_x_norm", float("nan")) + result["centroid_y"] = row.get("intensity_centroid_y_norm", float("nan")) + result["centroid_offset"] = row.get("intensity_centroid_offset_norm", float("nan")) + return result + + +def select(results: list[dict], **conds) -> list[dict]: + """条件で絞り込む(値が None のキーは無視).""" + out = [] + for r in results: + if all(v is None or r.get(k) == v for k, v in conds.items()): + out.append(r) + return out + + +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: 位置の感度曲線 +# --------------------------------------------------------------------------- + + +def robustness_summary(results: list[dict]) -> list[dict]: + """位置スイープについて,形状 × ペアごとに配置依存の大きさを集約する.""" + rows = [] + 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 + + +def draw_position(results: list[dict], summary: list[dict], output: Path) -> None: + """θ に対する感度曲線と,配置依存の集約指標の内面積依存を描く.""" + fig, axes = plt.subplots(2, 3, figsize=(20, 11)) + colors = size_colors(len(SIZE_PAIRS)) + 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") + axes[0, 1].set_title("構造 CoV vs θ(低いほど均一)", fontsize=11) + axes[0, 2].set_ylabel("輝度重心 X(正規化)") + axes[0, 2].set_title("輝度重心の X オフセット vs θ", fontsize=11) + for ax in axes[0]: + ax.axhline(0.0, color="gray", lw=0.8, ls="--") + ax.set_xlabel("LED 極角 θ [deg](0=底の中心・90=側壁の中段・120=板の直下)") + ax.set_xticks(THETA_GRID[::2]) + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + + for key, ax, title, ylabel in ( + ("struct_range", axes[1, 0], "配置依存の幅: 構造 CoV の max−min(θ 全域)", + "構造 CoV の幅"), + ("a1_absmax", axes[1, 1], "非対称の最大: max|a1|(θ 全域)", "max|a1| [%]"), + ("struct_max", axes[1, 2], "最悪配置の構造 CoV(点線は軸上 θ=0)", "構造 CoV"), + ): + 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]}", + **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"], + ls=":", alpha=0.6, label=f"{SHAPE_LABEL[shape]}(軸上)") + ax.set_xscale("log") + ax.set_xlabel("キャビティ内面積 (mm²・対数)") + ax.set_ylabel(ylabel) + ax.set_title(title, fontsize=11) + ax.grid(alpha=0.3, which="both") + ax.legend(fontsize=8) + fig.suptitle( + "照明位置への依存(1 灯・板中心向き・バッフル付き): 球 vs 同内面積の立方体", + fontsize=15, + ) + fig.tight_layout() + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +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 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: + plt.close(fig) + return + vmin = float(np.percentile(np.concatenate([m.ravel() for m in maps.values()]), 0.5)) + image = None + 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") + data = maps.get((shape, theta)) + if data is None: + continue + image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=1.0) + row = picked[(shape, theta)] + ax.set_title(f"{SHAPE_LABEL[shape]} {size_label(shape, size)} θ={theta:g}°\n" + 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") + fig.suptitle("LED 極角 θ による輝度マップの変化(LED は +X=画像右側.輪帯ピーク正規化)", + fontsize=13) + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +# --------------------------------------------------------------------------- +# 図 2: 向き +# --------------------------------------------------------------------------- + + +def draw_direction(results: list[dict], output: Path) -> None: + """向き(板中心 / 壁法線 / 真下)の比較を θ × サイズで描く.""" + fig, axes = plt.subplots(2, len(DIRECTION_THETAS), figsize=(6.2 * len(DIRECTION_THETAS), 10)) + colors = size_colors(len(SIZE_PAIRS)) + width = 0.09 + for col, theta in enumerate(DIRECTION_THETAS): + for metric_row, (key, ylabel, scale) in enumerate(( + ("structure_cov", "構造 CoV", 1.0), ("m1_abs", "|m1|(輪帯平均比 %)", 100.0), + )): + 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(SHAPES): + size = pair[s_idx] + rows = select(results, shape=shape, size_mm=size, theta_deg=theta, + aim=aim, n_led=1, rep=1) + if not rows: + continue + x = a_idx + (i * 2 + s_idx - 3.5) * width + hatch = "" if shape == "sphere" else "//" + ax.bar(x, rows[0][key] * scale, width=width, color=colors[i], + edgecolor=SHAPE_COLOR[shape], hatch=hatch, lw=1.0) + if rows[0]["baffle_ratio"] > 0.3: + ax.text(x, rows[0][key] * scale, "†", ha="center", va="bottom", + fontsize=8) + ax.set_xticks(range(len(AIMS))) + ax.set_xticklabels([AIM_LABEL[a] for a in AIMS]) + ax.set_ylabel(ylabel) + ax.set_title(f"θ={theta:g}°: {ylabel}", fontsize=11) + ax.grid(alpha=0.3, axis="y") + handles = [plt.Rectangle((0, 0), 1, 1, color=colors[i], + label=f"ペア{i + 1}: φ{2 * p[0]:g} / L={p[1]:g}") + for i, p in enumerate(SIZE_PAIRS)] + handles += [ + plt.Rectangle((0, 0), 1, 1, fc="white", ec=SHAPE_COLOR["sphere"], label="球(枠青)"), + plt.Rectangle((0, 0), 1, 1, fc="white", ec=SHAPE_COLOR["cube"], hatch="//", + label="立方体(枠赤・斜線)"), + ] + axes[0, 0].legend(handles=handles, fontsize=8, loc="upper left") + fig.suptitle("LED の向きの影響(1 灯).† = バッフル半径がキャビティ寸法の 30% 超" + "(板向き・法線向きはバッフル付き,真下向きは板が背後でバッフル無し)", + fontsize=13) + fig.tight_layout() + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +# --------------------------------------------------------------------------- +# 図 3: 灯数 +# --------------------------------------------------------------------------- + + +def draw_count(results: list[dict], output: Path) -> None: + """灯数 N の効果(θ=90°)と N=4 の θ 依存を描く.""" + fig, axes = plt.subplots(1, 3, figsize=(20, 6)) + colors = size_colors(len(SIZE_PAIRS)) + ns = [1] + COUNT_N + 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) + axes[1].set_xlabel("灯数 N") + axes[1].set_ylabel("方位成分(輪帯平均比 %)") + axes[1].set_title("|m1|(実線)と |m=N|(点線 ×)vs 灯数", fontsize=11) + axes[2].set_xlabel("LED 極角 θ [deg]") + axes[2].set_ylabel("構造 CoV") + axes[2].set_title("N=4 の θ 依存(θ=60/90/120)", fontsize=11) + for ax in axes: + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + axes[0].set_xticks(ns) + axes[1].set_xticks(ns) + fig.suptitle("灯数の効果: 多灯化で配置の痕跡はどれだけ消えるか(板中心向き・各灯にバッフル)", + fontsize=14) + fig.tight_layout() + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +# --------------------------------------------------------------------------- +# 表 +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# 図 4・5: 追加実験(方位角・真下向き多灯) +# --------------------------------------------------------------------------- + + +def draw_azimuth(results: list[dict], output: Path) -> None: + """立方体の面中央(φ=0)→ 稜寄り(φ=40°)の方位角依存を描く(球 φ=45° は対照).""" + fig, axes = plt.subplots(1, 2, figsize=(14, 5.5)) + colors = size_colors(len(SIZE_PAIRS)) + for shape, i, size in shape_size_pairs(): + phis = [0.0] + (AZIMUTH_CUBE if shape == "cube" else AZIMUTH_SPHERE) + rows = [select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA, + aim="board", n_led=1, rep=1, azimuth_deg=phi) for phi in phis] + xs = [phi for phi, r in zip(phis, rows, strict=True) if r] + picked = [r[0] for r in rows if r] + if len(picked) < 2: + continue + style = dict(SHAPE_STYLE[shape], color=colors[i], ms=5, lw=1.4, + label=f"{SHAPE_LABEL[shape]} {size_label(shape, size)}") + 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[0].set_ylabel("構造 CoV") + axes[0].set_title("方位角 φ vs 構造 CoV(θ=90°・1 灯・板中心向き)", fontsize=11) + axes[1].set_ylabel("|m1|(輪帯平均比 %)") + axes[1].set_title("方位角 φ vs 非対称 |m1|", fontsize=11) + for ax in axes: + ax.set_xlabel("LED 方位角 φ [deg](立方体: 0=面の中央・45=稜.球は対照)") + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + fig.suptitle("追加実験 E: 立方体の面のどこに LED を置くか(稜に寄せると悪化するか)", + fontsize=13) + fig.tight_layout() + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +def draw_ring(results: list[dict], output: Path) -> None: + """真下向き(バッフル無し)多灯と板向き(バッフル付き)多灯を灯数で比べる.""" + fig, axes = plt.subplots(1, 3, figsize=(20, 6)) + colors = size_colors(len(SIZE_PAIRS)) + ns = [1, 2, 4, 8] + for shape, i, size in shape_size_pairs(): + for aim, ax_idx, ls in (("down", 0, "-"), ("board", 1, "--")): + picked = [] + for n in ns: + azimuth = CUBE_N8_AZIMUTH if (shape == "cube" and n == 8) else 0.0 + rows = select(results, shape=shape, size_mm=size, theta_deg=COUNT_THETA, + aim=aim, n_led=n, rep=1, azimuth_deg=azimuth) + if rows: + picked.append((n, rows[0])) + if len(picked) < 2: + continue + style = dict(marker=SHAPE_STYLE[shape]["marker"], ls=ls, color=colors[i], ms=5, + lw=1.4, label=f"{SHAPE_LABEL[shape]} {size_label(shape, size)}") + axes[ax_idx].plot([n for n, _ in picked], [r["structure_cov"] for _, r in picked], + **style) + if aim == "down": + axes[2].plot([n for n, _ in picked], [r["raw_mean_roi"] for _, r in picked], + **style) + axes[0].set_title("真下向き(バッフル無し): 灯数 vs 構造 CoV", fontsize=11) + axes[1].set_title("板中心向き(各灯にバッフル): 灯数 vs 構造 CoV", fontsize=11) + axes[2].set_title("真下向き: 灯数 vs 生 mean(総光束一定)", fontsize=11) + for ax in axes[:2]: + ax.set_ylabel("構造 CoV") + axes[2].set_ylabel("ROI 生 mean") + for ax in axes: + ax.set_xlabel("灯数 N(θ=90° の輪に等方位・総光束一定)") + ax.set_xticks(ns) + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + fig.suptitle("追加実験 F: 真下向き多灯(バッフル無し)は実機で最も簡単な構成として成立するか", + fontsize=13) + fig.tight_layout() + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +def print_extra_tables(results: list[dict]) -> None: + """追加実験(方位角・真下向き多灯)の表を出す.""" + shape, size, phi_cal = AZIMUTH_CALIBRATION + calib = select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA, aim="board", + n_led=1, rep=1, azimuth_deg=phi_cal) + base = select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA, aim="board", + n_led=1, rep=1, azimuth_deg=0.0) + print("\n=== 方位角(θ=90°・1 灯・板中心向き)===") + if calib and base: + print(f" 符号較正 φ={phi_cal:g}°: a1={calib[0]['a1'] * 100:+.3f}% " + f"b1={calib[0]['b1'] * 100:+.3f}% → 投影 a1_led={calib[0]['a1_led'] * 100:+.3f}%" + f"(φ=0 の a1={base[0]['a1'] * 100:+.3f}%)") + print(f"{'条件':>36} {'φ':>4} {'a1_led [%]':>10} {'|m1| [%]':>9} {'構造CoV':>8} " + f"{'輪帯min/max':>11}") + for r in sorted(select(results, theta_deg=AZIMUTH_THETA, aim="board", n_led=1, rep=1), + key=lambda r: (r["shape"], r["size_mm"], r["azimuth_deg"])): + print(f"{r['name']:>36} {r['azimuth_deg']:4.0f} {r['a1_led'] * 100:10.3f} " + f"{r['m1_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['ring_min_max']:11.4f}") + + print("\n=== 真下向き多灯(バッフル無し)と板向き多灯 ===") + print(f"{'条件':>36} {'|m1| [%]':>9} {'|mN| [%]':>9} {'構造CoV':>8} {'輪帯min/max':>11} " + f"{'生mean':>7}") + ring_specs = {(aim, theta, n) for aim, theta, n in RING_SPECS} + for r in sorted(select(results, rep=1), + key=lambda r: (r["shape"], r["size_mm"], r["aim"], r["theta_deg"], + r["n_led"])): + key = (r["aim"], r["theta_deg"], int(r["n_led"])) + single_down = r["aim"] == "down" and r["n_led"] == 1 and r["theta_deg"] in (60.0, 90.0) + if key not in ring_specs and not single_down: + continue + print(f"{r['name']:>36} {r['m1_abs'] * 100:9.3f} {r['mN_abs'] * 100:9.3f} " + f"{r['structure_cov']:8.4f} {r['ring_min_max']:11.4f} {r['raw_mean_roi']:7.3f}") + + +def print_tables(results: list[dict], summary: list[dict]) -> None: + """集約表を標準出力に書く.""" + print("\n=== 位置スイープの集約(θ 全域)===") + print(f"{'形状':>6} {'サイズ':>7} {'内面積':>9} {'θ数':>3} {'構造CoV軸上':>11} {'平均':>8} " + f"{'最大':>8} {'幅':>8} {'max|a1|%':>9} {'最悪θ':>6}") + for r in summary: + print(f"{SHAPE_LABEL[r['shape']]:>6} {r['size_mm']:7g} {r['area']:9.0f} {r['n_theta']:3d} " + f"{r['struct_onaxis']:11.4f} {r['struct_mean']:8.4f} {r['struct_max']:8.4f} " + f"{r['struct_range']:8.4f} {r['a1_absmax'] * 100:9.2f} {r['theta_worst']:6.0f}") + + print("\n=== 位置スイープの全条件 ===") + print(f"{'条件':>30} {'a1 [%]':>14} {'b1 [%]':>8} {'構造CoV':>8} {'ノイズ':>7} " + f"{'輪帯min/max':>11} {'重心X':>8} {'生mean':>7} {'遮蔽':>6}") + for r in sorted(select(results, group="position"), + key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"])): + occl = r["occlusion_blocked_frac"] + occl = float(occl) if occl not in ("", None) else float("nan") + print(f"{r['name']:>30} {r['a1'] * 100:7.3f}±{r['se1'] * 100:5.3f} {r['b1'] * 100:8.3f} " + f"{r['structure_cov']:8.4f} {r['noise_cov']:7.4f} {r['ring_min_max']:11.4f} " + f"{r['centroid_x']:8.4f} {r['raw_mean_roi']:7.3f} {occl:6.3f}") + + print("\n=== 向き(1 灯)===") + print(f"{'条件':>30} {'バッフルR/寸法':>13} {'a1 [%]':>8} {'|m1| [%]':>9} {'構造CoV':>8} " + f"{'生mean':>7}") + 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} " + f"{r['m1_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['raw_mean_roi']:7.3f}") + + print("\n=== 灯数 ===") + print(f"{'条件':>30} {'|m1| [%]':>9} {'aN [%]':>8} {'|mN| [%]':>9} {'構造CoV':>8} " + f"{'生mean':>7}") + for r in sorted(select(results, aim="board", rep=1), + key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"], r["n_led"])): + if r["n_led"] == 1 and r["theta_deg"] not in (COUNT_THETA, 60.0, 120.0): + continue + print(f"{r['name']:>30} {r['m1_abs'] * 100:9.3f} {r['aN'] * 100:8.3f} " + f"{r['mN_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['raw_mean_roi']:7.3f}") + + print("\n=== 再現性(同一条件の再レンダ)===") + 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]) + a1 = np.array([r["a1"] for r in reps]) + print(f" {SHAPE_LABEL[shape]} {size_label(shape, reps[0]['size_mm'])} n={len(reps)}: " + f"構造CoV {struct.mean():.4f} ± {struct.std(ddof=1):.4f}, " + f"a1 {a1.mean() * 100:.3f} ± {a1.std(ddof=1) * 100:.3f} %") + + +def write_analysis_csv(results: list[dict], summary: list[dict]) -> None: + """条件別の解析値と位置スイープの集約を CSV に書く.""" + path = OUTPUT_BASE / "analysis.csv" + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=ANALYSIS_COLUMNS) + writer.writeheader() + for r in results: + writer.writerow({k: r.get(k, "") for k in ANALYSIS_COLUMNS}) + path = OUTPUT_BASE / "position_summary.csv" + if summary: + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(summary[0].keys())) + writer.writeheader() + writer.writerows(summary) + (OUTPUT_BASE / "analysis.json").write_text( + json.dumps({"results": results, "position_summary": summary}, ensure_ascii=False, + indent=1), + encoding="utf-8", + ) + + +def main() -> int: + """照明配置スイープの解析図と集約表を生成する.""" + parser = argparse.ArgumentParser(description="照明配置スイープの解析図を生成") + parser.add_argument("--no-figures", action="store_true", help="表と CSV だけ出す") + parser.add_argument("--material", choices=tuple(MATERIALS), default="legacy", + help="解析する材質系列(スイープの --material と同じ)") + args = parser.parse_args() + global OUTPUT_BASE # noqa: PLW0603 + set_material(args.material) + OUTPUT_BASE = sweep.OUTPUT_BASE + + write_summary() + rows = collect_rows() + results = [res for res in (analyze_condition(r) for r in rows) if res is not None] + if not results: + print("エラー: スイープの結果がありません(先にレンダを実行してください)") + return 1 + summary = robustness_summary(results) + write_analysis_csv(results, summary) + if not args.no_figures: + draw_position(results, summary, OUTPUT_BASE / "position_sensitivity.png") + draw_position_maps(results, OUTPUT_BASE / "position_maps.png") + draw_direction(results, OUTPUT_BASE / "direction_comparison.png") + draw_count(results, OUTPUT_BASE / "count_comparison.png") + draw_azimuth(results, OUTPUT_BASE / "azimuth_comparison.png") + draw_ring(results, OUTPUT_BASE / "ring_comparison.png") + print_tables(results, summary) + print_extra_tables(results) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_led_placement_maps.py b/scripts/build_led_placement_maps.py new file mode 100644 index 0000000..3133ca8 --- /dev/null +++ b/scripts/build_led_placement_maps.py @@ -0,0 +1,342 @@ +"""照明配置スイープ(TEST_18)の輝度マップを条件別に並べたパネルを生成する. + +数値指標(build_led_placement_comparison.py)とは別に,輝度分布の「形」を目で比べるための +画像パネル.各パネルは球(上段)と同内面積の立方体(下段)を同じ条件の並びで置き, +平滑化後の 99 パーセンタイルを 1.0 とするロバスト最大値正規化(`--normalize` で輪帯ピーク・ +ROI 平均にも切替可)・共通カラースケールで描く.LED は +X=画像右側. + +正規化の注意: 従来の輪帯ピーク正規化(20 輪帯平均の最大 = 1.0)は同心円状の分布を前提にしており, +非対称な分布では同じ輪帯に明るい側と暗い側が混ざって基準が下がり,明るい側が 1.0 を超えて飛ぶ. +本スクリプトの既定はこれを避けるため方位に依らないロバスト最大値を使う. + +出力(output/led_placement_sweep/maps/): + maps_position_<ペア>.png : θ を横に並べる(13 段階).4 ペア分 + maps_direction.png : θ × 向き(板向き / 壁法線向き / 真下向き).代表ペア + maps_count.png : 灯数 N=1/2/3/4(θ=90°)と N=4 の θ 違い.全ペア + maps_azimuth.png : 立方体の方位角 φ(球は対照) + maps_ring.png : 真下向き N=1/2/4/8 と板向き N=8 + maps_material.png : 旧材質 vs 校正材質(θ=0/30/90/120・多灯) + +実行方法: + uv run python scripts/build_led_placement_maps.py +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import matplotlib +import numpy as np +from scipy.ndimage import gaussian_filter, median_filter + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import sweep_led_placement as sweep # noqa: E402 +from build_radius_sweep_visual import normalized_map # noqa: E402 +from sweep_led_placement import ( # noqa: E402 + AIMS, + AZIMUTH_CUBE, + AZIMUTH_SPHERE, + COUNT_THETA, + CUBE_N8_AZIMUTH, + DIRECTION_THETAS, + MATERIAL_SUFFIX, + SHAPES, + SIZE_PAIRS, + THETA_GRID, + Condition, +) + +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +SHAPE_LABEL = {"sphere": "球", "cube": "立方体"} +AIM_LABEL = {"board": "板向き+バッフル", "normal": "壁法線向き+バッフル", "down": "真下向き"} +CMAP = "hot" +# 共通スケールの下限(最大=1.0 に対する比).全パネルで固定して見比べられるようにする +VMIN = 0.80 +REP_PAIR = 2 # 代表ペア(球 φ340 / 立方体 L=234) + +# 正規化の方式(--normalize): +# robust_peak: 平滑化後の 99 パーセンタイルを 1.0 にする(既定).方位に依らず「最も明るい所 +# からの落ち込み」を表す.画素の最大値は MC ノイズの外れ値に張り付くので +# パーセンタイルで避ける +# ring_peak : 20 輪帯平均の最大値を 1.0(従来).同心円状の分布向けで,非対称な分布では同じ輪帯に +# 明るい側と暗い側が混ざって基準が下がり,明るい側が 1.0 を超えて飛ぶ +# mean : ROI 平均を 1.0.CoV・mean anchor 正規化と同じ基準(明るい側が 1 を超える) +NORMALIZE_MODES = ("robust_peak", "ring_peak", "mean") +ROBUST_PERCENTILE = 99.0 +SMOOTH_MEDIAN = 5 +SMOOTH_SIGMA = 4.0 +_normalize = "robust_peak" + + +def output_root(material: str) -> Path: + """材質系列に対応する出力ディレクトリ.""" + return Path(sweep.ROOT) / "output" / f"led_placement_sweep{MATERIAL_SUFFIX[material]}" + + +def cond_name(shape: str, size: float, theta: float, aim: str, n: int, + azimuth: float = 0.0, rep: int = 1) -> str: + """条件名(出力ディレクトリ名)を組み立てる.""" + return Condition("", shape, size, theta, aim, n, rep, azimuth).name + + +def smoothed_roi(path: Path) -> np.ndarray: + """luminance.npy の ROI を表示用に平滑化して返す(normalized_map と同じ平滑化).""" + arr = np.load(path).astype(np.float64) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + roi = extract_roi_xywh(arr, SIM_ROI) + return gaussian_filter(median_filter(roi, size=SMOOTH_MEDIAN), sigma=SMOOTH_SIGMA) + + +def normalize_map(path: Path, mode: str = "robust_peak") -> np.ndarray: + """輝度マップを指定方式で正規化して返す(表示用に平滑化済み).""" + if mode == "ring_peak": + return normalized_map(path) + smooth = smoothed_roi(path) + if mode == "mean": + return smooth / float(smooth.mean()) + if mode == "robust_peak": + return smooth / float(np.percentile(smooth, ROBUST_PERCENTILE)) + raise ValueError(f"未知の正規化方式: {mode}({', '.join(NORMALIZE_MODES)})") + + +def load_map(name: str, material: str = "legacy") -> np.ndarray | None: + """条件名から正規化・平滑化済みの輝度マップを返す(無ければ None). + + 返り値の配列には属性は付けられないので,各マップの最小値(ノイズ補正付き最小/最大比)は + `min_ratio` で別に取る. + """ + path = output_root(material) / name / "luminance.npy" + return normalize_map(path, _normalize) if path.exists() else None + + +def min_ratio(name: str, material: str = "legacy") -> float | None: + """条件の「最大を 1 としたときの最小値」=ノイズ補正付き最小/最大比を返す(無ければ None). + + 表示の正規化(平滑化後 99 パーセンタイル = 1)で読んだ生の最小値は MC ノイズで押し下げ + られるので,evaluation/fullimage.py の定義(σ=12 平滑化 + ノイズ場の極値で補正)を使う. + """ + path = output_root(material) / name / "luminance.npy" + if not path.exists(): + return None + arr = np.load(path).astype(np.float64) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + roi = extract_roi_xywh(arr, SIM_ROI) + return float(calc_fullimage_min_max_ratio(roi)["fullimage_min_max_ratio"]) + + +def map_cell(label: str, name: str, material: str = "legacy") -> tuple[str, np.ndarray | None]: + """パネルの 1 セル(見出し + マップ)を作る.見出しの下に min(補正付き最小/最大比)を添える.""" + data = load_map(name, material) + if data is None: + return label, None + ratio = min_ratio(name, material) + return f"{label}\nmin={ratio:.3f}", data + + +def colorbar_label() -> str: + """正規化方式に応じたカラーバーの説明.""" + return { + "robust_peak": f"輝度(平滑化後 {ROBUST_PERCENTILE:g} パーセンタイル = 1.0)", + "ring_peak": "輝度(最大の輪帯平均 = 1.0)", + "mean": "輝度(ROI 平均 = 1.0)", + }[_normalize] + + +def row_label(shape: str, size: float) -> str: + """行見出し(形状とサイズ).""" + if shape == "sphere": + return f"{SHAPE_LABEL[shape]} φ{2 * size:g}" # 内径で表す(内部パラメータは半径) + return f"{SHAPE_LABEL[shape]} L={size:g}" + + +def pair_sizes(pair: int) -> dict[str, float]: + """ペア番号 → {形状: サイズ}.""" + return {shape: SIZE_PAIRS[pair][SHAPES.index(shape)] for shape in SHAPES} + + +def draw_grid( + cells: list[list[tuple[str, np.ndarray | None]]], + row_labels: list[str], + title: str, + output: Path, + cell_size: float = 2.3, +) -> None: + """(行 × 列) のマップ格子を共通スケールで描く.cells[row][col] = (見出し, マップ).""" + n_rows, n_cols = len(cells), max(len(r) for r in cells) + figsize = (cell_size * n_cols + 1.2, (cell_size + 0.35) * n_rows + 1.2) + fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize, squeeze=False) + image = None + for r, row in enumerate(cells): + for c in range(n_cols): + ax = axes[r, c] + ax.set_xticks([]) + ax.set_yticks([]) + if c >= len(row) or row[c][1] is None: + ax.axis("off") + continue + label, data = row[c] + vmax = 1.0 if _normalize != "mean" else 1.0 + (1.0 - VMIN) + image = ax.imshow(data, cmap=CMAP, vmin=VMIN, vmax=vmax) + ax.set_title(label, fontsize=7.5) + if c == 0: + ax.set_ylabel(row_labels[r], fontsize=10, fontweight="bold") + if image is not None: + fig.colorbar(image, ax=axes.ravel().tolist(), fraction=0.015, pad=0.01, + label=colorbar_label()) + fig.suptitle(f"{title}\n各マップの min = 最大を 1 としたときの最小値" + "(σ=12 平滑化・ノイズ補正付き最小/最大比)", fontsize=12) + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=110, bbox_inches="tight") + plt.close(fig) + print(f"出力: {output}") + + +def panel_position(out_dir: Path) -> None: + """θ を横に並べた位置スイープのパネル(ペアごとに 1 枚).""" + for pair in range(len(SIZE_PAIRS)): + sizes = pair_sizes(pair) + cells, labels = [], [] + for shape in SHAPES: + row = [] + for theta in THETA_GRID: + row.append(map_cell(f"θ={theta:g}°", + cond_name(shape, sizes[shape], theta, "board", 1))) + cells.append(row) + labels.append(row_label(shape, sizes[shape])) + draw_grid(cells, labels, + f"位置スイープ ペア{pair + 1}(1 灯・板向き・バッフル付き): " + "LED 極角 θ(0=底の中心・90=側壁中段・120=板の直下).LED は画像右側", + out_dir / f"maps_position_pair{pair + 1}.png", cell_size=1.9) + + +def panel_direction(out_dir: Path) -> None: + """θ × 向きのパネル(代表ペア).行 = 形状 × 向き,列 = θ.""" + sizes = pair_sizes(REP_PAIR) + cells, labels = [], [] + for shape in SHAPES: + for aim in AIMS: + row = [] + for theta in DIRECTION_THETAS: + row.append(map_cell(f"θ={theta:g}°", + cond_name(shape, sizes[shape], theta, aim, 1))) + cells.append(row) + labels.append(f"{SHAPE_LABEL[shape]}\n{AIM_LABEL[aim]}") + draw_grid(cells, labels, + f"向きの比較(1 灯・ペア{REP_PAIR + 1}: 球 φ{2 * sizes['sphere']:g} / " + f"L={sizes['cube']:g}).LED は画像右側", + out_dir / "maps_direction.png", cell_size=2.6) + + +def panel_count(out_dir: Path) -> None: + """灯数のパネル(全ペア).列 = N=1/2/3/4(θ=90°)+ N=4 の θ=60/120.""" + cells, labels = [], [] + for pair in range(len(SIZE_PAIRS)): + sizes = pair_sizes(pair) + for shape in SHAPES: + size = sizes[shape] + row = [map_cell(f"N={n} θ=90°", cond_name(shape, size, COUNT_THETA, "board", n)) + for n in (1, 2, 3, 4)] + row += [map_cell(f"N=4 θ={theta:g}°", cond_name(shape, size, theta, "board", 4)) + for theta in (60.0, 120.0)] + cells.append(row) + labels.append(row_label(shape, size)) + draw_grid(cells, labels, + "灯数の比較(板向き・各灯にバッフル・総光束一定).1 灯目は画像右側,以降等方位", + out_dir / "maps_count.png", cell_size=2.2) + + +def panel_azimuth(out_dir: Path) -> None: + """方位角のパネル(θ=90°・1 灯・板向き).立方体は φ=0〜40,球は 0 と 45.""" + cells, labels = [], [] + for pair in range(len(SIZE_PAIRS)): + sizes = pair_sizes(pair) + for shape in SHAPES: + phis = [0.0] + (AZIMUTH_CUBE if shape == "cube" else AZIMUTH_SPHERE) + row = [map_cell(f"φ={phi:g}°", cond_name(shape, sizes[shape], 90.0, "board", 1, phi)) + for phi in phis] + cells.append(row) + labels.append(row_label(shape, sizes[shape])) + draw_grid(cells, labels, + "方位角 φ の比較(θ=90°・1 灯・板向き).立方体: φ=0 が面の中央・45 が稜." + "LED は画像右から φ だけ反時計回り", + out_dir / "maps_azimuth.png", cell_size=2.2) + + +def panel_ring(out_dir: Path) -> None: + """真下向き多灯のパネル(全ペア).列 = 真下 N=1/2/4/8 + 板向き N=8.""" + cells, labels = [], [] + for pair in range(len(SIZE_PAIRS)): + sizes = pair_sizes(pair) + for shape in SHAPES: + row = [] + for n in (1, 2, 4, 8): + az = CUBE_N8_AZIMUTH if (shape == "cube" and n == 8) else 0.0 + name = cond_name(shape, sizes[shape], 90.0, "down", n, az) + row.append(map_cell(f"真下 N={n}", name)) + az = CUBE_N8_AZIMUTH if shape == "cube" else 0.0 + name = cond_name(shape, sizes[shape], 90.0, "board", 8, az) + row.append(map_cell("板向き N=8", name)) + cells.append(row) + labels.append(row_label(shape, sizes[shape])) + draw_grid(cells, labels, + "真下向き多灯(バッフル無し)と板向き多灯(θ=90° の輪・総光束一定)", + out_dir / "maps_ring.png", cell_size=2.2) + + +def panel_material(out_dir: Path) -> None: + """旧材質 vs 校正材質のパネル(校正材質で回した主要条件).""" + specs = [(0.0, "board", 1), (30.0, "board", 1), (90.0, "board", 1), (120.0, "board", 1), + (90.0, "board", 2), (90.0, "board", 4), (90.0, "down", 1), (90.0, "down", 4)] + spec_label = {"board": "板向き", "down": "真下"} + cells, labels = [], [] + # 校正材質は多灯・真下向きをペア 3 だけ回しているので,ペア 1 は位置の 4 条件だけ並べる + for pair, spec_subset in ((0, specs[:4]), (2, specs)): + sizes = pair_sizes(pair) + for shape in SHAPES: + for material, mat_label in (("legacy", "旧材質 ρ=0.85 光沢5%"), + ("calibrated", "校正 ρ=0.81 diffuse")): + row = [] + for theta, aim, n in spec_subset: + row.append(map_cell(f"θ={theta:g}° {spec_label[aim]} N={n}", + cond_name(shape, sizes[shape], theta, aim, n), material)) + cells.append(row) + labels.append(f"{row_label(shape, sizes[shape])}\n{mat_label}") + draw_grid(cells, labels, + "内壁材質の比較(旧材質 blendbsdf vs TEST_14 校正材質 純 diffuse ρ=0.81)", + out_dir / "maps_material.png", cell_size=2.2) + + +def main() -> int: + """全パネルを生成する.""" + parser = argparse.ArgumentParser(description="照明配置スイープの輝度マップパネルを生成") + parser.add_argument("--output", type=Path, default=None, help="出力ディレクトリ") + parser.add_argument("--normalize", choices=NORMALIZE_MODES, default="robust_peak", + help="正規化方式(既定 robust_peak=平滑化後 99 パーセンタイル=1.0)") + args = parser.parse_args() + global _normalize # noqa: PLW0603 + _normalize = args.normalize + out_dir = args.output or output_root("legacy") / "maps" + panel_position(out_dir) + panel_direction(out_dir) + panel_count(out_dir) + panel_azimuth(out_dir) + panel_ring(out_dir) + panel_material(out_dir) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_minmax_asymmetry_comparison.py b/scripts/build_minmax_asymmetry_comparison.py new file mode 100644 index 0000000..e5785d1 --- /dev/null +++ b/scripts/build_minmax_asymmetry_comparison.py @@ -0,0 +1,201 @@ +"""輪帯平均と画像全体の最小/最大比を,非対称な照明配置(TEST_18)で比較する. + +輪帯平均の min/max(`ring_min_max`)は方位方向に平均するので,LED をオフ軸に置いて +生じる「片側だけ明るい」非対称を打ち消してしまう.画像全体のノイズ補正付き min/max +(`fullimage_min_max`,`evaluation/fullimage.py`)は方位方向に平均しないので, +その非対称が値に乗るはずである.本スクリプトはその差が実際に非対称の大きさ |a1| +(m=1 方位フーリエ成分)とともに開くことを,位置スイープ(4 ペア × 13 θ)で確かめる. + +入力は `build_led_placement_comparison.py` が書いた analysis.csv(両定義の列を含む). +luminance.npy の読み直しはしない. + +出力(output/led_placement_sweep/): + minmax_asymmetry.png / minmax_asymmetry.csv + +実行方法: + uv run python scripts/build_minmax_asymmetry_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)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import build_led_placement_comparison as placement # noqa: E402 +import sweep_led_placement as sweep # noqa: E402 +from sweep_led_placement import MATERIALS, SHAPES, SIZE_PAIRS, set_material # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +CSV_COLUMNS = [ + "name", "shape", "size_mm", "theta_deg", "m1_abs", + "ring_min_max", "fullimage_min_max", "diff", "structure_cov", +] + +# θ=0 は軸上(対称)・θ=120 は板の直下(最も非対称).標準出力で対比する +THETA_SYMMETRIC = 0.0 +THETA_OFFAXIS = 120.0 + + +def load_rows(analysis_csv: Path) -> list[dict]: + """analysis.csv から位置スイープの行を読み,両定義と差を持つ dict のリストを返す.""" + rows: list[dict] = [] + with analysis_csv.open(encoding="utf-8", newline="") as fh: + for raw in csv.DictReader(fh): + if raw.get("group") != "position": + continue + if not raw.get("fullimage_min_max"): + continue + ring = float(raw["ring_min_max"]) + full = float(raw["fullimage_min_max"]) + rows.append({ + "name": raw["name"], + "shape": raw["shape"], + "size_mm": float(raw["size_mm"]), + "theta_deg": float(raw["theta_deg"]), + "m1_abs": float(raw["m1_abs"]), + "ring_min_max": ring, + "fullimage_min_max": full, + "diff": ring - full, + "structure_cov": float(raw["structure_cov"]), + }) + return sorted(rows, key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"])) + + +def series(rows: list[dict], shape: str, size: float) -> list[dict]: + """同じ形状・サイズの系列を θ 昇順で取り出す.""" + return [r for r in rows if r["shape"] == shape and r["size_mm"] == size] + + +def draw(rows: list[dict], output: Path) -> None: + """θ に対する両定義の比較(形状ごと)と,差 vs 非対称の大きさの散布図を描く.""" + fig, axes = plt.subplots(1, 3, figsize=(19, 5.6)) + colors = placement.size_colors(len(SIZE_PAIRS)) + + for ax, shape in zip(axes[:2], SHAPES, strict=False): + for i, pair in enumerate(SIZE_PAIRS): + size = placement.pair_size(pair, shape) + sel = series(rows, shape, size) + if not sel: + continue + theta = [r["theta_deg"] for r in sel] + label = placement.size_label(shape, size) + ax.plot(theta, [r["fullimage_min_max"] for r in sel], color=colors[i], + marker="o", ms=4, lw=1.6, ls="-", label=f"{label} 画像全体") + ax.plot(theta, [r["ring_min_max"] for r in sel], color=colors[i], + marker="x", ms=4, lw=1.2, ls=":", alpha=0.75, label=f"{label} 輪帯平均") + ax.set_xlabel("LED 極角 θ [deg](0=底の中心・90=側壁の中段・120=板の直下)") + ax.set_ylabel("最小/最大比(1 が理想)") + ax.set_title(f"{placement.SHAPE_LABEL[shape]}:実線=画像全体(ノイズ補正)" + "/点線=輪帯平均", fontsize=11) + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + + ax = axes[2] + for shape in SHAPES: + for i, pair in enumerate(SIZE_PAIRS): + size = placement.pair_size(pair, shape) + sel = series(rows, shape, size) + if not sel: + continue + ax.scatter([r["m1_abs"] * 100 for r in sel], [r["diff"] for r in sel], + color=colors[i], marker=placement.SHAPE_STYLE[shape]["marker"], + s=26, alpha=0.8, + label=f"{placement.SHAPE_LABEL[shape]} {placement.size_label(shape, size)}") + ax.axhline(0.0, color="gray", lw=0.8, ls="--") + ax.set_xlabel("非対称の大きさ |a1|(輪帯平均比 %)") + ax.set_ylabel("輪帯平均 − 画像全体(差)") + ax.set_title("方位非対称が大きいほど輪帯平均は均一に見える(差が開く)", fontsize=11) + ax.grid(alpha=0.3) + ax.legend(fontsize=7, ncol=2) + + fig.suptitle("最小/最大比の定義比較 — 輪帯平均は方位方向に平均するため" + "オフ軸 LED の非対称を過小評価する(TEST_18 位置スイープ)", fontsize=14) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=110) + plt.close(fig) + print(f"出力: {output}") + + +def report(rows: list[dict]) -> None: + """形状ごとに軸上/オフ軸での差と,差と |a1| の相関係数を表示する.""" + print("\n=== 輪帯平均 − 画像全体(差)===") + print(f"{'形状':>6} {'サイズ':>7} {'θ=0 の差':>10} {'θ=120 の差':>11} " + f"{'差の最大':>9} {'相関 r(差, |a1|)':>16}") + for shape in SHAPES: + for pair in SIZE_PAIRS: + size = placement.pair_size(pair, shape) + sel = series(rows, shape, size) + if len(sel) < 3: + continue + diffs = np.array([r["diff"] for r in sel]) + m1 = np.array([r["m1_abs"] for r in sel]) + at_sym = next((r["diff"] for r in sel if r["theta_deg"] == THETA_SYMMETRIC), np.nan) + at_off = next((r["diff"] for r in sel if r["theta_deg"] == THETA_OFFAXIS), np.nan) + corr = float(np.corrcoef(diffs, m1)[0, 1]) if diffs.std() > 0 else float("nan") + print(f"{placement.SHAPE_LABEL[shape]:>6} {size:7g} {at_sym:10.4f} {at_off:11.4f} " + f"{diffs.max():9.4f} {corr:16.3f}") + + for shape in SHAPES: + sel = [r for r in rows if r["shape"] == shape] + if len(sel) < 3: + continue + diffs = np.array([r["diff"] for r in sel]) + m1 = np.array([r["m1_abs"] for r in sel]) + corr = float(np.corrcoef(diffs, m1)[0, 1]) + print(f"{placement.SHAPE_LABEL[shape]}(全サイズ {len(sel)} 条件): " + f"差と |a1| の相関係数 r={corr:.3f}") + + +def write_csv(rows: list[dict], output: Path) -> None: + """比較表を CSV に書き出す.""" + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_COLUMNS) + writer.writeheader() + for r in rows: + writer.writerow({k: r[k] for k in CSV_COLUMNS}) + print(f"出力: {output}") + + +def main() -> int: + """位置スイープについて両定義の min/max 比を比較する図と表を出す.""" + parser = argparse.ArgumentParser(description="輪帯平均と画像全体の min/max 比を比較") + parser.add_argument("--material", choices=tuple(MATERIALS), default="legacy", + help="解析する材質系列(スイープの --material と同じ)") + args = parser.parse_args() + set_material(args.material) + output_base = sweep.OUTPUT_BASE + + analysis_csv = output_base / "analysis.csv" + if not analysis_csv.exists(): + print(f"エラー: {analysis_csv} がありません." + "先に build_led_placement_comparison.py を実行してください") + return 1 + + rows = load_rows(analysis_csv) + if not rows: + print(f"エラー: {analysis_csv} に fullimage_min_max 列の入った位置スイープの行が" + "ありません.build_led_placement_comparison.py を再実行してください") + return 1 + + write_csv(rows, output_base / "minmax_asymmetry.csv") + draw(rows, output_base / "minmax_asymmetry.png") + report(rows) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_minmax_definition_comparison.py b/scripts/build_minmax_definition_comparison.py new file mode 100644 index 0000000..f7ad308 --- /dev/null +++ b/scripts/build_minmax_definition_comparison.py @@ -0,0 +1,167 @@ +"""最小/最大比の「最大・最小の定義」による違いを軸上スイープ(TEST_15/16)で比較する. + +均一性の指標として使っている最小/最大比は,最大・最小をどう取るかで値が変わる. +本スクリプトは同じ luminance.npy に対して次の定義を計算し,サイズ依存の傾向と定義間の差を +表と図にする(TEST_18 の非対称な分布に進む前に,対称な分布で各定義の基準を押さえる). + +定義: + pixel : 画素の min / max(評価パイプラインの max_min_ratio と同じ.MC ノイズに支配される) + ring : 20 輪帯平均の min / max(評価パイプラインの radial_min_max_ratio と同じ) + ring_inner : 輪帯平均のうち四隅だけの最外輪帯(d > 0.95)を除いた min / max + (scripts/build_radial_comparison.py の ring_min_max_ratio と同じ) + smooth : 平滑化(メディアン 5 → ガウス σ=4)後の画素の min / max + robust : 平滑化後の 1 / 99 パーセンタイル(方位に依らないロバスト定義.TEST_18 の + マップ表示と同じ「最大」) + +出力(output/_normalization_comparison/): + minmax_definitions.csv : 条件 × 定義の値 + minmax_definitions.png : サイズ依存を定義ごとに重ね描き(球・立方体) + +実行方法: + uv run python scripts/build_minmax_definition_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)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from build_led_placement_maps import ROBUST_PERCENTILE, smoothed_roi # noqa: E402 +from build_normalization_comparison import OUT_DIR, conditions # noqa: E402 +from build_radial_comparison import MAX_DISTANCE # noqa: E402 + +from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +DEFINITIONS = ("pixel", "ring", "ring_inner", "smooth", "robust") +DEF_LABEL = { + "pixel": "画素 min/max", + "ring": "輪帯平均 min/max(20 ビン)", + "ring_inner": "輪帯平均 min/max(最外ビン除外)", + "smooth": "平滑化後 画素 min/max", + "robust": f"平滑化後 {100 - ROBUST_PERCENTILE:g}/{ROBUST_PERCENTILE:g} %ile", +} +DEF_STYLE = { + "pixel": dict(marker="x", ls=":", color="gray"), + "ring": dict(marker="o", ls="-", color="tab:blue"), + "ring_inner": dict(marker="o", ls="--", color="tab:cyan"), + "smooth": dict(marker="s", ls="-.", color="tab:orange"), + "robust": dict(marker="D", ls="-", color="tab:red"), +} +LOW_PERCENTILE = 100.0 - ROBUST_PERCENTILE + + +def min_max_ratios(path: Path) -> dict[str, float]: + """1 条件の輝度マップについて全定義の最小/最大比を返す.""" + arr = np.load(path).astype(np.float64) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + roi = extract_roi_xywh(arr, SIM_ROI) + profile = calc_radial_profile(roi, n_bins=20) + centers, means = profile[:, 0], profile[:, 1] + valid = ~np.isnan(means) + inner = valid & (centers <= MAX_DISTANCE) + smooth = smoothed_roi(path) + return { + "pixel": float(roi.min() / roi.max()), + "ring": float(means[valid].min() / means[valid].max()), + "ring_inner": float(means[inner].min() / means[inner].max()), + "smooth": float(smooth.min() / smooth.max()), + "robust": float(np.percentile(smooth, LOW_PERCENTILE) + / np.percentile(smooth, ROBUST_PERCENTILE)), + } + + +def draw(rows: list[dict], output: Path) -> None: + """定義ごとの最小/最大比をサイズに対して描く(球・立方体で 1 枚ずつ + 定義間の差).""" + fig, axes = plt.subplots(1, 3, figsize=(19, 5.5)) + for ax, shape, xlabel in ((axes[0], "sphere", "球 内径 φ [mm]"), + (axes[1], "cube", "立方体 内寸 L [mm]")): + sel = [r for r in rows if r["shape"] == shape] + scale = 2.0 if shape == "sphere" else 1.0 # 球は内径で描く + sizes = [r["size_mm"] * scale for r in sel] + for d in DEFINITIONS: + ax.plot(sizes, [r[d] for r in sel], label=DEF_LABEL[d], ms=5, lw=1.4, **DEF_STYLE[d]) + ax.set_xlabel(xlabel) + ax.set_ylabel("最小/最大比(1 が理想)") + ax.set_title("球 similar_radial(TEST_15)" if shape == "sphere" else "立方体(TEST_16)", + fontsize=11) + ax.grid(alpha=0.3) + ax.legend(fontsize=8) + ax = axes[2] + for shape, marker in (("sphere", "o"), ("cube", "s")): + sel = [r for r in rows if r["shape"] == shape] + areas = [r["area"] for r in sel] + label = "球" if shape == "sphere" else "立方体" + ax.plot(areas, [r["ring_inner"] - r["robust"] for r in sel], marker=marker, ls="-", + color="tab:red", label=f"{label}: 輪帯(最外除外)− ロバスト") + ax.plot(areas, [r["ring"] - r["ring_inner"] for r in sel], marker=marker, ls="--", + color="tab:blue", label=f"{label}: 輪帯(全) − 輪帯(最外除外)") + ax.plot(areas, [r["smooth"] - r["robust"] for r in sel], marker=marker, ls=":", + color="tab:orange", label=f"{label}: 平滑化 min/max − ロバスト") + ax.axhline(0.0, color="gray", lw=0.8) + ax.set_xscale("log") + ax.set_xlabel("キャビティ内面積 (mm²・対数)") + ax.set_ylabel("定義間の差") + ax.set_title("定義間の差(画素 min/max は桁が違うので除外)", fontsize=11) + ax.grid(alpha=0.3, which="both") + ax.legend(fontsize=7) + fig.suptitle("最小/最大比の定義による違い — 軸上 1 灯・板向き・バッフル付き(対称な分布)", + fontsize=13) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=110) + plt.close(fig) + print(f"出力: {output}") + + +def main() -> int: + """全定義の最小/最大比を計算して表と図を出す.""" + parser = argparse.ArgumentParser(description="最小/最大比の定義による違いを比較") + parser.parse_args() + from sweep_cube_size import cavity_geometry as cube_geo + from sweep_sphere_radius import cavity_geometry as sphere_geo + + rows = [] + for shape, items in conditions().items(): + for label, path in items: + size = float(label[1:]) / 2.0 if label.startswith("φ") else float(label.split("=")[1]) + area = (sphere_geo(size, "similar_radial") if shape == "sphere" + else cube_geo(size))["cavity_area_mm2"] + row = {"shape": shape, "size_mm": size, "area": area} + row.update(min_max_ratios(path)) + rows.append(row) + if not rows: + print("エラー: TEST_15/16 の結果が見つかりません") + return 1 + OUT_DIR.mkdir(parents=True, exist_ok=True) + with (OUT_DIR / "minmax_definitions.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + draw(rows, OUT_DIR / "minmax_definitions.png") + + print(f"\n{'形状':>7} {'サイズ':>6} " + " ".join(f"{d:>11}" for d in DEFINITIONS) + + f" {'輪帯内−ロバスト':>14}") + for r in rows: + print(f"{r['shape']:>7} {r['size_mm']:6g} " + + " ".join(f"{r[d]:11.4f}" for d in DEFINITIONS) + + f" {r['ring_inner'] - r['robust']:+14.4f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_normalization_comparison.py b/scripts/build_normalization_comparison.py new file mode 100644 index 0000000..f4a9cf4 --- /dev/null +++ b/scripts/build_normalization_comparison.py @@ -0,0 +1,168 @@ +"""軸上スイープ(TEST_15 球 10 半径・TEST_16 立方体 10 サイズ)の輝度マップを正規化方式で比較する. + +同じ luminance.npy を 3 方式で正規化して並べ,正規化の違いだけで見え方がどう変わるかを確かめる. +軸上配置は同心円状の分布なので輪帯ピーク正規化が本来の想定どおり働く条件であり, +非対称な分布(TEST_18)で採用したロバスト最大値との差が小さければ,過去の図の読みは正規化に +依存していなかったと言える. + +方式(build_led_placement_maps と同じ定義): + ring_peak : 20 輪帯平均の最大値 = 1.0(TEST_15/16 の図で使った従来方式) + robust_peak : 平滑化後の 99 パーセンタイル = 1.0(TEST_18 の既定) + mean : ROI 平均 = 1.0 + +出力(output/_normalization_comparison/): + norm_sphere.png / norm_cube.png : 行 = 方式,列 = サイズ(10).共通スケール + norm_summary.csv : 各マップの基準値(ピーク・パーセンタイル・平均)と比 + +実行方法: + uv run python scripts/build_normalization_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)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from build_led_placement_maps import ( # noqa: E402 + CMAP, + ROBUST_PERCENTILE, + normalize_map, + smoothed_roi, +) +from sweep_cube_size import OUTPUT_BASE as CUBE_OUTPUT_BASE # noqa: E402 +from sweep_cube_size import RUN_DIR as CUBE_RUN_DIR # noqa: E402 +from sweep_cube_size import SIZES as CUBE_SIZES # noqa: E402 +from sweep_cube_size import job_name as cube_job_name # noqa: E402 +from sweep_sphere_radius import OUTPUT_BASE as SPHERE_OUTPUT_BASE # noqa: E402 +from sweep_sphere_radius import RADII as SPHERE_RADII # noqa: E402 +from sweep_sphere_radius import job_name as sphere_job_name # noqa: E402 + +from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "output" / "_normalization_comparison" +SPHERE_RUN_DIR = "board_baffle_similar_radial" + +MODES = ("ring_peak", "robust_peak", "mean") +MODE_LABEL = { + "ring_peak": "輪帯ピーク\n(20 輪帯平均の最大=1)", + "robust_peak": f"ロバスト最大\n(平滑化後 {ROBUST_PERCENTILE:g}%ile=1)", + "mean": "ROI 平均\n(mean=1)", +} +# 共通スケール.mean は 1 を中心に上下対称,他は上限 1.0 +VMIN = 0.80 +VMAX = {"ring_peak": 1.0, "robust_peak": 1.0, "mean": 1.0 + (1.0 - VMIN)} + + +def conditions() -> dict[str, list[tuple[str, Path]]]: + """形状 → [(ラベル, luminance.npy)] を返す(存在するものだけ).""" + sphere_dir = SPHERE_OUTPUT_BASE / SPHERE_RUN_DIR + sphere = [(f"φ{2 * r:g}", sphere_dir / sphere_job_name(r) / "luminance.npy") + for r in SPHERE_RADII] + cube = [(f"L={size:g}", CUBE_OUTPUT_BASE / CUBE_RUN_DIR / cube_job_name(size) / "luminance.npy") + for size in CUBE_SIZES] + return {"sphere": [c for c in sphere if c[1].exists()], + "cube": [c for c in cube if c[1].exists()]} + + +def reference_values(path: Path) -> dict[str, float]: + """3 方式の基準値(正規化に使う分母)と生の ROI 統計を返す.""" + arr = np.load(path).astype(np.float64) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + roi = extract_roi_xywh(arr, SIM_ROI) + profile = calc_radial_profile(roi, n_bins=20) + smooth = smoothed_roi(path) + return { + "raw_mean": float(roi.mean()), + "ring_peak": float(np.nanmax(profile[:, 1])), + "robust_peak": float(np.percentile(smooth, ROBUST_PERCENTILE)), + "mean": float(smooth.mean()), + "smooth_max": float(smooth.max()), + } + + +def draw(shape: str, items: list[tuple[str, Path]], output: Path) -> None: + """行 = 正規化方式,列 = サイズのパネルを描く.""" + n_cols = len(items) + figsize = (1.9 * n_cols + 1.5, 2.1 * len(MODES) + 1.0) + fig, axes = plt.subplots(len(MODES), n_cols, figsize=figsize, squeeze=False) + images = {} + for r, mode in enumerate(MODES): + for c, (label, path) in enumerate(items): + ax = axes[r, c] + data = normalize_map(path, mode) + images[mode] = ax.imshow(data, cmap=CMAP, vmin=VMIN, vmax=VMAX[mode]) + ax.set_xticks([]) + ax.set_yticks([]) + if r == 0: + ax.set_title(label, fontsize=9) + if c == 0: + ax.set_ylabel(MODE_LABEL[mode], fontsize=9, fontweight="bold") + fig.colorbar(images[mode], ax=axes[r, :].tolist(), fraction=0.012, pad=0.01) + title = "球 similar_radial(TEST_15)" if shape == "sphere" else "立方体(TEST_16)" + fig.suptitle(f"正規化方式の比較 — {title} 軸上 1 灯・板向き・バッフル付き." + f"上 2 行は {VMIN:g}〜1.0,mean 行は {VMIN:g}〜{VMAX['mean']:g}", fontsize=12) + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=110, bbox_inches="tight") + plt.close(fig) + print(f"出力: {output}") + + +def write_summary(conds: dict[str, list[tuple[str, Path]]]) -> None: + """各マップの基準値と比を CSV と標準出力に書く.""" + rows = [] + for shape, items in conds.items(): + for label, path in items: + ref = reference_values(path) + rows.append({ + "shape": shape, "size": label, + **{k: round(v, 6) for k, v in ref.items()}, + "ring_over_robust": round(ref["ring_peak"] / ref["robust_peak"], 4), + "ring_over_mean": round(ref["ring_peak"] / ref["mean"], 4), + "robust_over_mean": round(ref["robust_peak"] / ref["mean"], 4), + }) + OUT_DIR.mkdir(parents=True, exist_ok=True) + with (OUT_DIR / "norm_summary.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + print(f"\n{'形状':>7} {'サイズ':>6} {'輪帯ピーク':>10} {'ロバスト最大':>12} {'平均':>8} " + f"{'輪帯/ロバスト':>12} {'輪帯/平均':>9} {'ロバスト/平均':>12}") + for r in rows: + print(f"{r['shape']:>7} {r['size']:>6} {r['ring_peak']:10.4f} {r['robust_peak']:12.4f} " + f"{r['mean']:8.4f} {r['ring_over_robust']:12.4f} {r['ring_over_mean']:9.4f} " + f"{r['robust_over_mean']:12.4f}") + + +def main() -> int: + """正規化方式の比較パネルと集約表を生成する.""" + parser = argparse.ArgumentParser(description="軸上スイープの輝度マップを正規化方式で比較") + parser.parse_args() + conds = conditions() + if not conds["sphere"] and not conds["cube"]: + print("エラー: TEST_15/16 の結果が見つかりません") + return 1 + for shape, items in conds.items(): + if items: + draw(shape, items, OUT_DIR / f"norm_{shape}.png") + write_summary(conds) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/profile_sweep_step.py b/scripts/profile_sweep_step.py new file mode 100644 index 0000000..18a69a7 --- /dev/null +++ b/scripts/profile_sweep_step.py @@ -0,0 +1,142 @@ +"""スイープ 1 条件の所要時間の内訳を測る(高速化の当たりを付けるための計測). + +sm-render / sm-evaluate をサブプロセスで呼ぶ現行方式と,同じ処理をプロセス内で +直接呼ぶ方式の各段階(起動・import・PLY 書き出し・シーン読込・レンダ・評価・図・遮蔽検証) +を秒で出す. + +実行方法: + uv run python scripts/profile_sweep_step.py \ + configs/_tmp_led_placement/scene_sphere_r170_th090_board_n1.yaml +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +ROOT = Path(__file__).resolve().parents[1] +EVAL_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" + + +class Timer: + """段階ごとの経過秒を記録する.""" + + def __init__(self) -> None: + self.rows: list[tuple[str, float]] = [] + self._t = time.perf_counter() + + def lap(self, label: str) -> None: + now = time.perf_counter() + self.rows.append((label, now - self._t)) + self._t = now + + def report(self, title: str) -> None: + print(f"\n=== {title} ===") + for label, sec in self.rows: + print(f" {label:<40} {sec:8.2f} s") + print(f" {'合計':<40} {sum(s for _, s in self.rows):8.2f} s") + + +def profile_subprocess(scene: Path, out_dir: Path) -> None: + """現行方式: uv run sm-render → uv run sm-evaluate をサブプロセスで.""" + t = Timer() + subprocess.run(["uv", "run", "python", "-c", "pass"], cwd=ROOT, check=True, + capture_output=True) + t.lap("uv run python -c pass(起動のみ)") + subprocess.run(["uv", "run", "python", "-c", "import mitsuba"], cwd=ROOT, check=True, + capture_output=True) + t.lap("uv run python -c 'import mitsuba'") + subprocess.run(["uv", "run", "sm-render", "--scene", str(scene), "--output", str(out_dir)], + cwd=ROOT, check=True, capture_output=True) + t.lap("uv run sm-render(全体)") + subprocess.run(["uv", "run", "sm-evaluate", "--image", str(out_dir / "luminance.npy"), + "--config", str(EVAL_YAML), "--output", str(out_dir)], + cwd=ROOT, check=True, capture_output=True) + t.lap("uv run sm-evaluate(全体)") + t.report("現行方式(サブプロセス)") + + +def profile_inprocess(scene: Path, out_dir: Path, variant: str) -> None: + """プロセス内で各段階を直接呼ぶ.""" + t = Timer() + import numpy as np + + from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path + + if variant.startswith("llvm_"): + ensure_drjit_libllvm_path() + import mitsuba as mi + + t.lap("import mitsuba") + mi.set_variant(variant) + t.lap(f"set_variant({variant})") + + from sm_tias_lightsim.scene.builder import build_scene + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(scene) + config = config.model_copy( + update={"render": config.render.model_copy(update={"variant": variant})} + ) + t.lap("load_scene_yaml") + scene_dict, tempdir = build_scene(config, project_root=scene.parent) + t.lap("build_scene(STL 読込 + PLY 書き出し)") + try: + mscene = mi.load_dict(scene_dict) + t.lap("mi.load_dict(PLY 読込 + 加速構造)") + for i in range(2): + img = mi.render(mscene, spp=config.render.spp) + arr = np.array(img) + t.lap(f"mi.render spp={config.render.spp}({i + 1} 回目)") + finally: + shutil.rmtree(tempdir, ignore_errors=True) + from sm_tias_lightsim.render.output import save_luminance, save_png + + save_png(arr, out_dir / "render.png") + save_luminance(arr, out_dir / "luminance.npy") + t.lap("save_png + save_luminance") + + from sm_tias_lightsim.evaluation.pipeline import evaluate_image + + image = out_dir / "luminance.npy" + evaluate_image(image, EVAL_YAML, out_dir, save_figures=True) + t.lap("evaluate_image(図あり)") + evaluate_image(image, EVAL_YAML, out_dir, save_figures=False) + t.lap("evaluate_image(図なし)") + + from verify_baffle_occlusion import check_occlusion + + result = check_occlusion(scene, board_grid=41, led_count=32) + t.lap(f"check_occlusion(variant={mi.variant()}・{result['blocked']:.0f}/{result['total']:.0f})") + result = check_occlusion(scene, board_grid=41, led_count=32) + t.lap("check_occlusion(2 回目)") + t.report(f"プロセス内方式(variant={variant})") + + +def main() -> int: + parser = argparse.ArgumentParser(description="スイープ 1 条件の所要時間の内訳") + parser.add_argument("scene", type=Path) + parser.add_argument("--variant", default=os.environ.get("SM_RENDER_VARIANT", "llvm_ad_rgb")) + parser.add_argument("--skip-subprocess", action="store_true") + args = parser.parse_args() + out_dir = Path(tempfile.mkdtemp(prefix="sm_profile_")) + try: + if not args.skip_subprocess: + profile_subprocess(args.scene.resolve(), out_dir) + profile_inprocess(args.scene.resolve(), out_dir, args.variant) + finally: + shutil.rmtree(out_dir, ignore_errors=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/show_scene_cutaway.py b/scripts/show_scene_cutaway.py index b16d6b8..41f6d4e 100644 --- a/scripts/show_scene_cutaway.py +++ b/scripts/show_scene_cutaway.py @@ -79,7 +79,7 @@ shown = mesh.clip(normal=(0, 1, 0), origin=(0, 0, 0)) if clipped else mesh if clipped: color, opacity = HOUSING_COLOR, 1.0 - elif entry["id"] == "baffle": + elif entry["id"].startswith("baffle"): color, opacity = BAFFLE_COLOR, 1.0 else: color, opacity = BOARD_COLOR, 0.55 diff --git a/scripts/sweep_led_placement.py b/scripts/sweep_led_placement.py new file mode 100644 index 0000000..24d7d46 --- /dev/null +++ b/scripts/sweep_led_placement.py @@ -0,0 +1,905 @@ +"""照明配置スイープ: LED の位置・向き・個数への依存を球と立方体で比較する(TEST_18). + +TEST_16 で「軸上配置なら同内面積の立方体が球より均一」,TEST_17 で「側壁 1 点への +オフ軸配置では球のほうが頑健」と分かった.本スイープは「積分球が使われる理由は +照明配置への依存が小さいからではないか」という仮説を,配置を系統的に振って検証する. + +条件の軸(同内面積の球・立方体ペアごと): + A. 位置 : LED をキャビティ中心から見た極角 θ(0°=底の中心=軸上,90°=中心と + 同じ高さの側壁,120°=板のすぐ下)で壁面上を動かす.板中心向き+バッフル + B. 向き : θ=60/90/120° で「板中心向き」「壁法線向き(キャビティ中心向き)」 + 「真下向き(板が発光面の背後=バッフル不要)」を比べる + C. 個数 : θ の輪に N 灯を等方位に置き総光束を保存する(radiance / N) + D. 再現性 : 同一条件の再レンダでノイズフロアを測る + E. 方位角 : 立方体の面中央(φ=0)→ 稜寄り(φ=40°)で位置依存が変わるか(追加実験) + F. 真下向き多灯 : バッフル無しの真下向き N=2/4/8 灯(最も簡単な実機構成,追加実験) + +θ の定義(両形状共通): + キャビティ中心 c(球: 球中心,立方体: 立方体中心)から方向 + u = (sinθ cosφ, sinθ sinφ, -cosθ) に伸ばした半直線が内壁と交わる点に LED を置く. + 球ではこれが「LED から見た板の立体角」を一意に決める自然なパラメータで, + 立方体には同じ角度をそのまま適用する(θ<45° は底面,45°<θ<135° は側壁). + 立方体の稜(θ=45°)は LED を置けないので θ グリッドから外す. + +バッフルは TEST_17 と同じ「LED から BAFFLE_GAP 先に置く傾いた円板」を,発光面の前方に +ある板の全点を隠す最小半径 × 安全率で数値的に決める.TEST_17 では外殻の貫通を +エラーにしたが,板の近く(θ が大きい)では円板が壁を大きく貫かざるを得ないので +本スイープでは許容し,食い込み量を記録するだけにする(外側に出た部分はキャビティ +内から見えないため光輸送には影響しない). + +固定条件は TEST_15/16/17 と同一(材質・レンダ・評価・評価板・LED 面積・総光束). + +実行方法: + uv run python scripts/sweep_led_placement.py --prepare-only # 幾何 + YAML のみ + uv run python scripts/sweep_led_placement.py --shapes sphere # 球だけレンダ + uv run python scripts/sweep_led_placement.py --groups position count # グループ指定 + uv run python scripts/sweep_led_placement.py --workers 3 # 3 条件同時に実行 +""" + +from __future__ import annotations + +import argparse +import copy +import csv +import io +import json +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 +from pathlib import Path + +import numpy as np +import trimesh +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from generate_cube_housing import build_cube_housing # noqa: E402 +from sweep_cube_size import cavity_geometry as cube_cavity_geometry # noqa: E402 +from sweep_offaxis_led import STANDOFF_MARGIN # noqa: E402 +from sweep_sphere_radius import ( # noqa: E402 + BAFFLE_GAP, + BAFFLE_MARGIN, + BAFFLE_THICKNESS, + BOARD_HALF, + BOARD_SURFACE_Z, + EVAL_CONFIG_YAML, + LED_RADIUS, + RIM_INSET, + RIM_Z, + TEMPLATE_YAML, + THICKNESS, + raw_stats, + read_metrics_csv, +) +from sweep_sphere_radius import ( + build_mesh as build_sphere_mesh, +) +from sweep_sphere_radius import ( + cavity_geometry as sphere_cavity_geometry, +) +from sweep_sphere_radius import ( + center_z as sphere_center_z, +) +from sweep_sphere_radius import ( + cut_z as sphere_cut_z, +) + +from sm_tias_lightsim.evaluation.pipeline import evaluate_image # noqa: E402 +from sm_tias_lightsim.render.pipeline import render_yaml # noqa: E402 +from sm_tias_lightsim.runtime.overrides import resolve_render_variant # noqa: E402 +from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] + +TMP_DIR = ROOT / "configs" / "_tmp_led_placement" +MESH_DIR = TMP_DIR / "meshes" +OUTPUT_BASE = ROOT / "output" / "led_placement_sweep" + +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), + (135.0, 187.0), + (170.0, 234.0), + (250.0, 340.0), +] +REPEAT_PAIR_INDEX = 2 # 再現性チェックに使うペア(r=170 / L=234) + +# A. 位置: 極角グリッド(立方体の稜 45° を避ける.120° は球の loft 接続(約 121°)直下) +THETA_GRID = [0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0] +# B. 向き +DIRECTION_THETAS = [60.0, 90.0, 120.0] +AIMS = ("board", "normal", "down") +# C. 個数 +COUNT_THETA = 90.0 +COUNT_N = [2, 3, 4] +COUNT_N4_THETAS = [60.0, 120.0] +# D. 再現性(同一条件の追加レンダ回数) +REPEATS = 3 + +# E. 方位角(追加実験): 立方体の面中央(φ=0)→ 稜寄り(φ=40°).球は φ=45° が対照(軸対称). +# φ=90° は m=1 の投影の符号較正用(+Y 面=面中央なので φ=0 と同じ値になるはず) +AZIMUTH_THETA = 90.0 +AZIMUTH_CUBE = [15.0, 30.0, 40.0] +AZIMUTH_SPHERE = [45.0] +AZIMUTH_CALIBRATION = ("cube", 234.0, 90.0) +# F. 真下向き多灯(追加実験): バッフル無しで最も簡単な実機構成.(向き, θ, N) +# 立方体の N=8 は稜(φ=45°)を避けるため方位を 22.5° ずらす +RING_SPECS = [("down", 90.0, 2), ("down", 90.0, 4), ("down", 90.0, 8), ("down", 60.0, 4), + ("board", 90.0, 8)] +CUBE_N8_AZIMUTH = 22.5 + +GROUPS = ("position", "direction", "count", "repeat", "azimuth", "ring") + +# G. 校正材質での引き直し(追加実験): TEST_14 で確定した純 diffuse ρ=0.81 に内壁を置き換え, +# 主要な結論(位置依存の幅・多灯・真下向き)が材質に依らないことを確かめる. +# 出力・一時 YAML は別ディレクトリ(サフィックス)に分ける +MATERIALS = { + "legacy": None, # テンプレートのまま(blendbsdf ρ=0.85・ω=0.05・α=0.1) + "calibrated": {"type": "diffuse", "reflectance": [0.81, 0.81, 0.81]}, +} +MATERIAL_SUFFIX = {"legacy": "", "calibrated": "_rho081"} +_material = "legacy" +# 「主要条件だけ」のサブセット: 位置スイープをペア 1・3 で,多灯(N=2/4)と真下向きをペア 3 で +KEY_SUBSET_PAIRS = (0, 2) +KEY_SUBSET_PAIR_MULTI = 2 + + +def set_material(material: str) -> None: + """内壁材質の種類を切り替え,出力・一時ディレクトリを材質ごとに分ける.""" + global _material, TMP_DIR, MESH_DIR, OUTPUT_BASE # noqa: PLW0603 + if material not in MATERIALS: + raise ValueError(f"未知の材質指定: {material}({', '.join(MATERIALS)})") + _material = material + suffix = MATERIAL_SUFFIX[material] + TMP_DIR = ROOT / "configs" / f"_tmp_led_placement{suffix}" + MESH_DIR = TMP_DIR / "meshes" + OUTPUT_BASE = ROOT / "output" / f"led_placement_sweep{suffix}" + + +def current_material() -> str: + """現在の材質指定を返す.""" + return _material + + +def key_subset(conds: list[Condition]) -> list[Condition]: + """材質の引き直しに使う主要条件だけを残す.""" + keep = [] + for c in conds: + pair = next((i for i, p in enumerate(SIZE_PAIRS) + if p[0 if c.shape == "sphere" else 1] == c.size), -1) + if c.group == "position" and pair in KEY_SUBSET_PAIRS: + keep.append(c) + elif (c.group == "count" and pair == KEY_SUBSET_PAIR_MULTI + and c.theta_deg == COUNT_THETA and c.n_led in (2, 4)): + keep.append(c) + elif (c.group == "direction" and pair == KEY_SUBSET_PAIR_MULTI + and c.theta_deg == COUNT_THETA and c.aim == "down"): + keep.append(c) + elif (c.group == "ring" and pair == KEY_SUBSET_PAIR_MULTI + and c.theta_deg == COUNT_THETA and c.aim == "down" and c.n_led == 4): + keep.append(c) + return keep + +# 評価板の中心(キャビティ側の面)= 板向き LED の照準点 +BOARD_CENTER = np.array([0.0, 0.0, BOARD_SURFACE_Z]) +# LED 円板を立方体の稜に近づけすぎない余裕(mm) +EDGE_MARGIN = 3.0 +# バッフル半径の上限(キャビティ寸法比).超える条件は物理的に無理があるので実行しない +BAFFLE_RADIUS_CAP_RATIO = 0.6 + +SUMMARY_COLUMNS = [ + "name", "group", "shape", "size_mm", "theta_deg", "aim", "n_led", "rep", "azimuth_deg", + "material", + "cavity_area_mm2", "led_x_mm", "led_y_mm", "led_z_mm", "led_standoff_mm", "tilt_deg", + "baffled", "baffle_radius_mm", "baffle_embed_mm", "board_front_frac", + "occlusion_blocked_frac", "occlusion_total", + "elapsed_s", "render_variant", "raw_mean_roi", "raw_mean_full", "raw_rel_std_roi", + "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio", + "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm", + "intensity_centroid_x_norm", "intensity_centroid_y_norm", "max_min_ratio", +] + + +@dataclass(frozen=True) +class Condition: + """1 レンダ条件(配置の指定).""" + + group: str + shape: str + size: float + theta_deg: float + aim: str + n_led: int + rep: int = 1 + azimuth_deg: float = 0.0 + + @property + def base_name(self) -> str: + """幾何(メッシュ・バッフル)を共有する名前(再レンダ条件は seed だけが違う). + + 方位角は 0 以外のときだけ付ける(追加実験より前の条件名を変えないため). + """ + prefix = "r" if self.shape == "sphere" else "L" + azimuth = f"_az{self.azimuth_deg:03.0f}" if self.azimuth_deg else "" + return (f"{self.shape}_{prefix}{self.size:g}_th{self.theta_deg:03.0f}" + f"_{self.aim}_n{self.n_led}{azimuth}") + + @property + def name(self) -> str: + """出力ディレクトリ名.""" + return self.base_name + (f"_rep{self.rep}" if self.rep > 1 else "") + + @property + def azimuths_deg(self) -> list[float]: + """各 LED の方位角(1 灯目は azimuth_deg,以降等間隔).""" + return [self.azimuth_deg + 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]: + """全条件を「重要な順」(位置 → 個数 → 向き → 再現性)に並べて返す. + + 途中で止まっても主実験(位置)が先に揃うようにする.同じ条件が複数グループから + 生まれる場合(θ=90 板向き 1 灯は位置・向き・個数の共通アンカー)は最初の 1 つだけ残す. + """ + result: list[Condition] = [] + seen: set[str] = set() + + def add(cond: Condition) -> None: + if cond.name not in seen: + seen.add(cond.name) + result.append(cond) + + 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)) + for shape, size in shape_sizes(): + for azimuth in AZIMUTH_CUBE if shape == "cube" else AZIMUTH_SPHERE: + add(Condition("azimuth", shape, size, AZIMUTH_THETA, "board", 1, + azimuth_deg=azimuth)) + add(Condition("azimuth", *AZIMUTH_CALIBRATION[:2], AZIMUTH_THETA, "board", 1, + azimuth_deg=AZIMUTH_CALIBRATION[2])) + for shape, size in shape_sizes(): + for aim, theta, n in RING_SPECS: + azimuth = CUBE_N8_AZIMUTH if (shape == "cube" and n == 8) else 0.0 + add(Condition("ring", shape, size, theta, aim, n, azimuth_deg=azimuth)) + return result + + +# --------------------------------------------------------------------------- +# 幾何 +# --------------------------------------------------------------------------- + + +def cavity_center(shape: str, size: float) -> np.ndarray: + """θ の基準になるキャビティ中心(mm)を返す.""" + if shape == "cube": + return np.array([0.0, 0.0, RIM_Z - size / 2.0]) + return np.array([0.0, 0.0, sphere_center_z(size, SPHERE_CAVITY)]) + + +def cavity_area(shape: str, size: float) -> float: + """比較の横軸に使う内面積(mm²)を返す(各スイープと同じ定義).""" + if shape == "cube": + return cube_cavity_geometry(size)["cavity_area_mm2"] + return sphere_cavity_geometry(size, SPHERE_CAVITY)["cavity_area_mm2"] + + +def polar_direction(theta_deg: float, azimuth_deg: float) -> np.ndarray: + """キャビティ中心から見た単位方向 u(θ=0 が -Z=底)を返す.""" + theta, phi = np.radians(theta_deg), np.radians(azimuth_deg) + return np.array([np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi), -np.cos(theta)]) + + +def wall_anchor( + shape: str, size: float, theta_deg: float, azimuth_deg: float +) -> tuple[np.ndarray, np.ndarray]: + """LED を貼り付ける内壁上の点と,そこでの内向き法線を返す(mm). + + Raises: + ValueError: 立方体の稜・球の loft 接続に近すぎて LED 円板を置けない場合. + """ + center = cavity_center(shape, size) + u = polar_direction(theta_deg, azimuth_deg) + if shape == "sphere": + point = center + size * u + # 球の壁は loft 接続平面(cut_z)より下だけ.接続の上では法線の定義が変わる + if point[2] > sphere_cut_z(size, SPHERE_CAVITY): + raise ValueError(f"θ={theta_deg:g}: 球の loft 接続より上に出ます") + return point, -u + half = size / 2.0 + with np.errstate(divide="ignore"): + t_axes = np.where(np.abs(u) > 1e-12, half / np.abs(u), np.inf) + axis = int(np.argmin(t_axes)) + point = center + t_axes[axis] * u + normal = np.zeros(3) + normal[axis] = -np.sign(u[axis]) + others = [i for i in range(3) if i != axis] + margin = float(min(half - abs(point[i] - center[i]) for i in others)) + if margin < LED_RADIUS + EDGE_MARGIN: + raise ValueError(f"θ={theta_deg:g}: 立方体の稜に近すぎます(余裕 {margin:.1f}mm)") + return point, normal + + +def _board_points(grid: int = 41) -> np.ndarray: + """評価板のキャビティ側の面の格子点(四隅を含む)を返す.""" + gx = np.linspace(-BOARD_HALF[0], BOARD_HALF[0], grid) + gy = np.linspace(-BOARD_HALF[1], BOARD_HALF[1], grid) + mesh_x, mesh_y = np.meshgrid(gx, gy) + z = np.full(mesh_x.size, BOARD_SURFACE_Z) + return np.stack([mesh_x.ravel(), mesh_y.ravel(), z], axis=1) + + +def _disk_points(center: np.ndarray, direction: np.ndarray, radius: float, n: int) -> np.ndarray: + """direction を法線とする円板の外周点 n 個 + 中心を返す.""" + axis = np.array([0.0, 1.0, 0.0]) if abs(direction[1]) < 0.9 else np.array([0.0, 0.0, 1.0]) + u = np.cross(direction, axis) + u /= np.linalg.norm(u) + v = np.cross(direction, u) + angles = 0.1234 + np.linspace(0.0, 2.0 * np.pi, n, endpoint=False) + ring = center + radius * (np.outer(np.cos(angles), u) + np.outer(np.sin(angles), v)) + return np.vstack([center[None, :], ring]) + + +def led_geometry( + shape: str, size: float, theta_deg: float, azimuth_deg: float, aim: str +) -> dict: + """LED 円板の中心・発光方向・浮かせ量・傾き角と,板への直接光の有無を返す. + + 浮かせ量は「傾いた円板の縁が壁面へ食い込む量(LED_RADIUS×sinτ)+ マージン」 + (τ は発光方向と壁法線のなす角).板向きは浮かせた後の中心から向け直す. + """ + wall, normal = wall_anchor(shape, size, theta_deg, azimuth_deg) + center = wall.copy() + for _ in range(2): + if aim == "board": + direction = BOARD_CENTER - center + elif aim == "normal": + direction = normal.copy() + elif aim == "down": + direction = np.array([0.0, 0.0, -1.0]) + else: + raise ValueError(f"未知の向き指定: {aim}") + direction = direction / np.linalg.norm(direction) + sin_tilt = float(np.linalg.norm(np.cross(direction, normal))) + standoff = LED_RADIUS * sin_tilt + STANDOFF_MARGIN + center = wall + standoff * normal + tilt_deg = float(np.degrees(np.arctan2(sin_tilt, float(direction @ normal)))) + front = ((_board_points() - center) @ direction) > 0.0 + return { + "center": center, "direction": direction, "normal": normal, + "standoff": standoff, "tilt_deg": tilt_deg, + "board_front_frac": float(front.mean()), + } + + +def baffle_radius(led: dict) -> float: + """発光面の前方にある板の全点から LED 全点を隠すバッフル半径(mm)を返す. + + LED 円板上の点 Q × 板上の点 P の直線がバッフル平面(LED から BAFFLE_GAP 先・ + 法線 = 発光方向)を横切る位置の,バッフル中心からの面内距離の最大値 × 安全率. + 発光面の背後にある P(直接光が届かない)は除外する. + """ + center, direction = led["center"], led["direction"] + b_center = center + BAFFLE_GAP * direction + q = _disk_points(center, direction, LED_RADIUS, 64) + p = _board_points() + delta = p[None, :, :] - q[:, None, :] # (M, N, 3) + denom = delta @ direction + front = denom > 1e-9 + if not front.any(): + return 0.0 + t = np.where(front, ((b_center - q) @ direction)[:, None] / np.where(front, denom, 1.0), 0.0) + x = q[:, None, :] + t[..., None] * delta + offset = x - b_center + radial = offset - (offset @ direction)[..., None] * direction[None, None, :] + return float(BAFFLE_MARGIN * np.linalg.norm(radial, axis=-1)[front].max()) + + +def _wall_distance(shape: str, size: float, points: np.ndarray) -> float: + """点群から解析的なキャビティ内壁までの最小距離(mm)を返す(負なら壁の外).""" + if shape == "cube": + margins = np.concatenate([ + (size / 2.0 - np.abs(points[:, 0:2])).ravel(), + (points[:, 2] - (RIM_Z - size)), + (RIM_Z - points[:, 2]), + ]) + return float(margins.min()) + return float((size - np.linalg.norm(points - cavity_center(shape, size), axis=1)).min()) + + +def build_baffle_mesh( + shape: str, size: float, led: dict, dest: Path +) -> tuple[trimesh.Trimesh, float, float]: + """LED の視線上に置く傾いたバッフル円板を生成して書き出す. + + Returns: + (メッシュ, 半径 mm, 内壁への食い込み深さ mm).食い込みは外殻の貫通も含めて許容する + (壁の外側に出た部分はキャビティ内から見えず,光輸送に影響しない). + + Raises: + ValueError: 半径がキャビティ寸法に対して大きすぎる(物理的に成立しない)場合. + """ + sections = 128 + radius = baffle_radius(led) + if radius > BAFFLE_RADIUS_CAP_RATIO * size: + raise ValueError( + f"バッフル半径 {radius:.1f}mm がキャビティ寸法 {size:g}mm の " + f"{BAFFLE_RADIUS_CAP_RATIO:.0%} を超えます(板が発光面の前方に掠めて入る配置)" + ) + mesh = trimesh.creation.cylinder(radius=radius, height=BAFFLE_THICKNESS, sections=sections) + mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1])) + direction = led["direction"] + mesh.apply_transform(trimesh.geometry.align_vectors([0.0, 0.0, 1.0], direction)) + mesh.apply_translation(led["center"] + BAFFLE_GAP * direction) + embed = max(0.0, -_wall_distance(shape, size, mesh.vertices)) + dest.parent.mkdir(parents=True, exist_ok=True) + mesh.export(dest) + return mesh, radius, embed + + +def build_housing_mesh(shape: str, size: float, dest: Path) -> trimesh.Trimesh: + """筐体メッシュを生成して書き出す(軸上スイープと同一のビルダ).""" + if dest.exists(): + return trimesh.load(dest, force="mesh") + if shape == "sphere": + return build_sphere_mesh(size, dest, SPHERE_CAVITY) + mesh = build_cube_housing( + size=size, thickness=THICKNESS, + board_half_x=BOARD_HALF[0], board_half_y=BOARD_HALF[1], + rim_inset=RIM_INSET, rim_z=RIM_Z, + ) + dest.parent.mkdir(parents=True, exist_ok=True) + mesh.export(dest) + return mesh + + +def housing_mesh_path(shape: str, size: float) -> Path: + """筐体 STL の置き場所(同じ形状・サイズの条件で共有).""" + prefix = "r" if shape == "sphere" else "L" + return MESH_DIR / f"筐体_{shape}_{prefix}{size:g}.stl" + + +def build_scene_yaml( + cond: Condition, mesh_path: Path, leds: list[dict], baffles: list[Path | None], dest: Path +) -> None: + """テンプレートから当該条件のシーン YAML を書き出す. + + 材質・センサ・レンダ設定・LED の半径は変更しない.LED の位置・向き・個数と + バッフルだけを変え,多灯では radiance を 1/N にして総光束を保存する. + """ + config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8"))) + config["name"] = f"led_placement_{cond.name}{MATERIAL_SUFFIX[_material]}" + config["render"]["seed"] = cond.rep - 1 + if MATERIALS[_material] is not None: + config["materials"]["printer_white"] = dict(MATERIALS[_material]) + config["description"] = ( + f"照明配置スイープ [{cond.shape} / {cond.size:g}mm]: θ={cond.theta_deg:g}° の壁面に " + f"{cond.n_led} 灯(向き={cond.aim})." + + ("バッフルで直接光を遮断." if any(b is not None for b in baffles) + else "板は発光面の背後(直接光なし・バッフル無し).") + ) + yaml_dir = dest.parent + for mesh in config["geometry"]["meshes"]: + if mesh["id"] == "sphere_housing": + if cond.shape == "cube": + mesh["id"] = "cube_housing" + mesh["path"] = relative_mesh_path(mesh_path, yaml_dir) + else: + mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir) + for k, baffle_path in enumerate(baffles): + if baffle_path is None: + continue + config["geometry"]["meshes"].append({ + "id": f"baffle_{k}", + "path": relative_mesh_path(baffle_path, yaml_dir), + "unit": "mm", + "material": "printer_white", + }) + template_light = config["lights"][0] + config["lights"] = [] + for k, led in enumerate(leds): + light = copy.deepcopy(template_light) + light["id"] = f"led_{k}" + light["center"] = [round(float(c), 4) for c in led["center"]] + light["direction"] = [round(float(d), 6) for d in led["direction"]] + light["radiance"] = [float(r) / cond.n_led for r in template_light["radiance"]] + config["lights"].append(light) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8") + + +def prepare_condition(cond: Condition) -> tuple[Path, dict]: + """1 条件の筐体・LED・バッフルを生成し,シーン YAML のパスと幾何の記録を返す. + + Raises: + ValueError: LED を置けない(稜・接続に近い),円板が壁を貫く,バッフルが大きすぎる. + """ + mesh_path = housing_mesh_path(cond.shape, cond.size) + housing = build_housing_mesh(cond.shape, cond.size, mesh_path) + if not housing.is_watertight: + raise ValueError(f"{cond.name}: watertight でないメッシュが生成されました") + leds: list[dict] = [] + baffles: list[Path | None] = [] + record: dict = { + "cavity_area_mm2": cavity_area(cond.shape, cond.size), + "baffled": 0, "baffle_radius_mm": 0.0, "baffle_embed_mm": 0.0, + "board_front_frac": 0.0, + } + for k, azimuth in enumerate(cond.azimuths_deg): + led = led_geometry(cond.shape, cond.size, cond.theta_deg, azimuth, cond.aim) + rim = _disk_points(led["center"], led["direction"], LED_RADIUS, 64) + # 球は解析的な球面に対して測る.θ=120° でも円板は loft 接続平面より下に収まり, + # 接続直上 12mm では loft が球の延長より外側(r=100)か 1.7mm 以内の内側なので + # 解析球面での判定が実質そのまま成り立つ(生成メッシュで確認済み) + clearance = _wall_distance(cond.shape, cond.size, rim) + if clearance < 0.0: + raise ValueError(f"{cond.name}: LED 円板が壁を貫いています(余裕 {clearance:.3f}mm)") + leds.append(led) + if led["board_front_frac"] > 0.0: + baffle_path = MESH_DIR / f"バッフル_{cond.base_name}_{k}.stl" + _, radius, embed = build_baffle_mesh(cond.shape, cond.size, led, baffle_path) + baffles.append(baffle_path) + record["baffled"] = 1 + record["baffle_radius_mm"] = round(radius, 3) + record["baffle_embed_mm"] = round(max(record["baffle_embed_mm"], embed), 3) + else: + baffles.append(None) + if k == 0: + record.update({ + "led_x_mm": round(float(led["center"][0]), 3), + "led_y_mm": round(float(led["center"][1]), 3), + "led_z_mm": round(float(led["center"][2]), 3), + "led_standoff_mm": round(float(led["standoff"]), 3), + "tilt_deg": round(led["tilt_deg"], 2), + "board_front_frac": round(led["board_front_frac"], 4), + "led_clearance_mm": round(clearance, 3), + }) + # 再レンダ条件(rep>1)は幾何が同じでも seed を変えた別 YAML にする.seed が同じだと + # シーンを読み直しても指標が 4 桁まで一致し,ノイズフロアにならない(サーバで実測) + scene_yaml = TMP_DIR / f"scene_{cond.name}.yaml" + build_scene_yaml(cond, mesh_path, leds, baffles, scene_yaml) + return scene_yaml, record + + +def prepare( + conds: list[Condition], log: Callable[[str], None], report_suffix: str = "" +) -> dict[str, tuple[Path, dict]]: + """条件群の幾何を生成し,置けない条件はスキップして記録する. + + report_suffix は形状別に並列で走らせるときにレポートを分けるための識別子. + """ + prepared: dict[str, tuple[Path, dict]] = {} + skipped: list[tuple[str, str]] = [] + log(f"=== 幾何生成: {len(conds)} 条件 ===") + for cond in conds: + try: + scene_yaml, record = prepare_condition(cond) + except ValueError as exc: + skipped.append((cond.name, str(exc))) + log(f" SKIP {cond.name}: {exc}") + continue + prepared[cond.name] = (scene_yaml, record) + baffle_note = (f"バッフル R={record['baffle_radius_mm']:6.2f} " + f"食い込み={record['baffle_embed_mm']:5.2f}" if record["baffled"] + else "バッフル無し(板は背後)") + log(f" {cond.name:>34} LED=({record['led_x_mm']:7.2f},{record['led_y_mm']:7.2f}," + f"{record['led_z_mm']:8.2f}) 傾き={record['tilt_deg']:5.1f}° " + f"余裕={record['led_clearance_mm']:4.2f} 板前方={record['board_front_frac']:.2f} " + f"{baffle_note}") + report = TMP_DIR / f"prepare_report{report_suffix}.csv" + keys = ["name", "status", "note", "cavity_area_mm2", "led_x_mm", "led_y_mm", "led_z_mm", + "led_standoff_mm", "tilt_deg", "led_clearance_mm", "board_front_frac", + "baffled", "baffle_radius_mm", "baffle_embed_mm"] + with report.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=keys) + writer.writeheader() + for cond in conds: + if cond.name in prepared: + row = {"name": cond.name, "status": "ok", "note": ""} + row.update(prepared[cond.name][1]) + else: + note = next(msg for name, msg in skipped if name == cond.name) + row = {"name": cond.name, "status": "skip", "note": note} + writer.writerow({k: row.get(k, "") for k in keys}) + log(f" 生成 {len(prepared)} / スキップ {len(skipped)}({report})") + return prepared + + +# --------------------------------------------------------------------------- +# 実行 +# --------------------------------------------------------------------------- + + +# 解決済みの Mitsuba variant(プロセス内で 1 回だけ決める.ワーカープロセスでも各 1 回) +_render_variant: str | None = None + + +def render_variant() -> str: + """このスイープが使う variant を返す(環境変数 SM_RENDER_VARIANT > テンプレート YAML). + + 条件ごとにシーン YAML を読み直すと 1 条件あたり 1 秒近く掛かるので,全条件で共通の + テンプレート(TEMPLATE_YAML)の render.variant を 1 回だけ解決して使い回す. + """ + global _render_variant # noqa: PLW0603 + if _render_variant is None: + template = yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8")) + _render_variant = resolve_render_variant(str(template["render"]["variant"])) + return _render_variant + + +def ensure_render_variant() -> str: + """レンダ前にプロセスの variant を確定させる(遮蔽検証も同じ variant で走らせるため). + + check_occlusion は mi.variant() が設定済みならそれを使うので,先に呼んでおくと + YAML の llvm_ad_rgb ではなくサーバの cuda_ad_rgb でレイキャストが走る + (球 1 条件で 25 秒 → 1 秒未満).import mitsuba を伴うので, + 既存結果を再利用するだけの条件では呼ばない. + """ + # mitsuba を読み込む import なので,モジュールレベルではなくここで行う + from sm_tias_lightsim.render.runner import ensure_variant + + variant = render_variant() + ensure_variant(variant) + return variant + + +def verify_occlusion(scene_yaml: Path, messages: list[str]) -> dict[str, float]: + """バッフル付き条件について板→LED の遮蔽率をレイキャストで測る(失敗しても続行). + + Dr.Jit のスレッド数は ensure_render_variant 内の apply_drjit_thread_count が + SM_DRJIT_THREADS を見て制限するので,ここでは触らない. + """ + try: + from verify_baffle_occlusion import check_occlusion + + result = check_occlusion(scene_yaml, board_grid=41, led_count=32) + return { + "occlusion_blocked_frac": round(result["blocked"] / result["total"], 6), + "occlusion_total": int(result["total"]), + } + except Exception as exc: # noqa: BLE001 - 検証の失敗でスイープを止めない + messages.append(f" !! 遮蔽検証に失敗(続行): {exc}") + 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, + "azimuth_deg": cond.azimuth_deg, + } + row.update(record) + row["render_variant"] = variant + row["material"] = _material + 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, material: str = "legacy" +) -> tuple[dict | None, list[str]]: + """1 条件をレンダ→評価まで実行し row.json を書いて集約行とログ行を返す. + + ワーカープロセスからも呼べるよう,引数・戻り値は pickle 可能なものだけにする + (主プロセスの log クロージャは渡せないので,ログはメッセージとして返して + 主プロセスが書き出す).材質はワーカー(spawn)に主プロセスの設定が伝わらないので + 引数で受け取り,ここで出力ディレクトリを切り替える. + + Returns: + (集約行, ログ行のリスト).失敗した条件の集約行は None. + """ + if material != _material: # 主プロセスや同一材質のワーカーでは出力先を触らない + set_material(material) + messages: list[str] = [] + out_dir = OUTPUT_BASE / cond.name + row_json = out_dir / "row.json" + 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 = 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() + if not render_and_evaluate(scene_yaml, out_dir, variant, messages): + return None, messages + + row["elapsed_s"] = round(time.time() - start, 1) + 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"] + messages.append( + f" -> {row['elapsed_s']:.0f}s CoV={row['cov']:.4f} centroid=({cx:+.4f},{cy:+.4f}) " + f"angular_ratio={row['angular_ratio']:.4f} 生mean={row['raw_mean_roi']:.4f} " + f"遮蔽={occl:.4f}") + return row, messages + + +def collect_rows() -> list[dict]: + """出力ディレクトリ配下の row.json をすべて読む(並列ジョブの結果を合流させる).""" + rows = [] + for path in sorted(OUTPUT_BASE.glob("*/row.json")): + rows.append(json.loads(path.read_text(encoding="utf-8"))) + return rows + + +def write_summary() -> Path: + """全 row.json から集約 CSV を書き直す(並列に走る形状別ジョブでも壊れない).""" + order = {name: i for i, name in enumerate(c.name for c in conditions())} + rows = sorted(collect_rows(), key=lambda r: order.get(r["name"], len(order))) + summary_csv = OUTPUT_BASE / "summary.csv" + summary_csv.parent.mkdir(parents=True, exist_ok=True) + with summary_csv.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS}) + return summary_csv + + +def main() -> int: + """照明配置スイープを実行するメインエントリポイント.""" + parser = argparse.ArgumentParser(description="照明配置スイープ(球 + 立方体)") + parser.add_argument("--prepare-only", action="store_true", + help="メッシュとシーン YAML の生成だけ行う(3D 配置確認用)") + parser.add_argument("--shapes", choices=("sphere", "cube"), nargs="*", default=None, + help="実行する形状(省略時は両方)") + parser.add_argument("--groups", choices=GROUPS, nargs="*", default=None, + help="実行する条件グループ(省略時は全部)") + parser.add_argument("--limit", type=int, default=None, + help="先頭 N 条件だけ実行する(動作確認用)") + parser.add_argument("--material", choices=tuple(MATERIALS), default="legacy", + help="内壁材質(calibrated=TEST_14 の純 diffuse ρ=0.81.出力は別 dir)") + parser.add_argument("--key-subset", action="store_true", + help="材質の引き直し用の主要条件だけに絞る") + parser.add_argument("--workers", type=int, default=1, + help="同時に実行する条件数(既定 1=逐次.GPU 1 枚なら 2〜3 が目安)") + parser.add_argument("--list", action="store_true", help="条件一覧を表示して終了") + args = parser.parse_args() + set_material(args.material) + + conds = [c for c in conditions() + if (not args.shapes or c.shape in args.shapes) + and (not args.groups or c.group in args.groups)] + if args.key_subset: + conds = key_subset(conds) + if args.limit: + conds = conds[: args.limit] + if args.list: + for c in conds: + print(c.name) + print(f"{len(conds)} 条件") + return 0 + + log_path = ROOT / "output" / "_sweep_led_placement.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() + + log(f"=== 照明配置スイープ({len(conds)} 条件)===") + suffix = "_" + "_".join(args.shapes) if args.shapes else "" + prepared = prepare(conds, log, suffix) + if args.prepare_only: + log(f"--prepare-only のため終了.シーン YAML: {TMP_DIR}") + log_file.close() + return 0 + + 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 の書き出しは主プロセスだけが行う. + log(f"=== {args.workers} 並列で実行(1 ワーカー 1 プロセス)===") + context = multiprocessing.get_context("spawn") + with ProcessPoolExecutor(max_workers=args.workers, mp_context=context) as pool: + futures = { + pool.submit(run_job, cond, scene_yaml, geometry_record, args.material): cond + for cond, scene_yaml, geometry_record in jobs + } + for future in as_completed(futures): + cond = futures[future] + try: + row, messages = future.result() + except Exception as exc: # noqa: BLE001 - 1 条件の異常で全体を止めない + log(f" !! {cond.name}: ワーカーが異常終了(続行): {exc}") + continue + done += log_result(row, messages) + else: + for cond, scene_yaml, geometry_record in jobs: + done += log_result(*run_job(cond, scene_yaml, geometry_record, args.material)) + + log(f"\n=== 完了: {done} / {len(prepared)} 条件 ===") + log(f" 集約 CSV: {write_summary()}") + log_file.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index 5a5d4ba..0ac07eb 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -3,6 +3,10 @@ PNG または luminance.npy を入力として均一性評価を実行し, metrics.csv と可視化 PNG を出力ディレクトリに保存する. +処理本体は `evaluation/pipeline.py` の `evaluate_image()` にある(スイープ等から +プロセス内で直接呼べるようにするため).本モジュールは引数のパースと, +例外 → 「エラー: …」表示 + 終了コード 1 への変換だけを担う. + matplotlib のバックエンドは Agg を強制する(headless 環境対応). evaluate は Mitsuba を使わないため,render.py と異なり遅延 import は不要. ただし Agg バックエンドの設定順序を保つため,matplotlib.use() は pyplot import より前に置く. @@ -17,54 +21,38 @@ import matplotlib # headless 環境(サーバ・CI 等)でも動くよう Agg バックエンドを強制する. -# visualize.py より先に設定することで,pyplot import 時に Agg が有効になる. +# pipeline(→ visualize.py)より先に設定することで,pyplot import 時に Agg が有効になる. matplotlib.use("Agg") -import numpy # noqa: E402 -import pandas # noqa: E402 -from PIL import Image # noqa: E402 - -from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor # noqa: E402 -from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure # noqa: E402 -from sm_tias_lightsim.evaluation.radial import ( # noqa: E402 +# evaluate_image / EvaluationStepError 以外は,補助関数が本モジュールにあった頃の +# 参照互換のための再エクスポート(テスト・スクリプトが cli.evaluate.<名前> を参照している). +from sm_tias_lightsim.evaluation.pipeline import ( # noqa: E402, F401 + _FIGURES_DIRNAME, + _HISTOGRAM_FILENAME, + _LUMINANCE_MAP_FILENAME, + _METRICS_FILENAME, + _RADIAL_PROFILE_FILENAME, + _ZONE_MAP_FILENAME, + EvaluationStepError, + _apply_normalization_to_luminance, + _apply_oetf_to_luminance, + _load_eval_config, + _load_luminance, + _load_reference_luminance, + _print_summary, + _run_fullimage_min_max, + _run_radial_min_max, + _run_radial_profile, + _run_shape_metrics, + _run_zone_analysis, + _save_figures, + _save_metrics_csv, + evaluate_image, +) +from sm_tias_lightsim.evaluation.radial import ( # noqa: E402, F401 calc_radial_min_max_ratio, calc_radial_profile, ) -from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402 -from sm_tias_lightsim.evaluation.shape_metrics import ( # noqa: E402 - calc_angular_radial_decomposition, - calc_intensity_centroid, - calc_intensity_pca, - calc_ncc, - calc_ssim, -) -from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 - save_histogram, - save_luminance_map, - save_radial_profile, - save_zone_map, -) -from sm_tias_lightsim.evaluation.zones import ( # noqa: E402 - calc_zone_stats, - classify_zones, - compute_normalized_distance_map, -) -from sm_tias_lightsim.scene.schema import ( # noqa: E402 - EvaluationConfig, - MetricsConfig, - NormalizationConfig, - OetfConfig, - ZonesConfig, -) -from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale # noqa: E402 - -# 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため) -_METRICS_FILENAME = "metrics.csv" -_FIGURES_DIRNAME = "figures" -_LUMINANCE_MAP_FILENAME = "luminance_map.png" -_HISTOGRAM_FILENAME = "histogram.png" -_RADIAL_PROFILE_FILENAME = "radial_profile.png" -_ZONE_MAP_FILENAME = "zone_map.png" def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -106,431 +94,19 @@ metavar="OUTPUT_DIR", help="出力先ディレクトリ(存在しない場合は親ごと作成する).", ) + parser.add_argument( + "--no-figures", + action="store_true", + help="figures/ の可視化 PNG を作らない(指標だけ欲しいスイープ用.描画分だけ速い).", + ) return parser.parse_args(argv) -def _apply_oetf_to_luminance( - roi_luminance: numpy.ndarray, - oetf_cfg: OetfConfig, -) -> numpy.ndarray: - """OETF 順方向適用:ROI linear luminance を sRGB 符号化空間へ変換する. - - 無彩色シーン(シミュ LED・材料が RGB 等価)では luminance への直接 OETF 適用は - 「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する(SPEC_03 参照). - - 露出ソルバは ROI 上で解く(target_mean は実測 ROI mean のため ROI 上で収束させる). - - Args: - roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). - oetf_cfg: OetfConfig(enabled=True が前提). - - Returns: - OETF 適用後の ROI 輝度配列(H' x W', float64, quantize_uint8=True なら [0,255]). - - Raises: - ValueError: oetf_cfg.exposure が None の場合(anchor_mean モードで必須). - """ - if oetf_cfg.exposure is None: - msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です." - raise ValueError(msg) - - roi_lin = roi_luminance.astype(numpy.float64) - target_mean = oetf_cfg.exposure.target_mean - # ROI linear 値で露出スケールを決定(実測 mean が ROI mean のため ROI 上で解く) - scale, clipped = solve_exposure(roi_lin, target_mean) - clip_pct = clipped * 100.0 - print(f" OETF exposure : scale={scale:.6g} clip={clip_pct:.1f}%") - - # ROI に対してのみ OETF を適用する(指標算出は ROI 上で行うため) - return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8) - - -def _apply_normalization_to_luminance( - roi_luminance: numpy.ndarray, - norm_cfg: NormalizationConfig, -) -> numpy.ndarray: - """linear mean anchor 正規化を ROI 輝度配列に適用する. - - ROI mean を norm_cfg.target_mean に揃える線形ゲインを適用する. - 形状指標(CoV / 勾配 / C/P 比 / max-min 比)は線形ゲインに不変なので, - 絶対値スケールだけが変わる. - - Args: - roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). - norm_cfg: NormalizationConfig(enabled=True が前提). - - Returns: - 正規化後の ROI 輝度配列(H' x W', float64). - - Raises: - ValueError: ROI mean が 0 の場合(apply_mean_anchor から伝播). - """ - roi_lin = roi_luminance.astype(numpy.float64) - target_mean = norm_cfg.target_mean - normalized, alpha = apply_mean_anchor(roi_lin, target_mean) - print(f" normalization : alpha={alpha:.6g} target_mean={target_mean}") - return normalized - - -def _load_luminance(image_path: Path) -> numpy.ndarray: - """画像ファイルを読み込み,輝度配列(2D float64)を返す. - - 拡張子が .npy の場合は numpy.load で読み込む. - それ以外(PNG 等)は PIL で RGB 画像として読み込み,to_grayscale で変換する. - .npy が 3D(H, W, 3)だった場合も to_grayscale を通す. - - Args: - image_path: 読み込む画像ファイルのパス. - - Returns: - 輝度配列(H x W, float64). - - Raises: - FileNotFoundError: ファイルが存在しない場合. - ValueError: ファイルを輝度配列に変換できない場合. - """ - if not image_path.exists(): - msg = f"画像ファイルが見つかりません: {image_path}" - raise FileNotFoundError(msg) - - if image_path.suffix.lower() == ".npy": - data = numpy.load(image_path) - if data.ndim == 3: - return to_grayscale(data.astype(numpy.float64)) - if data.ndim == 2: - return data.astype(numpy.float64) - msg = f".npy の次元数が不正です(ndim={data.ndim}).2D または 3D を期待します." - raise ValueError(msg) - - pil_image = Image.open(image_path).convert("RGB") - return to_grayscale(numpy.array(pil_image, dtype=numpy.float64)) - - -def _load_eval_config(config_path: Path) -> EvaluationConfig: - """YAML を読み込んで EvaluationConfig を返す. - - SceneConfig の evaluation サブツリー,または EvaluationConfig 互換の - トップレベル YAML の両方をサポートする. - - Args: - config_path: YAML ファイルのパス. - - Returns: - EvaluationConfig インスタンス. - - Raises: - FileNotFoundError: ファイルが存在しない場合. - pydantic.ValidationError: スキーマ違反. - yaml.YAMLError: YAML 構文エラー. - """ - import yaml - - if not config_path.exists(): - msg = f"設定 YAML ファイルが見つかりません: {config_path}" - raise FileNotFoundError(msg) - - with config_path.open(encoding="utf-8") as f: - data = yaml.safe_load(f) - - # "evaluation" キーがあれば SceneConfig 埋め込みとみなしてサブツリーを取得する - if isinstance(data, dict) and "evaluation" in data: - eval_data = data["evaluation"] - else: - eval_data = data - - return EvaluationConfig.model_validate(eval_data) - - -def _run_zone_analysis( - roi_luminance: numpy.ndarray, - zones_config: ZonesConfig | None, -) -> tuple[numpy.ndarray, dict] | tuple[None, dict]: - """ゾーン分析を実行し,zone_map とゾーン統計を返す. - - Args: - roi_luminance: ROI 輝度配列(H x W, float64). - zones_config: ZonesConfig インスタンス(enabled / boundaries を持つ). - None の場合は無効扱い. - - Returns: - (zone_map, zone_stats) のタプル. - zones が無効なら (None, {}) を返す. - - Raises: - ValueError: ゾーン分析の計算に失敗した場合. - """ - if zones_config is None or not zones_config.enabled: - return None, {} - - h, w = roi_luminance.shape - distance_map = compute_normalized_distance_map(h, w) - zone_map = classify_zones(distance_map, zones_config.boundaries) - zone_stats = calc_zone_stats(roi_luminance, zone_map) - return zone_map, zone_stats - - -def _run_radial_profile( - roi_luminance: numpy.ndarray, - metrics_config: MetricsConfig | None, -) -> numpy.ndarray | None: - """放射状プロファイルを算出して返す. - - Args: - roi_luminance: ROI 輝度配列(H x W, float64). - metrics_config: MetricsConfig インスタンス(radial_profile フラグを持つ). - None の場合は無効扱い. - - Returns: - 放射状プロファイル配列((n_bins, 2)),無効時は None. - """ - if metrics_config is None or not metrics_config.radial_profile: - return None - return calc_radial_profile(roi_luminance) - - -def _run_radial_min_max( - roi_luminance: numpy.ndarray, - metrics_config: MetricsConfig | None, - radial_profile: numpy.ndarray | None, -) -> dict: - """動径 min/max 比を算出して返す. - - radial_min_max が True のとき,radial_profile が既に算出されていればそれを再利用し, - 未算出(None)の場合はその場で calc_radial_profile を呼び出して算出する. - 重複算出を避けるため,radial_profile の結果を優先的に使用する. - - Args: - roi_luminance: ROI 輝度配列(H x W, float64). - metrics_config: MetricsConfig インスタンス(radial_min_max フラグを持つ). - None の場合は無効扱い. - radial_profile: _run_radial_profile で算出済みのプロファイル配列. - None の場合はここで算出する. - - Returns: - 動径 min/max 比指標の辞書(radial_min_max_ratio / radial_min_distance / - radial_min / radial_max の 4 キー).無効時は空辞書. - """ - if metrics_config is None or not metrics_config.radial_min_max: - return {} - - # radial_profile が未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) - profile = radial_profile if radial_profile is not None else calc_radial_profile(roi_luminance) - return calc_radial_min_max_ratio(profile) - - -def _load_reference_luminance(reference_path: Path) -> numpy.ndarray: - """形状指標用の参照画像を読み込み,輝度配列(2D float64)を返す. - - SSIM / NCC の参照画像として使用する._load_luminance と同一の変換ロジックを用い, - .npy 形式(2D/3D)と PNG 形式の両方に対応する. - 呼び出し元(_run_shape_metrics)で shape を確認してから calc_ssim / calc_ncc に渡すこと. - - Args: - reference_path: 参照画像ファイルのパス(.npy または .png). - - Returns: - 輝度配列(H x W, float64). - - Raises: - FileNotFoundError: ファイルが存在しない場合. - ValueError: ファイルを輝度配列に変換できない場合. - """ - return _load_luminance(reference_path) - - -def _run_shape_metrics( - roi_luminance: numpy.ndarray, - metrics_config: MetricsConfig | None, - config_dir: Path, -) -> dict: - """形状指標(輝度重心 / 角度-放射分解 / PCA 主軸 / SSIM / NCC)を算出する. - - metrics.shape_metrics=True のとき,以下の指標を算出して辞書として返す: - - 輝度重心 5 値 (calc_intensity_centroid) - - 角度-放射分解 3 値 (calc_angular_radial_decomposition) - - PCA 主軸 4 値 (calc_intensity_pca) - - SSIM / NCC(shape_reference が指定されたときのみ,それ以外は float("nan")) - - shape_reference パスは config YAML のディレクトリを基準に解決する. - 絶対パスの場合はそのまま使用する. - - Args: - roi_luminance: ROI 輝度配列(H x W, float64). - metrics_config: MetricsConfig インスタンス(shape_metrics フラグを持つ). - None の場合は無効扱い. - config_dir: 評価 YAML ファイルのディレクトリ(shape_reference の相対パス解決用). - - Returns: - 形状指標の辞書(12 キー).無効時は空辞書. - SSIM / NCC が計算不能な場合は float("nan") を格納する. - """ - if metrics_config is None or not metrics_config.shape_metrics: - return {} - - result: dict = {} - - # 輝度重心 - centroid = calc_intensity_centroid(roi_luminance) - result["intensity_centroid_x_px"] = centroid["centroid_x_px"] - result["intensity_centroid_y_px"] = centroid["centroid_y_px"] - result["intensity_centroid_x_norm"] = centroid["centroid_x_norm"] - result["intensity_centroid_y_norm"] = centroid["centroid_y_norm"] - result["intensity_centroid_offset_norm"] = centroid["centroid_offset_norm"] - - # 角度-放射分解 - ard = calc_angular_radial_decomposition(roi_luminance) - result["angular_variance"] = ard["angular_variance"] - result["radial_variance"] = ard["radial_variance"] - result["angular_ratio"] = ard["angular_ratio"] - - # PCA 主軸 - pca = calc_intensity_pca(roi_luminance) - result["pca_primary_axis_angle_deg"] = pca["primary_axis_angle_deg"] - 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") - - return result - - -def _save_metrics_csv(metrics: dict, csv_path: Path) -> None: - """均一性指標を CSV ファイルとして保存する. - - Args: - metrics: 指標名 → 値の辞書. - csv_path: 保存先 CSV パス. - """ - df = pandas.DataFrame([metrics]) - df.to_csv(csv_path, index=False) - - -def _save_figures( - roi_luminance: numpy.ndarray, - figures_dir: Path, - radial_profile: numpy.ndarray | None, - zone_map: numpy.ndarray | None, -) -> None: - """可視化 PNG を figures/ ディレクトリに保存する. - - Args: - roi_luminance: ROI 輝度配列(H x W, float64). - figures_dir: 保存先ディレクトリ(事前に mkdir 済みであること). - radial_profile: 放射状プロファイル配列,None の場合は保存しない. - zone_map: ゾーンマップ配列,None の場合は保存しない. - """ - save_luminance_map(roi_luminance, figures_dir / _LUMINANCE_MAP_FILENAME) - save_histogram(roi_luminance, figures_dir / _HISTOGRAM_FILENAME) - - if radial_profile is not None: - save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME) - - if zone_map is not None: - save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance) - - -def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path) -> None: - """評価サマリーを標準出力に表示する. - - Args: - metrics: 指標名 → 値の辞書. - csv_path: metrics.csv の保存先パス(表示用). - figures_dir: figures/ ディレクトリのパス(表示用). - """ - print("=== 均一性評価サマリー ===") - print(f" CoV : {metrics['cov']:.6f}") - print(f" std : {metrics['std']:.4f}") - print(f" max/min ratio : {metrics['max_min_ratio']:.4f}") - print(f" mean : {metrics['mean']:.4f}") - print(f" max : {metrics['max']:.4f}") - print(f" min : {metrics['min']:.4f}") - - if "center_periphery_ratio" in metrics: - print(f" C/P ratio : {metrics['center_periphery_ratio']:.4f}") - print(f" gradient [%] : {metrics['gradient_magnitude']:.2f}") - - if "radial_min_max_ratio" in metrics: - print(f" radial min/max : {metrics['radial_min_max_ratio']:.4f}") - print(f" radial min dist: {metrics['radial_min_distance']:.4f}") - - if "intensity_centroid_offset_norm" in metrics: - print(f" centroid offset: {metrics['intensity_centroid_offset_norm']:.4f} (norm)") - print( - f" centroid (norm): x={metrics['intensity_centroid_x_norm']:.4f}" - f" y={metrics['intensity_centroid_y_norm']:.4f}" - ) - ang_ratio = metrics.get("angular_ratio", float("nan")) - if ang_ratio != ang_ratio: # NaN check - print(" angular_ratio : NaN (uniform image)") - else: - print(f" angular_ratio : {ang_ratio:.4f} (0=concentric, 1=directional)") - print(f" pca axis [deg] : {metrics['pca_primary_axis_angle_deg']:.1f}") - print(f" pca eccentricit: {metrics['pca_eccentricity']:.4f}") - ssim_val = metrics.get("ssim", float("nan")) - ncc_val = metrics.get("ncc", float("nan")) - if ssim_val == ssim_val: # not NaN - print(f" SSIM : {ssim_val:.4f}") - print(f" NCC : {ncc_val:.4f}") - - print(f"\nmetrics.csv : {csv_path}") - print(f"figures/ : {figures_dir}") - - def main(argv: list[str] | None = None) -> int: """sm-evaluate コマンドのメインエントリポイント. - 処理フロー: - 1. コマンドライン引数をパースする. - 2. 画像ファイルを読み込み,輝度配列を得る. - 3. YAML 設定ファイルを読み込み,EvaluationConfig を得る. - 4. 出力ディレクトリと figures/ サブディレクトリを作成する. - 5. extract_roi で ROI を切り出す. - 6. normalization.enabled のとき linear mean anchor 正規化を適用する. - 7. oetf.enabled のとき sRGB OETF を適用する. - 8. calc_uniformity で均一性指標を算出する. - 9. zones.enabled のときゾーン分析を実行する. - 10. metrics.radial_profile のとき放射状プロファイルを算出する. - 11. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用). - 12. metrics.shape_metrics のとき形状指標を算出する - (重心 / 角度-放射分解 / PCA / SSIM / NCC,metrics.csv に 12 列末尾追加). - 13. metrics.csv を保存する. - 14. 可視化 PNG を figures/ に保存する. - 15. 算出指標のサマリーを標準出力に表示する. + 引数をパースして `evaluate_image()` を呼び,段階別のエラーを終了コードに変換する. + 評価そのものの処理フローは `evaluation/pipeline.py` の `evaluate_image()` を参照. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -542,112 +118,15 @@ # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する args = _parse_args(argv) - image_path: Path = args.image.resolve() - config_path: Path = args.config.resolve() - output_dir: Path = args.output.resolve() - try: - luminance = _load_luminance(image_path) - except FileNotFoundError as exc: - print(f"エラー: {exc}", file=sys.stderr) - return 1 - except Exception as exc: # noqa: BLE001 - print(f"エラー: 画像の読み込みに失敗しました — {exc}", file=sys.stderr) - return 1 - - try: - eval_config = _load_eval_config(config_path) - except FileNotFoundError as exc: - print(f"エラー: {exc}", file=sys.stderr) - return 1 - except Exception as exc: # noqa: BLE001 - print(f"エラー: 設定 YAML の読み込みに失敗しました — {exc}", file=sys.stderr) - return 1 - - figures_dir = output_dir / _FIGURES_DIRNAME - try: - output_dir.mkdir(parents=True, exist_ok=True) - figures_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr) - return 1 - - try: - roi_luminance = extract_roi(luminance, eval_config.roi) - except (ValueError, NotImplementedError) as exc: - print(f"エラー: ROI 抽出に失敗しました — {exc}", file=sys.stderr) - return 1 - - # 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: - roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg) - except ValueError as exc: - print(f"エラー: linear 正規化に失敗しました — {exc}", file=sys.stderr) - return 1 - - # 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: - roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg) - except ValueError as exc: - print(f"エラー: OETF 適用に失敗しました — {exc}", file=sys.stderr) - return 1 - - try: - uniformity_stats = calc_uniformity(roi_luminance) - except ValueError as exc: - print(f"エラー: 均一性指標の算出に失敗しました — {exc}", file=sys.stderr) - return 1 - - metrics: dict = dict(uniformity_stats) - - try: - zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones) - metrics.update(zone_stats) - except ValueError as exc: - print(f"エラー: ゾーン分析に失敗しました — {exc}", file=sys.stderr) - return 1 - - try: - radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics) - except Exception as exc: # noqa: BLE001 - print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr) - return 1 - - try: - radial_min_max_stats = _run_radial_min_max( - roi_luminance, eval_config.metrics, radial_profile + evaluate_image( + args.image.resolve(), + args.config.resolve(), + args.output.resolve(), + save_figures=not args.no_figures, ) - metrics.update(radial_min_max_stats) - except Exception as exc: # noqa: BLE001 - print(f"エラー: 動径 min/max 比の算出に失敗しました — {exc}", file=sys.stderr) + except EvaluationStepError as exc: + print(f"エラー: {exc}", file=sys.stderr) return 1 - try: - shape_stats = _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent) - metrics.update(shape_stats) - except Exception as exc: # noqa: BLE001 - print(f"エラー: 形状指標の算出に失敗しました — {exc}", file=sys.stderr) - return 1 - - csv_path = output_dir / _METRICS_FILENAME - try: - _save_metrics_csv(metrics, csv_path) - except Exception as exc: # noqa: BLE001 - print(f"エラー: metrics.csv の保存に失敗しました — {exc}", file=sys.stderr) - return 1 - - try: - _save_figures(roi_luminance, figures_dir, radial_profile, zone_map) - except Exception as exc: # noqa: BLE001 - print(f"エラー: 可視化 PNG の保存に失敗しました — {exc}", file=sys.stderr) - return 1 - - _print_summary(metrics, csv_path, figures_dir) return 0 diff --git a/src/sm_tias_lightsim/cli/render.py b/src/sm_tias_lightsim/cli/render.py index 5512dde..8f4e8e5 100644 --- a/src/sm_tias_lightsim/cli/render.py +++ b/src/sm_tias_lightsim/cli/render.py @@ -1,13 +1,18 @@ # pyright: reportCallIssue=none # Mitsuba 3 は variant ベースの動的属性解決を行うため Pylance/Pyright が # mi.* を object 型と推論し reportCallIssue を誤検知する(coding-style.md の方針). -# 下記の Mitsuba 依存 import(load_scene_yaml / render_scene / save_png / save_luminance)を -# main() 内で遅延 import しているのは,--help だけ使う場面(Mitsuba 不在の軽量環境)でも -# 起動できるようにするためである. +# 処理本体(render_yaml)を main() 内で遅延 import しているのは,--help だけ使う場面 +# (Mitsuba 不在の軽量環境)でも起動できるようにするためである. +# render/pipeline.py 自体も mitsuba 依存 import を関数内に閉じ込めており, +# llvm_* variant の DRJIT_LIBLLVM_PATH 設定が mitsuba import より前に走ることを保証する. """sm-render コマンドラインエントリポイント. YAML シーン定義を読み込み,Mitsuba 3 でレンダリングを実行して PNG と luminance.npy を出力ディレクトリに保存する. + +処理本体は `render/pipeline.py` の `render_yaml()` にある(スイープ等から +プロセス内で直接呼べるようにするため).本モジュールは引数のパースと, +例外 → 「エラー: …」表示 + 終了コード 1 への変換だけを担う. """ from __future__ import annotations @@ -16,10 +21,6 @@ import sys from pathlib import Path -# Step 6 の evaluate CLI も同じファイルを読むため,名前を定数として一元管理する -_PNG_FILENAME = "render.png" -_NPY_FILENAME = "luminance.npy" - def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: """コマンドライン引数をパースして返す. @@ -67,13 +68,8 @@ def main(argv: list[str] | None = None) -> int: """sm-render コマンドのメインエントリポイント. - 処理フロー: - 1. コマンドライン引数をパースする. - 2. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. - 3. 出力ディレクトリを作成する(親ごと,既存なら何もしない). - 4. render_scene で Mitsuba レンダリングを実行し,HDR 配列を得る. - 5. save_png で render.png を保存する. - 6. save_luminance で luminance.npy を保存する. + 引数をパースして `render_yaml()` を呼び,段階別のエラーを終了コードに変換する. + レンダそのものの処理フローは `render/pipeline.py` の `render_yaml()` を参照. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -85,64 +81,17 @@ # ここでは ValueError / FileNotFoundError 等の実行時エラーのみ捕捉する args = _parse_args(argv) - scene_path: Path = args.scene.resolve() - output_dir: Path = args.output.resolve() + # mitsuba 依存のモジュールを読み込む前に引数をパースする(--help を軽量に保つ) + from sm_tias_lightsim.render.pipeline import RenderStepError, render_yaml try: - from sm_tias_lightsim.scene.schema import load_scene_yaml - - config = load_scene_yaml(scene_path) - except FileNotFoundError as exc: - print(f"エラー: シーン YAML ファイルが見つかりません — {exc}", file=sys.stderr) - return 1 - except Exception as exc: # noqa: BLE001 - print(f"エラー: シーン YAML の読み込みに失敗しました — {exc}", file=sys.stderr) - return 1 - - # 同じ YAML を手元(llvm)とサーバ(cuda)で使い回すため,variant だけ実行側から差し替える - # 優先順位: --variant > 環境変数 SM_RENDER_VARIANT > YAML の render.variant - from sm_tias_lightsim.runtime.overrides import resolve_render_variant - - variant = resolve_render_variant(config.render.variant, args.variant) - if variant != config.render.variant: - print(f"variant を上書き: {config.render.variant} → {variant}") - config = config.model_copy( - update={"render": config.render.model_copy(update={"variant": variant})} + png_path, npy_path = render_yaml( + args.scene.resolve(), + args.output.resolve(), + args.variant, ) - - try: - output_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - print(f"エラー: 出力ディレクトリを作成できません — {exc}", file=sys.stderr) - return 1 - - # project_root を scene YAML の親ディレクトリとすることで, - # YAML 内の STL 相対パスが YAML ファイルを基点に解決される - project_root = scene_path.parent - try: - # llvm_* variant の場合,mitsuba の import チェーン(sensors.py / lights.py)が - # 走る前に DRJIT_LIBLLVM_PATH を設定しておく必要がある. - # runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. - if config.render.variant.startswith("llvm_"): - from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path - - ensure_drjit_libllvm_path() - from sm_tias_lightsim.render.runner import render_scene - - hdr_image = render_scene(config, project_root=project_root) - except Exception as exc: # noqa: BLE001 - print(f"エラー: レンダリングに失敗しました — {exc}", file=sys.stderr) - return 1 - - png_path = output_dir / _PNG_FILENAME - npy_path = output_dir / _NPY_FILENAME - try: - from sm_tias_lightsim.render.output import save_luminance, save_png - - save_png(hdr_image, png_path) - save_luminance(hdr_image, npy_path) - except Exception as exc: # noqa: BLE001 - print(f"エラー: 出力ファイルの保存に失敗しました — {exc}", file=sys.stderr) + except RenderStepError as exc: + print(f"エラー: {exc}", file=sys.stderr) return 1 print(f"レンダリング完了: {png_path}") diff --git a/src/sm_tias_lightsim/evaluation/fullimage.py b/src/sm_tias_lightsim/evaluation/fullimage.py new file mode 100644 index 0000000..fae4894 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/fullimage.py @@ -0,0 +1,174 @@ +"""画像全体で評価する,ノイズ補正付き最小/最大比のモジュール. + +輪帯平均の min/max(``radial.calc_radial_min_max_ratio``)は方位方向に平均するため, +同心円状でない輝度分布(TEST_18 のオフ軸 LED 配置)では不均一を平均で打ち消してしまう. +一方,画素単位の min/max や平滑化後のパーセンタイルはモンテカルロ(MC)ノイズの裾を拾い, +生の輝度が低い大きなキャビティで値が頭打ちになる. + +本モジュールはその中間として「ガウス平滑化した像の min/max を,同じ平滑化を掛けた +ノイズ場の極値で補正する」定義を提供する.平滑化で方位方向に平均せずノイズだけを落とし, +残ったノイズの寄与(平滑化後でも残る極値のバイアス)を統計的に差し引く. + +補正に使うノイズ場は,残差(生 − メディアン/ガウスで均した像)を空間シャッフルして +同じ σ で平滑化したもの.シャッフルで構造を壊し,ノイズの振幅分布だけを残す +(``evaluation`` 外の ``scripts/build_radius_sweep_visual.py:decompose_cov`` と同じ発想). + +仮定: + ノイズが空間的に独立(画素間で無相関)であること.シャッフルは画素の並べ替えなので, + もともと空間相関を持つノイズ(デノイザ適用後の像等)では補正量を過小評価する. + MC レンダの生出力(デノイズ無し)を前提とする. + +検証: + 軸上スイープ(TEST_15 球 / TEST_16 立方体の計 20 条件,分布が同心円対称なので + 輪帯平均が基準として使える)で,本定義は輪帯平均と ±0.01 以内で一致し, + 一様像にノイズだけを載せたときの見かけの不均一(ノイズ床)は ±0.004 だった. + 検証スクリプト: ``scripts/build_fullimage_minmax.py``(定義名 ``smooth_corr``). +""" + +from __future__ import annotations + +import numpy +from scipy.ndimage import gaussian_filter, median_filter + + +def estimate_noise_extremes( + image: numpy.ndarray, + sigma: float, + residual_sigma: float = 4.0, + median_size: int = 5, + n_shuffle: int = 4, + seed: int = 0, +) -> tuple[float, float]: + """ノイズだけの場に σ=``sigma`` の平滑化を掛けたときの (min, max) の期待値を返す. + + 手順: + 1. 残差 $r = I - G_{\\sigma_r}(M_k(I))$ を取る($M_k$ = サイズ $k$ のメディアン + フィルタ,$G_{\\sigma_r}$ = σ=``residual_sigma`` のガウス平滑化). + メディアンで firefly を落としてからガウスで均すことで,残差に構造を残さない. + 2. 残差を空間シャッフルして構造を壊し,σ=``sigma`` のガウス平滑化を掛ける. + 3. その場の min / max を ``n_shuffle`` 回平均する. + + 乱数は ``numpy.random.default_rng(seed)`` をこの関数内で生成するため, + 同じ入力・同じ ``seed`` なら常に同じ値を返す(呼び出し順に依存しない). + + Args: + image: 2D 輝度配列(H x W). + sigma: 本体の平滑化と同じガウス σ(画素単位). + residual_sigma: 残差を取るときのガウス σ(画素単位). + median_size: 残差を取るときのメディアンフィルタのサイズ(画素). + n_shuffle: シャッフルの反復回数(多いほど期待値の推定が安定する). + seed: 乱数シード. + + Returns: + (ノイズ場の最小値の期待値, ノイズ場の最大値の期待値). + それぞれ平滑化後の像の min / max から差し引くための量(min 側は負,max 側は正). + """ + img = numpy.asarray(image, dtype=numpy.float64) + rng = numpy.random.default_rng(seed) + + residual = img - gaussian_filter(median_filter(img, size=median_size), sigma=residual_sigma) + + mins: list[float] = [] + maxs: list[float] = [] + for _ in range(n_shuffle): + flat = residual.ravel().copy() + rng.shuffle(flat) + field = gaussian_filter(flat.reshape(img.shape), sigma=sigma) + mins.append(float(field.min())) + maxs.append(float(field.max())) + + return float(numpy.mean(mins)), float(numpy.mean(maxs)) + + +def calc_fullimage_min_max_ratio( + image: numpy.ndarray, + sigma: float = 12.0, + residual_sigma: float = 4.0, + median_size: int = 5, + n_shuffle: int = 4, + seed: int = 0, +) -> dict[str, float]: + """画像全体で評価する,ノイズ補正付き最小/最大比を算出する. + + $\\mathrm{ratio} = \\dfrac{\\min G_\\sigma(I) - n_{\\min}}{\\max G_\\sigma(I) - n_{\\max}}$ + + ここで $G_\\sigma$ は σ=``sigma`` のガウス平滑化,$(n_{\\min}, n_{\\max})$ は + ``estimate_noise_extremes`` が返すノイズ場の極値の期待値. + 平滑化で MC ノイズを落としつつ方位方向には平均しないので, + オフ軸 LED のような方位依存の不均一も min/max に反映される. + + 検証(``scripts/build_fullimage_minmax.py``): 同心円対称な軸上スイープ 20 条件で + 輪帯平均の min/max と ±0.01 以内で一致し,ノイズ床(一様像 + ノイズでの 1 からのずれ)は + ±0.004.ノイズが空間的に独立であることを仮定する(モジュール docstring 参照). + + Args: + image: 2D 輝度配列(H x W).ROI 抽出後の輝度を渡すこと. + sigma: ガウス平滑化の σ(画素単位).既定 12.0 は 464x450 の ROI で検証した値. + residual_sigma: ノイズ場推定の残差を取るガウス σ(画素単位). + median_size: ノイズ場推定の残差を取るメディアンフィルタのサイズ(画素). + n_shuffle: ノイズ場の極値を平均する回数. + seed: ノイズ場推定の乱数シード(同じ入力・同じ seed なら決定論的). + + Returns: + 以下のキーを持つ辞書: + - ``"fullimage_min_max_ratio"`` (float): 補正後の最小/最大比(1 が理想). + - ``"fullimage_min"`` (float): 補正後の最小値(平滑化後 min − ノイズ場 min). + - ``"fullimage_max"`` (float): 補正後の最大値(平滑化後 max − ノイズ場 max). + - ``"fullimage_noise_min"`` (float): ノイズ場の最小値の期待値. + - ``"fullimage_noise_max"`` (float): ノイズ場の最大値の期待値. + + Raises: + ValueError: image が 2D の数値配列でない場合. + ValueError: sigma が 0 以下の場合. + ValueError: 画像が平滑化に対して小さすぎる場合(min(H, W) < 4 * sigma). + ValueError: 補正後の最大値が 0 以下の場合(画像がノイズだけとみなせる). + """ + img = numpy.asarray(image) + if img.ndim != 2: + msg = f"image は 2D 配列を期待します(ndim={img.ndim})" + raise ValueError(msg) + if not numpy.issubdtype(img.dtype, numpy.number): + msg = f"image は数値配列を期待します(dtype={img.dtype})" + raise ValueError(msg) + img = img.astype(numpy.float64) + + if sigma <= 0.0: + msg = f"sigma は正の値を期待します(sigma={sigma})" + raise ValueError(msg) + + # 平滑化カーネルが画像からはみ出すと端の値が境界条件に支配されるため下限を設ける + min_side = min(img.shape) + if min_side < 4.0 * sigma: + msg = ( + f"画像が平滑化 σ={sigma:g} に対して小さすぎます" + f"(min(H, W)={min_side} < 4σ={4.0 * sigma:g}).sigma を小さくしてください." + ) + raise ValueError(msg) + + smooth = gaussian_filter(img, sigma=sigma) + noise_min, noise_max = estimate_noise_extremes( + img, + sigma=sigma, + residual_sigma=residual_sigma, + median_size=median_size, + n_shuffle=n_shuffle, + seed=seed, + ) + + corrected_min = float(smooth.min()) - noise_min + corrected_max = float(smooth.max()) - noise_max + if corrected_max <= 0.0: + msg = ( + "ノイズ補正後の最大値が 0 以下です" + f"(平滑化後 max={smooth.max():.6g}, ノイズ場 max={noise_max:.6g})." + "画像が実質ノイズのみの可能性があります." + ) + raise ValueError(msg) + + return { + "fullimage_min_max_ratio": corrected_min / corrected_max, + "fullimage_min": corrected_min, + "fullimage_max": corrected_max, + "fullimage_noise_min": noise_min, + "fullimage_noise_max": noise_max, + } diff --git a/src/sm_tias_lightsim/evaluation/pipeline.py b/src/sm_tias_lightsim/evaluation/pipeline.py new file mode 100644 index 0000000..21fa584 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/pipeline.py @@ -0,0 +1,672 @@ +"""評価パイプラインの本体(sm-evaluate の処理を関数として提供する). + +`evaluate_image()` が「画像読込 → 評価設定読込 → ROI 抽出 → 正規化 / OETF → +各種指標 → metrics.csv 保存 → 図保存」の一連の処理を行う.CLI(`cli/evaluate.py`)は +引数のパースと例外→終了コードの変換だけを担い,本モジュールを呼ぶ. + +スイープスクリプトのようにプロセス内から繰り返し評価する用途では +`save_figures=False` を渡すと figures/ の描画(1 条件あたり約 1.8 秒)を丸ごと省ける. + +matplotlib のバックエンドは Agg を強制する(headless 環境対応). +evaluate は Mitsuba を使わないため,render 側と異なり遅延 import は不要. +ただし Agg バックエンドの設定順序を保つため,matplotlib.use() は pyplot import より前に置く. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import matplotlib + +# headless 環境(サーバ・CI 等)でも動くよう Agg バックエンドを強制する. +# visualize.py より先に設定することで,pyplot import 時に Agg が有効になる. +matplotlib.use("Agg") + +import numpy # noqa: E402 +import pandas # noqa: E402 +from PIL import Image # noqa: E402 + +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402 +from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor # noqa: E402 +from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure # noqa: E402 +from sm_tias_lightsim.evaluation.radial import ( # noqa: E402 + calc_radial_min_max_ratio, + calc_radial_profile, +) +from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402 +from sm_tias_lightsim.evaluation.shape_metrics import ( # noqa: E402 + calc_angular_radial_decomposition, + calc_intensity_centroid, + calc_intensity_pca, + calc_ncc, + calc_ssim, +) +from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 + save_histogram, + save_luminance_map, + save_radial_profile, + save_zone_map, +) +from sm_tias_lightsim.evaluation.zones import ( # noqa: E402 + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, +) +from sm_tias_lightsim.scene.schema import ( # noqa: E402 + EvaluationConfig, + MetricsConfig, + NormalizationConfig, + OetfConfig, + ZonesConfig, +) +from sm_tias_lightsim.uniformity import calc_uniformity, to_grayscale # noqa: E402 + +# 出力ファイル名を一元管理する(変更時の影響範囲を最小化するため) +_METRICS_FILENAME = "metrics.csv" +_FIGURES_DIRNAME = "figures" +_LUMINANCE_MAP_FILENAME = "luminance_map.png" +_HISTOGRAM_FILENAME = "histogram.png" +_RADIAL_PROFILE_FILENAME = "radial_profile.png" +_ZONE_MAP_FILENAME = "zone_map.png" + + +class EvaluationStepError(RuntimeError): + """評価パイプラインのどの段階で失敗したかを文言として保持する例外. + + CLI は `print(f"エラー: {exc}")` するだけで従来どおりの段階別メッセージを出せる. + error-handling.md の「独自例外は最小限」に沿って階層は作らず,この 1 クラスのみとする + (段階名を CLI 側で分岐せずにメッセージを保つための最小限の器). + """ + + +@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, +) -> numpy.ndarray: + """OETF 順方向適用:ROI linear luminance を sRGB 符号化空間へ変換する. + + 無彩色シーン(シミュ LED・材料が RGB 等価)では luminance への直接 OETF 適用は + 「チャネル別 sRGB OETF → Rec.709 luma 加重和」と厳密一致する(SPEC_03 参照). + + 露出ソルバは ROI 上で解く(target_mean は実測 ROI mean のため ROI 上で収束させる). + + Args: + roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). + oetf_cfg: OetfConfig(enabled=True が前提). + + Returns: + OETF 適用後の ROI 輝度配列(H' x W', float64, quantize_uint8=True なら [0,255]). + + Raises: + ValueError: oetf_cfg.exposure が None の場合(anchor_mean モードで必須). + """ + if oetf_cfg.exposure is None: + msg = "oetf.enabled=True かつ anchor_mean モードでは exposure 設定が必須です." + raise ValueError(msg) + + roi_lin = roi_luminance.astype(numpy.float64) + target_mean = oetf_cfg.exposure.target_mean + # ROI linear 値で露出スケールを決定(実測 mean が ROI mean のため ROI 上で解く) + scale, clipped = solve_exposure(roi_lin, target_mean) + clip_pct = clipped * 100.0 + print(f" OETF exposure : scale={scale:.6g} clip={clip_pct:.1f}%") + + # ROI に対してのみ OETF を適用する(指標算出は ROI 上で行うため) + return apply_srgb_oetf(roi_lin, scale, quantize_uint8=oetf_cfg.quantize_uint8) + + +def _apply_normalization_to_luminance( + roi_luminance: numpy.ndarray, + norm_cfg: NormalizationConfig, +) -> numpy.ndarray: + """linear mean anchor 正規化を ROI 輝度配列に適用する. + + ROI mean を norm_cfg.target_mean に揃える線形ゲインを適用する. + 形状指標(CoV / 勾配 / C/P 比 / max-min 比)は線形ゲインに不変なので, + 絶対値スケールだけが変わる. + + Args: + roi_luminance: ROI 切り出し済みの linear 輝度配列(H' x W', float64). + norm_cfg: NormalizationConfig(enabled=True が前提). + + Returns: + 正規化後の ROI 輝度配列(H' x W', float64). + + Raises: + ValueError: ROI mean が 0 の場合(apply_mean_anchor から伝播). + """ + roi_lin = roi_luminance.astype(numpy.float64) + target_mean = norm_cfg.target_mean + normalized, alpha = apply_mean_anchor(roi_lin, target_mean) + print(f" normalization : alpha={alpha:.6g} target_mean={target_mean}") + return normalized + + +def _load_luminance(image_path: Path) -> numpy.ndarray: + """画像ファイルを読み込み,輝度配列(2D float64)を返す. + + 拡張子が .npy の場合は numpy.load で読み込む. + それ以外(PNG 等)は PIL で RGB 画像として読み込み,to_grayscale で変換する. + .npy が 3D(H, W, 3)だった場合も to_grayscale を通す. + + Args: + image_path: 読み込む画像ファイルのパス. + + Returns: + 輝度配列(H x W, float64). + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: ファイルを輝度配列に変換できない場合. + """ + if not image_path.exists(): + msg = f"画像ファイルが見つかりません: {image_path}" + raise FileNotFoundError(msg) + + if image_path.suffix.lower() == ".npy": + data = numpy.load(image_path) + if data.ndim == 3: + return to_grayscale(data.astype(numpy.float64)) + if data.ndim == 2: + return data.astype(numpy.float64) + msg = f".npy の次元数が不正です(ndim={data.ndim}).2D または 3D を期待します." + raise ValueError(msg) + + pil_image = Image.open(image_path).convert("RGB") + return to_grayscale(numpy.array(pil_image, dtype=numpy.float64)) + + +def _load_eval_config(config_path: Path) -> EvaluationConfig: + """YAML を読み込んで EvaluationConfig を返す. + + SceneConfig の evaluation サブツリー,または EvaluationConfig 互換の + トップレベル YAML の両方をサポートする. + + Args: + config_path: YAML ファイルのパス. + + Returns: + EvaluationConfig インスタンス. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + pydantic.ValidationError: スキーマ違反. + yaml.YAMLError: YAML 構文エラー. + """ + import yaml + + if not config_path.exists(): + msg = f"設定 YAML ファイルが見つかりません: {config_path}" + raise FileNotFoundError(msg) + + with config_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # "evaluation" キーがあれば SceneConfig 埋め込みとみなしてサブツリーを取得する + if isinstance(data, dict) and "evaluation" in data: + eval_data = data["evaluation"] + else: + eval_data = data + + return EvaluationConfig.model_validate(eval_data) + + +def _run_zone_analysis( + roi_luminance: numpy.ndarray, + zones_config: ZonesConfig | None, +) -> tuple[numpy.ndarray, dict] | tuple[None, dict]: + """ゾーン分析を実行し,zone_map とゾーン統計を返す. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + zones_config: ZonesConfig インスタンス(enabled / boundaries を持つ). + None の場合は無効扱い. + + Returns: + (zone_map, zone_stats) のタプル. + zones が無効なら (None, {}) を返す. + + Raises: + ValueError: ゾーン分析の計算に失敗した場合. + """ + if zones_config is None or not zones_config.enabled: + return None, {} + + h, w = roi_luminance.shape + distance_map = compute_normalized_distance_map(h, w) + zone_map = classify_zones(distance_map, zones_config.boundaries) + zone_stats = calc_zone_stats(roi_luminance, zone_map) + return zone_map, zone_stats + + +def _run_radial_profile( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, +) -> numpy.ndarray | None: + """放射状プロファイルを算出して返す. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(radial_profile フラグを持つ). + None の場合は無効扱い. + + Returns: + 放射状プロファイル配列((n_bins, 2)),無効時は None. + """ + if metrics_config is None or not metrics_config.radial_profile: + return None + return calc_radial_profile(roi_luminance) + + +def _run_radial_min_max( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, + radial_profile: numpy.ndarray | None, +) -> dict: + """動径 min/max 比を算出して返す. + + radial_min_max が True のとき,radial_profile が既に算出されていればそれを再利用し, + 未算出(None)の場合はその場で calc_radial_profile を呼び出して算出する. + 重複算出を避けるため,radial_profile の結果を優先的に使用する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(radial_min_max フラグを持つ). + None の場合は無効扱い. + radial_profile: _run_radial_profile で算出済みのプロファイル配列. + None の場合はここで算出する. + + Returns: + 動径 min/max 比指標の辞書(radial_min_max_ratio / radial_min_distance / + radial_min / radial_max の 4 キー).無効時は空辞書. + """ + if metrics_config is None or not metrics_config.radial_min_max: + return {} + + # radial_profile が未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) + profile = radial_profile if radial_profile is not None else calc_radial_profile(roi_luminance) + return calc_radial_min_max_ratio(profile) + + +def _run_fullimage_min_max( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, +) -> dict: + """画像全体のノイズ補正付き min/max 比を算出して返す. + + 輪帯平均の min/max(`_run_radial_min_max`)は方位方向に平均するため, + オフ軸 LED のような方位依存の不均一を打ち消してしまう.本指標は平滑化で + MC ノイズだけを落とし,残るノイズの寄与を差し引いた min/max を返す. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(fullimage_min_max フラグと + fullimage_sigma を持つ).None の場合は無効扱い. + + Returns: + 画像全体 min/max 比指標の辞書(fullimage_min_max_ratio / fullimage_min / + fullimage_max / fullimage_noise_min / fullimage_noise_max の 5 キー).無効時は空辞書. + """ + if metrics_config is None or not metrics_config.fullimage_min_max: + return {} + + return calc_fullimage_min_max_ratio(roi_luminance, sigma=metrics_config.fullimage_sigma) + + +def _load_reference_luminance(reference_path: Path) -> numpy.ndarray: + """形状指標用の参照画像を読み込み,輝度配列(2D float64)を返す. + + SSIM / NCC の参照画像として使用する._load_luminance と同一の変換ロジックを用い, + .npy 形式(2D/3D)と PNG 形式の両方に対応する. + 呼び出し元(_run_shape_metrics)で shape を確認してから calc_ssim / calc_ncc に渡すこと. + + Args: + reference_path: 参照画像ファイルのパス(.npy または .png). + + Returns: + 輝度配列(H x W, float64). + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: ファイルを輝度配列に変換できない場合. + """ + return _load_luminance(reference_path) + + +def _run_shape_metrics( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, + config_dir: Path, +) -> dict: + """形状指標(輝度重心 / 角度-放射分解 / PCA 主軸 / SSIM / NCC)を算出する. + + metrics.shape_metrics=True のとき,以下の指標を算出して辞書として返す: + - 輝度重心 5 値 (calc_intensity_centroid) + - 角度-放射分解 3 値 (calc_angular_radial_decomposition) + - PCA 主軸 4 値 (calc_intensity_pca) + - SSIM / NCC(shape_reference が指定されたときのみ,それ以外は float("nan")) + + shape_reference パスは config YAML のディレクトリを基準に解決する. + 絶対パスの場合はそのまま使用する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(shape_metrics フラグを持つ). + None の場合は無効扱い. + config_dir: 評価 YAML ファイルのディレクトリ(shape_reference の相対パス解決用). + + Returns: + 形状指標の辞書(12 キー).無効時は空辞書. + SSIM / NCC が計算不能な場合は float("nan") を格納する. + """ + if metrics_config is None or not metrics_config.shape_metrics: + return {} + + result: dict = {} + + # 輝度重心 + centroid = calc_intensity_centroid(roi_luminance) + result["intensity_centroid_x_px"] = centroid["centroid_x_px"] + result["intensity_centroid_y_px"] = centroid["centroid_y_px"] + result["intensity_centroid_x_norm"] = centroid["centroid_x_norm"] + result["intensity_centroid_y_norm"] = centroid["centroid_y_norm"] + result["intensity_centroid_offset_norm"] = centroid["centroid_offset_norm"] + + # 角度-放射分解 + ard = calc_angular_radial_decomposition(roi_luminance) + result["angular_variance"] = ard["angular_variance"] + result["radial_variance"] = ard["radial_variance"] + result["angular_ratio"] = ard["angular_ratio"] + + # PCA 主軸 + pca = calc_intensity_pca(roi_luminance) + result["pca_primary_axis_angle_deg"] = pca["primary_axis_angle_deg"] + result["pca_eccentricity"] = pca["eccentricity"] + + # SSIM / NCC(shape_reference が指定されている場合のみ) + 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 ファイルとして保存する. + + Args: + metrics: 指標名 → 値の辞書. + csv_path: 保存先 CSV パス. + """ + df = pandas.DataFrame([metrics]) + df.to_csv(csv_path, index=False) + + +def _save_figures( + roi_luminance: numpy.ndarray, + figures_dir: Path, + radial_profile: numpy.ndarray | None, + zone_map: numpy.ndarray | None, +) -> None: + """可視化 PNG を figures/ ディレクトリに保存する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + figures_dir: 保存先ディレクトリ(事前に mkdir 済みであること). + radial_profile: 放射状プロファイル配列,None の場合は保存しない. + zone_map: ゾーンマップ配列,None の場合は保存しない. + """ + save_luminance_map(roi_luminance, figures_dir / _LUMINANCE_MAP_FILENAME) + save_histogram(roi_luminance, figures_dir / _HISTOGRAM_FILENAME) + + if radial_profile is not None: + save_radial_profile(radial_profile, figures_dir / _RADIAL_PROFILE_FILENAME) + + if zone_map is not None: + save_zone_map(zone_map, figures_dir / _ZONE_MAP_FILENAME, luminance=roi_luminance) + + +def _print_summary(metrics: dict, csv_path: Path, figures_dir: Path | None) -> None: + """評価サマリーを標準出力に表示する. + + Args: + metrics: 指標名 → 値の辞書. + csv_path: metrics.csv の保存先パス(表示用). + figures_dir: figures/ ディレクトリのパス(表示用). + 図を保存しない場合は None(figures/ の行を出力しない). + """ + print("=== 均一性評価サマリー ===") + print(f" CoV : {metrics['cov']:.6f}") + print(f" std : {metrics['std']:.4f}") + print(f" max/min ratio : {metrics['max_min_ratio']:.4f}") + print(f" mean : {metrics['mean']:.4f}") + print(f" max : {metrics['max']:.4f}") + print(f" min : {metrics['min']:.4f}") + + if "center_periphery_ratio" in metrics: + print(f" C/P ratio : {metrics['center_periphery_ratio']:.4f}") + print(f" gradient [%] : {metrics['gradient_magnitude']:.2f}") + + if "radial_min_max_ratio" in metrics: + print(f" radial min/max : {metrics['radial_min_max_ratio']:.4f}") + print(f" radial min dist: {metrics['radial_min_distance']:.4f}") + + if "fullimage_min_max_ratio" in metrics: + print(f" fullimage min/max: {metrics['fullimage_min_max_ratio']:.4f}") + + if "intensity_centroid_offset_norm" in metrics: + print(f" centroid offset: {metrics['intensity_centroid_offset_norm']:.4f} (norm)") + print( + f" centroid (norm): x={metrics['intensity_centroid_x_norm']:.4f}" + f" y={metrics['intensity_centroid_y_norm']:.4f}" + ) + ang_ratio = metrics.get("angular_ratio", float("nan")) + if ang_ratio != ang_ratio: # NaN check + print(" angular_ratio : NaN (uniform image)") + else: + print(f" angular_ratio : {ang_ratio:.4f} (0=concentric, 1=directional)") + print(f" pca axis [deg] : {metrics['pca_primary_axis_angle_deg']:.1f}") + print(f" pca eccentricit: {metrics['pca_eccentricity']:.4f}") + ssim_val = metrics.get("ssim", float("nan")) + ncc_val = metrics.get("ncc", float("nan")) + if ssim_val == ssim_val: # not NaN + print(f" SSIM : {ssim_val:.4f}") + print(f" NCC : {ncc_val:.4f}") + + print(f"\nmetrics.csv : {csv_path}") + if figures_dir is not None: + print(f"figures/ : {figures_dir}") + + +def evaluate_image( + image_path: Path, + config_path: Path, + output_dir: Path, + save_figures: bool = True, +) -> dict: + """画像 1 枚を評価し,metrics.csv(と図)を出力して指標の辞書を返す. + + 処理フロー: + 1. 画像ファイルを読み込み,輝度配列を得る. + 2. YAML 設定ファイルを読み込み,EvaluationConfig を得る. + 3. 出力ディレクトリ(save_figures のとき figures/ も)を作成する. + 4. extract_roi で ROI を切り出す. + 5. normalization.enabled のとき linear mean anchor 正規化を適用する. + 6. oetf.enabled のとき sRGB OETF を適用する. + 7. calc_uniformity で均一性指標を算出する. + 8. zones.enabled のときゾーン分析を実行する. + 9. metrics.radial_profile のとき放射状プロファイルを算出する. + 10. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用). + 11. metrics.fullimage_min_max のとき画像全体のノイズ補正付き min/max 比を算出する. + 12. metrics.shape_metrics のとき形状指標を算出する. + 13. metrics.csv を保存する. + 14. save_figures のとき可視化 PNG を figures/ に保存する. + 15. 算出指標のサマリーを標準出力に表示する. + + Args: + image_path: 評価対象の画像ファイル(PNG または .npy). + config_path: 評価設定 YAML(SceneConfig または EvaluationConfig 互換). + output_dir: 出力先ディレクトリ(存在しなければ親ごと作成する). + save_figures: False のとき figures/ を作らず図も保存しない + (スイープ等で図が不要なときに描画時間を省くため). + + Returns: + 指標名 → 値の辞書(metrics.csv に書いた内容と同じ). + + Raises: + EvaluationStepError: いずれかの段階で失敗した場合(失敗した段階を示す文言を持つ). + """ + image_path = Path(image_path).resolve() + config_path = Path(config_path).resolve() + output_dir = Path(output_dir).resolve() + + with _step("画像の読み込みに失敗しました", keep_message=(FileNotFoundError,)): + luminance = _load_luminance(image_path) + + with _step("設定 YAML の読み込みに失敗しました", keep_message=(FileNotFoundError,)): + eval_config = _load_eval_config(config_path) + + figures_dir = output_dir / _FIGURES_DIRNAME if save_figures else None + 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) + + with _step("ROI 抽出に失敗しました", (ValueError, NotImplementedError)): + roi_luminance = extract_roi(luminance, eval_config.roi) + + # 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: + with _step("linear 正規化に失敗しました", ValueError): + roi_luminance = _apply_normalization_to_luminance(roi_luminance, norm_cfg) + + # 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: + with _step("OETF 適用に失敗しました", ValueError): + roi_luminance = _apply_oetf_to_luminance(roi_luminance, oetf_cfg) + + with _step("均一性指標の算出に失敗しました", ValueError): + metrics: dict = dict(calc_uniformity(roi_luminance)) + + with _step("ゾーン分析に失敗しました", ValueError): + zone_map, zone_stats = _run_zone_analysis(roi_luminance, eval_config.zones) + metrics.update(zone_stats) + + with _step("放射状プロファイルの算出に失敗しました"): + radial_profile = _run_radial_profile(roi_luminance, eval_config.metrics) + + with _step("動径 min/max 比の算出に失敗しました"): + metrics.update( + _run_radial_min_max(roi_luminance, eval_config.metrics, radial_profile) + ) + + with _step("画像全体 min/max 比の算出に失敗しました"): + metrics.update(_run_fullimage_min_max(roi_luminance, eval_config.metrics)) + + with _step("形状指標の算出に失敗しました"): + metrics.update( + _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent) + ) + + csv_path = output_dir / _METRICS_FILENAME + with _step("metrics.csv の保存に失敗しました"): + _save_metrics_csv(metrics, csv_path) + + if figures_dir is not None: + with _step("可視化 PNG の保存に失敗しました"): + _save_figures(roi_luminance, figures_dir, radial_profile, zone_map) + + _print_summary(metrics, csv_path, figures_dir) + return metrics diff --git a/src/sm_tias_lightsim/render/pipeline.py b/src/sm_tias_lightsim/render/pipeline.py new file mode 100644 index 0000000..2c34542 --- /dev/null +++ b/src/sm_tias_lightsim/render/pipeline.py @@ -0,0 +1,118 @@ +"""レンダパイプラインの本体(sm-render の処理を関数として提供する). + +`render_yaml()` が「シーン YAML 読込 → variant 解決 → レンダ → render.png / +luminance.npy 保存」を行う.CLI(`cli/render.py`)は引数のパースと, +例外 → 「エラー: …」表示 + 終了コード 1 への変換だけを担う. + +スイープスクリプトのように何十条件も回す用途では,サブプロセス(`uv run sm-render`)の +起動と `import mitsuba` が 1 条件あたり 2 秒前後かかるため,本関数をプロセス内で +繰り返し呼ぶことで丸ごと省ける. + +mitsuba に触れる import(runner / builder / schema)はすべて関数内で行う. +モジュールレベルで import すると,llvm_* variant 用の DRJIT_LIBLLVM_PATH 設定より前に +LLVM 初期化が走ってしまうため(cli/render.py と同じ遅延 import 方針). +""" + +from __future__ import annotations + +from pathlib import Path + +# Step 6 の evaluate CLI も同じファイルを読むため,名前を定数として一元管理する +_PNG_FILENAME = "render.png" +_NPY_FILENAME = "luminance.npy" + + +class RenderStepError(RuntimeError): + """レンダパイプラインのどの段階で失敗したかを文言として保持する例外. + + CLI は `print(f"エラー: {exc}")` するだけで従来どおりの段階別メッセージを出せる. + error-handling.md の「独自例外は最小限」に沿って階層は作らず,この 1 クラスのみとする. + """ + + +def render_yaml( + scene_yaml: Path, + output_dir: Path, + variant: str | None = None, +) -> tuple[Path, Path]: + """シーン YAML をレンダリングし,render.png と luminance.npy を出力する. + + 処理フロー: + 1. YAML シーン定義を load_scene_yaml で読み込み,SceneConfig を得る. + 2. variant を解決する(引数 > 環境変数 SM_RENDER_VARIANT > YAML の render.variant). + 3. 出力ディレクトリを作成する(親ごと,既存なら何もしない). + 4. llvm_* のとき DRJIT_LIBLLVM_PATH を設定してから render_scene を実行する. + 5. render.png と luminance.npy を保存する. + + Args: + scene_yaml: シーン定義 YAML ファイルのパス. + output_dir: 出力先ディレクトリ(存在しない場合は親ごと作成する). + variant: Mitsuba variant の明示指定.None なら環境変数 / YAML から解決する. + + Returns: + (render.png のパス, luminance.npy のパス). + + Raises: + RenderStepError: いずれかの段階で失敗した場合(失敗した段階を示す文言を持つ). + """ + scene_path = Path(scene_yaml).resolve() + out_dir = Path(output_dir).resolve() + + try: + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(scene_path) + except FileNotFoundError as exc: + msg = f"シーン YAML ファイルが見つかりません — {exc}" + raise RenderStepError(msg) from exc + except Exception as exc: + msg = f"シーン YAML の読み込みに失敗しました — {exc}" + raise RenderStepError(msg) from exc + + # 同じ YAML を手元(llvm)とサーバ(cuda)で使い回すため,variant だけ実行側から差し替える + # 優先順位: 引数 variant > 環境変数 SM_RENDER_VARIANT > YAML の render.variant + from sm_tias_lightsim.runtime.overrides import resolve_render_variant + + resolved = resolve_render_variant(config.render.variant, variant) + if resolved != config.render.variant: + print(f"variant を上書き: {config.render.variant} → {resolved}") + config = config.model_copy( + update={"render": config.render.model_copy(update={"variant": resolved})} + ) + + try: + out_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + msg = f"出力ディレクトリを作成できません — {exc}" + raise RenderStepError(msg) from exc + + # project_root を scene YAML の親ディレクトリとすることで, + # YAML 内の STL 相対パスが YAML ファイルを基点に解決される + project_root = scene_path.parent + try: + # llvm_* variant の場合,mitsuba の import チェーン(sensors.py / lights.py)が + # 走る前に DRJIT_LIBLLVM_PATH を設定しておく必要がある. + # runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. + if config.render.variant.startswith("llvm_"): + from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path + + ensure_drjit_libllvm_path() + from sm_tias_lightsim.render.runner import render_scene + + hdr_image = render_scene(config, project_root=project_root) + except Exception as exc: + msg = f"レンダリングに失敗しました — {exc}" + raise RenderStepError(msg) from exc + + png_path = out_dir / _PNG_FILENAME + npy_path = out_dir / _NPY_FILENAME + try: + from sm_tias_lightsim.render.output import save_luminance, save_png + + save_png(hdr_image, png_path) + save_luminance(hdr_image, npy_path) + except Exception as exc: + msg = f"出力ファイルの保存に失敗しました — {exc}" + raise RenderStepError(msg) from exc + + return png_path, npy_path diff --git a/src/sm_tias_lightsim/render/runner.py b/src/sm_tias_lightsim/render/runner.py index 49914fc..8eb362d 100644 --- a/src/sm_tias_lightsim/render/runner.py +++ b/src/sm_tias_lightsim/render/runner.py @@ -26,6 +26,70 @@ _current_variant: str | None = None +def ensure_variant(variant: str) -> None: + """Mitsuba の variant をプロセス内で 1 度だけ設定する(2 回目以降は何もしない). + + llvm_* variant 要求時は mi.set_variant 前に DRJIT_LIBLLVM_PATH を自動探索して設定する. + mitsuba は関数内でインポートするため,モジュール import 時点では LLVM 初期化が走らない. + set_variant 成功後に variant 状態を INFO ログで出力する. + 既に別の variant が設定されている場合は警告ログを出して続行する + (Mitsuba は同一プロセスで variant を切り替えられないため). + + レンダ前だけでなく,レイキャストによる検証(遮蔽チェック等)を目的の variant で + 走らせたいときにも先に呼べるよう,render_scene から切り出してある. + + Args: + variant: 設定したい Mitsuba variant(例: "llvm_ad_rgb" / "cuda_ad_rgb"). + + Raises: + ImportError: JIT の初期化に失敗した場合(LLVM-C.dll 未検出等). + """ + global _current_variant # noqa: PLW0603 + + if _current_variant is None: + # llvm_* variant の場合のみ DRJIT_LIBLLVM_PATH を自動探索する + # scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない + # mitsuba の import 前に os.environ['DRJIT_LIBLLVM_PATH'] を設定することが必須. + # mitsuba を先に import すると LLVM 初期化が走り,LLVM-C.dll 未設定で ImportError になる. + if variant.startswith("llvm_"): + ensure_drjit_libllvm_path() + + # mitsuba は DRJIT_LIBLLVM_PATH 設定後にインポートする. + # モジュールレベルで import すると,ensure_drjit_libllvm_path() の前に LLVM 初期化が走り + # ImportError になるため,この位置に遅延 import する. + import mitsuba as mi + + # 共用サーバで LLVM バックエンドが全コアを占有しないよう,SM_DRJIT_THREADS があれば制限する + apply_drjit_thread_count() + + if _current_variant is None: + try: + mi.set_variant(variant) + except ImportError as e: + if variant.startswith("llvm_") and "LLVM" in str(e): + _logger.error( + "Mitsuba variant=%s の JIT 初期化に失敗しました." + "LLVM-C.dll が見つかりません." + "Windows: C:\\Program Files\\LLVM\\bin\\LLVM-C.dll を配置するか," + "DRJIT_LIBLLVM_PATH 環境変数を設定してください." + "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照.", + variant, + ) + raise + + _current_variant = variant + log_variant_status(_logger, variant, mi.variant()) + elif _current_variant != variant: + _logger.warning( + "Mitsuba variant is already set to %r; " + "requested variant %r will be ignored. " + "Restart the process to change the variant.", + _current_variant, + variant, + ) + # 同じ variant が既に設定されている場合は何もしない + + def render_scene(config: SceneConfig, project_root: Path | None = None) -> numpy.ndarray: """SceneConfig からシーンを構築してレンダリングし,HDR 画像(H, W, 3)を返す. @@ -43,57 +107,15 @@ Returns: HDR 画像 (H, W, 3) の float32 配列.値域は物理量(0〜∞ も許容). """ - global _current_variant # noqa: PLW0603 + ensure_variant(config.render.variant) - desired_variant = config.render.variant - - if _current_variant is None: - # llvm_* variant の場合のみ DRJIT_LIBLLVM_PATH を自動探索する - # scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない - # mitsuba の import 前に os.environ['DRJIT_LIBLLVM_PATH'] を設定することが必須. - # mitsuba を先に import すると LLVM 初期化が走り,LLVM-C.dll 未設定で ImportError になる. - if desired_variant.startswith("llvm_"): - ensure_drjit_libllvm_path() - - # mitsuba は DRJIT_LIBLLVM_PATH 設定後にインポートする. - # モジュールレベルで import すると,ensure_drjit_libllvm_path() の前に LLVM 初期化が走り - # ImportError になるため,この位置に遅延 import する. + # ensure_variant で DRJIT_LIBLLVM_PATH 設定済み・variant 設定済みのため,ここで import する import mitsuba as mi - # 共用サーバで LLVM バックエンドが全コアを占有しないよう,SM_DRJIT_THREADS があれば制限する - apply_drjit_thread_count() - - if _current_variant is None: - try: - mi.set_variant(desired_variant) - except ImportError as e: - if desired_variant.startswith("llvm_") and "LLVM" in str(e): - _logger.error( - "Mitsuba variant=%s の JIT 初期化に失敗しました." - "LLVM-C.dll が見つかりません." - "Windows: C:\\Program Files\\LLVM\\bin\\LLVM-C.dll を配置するか," - "DRJIT_LIBLLVM_PATH 環境変数を設定してください." - "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照.", - desired_variant, - ) - raise - - _current_variant = desired_variant - log_variant_status(_logger, desired_variant, mi.variant()) - elif _current_variant != desired_variant: - _logger.warning( - "Mitsuba variant is already set to %r; " - "requested variant %r will be ignored. " - "Restart the process to change the variant.", - _current_variant, - desired_variant, - ) - # 同じ variant が既に設定されている場合は何もしない - scene_dict, tempdir = build_scene(config, project_root=project_root) try: scene = mi.load_dict(scene_dict) - image_tensor = mi.render(scene, spp=config.render.spp) + image_tensor = mi.render(scene, spp=config.render.spp, seed=config.render.seed) arr = numpy.array(image_tensor) finally: shutil.rmtree(tempdir, ignore_errors=True) diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index 6eee4fa..1ff8078 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -98,6 +98,10 @@ ) max_depth: int = Field(default=8, ge=1, description="最大光線追跡深度") spp: int = Field(default=256, ge=1, description="ピクセルあたりのサンプル数") + seed: int = Field( + default=0, ge=0, + description="乱数シード(mi.render の seed).再レンダでノイズの実現を変えるときに使う", + ) # --------------------------------------------------------------------------- @@ -448,6 +452,23 @@ "True のとき radial_profile の有無に関わらず内部で radial_profile を算出する." ), ) + fullimage_min_max: bool = Field( + default=False, + description=( + "画像全体のノイズ補正付き min/max 比(fullimage_min_max_ratio / fullimage_min / " + "fullimage_max / fullimage_noise_min / fullimage_noise_max の 5 列を " + "metrics.csv に追加).輪帯平均と違い方位方向に平均しないため," + "オフ軸 LED のような方位依存の不均一も評価できる." + ), + ) + fullimage_sigma: float = Field( + default=12.0, + gt=0, + description=( + "fullimage_min_max のガウス平滑化 σ(画素単位)." + "既定 12.0 は 464x450 の ROI で検証した値." + ), + ) shape_metrics: bool = Field( default=False, description=( diff --git a/tests/cli/test_evaluate_fullimage.py b/tests/cli/test_evaluate_fullimage.py new file mode 100644 index 0000000..b14debf --- /dev/null +++ b/tests/cli/test_evaluate_fullimage.py @@ -0,0 +1,320 @@ +"""sm-evaluate への画像全体 min/max 比の組み込みの仕様ベーステスト(統合テスト). + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」 + (オプション指標は metrics の bool フラグで有効化し,metrics.csv の末尾に列を追加する. + 既定 False で既存 config は無修正で従来どおり動く) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- evaluation/fullimage.py(指標の定義.5 つの値を返す) +- schema.py MetricsConfig.fullimage_min_max / fullimage_sigma + +検証する仕様: +- metrics.fullimage_min_max: true で metrics.csv に 5 列 + (fullimage_min_max_ratio / fullimage_min / fullimage_max / + fullimage_noise_min / fullimage_noise_max)が追加される +- サマリーに `fullimage min/max: x.xxxx` 行が出る +- フィールドを書かない既存 config では列も出力行も増えない(後方互換) +- metrics.fullimage_sigma を変えると値が変わる +- ROI が平滑化 σ に対して小さすぎる等の不正入力は段階別エラーとして終了コード 1 になる + +テスト方針(.claude/rules/testing.md): +- subprocess ではなく main() / _run_fullimage_min_max() を直接呼ぶ +- ファイル I/O はモックせず tmp_path で実ファイルを使う +- 乱数は seed を固定する +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pandas +import pytest + +from sm_tias_lightsim.cli.evaluate import main +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio +from sm_tias_lightsim.evaluation.pipeline import _run_fullimage_min_max +from sm_tias_lightsim.scene.schema import MetricsConfig + +_FULLIMAGE_COLUMNS = ( + "fullimage_min_max_ratio", + "fullimage_min", + "fullimage_max", + "fullimage_noise_min", + "fullimage_noise_max", +) + +_ROI_SIZE = 160 + + +def _make_luminance(seed: int = 0, size: int = _ROI_SIZE) -> numpy.ndarray: + """左右に勾配のある合成輝度配列(方位依存の不均一 + ノイズ,seed 固定).""" + rng = numpy.random.default_rng(seed) + _, xs = numpy.mgrid[0:size, 0:size] + base = (1.0 - 0.2 * xs / (size - 1)) * 100.0 + return base + rng.normal(0.0, 5.0, base.shape) + + +def _write_npy(tmp_path: Path, seed: int = 0) -> Path: + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, _make_luminance(seed)) + return npy_path + + +def _write_eval_yaml( + tmp_path: Path, + *, + name: str = "eval_fullimage.yaml", + fullimage_lines: str = "", + roi_size: int = _ROI_SIZE, +) -> Path: + """metrics セクションに任意の行を足した評価 YAML を書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: 0 + y: 0 + width: {roi_size} + height: {roi_size} +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true +{fullimage_lines}""" + yaml_path = tmp_path / name + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _run_evaluate(npy_path: Path, config_path: Path, output_dir: Path) -> int: + return main( + [ + "--image", + str(npy_path), + "--config", + str(config_path), + "--output", + str(output_dir), + "--no-figures", + ] + ) + + +# --------------------------------------------------------------------------- +# A. _run_fullimage_min_max(パイプライン段の振る舞い) +# --------------------------------------------------------------------------- + + +def test_run_fullimage_returns_empty_dict_when_metrics_config_is_none(): + """metrics_config が None のとき空辞書を返すこと(無効扱い).""" + assert _run_fullimage_min_max(_make_luminance(), None) == {} + + +@pytest.mark.parametrize( + "metrics_config", + [MetricsConfig(), MetricsConfig(fullimage_min_max=False)], + ids=["default", "explicit_false"], +) +def test_run_fullimage_returns_empty_dict_when_disabled(metrics_config: MetricsConfig): + """fullimage_min_max が False(既定含む)のとき空辞書を返すこと.""" + assert _run_fullimage_min_max(_make_luminance(seed=1), metrics_config) == {} + + +def test_run_fullimage_returns_five_keys_when_enabled(): + """fullimage_min_max=True のとき仕様どおり 5 キーを返すこと.""" + result = _run_fullimage_min_max(_make_luminance(seed=2), MetricsConfig(fullimage_min_max=True)) + + assert set(result) == set(_FULLIMAGE_COLUMNS) + + +def test_run_fullimage_matches_module_function_with_configured_sigma(): + """段の結果が calc_fullimage_min_max_ratio(roi, sigma=fullimage_sigma) と一致すること. + + 仕様: MetricsConfig.fullimage_sigma が平滑化 σ としてそのまま渡される. + """ + roi = _make_luminance(seed=3) + metrics_config = MetricsConfig(fullimage_min_max=True, fullimage_sigma=8.0) + + assert _run_fullimage_min_max(roi, metrics_config) == calc_fullimage_min_max_ratio( + roi, sigma=8.0 + ) + + +def test_run_fullimage_default_sigma_is_used_when_not_specified(): + """fullimage_sigma を指定しないときは既定 12.0 で計算されること.""" + roi = _make_luminance(seed=4) + + assert _run_fullimage_min_max( + roi, MetricsConfig(fullimage_min_max=True) + ) == calc_fullimage_min_max_ratio(roi, sigma=12.0) + + +# --------------------------------------------------------------------------- +# B. CLI 統合(metrics.csv の列) +# --------------------------------------------------------------------------- + + +def test_enabled_config_adds_five_columns_to_metrics_csv(tmp_path: Path): + """metrics.fullimage_min_max: true で metrics.csv に 5 列追加されること.""" + npy_path = _write_npy(tmp_path, seed=10) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + for column in _FULLIMAGE_COLUMNS: + assert column in frame.columns + assert frame[column].dtype.kind == "f" + + +def test_csv_value_matches_direct_calculation(tmp_path: Path): + """CSV の値が ROI 輝度への直接計算と一致すること(ROI 抽出後の輝度で評価される).""" + npy_path = _write_npy(tmp_path, seed=11) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + _run_evaluate(npy_path, config_path, output_dir) + + frame = pandas.read_csv(output_dir / "metrics.csv") + expected = calc_fullimage_min_max_ratio(_make_luminance(seed=11), sigma=12.0) + + assert frame["fullimage_min_max_ratio"].iloc[0] == pytest.approx( + expected["fullimage_min_max_ratio"], rel=1e-9 + ) + + +def test_disabled_by_default_keeps_csv_unchanged(tmp_path: Path): + """フィールドを書かない既存 config では列が増えないこと(後方互換).""" + npy_path = _write_npy(tmp_path, seed=12) + config_path = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + for column in _FULLIMAGE_COLUMNS: + assert column not in frame.columns + # 既存指標は従来どおり出る + assert "cov" in frame.columns + assert "radial_min_max_ratio" in frame.columns + + +def test_explicit_false_keeps_csv_unchanged(tmp_path: Path): + """明示的に false を書いた場合も列が増えないこと.""" + npy_path = _write_npy(tmp_path, seed=13) + config_path = _write_eval_yaml( + tmp_path, name="eval_false.yaml", fullimage_lines=" fullimage_min_max: false\n" + ) + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + assert "fullimage_min_max_ratio" not in frame.columns + + +def test_enabling_fullimage_does_not_change_existing_metric_values(tmp_path: Path): + """新指標の有効化で既存指標の値が変わらないこと(追加のみ).""" + npy_path = _write_npy(tmp_path, seed=14) + legacy_config = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + new_config = _write_eval_yaml( + tmp_path, name="eval_new.yaml", fullimage_lines=" fullimage_min_max: true\n" + ) + + _run_evaluate(npy_path, legacy_config, tmp_path / "legacy") + _run_evaluate(npy_path, new_config, tmp_path / "new") + + legacy = pandas.read_csv(tmp_path / "legacy" / "metrics.csv") + new = pandas.read_csv(tmp_path / "new" / "metrics.csv") + + for column in legacy.columns: + assert new[column].iloc[0] == pytest.approx(legacy[column].iloc[0], rel=1e-9) + + +# --------------------------------------------------------------------------- +# C. CLI 統合(sigma 設定とサマリー表示) +# --------------------------------------------------------------------------- + + +def test_fullimage_sigma_in_yaml_changes_the_value(tmp_path: Path): + """metrics.fullimage_sigma を変えると指標値が変わること(設定が効いている).""" + npy_path = _write_npy(tmp_path, seed=15) + small_sigma = _write_eval_yaml( + tmp_path, + name="eval_sigma4.yaml", + fullimage_lines=" fullimage_min_max: true\n fullimage_sigma: 4.0\n", + ) + large_sigma = _write_eval_yaml( + tmp_path, + name="eval_sigma32.yaml", + fullimage_lines=" fullimage_min_max: true\n fullimage_sigma: 32.0\n", + ) + + _run_evaluate(npy_path, small_sigma, tmp_path / "sigma4") + _run_evaluate(npy_path, large_sigma, tmp_path / "sigma32") + + small = pandas.read_csv(tmp_path / "sigma4" / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + large = pandas.read_csv(tmp_path / "sigma32" / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + + assert small != pytest.approx(large, rel=1e-6) + # 平滑化を強めるほど極値がなまり ratio は 1 に近づく + assert small < large + + +def test_summary_prints_fullimage_line_when_enabled(tmp_path: Path, capsys): + """有効時にサマリーへ `fullimage min/max: x.xxxx` 行が出ること.""" + npy_path = _write_npy(tmp_path, seed=16) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + _run_evaluate(npy_path, config_path, output_dir) + + stdout = capsys.readouterr().out + ratio = pandas.read_csv(output_dir / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + + assert f"fullimage min/max: {ratio:.4f}" in stdout + + +def test_summary_has_no_fullimage_line_when_disabled(tmp_path: Path, capsys): + """無効時はサマリーに fullimage の行が出ないこと(従来どおりの出力).""" + npy_path = _write_npy(tmp_path, seed=17) + config_path = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + + _run_evaluate(npy_path, config_path, tmp_path / "out") + + # 出力パス文字列に "fullimage" が含まれ得るのでサマリー行のラベルで判定する + assert "fullimage min/max" not in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# D. エラー経路 +# --------------------------------------------------------------------------- + + +def test_roi_smaller_than_four_sigma_exits_with_code_one(tmp_path: Path, capsys): + """ROI が平滑化 σ に対して小さすぎる場合,段階別エラーで終了コード 1 になること. + + 仕様: 指標算出の失敗は EvaluationStepError として文言付きで終了コード 1 に変換される + (cli/evaluate.py main). + """ + npy_path = _write_npy(tmp_path, seed=18) + config_path = _write_eval_yaml( + tmp_path, + name="eval_small_roi.yaml", + fullimage_lines=" fullimage_min_max: true\n", + roi_size=32, # 4σ = 48 未満 + ) + + assert _run_evaluate(npy_path, config_path, tmp_path / "out") == 1 + assert "画像全体 min/max 比の算出に失敗しました" in capsys.readouterr().err diff --git a/tests/cli/test_evaluate_no_figures.py b/tests/cli/test_evaluate_no_figures.py new file mode 100644 index 0000000..df6c701 --- /dev/null +++ b/tests/cli/test_evaluate_no_figures.py @@ -0,0 +1,145 @@ +"""sm-evaluate の --no-figures オプションの仕様ベーステスト(統合テスト). + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「出力ファイル仕様」(既定は metrics.csv + figures/ 4 種) +- docs/02_ENV/ENV_04_開発コマンド.md(sm-evaluate は PNG / luminance.npy を評価し + metrics.csv + figures/ を出力する) +- 実装サマリー: --no-figures を付けると figures/ を作らず描画もせず, + サマリーの figures/ 行も出さない(指標だけ欲しいスイープ用) + +検証する仕様: +- --no-figures なしは従来どおり(後方互換): metrics.csv + figures/ 4 種 +- --no-figures ありは metrics.csv のみ.終了コードは 0 +- どちらでも metrics.csv の内容は同一(描画は指標に影響しない) + +テスト方針(testing.md): subprocess ではなく main() を直接呼ぶ.tmp_path で実ファイルを使う. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import main + +_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png") + + +def _make_inputs(tmp_path: Path, seed: int = 0) -> tuple[Path, Path]: + """合成輝度 .npy と評価 YAML を書き出してパスを返す(seed 固定).""" + rng = numpy.random.RandomState(seed) + luminance = numpy.full((128, 128), 100.0) + rng.normal(0, 5, (128, 128)) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = tmp_path / "eval_config.yaml" + config_path.write_text( + """\ +roi: + type: image_pixel + x: 16 + y: 16 + width: 96 + height: 96 +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""", + encoding="utf-8", + ) + return npy_path, config_path + + +def _run(npy: Path, config: Path, out_dir: Path, *extra: str) -> int: + return main( + ["--image", str(npy), "--config", str(config), "--output", str(out_dir), *extra] + ) + + +def test_no_figures_returns_0(tmp_path: Path): + """--no-figures 付きでも正常終了(0)すること.""" + npy, config = _make_inputs(tmp_path, seed=0) + assert _run(npy, config, tmp_path / "out", "--no-figures") == 0 + + +def test_no_figures_creates_metrics_csv(tmp_path: Path): + """--no-figures でも metrics.csv は出力されること.""" + npy, config = _make_inputs(tmp_path, seed=1) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert (out_dir / "metrics.csv").is_file() + + +def test_no_figures_does_not_create_figures_dir(tmp_path: Path): + """--no-figures のとき figures/ ディレクトリが作られないこと.""" + npy, config = _make_inputs(tmp_path, seed=2) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert not (out_dir / "figures").exists() + + +def test_no_figures_writes_no_png(tmp_path: Path): + """--no-figures のとき PNG が 1 つも出力されないこと.""" + npy, config = _make_inputs(tmp_path, seed=3) + out_dir = tmp_path / "out" + _run(npy, config, out_dir, "--no-figures") + assert list(out_dir.rglob("*.png")) == [] + + +def test_no_figures_summary_has_no_figures_line(tmp_path: Path, capsys): + """--no-figures のとき標準出力のサマリーに figures/ 行が出ないこと.""" + npy, config = _make_inputs(tmp_path, seed=4) + _run(npy, config, tmp_path / "out", "--no-figures") + out = capsys.readouterr().out + assert "=== 均一性評価サマリー ===" in out + assert "metrics.csv" in out + assert "figures/" not in out + + +def test_default_still_creates_figures(tmp_path: Path): + """--no-figures を付けなければ従来どおり figures/ 4 種が出ること(後方互換).""" + npy, config = _make_inputs(tmp_path, seed=5) + out_dir = tmp_path / "out" + assert _run(npy, config, out_dir) == 0 + for name in _FIGURE_FILES: + assert (out_dir / "figures" / name).is_file(), name + + +def test_metrics_csv_identical_with_and_without_figures(tmp_path: Path): + """--no-figures の有無で metrics.csv の内容が一致すること.""" + npy, config = _make_inputs(tmp_path, seed=6) + with_dir = tmp_path / "with" + without_dir = tmp_path / "without" + _run(npy, config, with_dir) + _run(npy, config, without_dir, "--no-figures") + assert (with_dir / "metrics.csv").read_text(encoding="utf-8") == ( + without_dir / "metrics.csv" + ).read_text(encoding="utf-8") + + +def test_help_mentions_no_figures(capsys): + """--help に --no-figures が説明されていること.""" + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + assert "--no-figures" in capsys.readouterr().out + + +def test_no_figures_error_path_still_returns_1(tmp_path: Path, capsys): + """--no-figures 付きでも異常時は「エラー: 」を stderr に出して 1 を返すこと.""" + _, config = _make_inputs(tmp_path, seed=7) + result = _run(tmp_path / "missing.npy", config, tmp_path / "out", "--no-figures") + assert result == 1 + assert "エラー: " in capsys.readouterr().err diff --git a/tests/cli/test_evaluate_radial_min_max.py b/tests/cli/test_evaluate_radial_min_max.py index 155b511..fc01ef0 100644 --- a/tests/cli/test_evaluate_radial_min_max.py +++ b/tests/cli/test_evaluate_radial_min_max.py @@ -3,7 +3,8 @@ 仕様出典: - docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン) - docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) -- src/sm_tias_lightsim/cli/evaluate.py(_run_radial_min_max 実装) +- src/sm_tias_lightsim/evaluation/pipeline.py(_run_radial_min_max 実装.cli/evaluate.py は + 引数パースと例外→終了コード変換だけを担い,同名を再エクスポートしている) - src/sm_tias_lightsim/evaluation/radial.py(calc_radial_min_max_ratio 実装) テスト方針: @@ -11,6 +12,8 @@ - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (testing.md) - 乱数を使う場合は seed を必ず固定する (testing.md) - monkeypatch で calc_radial_profile の呼び出し有無を検証する + (差し替え先は実体のある evaluation.pipeline.cli.evaluate の再エクスポートを + 差し替えてもパイプライン内の参照には伝播しない) 注意: matplotlib の Agg バックエンドは evaluate.py 内部で設定される. @@ -219,7 +222,7 @@ metrics_cfg = MetricsConfig(radial_min_max=True) profile = calc_radial_profile(roi_luminance) - with patch("sm_tias_lightsim.cli.evaluate.calc_radial_profile") as mock_calc: + with patch("sm_tias_lightsim.evaluation.pipeline.calc_radial_profile") as mock_calc: _run_radial_min_max(roi_luminance, metrics_cfg, profile) mock_calc.assert_not_called() @@ -258,7 +261,7 @@ real_profile = calc_radial_profile(roi_luminance) with patch( - "sm_tias_lightsim.cli.evaluate.calc_radial_profile", + "sm_tias_lightsim.evaluation.pipeline.calc_radial_profile", return_value=real_profile, ) as mock_calc: result = _run_radial_min_max(roi_luminance, metrics_cfg, None) @@ -592,7 +595,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max", side_effect=RuntimeError("テスト用エラー"), ): result = _run_main(npy_path, config_path, output_dir) @@ -614,7 +617,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max", side_effect=RuntimeError("テスト用エラー"), ): main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]) @@ -637,7 +640,7 @@ output_dir = tmp_path / "output" with patch( - "sm_tias_lightsim.cli.evaluate.calc_radial_min_max_ratio", + "sm_tias_lightsim.evaluation.pipeline.calc_radial_min_max_ratio", side_effect=ValueError("テスト用内部エラー"), ): result = _run_main(npy_path, config_path, output_dir) diff --git a/tests/cli/test_render_jit_init.py b/tests/cli/test_render_jit_init.py index 01e6ce7..57c323e 100644 --- a/tests/cli/test_render_jit_init.py +++ b/tests/cli/test_render_jit_init.py @@ -7,7 +7,8 @@ テスト対象: - E1: cli/render.py がモジュール import の段階で mitsuba を import しないこと - E1 (動作): llvm_* variant 設定時に ensure_drjit_libllvm_path を呼んでから - render_scene を import すること + render_scene を import すること(処理本体が render/pipeline.py の + render_yaml へ移ったため,順序の検証対象もそちら) テスト方針: - AST レベルで import 文の存在をチェック(モジュールレベルの mitsuba import 不在を検証) @@ -24,9 +25,9 @@ # E1: cli/render.py がモジュール import 時点で mitsuba を import しないこと # --------------------------------------------------------------------------- -_CLI_RENDER_PATH = ( - Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" / "cli" / "render.py" -) +_SRC = Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" +_CLI_RENDER_PATH = _SRC / "cli" / "render.py" +_PIPELINE_PATH = _SRC / "render" / "pipeline.py" class TestCliRenderImportOrder: @@ -131,55 +132,107 @@ "仕様: モジュールレベルは標準ライブラリのみ(Mitsuba / 内部モジュールは遅延 import)" ) - def test_ensure_drjit_libllvm_path_is_called_in_main_before_render_scene(self) -> None: - """E1 (動作): main() の llvm_* 分岐で ensure_drjit_libllvm_path を - render_scene より前に呼んでいること(AST による検証). + def test_main_delegates_rendering_to_render_yaml(self) -> None: + """E1: main() は処理本体を render_yaml に委譲していること. 仕様: - runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. - ensure_drjit_libllvm_path は llvm_* variant のとき main() 内で先行呼び出しされる. + cli/render.py は引数のパースと例外 → 終了コード変換だけを担い, + 「YAML 読込 → variant 解決 → レンダ → 保存」は render/pipeline.py が行う. + JIT 初期化(ensure_drjit_libllvm_path)の順序保証もそちらへ移っている. """ src = open(_CLI_RENDER_PATH, encoding="utf-8").read() tree = ast.parse(src) - # main 関数の AST を取得 main_func = next( (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "main"), None, ) assert main_func is not None, "main() 関数が cli/render.py に存在すること" - # main 関数内で ensure_drjit_libllvm_path と render_scene の呼び出し順を確認 - # (AST の lineno ベースで順序を検証) - ensure_calls: list[int] = [] - render_calls: list[int] = [] - - for node in ast.walk(main_func): - if isinstance(node, ast.Call): - # ensure_drjit_libllvm_path の呼び出し - if isinstance(node.func, ast.Name) and node.func.id == "ensure_drjit_libllvm_path": - ensure_calls.append(node.lineno) - elif ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "ensure_drjit_libllvm_path" - ): - ensure_calls.append(node.lineno) - # render_scene の呼び出し - if isinstance(node.func, ast.Name) and node.func.id == "render_scene": - render_calls.append(node.lineno) - elif isinstance(node.func, ast.Attribute) and node.func.attr == "render_scene": - render_calls.append(node.lineno) - - # ensure_drjit_libllvm_path は main() 内に存在すること - assert ensure_calls, ( - "main() 内に ensure_drjit_libllvm_path の呼び出しが存在すること\n" - "仕様: llvm_* variant のとき render_scene より前に呼ぶ" + called = { + node.func.id + for node in ast.walk(main_func) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert "render_yaml" in called, ( + "main() は render_yaml を呼ぶこと(レンダ処理は render/pipeline.py に集約する)" ) - # render_scene は main() 内に存在すること - assert render_calls, "main() 内に render_scene の呼び出しが存在すること" - # ensure が render より前の行にあること +class TestRenderPipelineImportOrder: + """E1: render/pipeline.py の import 順が仕様通りであることを検証する. + + 仕様: + render_yaml() は llvm_* variant のとき ensure_drjit_libllvm_path を + render_scene(= mitsuba import chain)より前に呼ぶ. + そのためにモジュールレベルでは mitsuba 依存モジュールを import しない. + """ + + def test_no_top_level_mitsuba_or_runner_import_in_pipeline(self) -> None: + """E1: render/pipeline.py のモジュールレベルに mitsuba / runner の import が無いこと. + + 仕様: + モジュールレベルで import すると DRJIT_LIBLLVM_PATH 設定より前に + LLVM 初期化が走ってしまう(cli/render.py と同じ遅延 import 方針). + """ + src = open(_PIPELINE_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + offenders: list[str] = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("mitsuba"): + offenders.append(alias.name) + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith("mitsuba") or "runner" in node.module: + offenders.append(node.module) + + assert not offenders, ( + f"render/pipeline.py のモジュールレベルに遅延すべき import がある: {offenders}." + "仕様: mitsuba 依存 import はすべて render_yaml() 内で行う" + ) + + def test_ensure_drjit_libllvm_path_is_called_before_render_scene(self) -> None: + """E1 (動作): render_yaml() の llvm_* 分岐で ensure_drjit_libllvm_path を + render_scene より前に呼んでいること(AST による検証). + + 仕様: + runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ. + """ + src = open(_PIPELINE_PATH, encoding="utf-8").read() + tree = ast.parse(src) + + func = next( + ( + n + for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name == "render_yaml" + ), + None, + ) + assert func is not None, "render_yaml() 関数が render/pipeline.py に存在すること" + + ensure_calls: list[int] = [] + render_calls: list[int] = [] + for node in ast.walk(func): + if not isinstance(node, ast.Call): + continue + name = None + if isinstance(node.func, ast.Name): + name = node.func.id + elif isinstance(node.func, ast.Attribute): + name = node.func.attr + if name == "ensure_drjit_libllvm_path": + ensure_calls.append(node.lineno) + elif name == "render_scene": + render_calls.append(node.lineno) + + assert ensure_calls, ( + "render_yaml() 内に ensure_drjit_libllvm_path の呼び出しが存在すること." + "仕様: llvm_* variant のとき render_scene より前に呼ぶ" + ) + assert render_calls, "render_yaml() 内に render_scene の呼び出しが存在すること" assert min(ensure_calls) < min(render_calls), ( f"ensure_drjit_libllvm_path (line {min(ensure_calls)}) は " f"render_scene (line {min(render_calls)}) より前に呼ばれること" diff --git a/tests/evaluation/test_fullimage.py b/tests/evaluation/test_fullimage.py new file mode 100644 index 0000000..2261041 --- /dev/null +++ b/tests/evaluation/test_fullimage.py @@ -0,0 +1,492 @@ +"""evaluation/fullimage.py(画像全体のノイズ補正付き min/max 比)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」 + (輪帯平均 min/max は動径プロファイル 20 ビンの最小/最大比.画素単位 max/min より + ノイズにロバストだが,方位方向に平均するので方位依存の不均一は測れない) +- docs/06_TEST/TEST_18_照明配置依存性の球と立方体の比較.md(オフ軸 LED の非対称な分布を + 定量する必要性) +- 新指標の定義と検証条件(evaluation/fullimage.py モジュール docstring): + ratio = (min G_sigma(I) - n_min) / (max G_sigma(I) - n_max) + * G_sigma = ガウス平滑化,(n_min, n_max) = 同じ sigma で平滑化したノイズ場の極値の期待値 + * 同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致する + * 一様像 + ノイズでの見かけの不均一(ノイズ床)は 1 から ±0.004 程度 + * ノイズが空間的に独立であることを仮定する + +テスト方針(.claude/rules/testing.md): +- 核計算ロジックなので合成画像(既知の真値)で単体テストする.output/ の実データには依存しない +- 乱数は必ず seed を固定する +- 期待値は実装ではなく上記の定義から導く + +注記(定義そのものが持つバイアス): + ガウス平滑化は sigma スケールの構造をなまし,境界では折り返すため,平滑化後の min/max は + 「平滑化しない真値」より必ず中央寄り(= ratio は真値より大きめ)になる. + 256x256・sigma=12 の線形勾配では +0.013 程度.したがって真値との比較はこの構造バイアスを + 見込んだ許容差で行い,ノイズに対する頑健性(ノイズ有無での差)はノイズ床の許容差で厳しく見る. +""" + +from __future__ import annotations + +import numpy +import pytest +from scipy.ndimage import gaussian_filter + +from sm_tias_lightsim.evaluation.fullimage import ( + calc_fullimage_min_max_ratio, + estimate_noise_extremes, +) +from sm_tias_lightsim.evaluation.radial import calc_radial_min_max_ratio, calc_radial_profile + +# 仕様上の許容差 +_NOISE_FLOOR_TOL = 0.01 # ノイズ有無での ratio の差(仕様のノイズ床 ±0.004 に余裕を持たせた値) +_ANNULUS_AGREEMENT_TOL = 0.01 # 同心円対称な分布での輪帯平均との一致(仕様値) +_SMOOTHING_BIAS_TOL = 0.02 # 平滑化による構造バイアスを見込んだ真値との許容差 + +_SIZE = 256 +_MEAN_LEVEL = 100.0 + + +def _uniform(size: int = _SIZE, level: float = _MEAN_LEVEL) -> numpy.ndarray: + """一様な輝度配列(真の min/max 比 = 1.0).""" + return numpy.full((size, size), level, dtype=numpy.float64) + + +def _concentric_bump(size: int = _SIZE) -> numpy.ndarray: + """中心 1.0・周辺 0.85 の同心円対称なガウス山(輪帯平均が基準として使える分布).""" + ys, xs = numpy.mgrid[0:size, 0:size] + center = (size - 1) / 2.0 + d2 = ((xs - center) ** 2 + (ys - center) ** 2) / (center**2) + return (0.85 + 0.15 * numpy.exp(-1.5 * d2)) * _MEAN_LEVEL + + +def _linear_gradient(size: int = _SIZE, low: float = 0.8) -> numpy.ndarray: + """左 1.0 → 右 low の線形勾配(方位依存=同心円対称でない分布).真の min/max 比 = low.""" + _, xs = numpy.mgrid[0:size, 0:size] + return (1.0 - (1.0 - low) * xs / (size - 1)) * _MEAN_LEVEL + + +def _add_white_noise(base: numpy.ndarray, relative_sigma: float, seed: int) -> numpy.ndarray: + """平均に対する相対 sigma の白色ガウスノイズを載せる(画素間で独立=仕様の前提).""" + rng = numpy.random.default_rng(seed) + return base + rng.normal(0.0, relative_sigma * float(base.mean()), base.shape) + + +def _ratio(image: numpy.ndarray, **kwargs) -> float: + return calc_fullimage_min_max_ratio(image, **kwargs)["fullimage_min_max_ratio"] + + +def _pixel_ratio(image: numpy.ndarray) -> float: + return float(image.min()) / float(image.max()) + + +def _annulus_ratio(image: numpy.ndarray) -> float: + """輪帯平均(動径プロファイル 20 ビン)の min/max 比(SPEC_04 の既存指標).""" + return calc_radial_min_max_ratio(calc_radial_profile(image))["radial_min_max_ratio"] + + +# --------------------------------------------------------------------------- +# A. 一様分布とノイズ床 +# --------------------------------------------------------------------------- + + +def test_uniform_image_without_noise_gives_ratio_exactly_one(): + """ノイズの無い一様像では ratio が厳密に 1.0 になること. + + 仕様: 一様像は min = max なので不均一ゼロ(ratio = 1.0). + 残差も 0 なのでノイズ補正量も 0 になる. + """ + result = calc_fullimage_min_max_ratio(_uniform()) + + assert result["fullimage_min_max_ratio"] == pytest.approx(1.0, abs=1e-12) + assert result["fullimage_min"] == pytest.approx(result["fullimage_max"], rel=1e-12) + assert result["fullimage_noise_min"] == pytest.approx(0.0, abs=1e-9) + assert result["fullimage_noise_max"] == pytest.approx(0.0, abs=1e-9) + + +@pytest.mark.parametrize("seed", [0, 1, 2]) +@pytest.mark.parametrize("relative_sigma", [0.05, 0.10]) +def test_uniform_image_with_noise_stays_near_one(relative_sigma: float, seed: int): + """一様像 + 白色ガウスノイズでも ratio が 1 の近傍に留まること(ノイズ床). + + 仕様: 一様像にノイズだけを載せたときの見かけの不均一(ノイズ床)は小さい + (fullimage.py docstring: ±0.004.ここでは合成ノイズのばらつきを見込み ±0.01 で検証). + """ + image = _add_white_noise(_uniform(), relative_sigma, seed) + + assert abs(1.0 - _ratio(image)) < _NOISE_FLOOR_TOL + + +def test_uniform_image_with_noise_beats_pixel_min_max(): + """一様像 + ノイズで,画素単位 min/max 比より遥かに 1 に近いこと(本指標の存在意義). + + 仕様: 画素単位の min/max は MC ノイズの裾を拾って不均一を過大評価する + (SPEC_04「max/min 比 … 外れ値に敏感(MC ノイズ感度高)」). + """ + image = _add_white_noise(_uniform(), 0.10, seed=3) + + corrected_error = abs(1.0 - _ratio(image)) + pixel_error = abs(1.0 - _pixel_ratio(image)) + + assert corrected_error < _NOISE_FLOOR_TOL + assert pixel_error > 0.1 + assert corrected_error < pixel_error + + +def test_noise_floor_grows_with_noise_level(): + """ノイズ床がノイズ量とともに増える(補正が効いてもゼロにはならない)こと. + + 仕様: 補正はノイズ場の極値の「期待値」を差し引くので残差は残る. + ノイズが小さいほど 1 に近い. + """ + low = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.02, seed=4))) + high = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.20, seed=4))) + + assert low < high + + +# --------------------------------------------------------------------------- +# B. 同心円対称な分布 — 輪帯平均との一致(仕様の検証根拠) +# --------------------------------------------------------------------------- + + +def test_concentric_bump_agrees_with_annulus_average_without_noise(): + """同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致すること. + + 仕様: fullimage.py「同心円対称な軸上スイープ 20 条件で輪帯平均の min/max と + ±0.01 以内で一致」.輪帯平均は同心円分布では正しい基準になる. + """ + bump = _concentric_bump() + + assert abs(_ratio(bump) - _annulus_ratio(bump)) < _ANNULUS_AGREEMENT_TOL + + +def test_concentric_bump_agrees_with_annulus_average_with_noise(): + """同心円対称な分布 + MC 相当のノイズでも輪帯平均と ±0.01 以内で一致すること. + + 仕様: 上記と同じ.レンダ画像相当の弱いノイズ(相対 2%)を載せて確認する. + """ + noisy = _add_white_noise(_concentric_bump(), 0.02, seed=5) + + assert abs(_ratio(noisy) - _annulus_ratio(noisy)) < _ANNULUS_AGREEMENT_TOL + + +@pytest.mark.parametrize("seed", [6, 7]) +def test_concentric_bump_ratio_is_robust_to_noise(seed: int): + """同じ分布ならノイズの有無で ratio がほとんど変わらないこと(ノイズ頑健性). + + 仕様: 平滑化で MC ノイズを落とし,残るノイズの寄与を差し引く定義なので, + ノイズを載せても指標値は動かないはず(ノイズ床の範囲内). + """ + bump = _concentric_bump() + noisy = _add_white_noise(bump, 0.05, seed) + + assert abs(_ratio(noisy) - _ratio(bump)) < _NOISE_FLOOR_TOL + + +def test_concentric_bump_ratio_is_closer_to_truth_than_pixel_min_max(): + """同心円分布 + ノイズで,画素単位 min/max より真値に近いこと. + + 仕様: 画素単位はノイズの裾を拾うため真値から大きく外れる. + 真値 = ノイズを載せる前の分布の min/max 比. + """ + bump = _concentric_bump() + true_ratio = float(bump.min()) / float(bump.max()) + noisy = _add_white_noise(bump, 0.10, seed=8) + + assert abs(_ratio(noisy) - true_ratio) < _SMOOTHING_BIAS_TOL + assert abs(_pixel_ratio(noisy) - true_ratio) > abs(_ratio(noisy) - true_ratio) + + +# --------------------------------------------------------------------------- +# C. 非対称な分布 — 輪帯平均が測れない不均一を測る(新指標の動機) +# --------------------------------------------------------------------------- + + +def test_linear_gradient_ratio_tracks_true_value_without_noise(): + """左右の線形勾配(真値 0.8)を平滑化バイアスの範囲で再現すること. + + 仕様: 画像全体の min/max を使うので,方位依存の不均一もそのまま反映される. + 平滑化と境界の折り返しにより値は真値よりわずかに大きくなる(モジュール注記). + """ + gradient = _linear_gradient(low=0.8) + ratio = _ratio(gradient) + + assert ratio == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL) + assert ratio >= 0.8 # 平滑化バイアスは必ず 1 に近づく向き + + +@pytest.mark.parametrize("seed", [9, 10]) +def test_linear_gradient_with_noise_tracks_true_value(seed: int): + """線形勾配 + ノイズでも真値 0.8 を平滑化バイアスの範囲で再現すること.""" + noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed) + + assert _ratio(noisy) == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL) + + +def test_annulus_average_overestimates_uniformity_for_asymmetric_distribution(): + """輪帯平均は非対称な分布の不均一を過小評価(= 均一性を過大評価)すること. + + 仕様(新指標の動機): 輪帯平均は方位方向に平均するため,左右勾配のような + 方位依存の不均一を打ち消してしまう(SPEC_04 の動径 min/max は同心円分布向け). + 本指標は真値に近く,輪帯平均は 1 に張り付く. + """ + noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=11) + true_ratio = 0.8 + + fullimage_error = abs(_ratio(noisy) - true_ratio) + annulus_error = abs(_annulus_ratio(noisy) - true_ratio) + + assert fullimage_error < _SMOOTHING_BIAS_TOL + assert _annulus_ratio(noisy) > 0.95 # 輪帯平均は「ほぼ均一」と誤判定する + assert annulus_error > 5.0 * fullimage_error + + +def test_asymmetric_distribution_is_ranked_less_uniform_than_uniform_image(): + """非対称な分布は一様像より明確に低い ratio になること(順序の妥当性).""" + uniform_ratio = _ratio(_add_white_noise(_uniform(), 0.10, seed=12)) + gradient_ratio = _ratio(_add_white_noise(_linear_gradient(low=0.8), 0.10, seed=12)) + + assert gradient_ratio < uniform_ratio - 0.1 + + +# --------------------------------------------------------------------------- +# D. 入力バリデーション(すべて ValueError: .claude/rules/error-handling.md) +# --------------------------------------------------------------------------- + + +def test_three_dimensional_image_raises_value_error(): + """3D 配列(RGB 画像等)は ValueError になること. + + 仕様: image は 2D 輝度配列(ROI 抽出後の輝度)を渡す. + """ + with pytest.raises(ValueError, match="2D"): + calc_fullimage_min_max_ratio(numpy.zeros((64, 64, 3)) + 1.0) + + +def test_one_dimensional_image_raises_value_error(): + """1D 配列も 2D でないので ValueError になること.""" + with pytest.raises(ValueError, match="2D"): + calc_fullimage_min_max_ratio(numpy.ones(1024)) + + +@pytest.mark.parametrize( + "array", + [ + numpy.full((128, 128), "a"), + numpy.ones((128, 128), dtype=bool), + numpy.full((128, 128), None, dtype=object), + ], + ids=["str", "bool", "object"], +) +def test_non_numeric_dtype_raises_value_error(array: numpy.ndarray): + """数値でない dtype は ValueError になること.""" + with pytest.raises(ValueError, match="数値"): + calc_fullimage_min_max_ratio(array) + + +@pytest.mark.parametrize("sigma", [0.0, -1.0, -12.0]) +def test_non_positive_sigma_raises_value_error(sigma: float): + """sigma が 0 以下は ValueError になること(平滑化が定義されない).""" + with pytest.raises(ValueError, match="sigma"): + calc_fullimage_min_max_ratio(_uniform(), sigma=sigma) + + +def test_image_smaller_than_four_sigma_raises_value_error(): + """min(H, W) < 4σ の小さい画像は ValueError になること. + + 仕様: 平滑化カーネルが画像からはみ出すと端の値が境界条件に支配されるため下限を設ける. + """ + small = numpy.full((47, 256), _MEAN_LEVEL) + + with pytest.raises(ValueError, match="小さすぎます"): + calc_fullimage_min_max_ratio(small, sigma=12.0) + + +def test_image_exactly_four_sigma_is_accepted(): + """min(H, W) == 4σ はちょうど下限で受理されること(境界値).""" + boundary = numpy.full((48, 256), _MEAN_LEVEL) + + assert _ratio(boundary, sigma=12.0) == pytest.approx(1.0, abs=1e-12) + + +def test_all_zero_image_raises_value_error(): + """全ゼロ画像(補正後の最大値が 0 以下)は ValueError になること.""" + with pytest.raises(ValueError, match="ノイズ補正後の最大値"): + calc_fullimage_min_max_ratio(numpy.zeros((_SIZE, _SIZE))) + + +def test_integer_image_is_accepted(): + """整数 dtype の輝度配列も受理されること(内部で float64 に変換).""" + integer_image = numpy.full((_SIZE, _SIZE), 100, dtype=numpy.int32) + + assert _ratio(integer_image) == pytest.approx(1.0, abs=1e-12) + + +# --------------------------------------------------------------------------- +# E. 決定論性と seed +# --------------------------------------------------------------------------- + + +def test_same_input_and_seed_gives_bitwise_identical_result(): + """同じ入力・同じ seed なら完全に同じ値を返すこと(決定論的). + + 仕様: 乱数は関数内で default_rng(seed) を生成するため呼び出し順に依存しない. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=13) + + assert calc_fullimage_min_max_ratio(image) == calc_fullimage_min_max_ratio(image) + + +def test_result_does_not_depend_on_call_order(): + """間に別の画像の計算を挟んでも結果が変わらないこと(グローバル乱数に依存しない).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=14) + first = calc_fullimage_min_max_ratio(image) + + calc_fullimage_min_max_ratio(_add_white_noise(_linear_gradient(), 0.2, seed=15)) + numpy.random.seed(12345) # グローバル乱数状態を動かしても影響しないこと + + assert calc_fullimage_min_max_ratio(image) == first + + +def test_changing_seed_changes_value_only_within_noise_floor(): + """seed を変えるとノイズ場推定が変わるが,差はノイズ床以内であること.""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=16) + + base = _ratio(image, seed=0) + other = _ratio(image, seed=17) + + assert base != other + assert abs(base - other) < _NOISE_FLOOR_TOL + + +def test_estimate_noise_extremes_is_deterministic_for_fixed_seed(): + """estimate_noise_extremes が同じ入力・同じ seed で同じ値を返すこと.""" + image = _add_white_noise(_uniform(), 0.10, seed=18) + + assert estimate_noise_extremes(image, sigma=12.0) == estimate_noise_extremes(image, sigma=12.0) + + +def test_estimate_noise_extremes_brackets_zero_for_noisy_image(): + """ノイズ場の極値が min < 0 < max であること(補正量の符号). + + 仕様: min 側は負,max 側は正の補正量として返す. + """ + image = _add_white_noise(_uniform(), 0.10, seed=19) + + noise_min, noise_max = estimate_noise_extremes(image, sigma=12.0) + + assert noise_min < 0.0 < noise_max + + +def test_estimate_noise_extremes_is_zero_for_noiseless_uniform_image(): + """ノイズの無い一様像ではノイズ場の極値が 0 になること(残差が 0).""" + noise_min, noise_max = estimate_noise_extremes(_uniform(), sigma=12.0) + + assert noise_min == pytest.approx(0.0, abs=1e-9) + assert noise_max == pytest.approx(0.0, abs=1e-9) + + +def test_estimate_noise_extremes_grows_with_noise_level(): + """ノイズが大きいほどノイズ場の極値の絶対値が大きいこと.""" + weak = estimate_noise_extremes(_add_white_noise(_uniform(), 0.05, seed=20), sigma=12.0) + strong = estimate_noise_extremes(_add_white_noise(_uniform(), 0.20, seed=20), sigma=12.0) + + assert abs(weak[0]) < abs(strong[0]) + assert abs(weak[1]) < abs(strong[1]) + + +# --------------------------------------------------------------------------- +# F. 戻り値の整合とスケール不変性 +# --------------------------------------------------------------------------- + + +def test_returns_exactly_five_documented_keys(): + """戻り値が仕様どおりの 5 キーだけを持つこと(metrics.csv の 5 列に対応).""" + result = calc_fullimage_min_max_ratio(_add_white_noise(_concentric_bump(), 0.10, seed=21)) + + assert set(result) == { + "fullimage_min_max_ratio", + "fullimage_min", + "fullimage_max", + "fullimage_noise_min", + "fullimage_noise_max", + } + assert all(isinstance(value, float) for value in result.values()) + + +def test_returned_values_satisfy_definition(): + """戻り値が定義式どおりであること. + + 仕様: min = min G_sigma(I) - n_min,max = max G_sigma(I) - n_max,ratio = min / max. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=22) + sigma = 12.0 + + result = calc_fullimage_min_max_ratio(image, sigma=sigma) + smooth = gaussian_filter(image, sigma=sigma) + + assert result["fullimage_min"] == pytest.approx( + float(smooth.min()) - result["fullimage_noise_min"], rel=1e-12 + ) + assert result["fullimage_max"] == pytest.approx( + float(smooth.max()) - result["fullimage_noise_max"], rel=1e-12 + ) + assert result["fullimage_min_max_ratio"] == pytest.approx( + result["fullimage_min"] / result["fullimage_max"], rel=1e-12 + ) + assert result["fullimage_min"] <= result["fullimage_max"] + + +@pytest.mark.parametrize("gain", [0.25, 3.7, 1000.0]) +def test_ratio_is_invariant_under_linear_gain(gain: float): + """輝度を定数倍しても ratio が不変であること(露出・ゲインに依存しない指標). + + 仕様: min も max もノイズ補正量も線形量なので比は変わらない. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=23) + + base = calc_fullimage_min_max_ratio(image) + scaled = calc_fullimage_min_max_ratio(image * gain) + + assert scaled["fullimage_min_max_ratio"] == pytest.approx( + base["fullimage_min_max_ratio"], rel=1e-9 + ) + assert scaled["fullimage_min"] == pytest.approx(base["fullimage_min"] * gain, rel=1e-9) + assert scaled["fullimage_max"] == pytest.approx(base["fullimage_max"] * gain, rel=1e-9) + assert scaled["fullimage_noise_min"] == pytest.approx( + base["fullimage_noise_min"] * gain, rel=1e-9 + ) + assert scaled["fullimage_noise_max"] == pytest.approx( + base["fullimage_noise_max"] * gain, rel=1e-9 + ) + + +def test_default_sigma_is_twelve(): + """既定の sigma が 12.0 であること(MetricsConfig.fullimage_sigma の既定と一致).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=24) + + assert _ratio(image) == pytest.approx(_ratio(image, sigma=12.0), rel=1e-12) + + +def test_larger_sigma_smooths_more_and_raises_ratio(): + """sigma を大きくすると平滑化が強まり ratio が 1 に近づくこと(パラメータの効き).""" + image = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=25) + + assert _ratio(image, sigma=4.0) < _ratio(image, sigma=32.0) + + +def test_more_shuffles_do_not_change_result_beyond_noise_floor(): + """n_shuffle を増やしてもノイズ床を超えて値が動かないこと(期待値の推定回数).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=26) + + assert abs(_ratio(image, n_shuffle=1) - _ratio(image, n_shuffle=8)) < _NOISE_FLOOR_TOL + + +def test_input_image_is_not_modified(): + """入力配列が変更されないこと(純関数).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=27) + snapshot = image.copy() + + calc_fullimage_min_max_ratio(image) + + assert numpy.array_equal(image, snapshot) diff --git a/tests/evaluation/test_pipeline.py b/tests/evaluation/test_pipeline.py new file mode 100644 index 0000000..89cdee2 --- /dev/null +++ b/tests/evaluation/test_pipeline.py @@ -0,0 +1,296 @@ +"""evaluation/pipeline.py(evaluate_image)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md + 「出力ファイル仕様」: /metrics.csv と /figures/{luminance_map, histogram, + radial_profile, zone_map}.png + 「metrics.csv カラム定義」: cov, std, max_min_ratio, mean, max, min, + center_*, middle_*, periphery_*, center_periphery_ratio, gradient_magnitude +- docs/04_SPEC/SPEC_02_シーン定義仕様.md「evaluation」セクション(ROI / zones / metrics) +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプラインの責務分離) + +検証する仕様: +- evaluate_image() が CLI と同じ評価を行い,指標の辞書を返す +- save_figures=False のとき figures/ を作らず描画もしない(指標は変わらない) +- 段階ごとの失敗は EvaluationStepError として,どの段階かが分かる文言で送出される + (CLI はこれを「エラー: …」+ 終了コード 1 に変換するだけ) + +テスト方針(testing.md): +- ファイル I/O はモックせず tmp_path で実ファイルを使う +- 合成輝度マップは seed を固定する +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.pipeline import EvaluationStepError, evaluate_image + +# SPEC_04「metrics.csv カラム定義(ヘッダ順)」の必須列 +_BASE_COLUMNS = ("cov", "std", "max_min_ratio", "mean", "max", "min") +_ZONE_COLUMNS = ( + "center_mean", + "center_std", + "middle_mean", + "middle_std", + "periphery_mean", + "periphery_std", + "center_periphery_ratio", + "gradient_magnitude", +) +# SPEC_04「可視化規約」の 4 種 +_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png") + + +def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定).""" + rng = numpy.random.RandomState(seed) + return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size)) + + +def _write_npy(tmp_path: Path, seed: int = 0, size: int = 128) -> Path: + path = tmp_path / "luminance.npy" + numpy.save(path, _make_luminance_array(seed=seed, size=size)) + return path + + +def _write_eval_yaml( + tmp_path: Path, + x: int = 16, + y: int = 16, + width: int = 96, + height: int = 96, + zones_enabled: bool = True, + radial_profile: bool = True, +) -> Path: + """評価専用 YAML(トップレベルが EvaluationConfig 互換)を書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: {"true" if zones_enabled else "false"} + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {"true" if radial_profile else "false"} +""" + path = tmp_path / "eval_config.yaml" + path.write_text(content, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# 正常系: 既定(save_figures=True) +# --------------------------------------------------------------------------- + + +def test_returns_metrics_dict(tmp_path: Path): + """evaluate_image が指標の辞書を返すこと. + + 仕様: SPEC_04「metrics.csv カラム定義」の基本 6 指標を含む. + """ + metrics = evaluate_image( + _write_npy(tmp_path, seed=0), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + assert isinstance(metrics, dict) + for key in _BASE_COLUMNS: + assert key in metrics, key + assert isinstance(metrics[key], float) + + +def test_returned_metrics_are_physically_consistent(tmp_path: Path): + """返された指標が互いに整合すること(min <= mean <= max・CoV = std/mean).""" + metrics = evaluate_image( + _write_npy(tmp_path, seed=1), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + assert metrics["min"] <= metrics["mean"] <= metrics["max"] + assert metrics["cov"] == pytest.approx(metrics["std"] / metrics["mean"], rel=1e-9) + assert metrics["max_min_ratio"] == pytest.approx(metrics["max"] / metrics["min"], rel=1e-9) + + +def test_zone_metrics_included_when_zones_enabled(tmp_path: Path): + """zones.enabled のときゾーン指標が辞書に含まれること(SPEC_04 3 ゾーン分析).""" + metrics = evaluate_image( + _write_npy(tmp_path, seed=2), _write_eval_yaml(tmp_path), tmp_path / "out" + ) + for key in _ZONE_COLUMNS: + assert key in metrics, key + + +def test_creates_metrics_csv(tmp_path: Path): + """metrics.csv が出力ディレクトリ直下に作られること(SPEC_04 出力ファイル仕様).""" + out_dir = tmp_path / "out" + evaluate_image(_write_npy(tmp_path, seed=3), _write_eval_yaml(tmp_path), out_dir) + assert (out_dir / "metrics.csv").is_file() + + +def test_metrics_csv_matches_returned_dict(tmp_path: Path): + """metrics.csv の内容が戻り値の辞書と一致すること.""" + import csv + + out_dir = tmp_path / "out" + metrics = evaluate_image(_write_npy(tmp_path, seed=4), _write_eval_yaml(tmp_path), out_dir) + with (out_dir / "metrics.csv").open(encoding="utf-8") as fh: + row = next(csv.DictReader(fh)) + assert set(row) == set(metrics) + for key, value in row.items(): + assert float(value) == pytest.approx(metrics[key], rel=1e-6) + + +def test_creates_four_figures_by_default(tmp_path: Path): + """既定(save_figures 省略)で figures/ に 4 種の PNG が出ること(SPEC_04 可視化規約).""" + out_dir = tmp_path / "out" + evaluate_image(_write_npy(tmp_path, seed=5), _write_eval_yaml(tmp_path), out_dir) + for name in _FIGURE_FILES: + assert (out_dir / "figures" / name).is_file(), name + + +def test_creates_deep_nested_output_dir(tmp_path: Path): + """出力ディレクトリが親ごと作られること.""" + out_dir = tmp_path / "a" / "b" / "c" + evaluate_image(_write_npy(tmp_path, seed=6), _write_eval_yaml(tmp_path), out_dir) + assert (out_dir / "metrics.csv").is_file() + + +# --------------------------------------------------------------------------- +# save_figures=False(スイープ用の図なしモード) +# --------------------------------------------------------------------------- + + +def test_no_figures_dir_when_save_figures_false(tmp_path: Path): + """save_figures=False のとき figures/ が作られないこと.""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=7), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert not (out_dir / "figures").exists() + + +def test_metrics_csv_still_created_when_save_figures_false(tmp_path: Path): + """save_figures=False でも metrics.csv は出ること(指標だけ欲しい用途).""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=8), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert (out_dir / "metrics.csv").is_file() + + +def test_metrics_identical_with_and_without_figures(tmp_path: Path): + """図の有無で指標が変わらないこと(描画は評価結果に影響しない).""" + npy = _write_npy(tmp_path, seed=9) + config = _write_eval_yaml(tmp_path) + with_figs = evaluate_image(npy, config, tmp_path / "with", save_figures=True) + without_figs = evaluate_image(npy, config, tmp_path / "without", save_figures=False) + assert set(with_figs) == set(without_figs) + for key in with_figs: + assert with_figs[key] == pytest.approx(without_figs[key], rel=1e-12) + + +def test_summary_omits_figures_line_when_save_figures_false(tmp_path: Path, capsys): + """save_figures=False のときサマリーに figures/ 行を出さないこと.""" + evaluate_image( + _write_npy(tmp_path, seed=10), + _write_eval_yaml(tmp_path), + tmp_path / "out", + save_figures=False, + ) + out = capsys.readouterr().out + assert "metrics.csv" in out + assert "figures/" not in out + + +def test_summary_includes_figures_line_by_default(tmp_path: Path, capsys): + """既定ではサマリーに figures/ 行が出ること(従来どおり).""" + evaluate_image(_write_npy(tmp_path, seed=11), _write_eval_yaml(tmp_path), tmp_path / "out") + assert "figures/" in capsys.readouterr().out + + +def test_no_figure_files_written_anywhere_when_disabled(tmp_path: Path): + """save_figures=False のとき出力ディレクトリ配下に PNG が 1 つも無いこと.""" + out_dir = tmp_path / "out" + evaluate_image( + _write_npy(tmp_path, seed=12), _write_eval_yaml(tmp_path), out_dir, save_figures=False + ) + assert list(out_dir.rglob("*.png")) == [] + + +# --------------------------------------------------------------------------- +# 異常系: 段階別の EvaluationStepError +# --------------------------------------------------------------------------- + + +def test_missing_image_raises_step_error(tmp_path: Path): + """画像が無いとき EvaluationStepError になること(見つからない旨を含む).""" + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(tmp_path / "nope.npy", _write_eval_yaml(tmp_path), tmp_path / "out") + assert "画像ファイルが見つかりません" in str(exc.value) + + +def test_broken_image_raises_load_step_error(tmp_path: Path): + """壊れた .npy は「画像の読み込みに失敗しました」段階の例外になること.""" + bad = tmp_path / "broken.npy" + bad.write_bytes(b"not a npy file") + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(bad, _write_eval_yaml(tmp_path), tmp_path / "out") + assert "画像の読み込みに失敗しました" in str(exc.value) + + +def test_missing_config_raises_step_error(tmp_path: Path): + """設定 YAML が無いとき EvaluationStepError になること.""" + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=13), tmp_path / "nope.yaml", tmp_path / "out") + assert "設定 YAML ファイルが見つかりません" in str(exc.value) + + +def test_invalid_config_raises_config_step_error(tmp_path: Path): + """ROI 欠落の YAML は「設定 YAML の読み込みに失敗しました」段階の例外になること.""" + config = tmp_path / "bad.yaml" + config.write_text("zones:\n enabled: true\n", encoding="utf-8") + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=14), config, tmp_path / "out") + assert "設定 YAML の読み込みに失敗しました" in str(exc.value) + + +def test_roi_out_of_bounds_raises_roi_step_error(tmp_path: Path): + """ROI が画像外のとき「ROI 抽出に失敗しました」段階の例外になること.""" + config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999) + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(_write_npy(tmp_path, seed=15), config, tmp_path / "out") + assert "ROI 抽出に失敗しました" in str(exc.value) + + +def test_zero_luminance_raises_metrics_step_error(tmp_path: Path): + """全ゼロ画像は「均一性指標の算出に失敗しました」段階の例外になること.""" + npy = tmp_path / "zeros.npy" + numpy.save(npy, numpy.zeros((128, 128), dtype=numpy.float64)) + with pytest.raises(EvaluationStepError) as exc: + evaluate_image(npy, _write_eval_yaml(tmp_path), tmp_path / "out") + assert "均一性指標の算出に失敗しました" in str(exc.value) + + +def test_step_error_is_runtime_error_subclass(): + """EvaluationStepError は RuntimeError 派生であること(呼び出し側の捕捉粒度).""" + assert issubclass(EvaluationStepError, RuntimeError) + + +def test_no_output_written_when_roi_fails(tmp_path: Path): + """ROI 抽出で失敗したとき metrics.csv は作られないこと(中途半端な出力を残さない).""" + out_dir = tmp_path / "out" + config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999) + with pytest.raises(EvaluationStepError): + evaluate_image(_write_npy(tmp_path, seed=16), config, out_dir) + assert not (out_dir / "metrics.csv").exists() diff --git a/tests/render/test_pipeline.py b/tests/render/test_pipeline.py new file mode 100644 index 0000000..75727b9 --- /dev/null +++ b/tests/render/test_pipeline.py @@ -0,0 +1,245 @@ +"""render/pipeline.py(render_yaml)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render パイプラインの責務.CLI は薄い層) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render セクション) +- docs/02_ENV/ENV_04_開発コマンド.md + (sm-render は PNG / luminance.npy を出力.variant は --variant > SM_RENDER_VARIANT > YAML) + +検証する仕様: +- render_yaml(scene_yaml, output_dir, variant) が render.png と luminance.npy を書き, + そのパスを (png, npy) の順で返す +- variant の優先順位は 引数 > 環境変数 SM_RENDER_VARIANT > YAML の render.variant で, + 上書き時だけ「variant を上書き: A → B」を表示する(YAML ファイルは書き換えない) +- 失敗は段階別の文言を持つ RenderStepError になる(CLI が「エラー: …」に変換する) + +テスト方針(testing.md): +- 軽量テストでは render_scene を差し替えて「どの variant / どの config で呼ばれたか」を見る + (variant はプロセス内で 1 度しか設定できず,実レンダでは切り替えを検証できないため) +- 実レンダを伴う end-to-end は @pytest.mark.slow に 1 本置く +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest +import yaml + +from sm_tias_lightsim.render.pipeline import RenderStepError, render_yaml +from sm_tias_lightsim.scene.schema import load_scene_yaml + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" +YAML_VARIANT = "llvm_ad_rgb" # minimal.yaml の render.variant + + +@pytest.fixture +def render_calls(monkeypatch): + """render_scene を差し替え,渡された SceneConfig を記録する(実レンダはしない).""" + import sm_tias_lightsim.render.runner as runner + import sm_tias_lightsim.runtime.llvm_init as llvm_init + + calls: list[dict] = [] + + def fake_render_scene(config, project_root=None): + calls.append({"config": config, "project_root": project_root}) + return np.full((4, 4, 3), 0.5, dtype=np.float32) + + monkeypatch.setattr(runner, "render_scene", fake_render_scene) + monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None) + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + return calls + + +# --------------------------------------------------------------------------- +# 出力ファイル +# --------------------------------------------------------------------------- + + +def test_returns_png_and_npy_paths(tmp_path, render_calls): + """戻り値が (render.png, luminance.npy) のパスで,実ファイルが存在すること.""" + out_dir = tmp_path / "out" + png_path, npy_path = render_yaml(MINIMAL_YAML, out_dir) + assert png_path == out_dir / "render.png" + assert npy_path == out_dir / "luminance.npy" + assert png_path.is_file() + assert npy_path.is_file() + + +def test_luminance_npy_is_loadable_2d_or_3d(tmp_path, render_calls): + """luminance.npy が numpy で読める配列であること(評価側の入力になる).""" + _, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out") + data = np.load(npy_path) + assert data.ndim in (2, 3) + assert data.shape[0] == 4 + assert data.shape[1] == 4 + + +def test_creates_deep_nested_output_dir(tmp_path, render_calls): + """出力ディレクトリが親ごと作られること.""" + out_dir = tmp_path / "a" / "b" / "c" + png_path, _ = render_yaml(MINIMAL_YAML, out_dir) + assert png_path.is_file() + + +def test_project_root_is_yaml_parent(tmp_path, render_calls): + """STL 相対パスの基点が YAML のあるディレクトリになること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["project_root"] == MINIMAL_YAML.resolve().parent + + +def test_accepts_relative_paths(tmp_path, render_calls, monkeypatch): + """相対パスで渡しても解決されて出力できること.""" + monkeypatch.chdir(MINIMAL_YAML.parent) + png_path, npy_path = render_yaml(Path("minimal.yaml"), tmp_path / "out") + assert png_path.is_file() + assert npy_path.is_file() + + +# --------------------------------------------------------------------------- +# variant の解決(優先順位) +# --------------------------------------------------------------------------- + + +def test_yaml_variant_used_without_override(tmp_path, render_calls, capsys): + """引数・環境変数が無ければ YAML の variant を使い,上書き表示も出さないこと.""" + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["config"].render.variant == YAML_VARIANT + assert "variant を上書き" not in capsys.readouterr().out + + +def test_env_overrides_yaml(tmp_path, render_calls, capsys, monkeypatch): + """環境変数 SM_RENDER_VARIANT が YAML より優先されること.""" + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert render_calls[0]["config"].render.variant == "scalar_rgb" + assert f"variant を上書き: {YAML_VARIANT} → scalar_rgb" in capsys.readouterr().out + + +def test_argument_overrides_yaml(tmp_path, render_calls, capsys): + """variant 引数が YAML より優先されること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + assert f"variant を上書き: {YAML_VARIANT} → cuda_ad_rgb" in capsys.readouterr().out + + +def test_argument_overrides_env(tmp_path, render_calls, capsys, monkeypatch): + """variant 引数が環境変数より優先されること.""" + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + + +def test_same_variant_is_not_reported_as_override(tmp_path, render_calls, capsys): + """YAML と同じ variant を指定しても「上書き」とは表示しないこと.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", YAML_VARIANT) + assert "variant を上書き" not in capsys.readouterr().out + + +def test_override_changes_only_variant(tmp_path, render_calls): + """上書きするのは variant だけで,他の設定・シーン内容は YAML のままであること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + original = load_scene_yaml(MINIMAL_YAML) + config = render_calls[0]["config"] + assert config.render.model_dump(exclude={"variant"}) == original.render.model_dump( + exclude={"variant"} + ) + assert config.model_dump(exclude={"render"}) == original.model_dump(exclude={"render"}) + + +def test_yaml_file_is_not_modified(tmp_path, render_calls, monkeypatch): + """variant を上書きしても YAML ファイル自体は書き換えないこと.""" + before = MINIMAL_YAML.read_bytes() + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert MINIMAL_YAML.read_bytes() == before + + +def test_seed_from_yaml_reaches_render_scene(tmp_path, render_calls): + """YAML の render.seed がそのままレンダに渡ること(再レンダのノイズ実現の切り替え).""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + data["render"]["seed"] = 4 + # render_scene は差し替え済みなので STL の実在は問われない(YAML の中身だけを見る) + scene_yaml = tmp_path / "scene_with_seed.yaml" + scene_yaml.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + render_yaml(scene_yaml, tmp_path / "out") + assert render_calls[0]["config"].render.seed == 4 + + +def test_seed_survives_variant_override(tmp_path, render_calls): + """variant を上書きしても seed が既定 0 のまま保たれること.""" + render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb") + assert render_calls[0]["config"].render.seed == 0 + + +# --------------------------------------------------------------------------- +# 異常系: 段階別の RenderStepError +# --------------------------------------------------------------------------- + + +def test_missing_yaml_raises_step_error(tmp_path, render_calls): + """シーン YAML が無いとき RenderStepError になること.""" + with pytest.raises(RenderStepError) as exc: + render_yaml(tmp_path / "nope.yaml", tmp_path / "out") + assert "見つかりません" in str(exc.value) + + +def test_invalid_yaml_raises_load_step_error(tmp_path, render_calls): + """必須フィールド欠落の YAML は「シーン YAML の読み込みに失敗しました」になること.""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + del data["name"] # 必須フィールドを落とす(SPEC_02 トップレベル構造) + broken = tmp_path / "broken.yaml" + broken.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + with pytest.raises(RenderStepError) as exc: + render_yaml(broken, tmp_path / "out") + assert "シーン YAML の読み込みに失敗しました" in str(exc.value) + + +def test_render_failure_raises_render_step_error(tmp_path, monkeypatch): + """レンダ中の例外は「レンダリングに失敗しました」段階の例外になること.""" + import sm_tias_lightsim.render.runner as runner + import sm_tias_lightsim.runtime.llvm_init as llvm_init + + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None) + + def boom(config, project_root=None): + raise RuntimeError("テスト用レンダ失敗") + + monkeypatch.setattr(runner, "render_scene", boom) + with pytest.raises(RenderStepError) as exc: + render_yaml(MINIMAL_YAML, tmp_path / "out") + assert "レンダリングに失敗しました" in str(exc.value) + assert "テスト用レンダ失敗" in str(exc.value) + + +def test_step_error_is_runtime_error_subclass(): + """RenderStepError は RuntimeError 派生であること.""" + assert issubclass(RenderStepError, RuntimeError) + + +def test_no_output_written_when_load_fails(tmp_path, render_calls): + """YAML 読み込みで失敗したときは出力ファイルを作らないこと.""" + out_dir = tmp_path / "out" + with pytest.raises(RenderStepError): + render_yaml(tmp_path / "nope.yaml", out_dir) + assert not (out_dir / "render.png").exists() + + +# --------------------------------------------------------------------------- +# end-to-end(実レンダ) +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_end_to_end_render_writes_files(tmp_path, monkeypatch, capsys): + """実レンダで render.png と luminance.npy が出力されること(scalar_rgb).""" + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.delenv("SM_DRJIT_THREADS", raising=False) + png_path, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out", "scalar_rgb") + assert png_path.is_file() + assert npy_path.is_file() + luminance = np.load(npy_path) + assert luminance.shape[:2] == (64, 64) + assert float(np.nanmean(luminance)) > 0.0 diff --git a/tests/render/test_runner_ensure_variant.py b/tests/render/test_runner_ensure_variant.py new file mode 100644 index 0000000..d5d5cf4 --- /dev/null +++ b/tests/render/test_runner_ensure_variant.py @@ -0,0 +1,260 @@ +"""render/runner.py の ensure_variant / seed 受け渡しの仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/runner 層の責務: variant 設定と mi.render) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(seed を含むレンダ設定) +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 + (Mitsuba の variant はプロセス内で 1 度しか設定できない.変えるにはプロセスを作り直す) + +検証する仕様: +- ensure_variant(variant) は同一 variant で何度呼んでも例外にならない(2 回目以降は無操作) +- 既に別の variant が設定済みなら警告ログを出して続行する(例外にしない) +- スイープがレンダ前にレイキャストを目的の variant で走らせられるよう, + render_scene から独立して呼べる +- render_scene は config.render.seed を mi.render に渡す + +テスト方針(testing.md): +- variant はプロセス内で 1 度しか設定できないため,グローバル状態はフィクスチャで戻す +- 実 Mitsuba を使う経路は scalar_rgb(tests/conftest.py が設定済みの variant)で確認し, + 「別 variant 要求時の警告」「seed の受け渡し」はモックで確認する +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +import sm_tias_lightsim.render.runner as runner_module +from sm_tias_lightsim.scene.schema import SceneConfig + + +@pytest.fixture(autouse=True) +def reset_runner_variant(): + """各テスト前後に runner._current_variant をリセットする(テスト間の干渉防止).""" + original = runner_module._current_variant + runner_module._current_variant = None + yield + runner_module._current_variant = original + + +def _make_scene_config(variant: str = "scalar_rgb", seed: int | None = None) -> SceneConfig: + """最小限の SceneConfig(実 STL 不要 — build_scene をモックするため).""" + render: dict = {"variant": variant, "integrator": "path", "max_depth": 2, "spp": 1} + if seed is not None: + render["seed"] = seed + data = { + "name": "test_ensure_variant", + "geometry": { + "meshes": [{"id": "dummy", "path": "dummy.stl", "unit": "mm", "material": "white"}] + }, + "materials": {"white": {"type": "diffuse", "reflectance": 0.9}}, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 100.0], + "intensity": 1.0, + "profile": "lambertian", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 200.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 4, "height": 4}, + }, + "render": render, + } + return SceneConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# ensure_variant: 実 Mitsuba(scalar_rgb)での振る舞い +# --------------------------------------------------------------------------- + + +def test_ensure_variant_is_idempotent_for_same_variant(): + """同じ variant で 2 回呼んでも例外にならないこと(2 回目以降は無操作). + + 仕様: Mitsuba の set_variant はプロセス内 1 度だけ.スイープは条件ごとに + ensure_variant を呼ぶので,2 回目以降が失敗してはならない. + """ + runner_module.ensure_variant("scalar_rgb") + runner_module.ensure_variant("scalar_rgb") + import mitsuba as mi + + assert mi.variant() == "scalar_rgb" + + +def test_ensure_variant_records_current_variant(): + """1 度目の呼び出しで内部状態(_current_variant)が記録されること.""" + assert runner_module._current_variant is None + runner_module.ensure_variant("scalar_rgb") + assert runner_module._current_variant == "scalar_rgb" + + +def test_ensure_variant_does_not_call_set_variant_twice(): + """2 回目の呼び出しでは mi.set_variant を呼ばないこと.""" + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + with ( + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + runner_module.ensure_variant("scalar_rgb") + runner_module.ensure_variant("scalar_rgb") + assert dummy_mi.set_variant.call_count == 1 + + +# --------------------------------------------------------------------------- +# ensure_variant: 異なる variant 要求時 +# --------------------------------------------------------------------------- + + +def test_ensure_variant_warns_when_different_variant_requested(caplog): + """別の variant を要求すると警告ログを出して続行すること(例外にしない). + + 仕様: 同一プロセスで variant は切り替えられない.要求は無視し, + プロセスを作り直すよう促す警告を出す. + """ + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + with ( + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + runner_module.ensure_variant("scalar_rgb") + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"): + runner_module.ensure_variant("llvm_ad_rgb") + + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warnings, "別 variant 要求時に WARNING ログが出ること" + assert dummy_mi.set_variant.call_count == 1, "2 度目の set_variant は呼ばないこと" + + +def test_ensure_variant_keeps_first_variant_after_conflicting_request(): + """別 variant を要求しても最初に設定した variant が維持されること.""" + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + with ( + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + runner_module.ensure_variant("scalar_rgb") + runner_module.ensure_variant("cuda_ad_rgb") + assert runner_module._current_variant == "scalar_rgb" + + +def test_ensure_variant_calls_ensure_drjit_libllvm_path_for_llvm_variant(): + """llvm_* 要求時は set_variant 前に DRJIT_LIBLLVM_PATH を探索すること.""" + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "llvm_ad_rgb" + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure, + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + runner_module.ensure_variant("llvm_ad_rgb") + mock_ensure.assert_called_once() + + +def test_ensure_variant_skips_llvm_path_lookup_for_scalar_variant(): + """scalar_* 要求時は DRJIT_LIBLLVM_PATH の探索を行わないこと.""" + dummy_mi = MagicMock() + dummy_mi.variant.return_value = "scalar_rgb" + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure, + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + ): + runner_module.ensure_variant("scalar_rgb") + mock_ensure.assert_not_called() + + +# --------------------------------------------------------------------------- +# render_scene から ensure_variant を通じた variant 設定 +# --------------------------------------------------------------------------- + + +def _render_with_mock(config: SceneConfig) -> MagicMock: + """build_scene / mitsuba をモックして render_scene を呼び,mitsuba モックを返す.""" + dummy_image = np.zeros((4, 4, 3), dtype=np.float32) + dummy_mi = MagicMock() + dummy_mi.variant.return_value = config.render.variant + # numpy.array() でそのまま配列になるよう実配列を返す(mi.render の戻り値の代役) + dummy_mi.render.return_value = dummy_image + with ( + patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"), + patch( + "sm_tias_lightsim.render.runner.build_scene", + return_value=({"type": "scene"}, "/tmp/fake"), + ), + patch("sm_tias_lightsim.render.runner.log_variant_status"), + patch.dict("sys.modules", {"mitsuba": dummy_mi}), + patch("shutil.rmtree"), + ): + runner_module.render_scene(config, project_root=Path("/tmp")) + return dummy_mi + + +def test_render_scene_sets_variant_from_config(): + """render_scene が config.render.variant で variant を設定すること.""" + _render_with_mock(_make_scene_config(variant="scalar_rgb")) + assert runner_module._current_variant == "scalar_rgb" + + +def test_render_scene_passes_seed_to_mi_render(): + """render_scene が config.render.seed を mi.render に渡すこと. + + 仕様: 同一条件の再レンダで別のノイズ実現を得るため,seed はレンダに伝わる必要がある. + """ + dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=3)) + _, kwargs = dummy_mi.render.call_args + assert kwargs["seed"] == 3 + + +def test_render_scene_passes_default_seed_zero(): + """seed 未指定(既定 0)でも mi.render に seed=0 が渡ること.""" + dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb")) + _, kwargs = dummy_mi.render.call_args + assert kwargs["seed"] == 0 + + +def test_render_scene_passes_spp_and_seed_together(): + """spp と seed の両方が mi.render に渡ること(片方の追加で他方が落ちない).""" + dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=5)) + _, kwargs = dummy_mi.render.call_args + assert kwargs["spp"] == 1 + assert kwargs["seed"] == 5 + + +@pytest.mark.slow +def test_different_seeds_give_different_images(sphere_stl_in_cwd: Path, minimal_scene_yaml: str): + """seed を変えると実レンダの画素が変わること(ノイズの実現が変わる). + + 仕様: 再現性チェック(同一条件の再レンダ)で seed を振ってノイズフロアを測るため, + seed が結果に効いていなければならない. + """ + import yaml + + data = yaml.safe_load(minimal_scene_yaml) + data["render"]["variant"] = "scalar_rgb" + data["render"]["spp"] = 4 + + data["render"]["seed"] = 0 + img_a = runner_module.render_scene( + SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent + ) + data["render"]["seed"] = 1 + img_b = runner_module.render_scene( + SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent + ) + + assert img_a.shape == img_b.shape + assert not np.array_equal(img_a, img_b), "seed が違えば画素は一致しないこと" diff --git a/tests/scene/test_schema_metrics_fullimage.py b/tests/scene/test_schema_metrics_fullimage.py new file mode 100644 index 0000000..aad7af1 --- /dev/null +++ b/tests/scene/test_schema_metrics_fullimage.py @@ -0,0 +1,214 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +"""scene/schema.py の MetricsConfig.fullimage_min_max / fullimage_sigma の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション.オプション指標は + bool フラグで有効化し,既定 False で既存 YAML はそのまま動く) +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」(既定 False・ + metrics.csv に列を追加するオプション指標の扱い) +- evaluation/fullimage.py(新指標の定義.sigma はガウス平滑化の σ で正の値のみ意味を持つ) + +検証する仕様: +- fullimage_min_max の既定は False(後方互換: フィールドを書いていない既存 YAML が読める) +- fullimage_sigma の既定は 12.0(464x450 の ROI で検証した値) +- fullimage_sigma は正の値のみ受理する(0・負は ValidationError) +- 既存フィールド(cov / std / max_min / radial_profile / radial_min_max / shape_metrics)に + 干渉しない + +テスト方針(.claude/rules/testing.md): 入力バリデーションは必ずテストする. +Pydantic モデルの直接構築と YAML ロードの両方で確認する. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, MetricsConfig + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +_MINIMAL_ROI_YAML = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + + +def _eval_config_from_metrics_yaml(metrics_lines: str) -> EvaluationConfig: + """metrics セクションに追記した YAML を EvaluationConfig にする.""" + data = yaml.safe_load(_MINIMAL_ROI_YAML + metrics_lines) + return EvaluationConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# A. 既定値(後方互換) +# --------------------------------------------------------------------------- + + +def test_fullimage_min_max_default_is_false(): + """MetricsConfig() の fullimage_min_max 既定値が False であること. + + 仕様: オプション指標なので既定 False(既存挙動互換). + """ + assert MetricsConfig().fullimage_min_max is False + + +def test_fullimage_sigma_default_is_twelve(): + """MetricsConfig() の fullimage_sigma 既定値が 12.0 であること. + + 仕様: 既定 12.0 は 464x450 の ROI で検証した値(fullimage.py docstring). + """ + assert MetricsConfig().fullimage_sigma == pytest.approx(12.0) + + +def test_existing_yaml_without_fullimage_fields_is_loadable(): + """フィールドを持たない既存 YAML がそのまま読め,既定値になること(後方互換).""" + config = _eval_config_from_metrics_yaml("") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + assert config.metrics.fullimage_sigma == pytest.approx(12.0) + + +@pytest.mark.parametrize( + "config_name", + ["eval_baseline.yaml", "eval_baseline_linear.yaml", "eval_real.yaml"], +) +def test_shipped_existing_configs_keep_fullimage_disabled(config_name: str): + """同梱の既存評価 YAML が無修正で読め,新指標が無効のままであること(後方互換).""" + data = yaml.safe_load((_REPO_ROOT / "configs" / config_name).read_text(encoding="utf-8")) + config = EvaluationConfig.model_validate(data) + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + + +def test_shipped_dng_target_shape_config_enables_fullimage(): + """configs/eval_baseline_dng_target_shape.yaml で新指標が有効になっていること. + + 仕様: 実機比較用の評価 config で metrics.fullimage_min_max: true を有効化する. + """ + path = _REPO_ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" + config = EvaluationConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is True + assert config.metrics.fullimage_sigma == pytest.approx(12.0) + + +# --------------------------------------------------------------------------- +# B. 明示指定と YAML ロード +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_fullimage_min_max_explicit_value_is_kept(enabled: bool): + """MetricsConfig(fullimage_min_max=...) の明示指定がそのまま保持されること.""" + assert MetricsConfig(fullimage_min_max=enabled).fullimage_min_max is enabled + + +def test_yaml_load_fullimage_min_max_true(): + """YAML の metrics.fullimage_min_max: true が反映されること.""" + config = _eval_config_from_metrics_yaml(" fullimage_min_max: true\n") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is True + + +def test_yaml_load_fullimage_min_max_false(): + """YAML の metrics.fullimage_min_max: false が反映されること.""" + config = _eval_config_from_metrics_yaml(" fullimage_min_max: false\n") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + + +@pytest.mark.parametrize("sigma", [0.5, 8.0, 12.0, 30.0]) +def test_yaml_load_fullimage_sigma_positive_values(sigma: float): + """正の fullimage_sigma が YAML から読めること.""" + config = _eval_config_from_metrics_yaml( + f" fullimage_min_max: true\n fullimage_sigma: {sigma}\n" + ) + + assert config.metrics is not None + assert config.metrics.fullimage_sigma == pytest.approx(sigma) + + +def test_fullimage_sigma_accepts_integer_yaml_value(): + """YAML の整数値 sigma が float として読めること.""" + config = _eval_config_from_metrics_yaml(" fullimage_sigma: 8\n") + + assert config.metrics is not None + assert config.metrics.fullimage_sigma == pytest.approx(8.0) + + +# --------------------------------------------------------------------------- +# C. バリデーション(不正値は ValidationError) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sigma", [0.0, -1.0, -12.0]) +def test_non_positive_fullimage_sigma_raises_validation_error(sigma: float): + """fullimage_sigma が 0 以下なら ValidationError になること. + + 仕様: sigma はガウス平滑化の σ なので正の値のみ(fullimage.py も 0 以下を拒否する). + """ + with pytest.raises(ValidationError): + MetricsConfig(fullimage_sigma=sigma) + + +@pytest.mark.parametrize("sigma", ["0", "-3.0"]) +def test_non_positive_fullimage_sigma_in_yaml_raises_validation_error(sigma: str): + """YAML 経由でも 0 以下の fullimage_sigma が ValidationError になること.""" + with pytest.raises(ValidationError): + _eval_config_from_metrics_yaml(f" fullimage_sigma: {sigma}\n") + + +def test_non_numeric_fullimage_sigma_raises_validation_error(): + """数値でない fullimage_sigma は ValidationError になること.""" + with pytest.raises(ValidationError): + MetricsConfig(fullimage_sigma="wide") + + +def test_non_boolean_fullimage_min_max_raises_validation_error(): + """bool に解釈できない fullimage_min_max は ValidationError になること.""" + with pytest.raises(ValidationError): + MetricsConfig(fullimage_min_max="maybe") + + +# --------------------------------------------------------------------------- +# D. 既存フィールドへの非干渉 +# --------------------------------------------------------------------------- + + +def test_enabling_fullimage_does_not_change_other_metric_flags(): + """新フィールドを有効にしても既存フラグの既定値が変わらないこと.""" + default = MetricsConfig() + enabled = MetricsConfig(fullimage_min_max=True, fullimage_sigma=20.0) + + assert enabled.cov == default.cov + assert enabled.std == default.std + assert enabled.max_min == default.max_min + assert enabled.radial_profile == default.radial_profile + assert enabled.radial_min_max == default.radial_min_max + assert enabled.shape_metrics == default.shape_metrics + + +def test_metrics_config_is_frozen(): + """MetricsConfig が immutable であること(読み込み後に書き換えない).""" + config = MetricsConfig(fullimage_min_max=True) + + with pytest.raises(ValidationError): + config.fullimage_min_max = False diff --git a/tests/scene/test_schema_render_seed.py b/tests/scene/test_schema_render_seed.py new file mode 100644 index 0000000..e8b03b8 --- /dev/null +++ b/tests/scene/test_schema_render_seed.py @@ -0,0 +1,97 @@ +"""render.seed(レンダの乱数シード)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(レンダ設定の YAML スキーマ. + seed は本実装で追加された新フィールドで,仕様書側は Phase 4 の更新対象) +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 + (同じ YAML・同じ seed でも読み込み直すと画素は変わるが,指標はほぼ一致する. + ノイズの実現を意図的に変えたいときに seed を振る) + +検証する仕様: +- seed は省略可能(既定 0)=既存 YAML が書き換え無しで読める(後方互換) +- seed は 0 以上の整数のみ(負値は ValidationError) +- YAML に書いた値がそのまま SceneConfig に載る +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import RenderConfig, SceneConfig, load_scene_yaml + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# --------------------------------------------------------------------------- +# 既定値・後方互換 +# --------------------------------------------------------------------------- + + +def test_seed_defaults_to_zero(): + """seed を省略すると 0 になること(既定値).""" + assert RenderConfig().seed == 0 + + +def test_existing_yaml_without_seed_is_still_valid(): + """seed を書いていない既存 YAML がそのまま読め,seed=0 になること(後方互換).""" + config = load_scene_yaml(MINIMAL_YAML) + assert config.render.seed == 0 + + +def test_seed_omission_does_not_change_other_render_fields(): + """seed の追加で他の render 設定の既定値が変わっていないこと.""" + render = RenderConfig() + assert render.variant == "llvm_ad_rgb" + assert render.integrator == "path" + assert render.max_depth == 8 + assert render.spp == 256 + + +# --------------------------------------------------------------------------- +# 正常系: 値の指定 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("seed", [0, 1, 2, 42, 12345]) +def test_seed_accepts_non_negative_integers(seed): + """0 以上の整数はそのまま保持されること.""" + assert RenderConfig(seed=seed).seed == seed + + +def test_seed_from_yaml_is_loaded(tmp_path: Path): + """YAML の render.seed が SceneConfig に反映されること.""" + data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8")) + data["render"]["seed"] = 7 + # メッシュの相対パスを保つため,YAML はフィクスチャと同じディレクトリ基準で解決する + yaml_path = tmp_path / "scene_with_seed.yaml" + yaml_path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + config = SceneConfig.model_validate(yaml.safe_load(yaml_path.read_text(encoding="utf-8"))) + assert config.render.seed == 7 + + +# --------------------------------------------------------------------------- +# 異常系 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("seed", [-1, -42]) +def test_negative_seed_raises_validation_error(seed): + """負の seed は ValidationError になること(ge=0).""" + with pytest.raises(ValidationError): + RenderConfig(seed=seed) + + +def test_non_integer_seed_raises_validation_error(): + """整数に解釈できない seed は ValidationError になること.""" + with pytest.raises(ValidationError): + RenderConfig(seed="abc") + + +def test_float_seed_with_fraction_raises_validation_error(): + """小数の seed は ValidationError になること(整数フィールド).""" + with pytest.raises(ValidationError): + RenderConfig(seed=1.5) diff --git a/tests/scripts/test_sweep_led_placement.py b/tests/scripts/test_sweep_led_placement.py new file mode 100644 index 0000000..a171888 --- /dev/null +++ b/tests/scripts/test_sweep_led_placement.py @@ -0,0 +1,415 @@ +"""scripts/sweep_led_placement.py(照明配置スイープ)の仕様ベーステスト. + +仕様出典: +- docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(オフ軸配置の定量.本スイープの前段) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render.seed) +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 + (_tmp YAML のメッシュパスは YAML からの相対パスでなければサーバで読めない. + 同じ seed で読み直すと指標がほぼ一致するので,再現性チェックには seed を振る) +- スクリプト冒頭の条件設計(位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ) + +検証する仕様: +- conditions() が設計どおりの条件集合(255 条件・名前が一意・重要な順)を返す +- --list で条件名の一覧だけを表示して終了する(レンダしない) +- prepare_condition() が条件ごとのシーン YAML を作り,再レンダ条件(rep>1)は + 幾何を共有したまま render.seed = rep-1 の別 YAML になる +- YAML に書くメッシュパスは YAML からの相対パス(POSIX 区切り・ドライブ無し) + +テスト方針(testing.md): +- sm-render / sm-evaluate は呼ばない(レンダはしない).幾何生成と YAML だけを見る +- TMP_DIR / MESH_DIR を tmp_path に差し替える + (tests/scripts/test_sweep_relative_mesh_paths.py と同じ流儀) +- 筐体メッシュ生成は球で十数秒かかるので,生成を伴うテストは立方体条件のみを使い, + モジュールスコープのフィクスチャで 1 度だけ生成する +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import ModuleType +from unittest import mock + +import pytest +import yaml + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPTS = _ROOT / "scripts" + +# 再現性グループで使う立方体条件(スクリプトの SIZE_PAIRS[REPEAT_PAIR_INDEX] と対応) +_CUBE_SIZE = 234.0 + + +def _load(name: str) -> ModuleType: + """scripts/.py を import する(既に読み込み済みならそれを使う).""" + path = (_SCRIPTS / f"{name}.py").resolve() + cached = sys.modules.get(name) + if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path: + return cached + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def sweep() -> ModuleType: + return _load("sweep_led_placement") + + +@pytest.fixture(autouse=True) +def _same_drive(tmp_path): + if tmp_path.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") + + +# --------------------------------------------------------------------------- +# 条件集合 +# --------------------------------------------------------------------------- + + +def test_conditions_total_count(sweep): + """全条件数が 255 であること. + + 仕様: 位置 2 形状 × 4 サイズ × 13θ = 104,個数 2 × 4 × (3 + 2) = 40, + 向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は + 位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6, + 方位角 立方体 3 × 4 + 球 1 × 4 + 較正 1 = 17,真下向き多灯 2 × 4 × 5 = 40.合計 255. + """ + assert len(sweep.conditions()) == 255 + + +def test_condition_names_are_unique(sweep): + """条件名が一意であること(出力ディレクトリ名として使われるため).""" + names = [c.name for c in sweep.conditions()] + assert len(names) == len(set(names)) + + +def test_conditions_cover_all_groups(sweep): + """4 つのグループがすべて含まれること.""" + groups = {c.group for c in sweep.conditions()} + assert groups == set(sweep.GROUPS) + + +def test_group_counts_match_design(sweep): + """グループごとの条件数が設計どおりであること.""" + from collections import Counter + + counts = Counter(c.group for c in sweep.conditions()) + assert counts["position"] == 104 + assert counts["count"] == 40 + assert counts["direction"] == 48 + assert counts["repeat"] == 6 + + +def test_shapes_are_balanced(sweep): + """球と立方体が同数であること(同内面積ペアでの比較が目的).""" + conds = [c for c in sweep.conditions() if c.group != "azimuth"] # 方位角群だけ数が非対称 + assert sum(c.shape == "sphere" for c in conds) == sum(c.shape == "cube" for c in conds) + + +def test_position_group_comes_first(sweep): + """主実験(位置)が先頭に並ぶこと(途中で止めても主実験が揃うようにする).""" + groups = [c.group for c in sweep.conditions()] + assert groups[0] == "position" + assert set(groups[:104]) == {"position"} + + +def test_repeat_conditions_share_base_name_and_differ_by_rep(sweep): + """再現性条件は同じ base_name で rep だけが違うこと(幾何は同一).""" + repeats = [c for c in sweep.conditions() if c.group == "repeat"] + assert repeats, "再現性グループの条件が存在すること" + for cond in repeats: + assert cond.rep > 1 + assert cond.name == f"{cond.base_name}_rep{cond.rep}" + + +def test_position_conditions_use_rep_one(sweep): + """再現性以外の条件は rep=1(名前に rep が付かない)であること.""" + for cond in sweep.conditions(): + if cond.group != "repeat": + assert cond.rep == 1 + assert cond.name == cond.base_name + + +def test_azimuths_are_evenly_spaced(sweep): + """多灯条件の方位角が等間隔で,1 灯目が +X 側であること.""" + cond = sweep.Condition("count", "cube", _CUBE_SIZE, 90.0, "board", 4) + assert cond.azimuths_deg == [0.0, 90.0, 180.0, 270.0] + + +# --------------------------------------------------------------------------- +# --list(レンダせず一覧のみ) +# --------------------------------------------------------------------------- + + +def test_list_prints_all_condition_names(sweep, monkeypatch, capsys): + """--list が全条件名と件数を表示して 0 を返すこと(レンダしない).""" + monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"]) + assert sweep.main() == 0 + out = capsys.readouterr().out + assert "255 条件" in out + assert out.count("\n") == 256 # 条件名 255 行 + 件数 1 行 + + +def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys): + """--shapes / --groups で条件を絞れること.""" + monkeypatch.setattr( + sys, + "argv", + ["sweep_led_placement.py", "--list", "--shapes", "cube", "--groups", "position"], + ) + assert sweep.main() == 0 + lines = [ln for ln in capsys.readouterr().out.splitlines() if ln] + names = lines[:-1] + assert lines[-1] == "52 条件" # 4 サイズ × 13θ + assert all(name.startswith("cube_") for name in names) + + +# --------------------------------------------------------------------------- +# prepare_condition(幾何生成 + シーン YAML) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def prepared(sweep, tmp_path_factory) -> dict[str, tuple[Path, dict]]: + """立方体条件の rep1 / rep2 / rep3 を 1 度だけ生成する. + + 筐体メッシュは同じファイルを共有するので,2 件目以降の生成は速い. + """ + base = tmp_path_factory.mktemp("led_placement") + if base.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") + conds = [ + sweep.Condition("repeat", "cube", _CUBE_SIZE, 90.0, "board", 1, rep) + for rep in (1, 2, 3) + ] + with ( + mock.patch.object(sweep, "TMP_DIR", base), + mock.patch.object(sweep, "MESH_DIR", base / "meshes"), + ): + return {cond.name: sweep.prepare_condition(cond) for cond in conds} + + +def _load_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_prepare_writes_scene_yaml_named_after_condition(sweep, prepared): + """条件ごとに scene_.yaml が作られること.""" + for name, (scene_yaml, _) in prepared.items(): + assert scene_yaml.name == f"scene_{name}.yaml" + assert scene_yaml.is_file() + + +def test_repeat_conditions_get_separate_yaml_files(sweep, prepared): + """再レンダ条件(rep>1)は元の条件とは別の YAML になること.""" + paths = {str(scene_yaml) for scene_yaml, _ in prepared.values()} + assert len(paths) == len(prepared) + + +def test_seed_equals_rep_minus_one(sweep, prepared): + """render.seed が rep-1 になること. + + 仕様: seed が同じだとシーンを読み直しても指標が 4 桁まで一致してノイズフロアにならない + (ENV_04「研究室サーバでの実行」の実測).再レンダは seed を振って別実現にする. + """ + for name, (scene_yaml, _) in prepared.items(): + rep = int(name.split("_rep")[1]) if "_rep" in name else 1 + assert _load_yaml(scene_yaml)["render"]["seed"] == rep - 1, name + + +def test_first_rep_uses_default_seed_zero(sweep, prepared): + """rep=1(通常条件)の seed が 0 であること(既定値と同じ).""" + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + assert _load_yaml(scene_yaml)["render"]["seed"] == 0 + + +def test_repeat_yaml_differs_only_in_seed_and_name(sweep, prepared): + """再レンダ条件の YAML は seed・name・description 以外が元の条件と同一であること. + + 仕様: 幾何(筐体・LED・バッフル)は共有し,変えるのは乱数シードだけ. + """ + base = _load_yaml(prepared["cube_L234_th090_board_n1"][0]) + rep2 = _load_yaml(prepared["cube_L234_th090_board_n1_rep2"][0]) + for key in ("name", "description"): + base.pop(key, None) + rep2.pop(key, None) + assert base["render"].pop("seed") == 0 + assert rep2["render"].pop("seed") == 1 + assert base == rep2 + + +def test_scene_yaml_is_valid_scene_config(sweep, prepared): + """生成した YAML が SceneConfig として検証を通ること(SPEC_02 スキーマ).""" + from sm_tias_lightsim.scene.schema import SceneConfig + + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + config = SceneConfig.model_validate(_load_yaml(scene_yaml)) + assert config.render.seed == 0 + + +def test_mesh_paths_are_relative_to_yaml(sweep, prepared): + """メッシュパスが YAML からの相対パス(POSIX 区切り・ドライブ無し)であること. + + 仕様: Windows 絶対パスの YAML は研究室サーバで読めない(ENV_04 注意). + """ + from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths + + scene_yaml, _ = prepared["cube_L234_th090_board_n1"] + meshes = _load_yaml(scene_yaml)["geometry"]["meshes"] + assert meshes, "メッシュが 1 つ以上書かれていること" + for mesh in meshes: + path = mesh["path"] + assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}" + assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}" + assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}" + assert (scene_yaml.parent / path).resolve().is_file(), f"{mesh['id']}: 実体が無い: {path}" + + entry = PushEntry( + path=scene_yaml, arcname=f"configs/_tmp_led_placement/{scene_yaml.name}", size=0 + ) + assert find_yaml_with_windows_paths([entry]) == [] + + +def test_housing_mesh_is_shared_between_repeats(sweep, prepared): + """再レンダ条件が同じ筐体メッシュを参照すること(幾何を作り直さない).""" + base_yaml, _ = prepared["cube_L234_th090_board_n1"] + rep_yaml, _ = prepared["cube_L234_th090_board_n1_rep2"] + + def housing(path: Path) -> Path: + meshes = _load_yaml(path)["geometry"]["meshes"] + entry = next(m for m in meshes if m["id"] == "cube_housing") + return (path.parent / entry["path"]).resolve() + + assert housing(base_yaml) == housing(rep_yaml) + + +def test_baffled_condition_records_baffle(sweep, prepared): + """板の前方に発光面がある条件はバッフルを生成し,記録に残すこと.""" + _, record = prepared["cube_L234_th090_board_n1"] + assert record["baffled"] == 1 + assert record["baffle_radius_mm"] > 0.0 + + +# --------------------------------------------------------------------------- +# variant の解決 +# --------------------------------------------------------------------------- + + +def test_render_variant_prefers_env(sweep, monkeypatch): + """SM_RENDER_VARIANT がテンプレート YAML の variant より優先されること.""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb") + assert sweep.render_variant() == "cuda_ad_rgb" + + +def test_render_variant_falls_back_to_template(sweep, monkeypatch): + """環境変数が無ければテンプレート YAML の render.variant を使うこと.""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + template = yaml.safe_load(sweep.TEMPLATE_YAML.read_text(encoding="utf-8")) + assert sweep.render_variant() == template["render"]["variant"] + + +def test_render_variant_is_cached(sweep, monkeypatch): + """variant は 1 度だけ解決して使い回すこと(条件ごとの YAML 読み直しを避ける).""" + monkeypatch.setattr(sweep, "_render_variant", None) + monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb") + first = sweep.render_variant() + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + assert sweep.render_variant() == first + + +# --------------------------------------------------------------------------- +# run_job(レンダ + 評価をプロセス内で実行する) +# --------------------------------------------------------------------------- + + +def _fake_metrics_csv(path: Path) -> None: + """read_metrics_csv が読む列を持つ metrics.csv を書く.""" + columns = [ + "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio", + "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm", + "max_min_ratio", "intensity_centroid_x_norm", "intensity_centroid_y_norm", + ] + header = ",".join(columns) + values = ",".join("0.5" for _ in columns) + path.write_text(f"{header}\n{values}\n", encoding="utf-8") + + +def test_run_job_reuses_existing_row_json(sweep, tmp_path, monkeypatch): + """既に row.json がある条件は再レンダせず,その内容をそのまま返すこと. + + 仕様: スイープは途中で止めて再開できる(既存結果の再利用). + """ + def _must_not_render(*args, **kwargs): + raise AssertionError("既存結果があるときはレンダしないこと") + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", _must_not_render) + monkeypatch.setattr(sweep, "ensure_render_variant", _must_not_render) + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + out_dir = tmp_path / "output" / cond.name + out_dir.mkdir(parents=True) + (out_dir / "row.json").write_text( + json.dumps({"name": cond.name, "cov": 0.01}), encoding="utf-8" + ) + + row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + assert row == {"name": cond.name, "cov": 0.01} + + +def test_run_job_renders_and_evaluates_without_figures(sweep, tmp_path, monkeypatch): + """レンダ → 評価をプロセス内で呼び,評価は figures 無しで行うこと. + + 仕様: スイープでは図が不要なので save_figures=False で描画時間を省く. + """ + import numpy as np + + calls: dict = {} + + def fake_render_yaml(scene_yaml, out_dir, variant): + calls["render"] = {"scene": scene_yaml, "out_dir": out_dir, "variant": variant} + np.save(Path(out_dir) / "luminance.npy", np.full((512, 512), 100.0)) + return Path(out_dir) / "render.png", Path(out_dir) / "luminance.npy" + + def fake_evaluate_image(image, config, out_dir, save_figures=True): + calls["evaluate"] = {"image": image, "config": config, "save_figures": save_figures} + _fake_metrics_csv(Path(out_dir) / "metrics.csv") + return {} + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", fake_render_yaml) + monkeypatch.setattr(sweep, "evaluate_image", fake_evaluate_image) + monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb") + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + + assert calls["evaluate"]["save_figures"] is False + assert calls["render"]["variant"] == "scalar_rgb" + assert row["render_variant"] == "scalar_rgb" + assert row["name"] == cond.name + assert (tmp_path / "output" / cond.name / "row.json").is_file() + + +def test_run_job_returns_none_on_failure(sweep, tmp_path, monkeypatch): + """1 条件が失敗しても例外を投げず None を返すこと(スイープ全体を止めない).""" + def boom(*args, **kwargs): + raise RuntimeError("テスト用レンダ失敗") + + monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output") + monkeypatch.setattr(sweep, "render_yaml", boom) + monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb") + + cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1) + row, messages = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0}) + assert row is None + assert any("失敗" in m for m in messages) + assert not (tmp_path / "output" / cond.name / "row.json").exists()