diff --git a/CLAUDE.md b/CLAUDE.md
index 0fb74ec..2739976 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,7 +23,7 @@
## 開発進捗
-最新: フェーズ 7(実装)— 面法線修正(TEST_12)後の条件で内壁材質 3 軸を全数スイープした**[TEST_14](docs/06_TEST/TEST_14_材質パラメータ再スイープと残差の構造分解.md) 材質パラメータ再スイープと残差の構造分解**を実施.**内壁材質は ρ ≈ 0.81 の純 diffuse に確定**(振幅系 4 指標の実機交差点 0.80〜0.82 と,残差のお椀成分のゼロ交差 0.81 が独立に一致).**ω・α はほぼ無効で blendbsdf は不要**=調整パラメータが 1 つに減った.**最重要**: 全 60 条件で NCC が 0.81〜0.86 に張り付き,**形は材質パラメータでは原理的に動かない**ことを網羅探索で実証.残差を `傾き + お椀` に分解すると ρ が動かすのはお椀だけで,**傾き(約 6%・方位 −140 deg)は不変**,両者を除いた残りは約 1%=ほぼ MC ノイズ.既存単灯に同じ分解を当てると傾きは bottom 6.00%・left 7.17% に対し **right だけ 1.84%** =カメラ由来ではなく **bottom/left のモデル化誤差**と確定し,次の主題に設定.**未実施の前提**: ノイズフロア(seed 違い 2 本)未測定のため上位 4 条件は横並びで主張は「ρ ≈ 0.80〜0.82」まで.詳細な経緯・設計判断・失敗パターンは [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,3 +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 のスイープ.ロフト接合の 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 96df4da..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"
@@ -83,6 +83,10 @@
| `output/singles_fix/{summary,pairwise_ncc}.csv`・`output/_slide_panels/singles_fix_shape_roi.png` | TEST_12 追補 | — | — | 単灯形指標(修正前後・w1p4 対比,平滑後 p/m 併記)・灯間 NCC(raw/smooth)・3×4 パネル | `scripts/build_singles_fix_summary.py` の出力 |
| `output/full_w1p4_fix/`・`output/singles_fix/{bottom,left,right}_w1p4/` | TEST_12 追補 | `configs/_tmp_fix_{full,bottom,left,right}_w1p4.yaml`(後側を `後側_w1p4.stl` に差し替え,blend w0.05_a0.1・d=16・spp=1024) | 全灯のみ `eval_baseline_dng_target_shape.yaml` | 全灯 CoV 0.0605(基準 fix 0.0610 と差 1%)=**ロフト不感は修正後も維持**.単灯は平滑 p/m 差 ≤0.9%・灯間 NCC は +0.05〜0.12 同形化 — [TEST_12 §ロフト再検証](TEST_12_シェーディング法線アーティファクトの発見と修正.md) 参照 | TEST_10 相対結論の再検証.「bottom 平ら化=幾何レバー実証」は撤回相当に弱まり,不感性の結論が生存 |
| `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/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"
new file mode 100644
index 0000000..95f7c49
--- /dev/null
+++ "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"
@@ -0,0 +1,349 @@
+# バッフル導入と球半径スイープ (Baffle Introduction and Sphere Radius Sweep)
+
+球体筐体について,LED を評価板の反対側の壁面に密着させて評価板へ向け,直前のバッフルで
+直接光を遮る構成を新設し,その上で球内半径を 88〜250mm でスイープした(2026-08-31).
+均一性は同じ半径なら大きく改善したが,**球を大きくすると悪化する**という想定と逆の結果が出た.
+原因をロフト接合の幾何に特定し,接合を 2 段階で作り直したところ傾向が逆転し,
+最終的に**出発点(TEST_11 / TEST_12 の球)の 4.0 倍**まで均一性が上がった.
+
+関連:
+
+- 球実験の起点: [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md)
+- 前提となる修正: [TEST_12_シェーディング法線アーティファクトの発見と修正](TEST_12_シェーディング法線アーティファクトの発見と修正.md)
+- 材質の確定: [TEST_14_材質パラメータ再スイープと残差の構造分解](TEST_14_材質パラメータ再スイープと残差の構造分解.md)
+- 実行記録: [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md)
+
+## 背景・動機 (Motivation)
+
+TEST_14 で内壁材質は確定し,**形は材質パラメータでは動かない**ことが網羅探索で示された.
+残る手段は幾何のレバーである.そこで 2 つの問いを立てた.
+
+1. キャビティを大きくすれば理想積分球(開口比ゼロ)に近づいて均一になるか
+2. TEST_11 が残差の物理源と特定した「未混合の一次照射成分」をバッフルで除去できるか
+
+TEST_11 の記述によれば,R=0.85 の有限反射率では一次照射キャップ(LED が直接照らす壁面)が
+消えず,評価板の各点がそれを異なる見込み角で見るため cos 依存のドーム状落ち込みが残る.
+バッフルで評価板から光源を隠せば,板には 2 回以上反射した光だけが届く.
+
+## 実験条件 (Setup)
+
+材質・センサ・レンダ設定・LED の面積と radiance は TEST_11 / TEST_12 から一切変更していない.
+変えたのは LED の向き・位置とバッフルの有無,および筐体の幾何だけである.
+
+| 項目 | 値 |
+| --- | --- |
+| レンダ | spp=1024 / max_depth=16 / 512² / llvm_ad_rgb / path / orthographic (50×52) |
+| 内壁材質 | blendbsdf ρ=0.85 / ω=0.05 / α=0.1(TEST_14 の校正値 ρ≈0.81 では**なく**既存条件) |
+| 光源 | 軸上の円板 半径 1.7662mm(面積 9.8mm² 保存)・radiance 2807.25 |
+| 評価 | `configs/eval_baseline_dng_target_shape.yaml`(linear + mean アンカー 207.82) |
+| 評価板 | 99×102mm.**全条件で不変**(キャビティ側の面は z=−4.5mm) |
+
+### LED 構成 (LED Configuration)
+
+| 構成 | LED の位置と向き | バッフル |
+| --- | --- | --- |
+| `axial_wall`(従来) | −Z 極から 50mm 手前・壁向き (0,0,−1) | なし(板は発光面の裏側なので直接光ゼロ) |
+| `board_baffle`(新規) | **−Z 極の壁面に密着**・評価板向き (0,0,+1) | あり |
+
+壁面には 0.5mm だけ浮かせている.厳密に 0 にすると平らな発光円板が湾曲した壁面を貫くためで,
+円板半径 1.7662mm でのサジッタは r=88 でも 0.018mm なので実質的に密着である.
+極の壁面法線は +Z =評価板向きなので,「壁に付ける」と「評価板を向く」が同時に成り立つ.
+
+### バッフルの寸法 (Baffle Sizing)
+
+LED から `BAFFLE_GAP`=10mm 手前に置く円板とし,半径は評価板の**全点**から LED を隠す最小値に
+安全率 1.05 を掛けた.LED 上の点 Q と板上の点 P の内分点として閉形式で決まる.
+
+$$R \ge (1 - f)\,r_\text{LED} + f\,r_\text{corner}, \quad f = \frac{\text{gap}}{z_\text{board} - z_\text{LED}}$$
+
+半径が大きいほど LED が板から遠ざかり見込み角が小さくなるため,バッフルも小さくて済む
+(R = 6.9mm @ r=88 → 3.3mm @ r=250).厚み 1mm,材質は内壁と同一とした.
+
+**遮蔽は指標ではなくレイキャストで直接検証した**(`scripts/verify_baffle_occlusion.py`).
+評価板の全面 41×41 点 × LED 上 33 点 = 55,473 本のレイを飛ばし,全条件で 100% が LED 到達前に
+遮蔽されることを確認している.
+
+### 幾何のパラメータ化 (Cavity Parameterization)
+
+半径を変えるときのキャビティ形状の決め方を 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)
+
+同じ r=100・同じ `fixed_cut` 幾何で,LED 構成だけを比べる.
+
+| 構成 | 構造 CoV | MC ノイズ | 輪帯 min/max | 中心/周辺比 | 上下比 |
+| --- | --- | --- | --- | --- | --- |
+| 旧 `axial_wall`(バッフルなし) | 0.0579 | 0.0213 | 0.7967 | 1.129 | 1.0067 |
+| 新 `board_baffle` | **0.0312** | 0.0664 | **0.8610** | **1.085** | **1.0028** |
+
+構造成分は 46% 減,輪帯 min/max は 0.797 → 0.861 に改善した.中心のピークが下がり
+(1.10 → 1.085)周辺の落ち込みが浅くなる(0.86 → 0.93),つまりドーム全体が浅くなっている.
+旧構成にあった上下の傾きも消えて完全な軸対称になった.
+
+引き換えに MC ノイズは 3 倍に増えた.板へ届く光が全て多重反射経由になり,NEE が効かなくなる
+ためと考えられる.**絶対量の比較には spp を増やす必要がある**(下記「限界」参照).
+
+## 結果 2 — CoV は MC ノイズに支配され,そのままでは読めない (CoV Is Noise-dominated)
+
+本スイープは絶対輝度が半径で 1 桁変わる(生 mean 1.32 @ r=88 → 0.155 @ r=250,ほぼ 1/r²).
+spp を据え置いたため相対ノイズが半径とともに増え,`metrics.csv` の CoV は
+0.069 → 0.128 と単調悪化するが,**その大半はノイズである**.
+
+構造とノイズの分離は次の手順で行う(`scripts/build_radius_sweep_visual.py:decompose_cov`).
+
+1. σ=12 のガウシアン(+ median 5px)で平滑化した像の CoV を取る
+2. 残差(生 − 平滑)を**空間シャッフル**して同じ平滑化を掛け,ノイズの漏れ込み量を実測する
+3. 平滑後 CoV から漏れ込みを二乗で差し引く
+
+妥当性は 2 通りで確認した.平滑化を σ=4→20 と変えても構造値は 4% しか動かず(収束),
+漏れ込みは σ=12 で 0.0017〜0.0026 に留まる.
+
+**形の比較には同心円(輪帯)プロファイルを使うのが最も確実である.** 方位平均はリング内の
+画素数だけノイズを 1/√N に落とすため,標準誤差は中央値 0.0009〜0.0016 まで下がり,
+条件間の差(0.02〜0.12)の 1/20〜1/100 になる.ノイズ水準の違う条件どうしを公平に比較できる.
+
+## 結果 3 — `fixed_cut` では球を大きくすると悪化する (Bigger Is Worse in fixed_cut)
+
+`board_baffle` × `fixed_cut` の 10 条件.内側 = ピーク→d=0.5,外側 = d=0.5→0.95 の落ち込み.
+
+| r [mm] | 生 CoV | ノイズ | 構造 CoV | 輪帯 min/max | 内側 | 外側 | 生 mean |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| 88 | 0.0694 | 0.0640 | 0.0232 | 0.8951 | 5.11% | 5.59% | 1.322 |
+| 95 | 0.0740 | 0.0654 | 0.0316 | 0.8571 | 8.11% | 6.39% | 1.091 |
+| 100 | 0.0747 | 0.0664 | 0.0312 | 0.8610 | 7.33% | 7.02% | 0.974 |
+| 110 | 0.0774 | 0.0698 | 0.0302 | 0.8676 | 6.81% | 7.17% | 0.798 |
+| 120 | 0.0801 | 0.0732 | 0.0289 | 0.8718 | 5.77% | 7.31% | 0.669 |
+| 135 | 0.0828 | 0.0767 | 0.0270 | **0.8726** | 5.57% | 8.08% | 0.530 |
+| 150 | 0.0878 | 0.0814 | 0.0280 | 0.8603 | 5.31% | 9.70% | 0.430 |
+| 170 | 0.0941 | 0.0874 | 0.0300 | 0.8438 | 4.59% | 12.08% | 0.336 |
+| 200 | 0.1046 | 0.0963 | 0.0361 | 0.8189 | 3.46% | 15.11% | 0.243 |
+| 250 | 0.1278 | 0.1164 | 0.0478 | 0.7791 | 3.51% | 17.97% | 0.155 |
+
+輪帯 min/max は r=135 を境に単調悪化し,**r=250 は旧 `axial_wall` 構成(0.797)より悪い**.
+
+内側と外側に分けると構造がはっきりする.**内側は期待どおり単調に改善する**
+(8.11% @ r=95 → 3.46% @ r=200)が,**外側が同じ勢いで悪化して差し引きで負ける**
+(6.39% → 17.97%).大半径では「中心はほぼ平ら,周辺だけ崖」という形になる.
+
+この逆向きの動きから,律速はロフト斜面が作る「井戸」だという仮説を立てた.評価板は
+深さ 35.5mm・口径 85.4mm の開口の底にあり,球はその口を通してしか見えない.中心画素は
+開口をほぼ正面から見るので大きい球の恩恵をそのまま受け,周辺画素は開口に視野を切られる
+ため球を大きくしても届かない,という説明で内側・外側の両方が同時に説明できる.
+
+## 結果 4 — 接合が半径とともに不自然になっていた (Junction Degrades with Radius)
+
+`fixed_cut` は接続円を固定するため,半径を上げると接合点が球の頂点へ寄っていく.
+
+| r [mm] | 接合の傾き \|dρ/dz\| | 接合部の曲率比(ロフト/球) | 球の頂点 z [mm] |
+| --- | --- | --- | --- |
+| 88 | 0.248 | 9.30 | +26.8 |
+| 100 | 0.609 | 4.95 | +8.0 |
+| 120 | 0.987 | 1.80 | −4.3 |
+| 150 | 1.444 | 0.11 | −13.3 |
+| 170 | 1.721 | 0.68 | −17.0 |
+| 250 | 2.751 | 1.34 | −25.0 |
+
+**r ≥ 120 では球の頂点が評価板より下にもぐる**.つまりキャビティは「ほぼ閉じた玉に細い首が
+生えた」形になり,板はその首の先から玉を覗く.接線は一致していても曲率は 9.30 → 0.11 → 1.34 と
+暴れており,接合部に折れが残る.これが「井戸」の実体である.
+
+## 結果 5 — 接合を直すと傾向が逆転する (Fixing the Junction Reverses the Trend)
+
+`similar_g2`(キャビティ相似 + 5 次 G2 接合)では,接合の傾きは全半径で 0.609 一定,
+曲率比は厳密に 1.00,球の頂点は常に板より上(z=+7〜+20mm)に留まる.
+どの半径でも「お椀にフタ」の自然な形になる.
+
+r=100 / 170 / 250 の 3 条件で比較した.
+
+| r [mm] | 形状 | 内側 | 外側 | 輪帯 min/max | 生 mean |
+| --- | --- | --- | --- | --- | --- |
+| 100 | `fixed_cut` | 7.33% | 7.02% | 0.8610 | 0.974 |
+| 100 | `similar_g2` | 7.46% | 7.69% | 0.8520 | 0.973 |
+| 170 | `fixed_cut` | 4.59% | 12.08% | 0.8438 | 0.336 |
+| 170 | `similar_g2` | 4.87% | **6.94%** | **0.8831** | 0.355 |
+| 250 | `fixed_cut` | 3.51% | 17.97% | 0.7791 | 0.155 |
+| 250 | `similar_g2` | 3.24% | **8.75%** | **0.8911** | 0.166 |
+
+**外側の落ち込みが半分になった**(r=250 で 17.97% → 8.75%).r=100 でほぼ変化がないのは
+二つの設計がその半径でほぼ同形になるためで,整合性の確認になっている.
+
+**傾向が逆転した**.輪帯 min/max は `fixed_cut` では半径とともに悪化(0.873 → 0.779)していたが,
+`similar_g2` では改善する(0.852 → 0.883 → 0.891).r=250 の 0.891 はこれまで測った全条件で最良で,
+`fixed_cut` の最良(r=135 の 0.873)を上回る.
+
+**内側の落ち込みは両設計でほぼ同一**(r=250 で 3.51% vs 3.24%)である.変更が予測どおり外側だけに
+効いたことを意味し,「内側は球のサイズが支配し接合形状は無関係」という井戸仮説の構造と一致する.
+
+副次的に明るさも改善した(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% 減らし,上下の傾きも消える
+- **キャビティサイズは,接合が自然であって初めて効くレバーになる**.接続円を固定したまま球だけ
+ 大きくすると,中心は平らになるが周辺が崖になり差し引きで悪化する
+- **効きが最も大きいのは「リムでの立ち上がり方」である**.板に垂直に立ち上げると板直下に喉が
+ でき,これが外側の落ち込みを支配する.リム接線を半径方向に取って喉を消すと,外側の落ち込みは
+ 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)は未実行なので,
+ 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 系)とは別系統の設計探索として扱うこと
+- 材質は TEST_14 の校正値(ρ≈0.81 純 diffuse)ではなく旧条件(ρ=0.85 blendbsdf)を使っている.
+ TEST_11 / TEST_12 と直接比較するための選択であり,絶対値の実機予測には校正値で引き直しが必要
+
+## 失敗パターン (Pitfalls Hit)
+
+- **評価板のキャビティ側の面は z=0 ではなく z=−4.5mm**(板の下面).z=0 を基準にバッフル径を
+ 計算すると要求より小さくなる.センサは z=−50 から +Z を向いてこの面を撮っている
+- **バッフル円板のメッシュエッジが対称面と一致するとレイがすり抜ける**.蓋の三角形ファンの
+ エッジが XZ / YZ 平面に乗ると,その平面内を通るレイがエッジ上の交差になって数値的に
+ 遮蔽判定を外す(55,473 本中 4〜32 本).円板を半セクタ回転させて解消した.物理的な影響は
+ 無いが,遮蔽の検証を曖昧にする
+- **`subprocess.run` の出力は Windows 既定の cp932 で復号される**.`sm-render` の日本語ログで
+ `UnicodeDecodeError` になり読取スレッドが落ちる.レンダ自体は成功するので結果は残るが,
+ **本当に失敗したときエラー本文が読めなくなる**.`encoding="utf-8"` を明示すること
+- **生の CoV で半径依存を読むと結論を誤る**(結果 2).必ず構造とノイズを分離するか,
+ 同心円プロファイルで比較する
+- **輪帯プロファイルは 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. リム張り出し量 `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)自体をスイープする
+4. ノイズフロアの測定(同一条件を seed 違いで 2 本)と,バッフル構成での spp 増加
+5. 実機予測に使うなら材質を TEST_14 の校正値(ρ≈0.81 純 diffuse)に差し替えて引き直す
+6. r=250 は装置深さ 483mm・明るさ 1/7 なので,実機として成立する半径の上限を別途決める
+
+## 再現コマンド (Reproduction)
+
+```bash
+# 幾何生成のみ(3D 配置確認用.レンダはしない)
+uv run python scripts/sweep_sphere_radius.py --prepare-only --cavity similar_g2
+
+# 遮蔽の検証(評価板から LED へのレイが全て遮られることを実測)
+uv run python scripts/verify_baffle_occlusion.py --all-radii --cavity similar_g2
+
+# 幾何確認図・半割り断面
+uv run python scripts/build_radius_geometry_figure.py --shots-dir
--cavity similar_g2
+uv run python scripts/show_scene_cutaway.py --scene configs/_tmp_sphere_radius/scene_board_baffle_similar_g2_r170.yaml
+
+# スイープ本体
+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
+
+# 可視化(--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 条件,`board_baffle_similar_g2/`=3 条件,
+`board_baffle_similar_radial/`=10 条件).設計比較図は `cavity_comparison.png`,
+接合の断面 before-after は `cavity_design_comparison.png` と `rim_tangent_comparison.png`.
+
+## 参照 (References)
+
+- [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md): 球ロフト筐体の起点と一次照射キャップの特定
+- [TEST_14_材質パラメータ再スイープと残差の構造分解](TEST_14_材質パラメータ再スイープと残差の構造分解.md): 材質では形が動かないことの実証
+- [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md): 出力ディレクトリの索引
+- [SPEC_03_基準設計の物理構成](../04_SPEC/SPEC_03_基準設計の物理構成.md): 座標系と評価板の物理仕様
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 433338f..9e4bbc8 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -81,3 +81,7 @@
- **TEST_13 物理モデル健全性の一括監査 — 潜在バグ 3 件を修正し再発防止テストを常設 (2026-08-30)**: TEST_12 と同じ「エラーも警告も出ないまま結果だけが歪む」故障モードが他に残っていないかを横断監査(動機: 同型のバグが TEST_08 でも起きており,そのとき TEST_07 が誤出力を「物理的に正しい」と一度正当化している=3 度目を防ぐ).**健全と確認**: シェーディング法線 == 幾何法線(実測逸脱 0.044 度)・メッシュ法線の向き(裏面ヒット 0・全 15 STL 巻き順一貫)・**絶対放射量(積分球の解析解と 0.2% 一致=単位系/反射率/光源パワー/センサ校正が正しい)**・面光源の軸マッピング.**修正した問題 3 件**: ① 片面 BSDF の脆弱性(法線が裏返ると無警告で真っ黒.`twosided` ラップで恒久修正.実シーンでは mean +0.015%=無影響を確認)/ ② **LLVM 初期化の順序バグで `slow` レンダテスト 8 件が長期間全滅**(`scene/lights.py` がモジュール import 時に mitsuba を読むため `runner.py` の自動探索が手遅れ.開発機は環境変数が永続設定済みで症状が隠れていた)/ ③ TEST_08 の軸取り違えに実効的な回帰テストが無かった.**要判断の発見**: `max_depth=16` は収束値の **90.3%** しかエネルギーを拾えていない(間接照明のみの構成のため.ただし CoV への影響は +0.35% で形状指標の過去結論は無影響.設定変更は LED radiance 再校正を伴うため未実施,警告ログのみ実装).**未解決**: VTK 破損で `sm-visualize` と可視化テスト 39 件が実行不能(依存関係の修復が必要).設計判断: 対称性テストは負のコントロールが発火せず(立方体では平滑法線でも象限比 1.002 vs 1.003)検出力を実証できなかったため**常設テストに含めない**(検出できない検査は「検査済み」という誤った安心を与える).積分球テストは 1.5 秒と軽量なので `slow` を付けず通常実行に含めた(`slow` 隔離が ② の全滅を見逃した原因のため).記録 [TEST_13](06_TEST/TEST_13_物理モデル健全性の一括監査.md).
- **sm-visualize の起動不能を解消 — 原因は VTK 破損ではなく Smart App Control (2026-08-30)**: TEST_13 で唯一未解決だった 3D 可視化の全滅(可視化テスト 39 件・`sm-visualize` 実行不能)を解決.**当初「VTK のインストール破損」と誤診していたが,真因は Windows Smart App Control(強制モード)が vtk 9.6.1 の DLL 209 個のうち 14 個をレピュテーション判定でブロックしていたこと**.署名の有無は判定基準ではなく(通った DLL も弾かれた DLL も未署名),`.pyd` は全て正常に存在し `uv.lock` も 5/17 から不変だったため,`uv sync` や同一バージョン再インストールでは直らない種類だった.誤診の原因は最初に観測したメッセージが `No module named vtkmodules.vtkCommonCore` という二次症状だったこと=**ネイティブ拡張の ImportError は表示メッセージを額面どおり受け取らない**という教訓.対処は `vtk==9.4.2` への固定(より長く配布されレピュテーションが確立したバージョン.pyvista 0.48.0 の制約 `>=9.2.2,<9.7.0,!=9.4.1` 内)で,全 147 モジュールがブロック 0 件で読み込め可視化テスト 54 件が全通過.設計判断: Smart App Control の無効化は**一度オフにすると Windows 再インストールなしに戻せない**ため不採用(可視化ツール 1 本のために恒久的にマルウェア保護を手放すのは釣り合わない).WSL2 への切り出しは環境二重管理の負債を負うため次善策として保留.**再発防止**: 破損が 8/17〜8/30 の約 2 週間気づかれなかった原因は可視化テストが全て `slow` 隔離されていたことで,TEST_13 検査 7(LLVM 初期化バグでレンダテストが長期間全滅)とまったく同じ構図.import 可否だけを見る軽量テスト(1 秒未満)を `slow` 無しで常設し,`pyproject.toml` の固定にも理由をコメントで残した(Dependabot / `/deps-update` による静かな再破損を防ぐため).判断は `/consult`(advisor: Fable)に諮り,A(ダウングレード)→ B(WSL2)の順・C(SAC 無効化)不採用・import 監視テスト常設という助言に沿って実施.記録 [TEST_13 検査 8](06_TEST/TEST_13_物理モデル健全性の一括監査.md).
- **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
new file mode 100644
index 0000000..aa48dc1
--- /dev/null
+++ b/scripts/build_radial_comparison.py
@@ -0,0 +1,211 @@
+"""半径スイープの結果を同心円(輪帯)プロファイルで比較する.
+
+CoV は MC ノイズに支配されて半径間の比較ができない(本スイープでは絶対輝度が
+1 桁変わるため).一方**同心円リングの方位平均はリング内の画素数だけノイズを
+1/sqrt(N) に落とす**ので,ノイズ水準の違う条件どうしでも「中心から周辺への
+落ち方(ドームの形)」を公平に比較できる.
+
+輪帯の定義は評価パイプラインと同一(`evaluation/radial.py` の正規化楕円距離ビン).
+輝度は条件ごとに**最大の輪帯を 1** として正規化するので,絶対輝度の 1/r^2 の違いは
+相殺され,曲線は「ピークから何割落ちるか」を直接表す(曲線の最小値がそのまま
+輪帯 min/max 比になる).各リングには標準誤差(std/sqrt(N))の帯を重ねて,
+ノイズが平均化で潰れていることを図の上で確認できるようにする.
+
+分割数の既定は 20 で,評価パイプライン(`calc_radial_profile` の既定)および
+`build_radius_sweep_visual.py` の輝度マップ正規化と同一にしてある.図どうしで
+「ピーク=どの輪帯か」がずれないようにするため.
+
+実行方法:
+ uv run python scripts/build_radial_comparison.py --cavity similar_radial
+"""
+
+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 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
+
+ROOT = Path(__file__).resolve().parents[1]
+# 旧構成(LED 壁向き・バッフルなし)の r=100.バッフルの効果を同じ図で比較するための参照
+LEGACY_R100 = ROOT / "output" / "sphere_r100_loft_disk_fix" / "luminance.npy"
+# 最外ビンは ROI の四隅しか含まず画素数が激減し統計が荒れるため,代表値の算出から外す
+MAX_DISTANCE = 0.95
+
+plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"]
+
+
+def radial_stats(luminance_npy: Path, n_bins: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """輪帯ごとの (ビン中心距離, 平均輝度/ROI平均, 標準誤差/ROI平均) を返す.
+
+ 平滑化は掛けない.方位平均そのものがノイズ低減になっており,
+ 平滑化を重ねると本来見たい径方向の構造まで鈍らせるため.
+
+ ビン割りは `evaluation/radial.py:calc_radial_profile` と同一規約
+ (正規化楕円距離 [0, 1] を n_bins 等分・`digitize(right=True)`).
+ """
+ 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)
+ mean = roi.mean()
+
+ distance = compute_normalized_distance_map(*roi.shape)
+ edges = np.linspace(0.0, 1.0, n_bins + 1)
+ centers = (edges[:-1] + edges[1:]) / 2.0
+ indices = np.digitize(distance.ravel(), edges, right=True)
+ flat = roi.ravel()
+
+ means = np.full(n_bins, np.nan)
+ sems = np.full(n_bins, np.nan)
+ for i in range(1, n_bins + 1):
+ values = flat[indices == i]
+ if values.size:
+ means[i - 1] = values.mean() / mean
+ sems[i - 1] = values.std(ddof=1) / np.sqrt(values.size) / mean
+ return centers, means, sems
+
+
+def normalize_to_peak(
+ centers: np.ndarray, means: np.ndarray, sems: np.ndarray
+) -> tuple[np.ndarray, np.ndarray]:
+ """最大の輪帯平均を 1 とするスケールに直した (平均, 標準誤差) を返す.
+
+ ROI 平均で割る正規化だと曲線ごとに基準の高さが違い「どれだけ落ちるか」が
+ 読みにくい.ピークを 1 に揃えると縦軸がそのまま「ピーク比」になる.
+ 四隅しか含まない最外ビンは統計が荒いのでピーク探索から外す.
+ """
+ valid = (~np.isnan(means)) & (centers <= MAX_DISTANCE)
+ peak = means[valid].max()
+ return means / peak, sems / peak
+
+
+def ring_min_max_ratio(centers: np.ndarray, means: np.ndarray) -> float:
+ """輪帯平均の min/max 比を返す(1 が理想=ドームが無い).
+
+ 中心リング基準の比は,中心が 1 リングだけで統計が荒く,かつプロファイルの
+ ピークが中心そのものではなく d≈0.05 にあるため代表値として不安定だった.
+ 全輪帯の min/max なら非単調なプロファイルにも頑健に効く
+ (`evaluation/radial.py:calc_radial_min_max_ratio` と同じ考え方).
+ 四隅しか含まない最外ビン(d > MAX_DISTANCE)は除外する.
+ """
+ valid = (~np.isnan(means)) & (centers <= MAX_DISTANCE)
+ return float(means[valid].min() / means[valid].max())
+
+
+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 / run_dir_name(variant, cavity) / job_name(radius) / "luminance.npy"
+ if not path.exists():
+ continue
+ centers, means, sems = radial_stats(path, n_bins)
+ ratios.append((radius, ring_min_max_ratio(centers, means)))
+ means, sems = normalize_to_peak(centers, means, sems)
+ ax.plot(centers, means, color=color, lw=1.6, label=f"r={radius:g}")
+ ax.fill_between(centers, means - sems, means + sems, color=color, alpha=0.35, lw=0)
+ ax.axhline(1.0, color="gray", lw=0.8, ls="--")
+ ax.set_xlabel("正規化楕円距離(0=中心, 1=ROI 四隅)")
+ ax.set_ylabel("輪帯平均輝度(ピーク=1)")
+ ax.set_title("同心円プロファイルの半径依存(帯は ±1 標準誤差)", fontsize=11)
+ ax.legend(fontsize=8, ncol=2)
+ ax.grid(alpha=0.3)
+ return ratios
+
+
+def draw_dome_depth(ax, ratios: list[tuple[float, float]], legacy: float | None) -> None:
+ """輪帯 min/max の半径依存を描く(1 に近いほどドームが浅い=均一)."""
+ radii = [r for r, _ in ratios]
+ values = [v for _, v in ratios]
+ ax.plot(radii, values, marker="o", color="tab:red", label="新構成(LED 壁面 + バッフル)")
+ if legacy is not None:
+ ax.axhline(legacy, color="tab:blue", lw=1.4, ls="-.",
+ label=f"旧 axial_wall r=100 ({legacy:.3f})")
+ ax.axhline(1.0, color="gray", lw=0.8, ls="--")
+ ax.set_xlabel("球内半径 (mm)")
+ ax.set_ylabel(f"輪帯 min/max(d<={MAX_DISTANCE})")
+ ax.set_title("ドームの深さ(1 が理想)", fontsize=11)
+ ax.legend(fontsize=8)
+ ax.grid(alpha=0.3)
+
+
+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 = [
+ (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:
+ if not path.exists():
+ continue
+ centers, means, sems = radial_stats(path, n_bins)
+ means, sems = normalize_to_peak(centers, means, sems)
+ ax.plot(centers, means, color=color, lw=2.0, label=label)
+ ax.fill_between(centers, means - sems, means + sems, color=color, alpha=0.3, lw=0)
+ ax.axhline(1.0, color="gray", lw=0.8, ls="--")
+ ax.set_xlabel("正規化楕円距離")
+ ax.set_ylabel("輪帯平均輝度(ピーク=1)")
+ ax.set_title("同じ r=100 での構成比較", fontsize=11)
+ ax.legend(fontsize=9)
+ ax.grid(alpha=0.3)
+
+
+def main() -> int:
+ """同心円プロファイルの比較図を生成する."""
+ 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("--bins", type=int, default=20,
+ help="輪帯の分割数(既定 20=評価パイプラインと同一)")
+ parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス")
+ args = parser.parse_args()
+
+ 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.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.cavity, args.bins)
+ fig.suptitle(
+ f"同心円(輪帯)プロファイル比較 [{args.variant} / {args.cavity}]"
+ f" 輪帯 {args.bins} 分割・方位平均でノイズを低減・最大輪帯を 1 に正規化", fontsize=14
+ )
+ fig.tight_layout()
+ output.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(output, dpi=110)
+ print(f"出力: {output}")
+ for radius, ratio in ratios:
+ print(f" r={radius:6.1f} 輪帯 min/max = {ratio:.4f}")
+ if legacy is not None:
+ print(f" 旧 axial_wall r=100 輪帯 min/max = {legacy:.4f}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/build_radius_geometry_figure.py b/scripts/build_radius_geometry_figure.py
new file mode 100644
index 0000000..3e6a90c
--- /dev/null
+++ b/scripts/build_radius_geometry_figure.py
@@ -0,0 +1,157 @@
+"""半径スイープの幾何確認図(軸断面 + 3D スクリーンショット一覧)を生成する.
+
+CLAUDE.md の「幾何を変えるシーンは本番レンダの前に必ず 3D で配置確認する」に対応する
+確認用の図.sweep_sphere_radius.py が生成したシーン YAML と同じ式で軸断面を描き,
+評価板・ロフト・球・LED・一次照射キャップの位置関係を半径ごとに重ね描きする.
+
+3D スクリーンショットは事前に次で生成しておく(無い半径は自動でスキップ):
+ uv run sm-visualize --scene configs/_tmp_sphere_radius/scene_r.yaml \
+ --no-bake --screenshot /r.png
+
+実行方法:
+ uv run python scripts/build_radius_geometry_figure.py --shots-dir
+"""
+
+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))
+
+from generate_sphere_loft_housing import _loft_ring # noqa: E402
+from sweep_sphere_radius import ( # noqa: E402
+ BAFFLE_GAP,
+ BOARD_HALF,
+ BOARD_SURFACE_Z,
+ CAVITIES,
+ RADII,
+ RIM_INSET,
+ RIM_Z,
+ VARIANTS,
+ baffle_radius,
+ center_z,
+ cut_radius,
+ cut_z,
+ job_name,
+ led_z,
+ loft_blend,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_OUT = ROOT / "output" / "sphere_radius_sweep" / "geometry_check.png"
+
+plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"]
+
+
+def loft_profile(radius: float, cavity: str, n: int = 200) -> tuple[np.ndarray, np.ndarray]:
+ """ロフト部(リム矩形 → 球断面円)の X-Z 断面プロファイルを返す.
+
+ メッシュ生成本体(generate_sphere_loft_housing)と同じブレンド関数を
+ theta=0(+X 方向)について評価するので,図と STL が食い違わない.
+ """
+ rect_r = np.full(1, BOARD_HALF[0] - RIM_INSET)
+ z_cut, r_cut = cut_z(radius, cavity), cut_radius(radius, cavity)
+ 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]:
+ """球お椀部(接続面より下)の X-Z 断面プロファイルを返す."""
+ cz = center_z(radius, cavity)
+ phi = np.linspace(float(np.arccos((cut_z(radius, cavity) - cz) / radius)), np.pi, n)
+ return radius * np.sin(phi), cz + radius * np.cos(phi)
+
+
+def draw_cross_sections(ax, radii: list[float], variant: str, cavity: str) -> None:
+ """全半径の軸断面を重ね描きする(LED の向きとバッフルは構成に応じて描き分ける)."""
+ colors = plt.cm.viridis(np.linspace(0.0, 0.9, len(radii)))
+ to_board = variant == "board_baffle"
+ for radius, color in zip(radii, colors, strict=True):
+ lx, lz = loft_profile(radius, cavity)
+ sx, sz = sphere_profile(radius, cavity)
+ x = np.concatenate([lx, sx])
+ z = np.concatenate([lz, sz])
+ ax.plot(x, z, color=color, lw=1.4, label=f"r={radius:g}")
+ ax.plot(-x, z, color=color, lw=1.4)
+ zl = led_z(radius, variant, cavity)
+ ax.plot(0.0, zl, marker="^" if to_board else "v", color=color, ms=7)
+ if to_board:
+ # バッフル円板(実寸).評価板への直接光を完全に塞ぐ最小径
+ rb = baffle_radius(radius, variant, cavity)
+ ax.plot([-rb, rb], [zl + BAFFLE_GAP, zl + BAFFLE_GAP], color="black", lw=2.5)
+ # 板の四隅へ向かう直接光の視線(バッフルで切られることを示す)
+ for sign in (-1.0, 1.0):
+ ax.plot([0.0, sign * np.hypot(*BOARD_HALF)], [zl, BOARD_SURFACE_Z],
+ color=color, lw=0.5, ls=":", alpha=0.6)
+ else:
+ cap_r = float(np.sqrt(radius**2 - (zl - center_z(radius, cavity)) ** 2))
+ ax.plot([-cap_r, cap_r], [zl, zl], color=color, lw=0.7, ls=":")
+ ax.plot([-BOARD_HALF[0], BOARD_HALF[0]], [BOARD_SURFACE_Z] * 2, color="crimson", lw=3.0)
+ ax.text(0, 8, "評価板 99×102mm(不変)", color="crimson", ha="center", fontsize=9)
+ ax.plot([cut_radius(r, cavity) for r in radii], [cut_z(r, cavity) for r in radii],
+ color="black", lw=1.0, ls=":", marker="x", ms=4, label="接合点")
+ ax.set_aspect("equal")
+ ax.set_xlabel("X (mm)")
+ ax.set_ylabel("Z (mm)")
+ title = ("軸断面(▲=LED 評価板向き,黒線=バッフル,点線=遮断される直接光)" if to_board
+ else "軸断面(▼=LED 壁向き,点線=一次照射キャップの端)")
+ ax.set_title(title, fontsize=11)
+ ax.legend(fontsize=7, loc="lower right", ncol=2)
+ ax.grid(alpha=0.3)
+
+
+def draw_shots(fig, gs, shots_dir: Path, radii: list[float]) -> None:
+ """3D スクリーンショットをタイル表示する(存在するものだけ)."""
+ available = [r for r in radii if (shots_dir / f"{job_name(r)}.png").exists()]
+ for i, radius in enumerate(available[:4]):
+ ax = fig.add_subplot(gs[i // 2, 1 + i % 2])
+ ax.imshow(plt.imread(shots_dir / f"{job_name(radius)}.png"))
+ ax.set_title(f"r={radius:g}mm", fontsize=10)
+ ax.axis("off")
+
+
+def main() -> int:
+ """幾何確認図を生成して保存する."""
+ parser = argparse.ArgumentParser(description="半径スイープの幾何確認図を生成")
+ parser.add_argument("--shots-dir", type=Path, required=True,
+ help="sm-visualize が出力した r.png を置いたディレクトリ")
+ parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス")
+ parser.add_argument("--variant", choices=VARIANTS, default="board_baffle",
+ help="描画する LED 構成")
+ parser.add_argument("--cavity", choices=CAVITIES, default="similar_g2",
+ help="描画するキャビティ形状")
+ args = parser.parse_args()
+
+ output = args.output or DEFAULT_OUT.with_name(
+ f"geometry_check_{args.variant}_{args.cavity}.png")
+ fig = plt.figure(figsize=(16, 9))
+ gs = fig.add_gridspec(2, 3, width_ratios=[1.35, 1, 1])
+ ax = fig.add_subplot(gs[:, 0])
+ draw_cross_sections(ax, RADII, args.variant, args.cavity)
+ draw_shots(fig, gs, args.shots_dir, [88.0, 100.0, 170.0, 250.0])
+ fig.suptitle(
+ f"球体筐体 半径スイープ 幾何確認 [{args.variant} / {args.cavity}]", fontsize=13
+ )
+ fig.tight_layout()
+ output.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(output, dpi=110)
+ print(f"出力: {output}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/build_radius_sweep_visual.py b/scripts/build_radius_sweep_visual.py
new file mode 100644
index 0000000..415beb6
--- /dev/null
+++ b/scripts/build_radius_sweep_visual.py
@@ -0,0 +1,208 @@
+"""半径スイープの結果を一覧図にする(輝度マップ一覧 + 指標の半径依存カーブ).
+
+数値だけで形を判断しないための図.TEST_14 では総合スコアと残差 RMS が別の最良条件を
+指す食い違いが起き,輝度マップそのものを見て初めて解釈できた.本スクリプトは
+summary.csv の指標カーブと,各半径の輝度マップを必ず並べて出す.
+
+輝度マップは平滑化(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 --cavity similar_radial
+"""
+
+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 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"]
+
+# 指標カーブに出す列と表示名(小さいほど均一 / 1 に近いほど均一を注記する)
+METRIC_PANELS = [
+ ("gradient_magnitude", "勾配 [%](小さいほど均一)", False),
+ ("center_periphery_ratio", "中心/周辺比(1 が理想)", True),
+ ("radial_min_max_ratio", "動径 min/max(1 が理想)", True),
+ ("angular_ratio", "angular_ratio(0=同心円)", False),
+ ("raw_mean_roi", "生の ROI 平均輝度(絶対値・対数)", False),
+]
+
+# CoV 分解に使う平滑化強度.sigma=4 では MC ノイズが残るため構造の推定には 12 を使う
+# (sigma を 4→20 と変えても構造値は 4% しか動かないことを実測して確認済み)
+STRUCTURE_SIGMA = 12
+
+# 輝度マップの正規化に使う輪帯の分割数(評価パイプラインの既定と同一)
+NORMALIZE_BINS = 20
+
+
+def read_summary(path: Path) -> list[dict[str, float]]:
+ """summary.csv を読み,半径昇順の辞書リストで返す."""
+ with path.open(encoding="utf-8") as fh:
+ rows = [{k: (float(v) if v else float("nan")) for k, v in row.items()}
+ for row in csv.DictReader(fh)]
+ return sorted(rows, key=lambda r: r["radius_mm"])
+
+
+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)
+ 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]:
+ """CoV を「構造」と「MC ノイズ」に分解して (生, 構造, ノイズ) を返す.
+
+ 生の CoV は絶対輝度が半径で 1 桁変わる本スイープでは MC ノイズに支配され,
+ そのまま半径依存を読むと「大きい球ほど不均一」という誤った結論になる.
+
+ 構造は STRUCTURE_SIGMA で平滑化した像の CoV から,ノイズの漏れ込み分を
+ 二乗で差し引いて求める.漏れ込み量は残差(生 - 平滑)を空間シャッフルして
+ 同じ平滑化を掛けた std として実測する(構造を壊しノイズの統計だけ残す).
+ """
+ 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)
+ mean = roi.mean()
+ residual = roi - gaussian_filter(median_filter(roi, size=5), sigma=4)
+ smoothed = gaussian_filter(median_filter(roi, size=5), sigma=STRUCTURE_SIGMA)
+ flat = residual.ravel().copy()
+ np.random.default_rng(seed).shuffle(flat)
+ leak = gaussian_filter(
+ median_filter(flat.reshape(residual.shape), size=5), sigma=STRUCTURE_SIGMA
+ ).std() / mean
+ structure = float(np.sqrt(max((smoothed.std() / smoothed.mean()) ** 2 - leak**2, 0.0)))
+ return float(roi.std() / mean), structure, float(residual.std() / mean)
+
+
+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 / run_dir_name(variant, cavity)
+ / job_name(row["radius_mm"]) / "luminance.npy")
+ if not path.exists():
+ continue
+ raw, structure, noise = decompose_cov(path)
+ radii.append(row["radius_mm"])
+ raws.append(raw)
+ structures.append(structure)
+ noises.append(noise)
+ ax.plot(radii, raws, marker="o", color="gray", label="生 CoV(= metrics.csv)")
+ ax.plot(radii, noises, marker="^", color="tab:orange", label="MC ノイズ成分")
+ ax.plot(radii, structures, marker="s", color="tab:red", lw=2.2, label="構造成分(真の不均一)")
+ ax.set_xlabel("球内半径 (mm)")
+ ax.set_title("CoV の分解(構造 vs MC ノイズ)", fontsize=11)
+ ax.legend(fontsize=8)
+ ax.grid(alpha=0.3)
+
+
+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 / run_dir_name(variant, cavity)
+ / job_name(row["radius_mm"]) / "luminance.npy")
+ if path.exists():
+ maps.append((row["radius_mm"], normalized_map(path)))
+ if not maps:
+ return
+ stacked = np.concatenate([m.ravel() for _, m in maps])
+ # 上端は 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=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="最大輪帯(20 分割)=1.0")
+
+
+def draw_metrics(fig, gs, rows: list[dict[str, float]]) -> None:
+ """指標の半径依存カーブを描く(理想値がある指標には水平線を引く)."""
+ radii = [row["radius_mm"] for row in rows]
+ for i, (key, label, has_ideal) in enumerate(METRIC_PANELS, start=1):
+ ax = fig.add_subplot(gs[2 + i // 3, (i % 3) * 2 : (i % 3) * 2 + 2])
+ ax.plot(radii, [row[key] for row in rows], marker="o", color="tab:blue")
+ if has_ideal:
+ ax.axhline(1.0, color="gray", lw=0.8, ls="--")
+ if key == "raw_mean_roi":
+ ax.set_yscale("log")
+ ax.set_xlabel("球内半径 (mm)")
+ ax.set_title(label, fontsize=10)
+ ax.grid(alpha=0.3)
+
+
+def main() -> int:
+ """半径スイープの一覧図を生成する."""
+ 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()
+
+ 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 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, 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} / {args.cavity}]"
+ " 輝度マップは 20 分割の輪帯平均の最大値を 1.0 に正規化", fontsize=15
+ )
+ fig.tight_layout()
+ output.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(output, dpi=100)
+ print(f"出力: {output}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/generate_sphere_loft_housing.py b/scripts/generate_sphere_loft_housing.py
index 6d17653..17b178b 100644
--- a/scripts/generate_sphere_loft_housing.py
+++ b/scripts/generate_sphere_loft_housing.py
@@ -39,6 +39,101 @@
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,
+ cut_r: float,
+ m1: float,
+ m2: float,
+ blend: str,
+) -> np.ndarray:
+ """ロフト部のパラメータ u における半径を Hermite ブレンドで返す.
+
+ Args:
+ u: 0(リム矩形)→ 1(球との接続円)のパラメータ.
+ rect_r: リム矩形の角度ごとの半径.
+ cut_r: 接続円の半径.
+ m1: 接続円における dρ/du(球面の接線に一致させる値).
+ m2: 接続円における d²ρ/du²(球面の曲率に一致させる値.quintic のみ使用).
+ blend: "cubic"(傾きのみ一致)または "quintic"(傾きと曲率を一致).
+
+ Returns:
+ 角度ごとの半径配列.
+
+ Raises:
+ ValueError: 未対応の blend が指定された場合.
+ """
+ if blend == "cubic":
+ return (2 * u**3 - 3 * u**2 + 1) * rect_r + (-2 * u**3 + 3 * u**2) * cut_r + (
+ u**3 - u**2
+ ) * m1
+ if blend == "quintic":
+ # 5 次 Hermite: p(0)=rect_r, p'(0)=p''(0)=0, p(1)=cut_r, p'(1)=m1, p''(1)=m2
+ # 端点で曲率まで球に一致するので,接合部に曲率の段差(見た目の折れ)が出ない
+ h0 = 1 - 10 * u**3 + 15 * u**4 - 6 * u**5
+ h3 = 10 * u**3 - 15 * u**4 + 6 * u**5
+ h4 = -4 * u**3 + 7 * u**4 - 3 * u**5
+ h5 = 0.5 * (u**3 - 2 * u**4 + u**5)
+ return h0 * rect_r + h3 * cut_r + h4 * m1 + h5 * m2
+ msg = f"未対応の blend です: {blend!r}(cubic / quintic のいずれか)"
+ raise ValueError(msg)
+
+
def _build_surface(
radius: float,
center_z: float,
@@ -49,6 +144,7 @@
n_theta: int,
n_loft: int,
n_sphere: int,
+ blend: str = "cubic",
) -> tuple[np.ndarray, np.ndarray]:
"""リム矩形 → ロフト → 球お椀の 1 枚曲面(頂点とリング構造)を生成する.
@@ -61,22 +157,14 @@
cut_d = cut_z - center_z
cut_r = float(np.sqrt(radius**2 - cut_d**2))
- # 球面の cut_z における dρ/dz(下向きに進むと ρ が増える側)を Hermite 端点勾配に使う
- drho_dz = -cut_d / cut_r
- # u: 0(リム)→ 1(カット面).dz/du = cut_z - rim_z
- m1 = drho_dz * (cut_z - rim_z)
-
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):
- h00 = 2 * u**3 - 3 * u**2 + 1
- h01 = -2 * u**3 + 3 * u**2
- h11 = u**3 - u**2
- rho = h00 * rect_r + h01 * cut_r + h11 * m1
+ 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 * (cut_z - rim_z)))
+ z_list.append(z_ring)
# 球部(カット面 → 極直前).角度パラメータで均等分割
phi_cut = float(np.arccos(cut_d / radius))
@@ -124,6 +212,7 @@
n_theta: int = 256,
n_loft: int = 48,
n_sphere: int = 160,
+ blend: str = "cubic",
) -> trimesh.Trimesh:
"""球+ロフト一体型筐体の閉ソリッド(内面+外面+リム帯)を生成する.
@@ -139,6 +228,10 @@
n_theta: 周方向分割数.
n_loft: ロフト部の軸方向リング数.
n_sphere: 球部の軸方向リング数.
+ blend: ロフトの形状."cubic" は球の接線だけを一致させる(既定・後方互換).
+ "quintic" は曲率まで一致させ,接合部の折れを無くす.いずれもリムでは
+ 板に垂直に立ち上がるため板直下に喉ができる."radial" はリムの接線を
+ 半径方向(水平)に取り,喉を無くす.
Returns:
watertight な筐体ソリッドメッシュ(mm 単位).
@@ -149,7 +242,7 @@
hx_in = board_half_x - rim_inset
hy_in = board_half_y - rim_inset
v_in, rings_in = _build_surface(
- radius, center_z, hx_in, hy_in, rim_z, cut_z, n_theta, n_loft, n_sphere
+ radius, center_z, hx_in, hy_in, rim_z, cut_z, n_theta, n_loft, n_sphere, blend
)
v_out, rings_out = _build_surface(
radius + thickness,
@@ -161,6 +254,7 @@
n_theta,
n_loft,
n_sphere,
+ blend,
)
f_in = _stitch_rings(rings_in, len(v_in) - 1)
@@ -213,6 +307,11 @@
"--cut-z", type=float, default=-40.0, help="ロフト⇄球の接続平面 Z(mm).既定 -40"
)
parser.add_argument(
+ "--blend", choices=("cubic", "quintic", "radial"), default="cubic",
+ help="ロフトの形状.quintic は接合部で曲率まで球に一致させる."
+ "radial はリムで半径方向(水平)に出して板直下の喉を無くす.既定 cubic",
+ )
+ parser.add_argument(
"--output",
type=Path,
default=Path("configs/meshes/球ロフト_r100.stl"),
@@ -233,6 +332,7 @@
rim_inset=args.rim_inset,
rim_z=args.rim_z,
cut_z=args.cut_z,
+ blend=args.blend,
)
args.output.parent.mkdir(parents=True, exist_ok=True)
mesh.export(args.output)
diff --git a/scripts/show_scene_cutaway.py b/scripts/show_scene_cutaway.py
new file mode 100644
index 0000000..36925d5
--- /dev/null
+++ b/scripts/show_scene_cutaway.py
@@ -0,0 +1,131 @@
+"""シーン YAML の筐体を半割りにした 3D 断面図を出力する(幾何の目視確認用).
+
+sm-visualize は閉じたキャビティを外から見るため内部の位置関係が読み取りにくい.
+本スクリプトは筐体メッシュを y >= 0 で切り落とし,評価板・LED(実寸の円板 + 向き矢印)・
+センサ位置を色分けして描くことで,「LED が壁のどこを向いているか」「板がどこに嵌まるか」を
+断面で直接確認できるようにする.
+
+実行方法:
+ uv run python scripts/show_scene_cutaway.py \
+ --scene configs/_tmp_sphere_radius/scene_r170.yaml --output cutaway_r170.png
+
+ # ウィンドウを開いて回して見る場合は --output を省略する
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import numpy as np
+import pyvista as pv
+import trimesh
+import yaml
+
+ROOT = Path(__file__).resolve().parents[1]
+
+HOUSING_COLOR = "lightsteelblue"
+BOARD_COLOR = "crimson"
+LED_COLOR = "gold"
+BAFFLE_COLOR = "darkorange"
+# 半割りにして内部を見せるのは筐体だけ.評価板とバッフルは丸ごと残す
+CLIPPED_MESH_IDS = ("sphere_housing",)
+
+
+def _load_mesh(path: Path, unit: str) -> pv.PolyData:
+ """STL を読み込み mm 単位の PolyData として返す."""
+ mesh = trimesh.load(path, force="mesh")
+ vertices = np.asarray(mesh.vertices, dtype=np.float64)
+ if unit == "m":
+ vertices = vertices * 1000.0
+ faces = np.asarray(mesh.faces, dtype=np.int64)
+ return pv.PolyData(vertices, np.hstack([np.full((len(faces), 1), 3), faces]).ravel())
+
+
+def _add_led(plotter: pv.Plotter, light: dict, scale: float) -> None:
+ """LED を実寸の円板 + 向き矢印として追加する."""
+ center = np.asarray(light["center"], dtype=np.float64)
+ direction = np.asarray(light.get("direction", [0.0, 0.0, -1.0]), dtype=np.float64)
+ direction = direction / np.linalg.norm(direction)
+ radius = float(light.get("radius", 2.0))
+ disc = pv.Disc(
+ center=center, normal=direction, inner=0.0, outer=max(radius, scale * 0.01), c_res=48
+ )
+ plotter.add_mesh(disc, color=LED_COLOR)
+ plotter.add_mesh(
+ pv.Arrow(start=center, direction=direction, scale=scale * 0.18), color=LED_COLOR
+ )
+ plotter.add_point_labels(
+ [center], [f"LED z={center[2]:.1f}"], font_size=12, text_color="black",
+ shape_opacity=0.4, always_visible=True,
+ )
+
+
+def build_plotter(scene_yaml: Path, off_screen: bool) -> pv.Plotter:
+ """シーン YAML から半割り断面のプロッタを組み立てて返す."""
+ config = yaml.safe_load(scene_yaml.read_text(encoding="utf-8"))
+ plotter = pv.Plotter(off_screen=off_screen, window_size=(1400, 1000))
+ plotter.set_background("white")
+
+ extent = 100.0
+ for entry in config["geometry"]["meshes"]:
+ path = Path(entry["path"])
+ if not path.is_absolute():
+ path = (scene_yaml.parent / path).resolve()
+ mesh = _load_mesh(path, entry.get("unit", "mm"))
+ extent = max(extent, float(np.abs(mesh.bounds).max()))
+ clipped = entry["id"] in CLIPPED_MESH_IDS
+ 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":
+ color, opacity = BAFFLE_COLOR, 1.0
+ else:
+ color, opacity = BOARD_COLOR, 0.55
+ plotter.add_mesh(shown, color=color, opacity=opacity, show_edges=False,
+ label=entry["id"])
+
+ for light in config.get("lights", []):
+ _add_led(plotter, light, extent)
+
+ sensor = config.get("sensor")
+ if sensor:
+ position = np.asarray(sensor["position"], dtype=np.float64)
+ plotter.add_mesh(pv.Sphere(radius=extent * 0.012, center=position), color="royalblue")
+ plotter.add_point_labels(
+ [position], [f"センサ z={position[2]:.1f}"], font_size=12, text_color="black",
+ shape_opacity=0.4, always_visible=True,
+ )
+
+ plotter.add_axes()
+ plotter.show_grid(color="gray")
+ # 筐体は y >= 0 を切り落としているので,+Y 側から覗き込むと内面が見える
+ plotter.camera_position = [
+ (extent * 1.6, extent * 2.6, extent * 0.7),
+ (0.0, 0.0, -extent * 0.55),
+ (0.0, 0.0, 1.0),
+ ]
+ return plotter
+
+
+def main() -> int:
+ """断面図を表示または PNG 保存する."""
+ parser = argparse.ArgumentParser(description="シーン筐体の半割り断面を 3D 表示する")
+ parser.add_argument("--scene", type=Path, required=True, help="シーン定義 YAML")
+ parser.add_argument("--output", type=Path, default=None,
+ help="PNG 保存先(省略時はウィンドウを開く)")
+ args = parser.parse_args()
+
+ plotter = build_plotter(args.scene, off_screen=args.output is not None)
+ if args.output is None:
+ plotter.show()
+ return 0
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ plotter.screenshot(str(args.output))
+ print(f"出力: {args.output}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/sweep_sphere_radius.py b/scripts/sweep_sphere_radius.py
new file mode 100644
index 0000000..949ea03
--- /dev/null
+++ b/scripts/sweep_sphere_radius.py
@@ -0,0 +1,465 @@
+"""球体筐体の半径スイープ(キャビティサイズが照明均一性に与える効果の定量化).
+
+TEST_11 / TEST_12 の球ロフト筐体について,**評価板のサイズとレンダ条件を据え置いたまま
+球の内半径だけを変える**.半径が大きいほど球面積に対する評価板の占有率が下がるため,
+理想積分球(開口比ゼロ)に漸近するはずで,その効きを CoV / angular_ratio 等で測る.
+
+幾何のパラメータ化(重要):
+ ロフト接続円(z = CUT_Z の断面円 CUT_R)を**固定**し,球中心 z を動かして球を深くする.
+
+ center_z = CUT_Z - sqrt(radius^2 - CUT_R^2)
+ pole_z = center_z - radius
+ led_z = pole_z + LED_POLE_OFFSET
+
+ 球中心を固定して半径を変える方式は採らない.断面円まで一緒に大きくなり,評価板まわりの
+ スカート(ロフト)形状が半径ごとに変わって「球の大きさ」の効果と交絡するため.
+ radius = 100 のとき center_z = -92 となり,既存 configs/scene_sphere_r100_loft_disk.yaml
+ と厳密に一致する(パイプラインの検証点).
+
+固定条件(既存レンダと完全に同一に保つ):
+ 材質 : 既存の blendbsdf rho=0.85 / omega=0.05 / alpha=0.1
+ (TEST_14 の校正値 rho=0.81 純 diffuse では**なく**,TEST_11/12 と直接比較するため)
+ 光源 : 軸上の円板 radius=1.7662mm(面積 9.8mm^2 保存).評価板と反対側(-Z 極側)
+ レンダ: spp=1024 / max_depth=16 / 512^2 / llvm_ad_rgb / path / orthographic (50x52)
+ 評価 : configs/eval_baseline_dng_target_shape.yaml(shape_metrics 込み)
+
+LED 構成(--variant):
+ board_baffle(既定): LED を **-Z 極の壁面に密着**させ **評価板向き (0,0,+1)** にし,
+ 直前にバッフル円板を置いて
+ 評価板への直接光を幾何的にゼロにする.バッフルは板の全点から LED を隠す最小半径
+ (baffle_radius)に安全率を掛けたもので,半径が大きいほど板の見込み角が小さくなり
+ バッフルも小さくて済む.
+ axial_wall: LED を壁向き (0,0,-1) にした従来構成(TEST_11 / TEST_12 と地続き).
+ バッフル無しでも板は発光面の裏側にあるため直接光はゼロ.
+
+絶対輝度について:
+ 評価設定は mean アンカー正規化(target_mean=207.82)をかけるので metrics.csv の mean は
+ 常に 207.82 になり絶対的な明るさの情報が消える.球が大きいほど暗くなる(おおむね 1/r^2)
+ のは実機設計上の重要なトレードオフなので,luminance.npy の**生の mean** を別途記録する.
+
+実行方法:
+ uv run python scripts/sweep_sphere_radius.py --prepare-only # メッシュ + シーン YAML のみ
+ uv run python scripts/sweep_sphere_radius.py # 上記 + レンダ + 評価
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import csv
+import subprocess
+import sys
+import time
+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_sphere_loft_housing import build_loft_housing # noqa: E402
+
+from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402
+
+ROOT = Path(__file__).resolve().parents[1]
+
+TEMPLATE_YAML = ROOT / "configs" / "scene_sphere_r100_loft_disk.yaml"
+EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml"
+TMP_DIR = ROOT / "configs" / "_tmp_sphere_radius"
+MESH_DIR = TMP_DIR / "meshes"
+OUTPUT_BASE = ROOT / "output" / "sphere_radius_sweep"
+
+# 幾何定数(既存 r=100 / center_z=-92 構成から導出.変更しないこと)
+CUT_Z = -40.0
+CUT_R = float(np.sqrt(100.0**2 - 52.0**2)) # = 85.4166 ロフト接続円の半径
+
+# キャビティ形状の指定(--cavity)
+# fixed_cut : 接続円(CUT_R, CUT_Z)を固定して球中心だけ動かす.3 次ロフト.
+# 半径を上げると接続面が球の頂点に近づき,r>=120 では**球の頂点が評価板より下**に
+# もぐる(=ほぼ閉じた玉に細い首が生えた形).接合の傾きは 0.25→2.75 と 11 倍に
+# 急峻化し,接合部の曲率比も 9.3→0.11→1.34 と暴れる.
+# similar_g2: キャビティ全体を半径に相似させ(center_z=-0.92r, cut_z=-0.40r),
+# ロフトを 5 次 Hermite にして接合部で**傾きと曲率の両方**を球に一致させる.
+# 接合の傾きは全半径で 0.609 一定,曲率比は厳密に 1.00.球の頂点は常に板より上
+# (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", "similar_radial")
+_SIMILAR_CAVITIES = ("similar_g2", "similar_radial")
+
+# LED を -Z 極から何 mm 手前に置くか(構成ごとに異なる)
+# axial_wall: TEST_11 の分散低減実測(NEE 可視領域が球面積の 25%)に基づく 50mm
+# board_baffle: 壁面に密着させる.厳密に 0 にすると平らな発光円板が湾曲した壁面を貫くため
+# (円板半径 1.7662mm でのサジッタは r=88 でも 0.018mm)最小限だけ浮かせる.
+# 極の壁面法線は +Z=評価板向きなので,密着と「評価板を向く」が同時に成り立つ
+LED_WALL_STANDOFF = 0.5
+LED_POLE_OFFSET = {"axial_wall": 50.0, "board_baffle": LED_WALL_STANDOFF}
+
+# 筐体生成の既定値(generate_sphere_loft_housing の CLI 既定と同一)
+THICKNESS = 3.0
+BOARD_HALF = (49.5, 51.0)
+RIM_INSET = 1.5
+RIM_Z = 0.0
+
+# LED 円板の半径(テンプレート YAML と一致させること)
+LED_RADIUS = 1.7662
+# 評価板の最遠点(四隅)までの軸からの距離.バッフル寸法の律速になる
+BOARD_CORNER = float(np.hypot(*BOARD_HALF))
+# 評価板のキャビティ側の面の Z(照明ムラ補正ボード.stl の最下面).
+# センサは z=-50 から +Z を向いてこの面を撮るので,遮蔽すべき対象はこの面である
+BOARD_SURFACE_Z = -4.5
+
+# バッフル(board_baffle 構成): LED の直前に置く円板で評価板への直接光を完全に塞ぐ
+BAFFLE_GAP = 10.0 # LED 発光面からバッフル中心面までの距離(mm)
+BAFFLE_THICKNESS = 1.0 # 円板の厚み(mm)
+BAFFLE_MARGIN = 1.05 # 幾何的な最小半径に対する安全率
+
+# LED の向き.axial_wall: 壁(-Z 極)向き=従来 / board_baffle: 評価板(+Z)向き
+VARIANTS = ("axial_wall", "board_baffle")
+
+# r=170 は元の TIAS のサイズ,r=100 は既存結果との突合用アンカー.
+RADII = [88.0, 95.0, 100.0, 110.0, 120.0, 135.0, 150.0, 170.0, 200.0, 250.0]
+
+SUMMARY_COLUMNS = [
+ "radius_mm", "center_z_mm", "pole_z_mm", "led_z_mm",
+ "cavity_area_mm2", "board_area_ratio", "elapsed_s",
+ "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",
+ "max_min_ratio",
+]
+
+
+def center_z(radius: float, cavity: str = "fixed_cut") -> float:
+ """球中心の Z 座標を返す(球は -Z 側に伸びる)."""
+ 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})")
+ return CUT_Z - float(np.sqrt(radius**2 - CUT_R**2))
+
+
+def cut_z(radius: float, cavity: str = "fixed_cut") -> float:
+ """ロフトと球の接続平面の Z 座標を返す."""
+ return CUT_Z_RATIO * radius if cavity in _SIMILAR_CAVITIES else CUT_Z
+
+
+def cut_radius(radius: float, cavity: str = "fixed_cut") -> float:
+ """接続円の半径を返す(球と接続平面の交線)."""
+ return float(np.sqrt(radius**2 - (cut_z(radius, cavity) - center_z(radius, cavity)) ** 2))
+
+
+def loft_blend(cavity: str) -> str:
+ """キャビティ形状に対応するロフトの形状を返す."""
+ return {"similar_g2": "quintic", "similar_radial": "radial"}.get(cavity, "cubic")
+
+
+def led_z(radius: float, variant: str, cavity: str = "fixed_cut") -> float:
+ """LED 円板の Z 座標(-Z 極から構成ごとのオフセットだけ手前)を返す."""
+ return center_z(radius, cavity) - radius + LED_POLE_OFFSET[variant]
+
+
+def job_name(radius: float) -> str:
+ """半径からジョブ名(ディレクトリ名)を作る."""
+ return f"r{radius:g}"
+
+
+def baffle_radius(
+ radius: float, variant: str = "board_baffle", cavity: str = "fixed_cut"
+) -> float:
+ """評価板の全点から LED を隠すのに必要なバッフル円板の半径(mm)を返す.
+
+ LED(半径 LED_RADIUS の円板,z = led_z)から評価板のキャビティ側の面
+ (z = BOARD_SURFACE_Z)へ向かう直線が z = led_z + BAFFLE_GAP の平面を横切る位置は,
+ LED 上の点 Q と板上の点 P の内分点なので,
+ f = BAFFLE_GAP / (BOARD_SURFACE_Z - led_z) として
+
+ |C| <= (1 - f) * LED_RADIUS + f * BOARD_CORNER
+
+ で厳密に上から押さえられる(等号は Q と P が同じ方位にあるとき).
+ これに安全率 BAFFLE_MARGIN を掛けたものを採用する.
+ """
+ f = BAFFLE_GAP / (BOARD_SURFACE_Z - led_z(radius, variant, cavity))
+ return BAFFLE_MARGIN * ((1.0 - f) * LED_RADIUS + f * BOARD_CORNER)
+
+
+def build_baffle_mesh(
+ radius: float, dest: Path, variant: str = "board_baffle", cavity: str = "fixed_cut"
+) -> trimesh.Trimesh:
+ """LED 直前のバッフル円板(薄い円柱ソリッド)を生成して書き出す.
+
+ 円板は半セクタだけ回転させる.無回転だと蓋の三角形ファンのエッジが XZ / YZ 平面に
+ 完全一致し,その平面内を通るレイがエッジ上の交差になって数値的にすり抜ける
+ (verify_baffle_occlusion.py で実測した現象).物理的な影響は無いが,
+ 遮蔽の検証を明確にするため対称面上にエッジを置かない.
+ """
+ sections = 128
+ mesh = trimesh.creation.cylinder(
+ radius=baffle_radius(radius, variant, cavity), height=BAFFLE_THICKNESS, sections=sections
+ )
+ mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1]))
+ mesh.apply_translation([0.0, 0.0, led_z(radius, variant, cavity) + BAFFLE_GAP])
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ mesh.export(dest)
+ return mesh
+
+
+def build_mesh(radius: float, dest: Path, cavity: str = "fixed_cut") -> trimesh.Trimesh:
+ """指定半径・指定キャビティ形状の筐体 STL を生成して書き出す."""
+ mesh = build_loft_housing(
+ radius=radius,
+ thickness=THICKNESS,
+ center_z=center_z(radius, cavity),
+ board_half_x=BOARD_HALF[0],
+ board_half_y=BOARD_HALF[1],
+ rim_inset=RIM_INSET,
+ rim_z=RIM_Z,
+ cut_z=cut_z(radius, cavity),
+ blend=loft_blend(cavity),
+ )
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ mesh.export(dest)
+ return mesh
+
+
+def build_scene_yaml(
+ radius: float, mesh_path: Path, dest: Path, variant: str, cavity: str,
+ baffle_path: Path | None = None,
+) -> None:
+ """テンプレートから当該半径・当該構成のシーン YAML を書き出す.
+
+ 材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは
+ 筐体メッシュ・LED の Z・LED の向き(構成による)・バッフルの有無だけ.
+ メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため絶対パス化する.
+ """
+ config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8")))
+ config["name"] = f"sphere_radius_{variant}_{cavity}_{job_name(radius)}"
+ if variant == "board_baffle":
+ aim = "壁面に密着し評価板向き(+Z).バッフルで直接光を遮断"
+ else:
+ aim = "壁向き(-Z)で評価板への直接光が幾何的にゼロ"
+ config["description"] = (
+ f"球体筐体の半径スイープ [{variant} / {cavity}]: 内半径 {radius:g}mm"
+ f"(球中心 z={center_z(radius, cavity):.3f}・接続円 {cut_radius(radius, cavity):.3f}mm"
+ f" @ z={cut_z(radius, cavity):.3f}・ロフト {loft_blend(cavity)})."
+ f"LED は極から {LED_POLE_OFFSET[variant]:g}mm 手前,{aim}."
+ )
+ for mesh in config["geometry"]["meshes"]:
+ if mesh["id"] == "sphere_housing":
+ mesh["path"] = str(mesh_path.resolve())
+ else:
+ mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve())
+ if baffle_path is not None:
+ config["geometry"]["meshes"].append({
+ "id": "baffle",
+ "path": str(baffle_path.resolve()),
+ "unit": "mm",
+ "material": "printer_white",
+ })
+ for light in config["lights"]:
+ light["center"] = [0.0, 0.0, led_z(radius, variant, cavity)]
+ light["direction"] = [0.0, 0.0, 1.0 if variant == "board_baffle" else -1.0]
+ 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 cavity_geometry(radius: float, cavity: str = "fixed_cut") -> dict[str, float]:
+ """キャビティ内面積と,評価板がそれに占める割合(開口比)を返す.
+
+ 内面積 = 球のお椀(接続面より下の球冠)+ ロフト部側面(錐台近似)+ 評価板.
+ 開口比は積分球としての理想度の目安(小さいほど理想に近い).
+ """
+ z_cut = cut_z(radius, cavity)
+ r_cut = cut_radius(radius, cavity)
+ cap_height = z_cut - (center_z(radius, cavity) - radius) # 極から接続面までの高さ
+ sphere_area = 2.0 * np.pi * radius * cap_height
+ rect_perimeter = 2.0 * (2 * BOARD_HALF[0] + 2 * BOARD_HALF[1])
+ slant = float(np.hypot(abs(z_cut - RIM_Z), r_cut - float(np.hypot(*BOARD_HALF)) * 0.5))
+ loft_area = 0.5 * (2 * np.pi * r_cut + rect_perimeter) / 2.0 * slant
+ board_area = (2 * BOARD_HALF[0]) * (2 * BOARD_HALF[1])
+ total = sphere_area + loft_area + board_area
+ return {
+ "cavity_area_mm2": round(total, 1),
+ "board_area_ratio": round(board_area / total, 5),
+ }
+
+
+def read_metrics_csv(path: Path) -> dict[str, float]:
+ """sm-evaluate が出す metrics.csv の 1 行目から必要な列を取り出す."""
+ with path.open(encoding="utf-8") as fh:
+ row = next(csv.DictReader(fh))
+ keys = ("cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
+ "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
+ "max_min_ratio")
+ return {k: (float(row[k]) if row.get(k) else float("nan")) for k in keys}
+
+
+def raw_stats(luminance_npy: Path) -> dict[str, float]:
+ """正規化前の生の輝度統計(絶対的な明るさ)を返す.
+
+ metrics.csv は mean アンカー正規化後なので mean が常に 207.82 になり,
+ 「球が大きいほど暗くなる」という設計上重要な情報が失われる.ここで拾っておく.
+ """
+ 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 {
+ "raw_mean_roi": float(roi.mean()),
+ "raw_mean_full": float(arr.mean()),
+ "raw_rel_std_roi": float(roi.std() / roi.mean()),
+ }
+
+
+def prepare(radii: list[float], variant: str, cavity: str, log) -> dict[float, Path]:
+ """全半径について筐体・バッフルのメッシュとシーン YAML を生成し,watertight を検証する."""
+ scenes: dict[float, Path] = {}
+ log(f"=== 幾何生成(構成 {variant} / キャビティ {cavity})===")
+ for radius in radii:
+ mesh_path = MESH_DIR / f"球ロフト_{cavity}_{job_name(radius)}.stl"
+ mesh = build_mesh(radius, mesh_path, cavity)
+ if not mesh.is_watertight:
+ raise ValueError(f"r={radius}: watertight でないメッシュが生成されました")
+ baffle_path = None
+ baffle_note = ""
+ if variant == "board_baffle":
+ baffle_path = MESH_DIR / f"バッフル_{cavity}_{job_name(radius)}.stl"
+ baffle = build_baffle_mesh(radius, baffle_path, variant, cavity)
+ if not baffle.is_watertight:
+ raise ValueError(f"r={radius}: バッフルが watertight になりませんでした")
+ baffle_note = (f" バッフル R={baffle_radius(radius, variant, cavity):5.2f}mm "
+ f"z={led_z(radius, variant, cavity) + BAFFLE_GAP:8.2f}")
+ scene_yaml = TMP_DIR / f"scene_{variant}_{cavity}_{job_name(radius)}.yaml"
+ build_scene_yaml(radius, mesh_path, scene_yaml, variant, cavity, baffle_path)
+ scenes[radius] = scene_yaml
+ geo = cavity_geometry(radius, cavity)
+ log(f" r={radius:6.1f} center_z={center_z(radius, cavity):9.3f} "
+ f"cut_z={cut_z(radius, cavity):8.2f} 接続円={cut_radius(radius, cavity):7.2f} "
+ f"LED_z={led_z(radius, variant, cavity):9.3f} "
+ f"開口比={geo['board_area_ratio'] * 100:5.2f}%{baffle_note}")
+ return scenes
+
+
+def run_dir_name(variant: str, cavity: str) -> str:
+ """出力ディレクトリ名を返す.
+
+ 既定キャビティ(fixed_cut)は変更前に実施した board_baffle 10 条件の結果が
+ output/sphere_radius_sweep/board_baffle/ に既にあるため,そのまま再利用できるよう
+ 構成名だけを使う.新しいキャビティはサフィックスを付けて分ける.
+ """
+ return variant if cavity == "fixed_cut" else f"{variant}_{cavity}"
+
+
+def run_job(
+ radius: float, scene_yaml: Path, variant: str, cavity: str, log
+) -> dict[str, float] | None:
+ """1 半径をレンダ→評価まで実行して集約行を返す(失敗時 None).
+
+ 既に metrics.csv と luminance.npy が揃っているジョブは再レンダせず再利用する.
+ """
+ name = job_name(radius)
+ out_dir = OUTPUT_BASE / run_dir_name(variant, cavity) / name
+ row: dict[str, float] = {
+ "radius_mm": radius,
+ "center_z_mm": round(center_z(radius, cavity), 3),
+ "pole_z_mm": round(center_z(radius, cavity) - radius, 3),
+ "led_z_mm": round(led_z(radius, variant, cavity), 3),
+ "elapsed_s": 0.0,
+ }
+ row.update(cavity_geometry(radius, cavity))
+ if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists():
+ log(f"--- {name}: 既存結果を再利用")
+ else:
+ log(f"--- {name}: レンダ開始(center_z={center_z(radius, cavity):.2f} "
+ f"LED_z={led_z(radius, variant, cavity):.2f})")
+ out_dir.mkdir(parents=True, exist_ok=True)
+ start = time.time()
+ for cmd in (
+ ["uv", "run", "sm-render", "--scene", str(scene_yaml), "--output", str(out_dir)],
+ ["uv", "run", "sm-evaluate", "--image", str(out_dir / "luminance.npy"),
+ "--config", str(EVAL_CONFIG_YAML), "--output", str(out_dir)],
+ ):
+ # encoding を明示する.既定では Windows の cp932 で復号され,
+ # sm-render が出す日本語ログで UnicodeDecodeError になって
+ # 失敗時のエラー本文が読めなくなる(実測).
+ result = subprocess.run(
+ cmd, cwd=ROOT, capture_output=True, text=True,
+ encoding="utf-8", errors="replace",
+ )
+ if result.returncode != 0:
+ log(f" !! 失敗 ({cmd[2]}): {result.stderr[-800:]}")
+ return None
+ row["elapsed_s"] = round(time.time() - start, 1)
+ row.update(read_metrics_csv(out_dir / "metrics.csv"))
+ row.update(raw_stats(out_dir / "luminance.npy"))
+ log(f" -> {row['elapsed_s']:.0f}s CoV={row['cov']:.4f} "
+ f"angular_ratio={row['angular_ratio']:.4f} C/P={row['center_periphery_ratio']:.4f} "
+ f"勾配={row['gradient_magnitude']:.2f}% 生mean={row['raw_mean_roi']:.4f}")
+ return row
+
+
+def write_summary(rows: list[dict[str, float]], variant: str, cavity: str) -> None:
+ """集約 CSV を半径昇順で書き直す(途中終了でも最新状態が残る)."""
+ summary_csv = OUTPUT_BASE / run_dir_name(variant, cavity) / "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 sorted(rows, key=lambda r: r["radius_mm"]):
+ writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS})
+
+
+def main() -> int:
+ """半径スイープを実行するメインエントリポイント."""
+ parser = argparse.ArgumentParser(description="球体筐体の半径スイープ")
+ parser.add_argument("--prepare-only", action="store_true",
+ help="メッシュとシーン YAML の生成だけ行う(3D 配置確認用)")
+ parser.add_argument("--radii", type=float, nargs="*", default=None,
+ help="半径リスト(mm).省略時は既定グリッド")
+ parser.add_argument("--variant", choices=VARIANTS, default="board_baffle",
+ help="LED 構成.board_baffle=評価板向き+バッフル / axial_wall=壁向き")
+ parser.add_argument("--cavity", choices=CAVITIES, default="similar_g2",
+ help="キャビティ形状.similar_g2=相似+5次G2接合 / fixed_cut=接続円固定")
+ args = parser.parse_args()
+
+ radii = args.radii if args.radii else RADII
+ variant, cavity = args.variant, args.cavity
+ log_path = ROOT / "output" / "_sweep_sphere_radius.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"=== 球体筐体 半径スイープ(構成 {variant} / キャビティ {cavity})===")
+ scenes = prepare(radii, variant, cavity, log)
+ if args.prepare_only:
+ log(f"--prepare-only のため終了.シーン YAML: {TMP_DIR}")
+ log_file.close()
+ return 0
+
+ rows: list[dict[str, float]] = []
+ for radius in radii:
+ row = run_job(radius, scenes[radius], variant, cavity, log)
+ if row is not None:
+ rows.append(row)
+ write_summary(rows, variant, cavity)
+
+ log(f"\n=== 完了: {len(rows)} 条件 ===")
+ log(f" 集約 CSV: {OUTPUT_BASE / run_dir_name(variant, cavity) / 'summary.csv'}")
+ log_file.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/verify_baffle_occlusion.py b/scripts/verify_baffle_occlusion.py
new file mode 100644
index 0000000..4ec922b
--- /dev/null
+++ b/scripts/verify_baffle_occlusion.py
@@ -0,0 +1,178 @@
+"""バッフルが評価板への直接光を実際に塞いでいるかをレイキャストで直接検証する.
+
+「指標が良くなったから効いている」という論法を取らないための検証スクリプト.
+評価板上の多数の点から LED 発光面上の多数の点へレイを飛ばし,**LED に到達する前に
+別の物体(バッフル)に遮られること**を Mitsuba の交差計算で実測する.
+
+判定:
+ board_baffle 構成 : 全レイが遮蔽されていること(blocked == total)が合格条件.
+ 1 本でも通っていればバッフル寸法が不足している.
+ axial_wall 構成 : LED が板と反対向きなので「到達はするが発光面の裏側」.
+ 本スクリプトは遮蔽本数だけを報告し,合否判定は行わない.
+
+実行方法:
+ uv run python scripts/verify_baffle_occlusion.py \
+ --scene configs/_tmp_sphere_radius/scene_board_baffle_r100.yaml
+ uv run python scripts/verify_baffle_occlusion.py --all-radii
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import sys
+from pathlib import Path
+
+import numpy as np
+
+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 ( # noqa: E402
+ BOARD_SURFACE_Z,
+ CAVITIES,
+ RADII,
+ TMP_DIR,
+ job_name,
+)
+
+from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path # noqa: E402
+from sm_tias_lightsim.scene.builder import build_scene # noqa: E402
+from sm_tias_lightsim.scene.schema import AreaLight, load_scene_yaml # noqa: E402
+
+ROOT = Path(__file__).resolve().parents[1]
+MM_TO_M = 0.001
+
+# 評価板の実体半幅(configs/meshes/照明ムラ補正ボード.stl と一致)
+BOARD_HALF = (49.5, 51.0)
+# レイ起点を板面から少しキャビティ側へ浮かせる量(mm).板自身への自己交差を避ける
+SURFACE_OFFSET = 0.05
+# レイが LED 発光面そのものに当たったと誤判定しないための許容量(m)
+HIT_EPSILON = 1.0e-5
+# LED 外周サンプルの位相オフセット(rad).対称面上にサンプルを置かないための任意値
+LED_RING_PHASE = 0.1234
+
+
+def board_samples(grid: int) -> np.ndarray:
+ """評価板のキャビティ側の面を格子状にサンプリングした点(mm)を返す.
+
+ z は板面(BOARD_SURFACE_Z)から SURFACE_OFFSET だけキャビティ側へ浮かせる.
+ 面上に厳密に置くと自分自身の面に交差して「遮蔽された」と誤判定するため.
+ 四隅は必ず含める(見込み角が最大=最悪条件になる).
+ """
+ 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 - SURFACE_OFFSET)
+ return np.stack([mesh_x.ravel(), mesh_y.ravel(), z], axis=1)
+
+
+def led_samples(light: AreaLight, count: int) -> np.ndarray:
+ """LED 円板上のサンプル点(mm)を返す(中心 + 外周リング.外周が最悪条件).
+
+ リングの位相を LED_RING_PHASE だけずらす.0 始まりだとサンプル点が XZ / YZ 対称面に
+ 乗り,メッシュのエッジ上を走るレイになって交差判定が数値的に不安定になるため.
+ """
+ center = np.asarray(light.center, dtype=float)
+ radius = float(getattr(light, "radius", 0.0) or 0.0)
+ angles = LED_RING_PHASE + np.linspace(0.0, 2.0 * np.pi, count, endpoint=False)
+ ring = np.stack(
+ [center[0] + radius * np.cos(angles), center[1] + radius * np.sin(angles),
+ np.full(count, center[2])],
+ axis=1,
+ )
+ return np.vstack([center[None, :], ring])
+
+
+def check_occlusion(scene_yaml: Path, board_grid: int, led_count: int) -> dict[str, float]:
+ """板 × LED の全組合せについて,LED 到達前に遮蔽されるレイの割合を測る.
+
+ Returns:
+ total / blocked / clearance_min(遮蔽点から LED までの最短余裕・mm)を含む dict.
+ """
+ ensure_drjit_libllvm_path()
+ import mitsuba as mi
+
+ config = load_scene_yaml(scene_yaml)
+ if mi.variant() is None:
+ mi.set_variant(config.render.variant)
+
+ scene_dict, tempdir = build_scene(config, project_root=scene_yaml.parent)
+ try:
+ scene = mi.load_dict(scene_dict)
+ lights = [light for light in config.lights if isinstance(light, AreaLight)]
+ if not lights:
+ msg = f"{scene_yaml}: AreaLight が見つかりません"
+ raise ValueError(msg)
+
+ origins_mm = board_samples(board_grid)
+ targets_mm = np.vstack([led_samples(light, led_count) for light in lights])
+ # 全組合せを 1 回の交差計算にまとめる(llvm_* で 1 本ずつ呼ぶと JIT が不安定)
+ origins = np.repeat(origins_mm, len(targets_mm), axis=0) * MM_TO_M
+ targets = np.tile(targets_mm, (len(origins_mm), 1)) * MM_TO_M
+ delta = targets - origins
+ distance = np.linalg.norm(delta, axis=1)
+ direction = delta / distance[:, None]
+
+ ray = mi.Ray3f(
+ mi.Point3f(origins[:, 0], origins[:, 1], origins[:, 2]),
+ mi.Vector3f(direction[:, 0], direction[:, 1], direction[:, 2]),
+ )
+ si = scene.ray_intersect(ray)
+ hit_t = np.asarray(si.t).reshape(-1)
+ valid = np.asarray(si.is_valid()).reshape(-1)
+ # LED 発光面そのものへの命中は「遮蔽なし」.それより手前で何かに当たれば遮蔽
+ blocked = valid & (hit_t < distance - HIT_EPSILON)
+ clearance = (distance - hit_t)[blocked]
+ return {
+ "total": float(len(distance)),
+ "blocked": float(blocked.sum()),
+ "clearance_min_mm": float(clearance.min() / MM_TO_M) if blocked.any() else float("nan"),
+ }
+ finally:
+ shutil.rmtree(tempdir, ignore_errors=True)
+
+
+def main() -> int:
+ """指定シーン(または全半径)のバッフル遮蔽を検証する."""
+ parser = argparse.ArgumentParser(description="バッフルの遮蔽をレイキャストで検証する")
+ parser.add_argument("--scene", type=Path, default=None, help="検証するシーン YAML")
+ parser.add_argument("--all-radii", action="store_true",
+ help="_tmp_sphere_radius の board_baffle 全半径を検証する")
+ parser.add_argument("--cavity", choices=CAVITIES, default="similar_g2",
+ help="--all-radii のときに検証するキャビティ形状")
+ parser.add_argument("--board-grid", type=int, default=41,
+ help="評価板の 1 辺あたりサンプル数(既定 41 = 1681 点)")
+ parser.add_argument("--led-samples", type=int, default=32,
+ help="LED 円板の外周サンプル数(既定 32)")
+ args = parser.parse_args()
+
+ if args.all_radii:
+ scenes = [TMP_DIR / f"scene_board_baffle_{args.cavity}_{job_name(r)}.yaml"
+ for r in RADII]
+ elif args.scene is not None:
+ scenes = [args.scene]
+ else:
+ parser.error("--scene か --all-radii のいずれかを指定してください")
+
+ failures = 0
+ print(f"{'シーン':>44} {'レイ本数':>10} {'遮蔽':>10} {'判定':>8} {'最小余裕mm':>11}")
+ for scene_yaml in scenes:
+ if not scene_yaml.exists():
+ print(f"{scene_yaml.name:>44} (未生成のためスキップ)")
+ continue
+ result = check_occlusion(scene_yaml, args.board_grid, args.led_samples)
+ ok = result["blocked"] == result["total"]
+ failures += 0 if ok else 1
+ print(f"{scene_yaml.name:>44} {result['total']:10.0f} {result['blocked']:10.0f} "
+ f"{'OK' if ok else '不合格':>8} {result['clearance_min_mm']:11.2f}")
+
+ if failures:
+ print(f"\n!! {failures} 件で直接光が通っています.バッフル寸法を見直してください")
+ return 1
+ print("\n全シーンで評価板から LED への直線は完全に遮蔽されています")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())