diff --git a/CLAUDE.md b/CLAUDE.md index 4939702..ece1c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 7(実装)— **[TEST_16](docs/06_TEST/TEST_16_立方体キャビティ導入と球との比較.md) 立方体キャビティ導入と球との比較**を完了(全 9 条件).TEST_15 の序列「喉 > 接合 > サイズ」を受け,ロフト・接合・喉が構造的に存在しない**立方体キャビティ**(内寸 L・board_baffle 構成据え置き)を L=120〜360mm の 9 条件でスイープし,球 `similar_radial` と**内面積を横軸に**比較.**同じ内面積なら立方体が球より均一**(構造 CoV で球の 0.62〜0.89 倍,L=120 のみ互角.比は L に単調に開き交差点は内面積 10 万 mm² 前後)で,全指標が L に厳密単調改善・頭打ち未到達.L=360 は構造 CoV **0.0090**・輪帯 min/max **0.964**=全条件を通じた最良.同内面積で深さも約 3 割浅く,**設計探索の主軸は立方体系へ**.ムラの「形」は立方体でも同心円ドームのままで,四隅の写り込みは方位フーリエ **m=4 符号付き係数 a4**(b4 をノイズ対照に併用)で全条件 \|a4\| ≤ 0.26%(系統誤差床 ~0.1%)=**実質ゼロ・フィレット不要**(有意なのは小サイズの逆符号=辺余白の減光のみ.L=120→160 で単調減衰).**環境**: mitsuba は Smart App Control の DLL ブロック(vtk 9.6.1 と同型)により **3.7.1 に固定**.混在の妥当性は全テスト+L=120 アンカー再レンダ(差 1.17σ 以内)で検証済み,テストは `python -m pytest` で実行する(ENV_04 参照).**未実施**: フラット天井版(rim_z=−4.5)・直方体 L×L×D・ノイズフロア.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). +最新: フェーズ 7(実装)— **[TEST_17](docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md) オフ軸 LED 配置の影響とサイズ依存**を実施.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心へ向ける構成(バッフルは数値サイジング・遮蔽レイキャスト 10 条件 100%)で,球 `similar_radial` 5 半径 + 立方体 5 サイズをスイープ.**仮説「大きいほど光源位置の影響が薄まる」は両形状で成立**(m=1 符号付き a1 が最大サイズで最小の約 4 割まで減衰.球 −4.0→−1.6% / 立方体 −7.2→−2.3%).**明るくなるのは LED の反対側**(バッフル遮蔽下では反対側の壁の一次照射キャップが非対称の源).**オフ軸頑健性は球 > 立方体で軸上(TEST_16: 同内面積なら立方体が球の 0.62〜0.89 倍で最良,L=360 で構造 CoV 0.0090=全条件最良)と逆転**——位置ずれの Δ構造CoV は球 ±0.006 以内=実質ゼロ,立方体は最大 2.5 倍悪化.実機設計は「立方体 + 軸上配置」か「球 + 配置自由度」の二択構造.**環境**: mitsuba は SAC の DLL ブロックにより **3.7.1 に固定**(検証済み.テストは `python -m pytest`.ENV_04 参照).**未実施**: ずれ量の感度曲線(軸上→壁面の中間)・非対称ピーク非単調性の原因・フラット天井版・直方体 L×L×D・ノイズフロア.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -149,3 +149,4 @@ - 材質パラメータ再スイープと残差の構造分解: docs/06_TEST/TEST_14_材質パラメータ再スイープと残差の構造分解.md(面法線修正後の ρ×ω×α 全 60 条件スイープ.ρ ≈ 0.81 純 diffuse の確定と,材質では直せない傾き成分の分離) - バッフル導入と球半径スイープ: docs/06_TEST/TEST_15_バッフル導入と球半径スイープ.md(LED 壁面密着+バッフルで直接光を遮る新構成と,球内半径 88〜250mm のスイープ.ロフト接合の 2 段階の作り直しで「大きいほど悪化」が逆転し,板直下の喉が最大のレバーと判明した経緯) - 立方体キャビティ導入と球との比較: docs/06_TEST/TEST_16_立方体キャビティ導入と球との比較.md(ロフト・接合・喉の無い立方体を L=120〜360mm でスイープし内面積で球と対応づけ.同面積なら立方体が一貫して均一で,四隅の写り込みは m=4 方位フーリエで検出限界以下と確認) +- オフ軸 LED 配置の影響とサイズ依存: docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(LED を側壁に置いた非対称の定量.サイズとともに減衰するが,頑健性は球 > 立方体で軸上の優劣と逆転.明るくなるのは LED の反対側) 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 418b91f..eb7d166 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" @@ -89,6 +89,8 @@ | `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/cube_size_sweep/board_baffle/` (9 ディレクトリ) | TEST_16 | `configs/_tmp_cube_size/scene_board_baffle_L*.yaml`(`scripts/sweep_cube_size.py` が生成.筐体を立方体(内寸 L・天井 z=0・底面 z=-L)に変えた board_baffle 構成.LED は底面中心に密着し評価板向き,バッフルは球版と同じ閉形式.L ∈ {120〜360}mm の全 9 条件.材質・spp=1024・d=16 は据え置き.**先行 5 条件は mitsuba 3.8.0・後続 4 条件は 3.7.1**=SAC ブロックによる固定,アンカー検証済み) | `configs/eval_baseline_dng_target_shape.yaml` | L=360 で構造 CoV **0.0090**・輪帯 min/max **0.964**=全条件を通じた最良.同一内面積の球(対数内挿)に対し構造 CoV 比 0.62〜1.05・全 9 条件で単調改善 — [TEST_16](TEST_16_立方体キャビティ導入と球との比較.md) 参照 | **同じ内面積なら立方体が球より均一**(L=120 のみ互角)を実証.L=280 で球の全 10 条件の最良(r=250)を超える.遮蔽は 55,473 本 100%・法線逸脱 ≤0.06° を事前検証済み | | `output/cube_size_sweep/_anchor_check_mitsuba371/L120/` | TEST_16 | `configs/_tmp_cube_size/scene_board_baffle_L120.yaml`(本体スイープと同一.mitsuba **3.7.1** でレンダ) | 同上 | 3.8.0 版と比較して構造 CoV 一致(0.0379)・輪帯 min/max 差 +0.0002・輪帯プロファイル最大差 1.17σ | mitsuba 3.8.0→3.7.1 固定(SAC が `IlmThread-mitsuba.dll` をブロック)の等価性検証アンカー.バージョン混在の妥当性の根拠 | +| `output/offaxis_led_sweep/` (10 ディレクトリ) | TEST_17 | `configs/_tmp_offaxis/scene_offaxis_*.yaml`(`scripts/sweep_offaxis_led.py` が生成.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心 (0,0,-4.5) へ向けた board_baffle 構成.球 similar_radial r ∈ {88, 100, 135, 170, 250} + 立方体 L ∈ {120, 160, 200, 280, 360}.バッフルは視線上 10mm・数値サイジング.材質・spp=1024・d=16 据え置き・mitsuba 3.7.1) | `configs/eval_baseline_dng_target_shape.yaml` | m=1 符号付き a1 が全条件で負(LED 側が暗い)= -1.6〜-7.2%.サイズとともに約 4 割まで減衰.位置ずれの Δ構造CoV は球 ±0.006 以内・立方体 +0.010〜+0.033 — [TEST_17](TEST_17_オフ軸LED配置の影響とサイズ依存.md) 参照 | **オフ軸頑健性は球 > 立方体(軸上と逆転)**を実証.遮蔽は全 10 条件 55,473 本 100%(傾き追従の LED サンプルに一般化済み) | +| `output/offaxis_led_sweep/{summary.csv,offaxis_comparison.png}`・`_placement_check/cutaway_*.png` | TEST_17 | — | — | 条件別集約 CSV/解析図(輝度マップ 2 行 + a1・重心・構造 CoV の内面積依存を軸上と重ね描き)/レンダ前 3D 確認の半割り断面 10 枚 | `scripts/build_offaxis_comparison.py`・`show_scene_cutaway.py` の出力.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | | `output/cube_size_sweep/board_baffle/{summary.csv,cube_vs_sphere.png,fourfold_component.png}`・`output/cube_size_sweep/_placement_check/cutaway_L*.png` | TEST_16 | — | — | 条件別集約 CSV/球との比較図(輝度マップ 5 枚 + 輪帯プロファイル + 内面積軸の指標カーブ + 直接ペア 3 組)/m=4(cos 4φ)成分の定量図/レンダ前 3D 確認の半割り断面 5 枚 | `scripts/build_cube_sphere_comparison.py`・`analyze_fourfold_component.py`・`show_scene_cutaway.py` の出力.m=4 は全条件 \|a4\| ≤ 0.26%(系統誤差床 ~0.1%)=四隅の写り込みは実質ゼロ.レンダ前 3D 確認(CLAUDE.md 必須ルール)の記録を兼ねる | | `output/_slide_panels/sphere_loft_disk_before_after.png`・`normal_artifact_explainer.png` | TEST_12 | — | — | 球ロフト円形光源の修正前後 1×2 比較(相対輝度・共通スケール)/法線アーティファクトの機構説明図(模式図+実物メッシュの補間法線再現+修正前マップの 4 パネル) | 発表スライド素材(アドホック生成.修正前後比較は CoV 0.1205→0.0628 の視覚版) | diff --git "a/docs/06_TEST/TEST_17_\343\202\252\343\203\225\350\273\270LED\351\205\215\347\275\256\343\201\256\345\275\261\351\237\277\343\201\250\343\202\265\343\202\244\343\202\272\344\276\235\345\255\230.md" "b/docs/06_TEST/TEST_17_\343\202\252\343\203\225\350\273\270LED\351\205\215\347\275\256\343\201\256\345\275\261\351\237\277\343\201\250\343\202\265\343\202\244\343\202\272\344\276\235\345\255\230.md" new file mode 100644 index 0000000..dcbe1f8 --- /dev/null +++ "b/docs/06_TEST/TEST_17_\343\202\252\343\203\225\350\273\270LED\351\205\215\347\275\256\343\201\256\345\275\261\351\237\277\343\201\250\343\202\265\343\202\244\343\202\272\344\276\235\345\255\230.md" @@ -0,0 +1,182 @@ +# オフ軸 LED 配置の影響とサイズ依存 (Off-axis LED Placement and Size Dependence) + +これまでの board_baffle 構成は LED・光軸・センサがすべて z 軸上にあり,輝度分布は同心円対称に +なる.本実験では **LED を軸から外して側壁に置き**,その非対称が評価板にどれだけ写るか, +キャビティが大きいほど薄まるか(=配置誤差へのロバスト性)を球・立方体の両方で測った +(2026-09-01).仮説「大きいほど光源位置の影響が薄まる」は両形状で成立した. +加えて 2 つの発見があった.**明るくなるのは LED の反対側**であること(一次照射キャップの +機構),そして**オフ軸への頑健性は球が立方体に勝つ**——軸上の優劣(TEST_16)と逆転する—— +ことである. + +関連: + +- 軸上の球スイープと評価の作法: [TEST_15_バッフル導入と球半径スイープ](TEST_15_バッフル導入と球半径スイープ.md) +- 軸上の立方体スイープと m 次方位フーリエ解析: [TEST_16_立方体キャビティ導入と球との比較](TEST_16_立方体キャビティ導入と球との比較.md) +- 一次照射キャップの機構: [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md) +- 実行記録: [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md) + +## 背景・動機 (Motivation) + +実機では LED を理想の位置(軸上)に置けるとは限らない.基板の配置制約や組立誤差で光源が +軸から外れたとき,均一性がどれだけ壊れるかは筐体設計の実用上の性能である.TEST_16 で +「同じ内面積なら立方体が球より均一」と分かったが,それは完全に対称な配置での話であり, +**配置誤差への感度**という軸では別の答えがありうる.多重反射の混合はキャビティが大きいほど +強く効くはずなので,「大きいほど光源位置の影響が薄まる」という仮説を立てて検証した. + +## 実験条件 (Setup) + +固定条件(材質・レンダ・評価・評価板・LED の面積と radiance)は TEST_15/16 と同一. +球は `similar_radial` キャビティ(TEST_15 最良),立方体は TEST_16 と同じ筐体を使い, +**変えたのは LED の位置・向きとバッフルだけ**である.レンダは全条件 mitsuba 3.7.1 +(軸上の参照データとの等価性は TEST_16 のアンカー検証で確認済み). + +### LED の配置(ユーザー指定) + +- 深さ方向の軸(板中心〜キャビティ最深部)の**中点の高さ**で側壁と交わる点の **+X 側 1 箇所** + - 立方体: 側壁 x=+L/2・z=−L/2 + - 球: z_mid = pole_z/2 = −0.96r の高さで球面と交わる点(ほぼ赤道) +- 発光方向は**評価板の中心** (0, 0, −4.5).壁法線ではないので円板は壁に対して約 41°(球)/ + 約 44°(立方体)傾き,傾いた縁が壁を貫かない分だけ浮かせる(LED_RADIUS×sinθ + 0.5mm + ≈ 1.65〜1.74mm.縁と壁の余裕 0.5mm を数値で確認) + +| 条件 | LED (x, 0, z) [mm] | 傾き | バッフル R | 遮蔽 55,473 本 | +| --- | --- | --- | --- | --- | +| 球 r=88 | (86.3, −84.4) | 40.5° | 9.52mm | 100% | +| 球 r=100 | (98.3, −95.9) | 40.6° | 8.22mm | 100% | +| 球 r=135 | (133.2, −129.5) | 40.9° | 6.11mm | 100% | +| 球 r=170 | (168.2, −163.1) | 41.0° | 5.06mm | 100% | +| 球 r=250 | (248.1, −239.9) | 41.2° | 3.90mm | 100% | +| 立方体 L=120 | (58.3, −60.0) | 43.6° | 15.88mm | 100% | +| 立方体 L=160 | (78.3, −80.0) | 44.0° | 10.43mm | 100% | +| 立方体 L=200 | (98.3, −100.0) | 44.2° | 8.04mm | 100% | +| 立方体 L=280 | (138.3, −140.0) | 44.4° | 5.83mm | 100% | +| 立方体 L=360 | (178.3, −180.0) | 44.6° | 4.78mm | 100% | + +### バッフル(数値サイジング) + +LED→板中心の視線上 10mm 先に置く傾いた円板.軸対称でないため閉形式は使えず, +**LED 円板上の点 × 板上の点(41×41)の全直線がバッフル平面を横切る位置**から最小半径を +数値で求め,安全率 1.05 を掛けた(`sweep_offaxis_led.py:baffle_radius`).遮蔽はこれまで +どおりレイキャストで直接検証し,全 10 条件で 100% 遮蔽を確認した(`verify_baffle_occlusion` +の LED サンプルリングは円板の傾きに追従するよう一般化した). + +**立方体 L=120 のみバッフルが壁に 2.35mm 食い込む**(殻厚 3mm 以内).LED が壁際にあるため +板の手前側の縁への視線が壁とほぼ平行に走り,それを遮る円板は壁まで届く必要がある.実物の +積分球の「壁に取り付けたバッフル板」と同じ解釈で許容した(外殻の貫通は生成時に検査して弾く). + +## 結果 1 — 非対称はサイズとともに減衰する(仮説成立)(Asymmetry Decays with Size) + +非対称の定量には TEST_16 の方位フーリエ機構を m=1 に適用した.ズレ方向が +X と分かって +いるので,**板軸フレームの符号付き係数 a1**(正=LED 側が明るい)が非対称を直接与える. +構成は y=0 の鏡映対称なので直交成分 b1 がノイズ対照になる(実測 ≤0.3% に対し信号は +1.6〜7.2%=10〜40 倍で,確実に実構造). + +| 条件 | a1(オフ軸) | 重心 X | 構造 CoV オフ軸 | 軸上 | Δ | +| --- | --- | --- | --- | --- | --- | +| 球 r=88 | −3.90% | −0.0158 | 0.0304 | 0.0359 | −0.0056 | +| 球 r=100 | −4.00% | −0.0166 | 0.0299 | 0.0310 | −0.0011 | +| 球 r=135 | −3.41% | −0.0142 | 0.0253 | 0.0230 | +0.0023 | +| 球 r=170 | −2.69% | −0.0115 | 0.0203 | 0.0193 | +0.0011 | +| 球 r=250 | **−1.63%** | −0.0069 | 0.0134 | 0.0145 | −0.0011 | +| 立方体 L=120 | −6.16% | −0.0280 | 0.0540 | 0.0379 | +0.0161 | +| 立方体 L=160 | **−7.22%** | −0.0311 | 0.0547 | 0.0220 | **+0.0327** | +| 立方体 L=200 | −5.39% | −0.0236 | 0.0416 | 0.0163 | +0.0253 | +| 立方体 L=280 | −3.25% | −0.0137 | 0.0254 | 0.0109 | +0.0144 | +| 立方体 L=360 | **−2.32%** | −0.0101 | 0.0190 | 0.0090 | +0.0100 | + +- 最大サイズで \|a1\| は最小サイズの 4 割程度まで減衰し,重心オフセットも単調減衰する. + **「大きいほど光源位置の影響が薄まる」は両形状で成立** +- ただしゼロにはならず,最大サイズでも 1.6〜2.3% の傾きが残る +- 非対称のピークは最小サイズではなく 2 番目(球 r=100・立方体 L=160)にある非単調性が + ある(原因未特定.最小サイズでは板がキャビティの大きな立体角を占め幾何が質的に変わる + ためと推測) + +## 結果 2 — 明るいのは LED の反対側 (Bright Side Is Opposite the LED) + +a1 は全条件で**負=LED 側(+X)が暗い**.直感と逆だが機構は明快で,直接光はバッフルで +遮られているため,板を照らす主成分は「LED が最初に照らす壁=**反対側(−X)の壁にできる +一次照射キャップ**」である.板は −X 側でそのキャップを近く・正面に見るため,明るさは +LED の反対側に寄る.TEST_11 で特定した一次照射キャップの機構がそのまま非対称の源になる. + +## 結果 3 — オフ軸への頑健性は球が勝つ(軸上と逆転)(Sphere Is More Robust Off-axis) + +| | 軸上(TEST_16) | オフ軸(本実験) | +| --- | --- | --- | +| 同面積での優劣 | **立方体**(構造 CoV で球の 0.62〜0.89 倍) | **球**(\|a1\| が立方体の 55〜70%) | +| 位置ずれの総合ペナルティ Δ構造CoV | — | 球 **±0.006 以内=実質ゼロ**,立方体 **+0.010〜+0.033** | + +- 立方体は L=160 でオフ軸の構造 CoV が軸上の **2.5 倍**(0.0220→0.0547)に跳ねるのに対し, + 球は全サイズで軸上とほぼ同水準に留まる +- オフ軸同士の絶対比較でも大サイズでは球が勝つ(r=250: 0.0134 vs L=360: 0.0190) +- 機構: 球は「ランバート光源を壁に置くと球面の直接照度が一様になる」古典的性質を持ち, + バッフル付きでもその名残で一次照射キャップが広く均される.立方体は平面壁にキャップが + 集中するため非対称が強く出る + +**設計上の含意**: LED を軸上に置けるなら立方体が最良(TEST_16).**配置が軸から外れる +(外れうる)なら球が寛容**.立方体を採用するなら LED の軸上配置精度が効く,という +トレードオフが定量化された. + +## 結論 (Conclusions) + +- **仮説成立**: 光源位置ずれの影響(m=1 非対称・重心オフセット)はキャビティサイズと + ともに減衰する.最大サイズで最小サイズの約 4 割 +- **明るくなるのは LED の反対側**(バッフル遮蔽下では一次照射キャップが非対称の源) +- **オフ軸頑健性は球 > 立方体**で,軸上の優劣と逆転する.球は位置ずれの総合ペナルティが + 実質ゼロ,立方体は構造 CoV が最大 2.5 倍に悪化する +- 実機設計は「立方体 + 軸上配置」か「球 + 配置自由度」の二択構造になる + +## 限界・注意点 (Limitations) + +- **オフ軸位置は 1 点のみ**(軸中点の高さ・壁面密着).軸上→壁面の中間位置や高さ違いは + 未測定で,「ずれ量に対する感度曲線」はまだ描けていない +- **全条件 1 シードのみ**(TEST_15/16 と共通の宿題).ただし a1 の標準誤差は 0.04〜0.08% で + 信号(1.6〜7.2%)に対し十分小さい +- 非対称ピークの非単調性(結果 1)は原因未特定 +- オフ軸版は軸上より約 2 割暗い(バッフルが大きめ+斜め照射).球のレンダは 1 条件約 460 秒と + 立方体(約 280 秒)より遅い +- 材質は旧条件(ρ=0.85 blendbsdf).絶対値の実機予測には校正値での引き直しが必要 + +## 失敗パターン・方法論の学び (Pitfalls and Methodology) + +- **壁際光源のバッフルは壁に食い込ませないと成立しないことがある**.板の手前縁への視線が + 壁とほぼ平行になるため.「壁付きバッフル板」として殻厚以内の食い込みを許容し,外殻の + 貫通だけを生成時チェックで弾く設計にした +- **遮蔽検証の LED サンプルは円板の向きに追従させること**.従来実装は XY 平面リング固定で, + 傾いた円板では発光面上に無いレイを検証してしまう(`led_samples` を direction 基準の + 正規直交基底で一般化.軸沿いでは従来と同一) +- **方向が既知の非対称は符号付きフーリエ係数で測るのが最も感度が高い**.m=4(TEST_16)と + 同じ設計(鏡映対称でゼロのはずの直交成分をノイズ対照に使う)が m=1 でもそのまま機能した + +## 次にやること (Next Steps) + +1. ずれ量の感度曲線: LED 位置を軸上(底面中心)→壁面まで連続に振る中間スイープ +2. 非対称ピークの非単調性(球 r=100・立方体 L=160)の原因特定 +3. 立方体でオフ軸を許す設計の探索(例: バッフルの非対称化・LED 側壁の局所形状) +4. ノイズフロア(seed 違い)・校正材質での引き直し(TEST_15/16 と共通) + +## 再現コマンド (Reproduction) + +```bash +# 幾何生成のみ(3D 配置確認用.レンダはしない) +uv run python scripts/sweep_offaxis_led.py --prepare-only + +# 半割り断面(レンダ前の配置確認)と遮蔽検証 +uv run python scripts/show_scene_cutaway.py --scene configs/_tmp_offaxis/scene_offaxis_cube_L200.yaml +uv run python scripts/verify_baffle_occlusion.py --scene configs/_tmp_offaxis/scene_offaxis_cube_L200.yaml + +# スイープ本体(球 5 + 立方体 5.既存結果は再利用される) +uv run python scripts/sweep_offaxis_led.py + +# 解析図 + 集約表(軸上スイープとの比較込み) +uv run python scripts/build_offaxis_comparison.py +``` + +出力: `output/offaxis_led_sweep/`(条件別 10 ディレクトリ + `summary.csv` + +`offaxis_comparison.png`).レンダ前 3D 確認は `_placement_check/cutaway_*.png`. + +## 参照 (References) + +- [TEST_16_立方体キャビティ導入と球との比較](TEST_16_立方体キャビティ導入と球との比較.md): 軸上の優劣と方位フーリエ解析の設計 +- [TEST_15_バッフル導入と球半径スイープ](TEST_15_バッフル導入と球半径スイープ.md): board_baffle 構成と評価の作法 +- [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md): 一次照射キャップの機構 +- [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md): 出力ディレクトリの索引 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index aa7aaf9..31735c7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -89,3 +89,5 @@ - **TEST_16 立方体キャビティ導入と球との比較 — 同じ内面積なら立方体が球に勝つと判明 (2026-08-31〜09-01)**: TEST_15 の序列「喉 > 接合 > サイズ」から「ロフトも接合も喉も構造的に存在しない立方体はどうか」という仮説を立て,立方体キャビティ(内寸 L・天井 z=0・底面 z=-L・board_baffle 構成据え置き)を新設して L ∈ {120, 160, 200, 280, 360} の 5 条件(既定 9 グリッドの部分集合=傾向を先に安く見るユーザー判断)をスイープ,球 `similar_radial` 10 条件と**内面積を横軸に**比較した.**結果 ①**: 「球のほうが理想積分球だから有利」は不成立.同一内面積(対数内挿)で立方体の構造 CoV は球の **0.62〜0.81 倍**(L=120 のみ互角),L=280(0.0109)で球の全条件最良(r=250 の 0.0145)を超え,L=360 は **0.0090 / 輪帯 min/max 0.964** で全条件を通じた最良.同内面積で深さも約 3 割浅く実用面でも有利=**設計探索の主軸は立方体系へ**.**結果 ②**: ムラの「形」は立方体でも球と同一の同心円ドーム(L=120 で方位分散 5.96 vs 動径分散 118).一次照射(バッフル裏→床の明るいリング)が軸対称で cos 重みの真下方向が支配し,多重拡散反射が壁の形を洗い流すため=輪帯プロファイル比較の正当性が立方体でも成立.**結果 ③(四隅の痕跡の定量)**: ユーザーとの議論「差があるはずかを白黒つける検定」として,輪帯ごとの方位プロファイルの **cos 4φ 符号付き係数 a4**(正=四隅が暗い.鏡映対称からゼロのはずの直交成分 b4 をノイズ対照に併用)を抽出.全条件 \|a4\| ≤ 0.26% で輪帯構造(4〜15%)の 1/30 以下,「四隅が暗い」符号の一貫した信号なし=**角のフィレット等は不要**.唯一有意な L=120 の −0.26% は逆符号(辺中央方向が暗い=壁余白 9mm の局所幾何の名残)で,小型化の下限を決めるのは隅でなく辺の余白と判明.**方法論の学び**: ① 楕円距離ビンの外側輪帯(d>0.707)は四隅方向にしか画素が無く,方位フーリエに掛けるとカバレッジの欠けが m=0 を m=4 に漏れ込ませ偽信号を作る→全セクタ被覆の輪帯のみ使用 / ② b4 対照で系統誤差床 ~0.1% を実測=MC 伝播だけの σ は有意性を過大評価する / ③ `angular_ratio` の跳ね(L=360 で 0.71)は今回も分母潰れの見かけ(TEST_15 の再確認).幾何検証(watertight・遮蔽 55,473 本 100%・法線逸脱 ≤0.06°)は球版の対策を織り込み済みで全て一発通過.**未実施**: 残り 4 条件(L=140/180/240/320)・フラット天井版(rim_z=-4.5)・直方体 L×L×D・ノイズフロア.成果物: `scripts/generate_cube_housing.py`・`sweep_cube_size.py`・`build_cube_sphere_comparison.py`・`analyze_fourfold_component.py`,`output/cube_size_sweep/board_baffle/`(summary.csv・`cube_vs_sphere.png`・`fourfold_component.png`),記録 [TEST_16](06_TEST/TEST_16_立方体キャビティ導入と球との比較.md). - **TEST_16 追試 — 残り 4 条件で全 9 条件が揃い単調性を確定・mitsuba を SAC 起因で 3.7.1 に固定 (2026-09-01)**: 残り L ∈ {140, 180, 240, 320} を実行しようとしたところ,**mitsuba 3.8.0 が import 不能**(Smart App Control が同梱 `IlmThread-mitsuba.dll` 1 個をレピュテーション判定でブロック.ファイルは 8/17 から無変更=クラウド側の判定が一晩で反転.vtk 9.6.1=TEST_13 検査 8 と同型).`LoadLibraryExW` + `DONT_RESOLVE_DLL_REFERENCES` で WinError 4551(ポリシー)と 126(依存の巻き添え)を切り分けてブロック対象を 1 ファイルに特定し,一時 venv で 3.7.1 が全 DLL 通ることを確認して **`mitsuba==3.7.1` に固定**(ユーザー承認.`pyproject.toml` に理由コメント,ENV_04 に vtk と並べて注意書き).uv 生成の `pytest.exe` ランチャも同時にブロックされたため `python -m pytest` で回避.**バージョン混在の妥当性検証**: 全 1043 テスト通過(絶対放射量の解析解検証込み)+ L=120 アンカー再レンダで 3.8.0 と構造 CoV 一致(0.0379)・輪帯プロファイル最大差 1.17σ=統計的に区別不能.**結果**: 全 9 条件で構造 CoV(0.0379→0.0090)・輪帯 min/max(0.858→0.964)・C/P・勾配のすべてが **L に対し厳密に単調改善**,9 本の輪帯プロファイルは交差なし.球との構造 CoV 比は 1.05→0.62 と単調に開き,交差点は内面積 10 万 mm² 前後.m=4 は L=140 が −0.212%(L=120 と同じ逆符号=辺余白の名残)で L=120→140→160 と単調減衰し「小型化の下限は隅でなく辺の余白」の解釈を補強.TEST_16 の限界から「5 条件のみ」が消え「レンダラ混在(検証済み)」に置き換わった.成果物: `output/cube_size_sweep/board_baffle/`(全 9 条件)・`_anchor_check_mitsuba371/L120/`,`pyproject.toml`(mitsuba 固定),記録 [TEST_16](06_TEST/TEST_16_立方体キャビティ導入と球との比較.md)・ENV_04. + +- **TEST_17 オフ軸 LED 配置の影響とサイズ依存 — 頑健性は球が勝ち軸上の優劣と逆転 (2026-09-01)**: ユーザー発案「光源が軸からずれた場合の影響と,大きいほど影響が薄まるかの検証」.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心へ向ける構成(球 similar_radial 5 半径 + 立方体 5 サイズ・材質/レンダ据え置き)を新設した.円板は壁に対し約 41〜44° 傾くため縁が壁を貫かない分だけ浮かせ,バッフルは軸対称でないので**板 41×41 × LED 円板の全直線がバッフル平面を横切る位置から数値サイジング**(+遮蔽レイキャスト 10 条件 100%.`verify_baffle_occlusion` の LED サンプルを円板の傾きに追従するよう一般化).立方体 L=120 のみバッフルが壁に 2.35mm 食い込む(板の手前縁への視線が壁とほぼ平行なため円板が壁まで届く必要がある=実物積分球の「壁付きバッフル板」と同じ解釈で殻厚以内を許容).**結果 ①(仮説成立)**: 非対称(m=1 符号付き a1・重心オフセット)はサイズとともに減衰し最大サイズで最小の約 4 割(球 -4.0→-1.6% / 立方体 -7.2→-2.3%.ノイズ対照 b1 ≤0.3% の 10〜40 倍で確実に実構造).ただしゼロにはならない.**結果 ②**: a1 は全条件で負=**明るいのは LED の反対側**.バッフル遮蔽下では一次照射キャップ(TEST_11)が反対側の壁にでき,板がそれを非対称に見込むため.**結果 ③(軸上と逆転)**: オフ軸頑健性は**球 > 立方体**.位置ずれの Δ構造CoV は球 ±0.006 以内=実質ゼロ,立方体 +0.010〜+0.033(L=160 で軸上の 2.5 倍).球の「壁上ランバート光源→球面直接照度が一様」という古典的性質の名残.**設計上の含意**: 「立方体 + 軸上配置」か「球 + 配置自由度」の二択構造.**未解決**: 非対称ピークが最小サイズでなく 2 番目(球 r=100 / 立方体 L=160)に来る非単調性の原因,ずれ量の感度曲線(軸上→壁面の中間位置).成果物: `scripts/sweep_offaxis_led.py`・`build_offaxis_comparison.py`,`output/offaxis_led_sweep/`(10 条件 + summary.csv + `offaxis_comparison.png`),記録 [TEST_17](06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md). diff --git a/scripts/build_offaxis_comparison.py b/scripts/build_offaxis_comparison.py new file mode 100644 index 0000000..22e5903 --- /dev/null +++ b/scripts/build_offaxis_comparison.py @@ -0,0 +1,225 @@ +"""オフ軸 LED スイープの解析: 非対称の量とサイズによる減衰を球・立方体で比較する(TEST_17). + +LED を +X 側の側壁に置いたことで生じる非対称を,次の 3 層で測る. + +1. **m=1 符号付き係数 a1**(輪帯ごとの方位フーリエ・逆分散重み集約): ズレ方向が +X と + 分かっているので,cos φ 成分の符号付き振幅が「LED 側がどれだけ明るい/暗いか」を直接 + 与える.構成は y=0 の鏡映対称なので直交成分 b1 は対称性からゼロのはず=ノイズ対照 + (m=4 解析と同じ設計).軸上(on-axis)の同条件の |m1| が方法の背景値を与える +2. **輝度重心の X オフセット**(metrics.csv の intensity_centroid_x_norm) +3. **構造 CoV / 輪帯 min/max**: 非対称による総合的な均一性の悪化量(軸上との差) + +仮説「大きいほど光源位置の影響が薄まる」が正しければ,a1・重心オフセット・ +Δ構造CoV がキャビティ内面積とともに減衰するはずである. + +実行方法: + uv run python scripts/build_offaxis_comparison.py +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from analyze_fourfold_component import aggregate, ring_harmonics # noqa: E402 +from build_radial_comparison import radial_stats, ring_min_max_ratio # noqa: E402 +from build_radius_sweep_visual import decompose_cov, normalized_map # noqa: E402 +from sweep_cube_size import OUTPUT_BASE as CUBE_OUTPUT_BASE # noqa: E402 +from sweep_cube_size import RUN_DIR as CUBE_RUN_DIR # noqa: E402 +from sweep_cube_size import job_name as cube_job_name # noqa: E402 +from sweep_offaxis_led import ( # noqa: E402 + CUBE_SIZES, + OUTPUT_BASE, + SPHERE_RADII, + job_name, +) +from sweep_sphere_radius import OUTPUT_BASE as SPHERE_OUTPUT_BASE # noqa: E402 +from sweep_sphere_radius import job_name as sphere_job_name # noqa: E402 + +plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] + +SPHERE_ONAXIS_DIR = SPHERE_OUTPUT_BASE / "board_baffle_similar_radial" + +SHAPE_COLOR = {"sphere": "tab:blue", "cube": "tab:red"} +SHAPE_LABEL = {"sphere": "球 similar_radial", "cube": "立方体"} + + +def onaxis_dir(shape: str, size: float) -> Path: + """軸上(on-axis)スイープの同条件ディレクトリを返す.""" + if shape == "sphere": + return SPHERE_ONAXIS_DIR / sphere_job_name(size) + return CUBE_OUTPUT_BASE / CUBE_RUN_DIR / cube_job_name(size) + + +def read_summary_rows() -> list[dict]: + """オフ軸スイープの summary.csv を読み,欠損条件を除いて返す.""" + path = OUTPUT_BASE / "summary.csv" + with path.open(encoding="utf-8") as fh: + rows = [] + for row in csv.DictReader(fh): + parsed: dict = {"shape": row["shape"]} + parsed.update( + {k: (float(v) if v else float("nan")) for k, v in row.items() if k != "shape"} + ) + rows.append(parsed) + return sorted(rows, key=lambda r: (r["shape"], r["size_mm"])) + + +def load_roi_for(path: Path) -> np.ndarray: + """luminance.npy から ROI を読み出す(analyze_fourfold_component と同じ経路).""" + from analyze_fourfold_component import load_roi + + return load_roi(path) + + +def analyze_condition(shape: str, size: float) -> dict | None: + """1 条件のオフ軸・軸上の非対称指標と均一性指標を集める.""" + off_dir = OUTPUT_BASE / job_name(shape, size) + on_dir = onaxis_dir(shape, size) + if not (off_dir / "luminance.npy").exists() or not (on_dir / "luminance.npy").exists(): + return None + + result: dict = {"shape": shape, "size_mm": size} + for tag, directory in (("off", off_dir), ("on", on_dir)): + lum = directory / "luminance.npy" + rings = ring_harmonics(load_roi_for(lum)) + for key in ("a1", "b1"): + result[f"{tag}_{key}"], result[f"{tag}_{key}_se"] = aggregate(rings, key) + _, structure, _ = decompose_cov(lum) + centers, means, _ = radial_stats(lum, 20) + result[f"{tag}_structure_cov"] = structure + result[f"{tag}_ring_min_max"] = ring_min_max_ratio(centers, means) + with (directory / "metrics.csv").open(encoding="utf-8") as fh: + row = next(csv.DictReader(fh)) + result[f"{tag}_centroid_x"] = float(row["intensity_centroid_x_norm"]) + result[f"{tag}_gradient"] = float(row["gradient_magnitude"]) + return result + + +def draw_maps(fig, gs, rows: list[dict]) -> None: + """オフ軸の輝度マップを形状別に 1 行ずつ並べる(輪帯ピーク=1.0・共通スケール).""" + for row_idx, shape in enumerate(("sphere", "cube")): + selected = [r for r in rows if r["shape"] == shape] + maps = [ + (r["size_mm"], normalized_map(OUTPUT_BASE / job_name(shape, r["size_mm"]) + / "luminance.npy")) + for r in selected + ] + if not maps: + continue + stacked = np.concatenate([m.ravel() for _, m in maps]) + vmin = float(np.percentile(stacked, 0.5)) + for i, (size, data) in enumerate(maps[:5]): + ax = fig.add_subplot(gs[row_idx, i]) + image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=1.0) + prefix = "r" if shape == "sphere" else "L" + ax.set_title(f"{SHAPE_LABEL[shape]} {prefix}={size:g}", fontsize=9) + ax.axis("off") + if i == len(maps[:5]) - 1: + fig.colorbar(image, ax=ax, fraction=0.046, label="最大輪帯=1.0") + + +def draw_metric(ax, results: list[dict], key_off: str, key_on: str | None, + ylabel: str, title: str, se_key: str | None = None, + scale: float = 1.0) -> None: + """指標を内面積(対数軸)に対して球・立方体×オフ軸/軸上で描く.""" + for shape in ("sphere", "cube"): + rows = [r for r in results if r["shape"] == shape] + areas = [r["area"] for r in rows] + values = [r[key_off] * scale for r in rows] + if se_key: + errors = [r[se_key] * scale for r in rows] + ax.errorbar(areas, values, yerr=errors, marker="o", capsize=3, + color=SHAPE_COLOR[shape], label=f"{SHAPE_LABEL[shape]}(オフ軸)") + else: + ax.plot(areas, values, marker="o", color=SHAPE_COLOR[shape], + label=f"{SHAPE_LABEL[shape]}(オフ軸)") + if key_on: + ax.plot(areas, [r[key_on] * scale for r in rows], marker="s", ls="--", ms=4, + color=SHAPE_COLOR[shape], alpha=0.55, + label=f"{SHAPE_LABEL[shape]}(軸上)") + ax.axhline(0.0, color="gray", lw=0.8, ls="--") + ax.set_xscale("log") + ax.set_xlabel("キャビティ内面積 (mm²・対数)") + ax.set_ylabel(ylabel) + ax.set_title(title, fontsize=11) + ax.legend(fontsize=7) + ax.grid(alpha=0.3, which="both") + + +def print_table(results: list[dict]) -> None: + """非対称指標の集約表を出力する.""" + print("\n=== 非対称の指標(a1 > 0 = LED 側(+X)が明るい・輪帯平均比 %)===") + print(f"{'条件':>14} {'a1 オフ軸 [%]':>15} {'b1(対照)[%]':>14} {'a1 軸上 [%]':>12} " + f"{'重心X(off)':>10} {'構造CoV off':>11} {'on':>8} {'Δ':>8}") + for r in results: + name = job_name(r["shape"], r["size_mm"]) + print(f"{name:>14} {r['off_a1'] * 100:8.3f}±{r['off_a1_se'] * 100:5.3f} " + f"{r['off_b1'] * 100:8.3f}±{r['off_b1_se'] * 100:4.3f} " + f"{r['on_a1'] * 100:12.3f} {r['off_centroid_x']:10.5f} " + f"{r['off_structure_cov']:11.4f} {r['on_structure_cov']:8.4f} " + f"{r['off_structure_cov'] - r['on_structure_cov']:+8.4f}") + + +def main() -> int: + """オフ軸 LED スイープの比較図を生成する.""" + parser = argparse.ArgumentParser(description="オフ軸 LED スイープの解析図を生成") + parser.add_argument("--output", type=Path, default=None, help="出力 PNG パス") + args = parser.parse_args() + + summary = read_summary_rows() + results = [] + for shape, sizes in (("sphere", SPHERE_RADII), ("cube", CUBE_SIZES)): + for size in sizes: + res = analyze_condition(shape, size) + if res is None: + continue + match = [r for r in summary if r["shape"] == shape and r["size_mm"] == size] + res["area"] = match[0]["cavity_area_mm2"] if match else float("nan") + results.append(res) + if not results: + print("エラー: オフ軸スイープの結果がありません(先にレンダを実行してください)") + return 1 + + output = args.output or OUTPUT_BASE / "offaxis_comparison.png" + fig = plt.figure(figsize=(20, 14)) + gs = fig.add_gridspec(3, 6, height_ratios=[1.1, 1.1, 1]) + draw_maps(fig, gs, results) + draw_metric( + fig.add_subplot(gs[2, 0:2]), results, "off_a1", "on_a1", + "a1(輪帯平均比 %・正=LED 側が明るい)", + "m=1 非対称成分 vs 内面積", se_key="off_a1_se", scale=100.0, + ) + draw_metric( + fig.add_subplot(gs[2, 2:4]), results, "off_centroid_x", "on_centroid_x", + "輝度重心 X オフセット(正規化)", "重心の LED 側への寄り vs 内面積", + ) + draw_metric( + fig.add_subplot(gs[2, 4:6]), results, "off_structure_cov", "on_structure_cov", + "構造 CoV", "総合均一性: オフ軸 vs 軸上", + ) + fig.suptitle( + "オフ軸 LED(側壁・板中心向き・バッフル付き)の影響とサイズによる減衰" + " 輝度マップは輪帯ピーク正規化・LED は +X 側", fontsize=15, + ) + fig.tight_layout() + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=100) + print(f"出力: {output}") + print_table(results) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_offaxis_led.py b/scripts/sweep_offaxis_led.py new file mode 100644 index 0000000..567d999 --- /dev/null +++ b/scripts/sweep_offaxis_led.py @@ -0,0 +1,419 @@ +"""オフ軸 LED スイープ: 光源位置ずれの影響とキャビティサイズによる減衰(TEST_17). + +TEST_15/16 の board_baffle 構成は LED・光軸・センサがすべて z 軸上にあり, +輝度分布は同心円対称になる.本スイープでは **LED を軸から外して側壁に置き**, +その非対称が評価板にどれだけ写るか,キャビティが大きいほど多重反射の混合で +薄まるか(=配置誤差へのロバスト性)を球・立方体の両方で測る. + +幾何(ユーザー指定): + - LED は深さ方向の軸(板中心〜キャビティ底)の**中点の高さ**で側壁と交わる点の + **+X 側 1 箇所**に置く + 立方体: 側壁 x=+L/2・z=-L/2 + 球 : z_mid = pole_z/2 = -0.96r の高さで球面と交わる点(ほぼ赤道) + - 発光方向は**評価板の中心** (0, 0, -4.5) へ向ける.壁法線ではないので円板は + 壁に対して斜めになり,傾いた縁が壁を貫かない分だけ浮かせる + - バッフルは LED→板中心の視線上 10mm 先に置き,板の全点 × LED の全点の直線を + 遮る最小半径(軸対称でないため数値で求める)× 安全率とする.遮蔽はこれまで + どおりレイキャストで直接検証する(verify_baffle_occlusion --scene) + +固定条件は TEST_15/16 と同一(材質・レンダ・評価・評価板).LED の面積・radiance も +据え置き(全光束保存).球は similar_radial キャビティ(TEST_15 最良)を使う. + +実行方法: + uv run python scripts/sweep_offaxis_led.py --prepare-only # メッシュ + シーン YAML のみ + uv run python scripts/sweep_offaxis_led.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_cube_housing import build_cube_housing # noqa: E402 +from sweep_sphere_radius import ( # noqa: E402 + BAFFLE_GAP, + BAFFLE_MARGIN, + BAFFLE_THICKNESS, + BOARD_HALF, + BOARD_SURFACE_Z, + EVAL_CONFIG_YAML, + LED_RADIUS, + RIM_INSET, + RIM_Z, + TEMPLATE_YAML, + THICKNESS, + raw_stats, + read_metrics_csv, +) +from sweep_sphere_radius import ( + build_mesh as build_sphere_mesh, +) +from sweep_sphere_radius import ( + center_z as sphere_center_z, +) + +ROOT = Path(__file__).resolve().parents[1] + +TMP_DIR = ROOT / "configs" / "_tmp_offaxis" +MESH_DIR = TMP_DIR / "meshes" +OUTPUT_BASE = ROOT / "output" / "offaxis_led_sweep" + +SPHERE_CAVITY = "similar_radial" # TEST_15 最良の球設計を比較対象にする +SPHERE_RADII = [88.0, 100.0, 135.0, 170.0, 250.0] +CUBE_SIZES = [120.0, 160.0, 200.0, 280.0, 360.0] + +# 評価板の中心(キャビティ側の面)= LED の照準点 +BOARD_CENTER = np.array([0.0, 0.0, BOARD_SURFACE_Z]) +# 傾いた円板の縁が壁に触れないための追加マージン(mm).球面のサジッタも吸収する +STANDOFF_MARGIN = 0.5 + +SUMMARY_COLUMNS = [ + "shape", "size_mm", "led_x_mm", "led_z_mm", "led_standoff_mm", + "tilt_deg", "baffle_radius_mm", "cavity_area_mm2", "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 job_name(shape: str, size: float) -> str: + """条件名(ディレクトリ名)を作る.""" + return f"{shape}_{'r' if shape == 'sphere' else 'L'}{size:g}" + + +def wall_anchor(shape: str, size: float) -> tuple[np.ndarray, np.ndarray]: + """LED を貼り付ける側壁上の点と,そこでの内向き法線を返す(mm). + + 深さ方向の軸(板中心 z=0 付近〜キャビティ最深部)の中点の高さで,+X 側の壁面. + """ + if shape == "cube": + z_mid = RIM_Z - size / 2.0 + return np.array([size / 2.0, 0.0, z_mid]), np.array([-1.0, 0.0, 0.0]) + c_z = sphere_center_z(size, SPHERE_CAVITY) + pole_z = c_z - size + z_mid = (RIM_Z + pole_z) / 2.0 + dz = z_mid - c_z + rho = float(np.sqrt(size**2 - dz**2)) + point = np.array([rho, 0.0, z_mid]) + normal = (np.array([0.0, 0.0, c_z]) - point) / size + return point, normal + + +def led_geometry(shape: str, size: float) -> dict[str, np.ndarray | float]: + """LED 円板の中心・発光方向・浮かせ量・傾き角を返す. + + 浮かせ量は「傾いた円板の縁が壁面へ食い込む量(LED_RADIUS×sinθ)+ マージン」. + 方向は浮かせた後の中心から板中心へ向け直す(1 回の反復で十分収束する). + """ + wall, normal = wall_anchor(shape, size) + center = wall.copy() + for _ in range(2): + direction = BOARD_CENTER - center + direction = direction / np.linalg.norm(direction) + sin_tilt = float(np.linalg.norm(np.cross(direction, normal))) + standoff = LED_RADIUS * sin_tilt + STANDOFF_MARGIN + center = wall + standoff * normal + tilt_deg = float(np.degrees(np.arcsin(np.clip(sin_tilt, -1.0, 1.0)))) + return { + "center": center, "direction": direction, + "standoff": standoff, "tilt_deg": tilt_deg, + } + + +def _disk_points(center: np.ndarray, direction: np.ndarray, radius: float, n: int) -> np.ndarray: + """direction を法線とする円板の外周点 n 個 + 中心を返す.""" + axis = np.array([0.0, 1.0, 0.0]) if abs(direction[1]) < 0.9 else np.array([0.0, 0.0, 1.0]) + u = np.cross(direction, axis) + u /= np.linalg.norm(u) + v = np.cross(direction, u) + # 位相をずらして対称面(y=0)上にサンプル点を置かない(球版と同じ配慮) + angles = 0.1234 + np.linspace(0.0, 2.0 * np.pi, n, endpoint=False) + ring = center + radius * (np.outer(np.cos(angles), u) + np.outer(np.sin(angles), v)) + return np.vstack([center[None, :], ring]) + + +def _board_points(grid: int = 41) -> np.ndarray: + """評価板のキャビティ側の面の格子点(四隅を含む)を返す.""" + gx = np.linspace(-BOARD_HALF[0], BOARD_HALF[0], grid) + gy = np.linspace(-BOARD_HALF[1], BOARD_HALF[1], grid) + mesh_x, mesh_y = np.meshgrid(gx, gy) + z = np.full(mesh_x.size, BOARD_SURFACE_Z) + return np.stack([mesh_x.ravel(), mesh_y.ravel(), z], axis=1) + + +def baffle_radius(led: dict) -> float: + """板の全点から LED 全点を隠すのに必要なバッフル半径(mm)を数値的に求める. + + 軸対称でないため閉形式は使えない.LED 円板上の点 Q × 板上の点 P の全直線が + バッフル平面(LED から BAFFLE_GAP 先・法線 = 発光方向)を横切る位置を計算し, + バッフル中心からの面内距離の最大値に安全率を掛ける.サンプルは遮蔽検証 + (verify_baffle_occlusion)と同じ密度(板 41×41・LED 外周 64+1)を使う. + """ + center, direction = led["center"], led["direction"] + b_center = center + BAFFLE_GAP * direction + q = _disk_points(center, direction, LED_RADIUS, 64) + p = _board_points() + # 全組合せ: Q (M,3) × P (N,3) + delta = p[None, :, :] - q[:, None, :] # (M, N, 3) + denom = delta @ direction + t = ((b_center - q) @ direction)[:, None] / denom + x = q[:, None, :] + t[..., None] * delta + offset = x - b_center + radial = offset - (offset @ direction)[..., None] * direction[None, None, :] + return float(BAFFLE_MARGIN * np.linalg.norm(radial, axis=-1).max()) + + +def _wall_distance(shape: str, size: float, points: np.ndarray) -> float: + """点群からキャビティ内壁までの最小距離(mm)を返す(負なら壁の外).""" + if shape == "cube": + margins = np.concatenate([ + (size / 2.0 - np.abs(points[:, 0:2])).ravel(), + (points[:, 2] - (RIM_Z - size)), + (RIM_Z - points[:, 2]), + ]) + return float(margins.min()) + c = np.array([0.0, 0.0, sphere_center_z(size, SPHERE_CAVITY)]) + return float((size - np.linalg.norm(points - c, axis=1)).min()) + + +def build_baffle_mesh( + shape: str, size: float, led: dict, dest: Path +) -> tuple[trimesh.Trimesh, float]: + """LED の視線上に置く傾いたバッフル円板を生成して書き出す. + + 半セクタ回転(対称面エッジ回避)→ Z 軸を発光方向へ向ける回転 → 平行移動の順. + + 円板が内壁に食い込むのは許容する(実物の積分球バッフルと同じ「壁に取り付けた板」の + 解釈.LED が壁際にあるため,板の手前側の縁への視線=壁とほぼ平行な直線を遮るには + 円板が壁まで届く必要があり,円形ゆえ反対側の張り出しが壁に埋まる).ただし外殻 + (壁厚 THICKNESS)を貫通してはならない. + + Returns: + (メッシュ, 内壁への食い込み深さ mm.正なら食い込みあり・0 なら非接触). + + Raises: + ValueError: バッフルが外殻の外にはみ出す場合. + """ + sections = 128 + radius = baffle_radius(led) + mesh = trimesh.creation.cylinder(radius=radius, height=BAFFLE_THICKNESS, sections=sections) + mesh.apply_transform(trimesh.transformations.rotation_matrix(np.pi / sections, [0, 0, 1])) + direction = led["direction"] + rot = trimesh.geometry.align_vectors([0.0, 0.0, 1.0], direction) + mesh.apply_transform(rot) + mesh.apply_translation(led["center"] + BAFFLE_GAP * direction) + embed = max(0.0, -_wall_distance(shape, size, mesh.vertices)) + if embed > THICKNESS: + raise ValueError( + f"{job_name(shape, size)}: バッフルが外殻を貫通します(食い込み {embed:.2f}mm > " + f"壁厚 {THICKNESS:g}mm)" + ) + dest.parent.mkdir(parents=True, exist_ok=True) + mesh.export(dest) + return mesh, embed + + +def build_housing_mesh(shape: str, size: float, dest: Path) -> trimesh.Trimesh: + """筐体メッシュを生成して書き出す(軸上スイープと同一のビルダを使う).""" + if shape == "sphere": + return build_sphere_mesh(size, dest, SPHERE_CAVITY) + mesh = build_cube_housing( + size=size, thickness=THICKNESS, + board_half_x=BOARD_HALF[0], board_half_y=BOARD_HALF[1], + rim_inset=RIM_INSET, rim_z=RIM_Z, + ) + dest.parent.mkdir(parents=True, exist_ok=True) + mesh.export(dest) + return mesh + + +def cavity_area(shape: str, size: float) -> float: + """比較の横軸に使う内面積(mm²)を返す(各スイープと同じ定義).""" + if shape == "cube": + from sweep_cube_size import cavity_geometry as cube_geo + + return cube_geo(size)["cavity_area_mm2"] + from sweep_sphere_radius import cavity_geometry as sphere_geo + + return sphere_geo(size, SPHERE_CAVITY)["cavity_area_mm2"] + + +def build_scene_yaml( + shape: str, size: float, mesh_path: Path, baffle_path: Path, led: dict, dest: Path +) -> None: + """テンプレートから当該条件のシーン YAML を書き出す. + + 材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは + 筐体メッシュ・LED の位置と向き・バッフルだけ. + """ + config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8"))) + name = job_name(shape, size) + config["name"] = f"offaxis_{name}" + config["description"] = ( + f"オフ軸 LED スイープ [{shape} / {size:g}mm]: LED を側壁(軸中点の高さ・+X 側)に置き," + f"評価板中心 (0,0,{BOARD_SURFACE_Z:g}) へ向ける(傾き {led['tilt_deg']:.1f}°)." + f"バッフルで直接光を遮断." + ) + for mesh in config["geometry"]["meshes"]: + if mesh["id"] == "sphere_housing": + if shape == "cube": + mesh["id"] = "cube_housing" + mesh["path"] = str(mesh_path.resolve()) + else: + mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve()) + config["geometry"]["meshes"].append({ + "id": "baffle", + "path": str(baffle_path.resolve()), + "unit": "mm", + "material": "printer_white", + }) + for light in config["lights"]: + light["center"] = [round(float(c), 4) for c in led["center"]] + light["direction"] = [round(float(d), 6) for d in led["direction"]] + 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 jobs() -> list[tuple[str, float]]: + """実行する(形状, サイズ)の一覧を返す.""" + return [("sphere", r) for r in SPHERE_RADII] + [("cube", size) for size in CUBE_SIZES] + + +def prepare(log) -> dict[tuple[str, float], Path]: + """全条件のメッシュとシーン YAML を生成し,watertight とクリアランスを検証する.""" + scenes: dict[tuple[str, float], Path] = {} + log("=== 幾何生成(オフ軸 LED / 球 similar_radial + 立方体)===") + for shape, size in jobs(): + name = job_name(shape, size) + led = led_geometry(shape, size) + mesh_path = MESH_DIR / f"筐体_{name}.stl" + housing = build_housing_mesh(shape, size, mesh_path) + if not housing.is_watertight: + raise ValueError(f"{name}: watertight でないメッシュが生成されました") + # LED 円板の縁が壁の内側にあることを数値で確認する + led_rim = _disk_points(led["center"], led["direction"], LED_RADIUS, 64) + rim_clearance = _wall_distance(shape, size, led_rim) + if rim_clearance < 0.0: + raise ValueError(f"{name}: LED 円板が壁を貫いています(余裕 {rim_clearance:.3f}mm)") + baffle_path = MESH_DIR / f"バッフル_{name}.stl" + _, embed = build_baffle_mesh(shape, size, led, baffle_path) + scene_yaml = TMP_DIR / f"scene_offaxis_{name}.yaml" + build_scene_yaml(shape, size, mesh_path, baffle_path, led, scene_yaml) + scenes[(shape, size)] = scene_yaml + log(f" {name:>12} LED=({led['center'][0]:7.2f}, 0, {led['center'][2]:8.2f}) " + f"傾き={led['tilt_deg']:5.1f}° 浮かせ={led['standoff']:4.2f}mm " + f"バッフル R={baffle_radius(led):5.2f}mm 円板余裕={rim_clearance:5.2f}mm " + f"壁食い込み={embed:4.2f}mm") + return scenes + + +def run_job(shape: str, size: float, scene_yaml: Path, log) -> dict | None: + """1 条件をレンダ→評価まで実行して集約行を返す(失敗時 None).""" + name = job_name(shape, size) + out_dir = OUTPUT_BASE / name + led = led_geometry(shape, size) + row: dict = { + "shape": shape, + "size_mm": size, + "led_x_mm": round(float(led["center"][0]), 3), + "led_z_mm": round(float(led["center"][2]), 3), + "led_standoff_mm": round(float(led["standoff"]), 3), + "tilt_deg": round(led["tilt_deg"], 2), + "baffle_radius_mm": round(baffle_radius(led), 3), + "cavity_area_mm2": cavity_area(shape, size), + "elapsed_s": 0.0, + } + if (out_dir / "metrics.csv").exists() and (out_dir / "luminance.npy").exists(): + log(f"--- {name}: 既存結果を再利用") + else: + log(f"--- {name}: レンダ開始") + 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 で日本語ログが読めなくなる.球版で実測) + 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"centroid_offset={row['intensity_centroid_offset_norm']:.5f} " + f"angular_ratio={row['angular_ratio']:.4f} 生mean={row['raw_mean_roi']:.4f}") + return row + + +def write_summary(rows: list[dict]) -> None: + """集約 CSV を(形状, サイズ)順で書き直す(途中終了でも最新状態が残る).""" + summary_csv = OUTPUT_BASE / "summary.csv" + summary_csv.parent.mkdir(parents=True, exist_ok=True) + with summary_csv.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=SUMMARY_COLUMNS) + writer.writeheader() + for row in sorted(rows, key=lambda r: (r["shape"], r["size_mm"])): + writer.writerow({k: row.get(k, "") for k in SUMMARY_COLUMNS}) + + +def main() -> int: + """オフ軸 LED スイープを実行するメインエントリポイント.""" + parser = argparse.ArgumentParser(description="オフ軸 LED スイープ(球 + 立方体)") + parser.add_argument("--prepare-only", action="store_true", + help="メッシュとシーン YAML の生成だけ行う(3D 配置確認用)") + parser.add_argument("--shapes", choices=("sphere", "cube"), nargs="*", default=None, + help="実行する形状(省略時は両方)") + args = parser.parse_args() + + log_path = ROOT / "output" / "_sweep_offaxis_led.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("=== オフ軸 LED スイープ ===") + scenes = prepare(log) + if args.prepare_only: + log(f"--prepare-only のため終了.シーン YAML: {TMP_DIR}") + log_file.close() + return 0 + + rows: list[dict] = [] + for shape, size in jobs(): + if args.shapes and shape not in args.shapes: + continue + row = run_job(shape, size, scenes[(shape, size)], log) + if row is not None: + rows.append(row) + write_summary(rows) + + log(f"\n=== 完了: {len(rows)} 条件 ===") + log(f" 集約 CSV: {OUTPUT_BASE / '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 index 4ec922b..b3c4e3b 100644 --- a/scripts/verify_baffle_occlusion.py +++ b/scripts/verify_baffle_occlusion.py @@ -70,17 +70,21 @@ def led_samples(light: AreaLight, count: int) -> np.ndarray: """LED 円板上のサンプル点(mm)を返す(中心 + 外周リング.外周が最悪条件). - リングの位相を LED_RING_PHASE だけずらす.0 始まりだとサンプル点が XZ / YZ 対称面に - 乗り,メッシュのエッジ上を走るレイになって交差判定が数値的に不安定になるため. + リングは light.direction を法線とする円板の平面上に張る(オフ軸 LED=TEST_17 で + 円板が斜めを向くため.軸沿い direction=(0,0,±1) では従来どおり XY 平面になる). + 位相を LED_RING_PHASE だけずらすのは従来どおり=サンプル点が XZ / YZ 対称面に + 乗り,メッシュのエッジ上を走るレイになって交差判定が数値的に不安定になるのを防ぐ. """ center = np.asarray(light.center, dtype=float) radius = float(getattr(light, "radius", 0.0) or 0.0) + normal = np.asarray(getattr(light, "direction", None) or [0.0, 0.0, -1.0], dtype=float) + normal = normal / np.linalg.norm(normal) + axis = np.array([0.0, 1.0, 0.0]) if abs(normal[1]) < 0.9 else np.array([0.0, 0.0, 1.0]) + u = np.cross(normal, axis) + u /= np.linalg.norm(u) + v = np.cross(normal, u) 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, - ) + ring = center + radius * (np.outer(np.cos(angles), u) + np.outer(np.sin(angles), v)) return np.vstack([center[None, :], ring])