diff --git a/CLAUDE.md b/CLAUDE.md index c78c9c9..2739976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 7(実装)— 幾何レバーの探索に移行し,**[TEST_15](docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md) バッフル導入と球半径スイープ**を実施.LED を評価板と反対側の**壁面に密着**させて評価板へ向け,直前のバッフルで直接光を遮る構成を新設(遮蔽は指標ではなく**レイキャストで直接検証**=55,473 本が全条件 100% 遮蔽).**同じ r=100 で構造 CoV 0.0579→0.0312(46% 減)・上下の傾きも消失**=バッフルは有効.一方 **半径を上げると悪化**するという想定と逆の結果が出たため原因を追ったところ,接続円を固定する設計では r≥120 で**球の頂点が評価板より下にもぐり**接合の傾きが 11 倍に急峻化していた.**キャビティ全体を半径に相似させロフトを 5 次 Hermite にして接合部で曲率まで球に一致させると傾向が逆転**し,輪帯 min/max は半径とともに改善に転じた(r=250 で 0.779→0.891=全条件で最良).**方法論**: 絶対輝度が 1/r² で落ちるため生の CoV は MC ノイズ支配で読めない.**同心円プロファイルの方位平均**なら標準誤差が条件間の差の 1/20〜1/100 になり公平に比較できる.**未実施**: `similar_g2` は 3 半径のみ(残り 7 半径・ノイズフロア未測定).詳細な経緯・設計判断・失敗パターンは [docs/PROGRESS.md](docs/PROGRESS.md). +最新: フェーズ 7(実装)— 幾何レバーの探索として **[TEST_15](docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md) バッフル導入と球半径スイープ**を実施.LED を評価板と反対側の**壁面に密着**させて評価板へ向け,直前のバッフルで直接光を遮る構成を新設(遮蔽は指標ではなく**レイキャストで直接検証**=55,473 本が全条件 100% 遮蔽).同じ r=100 で構造 CoV 0.0579→0.0312.**当初「球を大きくすると悪化」という想定と逆の結果が出た**が,原因はロフト接合の幾何だった.**効き目の序列は 板直下の「喉」> 接合の相似・G2 化 > 球半径そのもの**.リムで壁を板に垂直に立ち上げると喉ができ外側の落ち込みを支配する.ロフトを 2 次元パラメトリック化して**リム接線を半径方向(水平)**に取り喉を消すと,外側の落ち込みは r=250 で 17.97%→**2.27%**,構造 CoV **0.0145**・輪帯 min/max **0.944**=出発点(旧 axial_wall r=100)の **4.0 倍**.内側・外側とも半径で単調改善し頭打ち未到達(ただし r≲95 では逆転).**方法論**: 絶対輝度が 1/r² で落ちるため生の CoV は MC ノイズ支配で読めない.**同心円プロファイルの方位平均**で比較し,輝度マップは**20 分割の輪帯平均の最大値**で正規化する(画素最大値は firefly に張り付く).**未実施**: リム張り出し量の最適化・`similar_g2` の残り 7 半径・ノイズフロア.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -147,4 +147,4 @@ - 輝度マップ形状追跡: docs/06_TEST/TEST_03_輝度マップ形状追跡.md(数値とは独立に「形」を実機に近づける観点.比較パネル画像と LSC モデル化の構造的意義) - 物理モデル健全性の一括監査: docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(「無警告で結果だけが歪む」故障モードの横断監査.解析解による絶対放射量検証・法線監査・打ち切りバイアス・常設した再発防止テスト) - 材質パラメータ再スイープと残差の構造分解: docs/06_TEST/TEST_14_材質パラメータ再スイープと残差の構造分解.md(面法線修正後の ρ×ω×α 全 60 条件スイープ.ρ ≈ 0.81 純 diffuse の確定と,材質では直せない傾き成分の分離) -- バッフル導入と球半径スイープ: docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md(LED 壁面密着+バッフルで直接光を遮る新構成と,球内半径 88〜250mm のスイープ.ロフト接合の作り直しで「大きいほど悪化」が逆転した経緯) +- バッフル導入と球半径スイープ: docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md(LED 壁面密着+バッフルで直接光を遮る新構成と,球内半径 88〜250mm のスイープ.ロフト接合の 2 段階の作り直しで「大きいほど悪化」が逆転し,板直下の喉が最大のレバーと判明した経緯) 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 1ce126b..f7f4407 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" @@ -85,7 +85,8 @@ | `output/_slide_panels/full_vs_singles_fix.png` | TEST_12 追補 | — | — | シミュ(面法線修正後)×実機の 2 行 × 全灯/bottom/left/right 4 列パネル(全セル同一平滑化・NCC 対実機併記) | 全灯と単灯の形の対応比較(`scripts/build_full_vs_singles_panel.py` が生成).平滑後 p/m はシミュ 1.07 前後 vs 実機 1.10〜1.16 | | `output/sphere_radius_sweep/board_baffle/` (10 ディレクトリ) | TEST_15 | `configs/_tmp_sphere_radius/scene_board_baffle_fixed_cut_r*.yaml`(`scripts/sweep_sphere_radius.py` が生成.LED を -Z 極の壁面に密着させ評価板向きにし,直前にバッフル円板を置いた構成.接続円 85.417mm @ z=-40 を固定して球中心のみ移動.r ∈ {88, 95, 100, 110, 120, 135, 150, 170, 200, 250}mm.材質・spp=1024・d=16 は据え置き) | `configs/eval_baseline_dng_target_shape.yaml` | r=100 で構造 CoV 0.0312(旧 axial_wall 0.0579 の 46% 減).輪帯 min/max は r=135 の 0.873 を頂点に半径とともに悪化し r=250 で 0.779 — [TEST_15](TEST_15_バッフル導入と球半径スイープ.md) 参照 | **バッフルの効果を実証**.ただし接続円固定では球を大きくするほど周辺が悪化する(外側の落ち込み 6.4%→18.0%).**生 CoV は MC ノイズ支配なのでそのまま読まないこと** | | `output/sphere_radius_sweep/board_baffle_similar_g2/` (3 ディレクトリ) | TEST_15 | 同上の `--cavity similar_g2` 版(キャビティ全体を半径に相似 center_z=-0.92r・cut_z=-0.40r,ロフトを 5 次 Hermite にして接合部で曲率まで球に一致.r ∈ {100, 170, 250}mm) | 同上 | 外側の落ち込みが半減(r=250 で 18.0%→8.8%)し,輪帯 min/max が半径とともに**改善**に転じる(0.852→0.883→0.891) — [TEST_15](TEST_15_バッフル導入と球半径スイープ.md) 参照 | **接合を自然にすると傾向が逆転**.r=250 の 0.891 は全条件で最良.残り 7 半径は未実行 | -| `output/sphere_radius_sweep/*/summary.csv`・`{sweep_overview,radial_comparison}.png`・`cavity_{design,effect}_comparison.png`・`geometry_check_*.png`・`cutaway_*.png` | TEST_15 | — | — | 条件別の集約 CSV(幾何・生 mean・各指標)/輝度マップ一覧 + CoV 分解カーブ/同心円プロファイル比較/接合設計の断面 before-after/幾何確認図・半割り断面 | `scripts/build_radius_sweep_visual.py`・`build_radial_comparison.py`・`build_radius_geometry_figure.py`・`show_scene_cutaway.py` の出力.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | +| `output/sphere_radius_sweep/board_baffle_similar_radial/` (10 ディレクトリ) | TEST_15 | 同上の `--cavity similar_radial` 版(相似スケールのまま**ロフトのリム接線を半径方向(水平)**に取り,板直下の「喉」を無くした形.ロフトを (ρ, z) の 2 次元パラメトリック Hermite 化して実現.r ∈ 全 10 半径) | `configs/eval_baseline_dng_target_shape.yaml` | 外側の落ち込みが r=250 で 2.27%(fixed_cut 17.97% の 8 分の 1).輪帯 min/max は r=95 以降単調改善し r=250 で **0.944**,構造 CoV **0.0145** — [TEST_15](TEST_15_バッフル導入と球半径スイープ.md) 参照 | **全条件で最良**.旧 axial_wall r=100(0.0579 / 0.797)の 4.0 倍.内側・外側とも半径で改善し頭打ち未到達.ただし r≲95 では fixed_cut に負ける | +| `output/sphere_radius_sweep/*/summary.csv`・`{sweep_overview,radial_comparison}.png`・`cavity_comparison.png`・`cavity_{design,effect}_comparison.png`・`rim_tangent_comparison.png`・`geometry_check_*.png`・`cutaway_*.png` | TEST_15 | — | — | 条件別の集約 CSV(幾何・生 mean・各指標)/輝度マップ一覧 + CoV 分解カーブ/同心円プロファイル比較/接合設計の断面 before-after/幾何確認図・半割り断面 | `scripts/build_radius_sweep_visual.py`・`build_radial_comparison.py`・`build_cavity_comparison.py`・`build_radius_geometry_figure.py`・`show_scene_cutaway.py` の出力.輝度マップは 20 分割の輪帯平均の最大値を 1.0 に正規化(画素最大値は firefly に張り付くため).レンダ前 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 の視覚版) | ### sweep_summary.csv の値(参考、TEST_01 line と一致確認用) diff --git "a/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" "b/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" index 6225176..95f7c49 100644 --- "a/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" +++ "b/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" @@ -3,7 +3,8 @@ 球体筐体について,LED を評価板の反対側の壁面に密着させて評価板へ向け,直前のバッフルで 直接光を遮る構成を新設し,その上で球内半径を 88〜250mm でスイープした(2026-08-31). 均一性は同じ半径なら大きく改善したが,**球を大きくすると悪化する**という想定と逆の結果が出た. -原因をロフト接合の幾何に特定し,接合を作り直したところ傾向が逆転した. +原因をロフト接合の幾何に特定し,接合を 2 段階で作り直したところ傾向が逆転し, +最終的に**出発点(TEST_11 / TEST_12 の球)の 4.0 倍**まで均一性が上がった. 関連: @@ -64,13 +65,19 @@ ### 幾何のパラメータ化 (Cavity Parameterization) -半径を変えるときのキャビティ形状の決め方を 2 通り用意した.どちらも r=100 では既存 -`configs/scene_sphere_r100_loft_disk.yaml` の球中心・接続円と一致する. +半径を変えるときのキャビティ形状の決め方を 3 通り用意した.いずれも r=100 では既存 +`configs/scene_sphere_r100_loft_disk.yaml` の球中心・接続円と一致する(ロフトの曲線だけが違う). | 形状 | 定義 | ロフト | | --- | --- | --- | | `fixed_cut` | 接続円(85.417mm @ z=−40)を固定し球中心だけ動かす | 3 次 Hermite(接線のみ一致) | | `similar_g2` | キャビティ全体を半径に相似(center_z=−0.92r,cut_z=−0.40r) | 5 次 Hermite(接線 + 曲率を一致) | +| `similar_radial` | 同上の相似スケール | 2 次元パラメトリック Hermite(**リム接線を半径方向**に取る) | + +`fixed_cut` / `similar_g2` はどちらも「z を等間隔に進めながら半径 ρ(z) を決める」書き方なので, +リムでの傾きは dρ/dz = 0,すなわち**板に垂直に立ち上がる形しか表現できない**. +`similar_radial` はロフトを (ρ, z) の 2 次元パラメトリック曲線に変え,リムの接線を +半径方向(水平)に取れるようにしたものである(張り出しの強さは弦長比 `RADIAL_TANGENT_SCALE`=0.6). ## 結果 1 — バッフル構成は不均一性を半減させる (Baffle Halves Non-uniformity) @@ -182,23 +189,86 @@ 副次的に明るさも改善した(r=250 で生 mean +7.1%).首が無くなったぶん光の閉じ込めが減ったと 考えられる. +## 結果 6 — 板直下の「喉」を消すと決定的に効く (Removing the Throat Is Decisive) + +`similar_g2` でも r=250 で外側 8.75% vs 内側 3.24% と井戸が残っていた. +断面を見たユーザーの指摘「半径が大きいほどくびれて見える」を数値化すると, +**板の直下に喉(throat)ができている**ことが分かった.r=250 では板の縁から 12.8mm 下がっても +半径が 48 → 49.2mm しか広がらず,そこから急に外へ開く.リムの接線が板に垂直だからである. + +リムの接線を**半径方向(水平)**に取ると喉は消える.板縁から 10mm 下の半径は +49.2mm(垂直)→ 85.1mm(水平・k=0.6)になり,天井が板の縁からそのまま外へ広がって +球へ回り込む形になる. + +3 設計を全 10 半径(`similar_g2` のみ 3 半径)で比較した. + +| r [mm] | `fixed_cut`
外側 / min-max / 構造 CoV | `similar_g2` | `similar_radial` | +| --- | --- | --- | --- | +| 88 | 5.59% / 0.895 / 0.0232 | — | 6.78% / 0.846 / 0.0359 | +| 95 | 6.39% / 0.857 / 0.0316 | — | 5.98% / 0.863 / 0.0325 | +| 100 | 7.02% / 0.861 / 0.0312 | 7.69% / 0.852 / 0.0328 | 5.71% / 0.864 / 0.0310 | +| 110 | 7.17% / 0.868 / 0.0302 | — | 4.81% / 0.883 / 0.0280 | +| 120 | 7.31% / 0.872 / 0.0289 | — | 4.33% / 0.888 / 0.0258 | +| 135 | 8.08% / 0.873 / 0.0270 | — | 4.01% / 0.902 / 0.0230 | +| 150 | 9.70% / 0.860 / 0.0281 | — | 4.11% / 0.919 / 0.0209 | +| 170 | 12.08% / 0.844 / 0.0300 | 6.94% / 0.883 / 0.0256 | 3.24% / 0.914 / 0.0193 | +| 200 | 15.11% / 0.819 / 0.0361 | — | 2.58% / 0.934 / 0.0169 | +| 250 | 17.97% / 0.779 / 0.0478 | 8.75% / 0.891 / 0.0272 | **2.27% / 0.944 / 0.0145** | + +**外側の落ち込みが 2.27% まで下がった**.`fixed_cut` の 8 分の 1,`similar_g2` の 4 分の 1 である. +喉が井戸の主因だったことがこれで確定した. + +**内側・外側の両方が半径とともに改善するようになった**.当初の想定(大きいほど理想積分球に +近づく)が素直に成り立つ状態で,r=250 でもまだ頭打ちが見えていない.輪帯 min/max は +r=95 以降ずっと単調改善で,10 本の輪帯プロファイルが交差せず半径の順に並ぶ. + +改善の積み上げ: + +| 段階 | 構造 CoV | 輪帯 min/max | +| --- | --- | --- | +| 旧 `axial_wall` r=100(TEST_11 / TEST_12 の球) | 0.0579 | 0.797 | +| + バッフル導入(`fixed_cut` r=100) | 0.0312 | 0.861 | +| + 接合を相似・G2 化(`similar_g2` r=250) | 0.0272 | 0.891 | +| + **リムを水平出し**(`similar_radial` r=250) | **0.0145** | **0.944** | + +r=250 では中心/周辺比 1.036・勾配 3.46%(`fixed_cut` は 1.110 / 9.88%). + +**`angular_ratio` が 0.49 に跳ねるが悪化ではない.** 内訳は角度方向の分散が 12.9 → 11.9 と微減, +動径方向の分散が 200.0 → 12.5 と 16 分の 1 に潰れている.軸対称性は保たれたままドーム構造が +ほぼ消えたために比が上がっただけで,**この構成では `angular_ratio` を単独で読んではならない**. + +**r=88 と 95 では水平出しが負ける**(構造 CoV 0.0359 vs 0.0232 @ r=88).交差は r≈95 付近で, +小さいキャビティでは天井を外へ張り出す余地がなく不利になる.小型機なら `fixed_cut`, +大型なら `similar_radial`,という切り分けになる. + ## 結論 (Conclusions) - **バッフル構成は有効**.同じ r=100 で構造 CoV を 46% 減らし,上下の傾きも消える - **キャビティサイズは,接合が自然であって初めて効くレバーになる**.接続円を固定したまま球だけ 大きくすると,中心は平らになるが周辺が崖になり差し引きで悪化する -- **接合はキャビティ全体を半径に相似させ,5 次 Hermite で曲率まで一致させるべき**.これにより - 半径を上げるほど均一になるという当初の想定どおりの挙動が得られる -- **絶対輝度は 1/r² で落ちる**(r=250 は r=88 の 1/8.5).実機では露光時間・ゲインに直結するので, - 均一性の改善と天秤にかける必要がある +- **効きが最も大きいのは「リムでの立ち上がり方」である**.板に垂直に立ち上げると板直下に喉が + でき,これが外側の落ち込みを支配する.リム接線を半径方向に取って喉を消すと,外側の落ち込みは + r=250 で 17.97% → 2.27% と 8 分の 1 になる.接合の曲率一致(G2 化)だけでは半分にしかならない +- **設計の効き目の序列は 喉 > 接合の相似・G2 化 > 球半径 そのもの**.球半径は単独ではレバーに + ならず,喉と接合を直して初めて「大きいほど均一」という素直な効き方をする +- **最良は `similar_radial` の r=250**(構造 CoV 0.0145 / 輪帯 min/max 0.944)で,出発点の + 旧 `axial_wall` r=100(0.0579 / 0.797)の 4.0 倍.r=250 でもまだ頭打ちが見えていない +- **小半径では逆転する**.r ≲ 95 では喉なし設計が負けるので,小型機は `fixed_cut` を選ぶ +- **絶対輝度は 1/r² で落ちる**(r=250 は r=88 の 1/8.5,装置深さは 483mm).実機では露光時間・ + ゲインに直結するので,均一性の改善と天秤にかける必要がある ## 限界・注意点 (Limitations) - **全条件 1 シードのみ**.ノイズフロア(seed 違い 2 本)は未測定.ただし輪帯プロファイルの 標準誤差は条件間の差の 1/20〜1/100 なので,本文の比較の結論は揺るがない -- **`similar_g2` は 3 条件のみ**.残り 7 半径(88 / 95 / 110 / 120 / 135 / 150 / 200)は未実行 +- **`similar_g2` は 3 条件のみ**.残り 7 半径(88 / 95 / 110 / 120 / 135 / 150 / 200)は未実行なので, + 3 設計を全半径で並べた比較にはなっていない(`fixed_cut` と `similar_radial` は全 10 条件) - **r=88(`fixed_cut`)だけ突出して良い**(輪帯 min/max 0.895)が,隣の r=95(0.857)と不連続で, 実体か揺らぎか未確定.r=88 は接続面が球中心のわずか 21mm 上で幾何が質的に違う +- **リム水平出しの張り出し量 `RADIAL_TANGENT_SCALE`=0.6 は未最適化**.0.4 / 1.0 でも幾何は破綻 + しない(ρ・z とも単調)ことを確認しただけで,レンダで比較していない +- **リムで水平に出すと壁が板の縁に薄い刃先で当たる**.現状は板の厚み内に 1.5mm 食い込む襟で + シールしているので,実機では襟の作り方を別途詰める必要がある(シミュ上は問題ない) - **バッフル構成は MC ノイズが 3 倍**になる.絶対量の比較や高精度な形の議論には spp を増やす必要がある - **`board_baffle` は実機の LED 配置(下/左/右 3 基板)とは無関係な理想化**である.実機との キャリブレーション(TEST_14 系)とは別系統の設計探索として扱うこと @@ -220,16 +290,27 @@ 同心円プロファイルで比較する - **輪帯プロファイルは ROI 平均ではなくピークで正規化する**.平均正規化では曲線の基準の高さが 条件ごとに違い,「どれだけ落ちるか」が読み取れない +- **輝度マップの正規化に画素単位の最大値を使ってはならない**.MC ノイズの外れ値(firefly)に + 張り付き,本スイープでは max/min が 2.9〜15.5 まで暴れる.**20 分割の輪帯平均の最大値**を + 1.0 とすれば,1 リングあたり数千画素の平均になるので基準値が安定し,条件間を同じ物差しで + 並べられる.分割数は評価パイプライン(`calc_radial_profile` の既定)と揃えると, + 輝度マップの「最大輪帯」と輪帯プロファイルの「ピーク」が同じ輪帯を指す +- **`angular_ratio` は分母が潰れると跳ねる**.`similar_radial` の r=250 では角度方向の分散が + 微減しているのに比が 0.06 → 0.49 に上がる.動径方向の分散が 16 分の 1 に潰れたためで, + 軸対称性の悪化ではない.分散の内訳(`angular_variance` / `radial_variance`)を見ること - レンダ所要は 1 条件 440〜500 秒(このマシン,spp=1024・d=16・512²).`axial_wall` 系の 200〜330 秒より遅い ## 次にやること (Next Steps) -1. `similar_g2` の残り 7 半径を流してカーブを埋める(約 55 分) -2. 井戸の残り(r=250 でも外側 8.75% vs 内側 3.24%)を詰めるなら,ロフトの深さ比 +1. リム張り出し量 `RADIAL_TANGENT_SCALE`(現在 0.6)のスイープ.喉が最も効くレバーだと + 分かったので,その強さの最適化が次に効く見込みが高い +2. `similar_g2` の残り 7 半径を流して 3 設計を全半径で揃える(約 55 分) +3. 残る外側の落ち込み(r=250 で 2.27% vs 内側 3.02%)を詰めるなら,ロフトの深さ比 (`CUT_Z_RATIO`=−0.40)と接続円の緯度(`CENTER_Z_RATIO`=−0.92)自体をスイープする -3. ノイズフロアの測定(同一条件を seed 違いで 2 本)と,バッフル構成での spp 増加 -4. 実機予測に使うなら材質を TEST_14 の校正値(ρ≈0.81 純 diffuse)に差し替えて引き直す +4. ノイズフロアの測定(同一条件を seed 違いで 2 本)と,バッフル構成での spp 増加 +5. 実機予測に使うなら材質を TEST_14 の校正値(ρ≈0.81 純 diffuse)に差し替えて引き直す +6. r=250 は装置深さ 483mm・明るさ 1/7 なので,実機として成立する半径の上限を別途決める ## 再現コマンド (Reproduction) @@ -247,14 +328,18 @@ # スイープ本体 uv run python scripts/sweep_sphere_radius.py --variant board_baffle --cavity fixed_cut uv run python scripts/sweep_sphere_radius.py --variant board_baffle --cavity similar_g2 --radii 100 170 250 +uv run python scripts/sweep_sphere_radius.py --variant board_baffle --cavity similar_radial -# 可視化 -uv run python scripts/build_radius_sweep_visual.py --variant board_baffle -uv run python scripts/build_radial_comparison.py --variant board_baffle +# 可視化(--cavity ごとに出力先が分かれる) +uv run python scripts/build_radius_sweep_visual.py --cavity similar_radial +uv run python scripts/build_radial_comparison.py --cavity similar_radial +uv run python scripts/build_cavity_comparison.py # 3 設計を半径軸で重ねる ``` -出力: `output/sphere_radius_sweep/board_baffle/`(`fixed_cut` 10 条件)と -`output/sphere_radius_sweep/board_baffle_similar_g2/`(3 条件). +出力: `output/sphere_radius_sweep/` 配下に構成別のディレクトリ +(`board_baffle/`=`fixed_cut` 10 条件,`board_baffle_similar_g2/`=3 条件, +`board_baffle_similar_radial/`=10 条件).設計比較図は `cavity_comparison.png`, +接合の断面 before-after は `cavity_design_comparison.png` と `rim_tangent_comparison.png`. ## 参照 (References) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ae5416f..9e4bbc8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -83,3 +83,5 @@ - **TEST_14 材質パラメータ再スイープと残差の構造分解 — ρ を確定し「材質では直せない誤差」を分離 (2026-08-31)**: 面法線修正(TEST_12)前に実施した反射率・blend BSDF スイープはアーティファクトへのフィッティングだった可能性が高いため,ρ×ω×α の全 60 条件を修正後条件(spp=1024・d=16 据え置き)で引き直した(無人 6 時間枠・58 ジョブ 3.98h).**結論は ρ ≈ 0.81 の純 diffuse**で,振幅系 4 指標(CoV/勾配/C-P/動径)の実機交差点が 0.80〜0.82 に揃い,残差のお椀成分のゼロ交差 0.81 とも独立に一致した.**ω・α はほぼ無効**(ρ 固定で CoV 変化幅 0.008〜0.011 に対し ρ 単独では 3 倍動く)で **blendbsdf は不要**=調整パラメータが 1 つに減る.**最重要の発見**: 60 条件すべてで NCC が 0.81〜0.86 に張り付き(外れ値の準鏡面系列を除く),**形は材質パラメータでは原理的に動かない**ことを網羅探索で実証した.残差を `傾き + お椀` に分解すると **ρ が動かすのはお椀だけ・傾き(約 6%・方位 −140 deg)は不変**で,両者を除いた残りは約 1%=ほぼ MC ノイズ.**方法論上の学び**: 残差 RMS は支配項である傾き(直せない誤差)を測ってしまうため ρ の選択基準に使ってはならない(RMS だけ見ると ρ=0.75 が最良に見える).**原因の切り分け**: 既存単灯に同じ分解を当てると傾きは bottom 6.00%・left 7.17% に対し **right だけ 1.84%** =基板ごとに違う.カメラ由来(LSC 残差)なら 4 条件に同一の傾きが乗るはずなので,**傾きは bottom/left のモデル化誤差**と確定.実機単灯の振幅も右>左>下で 10% ばらつくのにシミュは 0.5% 以内でフラット(未モデル化)だが,一律ゲイン差では right だけ整合する事実を説明できないため位置・向き・配光の疑いが濃い.**未実施の前提**: ノイズフロア(seed 違い 2 本)を測っていないため上位 4 条件(score 0.079〜0.087)は横並びで「ρ=0.82 > 0.78」とは言えず,主張は「ρ ≈ 0.80〜0.82」まで.また d=16 据え置きにより打ち切り損失が ρ とともに増える(採用条件で 5.1%,ρ=0.90 で 20.6%)点は高 ρ 側との交絡として残る.成果物: `output/bsdf_grid_fix/`(60 条件 + summary.csv),図 `output/_slide_panels/bsdf_grid_{response,contact_sheet,rho_series,top_residual,top_shape}.png`,`scripts/sweep_bsdf_grid_fix.py`・`build_bsdf_grid_panel.py`・`build_bsdf_grid_visual.py`,記録 [TEST_14](06_TEST/TEST_14_材質パラメータ再スイープと残差の構造分解.md). - **TEST_15 バッフル導入と球半径スイープ — 接合の不自然さが「大きい球ほど悪い」の原因と判明 (2026-08-31)**: TEST_14 で「形は材質では動かない」と確定したため幾何レバーに移行.ユーザー指示で LED を評価板と反対側の**壁面に密着**させて評価板へ向け,直前のバッフルで直接光を遮る構成を新設した(バッフル径は板の全点から LED を隠す最小値の閉形式+安全率.効果は指標ではなく**レイキャストで直接検証**=板全面 41×41 点 × LED 33 点の 55,473 本が全条件 100% 遮蔽).**結果 ①**: 同じ r=100 で構造 CoV 0.0579→**0.0312**(46% 減)・輪帯 min/max 0.797→0.861・上下の傾きも消失=バッフルは有効.**結果 ②(想定と逆)**: 半径 88〜250mm の 10 条件で,輪帯 min/max は r=135 を境に悪化し r=250 は旧構成より悪い.内側(ピーク→d=0.5)は期待どおり単調改善(8.1%→3.5%)だが**外側(d=0.5→0.95)が同じ勢いで悪化**(6.4%→18.0%)して差し引きで負ける.**結果 ③**: ユーザー指摘「半径が大きいほど不自然に接合している」を数値化したところ,接続円を固定する設計では**r≥120 で球の頂点が評価板より下にもぐり**(=ほぼ閉じた玉に細い首),接合の傾きが 0.25→2.75 と 11 倍に急峻化していた.**結果 ④**: キャビティ全体を半径に相似(center_z=-0.92r・cut_z=-0.40r)させ,ロフトを 5 次 Hermite にして接合部で**曲率まで球に一致**させると(傾き 0.609 一定・曲率比 厳密に 1.00・球の頂点は常に板より上),外側の落ち込みが半減し(r=250 で 18.0%→8.8%),**輪帯 min/max が半径とともに改善に転じた**(0.852→0.883→0.891).r=250 の 0.891 は全条件で最良.内側は両設計でほぼ同一=変更が予測どおり外側だけに効いた.**方法論上の学び**: 絶対輝度が半径で 1 桁変わる(1/r²)ため生の CoV は MC ノイズ支配(ノイズが構造の 2〜3 倍)で,そのまま読むと「大きい球ほど不均一」という誤った結論になる.**同心円(輪帯)プロファイルの方位平均**ならノイズが 1/√N に落ち,標準誤差が条件間の差の 1/20〜1/100 になるので公平に比較できる(ユーザー提案).また輪帯プロファイルは ROI 平均ではなく**ピークで正規化**しないと「どれだけ落ちるか」が読み取れない.失敗パターン: ① 評価板のキャビティ側の面を z=0 と取り違え(実際は z=-4.5 の下面)→ バッフル径が過小 / ② バッフル円板の三角形ファンのエッジが XZ/YZ 平面と一致し,その平面内のレイが数値的にすり抜け(55,473 本中 4〜32 本)→ 円板を半セクタ回転して解消 / ③ `subprocess.run` が Windows 既定の cp932 で復号して読取スレッドが落ち,レンダ失敗時にエラー本文が読めない → `encoding="utf-8"` 明示.**未完**: `similar_g2` は 3 条件のみで残り 7 半径が未実行.ノイズフロア(seed 違い)も未測定.バッフル構成は MC ノイズが 3 倍になるので絶対量の議論には spp 増が必要.成果物: `output/sphere_radius_sweep/{board_baffle,board_baffle_similar_g2}/`,`scripts/sweep_sphere_radius.py`・`verify_baffle_occlusion.py`・`build_radial_comparison.py`・`build_radius_sweep_visual.py`・`build_radius_geometry_figure.py`・`show_scene_cutaway.py`,記録 [TEST_15](06_TEST/TEST_15_バッフル導入と球半径スイープ.md). + +- **TEST_15 続き — 板直下の「喉」が最大のレバーと判明し,出発点の 4.0 倍まで改善 (2026-08-31)**: `similar_g2`(接合の相似・G2 化)でも r=250 で外側の落ち込みが 8.75% 残っていた.断面を見たユーザーの指摘「くびれがないようにしたい」「評価板に対して垂直方向にまず伸ばしているが,水平方向に伸ばしたらくびれにならないのでは」を数値化すると,**板の直下に喉ができていた**(r=250 で板縁から 12.8mm 下でも半径が 48→49.2mm しか広がらない).原因は実装形式そのもので,ロフトを「z を等間隔に進めながら ρ(z) を決める」形で書いていたためリムの傾きが dρ/dz=0(=板に垂直)しか表現できなかった.**ロフトを (ρ, z) の 2 次元パラメトリック Hermite に変えてリム接線を半径方向(水平)に取れるようにした**(`similar_radial`).**結果**: 外側の落ち込みが r=250 で 17.97%(fixed_cut)→ 8.75%(similar_g2)→ **2.27%**,構造 CoV **0.0145**・輪帯 min/max **0.944** で全条件の最良.旧 axial_wall r=100(0.0579 / 0.797)の **4.0 倍**で,内側・外側とも半径に対し単調改善し頭打ちが見えていない.**設計上の結論**: 効き目の序列は **喉 > 接合の相似・G2 化 > 球半径そのもの**.球半径は単独ではレバーにならず,喉と接合を直して初めて「大きいほど均一」という素直な効き方をする.ただし **r≲95 では喉なし設計が負ける**(小型機は fixed_cut).**方法論の追加**: 輝度マップの正規化に画素単位の最大値を使うと firefly に張り付く(max/min が 2.9〜15.5 まで暴れる)ため,**20 分割の輪帯平均の最大値**を 1.0 とする(ユーザー提案).分割数を評価パイプラインの既定と揃えると輝度マップの「最大輪帯」と輪帯プロファイルの「ピーク」が同じ輪帯を指し,図をまたいで数値が突き合わせられる.また `angular_ratio` は分母(動径分散)が潰れると跳ねるので単独で読んではならない(similar_radial r=250 で角度分散は 12.9→11.9 と微減しているのに比は 0.06→0.49).**未実施**: リム張り出し量 `RADIAL_TANGENT_SCALE`(現在 0.6)の最適化,`similar_g2` の残り 7 半径,ノイズフロア.実機観点ではリム水平出しは壁が板の縁に薄い刃先で当たるため襟の作り方を別途詰める必要がある.成果物: `output/sphere_radius_sweep/board_baffle_similar_radial/`,`scripts/build_cavity_comparison.py`,図 `cavity_comparison.png`・`rim_tangent_comparison.png`,記録 [TEST_15](06_TEST/TEST_15_バッフル導入と球半径スイープ.md). diff --git a/scripts/build_cavity_comparison.py b/scripts/build_cavity_comparison.py new file mode 100644 index 0000000..93db598 --- /dev/null +++ b/scripts/build_cavity_comparison.py @@ -0,0 +1,147 @@ +"""キャビティ形状(ロフト接合の設計)どうしを半径軸で比較する図を生成する. + +同じ LED 構成(board_baffle)のまま,ロフト接合の設計だけを変えた系列を重ねて描く. +比較は CoV ではなく**同心円(輪帯)プロファイル**を基本にする.本スイープは絶対輝度が +半径で 1 桁変わり生の CoV が MC ノイズに支配されるため([TEST_15](../docs/06_TEST/)), +方位平均でノイズを 1/sqrt(N) に落とした量でないと半径間の比較ができない. + +出す量: + 内側の落ち込み (ピーク→d=0.5) : 球のサイズが支配する成分 + 外側の落ち込み (d=0.5→0.95) : ロフトが作る「井戸」が支配する成分 + 輪帯 min/max : ドーム全体の深さ(1 が理想) + 構造 CoV : ノイズを除いた真の不均一(decompose_cov) + +実行方法: + uv run python scripts/build_cavity_comparison.py +""" + +from __future__ import annotations + +import argparse +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_radial_comparison import ( # noqa: E402 + normalize_to_peak, + radial_stats, + ring_min_max_ratio, +) +from build_radius_sweep_visual import decompose_cov # noqa: E402 +from sweep_sphere_radius import CAVITIES, OUTPUT_BASE, RADII, job_name, run_dir_name # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +# 表示名と色(キャビティ形状ごと) +STYLES = { + "fixed_cut": ("接続円固定・3 次(喉あり)", "tab:red", "--"), + "similar_g2": ("相似・5 次 G2(喉あり)", "tab:green", "-."), + "similar_radial": ("相似・リム水平出し(喉なし)", "tab:blue", "-"), +} +# 輪帯プロファイルを重ねて見せる代表半径 +PROFILE_RADII = (100.0, 250.0) + + +def collect(variant: str, cavity: str, bins: int) -> dict[str, list[float]]: + """1 つのキャビティ形状について,半径ごとの各指標を集める(欠けている半径は飛ばす).""" + out: dict[str, list[float]] = {k: [] for k in + ("radius", "inner", "outer", "ratio", "structure")} + for radius in RADII: + path = OUTPUT_BASE / run_dir_name(variant, cavity) / job_name(radius) / "luminance.npy" + if not path.exists(): + continue + centers, means, sems = radial_stats(path, bins) + ratio = ring_min_max_ratio(centers, means) + _, structure, _ = decompose_cov(path) + means, _ = normalize_to_peak(centers, means, sems) + valid = ~np.isnan(means) + mid = float(np.interp(0.5, centers[valid], means[valid])) + edge = float(np.interp(0.95, centers[valid], means[valid])) + out["radius"].append(radius) + out["inner"].append((1.0 - mid) * 100.0) + out["outer"].append((mid - edge) * 100.0) + out["ratio"].append(ratio) + out["structure"].append(structure) + return out + + +def draw_profiles(ax, variant: str, bins: int, radius: float) -> None: + """1 つの半径について,各キャビティ形状の輪帯プロファイルを重ねる.""" + for cavity, (label, color, style) in STYLES.items(): + path = OUTPUT_BASE / run_dir_name(variant, cavity) / job_name(radius) / "luminance.npy" + if not path.exists(): + continue + centers, means, sems = radial_stats(path, bins) + means, sems = normalize_to_peak(centers, means, sems) + ax.plot(centers, means, style, color=color, lw=2.0, label=label) + ax.fill_between(centers, means - sems, means + sems, color=color, alpha=0.25, lw=0) + ax.axhline(1.0, color="gray", lw=0.8, ls=":") + ax.set_xlabel("正規化楕円距離") + ax.set_ylabel("輪帯平均輝度(ピーク=1)") + ax.set_title(f"同心円プロファイル r={radius:g}mm", fontsize=12) + ax.legend(fontsize=9) + ax.grid(alpha=0.3) + + +def draw_series(ax, series: dict[str, dict], key: str, title: str, ylabel: str, + ideal: float | None = None) -> None: + """指標 1 つについて,各キャビティ形状の半径依存カーブを描く.""" + for cavity, data in series.items(): + if not data["radius"]: + continue + label, color, style = STYLES[cavity] + ax.plot(data["radius"], data[key], style, color=color, marker="o", lw=2.0, label=label) + if ideal is not None: + ax.axhline(ideal, color="gray", lw=0.8, ls="--") + ax.set_xlabel("球内半径 (mm)") + ax.set_ylabel(ylabel) + ax.set_title(title, fontsize=12) + ax.legend(fontsize=9) + ax.grid(alpha=0.3) + + +def main() -> int: + """キャビティ形状の比較図を生成する.""" + parser = argparse.ArgumentParser(description="キャビティ形状の比較図を生成") + parser.add_argument("--variant", default="board_baffle", help="LED 構成") + parser.add_argument("--bins", type=int, default=40, help="輪帯の分割数") + parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス") + args = parser.parse_args() + + series = {c: collect(args.variant, c, args.bins) for c in CAVITIES} + output = args.output or OUTPUT_BASE / "cavity_comparison.png" + + fig, axes = plt.subplots(2, 3, figsize=(20, 12)) + for ax, radius in zip(axes[0][:2], PROFILE_RADII, strict=True): + draw_profiles(ax, args.variant, args.bins, radius) + draw_series(axes[0][2], series, "ratio", "ドームの深さ(1 が理想)", "輪帯 min/max", 1.0) + draw_series(axes[1][0], series, "inner", "内側の落ち込み(ピーク→d=0.5)", "落ち込み [%]") + draw_series(axes[1][1], series, "outer", "外側の落ち込み(d=0.5→0.95)", "落ち込み [%]") + draw_series(axes[1][2], series, "structure", "構造 CoV(MC ノイズ除去後)", "構造 CoV") + + fig.suptitle( + f"ロフト接合の設計比較 [{args.variant}] 同心円平均でノイズを除いた比較", fontsize=15 + ) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=100) + print(f"出力: {output}") + for cavity, data in series.items(): + if data["radius"]: + best = int(np.argmax(data["ratio"])) + print(f" {cavity:16s} {len(data['radius']):2d} 条件 " + f"最良 r={data['radius'][best]:.0f} で 輪帯 min/max={data['ratio'][best]:.4f} " + f"構造 CoV={data['structure'][best]:.4f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_radial_comparison.py b/scripts/build_radial_comparison.py index 9ec957a..aa48dc1 100644 --- a/scripts/build_radial_comparison.py +++ b/scripts/build_radial_comparison.py @@ -11,8 +11,12 @@ 輪帯 min/max 比になる).各リングには標準誤差(std/sqrt(N))の帯を重ねて, ノイズが平均化で潰れていることを図の上で確認できるようにする. +分割数の既定は 20 で,評価パイプライン(`calc_radial_profile` の既定)および +`build_radius_sweep_visual.py` の輝度マップ正規化と同一にしてある.図どうしで +「ピーク=どの輪帯か」がずれないようにするため. + 実行方法: - uv run python scripts/build_radial_comparison.py --variant board_baffle + uv run python scripts/build_radial_comparison.py --cavity similar_radial """ from __future__ import annotations @@ -30,7 +34,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from sweep_sphere_radius import OUTPUT_BASE, RADII, VARIANTS, job_name # noqa: E402 +from sweep_sphere_radius import ( # noqa: E402 + CAVITIES, + OUTPUT_BASE, + RADII, + VARIANTS, + job_name, + run_dir_name, +) from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map # noqa: E402 @@ -102,12 +113,12 @@ return float(means[valid].min() / means[valid].max()) -def draw_all_radii(ax, variant: str, n_bins: int) -> list[tuple[float, float]]: +def draw_all_radii(ax, variant: str, cavity: str, n_bins: int) -> list[tuple[float, float]]: """全半径の輪帯プロファイルを重ね描きし,(半径, 周辺/中心比) のリストを返す.""" colors = plt.cm.viridis(np.linspace(0.0, 0.9, len(RADII))) ratios: list[tuple[float, float]] = [] for radius, color in zip(RADII, colors, strict=True): - path = OUTPUT_BASE / variant / job_name(radius) / "luminance.npy" + path = OUTPUT_BASE / run_dir_name(variant, cavity) / job_name(radius) / "luminance.npy" if not path.exists(): continue centers, means, sems = radial_stats(path, n_bins) @@ -140,10 +151,11 @@ ax.grid(alpha=0.3) -def draw_config_compare(ax, variant: str, n_bins: int) -> None: +def draw_config_compare(ax, variant: str, cavity: str, n_bins: int) -> None: """同じ r=100 で旧構成(バッフルなし)と新構成の輪帯プロファイルを比較する.""" + run_dir = OUTPUT_BASE / run_dir_name(variant, cavity) targets = [ - (OUTPUT_BASE / variant / "r100" / "luminance.npy", f"新 {variant} r=100", "tab:red"), + (run_dir / "r100" / "luminance.npy", f"新 {variant}/{cavity} r=100", "tab:red"), (LEGACY_R100, "旧 axial_wall r=100(バッフルなし)", "tab:blue"), ] for path, label, color in targets: @@ -165,20 +177,23 @@ """同心円プロファイルの比較図を生成する.""" parser = argparse.ArgumentParser(description="半径スイープの同心円プロファイル比較図") parser.add_argument("--variant", choices=VARIANTS, default="board_baffle") - parser.add_argument("--bins", type=int, default=40, help="輪帯の分割数(既定 40)") + parser.add_argument("--cavity", choices=CAVITIES, default="similar_radial") + parser.add_argument("--bins", type=int, default=20, + help="輪帯の分割数(既定 20=評価パイプラインと同一)") parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス") args = parser.parse_args() - output = args.output or OUTPUT_BASE / args.variant / "radial_comparison.png" + run_dir = OUTPUT_BASE / run_dir_name(args.variant, args.cavity) + output = args.output or run_dir / "radial_comparison.png" fig, axes = plt.subplots(1, 3, figsize=(19, 6)) - ratios = draw_all_radii(axes[0], args.variant, args.bins) + ratios = draw_all_radii(axes[0], args.variant, args.cavity, args.bins) legacy = None if LEGACY_R100.exists(): legacy = ring_min_max_ratio(*radial_stats(LEGACY_R100, args.bins)[:2]) - draw_dome_depth(axes[1], ratios, legacy) - draw_config_compare(axes[2], args.variant, args.bins) + draw_dome_depth(axes[1], ratios, legacy) # 旧構成は水平線で参照値として重ねる + draw_config_compare(axes[2], args.variant, args.cavity, args.bins) fig.suptitle( - f"同心円(輪帯)プロファイル比較 [{args.variant}]" + f"同心円(輪帯)プロファイル比較 [{args.variant} / {args.cavity}]" f" 輪帯 {args.bins} 分割・方位平均でノイズを低減・最大輪帯を 1 に正規化", fontsize=14 ) fig.tight_layout() diff --git a/scripts/build_radius_geometry_figure.py b/scripts/build_radius_geometry_figure.py index 612d376..3e6a90c 100644 --- a/scripts/build_radius_geometry_figure.py +++ b/scripts/build_radius_geometry_figure.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -from generate_sphere_loft_housing import _blend_radius # noqa: E402 +from generate_sphere_loft_housing import _loft_ring # noqa: E402 from sweep_sphere_radius import ( # noqa: E402 BAFFLE_GAP, BOARD_HALF, @@ -57,16 +57,16 @@ メッシュ生成本体(generate_sphere_loft_housing)と同じブレンド関数を theta=0(+X 方向)について評価するので,図と STL が食い違わない. """ - rect_r = np.asarray(BOARD_HALF[0] - RIM_INSET) + rect_r = np.full(1, BOARD_HALF[0] - RIM_INSET) z_cut, r_cut = cut_z(radius, cavity), cut_radius(radius, cavity) - span = z_cut - RIM_Z - m1 = (-(z_cut - center_z(radius, cavity)) / r_cut) * span - m2 = (-(radius**2) / r_cut**3) * span**2 - u = np.linspace(0.0, 1.0, n) - rho = np.array([ - float(_blend_radius(float(t), rect_r, r_cut, m1, m2, loft_blend(cavity))) for t in u - ]) - return rho, RIM_Z + u * span + cut_d = z_cut - center_z(radius, cavity) + blend = loft_blend(cavity) + points = [ + _loft_ring(float(t), rect_r, r_cut, z_cut, RIM_Z, cut_d, radius, blend) + for t in np.linspace(0.0, 1.0, n) + ] + return (np.array([float(p[0][0]) for p in points]), + np.array([float(p[1][0]) for p in points])) def sphere_profile(radius: float, cavity: str, n: int = 240) -> tuple[np.ndarray, np.ndarray]: diff --git a/scripts/build_radius_sweep_visual.py b/scripts/build_radius_sweep_visual.py index 5eb0723..415beb6 100644 --- a/scripts/build_radius_sweep_visual.py +++ b/scripts/build_radius_sweep_visual.py @@ -4,11 +4,15 @@ 指す食い違いが起き,輝度マップそのものを見て初めて解釈できた.本スクリプトは summary.csv の指標カーブと,各半径の輝度マップを必ず並べて出す. -輝度マップは平滑化(median 5px → gaussian sigma=4)した上で **各条件の平均で正規化**して -表示する.生のままでは MC ノイズが支配し,絶対輝度が半径で 1 桁変わるため形が比較できない. +輝度マップは平滑化(median 5px → gaussian sigma=4)した上で **20 分割の輪帯平均の最大値を +1.0** として正規化する.絶対輝度は半径で 1 桁変わるため何かで割らないと形が比較できないが, +画素単位の最大値は MC ノイズの外れ値(firefly)に張り付いて使えない(本スイープでは +max/min が 2.9〜15.5 まで暴れる).輪帯平均ならリング内の画素数だけノイズが 1/sqrt(N) に +落ちるので,最大値そのものが安定する.分割は評価パイプラインと同一 +(`evaluation/radial.py:calc_radial_profile` の既定 20 ビン). 実行方法: - uv run python scripts/build_radius_sweep_visual.py --variant board_baffle + uv run python scripts/build_radius_sweep_visual.py --cavity similar_radial """ from __future__ import annotations @@ -28,8 +32,15 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from sweep_sphere_radius import OUTPUT_BASE, VARIANTS, job_name # noqa: E402 +from sweep_sphere_radius import ( # noqa: E402 + CAVITIES, + OUTPUT_BASE, + VARIANTS, + job_name, + run_dir_name, +) +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"] @@ -47,6 +58,9 @@ # (sigma を 4→20 と変えても構造値は 4% しか動かないことを実測して確認済み) STRUCTURE_SIGMA = 12 +# 輝度マップの正規化に使う輪帯の分割数(評価パイプラインの既定と同一) +NORMALIZE_BINS = 20 + def read_summary(path: Path) -> list[dict[str, float]]: """summary.csv を読み,半径昇順の辞書リストで返す.""" @@ -56,13 +70,21 @@ return sorted(rows, key=lambda r: r["radius_mm"]) -def smooth_map(luminance_npy: Path) -> np.ndarray: - """ROI を切り出して平滑化した輝度マップを返す(MC ノイズ支配を避ける).""" +def normalized_map(luminance_npy: Path) -> np.ndarray: + """輪帯平均の最大値を 1.0 に正規化した平滑化輝度マップを返す. + + 正規化の基準は**生の ROI から取った 20 分割の輪帯平均の最大値**とする. + 画素単位の最大値は MC ノイズの外れ値に張り付いて条件間で比較できないが, + 輪帯平均なら方位方向の平均でノイズが 1/sqrt(N) に落ちて安定する. + 表示するマップ自体は形を見るために平滑化する(median 5px → gaussian sigma=4). + """ arr = np.load(luminance_npy).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=5), sigma=4) + profile = calc_radial_profile(roi, n_bins=NORMALIZE_BINS) + peak = float(np.nanmax(profile[:, 1])) + return gaussian_filter(median_filter(roi, size=5), sigma=4) / peak def decompose_cov(luminance_npy: Path, seed: int = 0) -> tuple[float, float, float]: @@ -91,11 +113,12 @@ return float(roi.std() / mean), structure, float(residual.std() / mean) -def draw_cov_panel(ax, rows: list[dict[str, float]], variant: str) -> None: +def draw_cov_panel(ax, rows: list[dict[str, float]], variant: str, cavity: str) -> None: """CoV を生・構造・ノイズに分けて描く(生だけ見ると誤読するため).""" radii, raws, structures, noises = [], [], [], [] for row in rows: - path = OUTPUT_BASE / variant / job_name(row["radius_mm"]) / "luminance.npy" + path = (OUTPUT_BASE / run_dir_name(variant, cavity) + / job_name(row["radius_mm"]) / "luminance.npy") if not path.exists(): continue raw, structure, noise = decompose_cov(path) @@ -112,25 +135,26 @@ ax.grid(alpha=0.3) -def draw_maps(fig, gs, rows: list[dict[str, float]], variant: str) -> None: - """各半径の輝度マップを平均正規化して並べる(共通カラースケール).""" +def draw_maps(fig, gs, rows: list[dict[str, float]], variant: str, cavity: str) -> None: + """各半径の輝度マップを「輪帯平均の最大値=1.0」で正規化して並べる(共通スケール).""" maps: list[tuple[float, np.ndarray]] = [] for row in rows: - path = OUTPUT_BASE / variant / job_name(row["radius_mm"]) / "luminance.npy" + path = (OUTPUT_BASE / run_dir_name(variant, cavity) + / job_name(row["radius_mm"]) / "luminance.npy") if path.exists(): - smoothed = smooth_map(path) - maps.append((row["radius_mm"], smoothed / smoothed.mean())) + maps.append((row["radius_mm"], normalized_map(path))) if not maps: return stacked = np.concatenate([m.ravel() for _, m in maps]) - vmin, vmax = np.percentile(stacked, [0.5, 99.5]) + # 上端は 1.0(=最大輪帯)に固定し,下端だけデータに合わせる + vmin = float(np.percentile(stacked, 0.5)) for i, (radius, data) in enumerate(maps[:10]): ax = fig.add_subplot(gs[i // 5, i % 5]) - image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=vmax) + image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=1.0) ax.set_title(f"r={radius:g}mm", fontsize=10) ax.axis("off") if i == len(maps[:10]) - 1: - fig.colorbar(image, ax=ax, fraction=0.046, label="平均=1 で正規化") + fig.colorbar(image, ax=ax, fraction=0.046, label="最大輪帯(20 分割)=1.0") def draw_metrics(fig, gs, rows: list[dict[str, float]]) -> None: @@ -152,22 +176,27 @@ """半径スイープの一覧図を生成する.""" parser = argparse.ArgumentParser(description="半径スイープの結果一覧図を生成") parser.add_argument("--variant", choices=VARIANTS, default="board_baffle") + parser.add_argument("--cavity", choices=CAVITIES, default="similar_radial") parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス") args = parser.parse_args() - summary = OUTPUT_BASE / args.variant / "summary.csv" + run_dir = OUTPUT_BASE / run_dir_name(args.variant, args.cavity) + summary = run_dir / "summary.csv" if not summary.exists(): print(f"エラー: {summary} がありません(先にスイープを実行してください)") return 1 rows = read_summary(summary) - output = args.output or OUTPUT_BASE / args.variant / "sweep_overview.png" + output = args.output or run_dir / "sweep_overview.png" fig = plt.figure(figsize=(20, 13)) gs = fig.add_gridspec(4, 6, height_ratios=[1.1, 1.1, 1, 1]) - draw_maps(fig, gs, rows, args.variant) - draw_cov_panel(fig.add_subplot(gs[2, 0:2]), rows, args.variant) + draw_maps(fig, gs, rows, args.variant, args.cavity) + draw_cov_panel(fig.add_subplot(gs[2, 0:2]), rows, args.variant, args.cavity) draw_metrics(fig, gs, rows) - fig.suptitle(f"球体筐体 半径スイープ 結果一覧 [{args.variant}]", fontsize=15) + fig.suptitle( + f"球体筐体 半径スイープ 結果一覧 [{args.variant} / {args.cavity}]" + " 輝度マップは 20 分割の輪帯平均の最大値を 1.0 に正規化", fontsize=15 + ) fig.tight_layout() output.parent.mkdir(parents=True, exist_ok=True) fig.savefig(output, dpi=100) diff --git a/scripts/generate_sphere_loft_housing.py b/scripts/generate_sphere_loft_housing.py index 484cf71..17b178b 100644 --- a/scripts/generate_sphere_loft_housing.py +++ b/scripts/generate_sphere_loft_housing.py @@ -39,6 +39,61 @@ return np.minimum(rx, ry) +# blend="radial" のリム接線の張り出し強さ(弦長に対する比). +# 大きいほど天井が板の外へ大きく張り出し,装置が扁平になる. +RADIAL_TANGENT_SCALE = 0.6 + + +def _loft_ring( + u: float, + rect_r: np.ndarray, + cut_r: float, + cut_z: float, + rim_z: float, + cut_d: float, + radius: float, + blend: str, +) -> tuple[np.ndarray, np.ndarray]: + """ロフト部のパラメータ u における 1 リングの (半径, Z) を返す. + + "cubic" / "quintic" は z を等間隔に進めながら半径 ρ(z) を決める形なので, + リムでの傾きは dρ/dz = 0(=板に垂直に立ち上がる)しか表現できない.これが + 板直下に「喉」を作り,半径が大きいほどくびれて見える原因になる. + "radial" は (ρ, z) の 2 次元パラメトリック Hermite に切り替え,リムの接線を + **半径方向(水平)**に取ることで喉を無くす(接続点側は従来どおり球の接線に一致). + + Args: + u: 0(リム矩形)→ 1(球との接続円)のパラメータ. + rect_r: リム矩形の角度ごとの半径. + cut_r: 接続円の半径. + cut_z: 接続平面の Z. + rim_z: リムの Z. + cut_d: 接続平面の球中心からの高さ(cut_z - center_z). + radius: 球内半径. + blend: "cubic" / "quintic" / "radial". + + Returns: + (半径, Z) の組.いずれも角度ごとの配列. + """ + span = cut_z - rim_z + if blend == "radial": + # 弦長に比例した接線ベクトルを両端に置く 2 次元 3 次 Hermite + chord = np.hypot(cut_r - rect_r, span) + scale = RADIAL_TANGENT_SCALE * chord + h00 = 2 * u**3 - 3 * u**2 + 1 + h10 = u**3 - 2 * u**2 + u + h01 = -2 * u**3 + 3 * u**2 + h11 = u**3 - u**2 + rho = h00 * rect_r + h10 * scale + h01 * cut_r + h11 * scale * (cut_d / radius) + z = h00 * rim_z + h01 * cut_z + h11 * scale * (-cut_r / radius) + return rho, z + m1 = (-cut_d / cut_r) * span + m2 = (-(radius**2) / cut_r**3) * span**2 + return _blend_radius(u, rect_r, cut_r, m1, m2, blend), np.full_like( + rect_r, rim_z + u * span + ) + + def _blend_radius( u: float, rect_r: np.ndarray, @@ -102,20 +157,14 @@ cut_d = cut_z - center_z cut_r = float(np.sqrt(radius**2 - cut_d**2)) - span = cut_z - rim_z # u: 0(リム)→ 1(カット面).dz/du = span - # 球面の cut_z における dρ/dz(下向きに進むと ρ が増える側)を Hermite 端点勾配に使う - m1 = (-cut_d / cut_r) * span - # 球面の d²ρ/dz² = -radius² / ρ³(5 次 Hermite で曲率まで合わせるときに使う) - m2 = (-(radius**2) / cut_r**3) * span**2 - rings_list: list[np.ndarray] = [] z_list: list[np.ndarray] = [] # ロフト部(リム u=0 を含み,カット面 u=1 は球部の先頭リングに任せる) for u in np.linspace(0.0, 1.0, n_loft, endpoint=False): - rho = _blend_radius(u, rect_r, cut_r, m1, m2, blend) + rho, z_ring = _loft_ring(u, rect_r, cut_r, cut_z, rim_z, cut_d, radius, blend) rings_list.append(rho) - z_list.append(np.full(n_theta, rim_z + u * span)) + z_list.append(z_ring) # 球部(カット面 → 極直前).角度パラメータで均等分割 phi_cut = float(np.arccos(cut_d / radius)) @@ -179,8 +228,10 @@ n_theta: 周方向分割数. n_loft: ロフト部の軸方向リング数. n_sphere: 球部の軸方向リング数. - blend: ロフトのブレンド次数."cubic" は球の接線だけを一致させる(既定・後方互換). - "quintic" は曲率まで一致させ,接合部の折れを無くす. + blend: ロフトの形状."cubic" は球の接線だけを一致させる(既定・後方互換). + "quintic" は曲率まで一致させ,接合部の折れを無くす.いずれもリムでは + 板に垂直に立ち上がるため板直下に喉ができる."radial" はリムの接線を + 半径方向(水平)に取り,喉を無くす. Returns: watertight な筐体ソリッドメッシュ(mm 単位). @@ -256,8 +307,9 @@ "--cut-z", type=float, default=-40.0, help="ロフト⇄球の接続平面 Z(mm).既定 -40" ) parser.add_argument( - "--blend", choices=("cubic", "quintic"), default="cubic", - help="ロフトのブレンド次数.quintic は接合部で曲率まで球に一致させる.既定 cubic", + "--blend", choices=("cubic", "quintic", "radial"), default="cubic", + help="ロフトの形状.quintic は接合部で曲率まで球に一致させる." + "radial はリムで半径方向(水平)に出して板直下の喉を無くす.既定 cubic", ) parser.add_argument( "--output", diff --git a/scripts/sweep_sphere_radius.py b/scripts/sweep_sphere_radius.py index 7e22f75..949ea03 100644 --- a/scripts/sweep_sphere_radius.py +++ b/scripts/sweep_sphere_radius.py @@ -86,9 +86,14 @@ # (z=+7〜+20mm)に留まり,どの半径でも「お椀にフタ」の自然な形になる. # 比は r=100 の既存構成(center_z=-92, cut_z=-40)をそのまま無次元化した値なので, # r=100 では接続円・球中心が既存と一致する(ロフトの曲線だけ 5 次になる). +# similar_radial: similar_g2 と同じ相似スケールだが,ロフトのリム接線を**半径方向(水平)**に +# 取る.cubic / quintic はリムで板に垂直に立ち上がるため板直下に「喉」ができ(r=250 で +# 板縁から 12.8mm 下でも半径が 48→49.2mm しか広がらない),半径が大きいほどくびれて見える. +# radial はこれを解消し,天井が板の縁からそのまま外へ広がって球へ回り込む形になる. CENTER_Z_RATIO = -0.92 CUT_Z_RATIO = -0.40 -CAVITIES = ("fixed_cut", "similar_g2") +CAVITIES = ("fixed_cut", "similar_g2", "similar_radial") +_SIMILAR_CAVITIES = ("similar_g2", "similar_radial") # LED を -Z 極から何 mm 手前に置くか(構成ごとに異なる) # axial_wall: TEST_11 の分散低減実測(NEE 可視領域が球面積の 25%)に基づく 50mm @@ -135,7 +140,7 @@ def center_z(radius: float, cavity: str = "fixed_cut") -> float: """球中心の Z 座標を返す(球は -Z 側に伸びる).""" - if cavity == "similar_g2": + if cavity in _SIMILAR_CAVITIES: return CENTER_Z_RATIO * radius if radius <= CUT_R: raise ValueError(f"radius は CUT_R={CUT_R:.3f}mm より大きい必要があります(指定 {radius})") @@ -144,7 +149,7 @@ def cut_z(radius: float, cavity: str = "fixed_cut") -> float: """ロフトと球の接続平面の Z 座標を返す.""" - return CUT_Z_RATIO * radius if cavity == "similar_g2" else CUT_Z + return CUT_Z_RATIO * radius if cavity in _SIMILAR_CAVITIES else CUT_Z def cut_radius(radius: float, cavity: str = "fixed_cut") -> float: @@ -153,8 +158,8 @@ def loft_blend(cavity: str) -> str: - """キャビティ形状に対応するロフトのブレンド次数を返す.""" - return "quintic" if cavity == "similar_g2" else "cubic" + """キャビティ形状に対応するロフトの形状を返す.""" + return {"similar_g2": "quintic", "similar_radial": "radial"}.get(cavity, "cubic") def led_z(radius: float, variant: str, cavity: str = "fixed_cut") -> float: