diff --git a/CLAUDE.md b/CLAUDE.md index ef5f3b8..47f6d2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 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). +最新: フェーズ 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` を変えて測る.**spp の指針**([TEST_19](docs/06_TEST/TEST_19_sppと計算時間ノイズ指標安定性.md)): GPU 時間は spp に比例(1024 で 5 秒)・固定費 0.8 秒・ノイズは 1/√spp 則.構造 CoV なら 1024,画像全体 min/max 比を暗い条件で比べるなら 2048〜4096.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -156,4 +156,5 @@ - バッフル導入と球半径スイープ: 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 の反対側) +- spp と計算時間・ノイズ・指標の安定性: docs/06_TEST/TEST_19_sppと計算時間ノイズ指標安定性.md(spp 256〜8192 × 6 条件.GPU 時間は spp に比例・固定費 0.8 秒,ノイズは 1/√spp 則に一致.構造 CoV は 1024 で十分,画像全体 min/max 比を暗い条件で比べるなら 2048〜4096) - 照明配置依存性の球と立方体の比較: docs/06_TEST/TEST_18_照明配置依存性の球と立方体の比較.md(LED の位置 θ・向き・個数を同内面積ペアで振った 198 条件.配置依存は球が立方体の 1/2〜1/3,対向 2 灯以上で形状差が消える.追加実験: 立方体は面の稜へ寄せると悪化,真下向き多灯はバッフル無しで成立するが板向き多灯には劣る,校正材質 ρ=0.81 でも結論不変だが軸上は両形状とも最良になる) 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 3237bfa..fad4f0d 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" @@ -108,6 +108,7 @@ - CPU(`llvm_*` / `scalar_*`)で回すときは `--threads N` で Dr.Jit のスレッド数に上限を付ける(共用サーバの 64 スレッドを占有しないため) - 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 以上は効果なし) +- **spp の指針**([TEST_19](../06_TEST/TEST_19_sppと計算時間ノイズ指標安定性.md)): GPU のレンダ時間は spp に厳密に比例(1024 で 5.1 秒・2048 で 10.3 秒・4096 で 20.1 秒)で,CPU 側の固定費は 0.8 秒(PLY 書き出し 0.34・評価 0.29・保存 0.12).ノイズは 1/√spp 則に乗る.構造 CoV は 1024 で安定,画像全体 min/max 比を生 mean < 0.2 の暗い条件まで比べるなら 2048〜4096 - 同一シーンを 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 ""` 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 18a2a5d..65f1ec3 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" @@ -92,6 +92,7 @@ | `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/spp_noise_sweep/` (72 ディレクトリ + `summary.csv` / `spp_summary_table.csv` / `spp_noise.png`) | TEST_19 | `configs/_tmp_spp_noise/scene_*.yaml`(`scripts/sweep_spp_noise.py` が TEST_18 の 6 条件の YAML を流用し spp と seed だけ書き換えて生成.球 φ340 軸上/θ=90°・立方体 L=234 軸上/θ=90°・球 φ500 θ=90°・立方体 L=340 θ=90° × spp 256〜8192 × seed 2.Newton GPU 0・cuda・逐次) | `configs/eval_baseline_dng_target_shape.yaml` | GPU 時間は spp に比例(1024 で 5.1 秒),固定費 0.8 秒.ノイズは 1/√spp 則に ±2% で一致.構造 CoV は 1024 で安定,画像全体 min/max は暗い条件で 2048〜4096 が必要 — [TEST_19](TEST_19_sppと計算時間ノイズ指標安定性.md) 参照 | 段階別の壁時計(`mi.render` は `dr.sync_thread` で同期)と seed 差を row.json に記録.`build_spp_noise_comparison.py` で図・表 | | `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 必須ルール)の記録を兼ねる | @@ -153,6 +154,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` | +| spp スイープ (TEST_19) | `uv run python scripts/sweep_spp_noise.py --prepare-only` → `uv run sm-remote push` → `uv run sm-remote run --name spp --gpu 0 --threads 16 -- python scripts/sweep_spp_noise.py` → `uv run sm-remote pull spp_noise_sweep` → `uv run python scripts/build_spp_noise_comparison.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_19_spp\343\201\250\350\250\210\347\256\227\346\231\202\351\226\223\343\203\216\343\202\244\343\202\272\346\214\207\346\250\231\345\256\211\345\256\232\346\200\247.md" "b/docs/06_TEST/TEST_19_spp\343\201\250\350\250\210\347\256\227\346\231\202\351\226\223\343\203\216\343\202\244\343\202\272\346\214\207\346\250\231\345\256\211\345\256\232\346\200\247.md" new file mode 100644 index 0000000..3d5baa7 --- /dev/null +++ "b/docs/06_TEST/TEST_19_spp\343\201\250\350\250\210\347\256\227\346\231\202\351\226\223\343\203\216\343\202\244\343\202\272\346\214\207\346\250\231\345\256\211\345\256\232\346\200\247.md" @@ -0,0 +1,129 @@ +# spp と計算時間・ノイズ・指標の安定性 (Samples per Pixel vs Time, Noise, and Metric Stability) + +TEST_18 までのレンダは spp=1024 固定で,大きいキャビティほど生の輝度が低く相対ノイズが +7〜26% に達していた(指標はノイズ補正で救っている).研究室サーバの GPU で 1 条件 5 秒に +なったので,spp を 256〜8192 で振り,**(1) 計算時間のうち GPU が何秒か,(2) ノイズが +1/√spp で下がるか,(3) 各指標がどの spp で安定するか**を測って今後の標準 spp を決めた +(2026-09-14).結論: **GPU 時間は spp に厳密に比例し固定費は 0.8 秒.ノイズは 1/√spp 則に +正確に乗る.構造 CoV は 1024 で十分,画像全体 min/max 比を暗い条件で比べるなら 2048〜4096**. + +関連: + +- 高速化の内訳と `--workers`: [ENV_04 開発コマンド](../02_ENV/ENV_04_開発コマンド.md)「研究室サーバでの実行」 +- 指標の定義(構造 CoV・画像全体 min/max 比): [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md),[TEST_18](TEST_18_照明配置依存性の球と立方体の比較.md)「均一性の指標」 +- 実行記録: [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md) + +## 実験条件 (Setup) + +- 条件: TEST_18 の 6 条件の YAML を流用(spp と seed だけ書き換え).球 φ340 の軸上と θ=90°, + 立方体 L=234 の軸上と θ=90°,ノイズが最も大きい球 φ500 θ=90° と立方体 L=340 θ=90°. + 1 灯・板向き・バッフル付き,旧材質(ρ=0.85 blendbsdf),512²,max_depth=16 +- spp: 256 / 512 / 1024 / 2048 / 4096 / 8192(2 倍刻み)× seed 2 つ=72 レンダ.16384 は + ユーザー都合で未実施(12 レンダ・約 17 分) +- 計測: 段階別の壁時計(YAML 読込 / PLY 書き出し / シーン読込 / `mi.render` / 保存 / 評価). + `mi.render` は `dr.sync_thread()` で GPU の完了を待ってから測る(投げっぱなしで短く見える + ことを防ぐ).GPU 率 = `mi.render` ÷ 全体 +- 指標: 残差ノイズ CoV(生 − 平滑化)・構造 CoV・輪帯平均 min/max・画像全体 min/max + (ノイズ補正あり/なし)・ノイズ場の極値 +- 環境: Newton GPU 0(RTX A6000)・cuda_ad_rgb・mitsuba 3.7.1・`SM_DRJIT_THREADS=16`・逐次実行 + +## 結果 1 — 計算時間: GPU は spp に比例,固定費は 0.8 秒 (Time) + +6 条件 × 2 seed の平均. + +| spp | 全体 [s] | GPU(`mi.render`)[s] | CPU 固定費 [s] | GPU の割合 | +| --- | --- | --- | --- | --- | +| 256 | 2.0 | 1.2 | 0.8 | 60% | +| 512 | 3.2 | 2.5 | 0.8 | 76% | +| 1024 | 5.9 | 5.1 | 0.8 | 87% | +| 2048 | 11.1 | 10.3 | 0.8 | 93% | +| 4096 | 20.9 | 20.1 | 0.8 | 96% | +| 8192 | 40.6 | 39.8 | 0.7 | 98% | + +- **GPU 時間は spp に厳密に比例**(1024 spp あたり 5.0 秒.条件による差は ±10%.球がやや遅い) +- **固定費は 0.75〜0.8 秒で spp に依らず一定**.内訳は PLY 書き出し 0.34 秒・評価(図なし)0.29 秒・ + 保存 0.12 秒・YAML 読込とシーン読込(OptiX の BVH 構築を含む)は各 0.01 秒 +- spp ≥ 1024 で固定費は 1 割以下.CPU 側をこれ以上削る意味は薄い.256 では 4 割を占めるので, + 低 spp を多数回す用途なら筐体メッシュの PLY キャッシュが効く + +## 結果 2 — ノイズは 1/√spp 則に正確に乗る (Noise) + +| 条件 | 生 mean | ノイズ CoV(1024) | ノイズ × √spp(6 段階の範囲) | +| --- | --- | --- | --- | +| 球 φ340 軸上 | 0.355 | 0.092 | 2.93〜2.99 | +| 球 φ340 θ=90° | 0.323 | 0.145 | 4.63〜4.67 | +| 立方体 L=234 軸上 | 0.387 | 0.104 | 3.30〜3.32 | +| 立方体 L=234 θ=90° | 0.356 | 0.156 | 4.97〜5.08 | +| 球 φ500 θ=90° | 0.153 | 0.189 | 5.84〜6.05 | +| 立方体 L=340 θ=90° | 0.174 | 0.200 | 6.23〜6.38 | + +- ノイズ × √spp は全条件で ±1〜2% 以内で一定=**MC ノイズだけで,レンダ側に別のノイズ源は無い** +- ノイズ量は生の輝度で決まる(暗いほど大きい)が,同じ明るさでも軸上より θ=90° が 1.5 倍大きい + (オフ軸は一次照射キャップの経路分散が大きい).大キャビティを球 φ340 軸上と同じノイズにするには + spp を 4〜5 倍要る +- 画像全体 min/max のノイズ補正量(ノイズ場の max − min)も 1/√spp で減り,8192 で平均の 1% 以下 + +## 結果 3 — 指標の安定性 (Metric Stability) + +`spp_noise.png` 下段.誤差棒は seed 2 つの差の半分. + +| 指標 | 1024 での状態 | 安定する spp | +| --- | --- | --- | +| 構造 CoV | 256→1024 で 5〜15% 低下後ほぼ一定.seed 差 ≤0.0005 | **1024 で十分**(8192 との差 ≤0.002) | +| 画像全体 min/max(補正あり) | 明るい条件は安定(seed 差 0.003).暗い φ500・L=340 は 0.93〜0.94 で seed 差 0.01 | **2048〜4096**(暗い条件が 0.95〜0.96 に収束し seed 差 0.002) | +| 画像全体 min/max(補正なし) | どの条件も spp とともに上昇し続ける | 8192 でも収束しない(ノイズ補正は必須) | +| 輪帯平均 min/max | θ=90° で 0.97〜0.99 に張り付き,seed 差 0.003〜0.01 | spp では改善しない(方位平均の問題) | + +- **spp=1024 は構造 CoV には十分だが,画像全体 min/max 比は暗い条件で 0.01〜0.02 過小評価**していた + (TEST_18 の min の値のうち球 φ500・立方体 L=340 はその分だけ低めに出ている.順位は変わらない) +- 補正なしの平滑化 min/max は spp を 32 倍にしても収束せず,補正の必要性を裏付ける + +## 結論と推奨 (Conclusions) + +- **標準 spp**: 構造 CoV・方位フーリエ・重心で比べる実験は **1024** のまま(5.9 秒/条件).画像全体 + min/max 比を暗い条件(生 mean < 0.2)まで含めて比べる実験は **2048〜4096**(11〜21 秒/条件. + 255 条件で 50〜90 分) +- **CPU 側の最適化はここまで**.固定費 0.8 秒は spp ≥ 1024 で 1 割以下 +- ノイズは 1/√spp 則に乗るので,必要な spp は「目標ノイズ ÷ 1024 でのノイズ」の 2 乗で見積もれる + +## 限界・注意点 (Limitations) + +- 16384 は未実施.傾向は 8192 までで単調に収束しているため結論は変わらない見込み +- nvidia-smi のサンプリングは,ジョブ再起動時の `pkill` がサンプラー自身を止めたため記録が無い. + GPU 時間は `dr.sync_thread()` で同期した実測で取れているので結論には影響しない +- 条件は 6 つ(1 灯・板向き).多灯やバッフル無しは未測定だが,ノイズは生の輝度で決まるので + 同じ関係が成り立つはず +- 時間は逐次実行の値.`--workers 2` では 1 条件あたり 1 割ほど短くなる(ENV_04) + +## 失敗パターン・方法論の学び (Pitfalls and Methodology) + +- **`mi.render` の時間は同期してから測る**.cuda variant では非同期に GPU へ投げられるので, + `dr.eval` + `dr.sync_thread()` を挟まないと数十 ms に見える +- **サーバへの push は `--only scripts` だけだと足りないことがある**.新しい src モジュール + (`evaluation/fullimage.py`)を使うスクリプトは,全体を push してから回す +- **併走プロセスの `pkill -f` は再起動時に自分も殺す**.サンプラーを再起動するなら先に止めてから + 起動する順序を守るか,PID ファイルで管理する + +## 再現コマンド (Reproduction) + +```bash +# YAML 生成(TEST_18 の _tmp_led_placement を流用するので先に sweep_led_placement.py --prepare-only) +uv run python scripts/sweep_spp_noise.py --prepare-only + +# サーバで実行(GPU 0).spp を絞るなら --spps 256 512 1024 +uv run sm-remote push +uv run sm-remote run --name spp --gpu 0 --threads 16 -- python scripts/sweep_spp_noise.py +uv run sm-remote pull spp_noise_sweep + +# 解析(図と表) +uv run python scripts/build_spp_noise_comparison.py +``` + +出力: `output/spp_noise_sweep/`(レンダ別 72 ディレクトリ + `summary.csv` + `spp_summary_table.csv` + +`spp_noise.png`). + +## 参照 (References) + +- [TEST_18_照明配置依存性の球と立方体の比較](TEST_18_照明配置依存性の球と立方体の比較.md): 流用した条件と指標の定義 +- [SPEC_04_評価仕様](../04_SPEC/SPEC_04_評価仕様.md): 画像全体 min/max 比の定義と注意 +- [ENV_04_開発コマンド](../02_ENV/ENV_04_開発コマンド.md): 高速化の内訳と `--workers` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 39c2d5c..3acd6e7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -104,3 +104,5 @@ - **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 の文書・図.条件名は半径のまま). + +- **TEST_19 spp と計算時間・ノイズ・指標の安定性 (2026-09-14)**: ユーザー発案「GPU で速くなったので spp を上げてノイズと計算時間の関係を測りたい.全体のうち GPU が何秒かも知りたい」.TEST_18 の 6 条件を spp 256〜8192 × seed 2 で回し,段階別の壁時計(`mi.render` は `dr.sync_thread` で同期)を記録した.**結果**: GPU 時間は spp に厳密に比例(1024 で 5.1 秒),CPU 側の固定費は 0.8 秒で一定(PLY 書き出し・評価・保存)=spp ≥ 1024 で 1 割以下なので CPU 側の最適化はここまで.ノイズは全条件で 1/√spp 則に ±2% で一致(レンダ側に別のノイズ源は無い.ノイズ量は生の輝度で決まり,オフ軸は軸上の 1.5 倍).構造 CoV は 1024 で安定(8192 との差 ≤0.002)だが,画像全体 min/max 比は暗い大キャビティで 1024 だと 0.01〜0.02 過小評価で 2048〜4096 で収束.補正なしの平滑化 min/max は 8192 でも収束せず,輪帯平均 min/max は spp では改善しない(方位平均の問題).**推奨**: 構造 CoV の実験は 1024 のまま,min/max 比を暗い条件まで比べるなら 2048〜4096.**失敗パターン**: ① `--only scripts` の push では新しい src モジュール(fullimage.py)が無くて落ちた → 全体を push / ② サンプラー再起動の `pkill -f` が自分自身を殺して nvidia-smi の記録が残らなかった(結論には影響なし).16384 はユーザー都合で未実施. diff --git a/scripts/build_spp_noise_comparison.py b/scripts/build_spp_noise_comparison.py new file mode 100644 index 0000000..7206b72 --- /dev/null +++ b/scripts/build_spp_noise_comparison.py @@ -0,0 +1,276 @@ +"""spp スイープの解析: 計算時間・GPU 率・ノイズ・指標の安定性を spp に対して描く. + +sweep_spp_noise.py の summary.csv(と併走した nvidia-smi のサンプル gpu_util.csv)を読み, + +1. spp vs 計算時間(段階別の積み上げ)と GPU 率(mi.render / 全体) +2. spp vs ノイズ CoV(1/sqrt(spp) の理論線付き)と生の輝度依存 +3. spp vs 指標(構造 CoV・輪帯 min/max・画像全体 min/max の補正あり/なし).seed 2 つの差も + +を出力する(output/spp_noise_sweep/spp_noise.png・spp_summary_table.csv). + +実行方法: + uv run python scripts/build_spp_noise_comparison.py +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from sweep_spp_noise import CONDITIONS, OUTPUT_BASE, SPPS # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +COND_LABEL = { + "sphere_d340_th000": "球 φ340 軸上", + "sphere_d340_th090": "球 φ340 θ=90°", + "cube_L234_th000": "立方体 L=234 軸上", + "cube_L234_th090": "立方体 L=234 θ=90°", + "sphere_d500_th090": "球 φ500 θ=90°", + "cube_L340_th090": "立方体 L=340 θ=90°", +} +STAGES = [("t_load_yaml", "YAML 読込"), ("t_build_scene", "PLY 書き出し"), + ("t_load_dict", "シーン読込"), ("t_render", "mi.render(GPU)"), + ("t_save", "保存"), ("t_evaluate", "評価")] + + +def load_rows() -> list[dict]: + """summary.csv を数値化して読む.""" + rows = [] + with (OUTPUT_BASE / "summary.csv").open(encoding="utf-8") as fh: + for r in csv.DictReader(fh): + row = {"name": r["name"], "condition": r["condition"], + "render_variant": r["render_variant"]} + for k, v in r.items(): + if k not in row: + row[k] = float(v) if v not in ("", None) else float("nan") + rows.append(row) + return rows + + +def by_condition_spp(rows: list[dict], key: str) -> dict[str, dict[int, tuple[float, float]]]: + """条件 → spp → (seed 平均, seed 差の半分) を返す.""" + out: dict[str, dict[int, tuple[float, float]]] = {} + for cond, _ in CONDITIONS: + out[cond] = {} + for spp in SPPS: + vals = [r[key] for r in rows if r["condition"] == cond and int(r["spp"]) == spp] + if vals: + spread = float(np.ptp(vals)) / 2.0 if len(vals) > 1 else 0.0 + out[cond][spp] = (float(np.mean(vals)), spread) + return out + + +def gpu_util_summary() -> str: + """nvidia-smi サンプルの要約(あれば).""" + path = OUTPUT_BASE / "gpu_util.csv" + if not path.exists(): + return "" + utils = [] + with path.open(encoding="utf-8") as fh: + for r in csv.DictReader(fh): + try: + utils.append(float(r["gpu_util"])) + except (ValueError, KeyError): + continue + if not utils: + return "" + arr = np.array(utils) + return (f"nvidia-smi サンプル {len(arr)} 点: 使用率 平均 {arr.mean():.0f}%・" + f"稼働(>0%)の割合 {np.mean(arr > 0) * 100:.0f}%") + + +def draw(rows: list[dict], output: Path) -> None: + """3 行 × 3 列の図を描く.""" + fig, axes = plt.subplots(3, 3, figsize=(20, 15)) + colors = plt.cm.tab10(np.linspace(0, 1, 10))[: len(CONDITIONS)] + + # 1-1 段階別の時間(代表条件 球 φ340 θ=90 の seed 平均を積み上げ) + ax = axes[0, 0] + rep = "sphere_d340_th090" + bottom = np.zeros(len(SPPS)) + for key, label in STAGES: + series = by_condition_spp(rows, key)[rep] + vals = np.array([series.get(s, (np.nan, 0))[0] for s in SPPS]) + ax.bar(range(len(SPPS)), vals, bottom=bottom, label=label) + bottom += np.nan_to_num(vals) + ax.set_xticks(range(len(SPPS))) + ax.set_xticklabels([str(s) for s in SPPS]) + ax.set_xlabel("spp") + ax.set_ylabel("時間 [s]") + ax.set_title(f"段階別の時間({COND_LABEL[rep]})", fontsize=11) + ax.legend(fontsize=8) + ax.grid(alpha=0.3, axis="y") + + # 1-2 全体時間 vs spp(全条件)と render 時間 + ax = axes[0, 1] + total = by_condition_spp(rows, "t_total") + render = by_condition_spp(rows, "t_render") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in total[cond]] + ax.plot(xs, [total[cond][s][0] for s in xs], marker="o", color=c, label=COND_LABEL[cond]) + ax.plot(xs, [render[cond][s][0] for s in xs], marker="x", ls=":", color=c, alpha=0.7) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("時間 [s](実線=全体,点線=mi.render)") + ax.set_title("計算時間 vs spp", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 1-3 GPU 率 + ax = axes[0, 2] + frac = by_condition_spp(rows, "gpu_fraction") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in frac[cond]] + ax.plot(xs, [frac[cond][s][0] * 100 for s in xs], marker="o", color=c, + label=COND_LABEL[cond]) + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel("GPU 率 [%] = mi.render / 全体") + ax.set_title("処理時間に占める GPU(レンダ本体)の割合", fontsize=11) + ax.set_ylim(0, 100) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-1 ノイズ CoV vs spp + 理論線 + ax = axes[1, 0] + noise = by_condition_spp(rows, "noise_cov") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in noise[cond]] + ys = [noise[cond][s][0] for s in xs] + ax.plot(xs, ys, marker="o", color=c, label=COND_LABEL[cond]) + if 1024 in noise[cond]: + ref = noise[cond][1024][0] + ax.plot(xs, [ref * np.sqrt(1024 / s) for s in xs], ls=":", color=c, alpha=0.6) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("残差ノイズ CoV(点線 = 1/√spp の理論線,1024 基準)") + ax.set_title("ノイズ vs spp", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-2 ノイズ CoV × sqrt(spp) の一定性(生の輝度に対して) + ax = axes[1, 1] + mean = by_condition_spp(rows, "raw_mean_roi") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in noise[cond]] + ax.plot(xs, [noise[cond][s][0] * np.sqrt(s) for s in xs], marker="o", color=c, + label=f"{COND_LABEL[cond]}(生 mean {mean[cond].get(1024, (np.nan, 0))[0]:.2f})") + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel("ノイズ CoV × √spp(一定なら 1/√spp 則)") + ax.set_title("1/√spp 則の検証(生の輝度が低いほど大きい)", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 2-3 ノイズ場の極値(補正量) + ax = axes[1, 2] + nmin = by_condition_spp(rows, "fullimage_noise_min") + nmax = by_condition_spp(rows, "fullimage_noise_max") + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in nmin[cond]] + m = [mean[cond][s][0] for s in xs] + spans = [(nmax[cond][s][0] - nmin[cond][s][0]) / mm for s, mm in zip(xs, m, strict=True)] + ax.plot(xs, spans, marker="o", color=c, label=COND_LABEL[cond]) + ax.set_xscale("log", base=2) + ax.set_yscale("log") + ax.set_xlabel("spp") + ax.set_ylabel("ノイズ場の (max − min) / 平均") + ax.set_title("画像全体 min/max のノイズ補正量", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + # 3-x 指標の安定性 + for ax, key, title in ((axes[2, 0], "structure_cov", "構造 CoV"), + (axes[2, 1], "ring_min_max", "輪帯平均 min/max"), + (axes[2, 2], "fullimage_min_max", "画像全体 min/max(ノイズ補正)")): + series = by_condition_spp(rows, key) + for (cond, _), c in zip(CONDITIONS, colors, strict=True): + xs = [s for s in SPPS if s in series[cond]] + ax.errorbar(xs, [series[cond][s][0] for s in xs], + yerr=[series[cond][s][1] for s in xs], marker="o", capsize=3, + color=c, label=COND_LABEL[cond]) + if key == "fullimage_min_max": + raw = by_condition_spp(rows, "fullimage_raw_min_max") + ax.plot(xs, [raw[cond][s][0] for s in xs], ls=":", color=c, alpha=0.6) + ax.set_xscale("log", base=2) + ax.set_xlabel("spp") + ax.set_ylabel(title + ("(点線 = 補正なし)" if key == "fullimage_min_max" else "")) + ax.set_title(f"{title} vs spp(誤差棒 = seed 2 つの差の半分)", fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + note = gpu_util_summary() + fig.suptitle("spp スイープ: 計算時間・GPU 率・ノイズ・指標の安定性" + "(Newton GPU 0・cuda_ad_rgb・512²)" + (f"\n{note}" if note else ""), + fontsize=13) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=100) + plt.close(fig) + print(f"出力: {output}") + + +def print_table(rows: list[dict]) -> None: + """spp ごとの要約表(条件平均)を出す.""" + print(f"\n{'spp':>6} {'全体[s]':>8} {'render[s]':>10} {'固定費[s]':>9} {'GPU率':>6} " + f"{'noise CoV':>10} {'noise×√spp':>11} {'構造CoV seed差':>14} {'min seed差':>11}") + out_rows = [] + for spp in SPPS: + sel = [r for r in rows if int(r["spp"]) == spp] + if not sel: + continue + total = np.mean([r["t_total"] for r in sel]) + render = np.mean([r["t_render"] for r in sel]) + noise = np.mean([r["noise_cov"] for r in sel]) + struct_diff, min_diff = [], [] + for cond, _ in CONDITIONS: + c = [r for r in sel if r["condition"] == cond] + if len(c) >= 2: + struct_diff.append(abs(c[0]["structure_cov"] - c[1]["structure_cov"])) + min_diff.append(abs(c[0]["fullimage_min_max"] - c[1]["fullimage_min_max"])) + row = {"spp": spp, "t_total": total, "t_render": render, "t_fixed": total - render, + "gpu_fraction": render / total, "noise_cov": noise, + "noise_x_sqrt": noise * np.sqrt(spp), + "structure_seed_diff": float(np.mean(struct_diff)) if struct_diff else np.nan, + "min_seed_diff": float(np.mean(min_diff)) if min_diff else np.nan} + out_rows.append(row) + print(f"{spp:>6} {total:8.1f} {render:10.1f} {total - render:9.1f} " + f"{render / total * 100:5.0f}% {noise:10.4f} {noise * np.sqrt(spp):11.3f} " + f"{row['structure_seed_diff']:14.4f} {row['min_seed_diff']:11.4f}") + with (OUTPUT_BASE / "spp_summary_table.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(out_rows[0].keys())) + writer.writeheader() + writer.writerows(out_rows) + note = gpu_util_summary() + if note: + print(note) + + +def main() -> int: + """spp スイープの図と表を出す.""" + parser = argparse.ArgumentParser(description="spp スイープの解析") + parser.parse_args() + rows = load_rows() + if not rows: + print("エラー: summary.csv が空です") + return 1 + draw(rows, OUTPUT_BASE / "spp_noise.png") + print_table(rows) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sample_gpu_util.sh b/scripts/sample_gpu_util.sh new file mode 100644 index 0000000..cfbae7b --- /dev/null +++ b/scripts/sample_gpu_util.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# GPU 使用率を一定間隔でサンプリングして CSV に追記する(spp スイープの GPU 率の裏取り用). +# サーバ上で tmux の中から起動する: +# nohup bash scripts/sample_gpu_util.sh 0 0.5 output/spp_noise_sweep/gpu_util.csv > /dev/null 2>&1 & +# 止めるときは pkill -f sample_gpu_util.sh +set -u +GPU="${1:-0}" +INTERVAL="${2:-0.5}" +OUT="${3:-output/gpu_util.csv}" +mkdir -p "$(dirname "$OUT")" +echo "timestamp,gpu_util,mem_used_mib" >> "$OUT" +while true; do + line=$(nvidia-smi -i "$GPU" --query-gpu=utilization.gpu,memory.used --format=csv,noheader,nounits 2>/dev/null | tr -d ' ') + echo "$(date +%s.%N),$line" >> "$OUT" + sleep "$INTERVAL" +done diff --git a/scripts/sweep_spp_noise.py b/scripts/sweep_spp_noise.py new file mode 100644 index 0000000..8af4d4f --- /dev/null +++ b/scripts/sweep_spp_noise.py @@ -0,0 +1,285 @@ +"""spp スイープ: サンプル数に対する計算時間・GPU 率・ノイズ・指標の安定性を測る. + +TEST_18 までは spp=1024 固定で,大きいキャビティほど生の輝度が低く相対ノイズが 7〜26% に +達していた(指標はノイズ補正で救っている).研究室サーバの GPU で 1 条件 5 秒になったので, +spp を上げたときに (1) 計算時間がどう増えるか(CPU 側の固定費と GPU 側の spp 比例部分), +(2) ノイズが理論どおり 1/sqrt(spp) で下がるか,(3) 各指標(構造 CoV・輪帯 min/max・ +画像全体 min/max)がどの spp で安定するかを測り,今後の標準 spp を決める. + +条件(TEST_18 の既存 YAML を流用し spp と seed だけ書き換える): + 球 φ340(r=170)θ=0/90,立方体 L=234 θ=0/90,球 φ500(r=250)θ=90,立方体 L=340 θ=90 + × spp = 256〜16384(2 倍刻み 7 段階)× seed 2 つ + +計測: + - 段階別の時間(YAML 読込 / build_scene=PLY 書き出し / load_dict / mi.render / 保存 / 評価). + mi.render は dr.sync_thread で GPU の完了を待ってから測る.GPU 率 = render / 全体 + - 残差ノイズ CoV(生 − 平滑化)・生の画素 CoV・構造 CoV・輪帯 min/max・画像全体 min/max + (ノイズ補正あり / なし)・ノイズ場の極値 + - 併走する nvidia-smi のサンプリング(sm-remote run の外で回す)は解析側で突き合わせる + +実行方法: + uv run python scripts/sweep_spp_noise.py --prepare-only + uv run sm-remote run --name spp --gpu 0 --threads 16 -- python scripts/sweep_spp_noise.py +""" + +from __future__ import annotations + +import argparse +import copy +import csv +import json +import os +import shutil +import sys +import time +from pathlib import Path + +import numpy as np +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from build_radial_comparison import radial_stats, ring_min_max_ratio # noqa: E402 +from build_radius_sweep_visual import decompose_cov # noqa: E402 +from sweep_led_placement import TMP_DIR as PLACEMENT_TMP_DIR # noqa: E402 +from sweep_sphere_radius import EVAL_CONFIG_YAML # noqa: E402 + +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402 +from sm_tias_lightsim.evaluation.pipeline import evaluate_image # noqa: E402 +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 +from sm_tias_lightsim.runtime.overrides import resolve_render_variant # noqa: E402 +from sm_tias_lightsim.scene.schema import load_scene_yaml # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +TMP_DIR = ROOT / "configs" / "_tmp_spp_noise" +OUTPUT_BASE = ROOT / "output" / "spp_noise_sweep" + +# (表示名, TEST_18 の条件名) +CONDITIONS = [ + ("sphere_d340_th000", "sphere_r170_th000_board_n1"), + ("sphere_d340_th090", "sphere_r170_th090_board_n1"), + ("cube_L234_th000", "cube_L234_th000_board_n1"), + ("cube_L234_th090", "cube_L234_th090_board_n1"), + ("sphere_d500_th090", "sphere_r250_th090_board_n1"), + ("cube_L340_th090", "cube_L340_th090_board_n1"), +] +SPPS = [256, 512, 1024, 2048, 4096, 8192, 16384] +SEEDS = [0, 1] + +SUMMARY_COLUMNS = [ + "name", "condition", "spp", "seed", "render_variant", + "t_total", "t_load_yaml", "t_build_scene", "t_load_dict", "t_render", "t_save", "t_evaluate", + "gpu_fraction", "t_render_per_kspp", + "raw_mean_roi", "pixel_cov", "noise_cov", "structure_cov", "ring_min_max", + "fullimage_min_max", "fullimage_raw_min_max", "fullimage_noise_min", "fullimage_noise_max", + "radial_min_max_ratio", "cov", +] + + +def job_name(condition: str, spp: int, seed: int) -> str: + """出力ディレクトリ名.""" + return f"{condition}_spp{spp}_s{seed}" + + +def prepare(log) -> dict[str, Path]: + """TEST_18 の YAML を spp・seed だけ書き換えて書き出す(メッシュは元の場所を相対参照).""" + scenes: dict[str, Path] = {} + TMP_DIR.mkdir(parents=True, exist_ok=True) + for display, source_name in CONDITIONS: + source = PLACEMENT_TMP_DIR / f"scene_{source_name}.yaml" + if not source.exists(): + raise FileNotFoundError( + f"{source} がありません.先に sweep_led_placement.py --prepare-only を実行" + ) + config = yaml.safe_load(source.read_text(encoding="utf-8")) + for mesh in config["geometry"]["meshes"]: + path = Path(mesh["path"]) + if not path.is_absolute(): + # 元 YAML からの相対パスを,新しい YAML からの相対パスに付け替える + target = (source.parent / path).resolve() + mesh["path"] = Path(os.path.relpath(target, TMP_DIR)).as_posix() + for spp in SPPS: + for seed in SEEDS: + cfg = copy.deepcopy(config) + cfg["name"] = f"spp_noise_{job_name(display, spp, seed)}" + cfg["render"]["spp"] = spp + cfg["render"]["seed"] = seed + dest = TMP_DIR / f"scene_{job_name(display, spp, seed)}.yaml" + dest.write_text(yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), + encoding="utf-8") + scenes[job_name(display, spp, seed)] = dest + log(f"=== 幾何流用: {len(CONDITIONS)} 条件 × {len(SPPS)} spp × {len(SEEDS)} seed = " + f"{len(scenes)} レンダ(YAML: {TMP_DIR})===") + return scenes + + +def timed_render(scene_yaml: Path, out_dir: Path, variant: str) -> tuple[dict, np.ndarray]: + """段階別に時間を測りながらレンダし,(時間の dict, HDR 画像) を返す. + + render/pipeline.render_yaml と同じ手順を手でほどいたもの(段階ごとの時間を取るため). + mi.render は非同期に GPU へ投げられるので,dr.sync_thread() で完了を待ってから測る. + """ + from sm_tias_lightsim.render.runner import ensure_variant + from sm_tias_lightsim.scene.builder import build_scene + + times: dict[str, float] = {} + t0 = time.perf_counter() + config = load_scene_yaml(scene_yaml) + render_cfg = config.render.model_copy(update={"variant": variant}) + config = config.model_copy(update={"render": render_cfg}) + t1 = time.perf_counter() + times["t_load_yaml"] = t1 - t0 + + ensure_variant(variant) + import drjit as dr + import mitsuba as mi + + scene_dict, tempdir = build_scene(config, project_root=scene_yaml.parent) + t2 = time.perf_counter() + times["t_build_scene"] = t2 - t1 + try: + scene = mi.load_dict(scene_dict) + dr.sync_thread() + t3 = time.perf_counter() + times["t_load_dict"] = t3 - t2 + image = mi.render(scene, spp=config.render.spp, seed=config.render.seed) + dr.eval(image) + dr.sync_thread() + arr = np.array(image) + t4 = time.perf_counter() + times["t_render"] = t4 - t3 + finally: + shutil.rmtree(tempdir, ignore_errors=True) + from sm_tias_lightsim.render.output import save_luminance, save_png + + out_dir.mkdir(parents=True, exist_ok=True) + save_png(arr, out_dir / "render.png") + save_luminance(arr, out_dir / "luminance.npy") + times["t_save"] = time.perf_counter() - t4 + return times, arr + + +def noise_metrics(luminance_npy: Path) -> dict[str, float]: + """ノイズと均一性の指標を luminance.npy から計算する.""" + arr = np.load(luminance_npy).astype(np.float64) + roi = extract_roi_xywh(arr, SIM_ROI) + pixel_cov, structure, noise = decompose_cov(luminance_npy) + centers, means, _ = radial_stats(luminance_npy, 20) + full = calc_fullimage_min_max_ratio(roi) + from scipy.ndimage import gaussian_filter + + smooth = gaussian_filter(roi, sigma=12.0) + return { + "raw_mean_roi": float(roi.mean()), + "pixel_cov": pixel_cov, + "noise_cov": noise, + "structure_cov": structure, + "ring_min_max": ring_min_max_ratio(centers, means), + "fullimage_min_max": full["fullimage_min_max_ratio"], + "fullimage_raw_min_max": float(smooth.min() / smooth.max()), + "fullimage_noise_min": full["fullimage_noise_min"], + "fullimage_noise_max": full["fullimage_noise_max"], + } + + +def run_job(name: str, condition: str, spp: int, seed: int, scene_yaml: Path, + variant: str, log) -> dict | None: + """1 レンダを計測付きで実行し row.json を書く(既存は再利用).""" + out_dir = OUTPUT_BASE / name + row_json = out_dir / "row.json" + if row_json.exists(): + log(f"--- {name}: 既存結果を再利用") + return json.loads(row_json.read_text(encoding="utf-8")) + log(f"--- {name}: レンダ開始") + t_start = time.perf_counter() + try: + times, _ = timed_render(scene_yaml, out_dir, variant) + t_eval0 = time.perf_counter() + import io + from contextlib import redirect_stdout + + with redirect_stdout(io.StringIO()): + metrics = evaluate_image(out_dir / "luminance.npy", EVAL_CONFIG_YAML, out_dir, + save_figures=False) + times["t_evaluate"] = time.perf_counter() - t_eval0 + except Exception as exc: # noqa: BLE001 - 1 条件の失敗でスイープを止めない + log(f" !! 失敗: {exc}") + return None + times["t_total"] = time.perf_counter() - t_start + row: dict = {"name": name, "condition": condition, "spp": spp, "seed": seed, + "render_variant": variant} + row.update({k: round(v, 4) for k, v in times.items()}) + row["gpu_fraction"] = round(times["t_render"] / times["t_total"], 4) + row["t_render_per_kspp"] = round(times["t_render"] / (spp / 1024.0), 4) + row.update(noise_metrics(out_dir / "luminance.npy")) + row["radial_min_max_ratio"] = float(metrics.get("radial_min_max_ratio", float("nan"))) + row["cov"] = float(metrics["cov"]) + row_json.write_text(json.dumps(row, ensure_ascii=False, indent=1), encoding="utf-8") + log(f" -> 全体 {row['t_total']:.1f}s(render {row['t_render']:.1f}s = " + f"{row['gpu_fraction'] * 100:.0f}%)noise={row['noise_cov']:.4f} " + f"structure={row['structure_cov']:.4f} min={row['fullimage_min_max']:.4f}") + return row + + +def write_summary() -> Path: + """全 row.json から集約 CSV を書く.""" + rows = [json.loads(p.read_text(encoding="utf-8")) + for p in sorted(OUTPUT_BASE.glob("*/row.json"))] + order = {job_name(d, s, k): i for i, (d, s, k) + in enumerate((d, s, k) for d, _ in CONDITIONS for s in SPPS for k in SEEDS)} + rows.sort(key=lambda r: order.get(r["name"], len(order))) + path = OUTPUT_BASE / "summary.csv" + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS) + writer.writeheader() + for r in rows: + writer.writerow({k: r.get(k, "") for k in SUMMARY_COLUMNS}) + return path + + +def main() -> int: + """spp スイープを実行する.""" + parser = argparse.ArgumentParser(description="spp スイープ(計算時間・GPU 率・ノイズ)") + parser.add_argument("--prepare-only", action="store_true", help="YAML の生成だけ行う") + parser.add_argument("--spps", type=int, nargs="*", default=None, help="実行する spp を絞る") + parser.add_argument("--conditions", nargs="*", default=None, help="実行する条件名を絞る") + args = parser.parse_args() + + log_path = ROOT / "output" / "_sweep_spp_noise.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = log_path.open("a", encoding="utf-8") + + def log(message: str) -> None: + print(message, flush=True) + log_file.write(message + "\n") + log_file.flush() + + scenes = prepare(log) + if args.prepare_only: + log_file.close() + return 0 + template = yaml.safe_load(next(iter(scenes.values())).read_text(encoding="utf-8")) + variant = resolve_render_variant(str(template["render"]["variant"])) + log(f"=== variant={variant} ===") + done = 0 + # spp の小さい順に全条件を回す(途中で止めても低 spp 側が揃う) + for spp in SPPS: + if args.spps and spp not in args.spps: + continue + for display, _ in CONDITIONS: + if args.conditions and display not in args.conditions: + continue + for seed in SEEDS: + name = job_name(display, spp, seed) + row = run_job(name, display, spp, seed, scenes[name], variant, log) + if row is not None: + done += 1 + write_summary() + log(f"=== 完了: {done} レンダ.集約 CSV: {write_summary()} ===") + log_file.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main())