diff --git a/CLAUDE.md b/CLAUDE.md index 39e8d5c..11f9717 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ 補足: **docs/05_TECH と docs/06_TEST に README 追加 (2026-06-02)**.15 ファイル (TECH 9 + TEST 6) がフラットに並んで「散らかり感」あるため,性質分類と navigation を README で提供.05_TECH/README: 🟢 現状方針 (TECH_07-09) と 🔵 議論の経緯 (TECH_01-06) に 2 分類,主な結論サマリ表,経緯マップ図.06_TEST/README: 🟡 キャリブ作業の中核 (TEST_01-03) と 🔵 クローズ済み (TEST_04) と 📋 外部スナップショット (TEST_05/06) に 3 分類,3 ファイル使い分け表.ファイル削除・移動はせずナビゲーションのみ追加. 補足: **残差 2.63× の主因候補を [TECH_10](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md) として網羅整理 (2026-06-02)**.Step 8-14 候補選定の判断材料として作成.ユーザー指摘 3 候補 (LED 配光・反射率・反射回数) + 私の追加候補で 5 カテゴリ 18 項目を網羅.**優先度評価: 🥇 1 位 A1 内壁の非 Lambertian 性 (光沢成分混合 BSDF)** - 「形」を変える可能性が物理的に最も高い,3D プリンタ白フィラメントは完全 Lambertian とは限らない / **🥈 2 位 A8 反射率 R=0.85→0.89** - Step 8-6 物理整合点で多重反射ゲイン 1.36× / **🥉 3 位 A9 max_depth 8→16+** - 後段多重反射の取りこぼし可能性.ユーザー認識 (LED 配光が一番影響) との差分: TECH_09 でデータシート Lambertian 整合済を踏まえて優先度を低下.Step 8-14 候補: β (A1 単独) か δ (A1+A8+A9 全部入り) が筋. 補足: **Step 8-14 候補 γ (D1 ROI 整合の再確認) 完了 — クリア (2026-06-02)**.他の物理モデル変更に進む前の事前確認として実施.設定変更ゼロで,既存設定の数値検証のみ.シミュ ROI (24, 37, 464, 450) の物理範囲 (X∈[-45.31, +45.31] mm × Y∈[-46.92, +44.48] mm,幅 90.63×91.41 mm) と実機 ROI (539, 1015, 1400, 1409) の物理範囲 (実機 PNG の評価板黒縁検出から物理分解能 0.0654 mm/px を確定 → 幅 91.54×92.13 mm) を比較した結果,**物理サイズ差 < 1%・中心位置差 < 1 mm で整合**.3 列パネル `output/_slide_panels/roi_alignment_check.png` (①実機画像 + 評価板境界・実機 ROI / ②シミュ輝度マップ + 評価板境界・シミュ ROI / ③物理座標系で重ね合わせ) でも視覚確認.**残差 2.63× の主因は ROI 整合ではない**ことが確定.過去の設定者 (`scripts/select_white_board.py` GUI 選択 + シミュ ortho_scale 設計値) が物理整合性を意識して ROI を揃えていたことが裏付けられた.副次発見: 評価板は実機画像中央から下方向に約 4.2 mm (65 px) オフセット配置されているが (取付位置のミリ精度差),ROI もそのオフセットに沿って選択されているため物理領域として整合.次のアクション: 残候補 β (A1 単独) / α (A8+A9) / δ (β+α 全部入り) を選択して Step 8-14 を実施. +補足: **Step 8-14 候補 β (A1 内壁の非 Lambertian 性) 完了 — クリア (2026-06-02)**.候補リスト 🥇 1 位だった A1 を 3×3 グリッド感度分析で検証.スキーマ拡張 (`BlendBSDFConfig` + `RoughConductorBSDFConfig` 追加,`MaterialConfig` Union 拡張,forward reference + `model_rebuild()`) と `materials.py` 再帰変換 (`_build_bsdf_dict`) を新規実装.テンプレ `configs/scene_baseline_blend.yaml` + スイープスクリプト `scripts/sweep_blend_bsdf.py` + 比較パネルスクリプト `scripts/build_blend_bsdf_panel.py`.新規テスト 89 件追加 (608 → 697 全通過).**weight ∈ {0.05, 0.15, 0.30} × α ∈ {0.1, 0.3, 0.6} の 9 ジョブ実走 (75.5 分)**.結果: ベースライン純 diffuse (CoV=0.1826, 勾配=24.65%, C/P=1.327, 動径 min/max=0.615) と比較して **全 9 パターンが悪化または不変** (最良 w=0.05/α=0.6 で CoV=0.1851,最悪 w=0.30/α=0.1 で CoV=0.2433).実機 (CoV=0.0695) 方向と **逆方向に動く**.「形」は w=0.30 で下端中央に 2 つの specular 集中スポットが顕在化.物理的解釈: 純 Lambertian は cos 重み散乱で多重反射の均一化作用が最大.specular 成分は特定方向に光を集中させ均一化を阻害.**3D プリンタ白フィラメントは完全 Lambertian として扱うのが実機挙動に近い近似** と判明 (TECH_09 LED Lambertian 整合と並ぶ「物理モデル妥当性の裏付け」).**A1 は残差 2.63× の主因ではない**ことが確定.TECH_10 優先順位を更新: A1 を「クリア」化,🥇 1 位 A8 反射率 R=0.89 / 🥈 2 位 A9 max_depth 16+ / 🥉 3 位 B1 幾何精度 に繰り上げ.副次成果: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入り,今後の物理モデル試行で再利用可能.比較パネル `output/_slide_panels/blend_bsdf_sweep.png` + `blend_bsdf_metrics.png`,全 9 ジョブ集約 `output/blend_sweep/summary.csv`.[TECH_10 § A1 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#a1-検証結果2026-06-02-完了) / [TEST_01 § Step 8-14 候補 β](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-β-a1-内壁の非-lambertian-性--混合-bsdf-感度分析) / [SPEC_02 materials](docs/04_SPEC/SPEC_02_シーン定義仕様.md#materials) 参照.次のアクション: Step 8-14 候補 α (A8 R=0.89 + A9 depth=16) の組み合わせ実施. 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -47,10 +48,10 @@ - `src/sm_tias_lightsim/evaluation/oetf.py` — 標準 sRGB OETF(IEC 61966-2-1)+ 露出ソルバ(OETF 後 ROI mean を実測値に対数二分でアンカー)+ 順方向適用.シミュ linear を実測 sRGB ドメインへ変換 - `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 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ).MiniTias-Evaluation 互換スタイル(hot cmap,zone_map は luminance 背景 + 境界線) -- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応 +- `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/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` を追加) +- `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` ネスト対応) - `scripts/select_white_board.py` — 実測 PNG で白色板境界を OpenCV GUI で選択(Step 8 キャリブ用) - `scripts/visualize_roi_3d.py` — 評価板 STL に ROI を 3D 投影(PyVista,Step 8 キャリブ用) - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) diff --git a/configs/scene_baseline_blend.yaml b/configs/scene_baseline_blend.yaml new file mode 100644 index 0000000..903fda8 --- /dev/null +++ b/configs/scene_baseline_blend.yaml @@ -0,0 +1,432 @@ +# Step 8-14 感度分析用: 内壁の非 Lambertian 性(光沢成分混合)テンプレート +# +# scene_baseline.yaml から `printer_white` 材料のみを blendbsdf に変更したもの. +# 他の設定(geometry / lights / sensor / render)は scene_baseline.yaml と完全に同じ. +# +# blendbsdf 構成: +# component[0]: diffuse (reflectance=0.85) ... 純粋 Lambertian 拡散反射 +# component[1]: roughconductor (alpha=0.3) ... GGX マイクロファセット鏡面反射 +# weight=0.15: component[1](鏡面)の混合比 = 15% specular +# +# スイープ対象パラメータ: +# weight ∈ {0.05, 0.15, 0.30} × alpha ∈ {0.1, 0.3, 0.6} の 9 組合せ +# → scripts/sweep_blend_bsdf.py が本ファイルを読んで weight/alpha を書き換えて実行する +# +# 参考: TECH_10 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +name: baseline_minitias_blend +description: > + MiniTIAS 基準設計シーン(Step 8-14 感度分析). + printer_white を diffuse + roughconductor の blendbsdf に変更して + 内壁の光沢成分が輝度分布「形」に与える影響を観察する. + weight=0.15, alpha=0.3 はスイープの中間値(起点). + LED 位置・センサ・レンダリング設定は scene_baseline.yaml と同一. +geometry: + meshes: + - id: housing_front + path: meshes/前側.stl + unit: mm + material: printer_white + - id: housing_back + path: meshes/後側.stl + unit: mm + material: printer_white + - id: phone_holder + path: meshes/スマホホルダ.stl + unit: mm + material: holder_dark + - id: led_cover_bottom + path: meshes/LED基板カバー下.stl + unit: mm + material: printer_white + - id: led_cover_right + path: meshes/LED基板カバー右.stl + unit: mm + material: printer_white + - id: led_cover_left + path: meshes/LED基板カバー左.stl + unit: mm + material: printer_white + - id: phone_body + path: meshes/SH-02M.stl + unit: mm + material: phone_body_dark + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.15 # specular 比(sweep 対象): 0=全 diffuse, 1=全 roughconductor + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.3 # GGX 粗さ(sweep 対象): 0=鏡面, 1=マット + distribution: ggx + holder_dark: + type: diffuse + reflectance: + - 0.5 + - 0.5 + - 0.5 + phone_body_dark: + type: diffuse + reflectance: + - 0.05 + - 0.05 + - 0.05 +lights: +- id: led_bottom_0 + type: area + shape: rectangle + center: + - -10.0 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_1 + type: area + shape: rectangle + center: + - -5.8 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_2 + type: area + shape: rectangle + center: + - -1.6 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_3 + type: area + shape: rectangle + center: + - 9.2 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_4 + type: area + shape: rectangle + center: + - 13.4 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_5 + type: area + shape: rectangle + center: + - 17.6 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_0 + type: area + shape: rectangle + center: + - 65.9987 + - -24.9738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_1 + type: area + shape: rectangle + center: + - 65.9987 + - -20.7738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_2 + type: area + shape: rectangle + center: + - 65.9987 + - -16.5738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_3 + type: area + shape: rectangle + center: + - 65.9987 + - -5.7738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_4 + type: area + shape: rectangle + center: + - 65.9987 + - -1.5738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_5 + type: area + shape: rectangle + center: + - 65.9987 + - 2.6262 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_0 + type: area + shape: rectangle + center: + - -66.0067 + - -32.5952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_1 + type: area + shape: rectangle + center: + - -66.0067 + - -28.3952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_2 + type: area + shape: rectangle + center: + - -66.0067 + - -24.1952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_3 + type: area + shape: rectangle + center: + - -66.0067 + - -13.3952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_4 + type: area + shape: rectangle + center: + - -66.0067 + - -9.1952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_5 + type: area + shape: rectangle + center: + - -66.0067 + - -4.9952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +sensor: + type: orthographic + position: + - 0.0 + - 0.0 + - -50.0 + target: + - 0.0 + - 0.0 + - 0.0 + up: + - 0.0 + - 1.0 + - 0.0 + ortho_scale: + - 50.0 + - 52.0 + film: + width: 512 + height: 512 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 1024 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 a04637f..00885a6 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,15 +60,36 @@ ext_ior: 1.0 ``` +サポート済みの `type`: + +| type | Mitsuba 対応 BSDF | 用途 | 主要パラメータ | +| --- | --- | --- | --- | +| `diffuse` | `diffuse` | Lambertian 反射 | `reflectance: [r, g, b]` (RGB) or スカラ | +| `roughconductor` | `roughconductor` | GGX マイクロファセット鏡面(感度分析用) | `alpha: 0.0〜1.0` (粗さ), `distribution: ggx`/`beckmann`.`material="none"` 固定で eta=k=1 の完全反射体相当 | +| `blendbsdf` | `blendbsdf` | 2 BSDF の線形補間(混合材料モデル) | `weight: 0.0〜1.0` (components[1] の比率), `components: [BSDF, BSDF]` (2 要素必須,再帰ネスト可能) | + サポート予定の `type`: | type | Mitsuba 対応 BSDF | 用途 | | --- | --- | --- | -| `diffuse` | `diffuse` | Lambertian 反射 | | `dielectric` | `dielectric` | 誘電体(厚みあり) | | `thindielectric` | `thindielectric` | 薄膜誘電体(ディフューザー薄板) | | `mask` | `mask` | 透過率マスク | -| 拡張余地 | `roughconductor` 等 | 反射率調整 | + +`blendbsdf` + `roughconductor` の例(Step 8-14 候補 β で導入): + +```yaml +materials: + printer_white_with_specular: + type: blendbsdf + weight: 0.15 # specular 成分の比率 (0=純 diffuse, 1=純 specular) + components: + - type: diffuse + reflectance: [0.85, 0.85, 0.85] + - type: roughconductor + alpha: 0.3 # GGX 粗さ (0=完全鏡面, 1=拡散的鏡面) + distribution: ggx +``` ### lights diff --git "a/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" "b/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" index 22471c3..87a0d7b 100644 --- "a/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" +++ "b/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" @@ -6,14 +6,14 @@ | 順位 | 候補 | 影響度 | 検証コスト | 推奨理由 | | --- | --- | --- | --- | --- | -| **🥇 1 位** | **A1. 内壁の非 Lambertian 性(光沢成分)** | **大の可能性** | 中 | 「形」を変える可能性が物理的に最も高い.3D プリンタ白フィラメントは完全 Lambertian とは限らない | -| **🥈 2 位** | **A8. 反射率 R=0.85 → 0.89(物理整合点)** | 中 | 低 | Step 8-6 で導出済の物理整合点.新方針 (linear+LSC) 下での再評価 | -| **🥉 3 位** | **A9. max_depth 8 → 16+** | 小〜中 | 低 | 後段多重反射の取りこぼし可能性 | +| ~~🥇 1 位~~ | ~~A1. 内壁の非 Lambertian 性(光沢成分)~~ | **✓ クリア** | — | **2026-06-02 検証完了**:3×3 グリッドで全 9 パターンが実機方向に作用しない(むしろ悪化)と判明.純 Lambertian が実機挙動に最も近い.[§ A1 検証結果](#a1-検証結果2026-06-02-完了) 参照 | +| **🥇 1 位** | **A8. 反射率 R=0.85 → 0.89(物理整合点)** | 中 | 低 | Step 8-6 で導出済の物理整合点.新方針 (linear+LSC) 下での再評価.A1 クリアで繰り上げ | +| **🥈 2 位** | **A9. max_depth 8 → 16+** | 小〜中 | 低 | 後段多重反射の取りこぼし可能性.A1 クリアで繰り上げ | +| **🥉 3 位** | **B1. 筐体内部の幾何精度** | 中 | 高 | 3D プリンタの実物理表面 vs 設計表面.A1 クリアで繰り上げ | | ~~4 位~~ | ~~D1. シミュ ROI と実機 ROI の物理的整合~~ | **✓ クリア** | — | **2026-06-02 検証完了**:物理サイズ差 < 1%・中心位置差 < 1 mm で整合.残差主因から除外([§ D1 検証結果](#d1-検証結果2026-06-02-完了) 参照) | -| 4 位 | B1. 筐体内部の幾何精度 | 中 | 高 | 3D プリンタの実物理表面 vs 設計表面 | -| 5 位 | A2/A3. LED 配光の個体差・樹脂ドーム | 小〜中 | 高 | データシート Lambertian 整合済 ([TECH_09](TECH_09_LEDデータシート調査結果.md)) | +| 4 位 | A2/A3. LED 配光の個体差・樹脂ドーム | 小〜中 | 高 | データシート Lambertian 整合済 ([TECH_09](TECH_09_LEDデータシート調査結果.md)) | -→ **「内壁の非 Lambertian 性(光沢成分)」が最有力候補**.次いで反射率精度・反射回数の組み合わせ調整. +→ **2026-06-02 更新**: 当時最有力だった A1 を 3×3 グリッドで検証したところ実機方向に作用しないと判明([§ A1 検証結果](#a1-検証結果2026-06-02-完了)).**現状の最有力は A8 反射率 R=0.89**.次いで A9 max_depth と B1 幾何精度. ## 背景 (Background) @@ -34,7 +34,7 @@ | ID | 候補 | 現状 | 影響度 | 検証状況 | | --- | --- | --- | --- | --- | -| **A1** | **内壁の非 Lambertian 性(光沢成分)** | `diffuse` BSDF(完全 Lambertian) | **★ 大の可能性** | **未検証**.3D プリンタ白フィラメントは微妙に specular 成分を持つことがある.TECH_08 §残差 2.63× で「内壁の非 Lambertian 性・LED パッケージ周辺の光学効果」として示唆済 | +| ~~A1~~ | ~~内壁の非 Lambertian 性(光沢成分)~~ | `diffuse` BSDF(完全 Lambertian) | **✓ クリア** | **2026-06-02 検証完了**:blendbsdf (diffuse + roughconductor) で weight×alpha の 3×3 グリッド全 9 パターンを実走→全パターンが純 diffuse より悪化または不変.[§ A1 検証結果](#a1-検証結果2026-06-02-完了) 参照 | | A2 | LED 個体差の配光ばらつき | 全 LED 完全均一 | 小 | 18 個平均で均される | | A3 | 樹脂ドーム形状の個体差 | データシート Lambertian 仮定 | 小〜中 | 実物の SMD 2835 ドームは樹脂で微妙な前方凸の傾向.データシート整合は [TECH_09](TECH_09_LEDデータシート調査結果.md) で確認 | | A4 | LED 駆動電流のばらつき | 全 LED 完全同じ radiance | 小 | 18 個並列接続で電流分配誤差は通常 ±5% 以内 | @@ -174,6 +174,66 @@ | A2/A3 LED 配光の個体差 | データシート Lambertian 整合済([TECH_09](TECH_09_LEDデータシート調査結果.md)).実物の個体差ばらつきは実測ゴニオフォトメータが必要で高コスト | | E1/E2 環境・運用系 | 新品撮影・通常室温で影響軽微 | +## A1 検証結果(2026-06-02 完了) + +候補リストで当時 🥇 1 位だった **A1「内壁の非 Lambertian 性(光沢成分)」** を Step 8-14 候補 β として **3×3 グリッド感度分析**で検証したところ,**どの組み合わせも実機方向に作用しない(むしろ悪化)**ことが確定した. + +### 検証手順 + +1. **スキーマ拡張**: `src/sm_tias_lightsim/scene/schema.py` に `RoughConductorBSDFConfig` (alpha, distribution) と `BlendBSDFConfig` (weight + components×2,forward reference + `model_rebuild()`) を追加.`MaterialConfig` Union を拡張.後方互換は既存 `scene_baseline.yaml` のスモークテストで確認 +2. **Mitsuba dict 変換拡張**: `src/sm_tias_lightsim/scene/materials.py` の `_build_bsdf_dict` を再帰ヘルパとして切り出し.`blendbsdf` のネスト対応 +3. **テンプレ YAML**: `configs/scene_baseline_blend.yaml` を作成.`printer_white` のみ `blendbsdf` (diffuse R=0.85 + `roughconductor` material="none") に置換 +4. **スイープ実行**: `scripts/sweep_blend_bsdf.py` で `weight ∈ {0.05, 0.15, 0.30}` × `α ∈ {0.1, 0.3, 0.6}` の 9 ジョブを順次 sm-render + sm-evaluate.各 8.4 分 × 9 = 75.5 分.eval は `configs/eval_baseline_dng_target.yaml` (linear + mean アンカー 207.82) +5. **比較パネル**: `scripts/build_blend_bsdf_panel.py` で 3×3 輝度マップ(shared vmin/vmax 1%/99% percentile)+ 数値表 PNG を生成 + +### 結果(CoV / 勾配% / C/P / 動径 min/max) + +ベースライン純 diffuse (w=0): **0.1826 / 24.65 / 1.327 / 0.615** +実機 TARGET: **0.0695 / 8.35 / 1.091 / 0.860** + +| weight \ α | 0.1(鋭い) | 0.3(中) | 0.6(粗い) | +| --- | --- | --- | --- | +| **0.05** | 0.192 / 24.8 / 1.33 / 0.62 | 0.189 / 24.9 / 1.33 / 0.62 | **0.185** / 24.4 / 1.32 / 0.62 ← 最良 | +| **0.15** | 0.213 / 26.0 / 1.35 / 0.61 | 0.204 / 26.6 / 1.36 / 0.60 | 0.193 / 25.5 / 1.34 / 0.61 | +| **0.30** | **0.243** / 26.8 / 1.37 / 0.59 ← 最悪 CoV | **0.227 / 29.0** / 1.41 / **0.57** ← 最悪 形 | 0.205 / 27.0 / 1.37 / 0.59 | + +### 観察された傾向 + +| 軸 | 効果 | +| --- | --- | +| **weight ↑(光沢強化)** | CoV / 勾配 / C/P すべて **単調悪化**(不均一性増) | +| **α ↑(粗さ大)** | weight 固定で **わずか改善**(diffuse に近づくため) | +| **動径 min/max** | specular 強化で **0.615 → 0.570** へ低下(不均一さが目立つ) | + +### 「形」の観察(`output/_slide_panels/blend_bsdf_sweep.png`) + +- **w=0.05 行**: ベースラインに近い Y 軸単調勾配(純 diffuse と区別困難) +- **w=0.15 行**: 下端中央に明るい集中点が現れ始める +- **w=0.30 行**: 下端中央に **2 つの明るいスポット**が顕在化.specular 反射が LED 直下の評価板領域に集中 +- α を上げると specular が「拡散的」になり diffuse に近づくが、実機の中央ピーク楕円パターンには近づかない + +### 物理的解釈 + +純 Lambertian は cos 重み散乱で **多重反射の均一化作用が最大**.specular 成分が混ざると: + +- 内壁での 1 回反射が **特定方向に集中** +- 多重反射ループから外れて評価板の特定エリア(カバー側下端)に集中 +- 多重反射の累積が不均一になり ambient 成分が減る + +3D プリンタ白フィラメントは **完全 Lambertian として扱うのが実機挙動に近い近似** だと判明([TECH_09](TECH_09_LEDデータシート調査結果.md) の LED Lambertian 整合性と並ぶ「物理モデル妥当性の裏付け」). + +### 判定と意味 + +- **A1 は残差 2.63× の主因ではない**ことが確定.候補リストから「クリア」として除外 +- 「最有力候補」だった A1 の除外は **次の候補に焦点を絞れる**正味前進 +- 副次成果: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入った.今後の物理モデル試行で再利用可能 +- 新規テスト 89 件追加(既存 608 → 697 全通過).Pydantic v2 forward reference + 再帰 BSDF 変換の安全網が整備された + +### 副次発見 + +- `roughconductor` の `material="none"` 設定は eta=k=1 の完全反射体として機能する(Mitsuba 3 仕様).感度分析用途で扱いやすい +- weight=0 で純 diffuse に完全一致するため、ベースライン (output/baseline/luminance.npy) を 10 番目のセルとして使い回せる設計が妥当だった + ## D1 検証結果(2026-06-02 完了) 候補リスト 4 位の **D1「シミュ ROI と実機 ROI の物理的整合」** を Step 8-14 候補 γ として **事前検証**したところ,**両者は物理的に同じ領域を見ている**ことが確認できた.**設定変更は一切行わず,既存設定の数値検証のみ**で結論が出た. @@ -212,12 +272,12 @@ | Step 8-14 候補 | 内容 | 状態 | 推奨度 | | --- | --- | --- | --- | -| **Step 8-14 候補 α** | **A8 (R=0.89) + A9 (depth=16) の組み合わせ**(低コスト・既存技術) | 未実施 | ⭐⭐ | -| **Step 8-14 候補 β** | **A1 内壁の非 Lambertian 性(光沢成分混合 BSDF)**(高インパクト・実装中規模) | 未実施 | ⭐⭐⭐ | -| ~~Step 8-14 候補 γ~~ | ~~D1 ROI 整合の再確認~~ | **✓ 2026-06-02 完了(クリア)** | — | -| **Step 8-14 候補 δ** | **β + α 全部**(D1 確認済のため γ 除外.最大改善を狙う・レンダ時間 ~17 分) | 未実施 | ⭐⭐⭐ | +| **Step 8-14 候補 α** | **A8 (R=0.89) + A9 (depth=16) の組み合わせ**(低コスト・既存技術) | 未実施 | ⭐⭐⭐ | +| ~~Step 8-14 候補 β~~ | ~~A1 内壁の非 Lambertian 性(光沢成分混合 BSDF)~~ | **✓ 2026-06-02 完了(クリア — 実機方向に作用しない)** | — | +| ~~Step 8-14 候補 γ~~ | ~~D1 ROI 整合の再確認~~ | **✓ 2026-06-02 完了(クリア — 物理差 < 1%)** | — | +| ~~Step 8-14 候補 δ~~ | ~~β + α 全部~~ | **β クリアで意義消失** | — | -→ **Step 8-14 として β(A1 単独)か δ(β + α 全部入り)が筋**.β は「形」改善の単一実験として価値が高く,δ は実用上「全部やってどうなるか」を見る価値がある. +→ **A1 除外確定により Step 8-14 は α(A8 + A9)が筋**.低コストで既存技術延長.レンダ時間も 1 ジョブ 8 分程度で済む. ## 関連ドキュメント (Related Documents) diff --git "a/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" "b/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" index ed43cf1..685464e 100644 --- "a/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" +++ "b/docs/06_TEST/TEST_01_\343\202\255\343\203\243\343\203\252\343\203\226\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\346\244\234\350\250\274\345\261\245\346\255\264.md" @@ -45,6 +45,8 @@ | **実測 TARGET (新 DNG+LSC linear)** | — | **linear** | **207.82** | **0.0695** | **8.35** | **1.091** | **1.0×** | | **8-13 新方針シミュ突合** | R0.85, d8, spp1024 | **linear** | 207.82* | **0.1826** | **24.65** | **1.327** | **2.63×** | | 8-14 候補 γ (D1) ROI 整合確認 | 変更なし | — | — | — | — | — | **クリア(ROI 物理整合差 < 1%)** | +| 8-14 候補 β (A1) 混合 BSDF 最良 | R0.85 + blendbsdf w0.05 α0.6 | linear | 207.82* | 0.1851 | 24.44 | 1.323 | 残差 2.66× (悪化) | +| 8-14 候補 β (A1) 混合 BSDF 最悪 | R0.85 + blendbsdf w0.30 α0.1 | linear | 207.82* | 0.2433 | 26.81 | 1.366 | **クリア(実機方向に作用しない)** | \* mean アンカー固定.R, d, spp は `reflectance`, `max_depth`, `samples_per_pixel`. @@ -536,6 +538,35 @@ - 設定変更ゼロで結論が出たため,シミュ/実機双方の YAML・コードへの **コミット可能な変更なし**.本記録のみ. - **次のアクション**: 残る候補(β A1 単独 / α A8+A9 / δ β+α 全部入り)を選択して Step 8-14 を実施.[TECH_10 § Step 8-14 候補選定](../05_TECH/TECH_10_残差主因候補の網羅整理.md#step-8-14-候補選定次のアクション) 参照. +### Step 8-14 候補 β: A1 内壁の非 Lambertian 性 — 混合 BSDF 感度分析 + +- **日付**: 2026-06-02 +- **位置付け**: [TECH_10](../05_TECH/TECH_10_残差主因候補の網羅整理.md) で当時 🥇 1 位だった **A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」** の感度分析.3D プリンタ白フィラメント物性整合性は一旦置き,weight × roughness の 2 軸 3×3 グリッドで「形」と数値がどう動くかを観察. +- **仮説**: 実物の 3D プリンタ白フィラメントは完全 Lambertian ではなく微妙な specular 成分を持つ可能性.これが残差 2.63× を説明するなら,weight を上げると CoV/勾配/C/P が改善方向(実機 0.0695 / 8.35% / 1.091 に近づく方向)に動くはず. +- **実装**: スキーマ拡張 (`BlendBSDFConfig` + `RoughConductorBSDFConfig`) + `materials.py` 再帰変換 + テンプレ YAML + sweep スクリプト + 比較パネル生成スクリプト.新規テスト 89 件追加 (既存 608 → 697 全通過). +- **設定**: `configs/scene_baseline_blend.yaml` ベース.`printer_white` を `blendbsdf` (`diffuse` R=0.85 + `roughconductor` material="none") に変更.`weight ∈ {0.05, 0.15, 0.30}` × `α ∈ {0.1, 0.3, 0.6}` の 9 ジョブ × spp=1024 × max_depth=8. +- **結果**(CoV / 勾配% / C/P / 動径 min/max.ベースライン純 diffuse: **0.1826 / 24.65 / 1.327 / 0.615**, 実機: **0.0695 / 8.35 / 1.091 / 0.860**): + +| weight \ α | 0.1(鋭い) | 0.3(中) | 0.6(粗い) | +| --- | --- | --- | --- | +| **0.05** | 0.192 / 24.8 / 1.33 / 0.62 | 0.189 / 24.9 / 1.33 / 0.62 | **0.185** / 24.4 / 1.32 / 0.62 ← 最良 | +| **0.15** | 0.213 / 26.0 / 1.35 / 0.61 | 0.204 / 26.6 / 1.36 / 0.60 | 0.193 / 25.5 / 1.34 / 0.61 | +| **0.30** | **0.243** / 26.8 / 1.37 / 0.59 ← 最悪 CoV | **0.227 / 29.0** / 1.41 / **0.57** ← 最悪 形 | 0.205 / 27.0 / 1.37 / 0.59 | + +→ **どの組み合わせも純 diffuse より悪化または不変**.実機方向 (CoV=0.0695) と **逆方向**に動く. +- **「形」の観察**(`output/_slide_panels/blend_bsdf_sweep.png`): + - w=0.05 行: ベースラインに近い Y 軸単調勾配(純 diffuse と区別困難) + - w=0.15 行: 下端中央に明るい集中点が現れ始める + - **w=0.30 行**: 下端中央に **2 つの明るいスポット**が顕在化.specular 反射が LED 直下の評価板領域に集中 + - α を上げると specular が「拡散的」になり diffuse に近づくが、実機の中央ピーク楕円パターンには近づかない +- **解釈・学び**: + - 純 Lambertian は cos 重み散乱で **多重反射の均一化作用が最大**.specular 成分は特定方向に光を集中させ均一化を阻害する + - 3D プリンタ白フィラメントは **完全 Lambertian として扱うのが実機挙動に近い近似** だと判明([TECH_09](../05_TECH/TECH_09_LEDデータシート調査結果.md) の LED Lambertian 整合性と並ぶ「物理モデル妥当性の裏付け」) + - **A1 は残差 2.63× の主因ではない**ことが確定.TECH_10 優先順位を更新(A1 を「クリア」に,A8/A9/B1 を繰り上げ).[TECH_10 § A1 検証結果](../05_TECH/TECH_10_残差主因候補の網羅整理.md#a1-検証結果2026-06-02-完了) 参照 + - 「最有力候補」だった A1 の除外は **次の候補に焦点を絞れる**正味前進.計算コスト 75.5 分 + 実装 2-3 時間で「やってみないと分からない仮説」を確実に潰せた +- **副次成果**: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入った.今後の物理モデル試行(例:thindielectric 評価板の薄板透過モデル,特殊壁面の光沢追加)でも再利用可能 +- **次のアクション**: A8 (反射率 R=0.85→0.89) を試す Step 8-14 候補 α,または A9 (max_depth 8→16+) を試す案,または両方の組み合わせを実施.[TECH_10 § Step 8-14 候補選定](../05_TECH/TECH_10_残差主因候補の網羅整理.md#step-8-14-候補選定次のアクション) 参照. + ## Step 8-N 候補: LSC 順方向モデル(不採用 — Step 8-13 で方針確定) > **2026-06-02 注記**: 本セクションは当時「実機側を変更しない前提」で「シミュ側に LSC モデル化を追加して残差を埋める」案として立案されていた.[Step 8-13](#step-8-13-新方針-実機-dng--lsc-linear-でのシミュ突合) で **実機側に LSC 順方向適用** するアプローチが MiniTias-Evaluation で確立した結果,**本案は不採用**となった.以下は歴史的記録として残す.詳細な却下理由は [TECH_07 §検討した代替案](../05_TECH/TECH_07_LSC取り扱い方針.md) 参照. 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 6137e0c..bfa9220 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" @@ -32,6 +32,10 @@ | `output/baseline_datasheet/` | 8-11 データシート従順版 | `configs/scene_baseline_datasheet.yaml`(spot cutoff=85/beam=26.72, I_0=0.0220 W/sr,反射率 0.89, データシート規格値準拠) | `configs/eval_baseline_linear.yaml` (linear + normalization) | 146.71 / 0.1829 / 26.79 / 1.366 | TEST_01 Step 8-11 検証.データシート準拠は物理正確性のみ達成,形は LSC が依然必要.α=14.30 | | `output/perspective_path_hi/` | 8-12 Helmholtz 検証 | `configs/scene_baseline_perspective_path.yaml`(perspective fov=90°, path, scalar_rgb, spp=4096) | (評価なし,検証目的) | mean=160, bot/top=2.035 | TEST_01 Step 8-12 検証.方向独立性の基準(バックワード) | | `output/perspective_ptracer_hi/` | 8-12 Helmholtz 検証 | `configs/scene_baseline_perspective_ptracer.yaml`(perspective fov=90°, ptracer, scalar_rgb, spp=4096) | (評価なし,検証目的) | mean=319, bot/top=2.006 | TEST_01 Step 8-12 検証.方向独立性の対象(フォワード).形は path と一致(bot/top 比 誤差 1.4%) | +| `output/blend_sweep/w{w}_a{a}/` (9 ディレクトリ) | 8-14 候補 β | `configs/scene_baseline_blend.yaml` を `scripts/sweep_blend_bsdf.py` で `printer_white.weight` ∈ {0.05, 0.15, 0.30} × `components[1].alpha` ∈ {0.1, 0.3, 0.6} に書き換えた 9 種 (R=0.85 + roughconductor material=none, d=8, spp=1024) | `configs/eval_baseline_dng_target.yaml` (linear + mean アンカー 207.82) | 9 ジョブ分の CoV/勾配 — [TEST_01 § Step 8-14 候補 β 結果表](TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-β-a1-内壁の非-lambertian-性--混合-bsdf-感度分析) 参照 | TEST_01 Step 8-14 候補 β 検証.A1 が実機方向に作用しないことを確定 | +| `output/blend_sweep/summary.csv` | 8-14 候補 β | — | — | 9 ジョブ集約 CSV (weight, alpha, elapsed_s + 12 指標カラム) | `scripts/sweep_blend_bsdf.py` の集約出力.`scripts/build_blend_bsdf_panel.py` の入力 | +| `output/_slide_panels/blend_bsdf_sweep.png` | 8-14 候補 β | — | — | 3×3 輝度マップ比較パネル (shared vmin/vmax 1%/99% percentile) | TEST_01 Step 8-14 β「形」追跡用 | +| `output/_slide_panels/blend_bsdf_metrics.png` | 8-14 候補 β | — | — | 9 ジョブの数値表 (CoV/勾配/C/P/動径 min/max) | TEST_01 Step 8-14 β 数値追跡用 | ### sweep_summary.csv の値(参考、TEST_01 line と一致確認用) @@ -63,6 +67,8 @@ | baseline_datasheet 評価 | `uv run sm-evaluate --image output/baseline_datasheet/luminance.npy --config configs/eval_baseline_linear.yaml --output output/baseline_datasheet/` | | perspective_path_hi レンダ (Step 8-12 検証) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/scene_baseline_perspective_path.yaml --output output/perspective_path_hi/` | | perspective_ptracer_hi レンダ (Step 8-12 検証) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/scene_baseline_perspective_ptracer.yaml --output output/perspective_ptracer_hi/` | +| blend BSDF 3×3 スイープ (Step 8-14 β) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sweep_blend_bsdf.py` (9 ジョブ × 約 8.4 分 = 75.5 分) | +| blend BSDF 比較パネル生成 | `uv run python scripts/build_blend_bsdf_panel.py` (要 `output/blend_sweep/summary.csv` + 各 `luminance.npy`) | ## 過去の整理実績 (Cleanup History) diff --git a/scripts/build_blend_bsdf_panel.py b/scripts/build_blend_bsdf_panel.py new file mode 100644 index 0000000..f0a4c4a --- /dev/null +++ b/scripts/build_blend_bsdf_panel.py @@ -0,0 +1,418 @@ +"""Step 8-14 blendbsdf スイープ結果の比較パネルを生成する. + +sweep_blend_bsdf.py の実行後に本スクリプトを実行すること. + +生成するファイル: + output/_slide_panels/blend_bsdf_sweep.png -- 3×3 輝度マップパネル + output/_slide_panels/blend_bsdf_metrics.png -- 数値指標表パネル + +共有 vmin/vmax: + 全 9 ジョブ + ベースライン純 diffuse の輝度マップを対象として + 1%/99% パーセンタイルを計算し,全セルで共通スケールを使う. + これにより「色の濃淡が画像間で物理的に同じ意味を持つ」. + +使い方: + uv run python scripts/build_blend_bsdf_panel.py + +前提: + - scripts/sweep_blend_bsdf.py が完了していること + - output/blend_sweep/summary.csv が存在すること + - output/baseline/luminance.npy が存在すること(ベースライン) + +TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +""" + +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor + +# --------------------------------------------------------------------------- +# 定数 +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] + +SUMMARY_CSV = ROOT / "output" / "blend_sweep" / "summary.csv" +OUTPUT_DIR = ROOT / "output" / "_slide_panels" +BASELINE_NPY = ROOT / "output" / "baseline" / "luminance.npy" +BLEND_OUTPUT_BASE = ROOT / "output" / "blend_sweep" + +# ROI 設定(configs/eval_baseline.yaml / eval_baseline_dng_target.yaml と整合) +SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450} + +# mean 正規化の target_mean(eval_baseline_dng_target.yaml の target_mean と整合) +TARGET_MEAN = 207.82 + +# 共有 colorbar のパーセンタイル +VMIN_PERCENTILE = 1.0 +VMAX_PERCENTILE = 99.0 + +# スイープグリッドの定義順(行列インデックスの順序) +WEIGHTS = [0.05, 0.15, 0.30] +ALPHAS = [0.1, 0.3, 0.6] + +# 数値指標テーブルで表示するカラムとラベル +METRICS_DISPLAY = [ + ("cov", "CoV"), + ("gradient_magnitude", "gradient [%]"), + ("center_periphery_ratio", "C/P"), + ("radial_min_max_ratio", "radial min/max"), +] + + +# --------------------------------------------------------------------------- +# ヘルパ関数 +# --------------------------------------------------------------------------- + + +def _format_param(value: float) -> str: + """パラメータ値をファイル名に使える文字列に変換する.""" + return str(value) + + +def _make_job_name(weight: float, alpha: float) -> str: + """ジョブ識別子を生成する(sweep_blend_bsdf.py と同一フォーマット).""" + return f"w{_format_param(weight)}_a{_format_param(alpha)}" + + +def _extract_roi(arr: np.ndarray, roi: dict) -> np.ndarray: + """ROI 矩形を切り出して float64 に変換する.""" + x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"] + return arr[y: y + h, x: x + w].astype(np.float64) + + +def _rec709_luma(rgb: np.ndarray) -> np.ndarray: + """RGB 配列を Rec.709 luma に変換する (H, W, 3) → (H, W).""" + return 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + + +def _load_npy_roi(npy_path: Path) -> np.ndarray: + """luminance.npy → ROI 抽出 → mean アンカー正規化 → float64 配列. + + Args: + npy_path: luminance.npy のパス. + + Returns: + ROI 正規化済み輝度配列(H × W, float64). + + Raises: + FileNotFoundError: ファイルが存在しない場合. + """ + if not npy_path.exists(): + msg = f"luminance.npy が見つかりません: {npy_path}" + raise FileNotFoundError(msg) + + arr = np.load(npy_path) + if arr.ndim == 3: + arr = _rec709_luma(arr) + roi = _extract_roi(arr, SIM_ROI) + normalized, _ = apply_mean_anchor(roi, TARGET_MEAN) + return normalized + + +def _read_summary_csv(csv_path: Path) -> list[dict]: + """summary.csv を読み込んで辞書のリストを返す. + + Args: + csv_path: summary.csv のパス. + + Returns: + 各行の辞書リスト. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + """ + if not csv_path.exists(): + msg = f"summary.csv が見つかりません: {csv_path}" + raise FileNotFoundError(msg) + + with csv_path.open(encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + return list(reader) + + +def _build_job_index(rows: list[dict]) -> dict[str, dict]: + """summary CSV の行リストを job 名をキーとした辞書に変換する. + + Args: + rows: _read_summary_csv の返り値. + + Returns: + job 名 → 行辞書のマッピング. + """ + return {row["job"]: row for row in rows} + + +# --------------------------------------------------------------------------- +# パネル描画 +# --------------------------------------------------------------------------- + + +def _build_luminance_panel( + job_images: dict[str, np.ndarray], + baseline_roi: np.ndarray, + job_metrics: dict[str, dict], + output_path: Path, +) -> None: + """3×3 輝度マップパネルを生成・保存する. + + 行 = weight, 列 = alpha の 3×3 グリッドで各ジョブの輝度マップを描画する. + 各セルのタイトルに weight/alpha と CoV/勾配 を併記する. + + Args: + job_images: job 名 → 正規化済み ROI 輝度配列. + baseline_roi: ベースライン(純 diffuse)の ROI 輝度配列. + job_metrics: job 名 → metrics 辞書. + output_path: 保存先 PNG パス. + """ + # 共有 vmin/vmax: 全 9 ジョブ + ベースラインの 1%/99% パーセンタイル + all_vals = np.concatenate( + [baseline_roi.ravel()] + + [img.ravel() for img in job_images.values()] + ) + vmin = float(np.percentile(all_vals, VMIN_PERCENTILE)) + vmax = float(np.percentile(all_vals, VMAX_PERCENTILE)) + + n_rows = len(WEIGHTS) + n_cols = len(ALPHAS) + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=(n_cols * 6, n_rows * 5.5), + constrained_layout=True, + ) + + for row_idx, weight in enumerate(WEIGHTS): + for col_idx, alpha in enumerate(ALPHAS): + ax = axes[row_idx][col_idx] + job_name = _make_job_name(weight, alpha) + + if job_name in job_images: + roi = job_images[job_name] + m = job_metrics.get(job_name, {}) + cov_str = f"{float(m.get('cov', 0)):.4f}" if m.get("cov") else "N/A" + grad_val = m.get("gradient_magnitude") + grad_str = f"{float(grad_val):.2f}%" if grad_val else "N/A" + ax.imshow(roi, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + title = f"w={weight}, α={alpha}\nCoV={cov_str} grad={grad_str}" + else: + # ジョブが失敗していた場合は空セル + ax.set_facecolor("0.15") + title = f"w={weight}, α={alpha}\n[MISSING]" + + ax.set_title(title, fontsize=9, pad=4) + ax.set_axis_off() + + # 共有 colorbar + fig.colorbar( + plt.cm.ScalarMappable( + norm=plt.Normalize(vmin=vmin, vmax=vmax), cmap="hot" + ), + ax=axes, + fraction=0.015, + pad=0.02, + label="Luminance (mean-anchored)", + ) + + fig.suptitle( + f"Step 8-14 blend BSDF sweep — luminance maps\n" + f"shared vmin/vmax = [{vmin:.2f}, {vmax:.2f}] (1%–99% of all 9 jobs + baseline)\n" + f"rows=weight (specular ratio), cols=alpha (GGX roughness)", + fontsize=10, + y=1.01, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"輝度マップパネル保存: {output_path}") + + +def _build_metrics_panel( + job_metrics: dict[str, dict], + output_path: Path, +) -> None: + """数値指標を 3×3 グリッドで表示するパネルを生成・保存する. + + 各セルに CoV / 勾配 / C/P / 動径 min/max を表示する. + 数値の大小が視覚的にわかるよう,セルの背景色を列ごとの最小値に基づいてカラーマップする. + + Args: + job_metrics: job 名 → metrics 辞書. + output_path: 保存先 PNG パス. + """ + n_rows = len(WEIGHTS) + n_cols = len(ALPHAS) + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=(n_cols * 3.5, n_rows * 3.0), + constrained_layout=True, + ) + + # 各指標の全 9 ジョブにわたる値域を計算(背景色正規化用) + metric_vals: dict[str, list[float]] = {col: [] for col, _ in METRICS_DISPLAY} + for weight in WEIGHTS: + for alpha in ALPHAS: + job_name = _make_job_name(weight, alpha) + m = job_metrics.get(job_name, {}) + for col, _ in METRICS_DISPLAY: + try: + metric_vals[col].append(float(m[col])) + except (KeyError, ValueError, TypeError): + pass + + metric_ranges: dict[str, tuple[float, float]] = {} + for col, vals in metric_vals.items(): + if vals: + metric_ranges[col] = (min(vals), max(vals)) + else: + metric_ranges[col] = (0.0, 1.0) + + for row_idx, weight in enumerate(WEIGHTS): + for col_idx, alpha in enumerate(ALPHAS): + ax = axes[row_idx][col_idx] + job_name = _make_job_name(weight, alpha) + m = job_metrics.get(job_name, {}) + + lines = [f"w={weight} α={alpha}"] + for metric_key, label in METRICS_DISPLAY: + try: + val = float(m[metric_key]) + lines.append(f"{label}: {val:.4f}") + except (KeyError, ValueError, TypeError): + lines.append(f"{label}: N/A") + + text = "\n".join(lines) + ax.text( + 0.5, 0.5, text, + ha="center", va="center", + fontsize=8.5, + transform=ax.transAxes, + family="monospace", + ) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_axis_off() + + # セル枠線を追加する + for spine in ax.spines.values(): + spine.set_visible(True) + spine.set_linewidth(0.8) + spine.set_color("0.4") + + # 行ラベル(weight) + for row_idx, weight in enumerate(WEIGHTS): + axes[row_idx][0].set_ylabel(f"w={weight}", fontsize=9, rotation=0, labelpad=40, va="center") + + # 列ラベル(alpha) + for col_idx, alpha in enumerate(ALPHAS): + axes[0][col_idx].set_title(f"α={alpha}", fontsize=9, pad=6) + + fig.suptitle( + "Step 8-14 blend BSDF sweep — metrics summary\n" + "rows=weight (specular ratio), cols=alpha (GGX roughness)", + fontsize=10, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"指標テーブルパネル保存: {output_path}") + + +# --------------------------------------------------------------------------- +# メイン処理 +# --------------------------------------------------------------------------- + + +def main() -> int: + """パネル生成のメインエントリポイント. + + Returns: + 終了コード(成功なら 0,失敗なら 1). + """ + # summary.csv の読み込み + try: + summary_rows = _read_summary_csv(SUMMARY_CSV) + except FileNotFoundError as exc: + print(f"エラー: {exc}", file=sys.stderr) + print("先に scripts/sweep_blend_bsdf.py を実行してください.", file=sys.stderr) + return 1 + + job_metrics = _build_job_index(summary_rows) + print(f"summary.csv 読み込み完了: {len(summary_rows)} ジョブ") + + # ベースライン(純 diffuse)の輝度マップを読み込む + if not BASELINE_NPY.exists(): + print(f"警告: ベースライン luminance.npy が見つかりません: {BASELINE_NPY}", file=sys.stderr) + print(" ベースラインなしでパネルを生成します.", file=sys.stderr) + baseline_roi = None + else: + baseline_roi = _load_npy_roi(BASELINE_NPY) + print(f"ベースライン読み込み完了: {BASELINE_NPY}") + + # 各ジョブの輝度マップを読み込む + job_images: dict[str, np.ndarray] = {} + missing_jobs: list[str] = [] + + for weight in WEIGHTS: + for alpha in ALPHAS: + job_name = _make_job_name(weight, alpha) + npy_path = BLEND_OUTPUT_BASE / job_name / "luminance.npy" + try: + job_images[job_name] = _load_npy_roi(npy_path) + except FileNotFoundError: + print(f" 警告: {job_name} の luminance.npy が見つかりません ({npy_path})") + missing_jobs.append(job_name) + + print(f"輝度マップ読み込み: {len(job_images)} 成功, {len(missing_jobs)} 不足") + if missing_jobs: + print(f" 不足ジョブ: {', '.join(missing_jobs)}") + + if not job_images: + print("エラー: 有効な輝度マップが 1 件もありません.", file=sys.stderr) + return 1 + + # ベースラインが存在しない場合は最初のジョブで代替する + if baseline_roi is None: + first_roi = next(iter(job_images.values())) + baseline_roi = first_roi + print(" ベースライン代替: 最初のジョブの ROI を使用します.") + + # パネル描画 + try: + _build_luminance_panel( + job_images=job_images, + baseline_roi=baseline_roi, + job_metrics=job_metrics, + output_path=OUTPUT_DIR / "blend_bsdf_sweep.png", + ) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 輝度マップパネルの生成に失敗しました — {exc}", file=sys.stderr) + return 1 + + try: + _build_metrics_panel( + job_metrics=job_metrics, + output_path=OUTPUT_DIR / "blend_bsdf_metrics.png", + ) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 指標テーブルパネルの生成に失敗しました — {exc}", file=sys.stderr) + return 1 + + print("\n=== パネル生成完了 ===") + print(f" 輝度マップ: {OUTPUT_DIR / 'blend_bsdf_sweep.png'}") + print(f" 指標テーブル: {OUTPUT_DIR / 'blend_bsdf_metrics.png'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_blend_bsdf.py b/scripts/sweep_blend_bsdf.py new file mode 100644 index 0000000..33e076f --- /dev/null +++ b/scripts/sweep_blend_bsdf.py @@ -0,0 +1,319 @@ +"""Step 8-14 感度分析: blendbsdf weight × alpha の 3×3 グリッドスイープ. + +内壁の非 Lambertian 性(光沢成分混合)が輝度分布「形」と数値指標に +与える影響を観察する感度分析スクリプト. + +スイープグリッド: + weight ∈ {0.05, 0.15, 0.30} (specular 混合比) + alpha ∈ {0.1, 0.3, 0.6 } (GGX 粗さ) + → 9 組合せ × (sm-render + sm-evaluate) を順次実行 + +実行方法: + uv run python scripts/sweep_blend_bsdf.py + +前提条件: + - configs/scene_baseline_blend.yaml が存在すること + - configs/eval_baseline_dng_target.yaml が存在すること + - sm-render / sm-evaluate が PATH に通っていること + +出力: + - output/blend_sweep/w{w}_a{a}/luminance.npy (各ジョブのレンダリング結果) + - output/blend_sweep/w{w}_a{a}/metrics.csv (各ジョブの評価指標) + - output/blend_sweep/summary.csv (全 9 ジョブの集約結果) + - configs/_tmp_blend/scene_w{w}_a{a}.yaml (一時 YAML,実行後も残す) + +TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +""" + +from __future__ import annotations + +import csv +import subprocess +import sys +import time +from pathlib import Path + +import yaml + +# --------------------------------------------------------------------------- +# 定数 +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] + +TEMPLATE_YAML = ROOT / "configs" / "scene_baseline_blend.yaml" +EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_blend" +OUTPUT_BASE = ROOT / "output" / "blend_sweep" + +# スイープグリッド +WEIGHTS = [0.05, 0.15, 0.30] +ALPHAS = [0.1, 0.3, 0.6] + +# 集約 CSV のカラム定義(metrics.csv に存在するキー) +SUMMARY_EXTRA_COLS = [ + "cov", + "std", + "max_min_ratio", + "mean", + "max", + "min", + "center_periphery_ratio", + "gradient_magnitude", + "radial_min_max_ratio", + "radial_min_distance", + "radial_min", + "radial_max", +] + + +# --------------------------------------------------------------------------- +# ヘルパ関数 +# --------------------------------------------------------------------------- + + +def _format_param(value: float) -> str: + """パラメータ値をファイル名に使える文字列に変換する. + + 例: 0.05 → "0.05", 0.3 → "0.3" + + Args: + value: float のパラメータ値. + + Returns: + ファイル名に使える文字列. + """ + return str(value) + + +def _make_job_name(weight: float, alpha: float) -> str: + """ジョブ識別子を生成する. + + Args: + weight: blendbsdf の weight 値. + alpha: roughconductor の alpha 値. + + Returns: + ジョブ識別子文字列(例: "w0.05_a0.1"). + """ + return f"w{_format_param(weight)}_a{_format_param(alpha)}" + + +def _generate_tmp_yaml(weight: float, alpha: float) -> Path: + """一時 YAML ファイルを生成して返す. + + scene_baseline_blend.yaml のテンプレートを読み込み, + materials.printer_white.weight と components[1].alpha を書き換えて + 一時 YAML として保存する. + + Note: + UTF-8 BOM なしで書き込む(Mitsuba YAML パーサは BOM 付きで失敗するため). + + Args: + weight: blendbsdf の weight 値(specular 混合比). + alpha: roughconductor の alpha 値(GGX 粗さ). + + Returns: + 生成した一時 YAML ファイルのパス. + """ + TMP_DIR.mkdir(parents=True, exist_ok=True) + + with TEMPLATE_YAML.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # geometry.meshes の STL 相対パスを絶対化(一時 YAML の配置場所が変わっても解決可能にする) + template_base = TEMPLATE_YAML.parent + for item in data.get("geometry", {}).get("meshes", []): + rel_path = item.get("path") + if rel_path and not Path(rel_path).is_absolute(): + item["path"] = str((template_base / rel_path).resolve()) + + # printer_white の blendbsdf パラメータを書き換える + printer_white = data["materials"]["printer_white"] + printer_white["weight"] = weight + # components[1] が roughconductor(スキーマ順: [0]=diffuse, [1]=roughconductor) + printer_white["components"][1]["alpha"] = alpha + + # name を識別しやすい値に更新する + job_name = _make_job_name(weight, alpha) + data["name"] = f"baseline_minitias_blend_{job_name}" + + tmp_path = TMP_DIR / f"scene_{job_name}.yaml" + with tmp_path.open("w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + + return tmp_path + + +def _run_command(cmd: list[str], label: str) -> int: + """外部コマンドを実行して終了コードを返す. + + Args: + cmd: 実行するコマンドリスト. + label: ログ出力用のラベル. + + Returns: + 終了コード(0 = 成功). + """ + print(f" [run] {' '.join(cmd)}") + result = subprocess.run(cmd, check=False) # noqa: S603 + if result.returncode != 0: + print(f" [ERROR] {label} failed (returncode={result.returncode})", file=sys.stderr) + return result.returncode + + +def _read_metrics_csv(csv_path: Path) -> dict: + """metrics.csv を読み込んで辞書を返す. + + Args: + csv_path: 読み込む metrics.csv のパス. + + Returns: + 指標名 → 値の辞書.1 行目のヘッダ + 2 行目の値を読む. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: ファイルが空または形式不正の場合. + """ + if not csv_path.exists(): + msg = f"metrics.csv が見つかりません: {csv_path}" + raise FileNotFoundError(msg) + + with csv_path.open(encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + rows = list(reader) + + if not rows: + msg = f"metrics.csv が空です: {csv_path}" + raise ValueError(msg) + + return rows[0] + + +# --------------------------------------------------------------------------- +# メイン処理 +# --------------------------------------------------------------------------- + + +def main() -> int: + """スイープのメインエントリポイント. + + Returns: + 終了コード(全ジョブ成功なら 0,失敗があれば 1). + """ + # 前提ファイルの確認 + if not TEMPLATE_YAML.exists(): + print(f"エラー: テンプレート YAML が見つかりません: {TEMPLATE_YAML}", file=sys.stderr) + return 1 + if not EVAL_CONFIG_YAML.exists(): + print(f"エラー: 評価 config YAML が見つかりません: {EVAL_CONFIG_YAML}", file=sys.stderr) + return 1 + + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # ジョブリスト生成 + jobs = [(w, a) for w in WEIGHTS for a in ALPHAS] + total = len(jobs) + print(f"=== blend BSDF sweep: {total} jobs ({len(WEIGHTS)} weights × {len(ALPHAS)} alphas) ===") + print(f" weights: {WEIGHTS}") + print(f" alphas: {ALPHAS}") + print(f" output: {OUTPUT_BASE}") + print() + + failed_jobs: list[str] = [] + summary_rows: list[dict] = [] + total_start = time.time() + + for job_idx, (weight, alpha) in enumerate(jobs, start=1): + job_name = _make_job_name(weight, alpha) + output_dir = OUTPUT_BASE / job_name + metrics_csv = output_dir / "metrics.csv" + + print(f"--- Job {job_idx}/{total}: {job_name} ---") + job_start = time.time() + + # 一時 YAML 生成 + tmp_yaml = _generate_tmp_yaml(weight, alpha) + print(f" tmp YAML: {tmp_yaml}") + + # sm-render 実行 + render_ret = _run_command( + ["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)], + label=f"sm-render ({job_name})", + ) + if render_ret != 0: + failed_jobs.append(f"{job_name} [render]") + print(" -> スキップ: render 失敗\n") + continue + + # sm-evaluate 実行 + npy_path = output_dir / "luminance.npy" + evaluate_ret = _run_command( + [ + "sm-evaluate", + "--image", str(npy_path), + "--config", str(EVAL_CONFIG_YAML), + "--output", str(output_dir), + ], + label=f"sm-evaluate ({job_name})", + ) + if evaluate_ret != 0: + failed_jobs.append(f"{job_name} [evaluate]") + print(" -> スキップ: evaluate 失敗\n") + continue + + # metrics 読み込み + try: + metrics = _read_metrics_csv(metrics_csv) + except (FileNotFoundError, ValueError) as exc: + print(f" [ERROR] metrics.csv 読み込み失敗: {exc}", file=sys.stderr) + failed_jobs.append(f"{job_name} [metrics_read]") + print() + continue + + elapsed = time.time() - job_start + print(f" -> 完了 ({elapsed:.1f}s)") + cov_val = metrics.get("cov", "N/A") + grad_val = metrics.get("gradient_magnitude", "N/A") + print(f" CoV={cov_val}, gradient={grad_val}%") + + row: dict = { + "job": job_name, + "weight": weight, + "alpha": alpha, + "elapsed_s": f"{elapsed:.1f}", + } + for col in SUMMARY_EXTRA_COLS: + row[col] = metrics.get(col, "") + summary_rows.append(row) + print() + + # 集約 CSV 保存 + summary_csv = OUTPUT_BASE / "summary.csv" + if summary_rows: + fieldnames = ["job", "weight", "alpha", "elapsed_s"] + SUMMARY_EXTRA_COLS + with summary_csv.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(summary_rows) + print(f"集約 CSV 保存: {summary_csv}") + else: + print( + "警告: 成功したジョブが 0 件のため summary.csv を保存しませんでした.", + file=sys.stderr, + ) + + total_elapsed = time.time() - total_start + print(f"\n=== スイープ完了: {len(summary_rows)}/{total} 成功, 総時間 {total_elapsed:.1f}s ===") + + if failed_jobs: + print(f"失敗ジョブ ({len(failed_jobs)}件):", file=sys.stderr) + for j in failed_jobs: + print(f" - {j}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sm_tias_lightsim/scene/materials.py b/src/sm_tias_lightsim/scene/materials.py index 6c2241b..857cce9 100644 --- a/src/sm_tias_lightsim/scene/materials.py +++ b/src/sm_tias_lightsim/scene/materials.py @@ -3,9 +3,11 @@ from __future__ import annotations from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, DielectricMaterial, DiffuseMaterial, MaterialConfig, + RoughConductorBSDFConfig, ThinDielectricMaterial, ) @@ -27,17 +29,17 @@ return list(value) -def build_material_dict(material: MaterialConfig) -> dict: - """1 MaterialConfig を Mitsuba BSDF dict に変換する. +def _build_bsdf_dict(material: object) -> dict: + """1 つの BSDF config を Mitsuba BSDF dict に再帰変換する(内部ヘルパ). + + BlendBSDFConfig の components は再帰的に変換する. Args: - material: SceneConfig.materials の値(DiffuseMaterial 等). + material: DiffuseMaterial / DielectricMaterial / ThinDielectricMaterial / + RoughConductorBSDFConfig / BlendBSDFConfig のいずれか. Returns: Mitsuba BSDF dict. - - diffuse → {"type": "diffuse", "reflectance": {"type":"rgb","value":[r,g,b]}} - - dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} - - thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} Raises: ValueError: 未対応の材料タイプが指定された場合. @@ -65,5 +67,44 @@ "ext_ior": material.ext_ior, } + if isinstance(material, RoughConductorBSDFConfig): + # material="none" で eta/k なし(完全反射体)として扱う + return { + "type": "roughconductor", + "alpha": material.alpha, + "distribution": material.distribution, + "material": "none", + } + + if isinstance(material, BlendBSDFConfig): + # components は必ず 2 要素(スキーマで min_length=2, max_length=2 を保証) + return { + "type": "blendbsdf", + "weight": material.weight, + "bsdf_0": _build_bsdf_dict(material.components[0]), + "bsdf_1": _build_bsdf_dict(material.components[1]), + } + msg = f"Unsupported material type: {type(material).__name__}" raise ValueError(msg) + + +def build_material_dict(material: MaterialConfig) -> dict: + """1 MaterialConfig を Mitsuba BSDF dict に変換する. + + Args: + material: SceneConfig.materials の値(DiffuseMaterial / BlendBSDFConfig 等). + + Returns: + Mitsuba BSDF dict. + - diffuse → {"type": "diffuse", "reflectance": {"type":"rgb","value":[r,g,b]}} + - dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} + - thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} + - roughconductor → {"type": "roughconductor", "alpha": ..., "distribution": ..., + "material": "none"} + - blendbsdf → {"type": "blendbsdf", "weight": ..., "bsdf_0": ..., "bsdf_1": ...} + + Raises: + ValueError: 未対応の材料タイプが指定された場合. + """ + return _build_bsdf_dict(material) diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index 4054431..f033be7 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -131,9 +131,69 @@ type: Literal["thindielectric"] +class RoughConductorBSDFConfig(_FrozenBase): + """マイクロファセット鏡面反射材料(完全導体近似). + + Mitsuba 3 の ``roughconductor`` BSDF に対応する. + ``material="none"`` で eta/k なし(完全反射体)として扱い, + 内壁の光沢成分を表現する感度分析に使用する. + """ + + type: Literal["roughconductor"] + alpha: float = Field( + ..., ge=0.0, le=1.0, description="マイクロファセット粗さ(0=鏡面,1=マット)" + ) + distribution: Literal["ggx", "beckmann"] = Field( + default="ggx", description="マイクロファセット法線分布(ggx または beckmann)" + ) + + +class BlendBSDFConfig(_FrozenBase): + """2 つの BSDF を線形補間するブレンド材料. + + Mitsuba 3 の ``blendbsdf`` に対応する. + ``weight=0`` で component[0] のみ,``weight=1`` で component[1] のみ有効. + 内壁の diffuse(拡散)と roughconductor(光沢)を weight でブレンドする + 感度分析に使用する. + + 再帰的な入れ子も可能だが,感度分析では 2 要素のフラット構造を想定する. + """ + + type: Literal["blendbsdf"] + weight: float = Field( + ..., + ge=0.0, + le=1.0, + description="component[1] の混合比(0=全 component[0],1=全 component[1])", + ) + components: list[ + Annotated[ + DiffuseMaterial + | DielectricMaterial + | ThinDielectricMaterial + | RoughConductorBSDFConfig + | BlendBSDFConfig, + Field(discriminator="type"), + ] + ] = Field( + ..., + min_length=2, + max_length=2, + description="ブレンドする 2 つの BSDF([component0, component1])", + ) + + +# 再帰型の forward reference を解決する +BlendBSDFConfig.model_rebuild() + + # type フィールドで識別される discriminated union MaterialConfig = Annotated[ - DiffuseMaterial | DielectricMaterial | ThinDielectricMaterial, + DiffuseMaterial + | DielectricMaterial + | ThinDielectricMaterial + | RoughConductorBSDFConfig + | BlendBSDFConfig, Field(discriminator="type"), ] diff --git a/tests/scene/test_materials_blend_bsdf.py b/tests/scene/test_materials_blend_bsdf.py new file mode 100644 index 0000000..2e6b6b1 --- /dev/null +++ b/tests/scene/test_materials_blend_bsdf.py @@ -0,0 +1,392 @@ +"""scene/materials.py の blendbsdf / roughconductor 変換仕様ベーステスト. + +仕様出典: 実装サマリー §D +- D1: DiffuseMaterial → Mitsuba dict (既存仕様・後方互換確認) +- D2: RoughConductorBSDFConfig → Mitsuba dict +- D3: BlendBSDFConfig (flat) → Mitsuba dict +- D4: ネスト BlendBSDFConfig → 再帰変換 +- D5: build_material_dict 公開 API の後方互換 + +build_material_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.materials import build_material_dict +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DiffuseMaterial, + RoughConductorBSDFConfig, +) + + +# --------------------------------------------------------------------------- +# D1: DiffuseMaterial → Mitsuba dict(既存仕様の後方互換確認) +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictDiffuse: + """D1 系: DiffuseMaterial の dict 変換(既存仕様確認).""" + + def test_diffuse_type_in_dict(self): + """DiffuseMaterial の dict type が "diffuse" であること. + + 仕様 D1: DiffuseMaterial → {type: "diffuse", reflectance: {type: "rgb", value: [r,g,b]}} + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["type"] == "diffuse" + + def test_diffuse_reflectance_type_is_rgb(self): + """DiffuseMaterial の reflectance dict に type="rgb" が含まれること. + + 仕様 D1: reflectance sub-dict に type="rgb" キー + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["reflectance"]["type"] == "rgb" + + def test_diffuse_scalar_reflectance_expands_to_rgb(self): + """スカラ reflectance が RGB 3 要素リストに展開されること. + + 仕様 D1: value: [r, g, b] + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.85) + result = build_material_dict(mat) + assert result["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + def test_diffuse_rgb_reflectance_preserved(self): + """RGB reflectance タプルがそのまま 3 要素リストに変換されること. + + 仕様 D1: RGB 指定時も value: [r, g, b] 形式 + """ + mat = DiffuseMaterial(type="diffuse", reflectance=(0.85, 0.85, 0.85)) + result = build_material_dict(mat) + assert result["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + +# --------------------------------------------------------------------------- +# D2: RoughConductorBSDFConfig → Mitsuba dict +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictRoughConductor: + """D2 系: RoughConductorBSDFConfig の dict 変換仕様.""" + + def test_roughconductor_type_in_dict(self): + """RoughConductorBSDFConfig の dict type が "roughconductor" であること. + + 仕様 D2: {type: "roughconductor", ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["type"] == "roughconductor" + + def test_roughconductor_alpha_in_dict(self): + """alpha 値が dict に格納されること. + + 仕様 D2: {alpha: 0.3, ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(0.3) + + def test_roughconductor_distribution_in_dict(self): + """distribution 値が dict に格納されること. + + 仕様 D2: {distribution: "ggx", ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["distribution"] == "ggx" + + def test_roughconductor_material_none_in_dict(self): + """material キーが "none" で格納されること(完全反射体・eta/k なし). + + 仕様 D2: {material: "none"} は固定 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["material"] == "none" + + def test_roughconductor_alpha_edge_zero(self): + """alpha=0.0(鏡面)が dict に正しく格納されること. + + 仕様 D2: alpha 端値の変換 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.0) + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(0.0) + + def test_roughconductor_alpha_edge_one(self): + """alpha=1.0(マット)が dict に正しく格納されること. + + 仕様 D2: alpha 端値の変換 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=1.0) + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(1.0) + + def test_roughconductor_beckmann_distribution_in_dict(self): + """distribution="beckmann" が dict に格納されること. + + 仕様 D2: distribution は指定値がそのまま格納される + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.5, distribution="beckmann") + result = build_material_dict(mat) + assert result["distribution"] == "beckmann" + + def test_roughconductor_no_reflectance_key(self): + """roughconductor の dict に reflectance キーが含まれないこと. + + 仕様 D2: roughconductor は reflectance ではなく alpha/distribution + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + result = build_material_dict(mat) + assert "reflectance" not in result + + +# --------------------------------------------------------------------------- +# D3: BlendBSDFConfig (flat) → Mitsuba dict +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBlendFlat: + """D3 系: BlendBSDFConfig(フラット 2 要素)の dict 変換仕様.""" + + def _make_cfg(self, weight: float = 0.15, alpha: float = 0.3) -> BlendBSDFConfig: + return BlendBSDFConfig( + type="blendbsdf", + weight=weight, + components=[ + DiffuseMaterial(type="diffuse", reflectance=(0.85, 0.85, 0.85)), + RoughConductorBSDFConfig(type="roughconductor", alpha=alpha), + ], + ) + + def test_blendbsdf_type_in_dict(self): + """BlendBSDFConfig の dict type が "blendbsdf" であること. + + 仕様 D3: {type: "blendbsdf", ...} + """ + result = build_material_dict(self._make_cfg()) + assert result["type"] == "blendbsdf" + + def test_blendbsdf_weight_in_dict(self): + """weight が dict に格納されること. + + 仕様 D3: {weight: 0.15, ...} + """ + result = build_material_dict(self._make_cfg(weight=0.15)) + assert result["weight"] == pytest.approx(0.15) + + def test_blendbsdf_has_bsdf_0_key(self): + """dict に bsdf_0 キーが存在すること. + + 仕様 D3: {bsdf_0: , bsdf_1: } + """ + result = build_material_dict(self._make_cfg()) + assert "bsdf_0" in result + + def test_blendbsdf_has_bsdf_1_key(self): + """dict に bsdf_1 キーが存在すること. + + 仕様 D3: {bsdf_0: , bsdf_1: } + """ + result = build_material_dict(self._make_cfg()) + assert "bsdf_1" in result + + def test_blendbsdf_bsdf_0_is_diffuse_dict(self): + """bsdf_0 が diffuse の dict であること(component[0] = DiffuseMaterial). + + 仕様 D3: bsdf_0 = + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_0"]["type"] == "diffuse" + + def test_blendbsdf_bsdf_1_is_roughconductor_dict(self): + """bsdf_1 が roughconductor の dict であること(component[1] = RoughConductorBSDFConfig). + + 仕様 D3: bsdf_1 = + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_1"]["type"] == "roughconductor" + + def test_blendbsdf_bsdf_0_reflectance_value(self): + """bsdf_0(diffuse)の reflectance.value が正しいこと. + + 仕様 D3: 子 bsdf_0 の内容は DiffuseMaterial 変換と同等 + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_0"]["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + def test_blendbsdf_bsdf_1_alpha_value(self): + """bsdf_1(roughconductor)の alpha が正しいこと. + + 仕様 D3: 子 bsdf_1 の内容は RoughConductorBSDFConfig 変換と同等 + """ + result = build_material_dict(self._make_cfg(alpha=0.3)) + assert result["bsdf_1"]["alpha"] == pytest.approx(0.3) + + def test_blendbsdf_bsdf_1_material_is_none(self): + """bsdf_1(roughconductor)の material が "none" であること. + + 仕様 D3: 子 roughconductor の material="none" が再帰変換で保持される + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_1"]["material"] == "none" + + def test_blendbsdf_no_components_key_in_dict(self): + """dict に "components" キーが含まれないこと(Mitsuba は bsdf_0/bsdf_1 形式). + + 仕様 D3: Mitsuba 形式は bsdf_0/bsdf_1 で components キーは不要 + """ + result = build_material_dict(self._make_cfg()) + assert "components" not in result + + def test_blendbsdf_weight_zero(self): + """weight=0.0 が dict に正しく格納されること. + + 仕様 D3: weight 端値の変換 + """ + result = build_material_dict(self._make_cfg(weight=0.0)) + assert result["weight"] == pytest.approx(0.0) + + def test_blendbsdf_weight_one(self): + """weight=1.0 が dict に正しく格納されること. + + 仕様 D3: weight 端値の変換 + """ + result = build_material_dict(self._make_cfg(weight=1.0)) + assert result["weight"] == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# D4: ネスト BlendBSDFConfig → 再帰変換 +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBlendNested: + """D4 系: ネスト BlendBSDFConfig の再帰変換仕様.""" + + def _make_nested_cfg(self) -> BlendBSDFConfig: + """BlendBSDFConfig の components[0] にさらに BlendBSDFConfig を持つ 2 段ネスト.""" + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.9), + RoughConductorBSDFConfig(type="roughconductor", alpha=0.1), + ], + ) + return BlendBSDFConfig( + type="blendbsdf", + weight=0.05, + components=[ + inner, + DiffuseMaterial(type="diffuse", reflectance=0.5), + ], + ) + + def test_nested_bsdf_0_is_blendbsdf_dict(self): + """ネスト時の bsdf_0 が blendbsdf の dict であること. + + 仕様 D4: ネスト BlendBSDFConfig も再帰的に正しく変換される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["type"] == "blendbsdf" + + def test_nested_bsdf_0_weight_preserved(self): + """ネストした bsdf_0(blendbsdf)の weight が正しいこと. + + 仕様 D4: 内部 BlendBSDFConfig の weight が再帰変換で保持される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["weight"] == pytest.approx(0.3) + + def test_nested_bsdf_0_inner_bsdf_0_type(self): + """ネストした bsdf_0.bsdf_0 が diffuse であること. + + 仕様 D4: 2 段再帰が正しく動作する + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_0"]["type"] == "diffuse" + + def test_nested_bsdf_0_inner_bsdf_1_type(self): + """ネストした bsdf_0.bsdf_1 が roughconductor であること. + + 仕様 D4: 2 段再帰が正しく動作する + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_1"]["type"] == "roughconductor" + + def test_nested_bsdf_0_inner_bsdf_1_alpha(self): + """ネストした bsdf_0.bsdf_1(roughconductor)の alpha が正しいこと. + + 仕様 D4: 深いネストの alpha 値も保持される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_1"]["alpha"] == pytest.approx(0.1) + + def test_nested_outer_weight_preserved(self): + """外側 BlendBSDFConfig の weight が正しいこと. + + 仕様 D4: 外側の weight は内側の再帰と独立 + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["weight"] == pytest.approx(0.05) + + def test_nested_bsdf_1_is_diffuse_dict(self): + """外側の bsdf_1 が diffuse の dict であること. + + 仕様 D4: 外側の非ネスト component は通常通り変換される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_1"]["type"] == "diffuse" + + +# --------------------------------------------------------------------------- +# D5: build_material_dict 公開 API の後方互換 +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBackwardCompat: + """D5 系: build_material_dict の公開 API 後方互換仕様.""" + + def test_existing_diffuse_material_still_works(self): + """既存の DiffuseMaterial 入力が従来通り動作すること. + + 仕様 D5: 公開 API シグネチャは破壊しない + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["type"] == "diffuse" + assert result["reflectance"]["type"] == "rgb" + assert "value" in result["reflectance"] + + def test_public_api_accepts_roughconductor(self): + """公開 API が RoughConductorBSDFConfig を受け付けること. + + 仕様 D5: 公開 API シグネチャは新型式も受け付ける + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + result = build_material_dict(mat) + assert result["type"] == "roughconductor" + + def test_public_api_accepts_blend_bsdf(self): + """公開 API が BlendBSDFConfig を受け付けること. + + 仕様 D5: 公開 API シグネチャは新型式も受け付ける + """ + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.85), + RoughConductorBSDFConfig(type="roughconductor", alpha=0.3), + ], + ) + result = build_material_dict(mat) + assert result["type"] == "blendbsdf" diff --git a/tests/scene/test_schema_blend_bsdf.py b/tests/scene/test_schema_blend_bsdf.py new file mode 100644 index 0000000..1ccfc3d --- /dev/null +++ b/tests/scene/test_schema_blend_bsdf.py @@ -0,0 +1,561 @@ +"""RoughConductorBSDFConfig / BlendBSDFConfig スキーマの仕様ベーステスト. + +仕様出典: 実装サマリー §A〜§C +- A: RoughConductorBSDFConfig (Pydantic スキーマ) +- B: BlendBSDFConfig (Pydantic スキーマ) +- C: MaterialConfig Union (既存 DiffuseMaterial との並列・後方互換) +""" + +from __future__ import annotations + +import copy + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DiffuseMaterial, + RoughConductorBSDFConfig, + SceneConfig, + load_scene_yaml, +) + +# --------------------------------------------------------------------------- +# A: RoughConductorBSDFConfig +# --------------------------------------------------------------------------- + + +class TestRoughConductorBSDFConfig: + """A 系: RoughConductorBSDFConfig のバリデーション仕様.""" + + # A1: 正常生成 + def test_valid_construction_with_type_roughconductor(self): + """type="roughconductor" で正常にインスタンスを生成できること. + + 仕様 A1: type: "roughconductor" で正常生成 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + assert cfg.type == "roughconductor" + assert cfg.alpha == pytest.approx(0.3) + + def test_invalid_type_raises_validation_error(self): + """type に "diffuse" 等の他値を指定すると ValidationError になること. + + 仕様 A1: type: "diffuse" 等の他値で ValidationError + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="diffuse", alpha=0.3) # type: ignore[arg-type] + + def test_invalid_type_other_string_raises_validation_error(self): + """type に "blendbsdf" を指定すると ValidationError になること. + + 仕様 A1: type: "blendbsdf" でも ValidationError + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="blendbsdf", alpha=0.3) # type: ignore[arg-type] + + # A2: alpha の範囲制約 + def test_alpha_zero_is_valid(self): + """alpha=0.0(鏡面)が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.0) + assert cfg.alpha == pytest.approx(0.0) + + def test_alpha_one_is_valid(self): + """alpha=1.0(マット)が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=1.0) + assert cfg.alpha == pytest.approx(1.0) + + def test_alpha_intermediate_is_valid(self): + """alpha=0.5 が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.5) + assert cfg.alpha == pytest.approx(0.5) + + def test_alpha_negative_raises_validation_error(self): + """alpha=-0.01(負値)は ValidationError になること. + + 仕様 A2: 負値でエラー + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=-0.01) + + def test_alpha_exceeds_one_raises_validation_error(self): + """alpha=1.01(1超)は ValidationError になること. + + 仕様 A2: 1超でエラー + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=1.01) + + # A3: distribution の許可値 + def test_distribution_ggx_is_valid(self): + """distribution="ggx" が許可されること. + + 仕様 A3: distribution は "ggx" または "beckmann" のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + assert cfg.distribution == "ggx" + + def test_distribution_beckmann_is_valid(self): + """distribution="beckmann" が許可されること. + + 仕様 A3: distribution は "ggx" または "beckmann" のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="beckmann") + assert cfg.distribution == "beckmann" + + def test_distribution_default_is_ggx(self): + """distribution を省略するとデフォルト "ggx" になること. + + 仕様 A3: distribution デフォルトは "ggx" + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + assert cfg.distribution == "ggx" + + def test_distribution_invalid_raises_validation_error(self): + """distribution に未知の値を指定すると ValidationError になること. + + 仕様 A3: "ggx" / "beckmann" 以外は許可しない + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="phong") # type: ignore[arg-type] + + # A4: frozen (immutable) + def test_frozen_raises_on_attribute_assignment(self): + """frozen=True であり属性への直接代入で例外が発生すること. + + 仕様 A4: クラスは frozen (immutable) + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + with pytest.raises((ValidationError, AttributeError)): + cfg.alpha = 0.9 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# B: BlendBSDFConfig +# --------------------------------------------------------------------------- + + +class TestBlendBSDFConfig: + """B 系: BlendBSDFConfig のバリデーション仕様.""" + + def _make_diffuse(self, reflectance: float = 0.85) -> DiffuseMaterial: + return DiffuseMaterial(type="diffuse", reflectance=reflectance) + + def _make_roughconductor(self, alpha: float = 0.3) -> RoughConductorBSDFConfig: + return RoughConductorBSDFConfig(type="roughconductor", alpha=alpha) + + # B1: 正常生成 + def test_valid_construction_with_type_blendbsdf(self): + """type="blendbsdf" で正常にインスタンスを生成できること. + + 仕様 B1: type: "blendbsdf" で正常生成 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.type == "blendbsdf" + assert cfg.weight == pytest.approx(0.15) + + # B2: weight の範囲制約 + def test_weight_zero_is_valid(self): + """weight=0.0 が許可されること(全 component[0]). + + 仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.0, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.weight == pytest.approx(0.0) + + def test_weight_one_is_valid(self): + """weight=1.0 が許可されること(全 component[1]). + + 仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=1.0, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.weight == pytest.approx(1.0) + + def test_weight_negative_raises_validation_error(self): + """weight=-0.01(負値)は ValidationError になること. + + 仕様 B2: 負値でエラー + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=-0.01, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + + def test_weight_exceeds_one_raises_validation_error(self): + """weight=1.01(1超)は ValidationError になること. + + 仕様 B2: 1超でエラー + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=1.01, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + + # B3: components の長さ制約 + def test_components_exactly_two_required(self): + """components がちょうど 2 要素の場合に正常生成できること. + + 仕様 B3: components はちょうど 2 要素必須 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert len(cfg.components) == 2 + + def test_components_one_element_raises_validation_error(self): + """components が 1 要素では ValidationError になること. + + 仕様 B3: 1 要素は ValidationError + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse()], + ) + + def test_components_three_elements_raises_validation_error(self): + """components が 3 要素以上では ValidationError になること. + + 仕様 B3: 3 要素以上は ValidationError + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[ + self._make_diffuse(), + self._make_roughconductor(), + self._make_diffuse(), + ], + ) + + def test_components_empty_raises_validation_error(self): + """components が空リストでは ValidationError になること. + + 仕様 B3: 2 要素必須 + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[], + ) + + # B4: discriminated union の分岐 + def test_components_discriminated_by_type_field_diffuse(self): + """components[0] が type="diffuse" で DiffuseMaterial として識別されること. + + 仕様 B4: type フィールドで discriminated union に分岐 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert isinstance(cfg.components[0], DiffuseMaterial) + + def test_components_discriminated_by_type_field_roughconductor(self): + """components[1] が type="roughconductor" で RoughConductorBSDFConfig として識別されること. + + 仕様 B4: type フィールドで discriminated union に分岐 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert isinstance(cfg.components[1], RoughConductorBSDFConfig) + + # B5: 再帰的な入れ子 + def test_nested_blend_bsdf_in_components(self): + """components の 1 つに BlendBSDFConfig をネストして正常生成できること. + + 仕様 B5: 再帰: components の 1 つに BlendBSDFConfig をネストして正常生成可能 + """ + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[self._make_diffuse(0.8), self._make_roughconductor(0.1)], + ) + outer = BlendBSDFConfig( + type="blendbsdf", + weight=0.1, + components=[inner, self._make_diffuse(0.9)], + ) + assert isinstance(outer.components[0], BlendBSDFConfig) + assert outer.components[0].weight == pytest.approx(0.3) + + def test_nested_blend_bsdf_components_accessible(self): + """ネストした BlendBSDFConfig の components に正常にアクセスできること. + + 仕様 B5: 再帰的な入れ子が正常動作 + """ + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[self._make_diffuse(), self._make_roughconductor(0.5)], + ) + outer = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), inner], + ) + nested = outer.components[1] + assert isinstance(nested, BlendBSDFConfig) + assert isinstance(nested.components[1], RoughConductorBSDFConfig) + assert nested.components[1].alpha == pytest.approx(0.5) + + # B6: frozen (immutable) + def test_frozen_raises_on_weight_assignment(self): + """frozen=True であり weight への直接代入で例外が発生すること. + + 仕様 B6: クラスは frozen + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + with pytest.raises((ValidationError, AttributeError)): + cfg.weight = 0.9 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# C: MaterialConfig Union(既存との並列・後方互換) +# --------------------------------------------------------------------------- + +# scene_baseline.yaml の最小相当 dict(diffuse のみ) +_MINIMAL_DIFFUSE_DICT = { + "name": "test_scene", + "geometry": { + "meshes": [ + { + "id": "housing", + "path": "configs/meshes/housing.stl", + "unit": "mm", + "material": "mat_a", + } + ] + }, + "materials": { + "mat_a": {"type": "diffuse", "reflectance": 0.5}, + }, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 50.0], + "intensity": 1.0, + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 100.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 512, "height": 512}, + }, + "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256}, +} + +# scene_baseline_blend.yaml 相当の最小 dict(blendbsdf を含む) +_BLEND_DICT = { + "name": "test_blend", + "geometry": { + "meshes": [ + { + "id": "housing", + "path": "configs/meshes/housing.stl", + "unit": "mm", + "material": "printer_white", + } + ] + }, + "materials": { + "printer_white": { + "type": "blendbsdf", + "weight": 0.15, + "components": [ + {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}, + {"type": "roughconductor", "alpha": 0.3, "distribution": "ggx"}, + ], + } + }, + "lights": [ + { + "id": "led", + "type": "area", + "shape": "rectangle", + "center": [0.0, 0.0, 10.0], + "size": [2.8, 3.5], + "direction": [0.0, 0.0, -1.0], + "radiance": 100.0, + } + ], + "sensor": { + "type": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 512, "height": 512}, + }, + "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256}, +} + + +class TestMaterialConfigUnion: + """C 系: MaterialConfig Union の仕様(後方互換・新型式).""" + + def test_backward_compatible_diffuse_only_yaml_parses(self): + """既存の type: diffuse のみの YAML が新スキーマで読めること(後方互換). + + 仕様 C1: 既存の type: diffuse のみの YAML が新スキーマで読める (後方互換) + """ + d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT) + config = SceneConfig.model_validate(d) + assert config.name == "test_scene" + mat = config.materials["mat_a"] + assert isinstance(mat, DiffuseMaterial) + + def test_diffuse_material_type_preserved_in_backward_compat(self): + """後方互換で読み込んだ DiffuseMaterial の type が "diffuse" であること. + + 仕様 C1: 既存マテリアルが正しい型で取得できること + """ + d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["mat_a"] + assert mat.type == "diffuse" + + def test_blend_bsdf_yaml_parses_successfully(self): + """blendbsdf を含む YAML が SceneConfig.model_validate で読み込み成功すること. + + 仕様 C3: scene_baseline_blend.yaml 相当を読み込んで BlendBSDFConfig インスタンスが得られる + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + assert config.name == "test_blend" + + def test_printer_white_is_blend_bsdf_instance(self): + """printer_white が BlendBSDFConfig インスタンスとして取得できること. + + 仕様 C3: printer_white が BlendBSDFConfig インスタンスとして得られる + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + + def test_printer_white_weight_preserved(self): + """printer_white.weight が正しく読み込まれること. + + 仕様 C3: scene_baseline_blend.yaml の weight=0.15 が保持される + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert mat.weight == pytest.approx(0.15) + + def test_printer_white_components_component0_is_diffuse(self): + """printer_white.components[0] が DiffuseMaterial であること. + + 仕様 C3: components[0] = diffuse (reflectance=0.85) + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert isinstance(mat.components[0], DiffuseMaterial) + + def test_printer_white_components_component1_is_roughconductor(self): + """printer_white.components[1] が RoughConductorBSDFConfig であること. + + 仕様 C3: components[1] = roughconductor (alpha=0.3) + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert isinstance(mat.components[1], RoughConductorBSDFConfig) + assert mat.components[1].alpha == pytest.approx(0.3) + + def test_scene_baseline_yaml_loads_without_blend(self, tmp_path): + """scene_baseline.yaml 相当(diffuse のみ)が SceneConfig で正常読み込みできること. + + 仕様 C2: scene_baseline.yaml を SceneConfig.model_validate で読み込み成功 + """ + yaml_text = """\ +name: test_baseline_compat +geometry: + meshes: + - id: housing + path: some/path.stl + unit: mm + material: printer_white +materials: + printer_white: + type: diffuse + reflectance: 0.85 +lights: + - id: led_0 + type: area + shape: rectangle + center: [0.0, 0.0, 10.0] + size: [2.8, 3.5] + direction: [0.0, 0.0, -1.0] + radiance: 100.0 +sensor: + type: orthographic + position: [0.0, 0.0, -50.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + ortho_scale: [50.0, 52.0] + film: + width: 512 + height: 512 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 256 +""" + yaml_file = tmp_path / "scene_compat.yaml" + yaml_file.write_text(yaml_text, encoding="utf-8") + config = load_scene_yaml(yaml_file) + assert config.name == "test_baseline_compat" + mat = config.materials["printer_white"] + assert isinstance(mat, DiffuseMaterial) diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/scripts/__init__.py diff --git a/tests/scripts/test_sweep_blend_bsdf.py b/tests/scripts/test_sweep_blend_bsdf.py new file mode 100644 index 0000000..dd5a692 --- /dev/null +++ b/tests/scripts/test_sweep_blend_bsdf.py @@ -0,0 +1,363 @@ +"""scripts/sweep_blend_bsdf.py の _generate_tmp_yaml 仕様ベーステスト. + +仕様出典: 実装サマリー §E +- E1: weight/alpha パラメータが一時 YAML に正しく書き込まれること +- E2: geometry.meshes の STL パスが絶対パスに変換されること +- E3: 一時 YAML が BOM なし UTF-8 で書かれること +- E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式に更新されること + +テスト戦略: +- subprocess(sm-render 実行)は含まない._generate_tmp_yaml の純変換ロジックのみをテストする +- 実際の TEMPLATE_YAML(configs/scene_baseline_blend.yaml)を使用する +- 出力先は tmp_path フィクスチャ(実ファイル)を使用する(GUIDE_08 ファイル I/O はモックしない方針) +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType +from unittest import mock + +import pytest +import yaml + +# --------------------------------------------------------------------------- +# sweep_blend_bsdf モジュールをインポートするヘルパ +# --------------------------------------------------------------------------- + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPT_PATH = _ROOT / "scripts" / "sweep_blend_bsdf.py" + +_TEMPLATE_YAML = _ROOT / "configs" / "scene_baseline_blend.yaml" + + +def _load_sweep_module() -> ModuleType: + """sweep_blend_bsdf.py を直接インポートする. + + scripts/ は通常のパッケージではないため importlib.util で直接ロードする. + モジュールキャッシュに登録して再 import を防ぐ. + """ + import importlib.util + + spec = importlib.util.spec_from_file_location("sweep_blend_bsdf", _SCRIPT_PATH) + assert spec is not None, f"スクリプトが見つかりません: {_SCRIPT_PATH}" + module = importlib.util.module_from_spec(spec) + # モジュールキャッシュに登録(2回目以降の import を安全にする) + sys.modules["sweep_blend_bsdf"] = module + assert spec.loader is not None + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +@pytest.fixture(scope="module") +def sweep_module() -> ModuleType: + """sweep_blend_bsdf モジュールを返すフィクスチャ(モジュールスコープ).""" + return _load_sweep_module() + + +# --------------------------------------------------------------------------- +# テンプレート YAML の存在確認(前提条件チェック) +# --------------------------------------------------------------------------- + + +def test_template_yaml_exists(): + """configs/scene_baseline_blend.yaml が存在すること(テストの前提条件). + + このファイルがなければ E 系テスト全体が無効になる. + """ + assert _TEMPLATE_YAML.exists(), ( + f"テンプレート YAML が見つかりません: {_TEMPLATE_YAML}\n" + "configs/scene_baseline_blend.yaml を生成してからテストを実行してください." + ) + + +# --------------------------------------------------------------------------- +# E1: weight/alpha が一時 YAML に正しく書き込まれる +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlParameters: + """E1 系: weight と alpha が一時 YAML の正しいパスに書き込まれること.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替えて一時 YAML の出力先をテスト用にする.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_weight_written_to_printer_white(self, sweep_module): + """生成された YAML の materials.printer_white.weight が入力値 0.15 と一致すること. + + 仕様 E1: weight=0.15 が一時 YAML の printer_white.weight に書き込まれる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.15) + + def test_alpha_written_to_roughconductor_component(self, sweep_module): + """生成された YAML の components[1].alpha が入力値 0.3 と一致すること. + + 仕様 E1: alpha=0.3 が components[1].alpha に書き込まれる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.3) + + def test_weight_0_05_written_correctly(self, sweep_module): + """weight=0.05 が正しく書き込まれること(スイープ最小値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.05) + + def test_alpha_0_6_written_correctly(self, sweep_module): + """alpha=0.6 が正しく書き込まれること(スイープ最大値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.6) + + def test_weight_0_30_written_correctly(self, sweep_module): + """weight=0.30 が正しく書き込まれること(スイープ最大値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.30) + + def test_component0_type_still_diffuse_after_write(self, sweep_module): + """components[0].type が "diffuse" のまま保持されること(破壊的書き換えがないこと). + + 仕様 E1: alpha 書き換えは components[1] のみに影響する + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][0]["type"] == "diffuse" + + def test_component1_type_still_roughconductor_after_write(self, sweep_module): + """components[1].type が "roughconductor" のまま保持されること. + + 仕様 E1: alpha 書き換えは値のみで type フィールドは変更しない + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["type"] == "roughconductor" + + +# --------------------------------------------------------------------------- +# E2: geometry.meshes の path が絶対パスに変換されること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlAbsolutePaths: + """E2 系: geometry.meshes の STL パスが絶対パスに変換されること.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_all_mesh_paths_are_absolute(self, sweep_module): + """全メッシュの path が絶対パスになっていること. + + 仕様 E2: 一時 YAML の geometry.meshes の各 path が絶対パスに変換されている + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + assert len(meshes) > 0, "meshes が空のため検証不可" + for mesh in meshes: + p = Path(mesh["path"]) + assert p.is_absolute(), f"mesh '{mesh.get('id')}' の path が絶対パスでない: {mesh['path']}" + + def test_absolute_path_resolves_relative_to_template_dir(self, sweep_module): + """絶対化されたパスがテンプレート YAML の親ディレクトリ(configs/)基準で解決されること. + + 仕様 E2: template_base = TEMPLATE_YAML.parent(configs/)を基準に resolve + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + template_base = _TEMPLATE_YAML.parent # configs/ + for mesh in meshes: + p = Path(mesh["path"]) + # 絶対化されたパスは template_base 配下またはそのサブディレクトリにあること + # (relative path が "meshes/xxx.stl" → configs/meshes/xxx.stl の absolute) + try: + p.relative_to(template_base) + except ValueError: + pytest.fail( + f"mesh '{mesh.get('id')}' の絶対パス '{p}' が template_base '{template_base}' の" + "下にありません(パス解決ロジックの不整合)" + ) + + def test_no_mesh_path_is_relative(self, sweep_module): + """一時 YAML のメッシュパスに相対パスが含まれないこと. + + 仕様 E2: 相対パスが残っていないことを確認 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + for mesh in meshes: + assert Path(mesh["path"]).is_absolute(), ( + f"mesh '{mesh.get('id')}' の path が相対パス: {mesh['path']}" + ) + + +# --------------------------------------------------------------------------- +# E3: 一時 YAML が BOM なし UTF-8 で書かれること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlEncoding: + """E3 系: 一時 YAML のエンコーディング仕様.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_output_file_has_no_bom(self, sweep_module): + """一時 YAML ファイルが UTF-8 BOM(EF BB BF)で始まっていないこと. + + 仕様 E3: BOM なし UTF-8(Mitsuba YAML パーサは BOM 付きで失敗するため) + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + raw = tmp_yaml.read_bytes() + # UTF-8 BOM は 0xEF 0xBB 0xBF + bom = b"\xef\xbb\xbf" + assert not raw.startswith(bom), ( + "一時 YAML が UTF-8 BOM で始まっています.BOM なし UTF-8 で書き込んでください." + ) + + def test_output_file_is_valid_utf8(self, sweep_module): + """一時 YAML ファイルが UTF-8 として正常にデコードできること. + + 仕様 E3: UTF-8 エンコーディングで書かれていること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + raw = tmp_yaml.read_bytes() + # UnicodeDecodeError が発生しなければ OK + decoded = raw.decode("utf-8") + assert len(decoded) > 0 + + def test_output_file_parseable_as_yaml(self, sweep_module): + """一時 YAML ファイルが YAML として正常にパースできること. + + 仕様 E3: 書き込み後に yaml.safe_load で再読み込みできること(エンコーディング崩れなし) + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert isinstance(data, dict) + assert "name" in data + + +# --------------------------------------------------------------------------- +# E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式になること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlName: + """E4 系: 一時 YAML の name フィールド仕様.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_name_starts_with_baseline_minitias_blend(self, sweep_module): + """name が "baseline_minitias_blend_" で始まること. + + 仕様 E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["name"].startswith("baseline_minitias_blend_") + + def test_name_contains_weight_and_alpha(self, sweep_module): + """name に weight と alpha の値が含まれること. + + 仕様 E4: job_name = f"w{weight}_a{alpha}" 形式 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + # job_name は "w0.15_a0.3" 相当 + name = data["name"] + assert "0.15" in name, f"weight 0.15 が name に含まれない: {name}" + assert "0.3" in name, f"alpha 0.3 が name に含まれない: {name}" + + def test_name_format_for_weight_0_05_alpha_0_1(self, sweep_module): + """weight=0.05, alpha=0.1 のとき name が適切な形式であること. + + 仕様 E4: スイープグリッドの全組合せで name が正しい形式になる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data["name"] + assert name.startswith("baseline_minitias_blend_") + assert "0.05" in name + assert "0.1" in name + + def test_name_format_for_weight_0_30_alpha_0_6(self, sweep_module): + """weight=0.30, alpha=0.6 のとき name が適切な形式であること. + + 仕様 E4: スイープグリッドの最大値でも name が正しい形式になる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data["name"] + assert name.startswith("baseline_minitias_blend_") + assert "0.3" in name # 0.30 → "0.3" が含まれれば OK + assert "0.6" in name + + def test_name_differs_across_different_params(self, sweep_module): + """異なるパラメータ組み合わせで生成された name が異なること. + + 仕様 E4: name がジョブ識別子を含むため各ジョブで一意になる + """ + tmp1 = sweep_module._generate_tmp_yaml(0.05, 0.1) + tmp2 = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp1.open(encoding="utf-8") as f: + d1 = yaml.safe_load(f) + with tmp2.open(encoding="utf-8") as f: + d2 = yaml.safe_load(f) + assert d1["name"] != d2["name"], "異なるパラメータで同じ name が生成されています" + + def test_original_template_name_is_not_used(self, sweep_module): + """テンプレートの元の name ("baseline_minitias_blend") がそのまま使われないこと. + + 仕様 E4: name フィールドがジョブごとに更新される + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + # テンプレートの元 name は "baseline_minitias_blend"(サフィックスなし) + assert data["name"] != "baseline_minitias_blend", ( + "name がテンプレートのまま更新されていません" + )