diff --git a/CLAUDE.md b/CLAUDE.md index 0c522d2..a2805df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 7(実装)— Step 8 基準設計キャリブレーションの **Step 8-14(物理モデル精緻化)** 進行中.[TEST_12](docs/06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md) で **Step 8 残差の支配成分がレンダリングバグ(PLY 自動スムーズ法線 × 評価板の巨大三角形)だったことを発見・修正**(TEST_11 球体筐体実験の完全軸対称化で顕在化,builder に `face_normals: true` を強制).修正後は現行筐体全灯 **CoV 0.1717→0.0610**(実機 0.0695 を下回る)・勾配 6.8%(実機 8.35%)・C/P 1.073(実機 1.091)=**振幅系指標は実機とほぼ整合し,「残差 2.4×」と Y 軸単調勾配の見かけは大半がアーティファクトだったと判明**.球ロフト(CoV 0.0628・完全同心円)は現行筐体と同等=現行筐体はすでに理想積分球級で球形化の優位性なし(TEST_11 の結論は撤回・BEFORE FIX 注記参照).単灯再検証(TEST_12 追補)で **TEST_09 中核結論「3 灯同形=非対称相補性を再現できない」も撤回**: 修正後は灯間 NCC(平滑)0.85/0.93/0.73 の非対称で「left-right 最違い」は実機(0.92/0.66/0.44)と一致(振幅・最類似ペアは不一致=残課題).ロフト w1.4 の修正後再検証では**「全灯・単灯とも実質不感」が維持**(実機 w1.4 測定への予測も不変)される一方「bottom 平ら化=幾何レバー実証」は撤回相当に弱まった.実験4(漏れ点検)の主動機は大幅に弱まり,残る差分は「形」(相補性の振幅・実機の斜め構造)の切り分けへ再設定.実機側はロフト w=1.4 版 `MiniTIAS_QM_20260726_183416.dng` が SmTIAS-Evaluation 分析待ち.詳細な経緯・設計判断・失敗パターンは [docs/PROGRESS.md](docs/PROGRESS.md). +最新: フェーズ 7(実装)— Step 8-14 の物理モデル精緻化と並行し,**[TEST_13](docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md) 物理モデル健全性の一括監査**を実施.TEST_12 と同型の「無警告で結果だけが歪む」故障を横断検査し,**絶対放射量が積分球の解析解と 0.2% 一致**(単位系・反射率・光源パワー・センサ校正は健全)・シェーディング法線と幾何法線の一致(逸脱 0.044 度)・メッシュ法線の向き(裏面ヒット 0)を確認.**修正 3 件**: 片面 BSDF の脆弱性(法線裏返りで無警告の黒化 → `twosided` ラップ.実シーンには無影響)/**LLVM 初期化の順序バグでレンダテスト 8 件が長期間全滅していた**問題/ TEST_08 軸取り違えの回帰テスト欠落.**要判断**: `max_depth=16` は収束値の 90.3% しかエネルギーを拾えていない(CoV への影響は +0.35% で形状指標の過去結論は無影響.設定変更は LED radiance 再校正を伴うため保留,警告ログのみ実装).**未解決**: VTK 破損で `sm-visualize` と可視化テスト 39 件が実行不能.詳細な経緯・設計判断・失敗パターンは [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -34,6 +34,7 @@ - `src/sm_tias_lightsim/evaluation/normalize.py` — linear mean anchor 正規化(`apply_mean_anchor`).OETF と排他適用,形状指標は厳密不変で mean のみ target_mean に揃う.`configs/eval_baseline_linear.yaml` で有効化 - `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ).SmTIAS-Evaluation 互換スタイル(hot cmap,zone_map は luminance 背景 + 境界線) - `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応.materials は `DiffuseMaterial` / `RoughConductorBSDFConfig` (alpha + distribution=ggx/beckmann,material="none" 固定で完全反射体) / `BlendBSDFConfig` (weight + components×2,再帰ネスト可能,forward reference + `model_rebuild()`) を Union として並列サポート.後方互換維持 +- `src/sm_tias_lightsim/scene/audit.py` — シーンの物理的健全性を監査するモジュール(TEST_13).`check_truncation_bias` は最大反射率と `max_depth` から多重反射の打ち切りによる相対エネルギー欠損(rho^(max_depth-1))を見積もり,1% を超えると警告文を返す(`build_scene` から呼ばれ警告ログを出す).`audit_scene_normals` は読み込み済み Mitsuba シーンにレイを飛ばし,シェーディング法線と幾何法線の一致(`face_normals` の実効性)と裏向き面の有無を実測する(ベクトル化 variant では 1 回の交差計算にまとめる) - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス - `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 - `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換(sensors は orthographic 対応,lights は `area` / `point` / `directional` / `spot` 対応.Step 8-10 で `SpotLight` を追加,Step 8-14 β で `materials.py` に `_build_bsdf_dict` 再帰ヘルパを追加して `blendbsdf` ネスト対応) @@ -144,3 +145,4 @@ - キャリブレーション検証履歴: docs/06_TEST/TEST_01_キャリブレーション検証履歴.md(Step 8 イテレーション 8-1〜8-7 のフル記録.仮説/変更/数値/解釈/学びを時系列で) - シミュレーション実行記録: docs/06_TEST/TEST_02_シミュレーション実行記録.md(output/ 各サブディレクトリの索引.Step との対応・シーン/評価 config・主要指標・再現コマンド・削除推奨) - 輝度マップ形状追跡: docs/06_TEST/TEST_03_輝度マップ形状追跡.md(数値とは独立に「形」を実機に近づける観点.比較パネル画像と LSC モデル化の構造的意義) +- 物理モデル健全性の一括監査: docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(「無警告で結果だけが歪む」故障モードの横断監査.解析解による絶対放射量検証・法線監査・打ち切りバイアス・常設した再発防止テスト) diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index a373ab4..8194471 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -32,6 +32,18 @@ - `slow` マーカーは `pyproject.toml` の `[tool.pytest.ini_options]` に登録済み. +物理モデルの健全性検査(積分球の解析解一致・法線監査・面光源の軸マッピング)は +**通常実行に含まれる**(合計 2 秒程度).`slow` に隔離するとレンダ経路が壊れても +気づかないため([TEST_13](../06_TEST/TEST_13_物理モデル健全性の一括監査.md)). + +```bash +uv run pytest -m "" # slow を含む全テスト.シーン幾何を変えた後は一度通す +``` + +> ⚠ 既知の問題: `slow` 層のうち `tests/visualize/` と `tests/cli/test_visualize_cli.py` は +> VTK のインストール破損により失敗する(`import pyvista` 単体で再現).`sm-visualize` も +> 同じ理由で実行できない.依存関係の修復が必要. + ## Lint / フォーマット (Lint & Format) 規約の詳細は `.claude/rules/coding-style.md` を参照.ruff を唯一の lint / format ツールとする. diff --git "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index 305bef6..d56c026 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -60,6 +60,13 @@ ext_ior: 1.0 ``` +**面の向きの扱い**: 不透明反射材(`diffuse` / `roughconductor` / 不透明のみで構成される +`blendbsdf`)はシーン登録時に自動的に Mitsuba の `twosided` で包まれる.Mitsuba の +これらの BSDF は片面であり,法線が裏返ったメッシュ面は入射光を一切反射せず +**エラーも警告もなく真っ黒**になるため([TEST_13](../06_TEST/TEST_13_物理モデル健全性の一括監査.md)). +法線が正しい場合の結果は完全に同一で,YAML 側の記述は不要. +透過を伴う `dielectric` / `thindielectric` は両面挙動が本質なのでラップしない. + サポート済みの `type`: | type | Mitsuba 対応 BSDF | 用途 | 主要パラメータ | diff --git "a/docs/06_TEST/TEST_13_\347\211\251\347\220\206\343\203\242\343\203\207\343\203\253\345\201\245\345\205\250\346\200\247\343\201\256\344\270\200\346\213\254\347\233\243\346\237\273.md" "b/docs/06_TEST/TEST_13_\347\211\251\347\220\206\343\203\242\343\203\207\343\203\253\345\201\245\345\205\250\346\200\247\343\201\256\344\270\200\346\213\254\347\233\243\346\237\273.md" new file mode 100644 index 0000000..d4e683b --- /dev/null +++ "b/docs/06_TEST/TEST_13_\347\211\251\347\220\206\343\203\242\343\203\207\343\203\253\345\201\245\345\205\250\346\200\247\343\201\256\344\270\200\346\213\254\347\233\243\346\237\273.md" @@ -0,0 +1,218 @@ +# 物理モデル健全性の一括監査 (Physical Model Sanity Audit) + +本ドキュメントは,[TEST_12](TEST_12_シェーディング法線アーティファクトの発見と修正.md) と同じ +**「エラーも警告も出ないまま結果だけが静かに歪む」故障モード**が他に残っていないかを一括で +洗い出し,見つかった問題の修正と再発防止テストの常設化を行った記録である(2026-08-30). + +## 動機 (Motivation) + +TEST_12 のバグ(PLY 自動スムーズ法線 × 評価板の巨大三角形)は,CoV の 6 割超を占める +系統誤差を生みながら Step 8-13 まで気づかれなかった.損失は計算コストではなく, +**間違った前提の上に積み上げた考察と設計判断**そのものだった.同種の故障は +[TEST_08](TEST_08_面光源スケール軸バグの発見と修正.md)(面光源スケール軸バグ)でも起きており, +そのときは [TEST_07](TEST_07_LEDグループ別動作確認.md) が誤った出力を「物理的に正しい」と +一度は正当化してしまっている. + +このパターンは 2 度繰り返されている.3 度目を防ぐため,レンダリング規約・幾何・単位系・ +エネルギー収支を横断的に検査し,**検出力を実証したテストだけ**を通常実行に常設した. + +## 監査結果のまとめ (Summary) + +| # | 監査項目 | 結果 | 対応 | +| --- | --- | --- | --- | +| 1 | シェーディング法線 == 幾何法線 | **健全**(実測逸脱 0.044 度) | 再発防止テストを追加(負のコントロール付き) | +| 2 | 片面 BSDF による無警告の黒化 | **潜在的脆弱性あり** | `twosided` ラップで恒久修正 | +| 3 | メッシュ法線の向き | **健全**(裏面ヒット 0 件 / 全 15 STL の巻き順一貫) | 監査関数と回帰テストを追加 | +| 4 | 絶対放射量(単位系・反射率・光源パワー・センサ) | **健全**(解析解と 0.2% 一致) | 積分球テストを常設 | +| 5 | 多重反射の打ち切り(`max_depth`) | **問題あり**(基準設計シーンで **9.7% のエネルギー欠損**) | 警告を実装.設定変更は要判断 | +| 6 | 面光源のサイズ→軸マッピング(TEST_08 の再発) | **健全**だが**テストが存在しなかった** | 実効的な回帰テストを追加 | +| 7 | レンダ経路のテスト(`slow` 層) | **長期間全滅していた** | LLVM 初期化順序のバグを修正 | +| 8 | 3D 可視化のテスト(`slow` 層) | **全滅**(VTK 環境破損) | 環境側の対応が必要(未着手) | + +## 検査 1・3 — 法線の健全性 (Normals) + +読み込み済み Mitsuba シーンの空洞内部からレイを飛ばし,`si.sh_frame.n`(シェーディング法線)と +`si.n`(幾何法線)を実測比較する監査関数 `scene/audit.py: audit_scene_normals` を追加した. + +基準設計シーン(`configs/scene_baseline.yaml`,実 STL 8 個)での実測: + +- ヒット 26,853 本に対し **シェーディング法線と幾何法線の最大なす角 0.044 度** + (=浮動小数点誤差のみ.`face_normals: True` が Mitsuba レベルで実効) +- **裏面ヒット 0 件**(空洞内壁の法線は全て内向き) +- 全 15 STL について巻き順一貫・退化三角形ゼロ・体積正を確認 + +既存の再発防止テストは scene dict に `face_normals` キーが存在することしか見ていなかった. +本監査は実際にレンダラへ読み込ませて挙動を測るので,キーの意味が変わった場合も検出できる. + +**負のコントロール**: `face_normals` を外すと逸脱が 30 度以上に跳ね上がることをテストで確認している. +検出力を実証できない検査は再発防止にならないため,この対照実験は必ず維持する. + +## 検査 2 — 片面 BSDF による無警告の黒化 (One-sided BSDF) + +Mitsuba の `diffuse` / `roughconductor` は**片面 BSDF** であり,法線が裏返った面は +入射光を一切反射せず**エラーも警告もなく真っ黒**になる.SmTIAS は LED 直接光が評価板に +届かない間接照明のみの構成([SPEC_03](../04_SPEC/SPEC_03_基準設計の物理構成.md))なので, +内壁が 1 面でも黒化すれば輝度分布が静かに壊れる. + +現行の STL は全て法線が正しいため実害は出ていなかったが,STL を差し替えた瞬間に +発現しうる「気づけない」構造だった.積分球の解析解で定量化した結果: + +| 法線 | BSDF | 観測放射輝度 | 解析解比 | +| --- | --- | --- | --- | +| 内向き(正常) | `diffuse` | 0.407280 | 0.9996 | +| 内向き(正常) | `twosided` | 0.407280 | **0.9996(完全に同一=無影響)** | +| 外向き(裏返り) | `diffuse` | **0.000000** | **0.0000** | +| 外向き(裏返り) | `twosided` | 0.407475 | 1.0001 | + +**修正**: `scene/materials.py` に `build_scene_bsdf_dict` を追加し,不透明反射材 +(`diffuse` / `roughconductor` / 不透明のみで構成される `blendbsdf`)を `twosided` で包む. +透過を伴う誘電体は両面挙動が本質なのでラップしない. + +実シーン(`scene_baseline`,spp=256 / max_depth=16)での修正前後比較は +**mean +0.015% / CoV -0.009%**(MC ノイズの範囲内)で,**過去の結果に影響しない**ことを確認した. + +## 検査 4 — 絶対放射量の検証 (Absolute Radiometry) + +内面反射率 rho の閉じた球殻(積分球)は厳密な閉形式解を持つ.全放射束 Phi の光源を +内部に置くと壁面の全照度は `E = Phi / (A (1 - rho))`(`A = 4 pi R^2`)となり, +直接光が届かない領域の壁面放射輝度は `L = rho^2 Phi / (pi A (1 - rho))` になる. + +これを 1 本のテストで突き合わせると,**単位系(mm→m)・反射率・発光面の放射束・ +センサ校正・多重反射の収束**をまとめて検証できる.いずれかが壊れていれば一致しない. + +実測(rho=0.8・R=100mm・発光面 4x4mm・max_depth 収束後): + +| max_depth | 観測 L | 解析解比 | +| --- | --- | --- | +| 8 | 0.300061 | 0.7365 | +| 16 | 0.388804 | 0.9543 | +| 32 | 0.406155 | 0.9969 | +| 64 | 0.406630 | **0.9980** | +| 128 | 0.406630 | 0.9980 | + +収束値は解析解の **0.998**(残差 0.2% は icosphere が内接多面体であることによる面積欠損). +**パイプラインの絶対放射量は正しい**と結論できる. + +## 検査 5 — 多重反射の打ち切りバイアス (Truncation Bias) + +上表が示すとおり `max_depth` の打ち切りは無視できない.**基準設計シーンは +LED 直接光が評価板に届かない間接照明のみの構成**であり,輝度は白筐体内壁 +(rho=0.85)の多重反射の累積だけで決まるため,影響はより大きい. + +`configs/scene_baseline.yaml`(spp=256)での実測: + +| max_depth | mean | CoV | 最深値比(mean) | +| --- | --- | --- | --- | +| 8 | 12.634 | 0.26035 | 0.6176 | +| 16(現行設定) | 18.476 | 0.25270 | **0.9032** | +| 32 | 20.334 | 0.25181 | 0.9940 | +| 64 | 20.457 | 0.25181 | 1.0000 | + +**現行の `max_depth=16` は収束値の 90.3% しかエネルギーを拾えていない**(約 10% の過小評価). + +ただし **CoV への影響は +0.35% と軽微**である.これまでの検討の主題は形状指標 +(CoV・勾配・C/P・NCC)であり,絶対輝度は実測へのアンカー(mean anchor / OETF 露出ソルバ)で +吸収されるため,**過去の形状に関する結論は影響を受けない**. + +一方で以下には影響する. + +- LED radiance の校正値は打ち切り分を含んだ値になっている(自己整合的ではあるが物理的な絶対値ではない) +- **反射回数の分布が異なる筐体形状どうしの絶対輝度比較**(現行筐体 vs 球ロフト等)には系統差が乗る + +**対応**: `scene/audit.py: check_truncation_bias` を実装し,`build_scene` 時に +警告ログを出す.基準設計シーン(rho=0.85)では以下が出力される. + +```text +render.max_depth=16 は最大反射率 0.850 の多重反射を拾いきれていません +(相対エネルギー欠損の見積もり 8.7%).絶対輝度が系統的に過小評価されます. +max_depth >= 30 を推奨します. +``` + +欠損の見積もり式 `rho^(max_depth - 1)` は実測とよく一致する(積分球 rho=0.8: 予測 3.5% / 実測 4.6%, +基準設計 rho=0.85: 予測 8.7% / 実測 9.7%). + +> ⚠ **`configs/*.yaml` の `max_depth` は未変更**.値を上げると絶対輝度の基準が動き +> LED radiance の再校正が必要になるため,設定変更は人間の判断に委ねる. +> 計算コストは max_depth=16 で 80 秒 / 64 で 96 秒(spp=256・512x512)と +20% 程度. + +## 検査 6 — 面光源のサイズ→軸マッピング (Area-Light Axis Mapping) + +TEST_08 のバグ(発光面の高さを縦 Y ではなく奥行き Z に入れていた)に対する +**実効的な回帰テストが存在しなかった**.既存テストは `to_world` キーの存在と, +`position_scale` を変えたときに文字列表現が変わることしか検証しておらず, +軸を入れ替えても検出できない. + +単位図形の頂点を実際に `to_world` で変換し,世界座標での寸法を検証するテストを追加した +(現状は健全: size=(2.8, 3.5) が X=2.8mm / Y=3.5mm / Z=0 に正しく対応). + +## 検査 7 — レンダ経路のテストが全滅していた (Broken Render Tests) + +`slow` マークのテストを実行したところ **47 件中 47 件が失敗**していた. +うち 8 件はレンダリング経路で,原因は **LLVM 初期化の順序バグ**. + +`render/runner.py` は `mi.set_variant` の前に `ensure_drjit_libllvm_path()` を呼んで +`DRJIT_LIBLLVM_PATH` を自動探索する設計だったが,`scene/lights.py` と `scene/sensors.py` が +**モジュール読み込み時に mitsuba を import** するため,`runner` を import した時点で +既に Dr.Jit の LLVM 初期化が走っており,環境変数の設定が手遅れになっていた. + +開発機では環境変数が User scope に永続設定されているため症状が隠れていたが, +**環境変数が無い環境ではレンダリングが一切できない**([ENV_02](../02_ENV/ENV_02_環境構築手順.md) が +案内する自動探索が機能していなかった). + +**修正**: `scene/lights.py` / `scene/sensors.py` の mitsuba import 直前と, +パッケージの `__init__.py` で `ensure_drjit_libllvm_path()` を呼ぶ.これで 8 件が復活した. + +> **教訓**: 物理的健全性の検査を `slow` に隔離すると,通常実行から外れて壊れても気づかない. +> 本監査で追加した積分球テストは 1 本 0.2 秒(32x32 / spp=128)で合計 1.5 秒程度なので, +> `slow` を付けず通常実行に含めている. + +## 検査 8 — 3D 可視化テストの全滅(未解決) (Broken Visualization Tests) + +残る `slow` 失敗 39 件は全て `tests/visualize/` と `tests/cli/test_visualize_cli.py` で, +原因は **VTK のインストール破損**である(`ImportError: Failed to load vtkCommonMath: +No module named vtkmodules.vtkCommonCore`.`import pyvista` 単体で再現,mitsuba とは無関係). + +現在 **`sm-visualize` は実行できない状態**.[CLAUDE.md](../../CLAUDE.md) の +「幾何を変えるシーンは本番レンダ前に必ず 3D で配置確認する」運用が塞がっているため, +依存関係の修復が必要(`vtk 9.6.1` / `pyvista 0.48.0`).本監査では未着手. + +## 採用を見送った検査 (Rejected Checks) + +**軸対称シーンの対称性テスト**は検出力を実証できなかったため採用しなかった. + +立方体空洞+軸上点光源+軸上 orthographic カメラで,`face_normals` の有無による +象限比を比較したところ **1.0020 vs 1.0028** とほぼ差が出なかった(一方で mean は +394.4 → 300.2 と 24% 変化した).TEST_11 で異方性が顕在化したのは評価板メッシュの +非対称な三角形分割が効いていたためで,合成シーンでは再現できない. + +**検出できない検査をテストとして常設すると「検査済み」という誤った安心を与える**ため, +対称性チェックは手動の診断手段として残し,自動テストには含めない. + +## 追加した再発防止テスト (Added Regression Tests) + +すべて通常実行(`uv run pytest`)に含まれる.合計約 2 秒. + +| テストファイル | 内容 | 負のコントロール | +| --- | --- | --- | +| `tests/scene/test_audit.py` | 打ち切りバイアスの見積もり・警告生成 | — | +| `tests/scene/test_audit_normals.py` | シェーディング法線 == 幾何法線 / 裏面ヒット 0 | あり(`face_normals` を外す・メッシュを裏返す) | +| `tests/scene/test_materials_twosided.py` | 不透明反射材の `twosided` ラップと誘電体の除外 | あり | +| `tests/scene/test_lights_axis_mapping.py` | 面光源サイズの世界座標軸マッピング(TEST_08) | — | +| `tests/render/test_physics_validation.py` | 積分球の解析解一致・線形性・法線裏返り耐性・打ち切り | あり | + +`tests/scene/test_audit_normals.py` には基準設計シーン(実 STL 8 個)に対する +`slow` マークのテストも含む. + +## 過去の結論への影響 (Impact on Past Conclusions) + +- **形状指標に関する結論は影響を受けない**.`twosided` 化は無影響(MC ノイズ以内)で, + `max_depth` の打ち切りが CoV に与える影響も +0.35% にとどまる. +- **絶対輝度の解釈には注意が必要**.現行の LED radiance 校正値は打ち切り分(約 10%)を + 含んでいる.筐体形状の異なるシーン間で絶対 mean を比較する際は, + `max_depth` を揃えるだけでなく,反射回数分布の差による系統差が残ることに留意する. + +## 次にやること (Next Steps) + +1. `max_depth` を 32〜64 に引き上げるかの判断(引き上げる場合は LED radiance の再校正が必要) +2. VTK の修復(`sm-visualize` の復旧) +3. 残る差分=「形」(実機の斜め構造・相補性の振幅)の切り分けへ復帰 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6e9881e..39b3caa 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -78,3 +78,4 @@ - **TEST_12 シェーディング法線アーティファクトの発見と修正 — Step 8 残差の支配成分がレンダリングバグと判明 (2026-08-18)**: 完全軸対称化した球実験の輝度マップが同心円にならない違和感(ユーザー指摘)を起点に消去法診断(円形/矩形プレート×浮き球/ロフト筐体の 4 診断=すべて対称,実物評価板のみ非対称,`face_normals=true` で完全対称化)でバグを特定.**原因**: builder の PLY 出力に法線が無く Mitsuba がスムーズ頂点法線を自動生成 → 評価板裏面は巨大三角形 26 枚・全頂点エッジ上で法線が約 47° 傾き,評価面全体が汚染補間される.**修正**: builder で `face_normals: true` を強制(1 行+再発防止テスト).**影響**: 現行筐体全灯 CoV 0.1717→**0.0610**(実機 0.0695 を下回る),勾配 24.7→6.8%(実機 8.35%),C/P 1.328→1.073(実機 1.091)=**Step 8-13 以降の「残差 2.4×」の大半と「Y 軸単調勾配」の見かけはこのアーティファクトだった**.球ロフトは CoV 0.0628・angular_ratio 0.005(完全同心円)で現行筐体とほぼ同等=**現行筐体はすでに理想積分球級の混合性能**(TEST_11 の「球で -30% 改善」も撤回).振幅系指標が実機と揃った今,残る差分は「形」(実機の斜め構造 vs シミュの上端暗,NCC 0.616)に移った.過去の絶対値・形の結論は要再検証,相対比較の結論(ロフト不感・レバー序列)は共有アーティファクトのため方向性は保持見込み(各文書の BEFORE FIX 注記参照).検証手順: 再構成 full_base を修正前コードで保存済み結果と一致確認(CoV 差 1e-4)→ 修正後再レンダで前後差を分離.成果物: `output/{full_base_fix,sphere_r100_loft_disk_fix}/`,図 `output/_slide_panels/sphere_fix_{shape_roi,relative}.png`,記録 [TEST_12](06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md). - **TEST_12 追補 — 単灯再検証で TEST_09 中核結論「3 灯同形」を撤回・非対称相補性の部分再現を確認 (2026-08-18)**: 基準単灯 3 灯(blend w0.05_a0.1)を修正版で再レンダ(ロフト w1p4 系はユーザー判断でスキップ).灯間 NCC(平滑)は修正前 0.99 均等 → **修正後 0.85/0.93/0.73 の非対称**=「シミュは 3 灯ほぼ同形で非対称相補性を再現できない → B1 非対称幾何が唯一の候補」(TEST_09)はアーティファクトが作った見かけと判明し撤回(TEST_09 冒頭に BEFORE FIX 注記).修正後シミュは「left-right が最も異なる」点で実機(0.92/0.66/0.44)と一致するが,差の振幅は弱く最類似ペアは不一致=実験4(漏れ点検)の主動機は大幅に弱まり,残る課題は相補性の振幅・並びと実機の斜め構造の切り分けに再設定.計測上の学び: 単灯 spp=1024 はノイズ 5.3% > 構造振幅 4% で raw 指標(特に peak/mean・NCC)はノイズ支配 → 灯間 NCC は raw/smooth 併記とし解釈は smooth を正(`pairwise_ncc.csv`),以後の単灯実験は spp≥4096 推奨.成果物: `output/singles_fix/`,`scripts/build_singles_fix_summary.py`,図 `output/_slide_panels/singles_fix_shape_roi.png`,記録 [TEST_12 §追補](06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md). - **TEST_12 追補 2 — ロフト w1.4 の修正後再検証で TEST_10 の判定を確定 (2026-08-18)**: ロフト w1.4(全灯 1+単灯 3)を修正版で再レンダ.**④「全灯に実質不感」は維持**(CoV 0.0610→0.0605,全指標差 ≈1%=実機 w1.4 測定への予測「従来筐体とほぼ同じ」も不変).**③「対称拡大は 3 灯を同形化」も維持・明確化**(灯間 NCC 平滑 0.85/0.93/0.73→0.91/0.96/0.84 で実機の非対称から遠ざかる).一方 **①「bottom 単灯の平ら化=幾何レバー初実証」は撤回相当に弱まる**(平滑 p/m 変化 -0.9% のみ.TEST_10 の raw -10% はアーティファクト期の見かけが大半.修正後 raw p/m はヘビーテイルノイズの最大値ゆらぎで不採用).②「left/right 不動」維持.TEST_10 冒頭に BEFORE FIX 注記(結論の生死を明記).成果物: `output/full_w1p4_fix/`・`output/singles_fix/*_w1p4/`,summary/pairwise CSV と 3×4 パネル更新,記録 [TEST_12 §ロフト再検証](06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md). +- **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). diff --git a/src/sm_tias_lightsim/scene/audit.py b/src/sm_tias_lightsim/scene/audit.py new file mode 100644 index 0000000..8705f7e --- /dev/null +++ b/src/sm_tias_lightsim/scene/audit.py @@ -0,0 +1,288 @@ +"""シーン設定・シーン幾何の物理的健全性を監査するモジュール. + +「エラーも警告も出ないまま結果だけが静かに歪む」タイプの不具合を検出することを目的とする. +TEST_12(シェーディング法線アーティファクト)では,レンダリング規約の取り違えが +CoV の 6 割超を占める系統誤差を生みながら 4 ステップにわたり気づかれなかった. +同種の故障モードを自動検出できるようにするのが本モジュールの役割. + +現在の監査項目: + +- **多重反射の打ち切りバイアス** (`estimate_truncation_residual`) + SmTIAS の照明は LED 直接光が評価板に届かない間接照明のみの構成(SPEC_03)であり, + 輝度は白筐体内壁の多重反射の累積で決まる.`render.max_depth` が小さいと + 高次の反射が切り捨てられ,絶対輝度が系統的に過小評価される. +- **シェーディング法線の同一性・面の向き** (`audit_scene_normals`) + 読み込み済み Mitsuba シーンにレイを飛ばし,シェーディング法線が幾何法線と一致するか + (=`face_normals` が実効か)と,空洞内部から見て裏向きの面が無いかを実測する. + +設定に対する検査は副作用を持たない純関数.`audit_scene_normals` は +読み込み済みシーンへの読み取り専用クエリで,seed 固定により決定論的. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DiffuseMaterial, + MaterialConfig, + RoughConductorBSDFConfig, + SceneConfig, +) + +# 打ち切り残差の既定許容値(1%).これを超えると絶対輝度が無視できない過小評価になる. +DEFAULT_TRUNCATION_TOLERANCE = 0.01 + +# material="none" の roughconductor は完全反射体として扱う(schema の仕様) +_PERFECT_REFLECTOR = 1.0 + + +def material_max_reflectance(material: MaterialConfig) -> float | None: + """1 材料の最大反射率を返す(不透明反射材のみ). + + Args: + material: SceneConfig.materials の値. + + Returns: + 反射率の最大成分.反射率を定義できない材料(誘電体等)は None. + """ + if isinstance(material, DiffuseMaterial): + r = material.reflectance + return float(r) if isinstance(r, (int, float)) else float(max(r)) + + if isinstance(material, RoughConductorBSDFConfig): + # material="none" 固定=完全反射体(schema の仕様) + return _PERFECT_REFLECTOR + + if isinstance(material, BlendBSDFConfig): + values = [material_max_reflectance(c) for c in material.components] + known = [v for v in values if v is not None] + return max(known) if known else None + + # DielectricMaterial / ThinDielectricMaterial は吸収を持たず反射率で表せない + return None + + +def scene_max_reflectance(config: SceneConfig) -> float | None: + """シーン中の最大反射率を返す. + + 多重反射の減衰率を支配するのは最も明るい材料なので,最大値を使う. + + Args: + config: 検証済み SceneConfig. + + Returns: + 最大反射率.反射率を定義できる材料が 1 つも無ければ None. + """ + values = [material_max_reflectance(m) for m in config.materials.values()] + known = [v for v in values if v is not None] + return max(known) if known else None + + +def estimate_truncation_residual(reflectance: float, max_depth: int) -> float: + """多重反射を max_depth で打ち切ることによる相対エネルギー欠損を見積もる. + + 反射率 rho の閉じた空洞では,n 回反射後に残るエネルギーが rho^n なので, + 深度 max_depth で打ち切ると収束値に対して概ね rho^(max_depth - 1) を取りこぼす. + + 実測との対応(本モジュール導入時の検証): + + | シーン | rho | max_depth | 本式の予測欠損 | 実測欠損 | + | --- | --- | --- | --- | --- | + | 積分球 | 0.80 | 16 | 3.5% | 4.6% | + | 基準設計シーン | 0.85 | 16 | 8.7% | 9.7% | + + Args: + reflectance: シーン中の最大反射率(0 以上). + max_depth: `render.max_depth`. + + Returns: + 収束値に対する相対エネルギー欠損の見積もり(0〜1.1 に近いほど未収束). + max_depth が 1 以下なら 1.0(ほぼ何も拾えていない)を返す. + """ + if max_depth <= 1: + return 1.0 + return float(reflectance ** (max_depth - 1)) + + +def check_truncation_bias( + config: SceneConfig, tolerance: float = DEFAULT_TRUNCATION_TOLERANCE +) -> str | None: + """max_depth が多重反射を十分に拾えているか検査し,不足なら警告文を返す. + + Args: + config: 検証済み SceneConfig. + tolerance: 許容する相対エネルギー欠損(既定 1%). + + Returns: + 欠損が tolerance を超える場合は警告メッセージ.十分なら None. + 反射率を定義できる材料が無い場合も None. + """ + reflectance = scene_max_reflectance(config) + if reflectance is None: + return None + + residual = estimate_truncation_residual(reflectance, config.render.max_depth) + if residual <= tolerance: + return None + + return ( + f"render.max_depth={config.render.max_depth} は最大反射率 {reflectance:.3f} の" + f"多重反射を拾いきれていません(相対エネルギー欠損の見積もり {residual:.1%})." + f"絶対輝度が系統的に過小評価されます." + f"max_depth >= {required_max_depth(reflectance, tolerance)} を推奨します." + ) + + +def required_max_depth(reflectance: float, tolerance: float = DEFAULT_TRUNCATION_TOLERANCE) -> int: + """相対エネルギー欠損を tolerance 以下に収めるのに必要な max_depth を返す. + + Args: + reflectance: シーン中の最大反射率. + tolerance: 許容する相対エネルギー欠損. + + Returns: + 必要な max_depth(1 以上の整数).reflectance が 0 以下または 1 以上で + 解が定まらない場合はそれぞれ 1 / 大きな既定値を返す. + """ + if reflectance <= 0.0: + return 1 + if reflectance >= 1.0: + # 吸収の無い完全反射体では有限深度で収束しない.実用上の上限を返す. + return 1024 + return 1 + math.ceil(math.log(tolerance) / math.log(reflectance)) + + +@dataclass(frozen=True) +class NormalAuditResult: + """`audit_scene_normals` の測定結果. + + Attributes: + hits: シーンに命中したレイの本数. + backface_hits: 法線がレイの発射点と反対を向いていた命中数. + 空洞内部から飛ばした場合,これが 0 でないメッシュは裏返っている. + max_shading_normal_deviation_deg: シェーディング法線と幾何法線の + 最大なす角 [deg].`face_normals` が実効なら浮動小数点誤差程度になる. + """ + + hits: int + backface_hits: int + max_shading_normal_deviation_deg: float + + +def _sample_directions(rng: np.random.Generator, count: int) -> np.ndarray: + """単位球面上の一様方向を count 本サンプリングする.""" + directions = rng.normal(size=(count, 3)) + return directions / np.linalg.norm(directions, axis=1, keepdims=True) + + +def _intersect_batched(scene: Any, origins: np.ndarray, directions: np.ndarray): + """ベクトル化 variant(llvm_* / cuda_*)で全レイを 1 回の交差計算にまとめる. + + Returns: + (valid, geometric, shading) のタプル.いずれも (N,) / (N, 3) の numpy 配列. + """ + import mitsuba as mi + + ray = mi.Ray3f( + mi.Point3f(origins[:, 0], origins[:, 1], origins[:, 2]), + mi.Vector3f(directions[:, 0], directions[:, 1], directions[:, 2]), + ) + si = scene.ray_intersect(ray) + valid = np.asarray(si.is_valid()).reshape(-1) + # Dr.Jit の 3 成分ベクトルは (3, N) で numpy 化されるので転置して (N, 3) に揃える + geometric = np.asarray(si.n).reshape(3, -1).T + shading = np.asarray(si.sh_frame.n).reshape(3, -1).T + return valid, geometric, shading + + +def _intersect_per_ray(scene: Any, origins: np.ndarray, directions: np.ndarray): + """scalar_* variant 用に 1 本ずつ交差計算する(ベクトル化非対応のため). + + Returns: + `_intersect_batched` と同じ形式のタプル. + """ + import mitsuba as mi + + count = len(origins) + valid = np.zeros(count, dtype=bool) + geometric = np.zeros((count, 3), dtype=float) + shading = np.zeros((count, 3), dtype=float) + + for i in range(count): + ray = mi.Ray3f( + mi.Point3f(float(origins[i, 0]), float(origins[i, 1]), float(origins[i, 2])), + mi.Vector3f(float(directions[i, 0]), float(directions[i, 1]), float(directions[i, 2])), + ) + si = scene.ray_intersect(ray) + if not bool(si.is_valid()): + continue + valid[i] = True + geometric[i] = np.asarray(si.n).reshape(3) + shading[i] = np.asarray(si.sh_frame.n).reshape(3) + + return valid, geometric, shading + + +def audit_scene_normals( + scene: Any, + origins: np.ndarray, + rays_per_origin: int = 500, + seed: int = 0, +) -> NormalAuditResult: + """空洞内部からレイを飛ばし,法線の健全性を実測する. + + 2 つの「気づけない故障モード」を同時に検査する. + + 1. **シェーディング法線の汚染**(TEST_12 の原因). + PLY に法線が無いと Mitsuba は既定でスムーズ頂点法線を自動生成し, + 巨大三角形の平面がエッジ頂点の平均化法線で描画されて偽の異方性を生む. + `face_normals` が実効なら幾何法線と一致する. + 2. **面の裏返り**.片面 BSDF では裏向きの面が無警告で真っ黒になる + (`materials.build_scene_bsdf_dict` の twosided ラップで救済済みだが, + 裏返り自体はモデリング誤りの兆候なので検出する). + + レイはベクトル化 variant では 1 回の交差計算にまとめる.llvm_* で 1 本ずつ + 呼ぶと JIT カーネルが大量に生成され,実行時に不安定になるため. + + Args: + scene: `mi.load_dict` で読み込み済みの Mitsuba シーン. + origins: レイ発射点 (N, 3).シーンと同じ単位(m)で空洞内部の点を与える. + rays_per_origin: 発射点あたりのレイ本数. + seed: 方向サンプリングの乱数シード(決定論性のため固定する). + + Returns: + NormalAuditResult. + """ + import mitsuba as mi + + origins = np.asarray(origins, dtype=float) + rng = np.random.default_rng(seed) + + ray_origins = np.repeat(origins, rays_per_origin, axis=0) + ray_directions = _sample_directions(rng, len(ray_origins)) + + intersect = _intersect_per_ray if mi.variant().startswith("scalar") else _intersect_batched + valid, geometric, shading = intersect(scene, ray_origins, ray_directions) + + if not valid.any(): + return NormalAuditResult(hits=0, backface_hits=0, max_shading_normal_deviation_deg=0.0) + + geometric = geometric[valid] + shading = shading[valid] + directions = ray_directions[valid] + + cos_deviation = np.abs(np.einsum("ij,ij->i", geometric, shading)) + deviation_deg = np.degrees(np.arccos(np.clip(cos_deviation, 0.0, 1.0))) + backface = np.einsum("ij,ij->i", geometric, -directions) < 0.0 + + return NormalAuditResult( + hits=int(valid.sum()), + backface_hits=int(backface.sum()), + max_shading_normal_deviation_deg=float(deviation_deg.max()), + ) diff --git a/src/sm_tias_lightsim/scene/builder.py b/src/sm_tias_lightsim/scene/builder.py index 52d03e2..da3f3a1 100644 --- a/src/sm_tias_lightsim/scene/builder.py +++ b/src/sm_tias_lightsim/scene/builder.py @@ -5,6 +5,7 @@ from __future__ import annotations +import logging import shutil import tempfile from pathlib import Path @@ -13,11 +14,14 @@ from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl from sm_tias_lightsim.geometry.transforms import scale_vertices +from sm_tias_lightsim.scene.audit import check_truncation_bias from sm_tias_lightsim.scene.lights import build_light_dict -from sm_tias_lightsim.scene.materials import build_material_dict +from sm_tias_lightsim.scene.materials import build_scene_bsdf_dict from sm_tias_lightsim.scene.schema import SceneConfig from sm_tias_lightsim.scene.sensors import build_sensor_dict +_logger = logging.getLogger(__name__) + # mm→m の変換係数 _MM_TO_M = 0.001 @@ -101,6 +105,13 @@ try: scene_dict: dict = {"type": "scene"} + # 多重反射の打ち切りバイアスを検査する.SmTIAS は間接照明のみの構成(SPEC_03)で + # 輝度が内壁の多重反射に支配されるため,max_depth 不足は絶対輝度を静かに + # 過小評価する.気づけない故障モードを避けるため必ず警告として顕在化させる. + truncation_warning = check_truncation_bias(config) + if truncation_warning is not None: + _logger.warning("%s", truncation_warning) + # integrator scene_dict["integrator"] = { "type": config.render.integrator, @@ -112,7 +123,10 @@ # materials(トップレベルに配置し mesh から id 参照する) for name, mat in config.materials.items(): - mat_dict = build_material_dict(mat) + # build_scene_bsdf_dict は不透明反射材を twosided でラップする. + # 片面 diffuse のままだと法線が裏返った面が無警告で真っ黒になる + # (materials.build_scene_bsdf_dict の docstring 参照). + mat_dict = build_scene_bsdf_dict(mat) mat_dict["id"] = f"mat_{name}" scene_dict[f"mat_{name}"] = mat_dict diff --git a/src/sm_tias_lightsim/scene/materials.py b/src/sm_tias_lightsim/scene/materials.py index 857cce9..bc805b3 100644 --- a/src/sm_tias_lightsim/scene/materials.py +++ b/src/sm_tias_lightsim/scene/materials.py @@ -108,3 +108,54 @@ ValueError: 未対応の材料タイプが指定された場合. """ return _build_bsdf_dict(material) + + +def _is_opaque(material: object) -> bool: + """不透明な反射専用 BSDF かどうかを判定する(内部ヘルパ). + + 誘電体(dielectric / thindielectric)は透過を伴い両面挙動が本質なので + twosided でラップしてはならない.拡散・粗面導体は片面 BSDF なので + ラップ対象になる.blendbsdf は全構成要素が不透明なときのみ不透明とみなす. + + Args: + material: MaterialConfig のいずれか. + + Returns: + twosided でラップしてよい不透明反射材なら True. + """ + if isinstance(material, (DiffuseMaterial, RoughConductorBSDFConfig)): + return True + + if isinstance(material, BlendBSDFConfig): + return all(_is_opaque(c) for c in material.components) + + # DielectricMaterial / ThinDielectricMaterial および未知型は非対象 + return False + + +def build_scene_bsdf_dict(material: MaterialConfig) -> dict: + """シーン登録用の BSDF dict を返す(不透明反射材は twosided でラップする). + + `build_material_dict` が返す素の BSDF dict をシーンにそのまま登録すると, + Mitsuba の diffuse / roughconductor は**片面 BSDF** であるため, + 法線が裏返ったメッシュ面は入射光を一切反射せず**エラーも警告もなく真っ黒**になる. + 間接照明のみで評価板を照らす SmTIAS の構成では,内壁が 1 面でも黒化すると + 輝度分布が静かに壊れる(TEST_12 のシェーディング法線バグと同じ「気づけない」故障モード). + + そこで不透明反射材を `twosided` でラップし,面の向きに依存しない物理的に正しい + 挙動にする.積分球の解析解による検証では,法線が正しい場合はラップの有無で + 結果が完全に一致し(無影響),法線が裏返った場合のみ黒化を救済することを確認済み. + + 透過を伴う誘電体(dielectric / thindielectric)は両面挙動が本質なのでラップしない. + + Args: + material: SceneConfig.materials の値. + + Returns: + Mitsuba BSDF dict.不透明反射材は + {"type": "twosided", "bsdf": <素の BSDF dict>} の形になる. + """ + bsdf = build_material_dict(material) + if _is_opaque(material): + return {"type": "twosided", "bsdf": bsdf} + return bsdf diff --git a/tests/render/test_physics_validation.py b/tests/render/test_physics_validation.py new file mode 100644 index 0000000..f4a7e6e --- /dev/null +++ b/tests/render/test_physics_validation.py @@ -0,0 +1,234 @@ +"""解析解に対する物理量の妥当性検証テスト. + +仕様出典: +- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md + +積分球(内面反射率 rho の閉じた球殻)は放射伝達に厳密な閉形式解を持つ. +全放射束 Phi の光源を内部に置くと,壁面の全照度は + + E_total = Phi / (A (1 - rho)) A = 4 pi R^2 + +となり,直接光が届かない領域の壁面照度は間接成分のみの + + E_indirect = rho * E_total + +なので,そこを見たカメラが観測する放射輝度は + + L = rho * E_indirect / pi = rho^2 * Phi / (pi * A * (1 - rho)) + +これを実測と突き合わせると,**単位系(mm→m)・反射率・発光面の放射束・ +センサ校正・多重反射の収束**をまとめて 1 本で検証できる.いずれかが壊れていれば +一致しない.TEST_12 のような「エラーも警告も出ないまま結果だけが歪む」故障を +数値で捕まえるための土台. + +本モジュールを `slow` にしない理由: レンダリングを伴うが 32x32 / spp 128 の +極小シーンで合計数秒しかかからない.物理的健全性の検査は通常実行に含めないと +意味がない(実際 `slow` 層はレンダ経路の破損で長期間全滅していたが気づかれなかった). +""" + +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest +import trimesh +import yaml + +from sm_tias_lightsim.render.runner import render_scene +from sm_tias_lightsim.scene.schema import load_scene_yaml + +# --- 積分球の構成(すべて mm) --- +_SPHERE_RADIUS_MM = 100.0 +_REFLECTANCE = 0.8 +_EMITTER_RADIANCE = 1000.0 # W/(m^2 sr) +_EMITTER_SIZE_MM = 4.0 +# 発光面は -Z を向き z=-50mm に置く.z > -50mm の領域には直接光が一切届かないので, +# +Z 極を見るカメラは純粋に間接成分だけを観測する. +_EMITTER_CENTER_MM = (0.0, 0.0, -50.0) + +# 反射率 0.8 では rho^47 ~ 3e-5 なので打ち切り誤差は無視できる +_CONVERGED_MAX_DEPTH = 48 +_SPP = 128 +_FILM = 32 + +# 許容誤差 2%: 内訳は icosphere(内接多面体)の面積欠損 ~0.2% と MC ノイズ ~0.3%. +# 実測は解析解比 0.9996〜1.0001 だった(TEST_13). +_TOLERANCE = 0.02 + +_MM_TO_M = 0.001 + + +def _analytic_shadowed_radiance(radiance: float = _EMITTER_RADIANCE) -> float: + """直接光が届かない壁面の放射輝度の解析解 [W/(m^2 sr)]. + + Args: + radiance: 発光面の放射輝度 [W/(m^2 sr)]. + + Returns: + 壁面の放射輝度 [W/(m^2 sr)]. + """ + area = 4.0 * math.pi * (_SPHERE_RADIUS_MM * _MM_TO_M) ** 2 + emitter_area = (_EMITTER_SIZE_MM * _MM_TO_M) ** 2 + # ランバート発光面の全放射束 Phi = pi * L * A + flux = math.pi * radiance * emitter_area + irradiance_total = flux / (area * (1.0 - _REFLECTANCE)) + irradiance_indirect = _REFLECTANCE * irradiance_total + return _REFLECTANCE * irradiance_indirect / math.pi + + +def _write_sphere_stl(path: Path, inward: bool) -> None: + """半径 100mm の球殻 STL を書き出す. + + Args: + path: 出力先. + inward: True なら面を反転して法線を内向きにする(空洞の内壁として正しい向き). + """ + sphere = trimesh.creation.icosphere(subdivisions=4, radius=_SPHERE_RADIUS_MM) + if inward: + sphere.faces = sphere.faces[:, ::-1] + sphere.export(str(path)) + + +def _write_scene_yaml( + path: Path, + stl_path: Path, + max_depth: int = _CONVERGED_MAX_DEPTH, + radiance: float = _EMITTER_RADIANCE, +) -> Path: + """積分球シーンの YAML を書き出してパスを返す.""" + cfg = { + "name": "integrating_sphere", + "description": "解析解と突き合わせる積分球", + "geometry": { + "meshes": [{"id": "shell", "path": str(stl_path), "unit": "mm", "material": "wall"}] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [_REFLECTANCE] * 3}}, + "lights": [ + { + "id": "src", + "type": "area", + "shape": "rectangle", + "center": list(_EMITTER_CENTER_MM), + "size": [_EMITTER_SIZE_MM, _EMITTER_SIZE_MM], + "direction": [0.0, 0.0, -1.0], + "radiance": [radiance] * 3, + } + ], + # +Z 極の壁を見る.発光面は -Z を向いているのでここには直接光が届かない. + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 50.0], + "target": [0.0, 0.0, 90.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": _FILM, "height": _FILM, "rfilter": "box"}, + }, + "render": { + "variant": "llvm_ad_rgb", + "integrator": "path", + "max_depth": max_depth, + "spp": _SPP, + }, + } + path.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + return path + + +def _render_mean_radiance( + tmp_path: Path, + inward: bool, + max_depth: int, + radiance: float = _EMITTER_RADIANCE, +) -> float: + """積分球をレンダリングし,観測された平均放射輝度を返す.""" + tmp_path.mkdir(parents=True, exist_ok=True) + stl_path = tmp_path / ("sphere_inward.stl" if inward else "sphere_outward.stl") + _write_sphere_stl(stl_path, inward=inward) + scene_yaml = _write_scene_yaml( + tmp_path / "scene.yaml", stl_path, max_depth=max_depth, radiance=radiance + ) + image = render_scene(load_scene_yaml(scene_yaml), project_root=tmp_path) + return float(np.mean(image)) + + +# --------------------------------------------------------------------------- +# 絶対放射量の検証 +# --------------------------------------------------------------------------- + + +def test_integrating_sphere_radiance_matches_analytic(tmp_path): + """積分球の壁面放射輝度が解析解と一致すること. + + 単位系・反射率・発光面の放射束・センサ校正・多重反射の収束を一括で検証する. + """ + measured = _render_mean_radiance(tmp_path, inward=True, max_depth=_CONVERGED_MAX_DEPTH) + assert measured == pytest.approx(_analytic_shadowed_radiance(), rel=_TOLERANCE) + + +def test_rendered_radiance_scales_linearly_with_emitter_radiance(tmp_path): + """発光面の radiance を 2 倍にすると観測値も 2 倍になること. + + 輸送が線形であること(露出・トーンマップ等の非線形処理が紛れ込んでいないこと)を + 実レンダで確認する.シミュ値を実測にアンカーする校正はこの線形性に依存している. + """ + base = _render_mean_radiance(tmp_path / "base", inward=True, max_depth=_CONVERGED_MAX_DEPTH) + doubled = _render_mean_radiance( + tmp_path / "doubled", + inward=True, + max_depth=_CONVERGED_MAX_DEPTH, + radiance=_EMITTER_RADIANCE * 2.0, + ) + assert doubled == pytest.approx(2.0 * base, rel=_TOLERANCE) + + +# --------------------------------------------------------------------------- +# 片面 BSDF による無警告の黒化に対する耐性 +# --------------------------------------------------------------------------- + + +def test_flipped_normals_do_not_black_out_the_cavity(tmp_path): + """法線が裏返った空洞でも黒化せず解析解に一致すること. + + Mitsuba の diffuse は片面 BSDF なので,twosided ラップが無いと + ここは 0.0 になる(エラーも警告も出ない). + materials.build_scene_bsdf_dict のラップが効いていることの実証. + """ + measured = _render_mean_radiance(tmp_path, inward=False, max_depth=_CONVERGED_MAX_DEPTH) + assert measured > 0.0, "空洞が真っ黒になっている(片面 BSDF に逆戻りしている)" + assert measured == pytest.approx(_analytic_shadowed_radiance(), rel=_TOLERANCE) + + +def test_flipped_and_correct_normals_agree(tmp_path): + """法線の向きによらず同じ結果になること. + + 不透明な壁の反射は面の向き付けという描画上の規約に依存してはならない. + """ + inward = _render_mean_radiance(tmp_path / "a", inward=True, max_depth=_CONVERGED_MAX_DEPTH) + outward = _render_mean_radiance(tmp_path / "b", inward=False, max_depth=_CONVERGED_MAX_DEPTH) + assert outward == pytest.approx(inward, rel=_TOLERANCE) + + +# --------------------------------------------------------------------------- +# 多重反射の打ち切りバイアス +# --------------------------------------------------------------------------- + + +def test_shallow_max_depth_underestimates_radiance(tmp_path): + """max_depth が浅いと解析解を有意に下回ること. + + scene/audit.py の打ち切り警告が実在の問題に対する警告であることの裏付け. + rho=0.8・max_depth=8 では収束値の約 74% しか拾えない(TEST_13 実測). + """ + shallow = _render_mean_radiance(tmp_path, inward=True, max_depth=8) + analytic = _analytic_shadowed_radiance() + assert shallow < analytic * 0.9 + + +def test_deeper_max_depth_converges_towards_analytic(tmp_path): + """max_depth を深くすると解析解に近づくこと(単調な収束).""" + analytic = _analytic_shadowed_radiance() + shallow = _render_mean_radiance(tmp_path / "a", inward=True, max_depth=8) + deep = _render_mean_radiance(tmp_path / "b", inward=True, max_depth=_CONVERGED_MAX_DEPTH) + assert abs(deep - analytic) < abs(shallow - analytic) diff --git a/tests/scene/test_audit.py b/tests/scene/test_audit.py new file mode 100644 index 0000000..66ed724 --- /dev/null +++ b/tests/scene/test_audit.py @@ -0,0 +1,218 @@ +"""scene/audit.py の仕様ベーステスト. + +仕様出典: +- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(多重反射の打ち切りバイアス) +- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(LED は間接照明のみ=多重反射が輝度を支配) + +打ち切り残差の式 rho^(max_depth - 1) は,積分球(rho=0.8)と基準設計シーン(rho=0.85)の +実レンダで実測した欠損(4.6% / 9.7%)と 1 ポイント程度で一致することを確認して採用した. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.audit import ( + check_truncation_bias, + estimate_truncation_residual, + material_max_reflectance, + required_max_depth, + scene_max_reflectance, +) +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DielectricMaterial, + DiffuseMaterial, + RoughConductorBSDFConfig, + load_scene_yaml, +) + +# --------------------------------------------------------------------------- +# material_max_reflectance +# --------------------------------------------------------------------------- + + +def test_diffuse_scalar_reflectance_returned_as_is(): + """スカラ reflectance がそのまま返ること.""" + mat = DiffuseMaterial(type="diffuse", reflectance=0.85) + assert material_max_reflectance(mat) == pytest.approx(0.85) + + +def test_diffuse_rgb_reflectance_returns_max_channel(): + """RGB reflectance では最大チャンネルが返ること. + + 多重反射の減衰を支配するのは最も減衰しないチャンネルなので最大値を使う. + """ + mat = DiffuseMaterial(type="diffuse", reflectance=(0.2, 0.9, 0.5)) + assert material_max_reflectance(mat) == pytest.approx(0.9) + + +def test_dielectric_has_no_reflectance(): + """誘電体は反射率で表せないため None が返ること.""" + mat = DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0) + assert material_max_reflectance(mat) is None + + +def test_roughconductor_treated_as_perfect_reflector(): + """roughconductor(material="none")は完全反射体 1.0 として扱われること. + + 仕様: schema の RoughConductorBSDFConfig は material="none" 固定=完全反射体. + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx") + assert material_max_reflectance(mat) == pytest.approx(1.0) + + +def test_blend_returns_max_of_components(): + """blendbsdf は構成要素の最大反射率を返すこと.""" + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.4), + DiffuseMaterial(type="diffuse", reflectance=0.8), + ], + ) + assert material_max_reflectance(mat) == pytest.approx(0.8) + + +# --------------------------------------------------------------------------- +# estimate_truncation_residual +# --------------------------------------------------------------------------- + + +def test_truncation_residual_matches_geometric_series(): + """打ち切り残差が rho^(max_depth - 1) であること.""" + assert estimate_truncation_residual(0.85, 16) == pytest.approx(0.85**15) + + +def test_truncation_residual_decreases_with_depth(): + """max_depth を増やすと残差が単調減少すること.""" + residuals = [estimate_truncation_residual(0.85, d) for d in (8, 16, 32, 64)] + assert residuals == sorted(residuals, reverse=True) + + +def test_truncation_residual_is_one_for_depth_one(): + """max_depth=1 では多重反射を全く拾えず残差 1.0 になること.""" + assert estimate_truncation_residual(0.85, 1) == pytest.approx(1.0) + + +def test_truncation_residual_predicts_measured_deficit_of_baseline(): + """基準設計シーンの実測欠損(9.7%)を 2 ポイント以内で予測すること. + + 実測: scene_baseline(rho=0.85)を max_depth=16 と 64 でレンダしたとき + mean 比 0.9032(= 9.7% 欠損).本式の予測は 0.85^15 = 8.7%. + """ + predicted = estimate_truncation_residual(0.85, 16) + assert predicted == pytest.approx(0.097, abs=0.02) + + +# --------------------------------------------------------------------------- +# required_max_depth +# --------------------------------------------------------------------------- + + +def test_required_max_depth_achieves_tolerance(): + """返された max_depth で実際に残差が許容値以下になること.""" + rho = 0.85 + d = required_max_depth(rho, tolerance=0.01) + assert estimate_truncation_residual(rho, d) <= 0.01 + + +def test_required_max_depth_is_minimal(): + """返された max_depth より 1 小さいと許容値を超えること(最小性).""" + rho = 0.85 + d = required_max_depth(rho, tolerance=0.01) + assert estimate_truncation_residual(rho, d - 1) > 0.01 + + +def test_required_max_depth_for_perfect_reflector_is_bounded(): + """完全反射体でも無限にならず実用上限が返ること.""" + assert required_max_depth(1.0) == 1024 + + +# --------------------------------------------------------------------------- +# check_truncation_bias +# --------------------------------------------------------------------------- + + +def _scene_yaml(tmp_path, reflectance: float, max_depth: int): + """最小シーン YAML を書き出して SceneConfig を返す.""" + import yaml + + cfg = { + "name": "t", + "description": "t", + "geometry": { + "meshes": [ + { + "id": "m", + "path": "fixtures/meshes/sphere.stl", + "unit": "mm", + "material": "wall", + } + ] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [reflectance] * 3}}, + "lights": [ + { + "id": "src", + "type": "point", + "position": [0.0, 0.0, 0.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, -10.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": 8, "height": 8, "rfilter": "box"}, + }, + "render": { + "variant": "scalar_rgb", + "integrator": "path", + "max_depth": max_depth, + "spp": 1, + }, + } + p = tmp_path / "scene.yaml" + p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + return load_scene_yaml(p) + + +def test_check_returns_none_when_depth_is_sufficient(tmp_path): + """十分な max_depth では警告が出ないこと.""" + cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=64) + assert check_truncation_bias(cfg) is None + + +def test_check_returns_warning_when_depth_is_insufficient(tmp_path): + """不足する max_depth では警告文字列が返ること.""" + cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16) + warning = check_truncation_bias(cfg) + assert warning is not None + assert "max_depth" in warning + + +def test_warning_reports_the_estimated_deficit(tmp_path): + """警告文に欠損の見積もり(パーセント)が含まれること. + + 「気づけない故障モード」を顕在化させるのが目的なので, + どれだけ過小評価されるかを数値で示す必要がある. + """ + cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16) + warning = check_truncation_bias(cfg) + assert "8.7%" in warning + + +# --------------------------------------------------------------------------- +# scene_max_reflectance +# --------------------------------------------------------------------------- + + +def test_scene_max_reflectance_picks_brightest_material(tmp_path): + """複数材料のうち最大の反射率が採用されること.""" + cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16) + assert scene_max_reflectance(cfg) == pytest.approx(0.85) diff --git a/tests/scene/test_audit_normals.py b/tests/scene/test_audit_normals.py new file mode 100644 index 0000000..b1288aa --- /dev/null +++ b/tests/scene/test_audit_normals.py @@ -0,0 +1,225 @@ +"""シェーディング法線アーティファクト(TEST_12)の再発防止テスト. + +仕様出典: +- docs/06_TEST/TEST_12_シェーディング法線アーティファクトの発見と修正.md +- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md + +TEST_12 のバグは「法線のない PLY に対して Mitsuba がスムーズ頂点法線を自動生成し, +エッジ上にしか頂点を持たない巨大三角形の平面が約 47 度傾いた法線で描画される」もので, +エラーも警告も出さずに CoV の 6 割超を占める系統誤差を生んだ. + +`scene/builder.py` は PLY shape に `face_normals: True` を渡してこれを防いでいるが, +既存テストはその**キーの存在**しか検証していない.本モジュールは実際に Mitsuba へ +シーンを読み込み,レイを飛ばして**シェーディング法線が幾何法線と一致することを実測**する. + +負のコントロール(`test_..._detects_smooth_normal_contamination`)で, +`face_normals` を外すと本テストが実際に大きな逸脱を検出することも確認している. +検出できない検査は再発防止にならないため,この対照実験を必ず維持すること. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import numpy as np +import pytest +import trimesh +import yaml + +from sm_tias_lightsim.scene.audit import audit_scene_normals +from sm_tias_lightsim.scene.builder import build_scene +from sm_tias_lightsim.scene.schema import load_scene_yaml + +# 箱メッシュは 12 枚の巨大三角形からなり,全 8 頂点が法線の異なる 3 面に共有される. +# TEST_12 の評価板(裏面 26 枚の巨大三角形・内部頂点ゼロ)と同じ病理を最小構成で再現する. +_BOX_SIZE_MM = 100.0 + +# 空洞内部の観測点(m 単位).箱の中心付近から全方向にレイを飛ばす. +_INTERIOR_ORIGINS = np.array([[0.0, 0.0, 0.0], [0.01, 0.0, 0.0], [0.0, 0.01, -0.01]], dtype=float) + +# 面法線が使われていれば逸脱は浮動小数点誤差のみ(実測 0.05 度未満) +_FACE_NORMAL_TOLERANCE_DEG = 0.5 + + +def _build_box_cavity_scene(tmp_path: Path): + """内向き法線の箱(空洞)だけからなる最小シーンを構築する. + + Returns: + (scene_dict, tempdir) のタプル.呼び出し側が tempdir を削除する. + """ + box = trimesh.creation.box(extents=[_BOX_SIZE_MM] * 3) + # 空洞の内壁として使うため面を反転し,法線を内側へ向ける + box.faces = box.faces[:, ::-1] + stl_path = tmp_path / "box.stl" + box.export(str(stl_path)) + + cfg = { + "name": "box_cavity", + "description": "シェーディング法線の再発防止テスト用の最小空洞", + "geometry": { + "meshes": [{"id": "box", "path": str(stl_path), "unit": "mm", "material": "wall"}] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}}, + "lights": [ + { + "id": "src", + "type": "point", + "position": [0.0, 0.0, 0.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, -10.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": 8, "height": 8, "rfilter": "box"}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1}, + } + yaml_path = tmp_path / "scene.yaml" + yaml_path.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + + return build_scene(load_scene_yaml(yaml_path), project_root=tmp_path) + + +def _audit(scene_dict) -> object: + """scene dict を読み込んで法線監査を実行する.""" + import mitsuba as mi + + scene = mi.load_dict(scene_dict) + return audit_scene_normals(scene, _INTERIOR_ORIGINS, rays_per_origin=200, seed=0) + + +def test_shading_normals_match_geometric_normals(tmp_path): + """builder が構築したシーンでシェーディング法線が幾何法線と一致すること. + + 仕様: face_normals=True によりスムーズ頂点法線の自動生成を無効化する(TEST_12 の修正). + """ + scene_dict, tempdir = _build_box_cavity_scene(tmp_path) + try: + result = _audit(scene_dict) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + assert result.hits > 0, "レイが 1 本も命中していない.テストシーンが不正" + assert result.max_shading_normal_deviation_deg < _FACE_NORMAL_TOLERANCE_DEG + + +def test_audit_detects_smooth_normal_contamination(tmp_path): + """face_normals を外すと監査が大きな法線逸脱を検出すること(負のコントロール). + + この対照実験が通らなくなったら,本テスト群は TEST_12 の再発を検出できていない. + """ + scene_dict, tempdir = _build_box_cavity_scene(tmp_path) + try: + del scene_dict["mesh_box"]["face_normals"] + result = _audit(scene_dict) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + # 箱のエッジ頂点における平均化法線は面法線から大きく傾く(TEST_12 では約 47 度) + assert result.max_shading_normal_deviation_deg > 10.0 + + +def test_no_backface_hits_from_cavity_interior(tmp_path): + """空洞内部から見て裏向きの面が無いこと. + + 裏向きの面は片面 BSDF では無警告で真っ黒になる(twosided ラップで救済済みだが, + 裏返り自体はメッシュのモデリング誤りの兆候なので検出する). + """ + scene_dict, tempdir = _build_box_cavity_scene(tmp_path) + try: + result = _audit(scene_dict) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + assert result.backface_hits == 0 + + +def test_audit_detects_flipped_mesh(tmp_path): + """メッシュを裏返すと監査が裏面ヒットを検出すること(負のコントロール).""" + box = trimesh.creation.box(extents=[_BOX_SIZE_MM] * 3) + # 反転しない=法線が外向きのまま=空洞内部から見ると全て裏面 + stl_path = tmp_path / "box_outward.stl" + box.export(str(stl_path)) + + cfg = { + "name": "box_outward", + "description": "裏返りメッシュの検出テスト", + "geometry": { + "meshes": [{"id": "box", "path": str(stl_path), "unit": "mm", "material": "wall"}] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}}, + "lights": [ + { + "id": "src", + "type": "point", + "position": [0.0, 0.0, 0.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, -10.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": 8, "height": 8, "rfilter": "box"}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1}, + } + yaml_path = tmp_path / "scene.yaml" + yaml_path.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + + scene_dict, tempdir = build_scene(load_scene_yaml(yaml_path), project_root=tmp_path) + try: + result = _audit(scene_dict) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + assert result.backface_hits == result.hits + + +@pytest.mark.slow +def test_baseline_scene_has_clean_normals(): + """実運用シーン(configs/scene_baseline.yaml)の法線が健全であること. + + 合成メッシュではなく実際の設計 STL 8 個を読み込んで検証する. + STL の差し替えや builder の変更で法線規約が崩れた場合にここで検出する. + """ + project_root = Path(__file__).resolve().parents[2] / "configs" + scene_yaml = project_root / "scene_baseline.yaml" + if not scene_yaml.exists(): + pytest.skip("基準設計シーンが存在しない環境ではスキップする") + + scene_dict, tempdir = build_scene(load_scene_yaml(scene_yaml), project_root=project_root) + try: + # 空洞内部(LED の z=-66mm と評価板の z=-4.5mm の間)に観測点を配置する + origins = np.array( + [ + [0.0, 0.0, -0.050], + [0.0, 0.0, -0.030], + [0.020, 0.020, -0.040], + [-0.020, -0.020, -0.040], + ], + dtype=float, + ) + result = audit_scene_normals(scene_dict_to_scene(scene_dict), origins, 300, seed=0) + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + assert result.hits > 0 + assert result.max_shading_normal_deviation_deg < _FACE_NORMAL_TOLERANCE_DEG + assert result.backface_hits == 0 + + +def scene_dict_to_scene(scene_dict): + """scene dict を Mitsuba シーンに読み込む(テスト用ヘルパ).""" + import mitsuba as mi + + return mi.load_dict(scene_dict) diff --git a/tests/scene/test_lights_axis_mapping.py b/tests/scene/test_lights_axis_mapping.py new file mode 100644 index 0000000..f18a194 --- /dev/null +++ b/tests/scene/test_lights_axis_mapping.py @@ -0,0 +1,138 @@ +"""面光源のサイズ→世界座標軸マッピングの再発防止テスト. + +仕様出典: +- docs/06_TEST/TEST_08_面光源スケール軸バグの発見と修正.md +- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md + +TEST_08 のバグは `scale([sw / 2.0, 1.0, sh / 2.0])` と,発光面の高さを +縦(Y) ではなく奥行き(Z) に入れていたもの.LED の実寸 2.8 x 3.5 mm が +2.8 x (潰れた厚み) になり,下灯だけ 2.1 倍明るいという不一致を生んだ. +エラーは出ず,しかも TEST_07 でその出力が「物理的に正しい」と一度は正当化された. + +既存の tests/scene/test_lights.py は to_world キーの存在と,position_scale を +変えたときに文字列表現が変わることしか検証しておらず,**軸を入れ替えても検出できない**. +本モジュールは単位図形の頂点を実際に to_world で変換し,世界座標での寸法を検証する. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from sm_tias_lightsim.scene.lights import build_light_dict +from sm_tias_lightsim.scene.schema import AreaLight + +# Mitsuba の rectangle は XY 平面上の ±1 正方形(法線 +Z). +# 発光面は direction=(0,0,-1) で世界座標の XY 平面に載るので, +# 変換後の X / Y 方向の広がりがそのまま実寸になる. +_UNIT_RECTANGLE_CORNERS = [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)] + +_MM_TO_M = 0.001 + + +def _world_extents(light: AreaLight) -> tuple[float, float, float]: + """発光面の単位図形を to_world で変換し,世界座標での XYZ の広がりを返す. + + Args: + light: 検査対象の AreaLight. + + Returns: + (X の広がり, Y の広がり, Z の広がり).単位は m. + """ + import mitsuba as mi + + to_world = build_light_dict(light, position_scale=_MM_TO_M)["to_world"] + points = np.array( + [np.asarray(to_world @ mi.ScalarPoint3f(*c)).reshape(3) for c in _UNIT_RECTANGLE_CORNERS] + ) + return ( + float(np.ptp(points[:, 0])), + float(np.ptp(points[:, 1])), + float(np.ptp(points[:, 2])), + ) + + +def _rectangle(size: tuple[float, float]) -> AreaLight: + """XY 平面に載る矩形発光面を作る(direction は評価板と反対向きの基準設計と同じ).""" + return AreaLight( + id="led", + type="area", + shape="rectangle", + center=(0.0, 0.0, 0.0), + size=size, + direction=(0.0, 0.0, -1.0), + radiance=1.0, + ) + + +def test_size_width_maps_to_world_x_extent(): + """size[0](幅)が世界座標 X 方向の広がりになること. + + 仕様: SPEC_02 の size は [幅, 高さ].TEST_08 で高さが Z に入る取り違えがあった. + """ + extent_x, _, _ = _world_extents(_rectangle((2.8, 3.5))) + assert extent_x == pytest.approx(2.8 * _MM_TO_M) + + +def test_size_height_maps_to_world_y_extent(): + """size[1](高さ)が世界座標 Y 方向の広がりになること. + + これが TEST_08 のバグそのもの(高さが Y ではなく Z に入っていた). + """ + _, extent_y, _ = _world_extents(_rectangle((2.8, 3.5))) + assert extent_y == pytest.approx(3.5 * _MM_TO_M) + + +def test_emitter_has_no_thickness_in_normal_direction(): + """発光面は法線方向(Z)に厚みを持たないこと. + + 高さが Z に入っていると,この広がりが 0 でなくなる. + """ + _, _, extent_z = _world_extents(_rectangle((2.8, 3.5))) + assert extent_z == pytest.approx(0.0, abs=1e-12) + + +def test_swapping_size_swaps_world_extents(): + """size を入れ替えると世界座標の広がりも入れ替わること. + + 軸マッピングが size に実際に追随していることの確認(定数を返す実装を排除する). + """ + x1, y1, _ = _world_extents(_rectangle((2.8, 3.5))) + x2, y2, _ = _world_extents(_rectangle((3.5, 2.8))) + assert x2 == pytest.approx(y1) + assert y2 == pytest.approx(x1) + + +def test_emitter_area_matches_physical_size(): + """発光面の面積が実寸どおりであること. + + 面積は放射束 Phi = pi * L * A を決めるので,ここがずれると + 絶対輝度が系統的にずれる(TEST_08 の「下灯 2.1 倍」の正体). + """ + extent_x, extent_y, _ = _world_extents(_rectangle((2.8, 3.5))) + assert extent_x * extent_y == pytest.approx(2.8 * 3.5 * _MM_TO_M**2) + + +@pytest.mark.parametrize(("width", "height"), [(1.0, 1.0), (2.8, 3.5), (3.5, 2.8), (10.0, 0.5)]) +def test_extents_match_size_for_various_aspect_ratios(width, height): + """様々なアスペクト比で幅・高さが正しく対応すること.""" + extent_x, extent_y, _ = _world_extents(_rectangle((width, height))) + assert extent_x == pytest.approx(width * _MM_TO_M) + assert extent_y == pytest.approx(height * _MM_TO_M) + + +def test_disk_extents_are_the_diameter_on_both_axes(): + """円板発光面は両軸とも直径 2r の広がりを持つこと.""" + light = AreaLight( + id="disk", + type="area", + shape="disk", + center=(0.0, 0.0, 0.0), + radius=5.0, + direction=(0.0, 0.0, -1.0), + radiance=1.0, + ) + # 単位円板の外接正方形(±1)が半径でスケールされる + extent_x, extent_y, _ = _world_extents(light) + assert extent_x == pytest.approx(2 * 5.0 * _MM_TO_M) + assert extent_y == pytest.approx(2 * 5.0 * _MM_TO_M) diff --git a/tests/scene/test_materials_twosided.py b/tests/scene/test_materials_twosided.py new file mode 100644 index 0000000..ac5847c --- /dev/null +++ b/tests/scene/test_materials_twosided.py @@ -0,0 +1,256 @@ +"""scene/materials.py の twosided ラップ仕様のテスト. + +仕様出典: +- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(片面 BSDF による無警告の黒化) + +Mitsuba の diffuse / roughconductor は片面 BSDF であり,法線が裏返ったメッシュ面は +入射光を一切反射せずエラーも警告もなく真っ黒になる.SmTIAS は LED 直接光が評価板に +届かない間接照明のみの構成(SPEC_03)なので,内壁が 1 面でも黒化すると輝度分布が +静かに壊れる.build_scene_bsdf_dict はこれを防ぐため不透明反射材を twosided で包む. + +積分球の解析解による検証(TEST_13): + +| 法線 | BSDF | 解析解比 | +| --- | --- | --- | +| 内向き(正常) | diffuse | 0.9996 | +| 内向き(正常) | twosided | 0.9996(完全一致=無影響) | +| 外向き(裏返り) | diffuse | 0.0000(無警告で真っ黒) | +| 外向き(裏返り) | twosided | 1.0001(救済) | + +透過を伴う誘電体は両面挙動が本質なのでラップしない. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.materials import build_material_dict, build_scene_bsdf_dict +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DielectricMaterial, + DiffuseMaterial, + RoughConductorBSDFConfig, + ThinDielectricMaterial, +) + +# --------------------------------------------------------------------------- +# 不透明反射材は twosided でラップされる +# --------------------------------------------------------------------------- + + +def test_diffuse_is_wrapped_in_twosided(): + """diffuse が twosided で包まれること. + + 片面 BSDF のままだと法線が裏返った面が無警告で真っ黒になるため. + """ + result = build_scene_bsdf_dict(DiffuseMaterial(type="diffuse", reflectance=0.85)) + assert result["type"] == "twosided" + + +def test_wrapped_diffuse_preserves_inner_bsdf(): + """ラップしても内側の BSDF が素の変換結果と同一であること. + + twosided は面の向きの扱いだけを変え,反射特性そのものは変えない. + """ + mat = DiffuseMaterial(type="diffuse", reflectance=(0.1, 0.2, 0.3)) + result = build_scene_bsdf_dict(mat) + assert result["bsdf"] == build_material_dict(mat) + + +def test_roughconductor_is_wrapped_in_twosided(): + """roughconductor も片面 BSDF なので twosided で包まれること.""" + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx") + result = build_scene_bsdf_dict(mat) + assert result["type"] == "twosided" + assert result["bsdf"]["type"] == "roughconductor" + + +def test_blend_of_opaque_components_is_wrapped(): + """全構成要素が不透明な blendbsdf は twosided で包まれること.""" + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.05, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.85), + RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx"), + ], + ) + result = build_scene_bsdf_dict(mat) + assert result["type"] == "twosided" + assert result["bsdf"]["type"] == "blendbsdf" + + +def test_blend_components_are_not_double_wrapped(): + """blendbsdf の構成要素は個別にはラップされないこと(二重ラップ防止).""" + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.05, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.85), + DiffuseMaterial(type="diffuse", reflectance=0.5), + ], + ) + result = build_scene_bsdf_dict(mat) + assert result["bsdf"]["bsdf_0"]["type"] == "diffuse" + assert result["bsdf"]["bsdf_1"]["type"] == "diffuse" + + +# --------------------------------------------------------------------------- +# 誘電体はラップされない +# --------------------------------------------------------------------------- + + +def test_dielectric_is_not_wrapped(): + """dielectric は透過を伴い両面挙動が本質なのでラップされないこと. + + twosided で包むと透過が壊れる. + """ + result = build_scene_bsdf_dict(DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0)) + assert result["type"] == "dielectric" + + +def test_thindielectric_is_not_wrapped(): + """thindielectric もラップされないこと.""" + result = build_scene_bsdf_dict( + ThinDielectricMaterial(type="thindielectric", int_ior=1.5, ext_ior=1.0) + ) + assert result["type"] == "thindielectric" + + +def test_blend_containing_dielectric_is_not_wrapped(): + """透過成分を含む blendbsdf はラップされないこと.""" + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.85), + DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0), + ], + ) + result = build_scene_bsdf_dict(mat) + assert result["type"] == "blendbsdf" + + +# --------------------------------------------------------------------------- +# build_material_dict の後方互換 +# --------------------------------------------------------------------------- + + +def test_build_material_dict_still_returns_bare_bsdf(): + """build_material_dict は素の BSDF を返し続けること(既存 API の後方互換).""" + result = build_material_dict(DiffuseMaterial(type="diffuse", reflectance=0.85)) + assert result["type"] == "diffuse" + + +# --------------------------------------------------------------------------- +# builder への結線 +# --------------------------------------------------------------------------- + + +def test_builder_registers_twosided_materials(tmp_path): + """build_scene がシーンに登録する材料が twosided になっていること. + + build_material_dict を直接使う実装に戻ると片面 BSDF に逆戻りするため, + 結線そのものを検証する. + """ + import shutil + + import yaml + + from sm_tias_lightsim.scene.builder import build_scene + from sm_tias_lightsim.scene.schema import load_scene_yaml + + cfg = { + "name": "t", + "description": "t", + "geometry": { + "meshes": [ + {"id": "m", "path": "fixtures/meshes/sphere.stl", "unit": "mm", "material": "wall"} + ] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}}, + "lights": [ + { + "id": "src", + "type": "point", + "position": [0.0, 0.0, 0.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, -10.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": 8, "height": 8, "rfilter": "box"}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1}, + } + p = tmp_path / "scene.yaml" + p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + + from pathlib import Path + + scene_dict, tempdir = build_scene(load_scene_yaml(p), project_root=Path(__file__).parent.parent) + try: + assert scene_dict["mat_wall"]["type"] == "twosided" + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +def test_builder_keeps_material_id_on_the_wrapper(tmp_path): + """材料 ID が twosided ラッパ側に付くこと(mesh からの ref 参照が解決するため).""" + import shutil + from pathlib import Path + + import yaml + + from sm_tias_lightsim.scene.builder import build_scene + from sm_tias_lightsim.scene.schema import load_scene_yaml + + cfg = { + "name": "t", + "description": "t", + "geometry": { + "meshes": [ + {"id": "m", "path": "fixtures/meshes/sphere.stl", "unit": "mm", "material": "wall"} + ] + }, + "materials": {"wall": {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}}, + "lights": [ + { + "id": "src", + "type": "point", + "position": [0.0, 0.0, 0.0], + "intensity": [1.0, 1.0, 1.0], + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, -10.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 30.0, + "film": {"width": 8, "height": 8, "rfilter": "box"}, + }, + "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 64, "spp": 1}, + } + p = tmp_path / "scene.yaml" + p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8") + + scene_dict, tempdir = build_scene(load_scene_yaml(p), project_root=Path(__file__).parent.parent) + try: + assert scene_dict["mat_wall"]["id"] == "mat_wall" + assert scene_dict["mesh_m"]["bsdf"] == {"type": "ref", "id": "mat_wall"} + finally: + shutil.rmtree(tempdir, ignore_errors=True) + + +@pytest.mark.parametrize("reflectance", [0.0, 0.5, 1.0]) +def test_wrapping_is_independent_of_reflectance_value(reflectance): + """反射率の値によらず一様にラップされること.""" + result = build_scene_bsdf_dict(DiffuseMaterial(type="diffuse", reflectance=reflectance)) + assert result["type"] == "twosided"