diff --git a/CLAUDE.md b/CLAUDE.md index d11d783..e44dd56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ 補足: **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) の組み合わせ実施. 補足: **LED グループ別 sanity check 完了 — TEST_07 として報告 (2026-06-03)**.Step 8 系キャリブの主軸ではなく「シミュが下/右/左の 3 LED 基板を物理位置どおりに独立にモデル化できているか」の基礎健全性確認.`scripts/sanity_led_groups.py` で各基板の 6 LED だけを ON にした一時 YAML を生成 → 3 ジョブ sm-render (約 25 分) → 比較パネル.結果: **方向性 ✓** (bottom は下端中央/right は右側面寄り/left は左側面寄り),**左右対称性 ✓** (right mean=47.10 vs left mean=47.08),**量的バランスは bottom が right/left の 2.10× で強い**.ユーザー観察「bottom は上下の島,right/left は中心の円」は LED 配置で説明: bottom LED は評価板の下端真下に X 中央寄り集中 → 直接光が広範囲を叩く / right/left LED は側面外側で Y 中央付近に分散 → 直接光は端のみ・中央は多重反射経由 → 「直接光寄与が大きい bottom」vs「多重反射寄与が大きい right/left」の構造的違い.**線形性検証は別途未実施**(既存 `output/baseline/luminance.npy` が Step 8-1 時点の古いもので和との比較不可.Mitsuba は理論線形なので baseline 再生成で一致見込み).副次発見: 実機の right (Y∈[-25,+3]) と left (Y∈[-33,-5]) は **完全な左右対称設計ではない**(Fusion STL 実位置と整合).[TEST_07 LED グループ別動作確認](docs/06_TEST/TEST_07_LEDグループ別動作確認.md) 参照.**シミュレーションは 3 つの照明源を物理位置どおりに独立にモデル化できている**ことが確定し,Step 8 系の残差 2.63× 議論の前提が担保された. 補足: **scene_baseline.yaml の max_depth を 8 → 16 へ更新 (2026-06-03)**.Step 8-14 候補 α 前半の結論「d=16 が実用収束点」を反映.SPEC_03 の既知差分 #6 (未対処の max_depth=8) を解消.SPEC_03 の関連箇所 4 箇所 (パラメータ表 / 多重反射の説明 / 制限セクション / 既知差分一覧) を一括更新.TEST_02 の `output/baseline/` 行に「d=8 時代の古い結果」注記を追加(output/baseline/luminance.npy は 2026-05-18 生成で d=8、現 scene_baseline.yaml の d=16 とは不整合.必要時に再生成).影響: 今後の sm-render は自動的に d=16 で実行され、理論累積 92.6% を取り込む.計算時間は d=8 の約 2× (8.5 分 → 17 分) に増加するが、形状指標 4 つすべてが d=8 時代より実機方向に改善 (CoV 0.1817 → 0.1604, 勾配 24.01% → 20.59%).次のスイープ系 (A8 反射率, A10 サブサーフェス散乱等) は自動的に d=16 ベースで実行される. +補足: **Step 8-14 候補 ξ (A2/A3 LED 配光 cutoff スイープ) 完了 — 配光が形改善の本物の候補として初実証 (2026-06-03)**.ユーザー要請「配光条件を変えてどのような変化が起こるかの実験.データシートに合わせる必要なく,照明の影響を調べるための実験」に基づき,`scripts/sweep_led_distribution.py` で spot emitter の cutoff_angle を {30°, 60°, 90°, 120°, 150°} で 5 値スイープ.`configs/scene_baseline_spot.yaml` (Step 8-10 で作成) をテンプレに beam_width = cutoff/3 で連動,max_depth を 16 に更新.約 85 分.**重要な 2 つの発見**: ① **「area light → spot」のステップが本質的** — area (Lambertian) の angular_ratio=0.4536 → spot (どの cutoff でも) 0.34〜0.36 へ **25% 大幅低下** (実機方向).② **cutoff スイープ内 (30°→150°) では effect 限定的** — angular_ratio は 0.338→0.356 と +0.018 のみ.**A8 と真逆のパターン**: 数値悪化 (CoV +3〜11%) するが形改善 (angular_ratio -25%).これは「数値の偽物の一致」(A8) と対をなす「形改善の本物の候補」初実証.物理的解釈: spot は +Z 軸方向にしか光を出さない → 内壁に当たる成分が減り直接光増 → LED 直下の明点 → X 方向波打ち → 半径方向ばらつき支配 (angular_ratio 減).area は半球全方向に拡散光 → 多重反射 ambient 支配 → Y 軸勾配.**spot にすると「内壁多重反射 ambient < 直接光」に傾き,評価板の照度分布が LED 配置パターンを直接反映**.副次発見: 数値最良点 (cutoff=90°, CoV=0.165) と形最良点 (cutoff=30°, ang_ratio=0.338) が異なる→ キャリブで「何を優先するか」議論を明確化.**TECH_10 優先順位を再更新**: A2/A3/ξ を「形改善候補」へ昇格.B1 と並列の本筋候補 (本検証以前は「データシート Lambertian 整合済」として 4 位だった).[TECH_10 § ξ 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#ξ-検証結果2026-06-03-完了) / [TEST_01 § Step 8-14 候補 ξ](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-ξ-a2a3-led-配光-cutoff_angle-5-値スイープ-30-60-90-120-150) 参照.次のアクション: emitter のより本質的バリエーション (cos^n / IES / 軸ティルト ζ) / 配光と他候補の組み合わせ (spot + R 調整 + max_depth で形 + 数値両立) / B1 筐体幾何精度. 補足: **Step 8-14 候補 α 後半 (A8 反射率スイープ) 完了 — 数値改善のみ・形は逆効果 / 「数値の偽物の一致」の初の定量実証 (2026-06-03)**.`scripts/sweep_reflectance.py` で d=16 新基準のもと R ∈ {0.75, 0.80, 0.85, 0.89, 0.92} を 5 値スイープ.新指標 (shape_metrics) を初めて感度分析に投入.結果: 既存 4 指標 (CoV/勾配/C/P/動径 min/max) は単調改善 (R=0.75 → 0.92 で CoV 0.186→0.142 で残差 24% 縮小,勾配 24.7→17.7%,rmin/max 0.620→0.731) するが,**新指標 angular_ratio は 0.408→0.494 で実機方向と逆に増加**.実機は中央ピーク楕円 (同心円的 = angular_ratio 小予想) なので **形は実機から遠ざかる**.TECH_11 で予想していた「数値の偽物の一致」を初めて現実データで実証.物理的解釈: R 上昇で ambient 増 → 半径方向の偏りは圧縮されるが角度方向のばらつきは相対的に圧縮されにくい → 「半径 var / 角度 var」バランス崩れ angular_ratio 増加.「Y 単調勾配の contrast を緩める代わりに角度方向のムラを相対的に強調」する効果.**A1/A9 検証より強い発見**: 「均一化フィルタ系は形を変えない」を超えて「数値改善が形を逆方向に動かす」ケースの実証.TECH_10 優先順位を再更新: A8 を「数値改善のみクリア」化.**形改善の本筋は B1 筐体幾何精度に最終確定**(A1/A8/A9 すべてクリア済で構造的再配分系のみ残る).副次発見: R=0.85 の CoV=0.1604 が max_depth スイープ d=16 と完全一致 (別 sweep 間の数値整合性 OK).centroid_y_norm 0.045→0.036, eccentricity 0.444→0.414 はわずか改善 (但し angular_ratio 悪化が支配).新指標を導入していなければ気づけない発見であり、形状指標見直し (TECH_11) の正当性が定量データで裏付けられた.[TECH_10 § A8 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#a8-検証結果2026-06-03-完了) / [TEST_01 § Step 8-14 候補 α 後半](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-後半-a8-反射率-5-値スイープ-r075-080-085-089-092) 参照.次のアクション: **B1 筐体幾何精度の検討 / A10 サブサーフェス散乱 / ζ LED ティルト** の構造的再配分系候補に進む. 補足: **Step 8-14 候補 α 前半 (A9 max_depth スイープ) 完了 — 数値改善のみクリア・形不変 (2026-06-03)**.ユーザー仮説「現状 d=8 では 0.85^8≈0.27 で取りこぼし大.0.01 未満まで増やすべき」を 5 値スイープ {8, 12, 16, 20, 28} で検証.`scripts/sweep_max_depth.py` (約 85 分) + `scripts/build_max_depth_panel.py` (収束カーブ + 数値表) を新規実装.結果: 全 4 形状指標 (CoV/勾配/C/P/動径 min/max) が **単調に実機方向に改善**.d=8→28 で **CoV=0.1817→0.1583** (残差 20.9% 縮小),勾配=24.01→20.24% (24.1%),C/P=1.316→1.254 (27.6%),動径 min/max=0.628→0.689 (26.3%).mean は 207.82 で固定 (アンカー正規化).**実用的な収束は d=16 で達成** (d=16→28 で CoV 1.3% のみ動く).理論累積 R^n より早く収束は「直接光と初期反射が支配的,後段は ambient のみ」の物理直感と整合.**「形」は完全に不変** (3 種類のパネル shared/independent/mean=1 正規化 全てで Y 軸単調勾配のまま contrast が緩むだけ).物理的解釈: 多重反射は **空間的に均一な ambient 成分**で,これを足しても暗/明領域の絶対差は縮むが空間配置は不変.「LED が下にしかない」配置の非対称性は除去不能.これは TEST_03「Step 8-1〜8-7 で形は全く変わっていない」と整合.**ユーザー仮説の評価**: 方向性は完全に正しい (21-28% 残差縮小).ただし実用収束は d=16 (理論累積より早い).d=28 まで増やしても追加改善 1% のみ.実機 (CoV=0.0695) には届かず,**残る残差は形改善で詰める必要**.**TECH_10 優先順位を再更新**: A9 を「数値改善のみクリア」化.**形改善の本筋候補は B1 幾何精度に集約**.A8 反射率 R=0.89 は数値改善目的では有効だが形改善は予想薄.新規候補として ε B1 と ζ LED 軸ティルトを追加.新キャリブベースライン候補: **d=16** (現状 d=8 は理論累積の 72.7% で取りこぼし大).副次成果: スイープ + 収束カーブ + 形比較パネル (`shape.png` 上下 2 段, `relative.png` mean=1) のテンプレが整備され今後の感度分析に流用可能.[TECH_10 § A9 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#a9-検証結果2026-06-03-完了) / [TEST_01 § Step 8-14 候補 α 前半](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-前半-a9-max_depth-スイープ-8-12-16-20-28) 参照.次のアクション: 候補 ε B1 筐体幾何精度 / 候補 ζ LED 軸方向ティルト仮説 のいずれかを実施. 補足: **形状指標の本質的見直しと新指標導入 (2026-06-03)**.Step 8-14 で「A9 max_depth スイープで数値が実機方向に動いても形は全く変わらない」「A1 非 Lambertian でも形は実機に近づかない」が続けて確定したため,ユーザー指摘「現状 4 指標は不均一性の絶対量しか測れず,Y 軸単調勾配と中央ピーク楕円を構造的に区別できない」に対応する **形を直接評価する 5 指標** を追加実装.`src/sm_tias_lightsim/evaluation/shape_metrics.py` (新規): `calc_intensity_centroid`(輝度重心 5 値)/ `calc_angular_radial_decomposition`(極座標分解,angular_ratio: 0=同心円, 1=方向依存)/ `calc_ssim`(scikit-image 依存, shape_reference 指定時)/ `calc_ncc`(正規化相関係数)/ `calc_intensity_pca`(weighted PCA 主軸・eccentricity)の純関数 5 つ.`MetricsConfig` に `shape_metrics: bool = False` / `shape_reference: str | None = None` を追加(後方互換).`cli/evaluate.py` に `_run_shape_metrics` 関数追加 + コンソールサマリ拡張.`configs/eval_baseline_dng_target_shape.yaml` を新規作成 (shape_metrics=true, shape_reference=null).`pyproject.toml` に `scikit-image>=0.21.0` を追加.[TECH_11 形状指標の見直しと新指標導入](docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md) で背景・計算式・シミュ vs 実機予想値・過去判断への遡及的注記を記録.TEST_01 俯瞰サマリ上に「形状指標は絶対量のみ測る」注記を追加.TECH_10 A9/A1 検証結果に「新指標での再評価が必要」注記を追加.今後の評価方針: **数値指標 4 + 形指標 5 + 視覚比較の 3 軸評価**で「数値改善のみで形が変わらない候補」と「形を変えうる候補」を明確に区別する. @@ -66,8 +67,9 @@ - `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用,`normalization.enabled` 時は OETF 前段に linear mean 揃え正規化(OETF と排他).後方互換、シミュ用設定のみ - `src/sm_tias_lightsim/visualize/baker.py` — face 中心の照度ベイク.`mode="direct"`(shadow ray + cosine + 距離減衰、直接光のみ、後方互換)/ `mode="path"`(scalar_rgb・完全シーン・コサイン重点 hemisphere MC、多重反射込み per-face、`exclude_mesh_ids` 対応).`max_depth` 引数で path ベイクの反射回数を render 設定から独立設定(None で render 継承=後方互換) - `src/sm_tias_lightsim/visualize/projection.py` — orthographic センサ投影の純関数(評価板メッシュ特定 / `project_luminance_to_vertices` 頂点 bilinear 投影 / `compute_orthographic_uv` テクスチャ UV / `compute_face_camera_mask` 幾何 Z ベース巻き順非依存の評価面選択 / `compute_inward_face_mask` 法線がシーン中心側を向く内向き面判定=幾何カリング用).`sensors.py` look_at@scale 規約と整合(SPEC_03「画像↔物理の向き」検証済) -- `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア.既定=全メッシュ統一ベイク着色(**lit のみ共有 clim**・log/linear・単一カラーバー・`bake_opacity` 半透明・`hide_below` 非表示・`bake_smooth` 平滑化・depth peeling・viridis)+**`cull_outward`(既定 ON)で外向き ≈0 面を非描画**=暗殻の濁り/見かけ段差を解消・clim も可視内向き面に密着;`hide_sensor_marker`/`hide_led_markers` でマーカー個別非表示;`luminance_map` 指定時=評価板 512² テクスチャ投影(E=π·L/ρ で壁と同一 W/m² スケール、内側カメラ向き face のみ、外側ニュートラル、マスク無効時 per-vertex フォールバック) -- `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--bake-mode {direct,path}` / `--bake-samples` / `--bake-max-depth`(path ベイクの反射回数、既定 16、render 設定から独立)/ `--bake-cache `(1回計算→以後即時)/ `--cull-outward|--no-cull-outward`(既定 ON)/ `--opacity` / `--hide-below` / `--bake-smooth` / `--bake-scale {linear,log}` / `--clip-percentile` / `--no-sensor-marker`・`--no-led-markers`・`--no-markers` / `--render `(任意・テクスチャ投影).`--render` 無し=全面統一ベイク(既定) +- `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア.既定=全メッシュ統一ベイク着色(**lit のみ共有 clim**・log/linear・単一カラーバー・`bake_opacity` 半透明・`hide_below` 非表示・`bake_smooth` 平滑化・depth peeling・viridis)+**`cull_outward`(既定 ON)で外向き ≈0 面を非描画**=暗殻の濁り/見かけ段差を解消・clim も可視内向き面に密着;`hide_sensor_marker`/`hide_led_markers` でマーカー個別非表示;`luminance_map` 指定時=評価板 512² テクスチャ投影(E=π·L/ρ で壁と同一 W/m² スケール、内側カメラ向き face のみ、外側ニュートラル、マスク無効時 per-vertex フォールバック).**発表スライド向け表示機能(Step 8-14 期に追加)**: `style`("default"/"slide".slide=白背景・座標軸/bounds 非表示・アンチエイリアス・黒文字スカラーバー `_build_scalar_bar_args`・センサマーカー自動非表示.未知値は warning で default フォールバック)/ `cmap`(None で style 既定を解決:default→viridis・slide→hot=照明均一性評価と同一.未知名は warning で style 既定にフォールバック,`_make_irradiance_texture(cmap_name=)` も同一 cmap)/ `subdivide_max_edge`(m 単位.ベイク前に三角形を均一分割→巨大三角形のブロック感解消)/ `bake_denoise`(`denoise_face_scalars_median` 頂点経由メディアン近似でMCノイズ/firefly除去,エッジ保存.log変換後・smooth前に適用,`_get_triangle_faces` で PolyData/UnstructuredGrid 両対応)/ `bake_smooth_radius`(m 単位.`smooth_face_scalars_radius` ボクセル splat/gather 物理半径ガウス平滑化=メモリ有界・純NumPy・sigma=radius/2.denoise後・bake_smooth前に適用,三角形スケールのチェッカーノイズ除去)/ `backface_cull`(VTK 背面カリング `culling="back"`,opacity=1.0 併用で手前壁が隠れ混色なし) +- `src/sm_tias_lightsim/geometry/subdivide.py` — 三角形メッシュ均一分割の純関数(新規).`subdivide_mesh_to_max_edge(vertices, faces, max_edge)`=`trimesh.remesh.subdivide_to_size` で「最大辺長 ≤ max_edge」まで決定論的に三角形分割(同一入力→同一出力,baker と viewer が独立呼び出しでも face 配列一致).単位は呼び出し側座標系に従う(m 系で呼べば m).STL の三角形サイズが極端に不均一(曲面 ~1mm² vs 平面壁 最大 4794mm²)でも均一な空間解像度の per-face ベイクを可能にする +- `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--bake-mode {direct,path}` / `--bake-samples` / `--bake-max-depth`(path ベイクの反射回数、既定 16、render 設定から独立)/ `--bake-cache `(1回計算→以後即時.`--bake-subdivide` 設定値も npz に記録し不一致なら自動再ベイク)/ `--cull-outward|--no-cull-outward`(既定 ON)/ `--opacity` / `--hide-below` / `--bake-smooth` / `--bake-scale {linear,log}` / `--clip-percentile` / `--no-sensor-marker`・`--no-led-markers`・`--no-markers` / `--render `(任意・テクスチャ投影).**発表スライド向け(Step 8-14 期に追加)**: `--style {default,slide}` / `--cmap NAME`(既定: style に応じ viridis/hot)/ `--bake-subdivide MM`(三角形を最大辺長 MM mm 以下に均一分割)/ `--bake-denoise N`(メディアンデノイズ反復)/ `--bake-smooth-radius MM`(物理半径 MM mm のガウス平滑化)/ `--backface-cull`(背面カリング).mm 引数は内部で ×0.001 して m 系で show_scene/baker へ.`--render` 無し=全面統一ベイク(既定) - `src/sm_tias_lightsim/runtime/llvm_init.py` — LLVM JIT 初期化ヘルパ.`ensure_drjit_libllvm_path()` で `DRJIT_LIBLLVM_PATH` 未設定時に Windows/POSIX 標準パスを自動探索して `os.environ` 設定.`log_variant_status()` で variant 経路(llvm_/cuda_/scalar_)を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.`runner.py` / `baker.py` から呼ばれ環境変数未設定時のサイレントクラッシュを防ぐ - `src/sm_tias_lightsim/evaluation/dng_loader.py` — DNG 読み込みモジュール.MiniTias-Evaluation `src/io/dng_loader.py` を SmTIAS-LightSim にコピーしたもの (オリジナルは MiniTias-Evaluation 側で管理.バグ修正・機能追加はそちらで反映).主要 API: `load_image_dng(path, demosaic, black_level, output_format)`.`output_format` は `rgb_float32` / `luma_float32` / `rgb_uint8`.linear 系出力 (`rgb_float32` / `luma_float32`) は `output_color=raw` でカラーマトリクス非適用・per-channel 比例性保持.`gamma=(1,1) + no_auto_bright=True + use_camera_wb=False + user_wb=[1,1,1,1]` で linear raw を保つ.依存: `rawpy>=0.21.0` - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル.既存 `calc_radial_profile` に加えて MiniTias-Evaluation `src/analysis/spatial.py:calc_radial_min_max_ratio` をマージ.動径 min/max 比 (20 点輪帯平均) は画素単位 max/min よりノイズに対しロバスト.nan ビン除外を移植時に補強 (既存 `calc_radial_profile` は空ビンを nan で返す) 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 4eb248e..e164ef3 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" @@ -9,11 +9,12 @@ | ~~🥇 1 位~~ | ~~A1. 内壁の非 Lambertian 性(光沢成分)~~ | **✓ クリア** | — | **2026-06-02 検証完了**:3×3 グリッドで全 9 パターンが実機方向に作用しない(むしろ悪化)と判明.純 Lambertian が実機挙動に最も近い.[§ A1 検証結果](#a1-検証結果2026-06-02-完了) 参照 | | ~~🥈 2 位~~ | ~~A9. max_depth 8 → 16+~~ | **✓ 数値改善のみクリア(形不変)** | — | **2026-06-03 検証完了**:5 値スイープ {8,12,16,20,28} で **CoV/勾配/C/P/動径 min/max が全て単調改善**(d=28 で残差 21-28% 縮小).ただし「形」は Y 軸単調勾配のまま完全に不変.均一化フィルタとして働くが空間再配分は起こさない.[§ A9 検証結果](#a9-検証結果2026-06-03-完了) 参照 | | ~~🥇 1 位~~ | ~~A8. 反射率 R=0.85 → 0.89(物理整合点)~~ | **✓ 数値改善のみクリア(形は逆効果)** | — | **2026-06-03 検証完了**:5 値スイープ {0.75, 0.80, 0.85, 0.89, 0.92} で d=16 ベース.既存 4 指標は単調改善するが **angular_ratio は 0.408 → 0.494 で増加(実機方向と逆)**.「形状指標の偽物の一致」を初めて定量実証.[§ A8 検証結果](#a8-検証結果2026-06-03-完了) 参照 | -| **🥇 1 位(形改善・確定)** | **B1. 筐体内部の幾何精度** | **大の可能性** | 高 | 3D プリンタの実物理表面 vs 設計表面.A1/A8/A9 すべてクリアで **形改善の本筋として確定**.構造的に光分布を再配分する唯一の本格候補 | +| **🥇 1 位(形改善・初実証)** | **A2/A3/ξ. LED 配光モデル (area→spot)** | **大の実証** | 中 | **2026-06-03 検証完了**:cutoff スイープで **angular_ratio が 0.45→0.34 で 25% 低下(実機方向)**.「形」を構造的に変える初実証.ただし数値は若干悪化(A8 と真逆パターン).[§ ξ 検証結果](#ξ-検証結果2026-06-03-完了) 参照 | +| **🥇 1 位(形改善・未試行)** | **B1. 筐体内部の幾何精度** | **大の可能性** | 高 | 3D プリンタの実物理表面 vs 設計表面.残る構造的再配分系の有力候補.配光と組み合わせて使う余地あり | | ~~4 位~~ | ~~D1. シミュ ROI と実機 ROI の物理的整合~~ | **✓ クリア** | — | **2026-06-02 検証完了**:物理サイズ差 < 1%・中心位置差 < 1 mm で整合.残差主因から除外([§ D1 検証結果](#d1-検証結果2026-06-02-完了) 参照) | | 4 位 | A2/A3. LED 配光の個体差・樹脂ドーム | 小〜中 | 高 | データシート Lambertian 整合済 ([TECH_09](TECH_09_LEDデータシート調査結果.md)) | -→ **2026-06-03 二次更新**: A1/A9 に続き **A8 反射率も「数値改善のみクリア・形は逆効果」と確定**(5 値スイープで R=0.92 まで CoV 24% 縮小、**ただし angular_ratio は 0.408→0.494 で実機方向と逆**).[TECH_11](TECH_11_形状指標の見直しと新指標導入.md) で予想していた「数値の偽物の一致」を初めて定量実証.**形改善の本筋は B1 筐体幾何精度で確定**.次の候補は B1 / A10 サブサーフェス散乱 / ζ LED ティルト など構造的再配分系. +→ **2026-06-03 三次更新**: **配光モデル (A2/A3/ξ) が形改善の本物の候補として初実証**.cutoff スイープで area→spot のステップで angular_ratio が 0.45→0.34 へ 25% 低下(実機方向に最大改善).**「形」を構造的に変える候補が確定**.ただし数値は若干悪化(A8 と真逆パターン)→ 形 + 数値の両立は次の課題.B1 幾何精度は依然有力候補(未試行). ## 背景 (Background) @@ -35,8 +36,7 @@ | ID | 候補 | 現状 | 影響度 | 検証状況 | | --- | --- | --- | --- | --- | | ~~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) で確認 | +| **A2/A3/ξ** | **LED 配光モデル (area light → spot)** | area light Lambertian | **大の実証** | **2026-06-03 検証完了**:spot emitter で cutoff スイープ {30°, 60°, 90°, 120°, 150°} を実走.**area→spot のステップで angular_ratio が 0.45→0.34 で 25% 低下**(実機方向に最大改善).spot ファミリー内の cutoff 違いは effect 小.数値は若干悪化(A8 と真逆).[§ ξ 検証結果](#ξ-検証結果2026-06-03-完了) 参照 | | A4 | LED 駆動電流のばらつき | 全 LED 完全同じ radiance | 小 | 18 個並列接続で電流分配誤差は通常 ±5% 以内 | | A5 | 発光面の物理サイズ | 2.8mm 角(パッケージサイズ) | 小 | [Step 8-7](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で 1.5mm 角試行.形状不変,mean のみ変化 | | A6 | LED 方向(光軸) | 全 LED (0, 0, -1) | 小 | 実機の基板は平面で妥当 | @@ -174,6 +174,74 @@ | A2/A3 LED 配光の個体差 | データシート Lambertian 整合済([TECH_09](TECH_09_LEDデータシート調査結果.md)).実物の個体差ばらつきは実測ゴニオフォトメータが必要で高コスト | | E1/E2 環境・運用系 | 新品撮影・通常室温で影響軽微 | +## ξ 検証結果(2026-06-03 完了) + +候補リストで **A2/A3「LED 配光モデル」** の感度分析を Step 8-14 候補 ξ として実行.**emitter のタイプ自体が「形」を構造的に変える**ことが初実証された.これは A1/A8/A9 と異なる「形改善の本物の候補」. + +### 検証手順 + +1. **仮説**: cutoff スイープで配光の鋭さが「形」に効くかを測る.データシート整合は気にせず純粋に「広がりが形に効くか」を見る感度分析 +2. **実装**: `scripts/sweep_led_distribution.py` で `configs/scene_baseline_spot.yaml` (Step 8-10 で作成) をテンプレに spot emitter の cutoff_angle を {30, 60, 90, 120, 150}° に書き換え.**beam_width = cutoff_angle / 3 で連動**(ビーム形状を相似).max_depth を 16 に更新.flux 補正なし(mean アンカーで吸収).約 85 分. + +### 結果 + +| cutoff (beam) | CoV | 勾配% | C/P | rmin/max | **angular_ratio** | centroid_y | eccentricity | +| --- | --- | --- | --- | --- | --- | --- | --- | +| **area light** (baseline d=16) | **0.1604** | 20.59 | 1.259 | 0.684 | **0.4536** | 0.0400 | 0.4264 | +| **30°** (10° beam, 鋭) | 0.1776 | 25.51 | 1.343 | 0.621 | **0.3380** | 0.0486 | 0.4087 | +| 60° | 0.1664 | 24.13 | 1.318 | 0.639 | 0.3438 | 0.0431 | 0.4098 | +| **90°** (CoV 最良) | **0.1649** | **23.71** | 1.311 | 0.647 | 0.3524 | 0.0424 | 0.4106 | +| 120° | 0.1682 | 23.69 | 1.310 | 0.647 | 0.3546 | 0.0423 | 0.4116 | +| **150°** (広) | 0.1738 | 23.74 | 1.311 | 0.647 | **0.3556** | 0.0424 | 0.4119 | +| **実機 TARGET** | **0.0695** | **8.35** | **1.091** | **0.860** | **小予想** | **0** | — | + +### ★ 重要な 2 つの発見 ★ + +#### 発見 1: 「area light → spot」のステップが本質的 + +| 設定 | angular_ratio | +| --- | --- | +| area light (Lambertian) | **0.4536** | +| spot (どの cutoff でも) | **0.34 〜 0.36** | + +→ **area→spot に変えた瞬間に angular_ratio が 0.45 → 0.34 へ 25% 低下**(実機方向).「形」を構造的に変える初実証. + +#### 発見 2: cutoff スイープ内では効果限定的 + +spot ファミリーで cutoff を 30°→150° と変えても angular_ratio は 0.338 → 0.356 と +0.018 のみ.**「鋭さ」を変えても形はほぼ同じ**.効くのは emitter の基本構造. + +### 物理的解釈 + +- **area light (Lambertian)**: 半球全方向に cos θ で散乱 → 内壁に広く拡散光 → 多重反射 ambient 支配 → Y 軸方向勾配 +- **spot**: +Z 軸方向にしか光を出さない → 内壁に当たる成分が減り直接光増加 → LED 直下の明点 → X 方向波打ち → 半径方向のばらつきが支配的(angular_ratio 減) + +spot にすると **「内壁多重反射 ambient < 直接光」** に傾き、評価板の照度分布が **LED 配置のパターンを直接反映**するようになる. + +### A8 と真逆のパターン + +| 候補 | 数値 (CoV) | 形 (angular_ratio) | 評価 | +| --- | --- | --- | --- | +| **A8 反射率 R↑** | **改善** | 悪化(0.41→0.49) | 数値の偽物の一致 | +| **ξ LED 配光 area→spot** | **悪化** | **改善(0.45→0.34)** | **形改善の本物** | + +→ **数値 + 形の 2 軸評価方針 (TECH_11) が再び正当性を示す**. + +### 判定と意味 + +- **配光は「形」を構造的に変える本物の候補として初実証**.これまでクリアした A1/A8/A9 は均一化フィルタ系で形を変えなかったが、emitter type は構造的に光分布を再配分 +- **TECH_10 優先順位を再更新**: A2/A3/ξ を「形改善候補」へ昇格.B1 と並列の本筋候補 +- 数値最良点 (cutoff=90°, CoV=0.165) と形最良点 (cutoff=30°, ang_ratio=0.338) が違う → **キャリブで「何を優先するか」議論を明確化**する事例 +- **次の方向性**: + 1. emitter の本質的バリエーション(cos^n, IES, 軸ティルト ζ など) + 2. **形 + 数値の両立を狙う組み合わせ**(spot + 反射率調整 + max_depth 等) + 3. B1 筐体幾何精度(残る構造的再配分系候補) + +### 副次成果 + +- 配光スイープのテンプレ整備(今後の emitter 系試行に流用可能) +- Step 8-10 で作成した `configs/scene_baseline_spot.yaml` + `SpotLight` クラスが本格活用されたことの実証 +- A2/A3 を「データシート Lambertian 整合済」として低優先順位に置いていた過去判断([TECH_09](TECH_09_LEDデータシート調査結果.md))は **物理整合性の観点では正しい**が、**形改善の感度分析としては独立した価値**があることが判明 + ## A8 検証結果(2026-06-03 完了) 候補リストで **A8「反射率 R=0.85 → 0.89(物理整合点)」** を 5 値スイープ {0.75, 0.80, 0.85, 0.89, 0.92} で検証したところ,**既存 4 指標は実機方向に改善するが、新指標 angular_ratio は実機方向と逆に動く**ことが判明.[TECH_11](TECH_11_形状指標の見直しと新指標導入.md) で予想していた「**数値の偽物の一致**」を初めて定量実証した. 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 ae00bc4..3e878e6 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" @@ -681,6 +681,71 @@ | --- | --- | --- | --- | --- | --- | --- | --- | | 8-14 候補 α 後半 (A8) R=0.89 | **R=0.89**, d=16, spp1024 | linear | 207.82* | 0.1501 | 18.96 | 1.234 | **2.16×**(数値改善あり、**angular_ratio 増 = 形逆効果**) | | 8-14 候補 α 後半 (A8) R=0.92 | **R=0.92**, d=16, spp1024 | linear | 207.82* | 0.1424 | 17.72 | 1.215 | **2.05×**(数値最良、**angular_ratio 0.494 で実機方向と逆**) | +| 8-14 候補 ξ (A2/A3) spot cutoff=90° | **spot c90 b30** R0.85 d16 spp1024 | linear | 207.82* | 0.1649 | 23.71 | 1.311 | **2.37×**(数値悪化、**angular_ratio 0.352 で実機方向に大改善**) | +| 8-14 候補 ξ (A2/A3) spot cutoff=30° | **spot c30 b10** R0.85 d16 spp1024 | linear | 207.82* | 0.1776 | 25.51 | 1.343 | **2.56×**(数値悪化、**angular_ratio 0.338 で最低 = 形最良**) | + +### Step 8-14 候補 ξ: A2/A3 LED 配光 cutoff_angle 5 値スイープ {30°, 60°, 90°, 120°, 150°} + +- **日付**: 2026-06-03 +- **位置付け**: TECH_10 で 4 位だった **A2/A3「LED 配光モデル」** の感度分析.データシート整合は気にせず純粋に「光の広がりが形に効くか」を測る実験.ユーザー要請:「一番効きそうなものが配光条件を変えること」「データシートに合わせる必要なく,照明の影響を調べるための実験」. +- **仮説**: + 1. 鋭いビーム (cutoff=30°) → LED 直下に集中 → angular_ratio 増(角度依存性増) + 2. 広いビーム (cutoff=150°) → Lambertian 近似 → angular_ratio 減(同心円方向) + 3. 形状指標は実機方向に動く可能性あり(A1/A8/A9 では形が動かなかった残候補) +- **実装**: `scripts/sweep_led_distribution.py` で `configs/scene_baseline_spot.yaml` (Step 8-10 で作成) をテンプレに spot emitter の cutoff_angle を {30, 60, 90, 120, 150}° に書き換え.**beam_width = cutoff_angle / 3 で連動**(ビーム形状を相似に保つ).max_depth を 16 に更新.flux 補正なし(mean アンカーで吸収).約 85 分. +- **結果**: + +| cutoff (beam) | mean | CoV | 勾配% | C/P | rmin/max | **angular_ratio** | centroid_y | eccentricity | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **area light** (baseline d=16) | 207.82 | **0.1604** | 20.59 | 1.259 | 0.684 | **0.4536** | 0.0400 | 0.4264 | +| **30°** (10° beam, 鋭) | 207.82 | 0.1776 | 25.51 | 1.343 | 0.621 | **0.3380** | 0.0486 | 0.4087 | +| **60°** (20° beam) | 207.82 | 0.1664 | 24.13 | 1.318 | 0.639 | **0.3438** | 0.0431 | 0.4098 | +| **90°** (30° beam, 最良 CoV) | 207.82 | **0.1649** | **23.71** | 1.311 | **0.647** | 0.3524 | 0.0424 | 0.4106 | +| **120°** (40° beam) | 207.82 | 0.1682 | 23.69 | 1.310 | 0.647 | 0.3546 | 0.0423 | 0.4116 | +| **150°** (50° beam, 広) | 207.82 | 0.1738 | 23.74 | 1.311 | 0.647 | **0.3556** | 0.0424 | 0.4119 | +| **実機 TARGET** | — | **0.0695** | **8.35** | **1.091** | **0.860** | **小予想** | **0** | — | + +- **★ 重要な 2 つの発見 ★**: + +#### 発見 1: 「area light → spot」のステップが本質的 + +emitter のタイプ自体が「形」を決めている: + +| 設定 | angular_ratio | +| --- | --- | +| area light (Lambertian, baseline) | **0.4536** | +| spot (どの cutoff でも) | **0.34 〜 0.36** | + +→ area→spot に変えた瞬間に **angular_ratio が 0.45 → 0.34 へ大きく低下**(-25%).**実機方向 (同心円的) に動いた最大の発見**. + +#### 発見 2: cutoff スイープ内では効果限定的 + +spot ファミリーで cutoff を 30°→150° と変えても: + +| 指標 | 動き | +| --- | --- | +| angular_ratio | 0.338 → 0.356(+0.018 のみ) | +| eccentricity | 0.409 → 0.412(ほぼ不変) | +| CoV | U 字(90° が最良 0.165)| + +つまり **「鋭さ」を変えても形はほぼ同じ**.効くのは emitter の基本構造(area vs spot). + +- **A8 と真逆のパターン**: 数値悪化(CoV +3% 〜 +11%)するが、**angular_ratio は実機方向に改善(-25%)**.これは Step 8-14 α 後半 (A8) の「数値改善・形逆効果」と **真逆のパターン**.「形改善のための候補」として A2/A3/ξ が初実証された. +- **物理的解釈**: + - **area light**: 半球全方向に cos θ で散乱 → 内壁に広く拡散光 → 多重反射 ambient が支配 → Y 軸方向勾配が出る + - **spot**: +Z 軸方向にしか光を出さない(cutoff 内に限定)→ 内壁に当たる成分が減り直接光が増える → LED 直下に明点 → X 方向の波打ち → 半径方向のばらつきが支配的(angular_ratio 減) + - spot にすると **「内壁多重反射 ambient < 直接光」** に傾き、評価板の照度分布が **LED 配置のパターンを直接反映**するようになる +- **解釈・学び**: + - **「形」を変える本物の候補が初実証された**.これまでクリアした A1/A8/A9 はすべて「均一化フィルタ系」で形を変えなかったが、emitter type は **構造的に光分布を再配分**する + - **TECH_10 優先順位を再更新**: A2/A3/ξ を「形改善候補」へ昇格.B1 と並列の本筋候補 + - 数値とのトレードオフ: 形改善(angular_ratio 改善)と引き換えに数値(CoV/勾配)が悪化.TECH_11 評価方針(数値 + 形 の 2 軸)が再び正当性を示す + - cutoff の細分化スイープ余地は限定的.次の方向性は **emitter の本質的バリエーション**(cos^n, IES, 軸ティルト等)または **形 + 数値の両立を狙う組み合わせ**(spot + 反射率調整等) + - 副次成果: 配光スイープテンプレ整備.今後の emitter 系試行に流用可能 +- **副次発見**: spot の中で cutoff=90° が CoV 最良(0.165)だが、cutoff=30° が angular_ratio 最良(0.338).**数値最良点と形最良点が違う**ことを実証.これはキャリブで「何を優先するか」の議論を明確化する事例. +- **次のアクション**: + - **形改善目的**: 配光モデルをより本格的に検討(cos^n の n スイープ・LED 軸ティルト ζ)or 配光 + 反射率の組み合わせ + - **B1 筐体幾何精度** も依然有力候補(未試行) + - 配光 + B1 + max_depth の組み合わせで「形 + 数値」両立を狙う総合試行も視野 ## Step 8-N 候補: LSC 順方向モデル(不採用 — Step 8-13 で方針確定) 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 004c4fb..756a725 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" @@ -49,6 +49,9 @@ | `output/_slide_panels/reflectance_sweep.png` | 8-14 候補 α 後半 | — | — | 2 段比較パネル (上=共有スケール / 下=独立スケール) | TEST_01 視覚追跡用 | | `output/_slide_panels/reflectance_convergence.png` | 8-14 候補 α 後半 | — | — | 既存 4 指標カーブ vs R | TEST_01 数値追跡用 | | `output/_slide_panels/reflectance_shape_metrics.png` | 8-14 候補 α 後半 | — | — | 新指標カーブ (centroid_y / angular_ratio / eccentricity) vs R | TEST_01 「形」追跡用 — **angular_ratio が単調増加 = 形は実機方向と逆を視覚的に確認** | +| `output/led_distribution_sweep/c{030,060,090,120,150}/` (5 ディレクトリ) | 8-14 候補 ξ | `configs/scene_baseline_spot.yaml` (Step 8-10 で作成) をテンプレに `scripts/sweep_led_distribution.py` で全 spot light の `cutoff_angle` を {30, 60, 90, 120, 150}° に,`beam_width = cutoff/3` で連動,max_depth を 16 に更新 (R=0.85, spp=1024) | `configs/eval_baseline_dng_target_shape.yaml` (新指標 5 つ含む) | c090 で CoV=0.165, angular_ratio=0.352; c030 で angular_ratio=0.338 — [TEST_01 § Step 8-14 候補 ξ 結果表](TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-ξ-a2a3-led-配光-cutoff_angle-5-値スイープ-30-60-90-120-150) 参照 | TEST_01 Step 8-14 候補 ξ 検証.**配光モデルが形改善の本物の候補として初実証**.area→spot で angular_ratio が 0.45→0.34 で 25% 低下 | +| `output/led_distribution_sweep/summary.csv` | 8-14 候補 ξ | — | — | 5 ジョブ集約 CSV (cutoff_angle, beam_width + 既存 12 + 新 12 指標) | `scripts/sweep_led_distribution.py` の集約出力 | +| `output/_slide_panels/led_distribution_{sweep,convergence,shape_metrics}.png` | 8-14 候補 ξ | — | — | 輝度マップ 2 段 / 既存 4 指標カーブ / 新指標カーブ | TEST_01 「形」追跡 — **angular_ratio が cutoff 30° → 150° でほぼ flat (0.338→0.356) を視覚確認.area→spot の遷移が本質と判明** | ### sweep_summary.csv の値(参考、TEST_01 line と一致確認用) @@ -88,6 +91,8 @@ | max_depth 比較パネル生成 | `uv run python scripts/build_max_depth_panel.py` (1×5 輝度マップ + 2×2 収束カーブ.要 `output/max_depth_sweep/summary.csv` + 各 `luminance.npy`) | | 反射率 5 値スイープ (Step 8-14 α 後半) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sweep_reflectance.py` (5 ジョブ × 約 17 分 = 約 85 分) | | 反射率比較パネル生成 | `uv run python scripts/build_reflectance_panel.py` (輝度マップ 2 段 + 既存指標 + 新指標カーブ.要 `output/reflectance_sweep/summary.csv` + 各 `luminance.npy`) | +| LED 配光 cutoff 5 値スイープ (Step 8-14 候補 ξ) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sweep_led_distribution.py` (5 ジョブ × 約 17 分 = 約 85 分) | +| LED 配光比較パネル生成 | `uv run python scripts/build_led_distribution_panel.py` (輝度マップ 2 段 + 既存指標 + 新指標カーブ.要 `output/led_distribution_sweep/summary.csv` + 各 `luminance.npy`) | ## 過去の整理実績 (Cleanup History) diff --git a/scripts/build_led_distribution_panel.py b/scripts/build_led_distribution_panel.py new file mode 100644 index 0000000..0762ad2 --- /dev/null +++ b/scripts/build_led_distribution_panel.py @@ -0,0 +1,170 @@ +"""LED 配光 cutoff スイープの比較パネル生成. + +実行方法: + uv run python scripts/build_led_distribution_panel.py + +出力: + - output/_slide_panels/led_distribution_sweep.png (1×5 輝度マップ 2 段) + - output/_slide_panels/led_distribution_convergence.png (既存 4 指標カーブ) + - output/_slide_panels/led_distribution_shape_metrics.png (新指標カーブ) +""" + +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.font_manager as fm +import matplotlib.pyplot as plt + +ROOT = Path(__file__).resolve().parents[1] +SWEEP_BASE = ROOT / "output" / "led_distribution_sweep" +SUMMARY_CSV = SWEEP_BASE / "summary.csv" +OUT_LUM_PANEL = ROOT / "output" / "_slide_panels" / "led_distribution_sweep.png" +OUT_CONV_PANEL = ROOT / "output" / "_slide_panels" / "led_distribution_convergence.png" +OUT_SHAPE_PANEL = ROOT / "output" / "_slide_panels" / "led_distribution_shape_metrics.png" + +TARGET = {"cov": 0.0695, "gradient": 8.35, "cp": 1.091, "rmm": 0.860} + + +def _setup_japanese_font() -> None: + for p in ("C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/YuGothM.ttc"): + if Path(p).exists(): + fm.fontManager.addfont(p) + plt.rcParams["font.family"] = fm.FontProperties(fname=p).get_name() + return + + +def main() -> int: + _setup_japanese_font() + + if not SUMMARY_CSV.exists(): + print(f"エラー: {SUMMARY_CSV} が見つかりません", file=sys.stderr) + return 1 + + with SUMMARY_CSV.open(encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f)) + + cutoffs = [int(r["cutoff_angle"]) for r in rows] + covs = [float(r["cov"]) for r in rows] + grads = [float(r["gradient_magnitude"]) for r in rows] + cps = [float(r["center_periphery_ratio"]) for r in rows] + rmms = [float(r["radial_min_max_ratio"]) for r in rows] + means = [float(r["mean"]) for r in rows] + centroid_y = [float(r["intensity_centroid_y_norm"]) for r in rows] + ang_ratios = [float(r["angular_ratio"]) for r in rows] + eccs = [float(r["pca_eccentricity"]) for r in rows] + + lums = [] + for r in rows: + npy = SWEEP_BASE / r["job"] / "luminance.npy" + if not npy.exists(): + lums.append(None) + continue + arr = np.load(npy) + lums.append(arr.mean(axis=-1) if arr.ndim == 3 else arr) + + valid = [x for x in lums if x is not None] + stacked = np.stack(valid) + vmin_shared = float(np.percentile(stacked, 1)) + vmax_shared = float(np.percentile(stacked, 99)) + + # ============ 輝度マップ 2 段比較 ============ + n = len(rows) + fig, axes = plt.subplots(2, n, figsize=(4.5 * n, 10)) + for j, (c, lum, cov, grad) in enumerate(zip(cutoffs, lums, covs, grads)): + ax_t = axes[0, j] + im_t = ax_t.imshow(lum, cmap="hot", vmin=vmin_shared, vmax=vmax_shared) + ax_t.set_title(f"cutoff = {c}° (beam={c/3:.0f}°)\nCoV={cov:.4f} 勾配={grad:.2f}%", + fontsize=11) + ax_t.set_xticks([]); ax_t.set_yticks([]) + plt.colorbar(im_t, ax=ax_t, fraction=0.046, pad=0.04) + ax_b = axes[1, j] + vmin_i = float(np.percentile(lum, 1)) + vmax_i = float(np.percentile(lum, 99)) + im_b = ax_b.imshow(lum, cmap="hot", vmin=vmin_i, vmax=vmax_i) + ax_b.set_title(f"独立スケール [{vmin_i:.0f}, {vmax_i:.0f}]", fontsize=10) + ax_b.set_xticks([]); ax_b.set_yticks([]) + plt.colorbar(im_b, ax=ax_b, fraction=0.046, pad=0.04) + + axes[0, 0].set_ylabel("① 共有スケール", fontsize=12, fontweight="bold") + axes[1, 0].set_ylabel("② 独立スケール\n(形比較用)", fontsize=12, fontweight="bold") + fig.suptitle("LED 配光 cutoff_angle スイープ (d=16, beam_width=cutoff/3) — 2 段比較", + fontsize=14, fontweight="bold") + plt.tight_layout() + OUT_LUM_PANEL.parent.mkdir(parents=True, exist_ok=True) + plt.savefig(OUT_LUM_PANEL, dpi=110, bbox_inches="tight") + print(f"輝度マップパネル保存: {OUT_LUM_PANEL}") + + # ============ 既存 4 指標カーブ ============ + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + metrics_existing = [ + ("CoV", covs, TARGET["cov"]), + ("勾配 [%]", grads, TARGET["gradient"]), + ("C/P 比", cps, TARGET["cp"]), + ("動径 min/max", rmms, TARGET["rmm"]), + ] + for ax, (label, vals, target) in zip(axes.flat, metrics_existing): + ax.plot(cutoffs, vals, marker="o", linewidth=2, markersize=10, color="C0", label="シミュ") + ax.axhline(target, color="red", linestyle="--", linewidth=1.5, label=f"実機 TARGET ({target})") + for c, v in zip(cutoffs, vals): + ax.annotate(f"{v:.4f}", (c, v), textcoords="offset points", + xytext=(0, 10), ha="center", fontsize=9) + ax.set_xlabel("cutoff_angle [°]") + ax.set_ylabel(label) + ax.set_title(label) + ax.grid(alpha=0.3) + ax.legend(loc="best", fontsize=9) + + fig.suptitle("LED 配光スイープ — 既存 4 指標の変化", + fontsize=14, fontweight="bold") + plt.tight_layout() + plt.savefig(OUT_CONV_PANEL, dpi=110, bbox_inches="tight") + print(f"既存指標カーブ保存: {OUT_CONV_PANEL}") + + # ============ 新指標カーブ ============ + fig, axes = plt.subplots(1, 3, figsize=(16, 5)) + new_metrics = [ + ("輝度重心 Y (norm)", centroid_y, 0.0), + ("angular_ratio (0=同心円, 1=角度依存)", ang_ratios, 0.0), + ("PCA eccentricity", eccs, 0.0), + ] + for ax, (label, vals, ideal) in zip(axes, new_metrics): + ax.plot(cutoffs, vals, marker="o", linewidth=2, markersize=10, color="C2", label="シミュ") + ax.axhline(ideal, color="gray", linestyle=":", linewidth=1.5, label=f"理想 ({ideal})") + for c, v in zip(cutoffs, vals): + ax.annotate(f"{v:.4f}", (c, v), textcoords="offset points", + xytext=(0, 10), ha="center", fontsize=9) + ax.set_xlabel("cutoff_angle [°]") + ax.set_ylabel(label) + ax.set_title(label) + ax.grid(alpha=0.3) + ax.legend(loc="best", fontsize=9) + + fig.suptitle("LED 配光スイープ — 新指標 (shape_metrics) の変化", + fontsize=14, fontweight="bold") + plt.tight_layout() + plt.savefig(OUT_SHAPE_PANEL, dpi=110, bbox_inches="tight") + print(f"新指標カーブ保存: {OUT_SHAPE_PANEL}") + + # ============ サマリ ============ + print() + print("=== サマリ ===") + h = ("cutoff", "mean", "CoV", "勾配%", "C/P", "rmin/max", "centroid_y", "ang_ratio", "ecc") + print(f" {h[0]:<8}{h[1]:<10}{h[2]:<10}{h[3]:<10}{h[4]:<10}{h[5]:<10}{h[6]:<12}{h[7]:<12}{h[8]:<10}") + for c, mean, cov, grad, cp, rmm, cy, ar, ec in zip( + cutoffs, means, covs, grads, cps, rmms, centroid_y, ang_ratios, eccs): + print(f" {c}°{'':4}{mean:<10.2f}{cov:<10.4f}{grad:<10.2f}{cp:<10.4f}{rmm:<10.4f}" + f"{cy:<12.4f}{ar:<12.4f}{ec:<10.4f}") + print(f" TARGET: - {TARGET['cov']:<10}{TARGET['gradient']:<10}" + f"{TARGET['cp']:<10}{TARGET['rmm']:<10}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_sim_vs_darkroom_panel.py b/scripts/build_sim_vs_darkroom_panel.py new file mode 100644 index 0000000..4d2fcec --- /dev/null +++ b/scripts/build_sim_vs_darkroom_panel.py @@ -0,0 +1,237 @@ +"""スライド用「シミュ vs 暗室実機」形状比較パネルを生成する. + +シミュレーション輝度マップと暗室撮影 DNG (現行キャリブ TARGET) を, +**各画像を独立に [0, 100] へ正規化**して共有 colorbar で左右に並べる比較図. +正規化の基準は 20 ビン動径プロファイルの最小値→0・最大値→100 で, +絶対的な明るさの違い (mean) も全体コントラストの強弱もキャンセルし, +「照明の形 (空間パターン)」だけを直接比較できるようにする. + +これにより: + - 各画像がフル 0〜100 レンジを使うので「量の違い」ではなく「形」が見える + - 動径 min/max を基準にするため画素単位のノイズ・外れ値に影響されにくい + - 形状指標 (CoV / 勾配 / C/P / 動径 min/max) はオフセット込み正規化で値が + 変わるため,指標は **物理 ROI のまま** 算出して title に表示する + (表示用配列だけを 0〜100 正規化する) + +実機側 (暗室 DNG) の評価は MiniTias-Evaluation の正規手順を再現する: + load_image_dng(rgb_float32, output_color=raw, linear) + → meta.json lscMap を順方向適用 (レンズビネット除去) + → Rec.709 luma × 255 → rot90(-1) で portrait 向き合わせ + → 白板 ROI 抽出 +これは reference_scripts/run_uniformity_dng.py と同一 (mean=207.82 を再現). + +シミュ側は output/baseline/luminance.npy を SIM_ROI で抽出. + +使い方: + uv run python scripts/build_sim_vs_darkroom_panel.py + +出力: + output/_slide_panels/sim_vs_darkroom_normalized.png + +ドキュメント: + docs/06_TEST/TEST_01_キャリブレーション検証履歴.md (§Step 8-13 実機 DNG 突合) + docs/05_TECH/TECH_07_LSC取り扱い方針.md (LSC 順方向適用の役割分担) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from sm_tias_lightsim.evaluation.dng_loader import load_image_dng +from sm_tias_lightsim.evaluation.radial import ( + calc_radial_min_max_ratio, + calc_radial_profile, +) +from sm_tias_lightsim.evaluation.zones import ( + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, +) + +ROOT = Path(__file__).resolve().parents[1] + +# Rec.709 luma 係数 (uniformity.py / dng_loader.py と一致) +_REC709 = (0.2126, 0.7152, 0.0722) + +# 実機 暗室 DNG (Step 8-13 以降の正規 baseline) +REAL_DNG = ROOT / "reference_measurement" / "quantitative" / "MiniTIAS_QM_20260601_124500.dng" +REAL_META = ROOT / "reference_measurement" / "quantitative" / "MiniTIAS_QM_20260601_124500.meta.json" +# 白板 ROI (portrait, 旧 PNG と同一座標. DNG portrait は 2448x3264 で同一フレーミング) +REAL_ROI = {"x": 539, "y": 1015, "w": 1400, "h": 1409} + +# シミュ baseline (max_depth=16, R=0.85, spp=1024) +SIM_NPY = ROOT / "output" / "baseline" / "luminance.npy" +SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450} # configs/eval_baseline_dng_target.yaml + +OUT_PATH = ROOT / "output" / "_slide_panels" / "sim_vs_darkroom_normalized.png" + + +def _rec709_luma(rgb: np.ndarray) -> np.ndarray: + """(H, W, 3) RGB → (H, W) Rec.709 luma.""" + return _REC709[0] * rgb[..., 0] + _REC709[1] * rgb[..., 1] + _REC709[2] * rgb[..., 2] + + +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 _upsample(grid: np.ndarray, shape: tuple[int, int]) -> np.ndarray: + """(rows, cols) グリッドを (H, W) へバイリニア補間する (run_uniformity_dng.py と同一).""" + rows, cols = grid.shape + h, w = shape + ys = np.linspace(0, rows - 1, h) + xs = np.linspace(0, cols - 1, w) + y0 = np.floor(ys).astype(int) + y1 = np.minimum(y0 + 1, rows - 1) + x0 = np.floor(xs).astype(int) + x1 = np.minimum(x0 + 1, cols - 1) + wy = (ys - y0)[:, None] + wx = (xs - x0)[None, :] + top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx + bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx + return top * (1 - wy) + bot * wy + + +def _lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> np.ndarray: + """meta.json lscMap (rows, cols, 4=[R,Geven,Godd,B]) → (H, W, 3) RGB ゲイン.""" + meta = json.loads(meta_path.read_text(encoding="utf-8")) + rows, cols = meta["lscMapRowCount"], meta["lscMapColumnCount"] + m = np.array(meta["lscMap"], dtype=np.float64).reshape(rows, cols, 4) + gr = _upsample(m[:, :, 0], shape) + gg = _upsample((m[:, :, 1] + m[:, :, 2]) / 2.0, shape) + gb = _upsample(m[:, :, 3], shape) + return np.stack([gr, gg, gb], axis=2) + + +def _load_real_darkroom() -> np.ndarray: + """暗室 DNG → linear raw → LSC 順適用 → Rec.709×255 → portrait → 白板 ROI (物理値).""" + rgb = load_image_dng(str(REAL_DNG), output_format="rgb_float32") # linear landscape + rgb = rgb * _lsc_gain_rgb(REAL_META, rgb.shape[:2]) # LSC 順方向適用 (ビネット除去) + luma = _rec709_luma(rgb) * 255.0 # 0〜255 linear スケール + luma = np.rot90(luma, -1) # portrait (PNG / ROI と同じ向き) + return _extract_roi(luma, REAL_ROI) + + +def _load_sim() -> np.ndarray: + """シミュ luminance.npy → ROI (物理値).""" + arr = np.load(SIM_NPY) + if arr.ndim == 3: + arr = _rec709_luma(arr) + return _extract_roi(arr, SIM_ROI) + + +def _minmax_normalize(roi: np.ndarray) -> np.ndarray: + """各画像を実画素の最小値→0・最大値→100 へ線形正規化する. + + 元の輝度マップ (expected_results の luminance_map.png) はデータ実 min/max へ + 自動スケールして描かれるため,実画素 min/max を用いると左パネル (実機) が + 元画像と同じ見た目になる.mean・全体コントラストの違いはキャンセルされ, + 空間パターン (形) だけが共有 0〜100 スケールで比較できる. + クリップは起きない (両端が実 min/max に一致するため). + 注: 動径 min/max と違い画素ノイズの外れ値にレンジ端が引っ張られうる. + """ + vmin = float(roi.min()) + vmax = float(roi.max()) + return (roi - vmin) / (vmax - vmin) * 100.0 + + +def _compute_metrics(roi: np.ndarray) -> dict[str, float]: + """正規評価関数で CoV / 勾配% / C/P / 動径 min/max を算出する (CLI と同一定義).""" + mean = float(roi.mean()) + cov = float(roi.std() / mean) + dist = compute_normalized_distance_map(*roi.shape) + zone_stats = calc_zone_stats(roi, classify_zones(dist)) + rmm = calc_radial_min_max_ratio(calc_radial_profile(roi)) + return { + "CoV": cov, + "grad": zone_stats["gradient_magnitude"], + "CP": zone_stats["center_periphery_ratio"], + "rmm": rmm["radial_min_max_ratio"], + } + + +def _title(name: str, subtitle: str, m: dict[str, float]) -> str: + return ( + f"{name}\n{subtitle}\n" + f"CoV={m['CoV']:.4f} grad={m['grad']:.2f}% " + f"C/P={m['CP']:.3f} r.min/max={m['rmm']:.3f}" + ) + + +def main() -> None: + real_raw = _load_real_darkroom() + sim_raw = _load_sim() + + # 指標は物理 ROI のまま算出 (CoV/勾配/C/P/動径 min/max はオフセット込み正規化で変わるため) + real_m = _compute_metrics(real_raw) + sim_m = _compute_metrics(sim_raw) + + # 表示用配列だけ「実画素 min→0 / max→100」で各画像独立に正規化 + # (左パネルが元の輝度マップと同じ見た目になる) + real_disp = _minmax_normalize(real_raw) + sim_disp = _minmax_normalize(sim_raw) + + # 共有スケール: 0〜100 固定 (各画像の実 min/max がこの両端に対応) + vmin, vmax = 0.0, 100.0 + + fig, axes = plt.subplots(1, 2, figsize=(15, 7)) + + axes[0].imshow(real_disp, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + axes[0].set_box_aspect(1.0) # パネルを正方形に固定 + axes[0].set_axis_off() + axes[0].set_title( + _title( + "Real (dark-room DNG, TARGET)", + "linear + LSC forward, white-board ROI", + real_m, + ), + fontsize=11, + loc="left", + ) + + last_im = axes[1].imshow(sim_disp, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + axes[1].set_box_aspect(1.0) # パネルを正方形に固定 + axes[1].set_axis_off() + axes[1].set_title( + _title( + "Simulation (baseline)", + "R=0.85, d=16, spp=1024, linear", + sim_m, + ), + fontsize=11, + loc="left", + ) + + cbar = fig.colorbar( + last_im, + ax=axes.tolist(), + shrink=0.75, + label="Normalized intensity (pixel min -> 0, max -> 100, per image)", + ) + cbar.ax.tick_params(labelsize=9) + + fig.suptitle( + "Simulation vs Dark-room Measurement " + "(shape-focused: each map normalized to its pixel min/max -> 0/100)", + fontsize=14, + fontweight="bold", + ) + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + plt.savefig(OUT_PATH, dpi=120, bbox_inches="tight") + print(f"Saved: {OUT_PATH}") + print(" Display normalization: per-image pixel min->0, max->100 (vmin/vmax=0/100)") + print(f" Real : CoV={real_m['CoV']:.4f} grad={real_m['grad']:.2f}% " + f"C/P={real_m['CP']:.3f} r.min/max={real_m['rmm']:.3f}") + print(f" Sim : CoV={sim_m['CoV']:.4f} grad={sim_m['grad']:.2f}% " + f"C/P={sim_m['CP']:.3f} r.min/max={sim_m['rmm']:.3f}") + + +if __name__ == "__main__": + main() diff --git a/scripts/highlight_led_unit.py b/scripts/highlight_led_unit.py new file mode 100644 index 0000000..cddb262 --- /dev/null +++ b/scripts/highlight_led_unit.py @@ -0,0 +1,157 @@ +"""SmTIASv3.stl の LED ユニットを基板別に色分けして可視化する. + +連結成分を解析して LED / カバーを抽出し,bottom/right/left の各基板を色分け, +シミュと同じ視点(+X が右・+Y が上 = orthographic カメラ Z=-50→+Z)で投影した +画像を出力する.「右の LED だけ」を強調確認するのが目的. + +実行方法: + uv run python scripts/highlight_led_unit.py +出力: + output/_slide_panels/led_unit_highlight.png +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path + +import matplotlib +import numpy as np +import trimesh + +matplotlib.use("Agg") +import matplotlib.font_manager as fm +import matplotlib.pyplot as plt + +ROOT = Path(__file__).resolve().parents[1] +STL = ROOT / "configs" / "meshes" / "SmTIASv3.stl" +OUT = ROOT / "output" / "_slide_panels" / "led_unit_highlight.png" + + +def _setup_font() -> None: + for path in ("C:/Windows/Fonts/meiryo.ttc", "C:/Windows/Fonts/YuGothM.ttc"): + if Path(path).exists(): + fm.fontManager.addfont(path) + plt.rcParams["font.family"] = fm.FontProperties(fname=path).get_name() + return + + +def _components(mesh: trimesh.Trimesh) -> list[list[int]]: + n = len(mesh.vertices) + parent = list(range(n)) + + def find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + for f in mesh.faces: + union(int(f[0]), int(f[1])) + union(int(f[1]), int(f[2])) + groups: dict[int, list[int]] = defaultdict(list) + for fi, f in enumerate(mesh.faces): + groups[find(int(f[0]))].append(fi) + return sorted(groups.values(), key=min) + + +def _stats(mesh: trimesh.Trimesh, faces: list[int]) -> dict: + vi = np.unique(mesh.faces[faces].flatten()) + v = mesh.vertices[vi] + bmin, bmax = v.min(0), v.max(0) + return {"center": (bmin + bmax) / 2, "extents": bmax - bmin, "faces": faces} + + +def _label(center: np.ndarray) -> str: + x, y, _ = center + if abs(y) > abs(x): + return "bottom" if y < 0 else "top" + return "right" if x > 0 else "left" + + +def main() -> int: + _setup_font() + mesh = trimesh.load_mesh(str(STL)) + comps = [_stats(mesh, g) for g in _components(mesh)] + + # LED とカバーを分類(extract スクリプトと同基準) + leds, covers = [], [] + for c in comps: + e = c["extents"] + if max(e) <= 4.0 and min(e) <= 1.5: + leds.append(c) + elif max(e) > 30.0 and min(e) <= 3.0: + covers.append(c) + + colors = {"bottom": "#2ca02c", "right": "#d62728", "left": "#1f77b4", "top": "#9467bd"} + + # ---- 投影プロット: シミュカメラ視点 (+X 右, +Y 上) ---- + fig, axes = plt.subplots(1, 2, figsize=(16, 8)) + + # 左: 全体うっすら + LED/カバーを基板別に色分け + ax = axes[0] + # 全体の頂点を薄いグレーで散布(間引き) + allv = mesh.vertices[::37] + ax.scatter(allv[:, 0], allv[:, 1], s=0.5, c="#cccccc", alpha=0.3) + + summary: dict[str, list[np.ndarray]] = defaultdict(list) + for grp, marker, size in ((covers, "s", 12), (leds, "o", 40)): + for c in grp: + lab = _label(c["center"]) + summary[lab].append(c["center"]) + vi = np.unique(mesh.faces[c["faces"]].flatten()) + v = mesh.vertices[vi] + ax.scatter(v[:, 0], v[:, 1], s=size, c=colors[lab], marker=marker, + edgecolors="none", alpha=0.7) + + ax.set_xlabel("物理空間 X+ (mm) → 画像右") + ax.set_ylabel("物理空間 Y+ (mm) ↑ 画像上") + ax.set_aspect("equal") + ax.set_title("LED ユニット基板別カラー (シミュ視点 +X右/+Y上)\n" + "緑=下 / 赤=右(X>0) / 青=左(X<0)", fontsize=11) + ax.grid(alpha=0.3) + ax.axvline(0, color="k", lw=0.5, alpha=0.5) + ax.axhline(0, color="k", lw=0.5, alpha=0.5) + + # 右: 右基板だけ強調(他は非表示) + ax2 = axes[1] + ax2.scatter(allv[:, 0], allv[:, 1], s=0.5, c="#eeeeee", alpha=0.3) + for grp, marker, size in ((covers, "s", 14), (leds, "o", 55)): + for c in grp: + lab = _label(c["center"]) + if lab != "right": + continue + vi = np.unique(mesh.faces[c["faces"]].flatten()) + v = mesh.vertices[vi] + ax2.scatter(v[:, 0], v[:, 1], s=size, c=colors["right"], marker=marker, + edgecolors="k", linewidths=0.3, alpha=0.85) + ax2.set_xlabel("物理空間 X+ (mm) → 画像右") + ax2.set_ylabel("物理空間 Y+ (mm) ↑ 画像上") + ax2.set_aspect("equal") + ax2.set_title("右 LED ユニットのみ強調 (X>0)", fontsize=11) + ax2.grid(alpha=0.3) + ax2.axvline(0, color="k", lw=0.5, alpha=0.5) + ax2.axhline(0, color="k", lw=0.5, alpha=0.5) + + fig.suptitle("SmTIASv3.stl LED ユニット 右/左/下 の確認", fontsize=13, fontweight="bold") + plt.tight_layout() + OUT.parent.mkdir(parents=True, exist_ok=True) + plt.savefig(OUT, dpi=110, bbox_inches="tight") + + # コンソールサマリ + print(f"saved: {OUT}") + for lab in ("bottom", "right", "left", "top"): + if lab in summary: + cs = np.array(summary[lab]) + print(f" {lab:6s}: {len(cs)} comp(s), center mean=(" + f"{cs[:,0].mean():+.1f}, {cs[:,1].mean():+.1f}, {cs[:,2].mean():+.1f})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sweep_led_distribution.py b/scripts/sweep_led_distribution.py new file mode 100644 index 0000000..e22bc4e --- /dev/null +++ b/scripts/sweep_led_distribution.py @@ -0,0 +1,207 @@ +"""Step 8-14 候補 ξ: LED 配光 cutoff_angle 5 値スイープ. + +spot emitter の cutoff_angle を変えて配光の鋭さが「形」に効くか感度分析する. +データシート整合は気にしない感度分析. + +スイープグリッド: + cutoff_angle ∈ {30°, 60°, 90°, 120°, 150°} + beam_width = cutoff_angle / 3 で連動 (ビーム形状を相似に保つ) + +実装方針: + - configs/scene_baseline_spot.yaml をテンプレ (Step 8-10 で作成) + - max_depth を 16 に書き換え (Step 8-14 α 前半の新基準) + - 全 LED の cutoff_angle と beam_width を一括書き換え + - intensity (flux) は補正せず → mean アンカー正規化で吸収 + +期待する観察: + - 鋭いビーム (cutoff=30°) → 評価板の特定領域に集中 → angular_ratio 増? + - 広いビーム (cutoff=150°) → 多重反射主導でほぼ ambient → angular_ratio 減? + - 実機 (中央ピーク楕円・同心円的) に近づく cutoff を探索 + +実行方法: + uv run python scripts/sweep_led_distribution.py + +出力: + - output/led_distribution_sweep/c{N}/luminance.npy + - output/led_distribution_sweep/c{N}/metrics.csv + - output/led_distribution_sweep/summary.csv + - configs/_tmp_distribution/scene_c{N}.yaml + +TECH_10 参照: 残差主因候補 §A2/A3「LED 配光モデル」 +""" + +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_spot.yaml" +EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_distribution" +OUTPUT_BASE = ROOT / "output" / "led_distribution_sweep" + +# スイープ値 (degree) +CUTOFFS = [30, 60, 90, 120, 150] + +SUMMARY_EXTRA_COLS = [ + # 既存 4 指標 + 補助 + "cov", "std", "max_min_ratio", "mean", "max", "min", + "center_periphery_ratio", "gradient_magnitude", + "radial_min_max_ratio", "radial_min_distance", "radial_min", "radial_max", + # 新 5 指標 + "intensity_centroid_x_px", "intensity_centroid_y_px", + "intensity_centroid_x_norm", "intensity_centroid_y_norm", + "intensity_centroid_offset_norm", + "angular_variance", "radial_variance", "angular_ratio", + "pca_primary_axis_angle_deg", "pca_eccentricity", + "ssim", "ncc", +] + + +def _c_tag(c: int) -> str: + """cutoff 角度をファイル名タグに (90 → c090).""" + return f"c{c:03d}" + + +def _generate_tmp_yaml(cutoff_deg: int) -> Path: + """指定 cutoff_angle の一時 YAML を生成 (beam_width=cutoff/3 で連動).""" + 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 相対パスを絶対化 + 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()) + + # 全 LED の cutoff_angle と beam_width を書き換え (beam = cutoff/3 で相似) + beam_width_deg = cutoff_deg / 3.0 + for light in data["lights"]: + if light.get("type") == "spot": + light["cutoff_angle"] = float(cutoff_deg) + light["beam_width"] = float(beam_width_deg) + + # max_depth を 16 に更新 (Step 8-14 α 前半の新基準) + data["render"]["max_depth"] = 16 + data["name"] = f"baseline_minitias_distribution_{_c_tag(cutoff_deg)}" + + tmp_path = TMP_DIR / f"scene_{_c_tag(cutoff_deg)}.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(cmd: list[str], label: str) -> int: + 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_path: Path) -> dict: + with csv_path.open(encoding="utf-8", newline="") as f: + rows = list(csv.DictReader(f)) + if not rows: + msg = f"metrics.csv が空: {csv_path}" + raise ValueError(msg) + return rows[0] + + +def main() -> int: + 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) + + print(f"=== LED distribution sweep: {len(CUTOFFS)} jobs ===") + print(f" cutoffs: {CUTOFFS} (beam_width = cutoff/3 で連動)") + print(f" output: {OUTPUT_BASE}") + print() + + failed: list[str] = [] + rows: list[dict] = [] + total_start = time.time() + + for i, c in enumerate(CUTOFFS, start=1): + job_name = _c_tag(c) + output_dir = OUTPUT_BASE / job_name + metrics_csv = output_dir / "metrics.csv" + + print(f"--- Job {i}/{len(CUTOFFS)}: {job_name} (cutoff={c}°, beam={c/3:.1f}°) ---") + job_start = time.time() + + tmp_yaml = _generate_tmp_yaml(c) + print(f" tmp YAML: {tmp_yaml}") + + if _run(["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)], + f"sm-render ({job_name})") != 0: + failed.append(f"{job_name} [render]") + print(" -> スキップ: render 失敗\n") + continue + + npy = output_dir / "luminance.npy" + if _run(["sm-evaluate", "--image", str(npy), "--config", str(EVAL_CONFIG_YAML), + "--output", str(output_dir)], f"sm-evaluate ({job_name})") != 0: + failed.append(f"{job_name} [evaluate]") + print(" -> スキップ: evaluate 失敗\n") + continue + + try: + m = _read_metrics(metrics_csv) + except (FileNotFoundError, ValueError) as exc: + print(f" [ERROR] metrics.csv 読み込み失敗: {exc}", file=sys.stderr) + failed.append(f"{job_name} [metrics_read]") + print() + continue + + elapsed = time.time() - job_start + cov = m.get("cov", "N/A") + grad = m.get("gradient_magnitude", "N/A") + ar = m.get("angular_ratio", "N/A") + print(f" -> 完了 ({elapsed:.1f}s) CoV={cov}, grad={grad}%, angular_ratio={ar}") + + row: dict = { + "job": job_name, + "cutoff_angle": c, + "beam_width": c / 3.0, + "elapsed_s": f"{elapsed:.1f}", + } + for col in SUMMARY_EXTRA_COLS: + row[col] = m.get(col, "") + rows.append(row) + print() + + summary_csv = OUTPUT_BASE / "summary.csv" + if rows: + fieldnames = ["job", "cutoff_angle", "beam_width", "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(rows) + print(f"集約 CSV 保存: {summary_csv}") + + total_elapsed = time.time() - total_start + print(f"\n=== 完了: {len(rows)}/{len(CUTOFFS)} 成功, 総時間 {total_elapsed:.1f}s ===") + + if failed: + print("失敗ジョブ:", failed, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sm_tias_lightsim/cli/visualize.py b/src/sm_tias_lightsim/cli/visualize.py index 6e5539e..5092611 100644 --- a/src/sm_tias_lightsim/cli/visualize.py +++ b/src/sm_tias_lightsim/cli/visualize.py @@ -17,6 +17,9 @@ 4. --bake-cache 指定時はキャッシュ npz を読み書きする(path ベイクのみ有効). キャッシュが存在すれば Mitsuba 起動なしで即時読込,なければベイク後に npz 保存. 5. show_scene で PyVista 表示(または screenshot 保存)を行う. + --style slide: 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・ + アンチエイリアス・黒文字スカラバー・センサマーカー非表示). + --cmap NAME: カラーマップ名(未指定時は style に応じて default=viridis / slide=hot). 6. --output 指定があれば per-face irradiance を npz 形式で保存する. """ @@ -181,6 +184,30 @@ ), ) parser.add_argument( + "--bake-smooth-radius", + type=float, + default=None, + metavar="MM", + help=( + "ベイク着色を物理半径 MM mm のガウス重み付き平均で平滑化する(既定: なし)." + " 三角形スケールの MC ノイズを消し,半径より大きい照明構造は保つ." + " --bake-denoise の後・--bake-smooth の前に適用する." + " 投影モード・ベイク計算ロジックには影響しない." + ), + ) + parser.add_argument( + "--bake-denoise", + type=int, + default=0, + metavar="N", + help=( + "ベイク着色のメディアンデノイズ反復回数(既定 0=なし)." + " MC ノイズ(firefly)をエッジを保ったまま除去する." + " --bake-smooth の拡散平滑化より先に適用される." + " 投影モード・ベイク計算ロジックには影響しない." + ), + ) + parser.add_argument( "--bake-scale", choices=["linear", "log"], default="log", @@ -205,6 +232,18 @@ " --bake-mode direct では無視される(direct ベイクは path integrator を使わない)." ), ) + parser.add_argument( + "--bake-subdivide", + type=float, + default=None, + metavar="MM", + help=( + "ベイク前に三角形の最大辺長を指定 mm 以下まで均一分割する(既定: 分割なし)." + " 巨大三角形のブロック感を解消する." + " --bake-cache 使用時はキャッシュに設定値を記録し,次回以降の読み込み時に" + " 設定が変わっていれば警告を出して再ベイクする." + ), + ) # BooleanOptionalAction が Windows CP932 環境で --help 表示時に ↔ を出力し # UnicodeEncodeError になる問題を回避するため、store_true / store_false で実装する. _cull_group = parser.add_mutually_exclusive_group() @@ -253,6 +292,43 @@ " メッシュ着色とカラーバーは残す." ), ) + parser.add_argument( + "--style", + choices=["default", "slide"], + default="default", + metavar="STYLE", + help=( + "表示スタイルプリセット." + "'default'(既定): 現行表示そのまま(後方互換)." + "'slide': 発表スライド用クリーン体裁(背景純白・座標軸/bounds 非表示・" + "アンチエイリアス有効・scalar bar 黒文字・センサマーカー非表示)." + " screenshot 保存(--screenshot / --off-screen)にも反映される." + ), + ) + parser.add_argument( + "--cmap", + default=None, + metavar="NAME", + help=( + "カラーマップ名(matplotlib 互換)." + "未指定(既定): style に応じて自動決定する" + "(default → viridis、slide → hot)." + "明示指定時は全スタイルでそのカラーマップを使用する" + "(壁ベイク・評価板テクスチャ・スカラバーの配色が一致する)." + "不正な名前は warning を出して style 既定にフォールバックする." + ), + ) + parser.add_argument( + "--backface-cull", + action="store_true", + default=False, + help=( + "カメラに背を向けた面を描画しない(背面カリング)." + " --opacity 1.0 と併用すると,手前の壁が自動的に隠れて内部が混色なしで見える" + "(スライド/デモ向き)." + " 既定 OFF(後方互換)." + ), + ) return parser.parse_args(argv) @@ -288,6 +364,14 @@ bake_samples: int = int(args.bake_samples) bake_cache_path: Path | None = args.bake_cache.resolve() if args.bake_cache else None bake_max_depth: int = int(args.bake_max_depth) + # --bake-subdivide は mm 単位で受け取り、baker / viewer へは m 系で渡す(_MM_TO_M 規約) + bake_subdivide_m: float | None = ( + float(args.bake_subdivide) * 0.001 if args.bake_subdivide is not None else None + ) + # --bake-smooth-radius は mm 単位で受け取り viewer へは m 単位で渡す(_MM_TO_M 規約) + bake_smooth_radius_m: float | None = ( + float(args.bake_smooth_radius) * 0.001 if args.bake_smooth_radius is not None else None + ) cull_outward: bool = bool(args.cull_outward) # --no-markers は --no-sensor-marker / --no-led-markers 両方の互換ショートカット hide_sensor_marker: bool = bool(args.no_sensor_marker) or bool(args.no_markers) @@ -302,7 +386,11 @@ bake_opacity: float = float(args.opacity) hide_below: float = float(args.hide_below) bake_smooth: int = int(args.bake_smooth) + bake_denoise: int = int(args.bake_denoise) bake_scale: str = args.bake_scale + style: str = args.style # "default" または "slide" + cmap: str | None = args.cmap # None のとき style に応じて自動決定 + backface_cull: bool = bool(args.backface_cull) # YAML 読み込み try: @@ -392,20 +480,53 @@ if bake_cache_path.exists(): print(f"キャッシュを読み込みます(ベイク再計算なし): {bake_cache_path}") try: - npz = np.load(str(bake_cache_path)) - irradiance = {k: npz[k] for k in npz.files} + npz = np.load(str(bake_cache_path), allow_pickle=False) + irradiance = {k: npz[k] for k in npz.files if k != "_subdivide_setting"} # 形状・型の簡易チェック for k, v in irradiance.items(): if v.ndim != 1: msg = f"キャッシュ '{k}' の配列形状が不正です: ndim={v.ndim}" raise ValueError(msg) - cache_loaded = True - print(f"キャッシュ読み込み完了.メッシュ数={len(irradiance)}") - for mesh_id, irr in irradiance.items(): - print( - f" {mesh_id}: shape={irr.shape} min={irr.min():.4e} " - f"max={irr.max():.4e}" + + # subdivide_max_edge 設定の整合チェック + # キャッシュに _subdivide_setting が無い場合は「分割なし」とみなす + if "_subdivide_setting" in npz.files: + cached_subdivide_m = float(npz["_subdivide_setting"]) + else: + cached_subdivide_m = float("nan") # 旧キャッシュ: 分割なし扱い + + _cached_none = np.isnan(cached_subdivide_m) + _current_none = bake_subdivide_m is None + _mismatch = (_cached_none != _current_none) or ( + not _cached_none + and not _current_none + and abs(cached_subdivide_m - bake_subdivide_m) > 1e-9 # type: ignore[operator] + ) + if _mismatch: + _cached_str = ( + "なし" if _cached_none else f"{cached_subdivide_m * 1000:.1f} mm" ) + _current_str = ( + "なし" + if bake_subdivide_m is None + else f"{bake_subdivide_m * 1000:.1f} mm" + ) + print( + f"警告: キャッシュの subdivide 設定({_cached_str})と" + f" 現在の指定({_current_str})が不一致です." + " キャッシュを無視して再ベイクします.", + file=sys.stderr, + ) + irradiance = None + cache_loaded = False + else: + cache_loaded = True + print(f"キャッシュ読み込み完了.メッシュ数={len(irradiance)}") + for mesh_id, irr in irradiance.items(): + print( + f" {mesh_id}: shape={irr.shape} min={irr.min():.4e} " + f"max={irr.max():.4e}" + ) except Exception as exc: # noqa: BLE001 print( "警告: キャッシュの読み込みに失敗しました" @@ -427,6 +548,7 @@ samples=bake_samples, exclude_mesh_ids=exclude_ids, max_depth=bake_max_depth, + subdivide_max_edge=bake_subdivide_m, ) else: irradiance = bake_irradiance( @@ -434,13 +556,25 @@ project_root, mode="direct", exclude_mesh_ids=exclude_ids, + subdivide_max_edge=bake_subdivide_m, ) # path ベイク後にキャッシュを保存する + # _subdivide_setting キーに設定値を記録して次回読み込み時の整合チェックに使う if bake_mode == "path" and bake_cache_path is not None and irradiance: try: bake_cache_path.parent.mkdir(parents=True, exist_ok=True) - np.savez(str(bake_cache_path), **irradiance) + # subdivide 設定を scalar float 配列として保存 + # None(分割なし)の場合は nan で保存する(後方互換のため旧キャッシュは + # _subdivide_setting キーなし=分割なしとして読み込まれる) + _subdivide_setting_val = ( + bake_subdivide_m if bake_subdivide_m is not None else float("nan") + ) + np.savez( + str(bake_cache_path), + _subdivide_setting=np.float64(_subdivide_setting_val), + **irradiance, + ) print(f"ベイクキャッシュを保存しました: {bake_cache_path}") except Exception as exc: # noqa: BLE001 print( @@ -495,11 +629,17 @@ clip_high=clip_high, bake_opacity=bake_opacity, hide_below=hide_below, + bake_denoise=bake_denoise, bake_smooth=bake_smooth, bake_scale=bake_scale, cull_outward=cull_outward, hide_sensor_marker=hide_sensor_marker, hide_led_markers=hide_led_markers, + style=style, + cmap=cmap, + subdivide_max_edge=bake_subdivide_m, + bake_smooth_radius=bake_smooth_radius_m, + backface_cull=backface_cull, ) except Exception as exc: # noqa: BLE001 print(f"エラー: PyVista 表示に失敗しました — {exc}", file=sys.stderr) diff --git a/src/sm_tias_lightsim/geometry/__init__.py b/src/sm_tias_lightsim/geometry/__init__.py index 81752ba..5fbb8d0 100644 --- a/src/sm_tias_lightsim/geometry/__init__.py +++ b/src/sm_tias_lightsim/geometry/__init__.py @@ -1,6 +1,7 @@ -"""geometry パッケージ — STL 読み込みと単位変換.""" +"""geometry パッケージ — STL 読み込み・単位変換・メッシュ分割.""" from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl +from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge from sm_tias_lightsim.geometry.transforms import mm_to_m, scale_vertices -__all__ = ["LoadedMesh", "load_stl", "mm_to_m", "scale_vertices"] +__all__ = ["LoadedMesh", "load_stl", "mm_to_m", "scale_vertices", "subdivide_mesh_to_max_edge"] diff --git a/src/sm_tias_lightsim/geometry/subdivide.py b/src/sm_tias_lightsim/geometry/subdivide.py new file mode 100644 index 0000000..3bee26d --- /dev/null +++ b/src/sm_tias_lightsim/geometry/subdivide.py @@ -0,0 +1,58 @@ +"""メッシュ三角形分割ユーティリティモジュール. + +STL メッシュの三角形を最大辺長以下に均一分割する純関数を提供する. +per-face ベイクにおいて巨大三角形のブロック感を解消するために使う. +""" + +from __future__ import annotations + +import numpy as np + + +def subdivide_mesh_to_max_edge( + vertices: np.ndarray, + faces: np.ndarray, + max_edge: float, +) -> tuple[np.ndarray, np.ndarray]: + """メッシュの全辺長が max_edge 以下になるまで三角形を再分割する(決定論的). + + ``trimesh.remesh.subdivide_to_size`` を内部で呼び出す. + 同一の (vertices, faces, max_edge) を与えれば常に同一の出力を返すため, + baker と viewer が独立に呼んでも face 配列が一致する. + + max_edge の単位は呼び出し側の頂点座標系に従う. + baker / viewer では mm → m 変換後の頂点配列(m 単位)に対して呼ぶため, + max_edge も m 単位で渡すこと(例: 10 mm → max_edge=0.010). + + 注意: trimesh.remesh.subdivide_to_size は "triangle soup" を返す(頂点共有なし). + per-face ベイクではフェイスの重心・法線を計算するだけなので共有なしで問題ない. + ただし bake_smooth(Laplacian 平滑化)は隣接情報に依存するため, + 分割後のトポロジーが変わると近傍関係が変わる点に留意する(精度は許容範囲内). + + Args: + vertices: 頂点配列 (N_v, 3),呼び出し側の座標系単位(m 推奨). + faces: 三角形面配列 (N_f, 3),頂点インデックス. + max_edge: 各辺の最大許容長.この値以下になるまで分割を繰り返す. + 単位は vertices と同じ(m 単位で渡すなら m). + + Returns: + (new_vertices, new_faces) のタプル. + - new_vertices: 分割後の頂点配列 (N_v', 3),dtype=float64. + - new_faces: 分割後の三角形面配列 (N_f', 3),dtype=int64. + N_f' >= N_f (分割により face 数は増える). + + Raises: + ValueError: max_edge が 0 以下の場合. + """ + if max_edge <= 0.0: + msg = f"max_edge は正の値でなければなりません (got {max_edge})." + raise ValueError(msg) + + import trimesh + + new_verts, new_faces = trimesh.remesh.subdivide_to_size( + vertices=vertices.astype(np.float64), + faces=faces.astype(np.int64), + max_edge=max_edge, + ) + return new_verts.astype(np.float64), new_faces.astype(np.int64) diff --git a/src/sm_tias_lightsim/visualize/baker.py b/src/sm_tias_lightsim/visualize/baker.py index 5ac0da0..5b7bb82 100644 --- a/src/sm_tias_lightsim/visualize/baker.py +++ b/src/sm_tias_lightsim/visualize/baker.py @@ -28,6 +28,7 @@ import numpy as np from sm_tias_lightsim.geometry.loader import load_stl +from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge from sm_tias_lightsim.geometry.transforms import scale_vertices from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status from sm_tias_lightsim.scene.schema import ( @@ -194,6 +195,7 @@ samples: int = 64, exclude_mesh_ids: set[str] | None = None, max_depth: int | None = None, + subdivide_max_edge: float | None = None, ) -> dict[str, np.ndarray]: """各メッシュの face 中心で照度(W/m²)を計算する. @@ -224,6 +226,12 @@ 指定時はその値を使う(render 設定から独立して制御できる). mode="direct" では無視される(direct ベイクは path integrator を使わない). SPEC_03: depth≥12 推奨(depth=8 は後段多重反射を取りこぼし勾配を過大評価する). + subdivide_max_edge: ベイク前に各メッシュの三角形を最大辺長がこの値以下になるまで + 再分割する(m 単位).None(既定)のとき分割なし(後方互換). + STL の面サイズが不均一な場合(平面壁に巨大三角形がある場合等)に + per-face ベイクのブロック感を解消できる. + mode="direct" / "path" 両方で適用される. + 例: 10 mm の均一解像度にするには 0.010 を渡す. Returns: {mesh_id: irradiance_per_face (N_f,) float32} の辞書. @@ -271,7 +279,11 @@ try: scene = mi.load_dict(scene_dict) return _bake_direct( - scene_config, project_root, scene, exclude_mesh_ids=exclude_mesh_ids + scene_config, + project_root, + scene, + exclude_mesh_ids=exclude_mesh_ids, + subdivide_max_edge=subdivide_max_edge, ) finally: shutil.rmtree(tempdir, ignore_errors=True) @@ -283,6 +295,7 @@ samples=samples, exclude_mesh_ids=exclude_mesh_ids, max_depth=max_depth, + subdivide_max_edge=subdivide_max_edge, ) @@ -327,6 +340,7 @@ samples: int, exclude_mesh_ids: set[str] | None = None, max_depth: int | None = None, + subdivide_max_edge: float | None = None, ) -> dict[str, np.ndarray]: """多重反射込み path ベイクの内部実装. @@ -351,6 +365,8 @@ None のとき scene_config.render.max_depth を使用(後方互換). 指定時はその値で scene_dict["integrator"]["max_depth"] を上書きしてから mi.load_dict を呼ぶ(render 設定から独立して制御できる). + subdivide_max_edge: ベイク前に各メッシュの三角形を最大辺長がこの値以下に + なるまで再分割する(m 単位).None のとき分割なし(後方互換). Returns: {mesh_id: irradiance_per_face (N_f,) float32}. @@ -413,7 +429,29 @@ loaded = load_stl(stl_path, mesh_cfg.unit) scaled = scale_vertices(loaded, target_unit="m") - centroids, normals = _compute_face_centroids_and_normals(scaled.vertices, scaled.faces) + # --bake-subdivide 指定時: ベイク前に三角形を最大辺長以下に再分割する + # 平面壁の巨大三角形(最大一辺 ~70 mm 相当)によるブロック感を解消する + bake_vertices, bake_faces = scaled.vertices, scaled.faces + if subdivide_max_edge is not None: + n_before = len(bake_faces) + bake_vertices, bake_faces = subdivide_mesh_to_max_edge( + bake_vertices, bake_faces, subdivide_max_edge + ) + n_after = len(bake_faces) + _logger.info( + "path ベイク: メッシュ '%s' 三角形分割 faces %d → %d" + " (max_edge=%.4f m)", + mesh_cfg.id, + n_before, + n_after, + subdivide_max_edge, + ) + print( + f" subdivide: mesh='{mesh_cfg.id}' faces {n_before} → {n_after}" + f" (max_edge={subdivide_max_edge * 1000:.1f} mm)" + ) + + centroids, normals = _compute_face_centroids_and_normals(bake_vertices, bake_faces) n_faces = len(centroids) irradiance = np.zeros(n_faces, dtype=np.float64) @@ -502,6 +540,7 @@ scene: object, *, exclude_mesh_ids: set[str] | None = None, + subdivide_max_edge: float | None = None, ) -> dict[str, np.ndarray]: """直接照度ベイクの内部実装. @@ -512,6 +551,8 @@ project_root: STL 相対パスの基点ディレクトリ. scene: Mitsuba Scene オブジェクト(load_dict 済み). exclude_mesh_ids: ベイク計算からスキップする mesh_id の集合(任意). + subdivide_max_edge: ベイク前に各メッシュの三角形を最大辺長がこの値以下に + なるまで再分割する(m 単位).None のとき分割なし(後方互換). Returns: {mesh_id: irradiance_per_face (N_f,) float32}. @@ -533,7 +574,28 @@ loaded = load_stl(stl_path, mesh_cfg.unit) scaled = scale_vertices(loaded, target_unit="m") - centroids, normals = _compute_face_centroids_and_normals(scaled.vertices, scaled.faces) + # --bake-subdivide 指定時: ベイク前に三角形を最大辺長以下に再分割する + bake_vertices, bake_faces = scaled.vertices, scaled.faces + if subdivide_max_edge is not None: + n_before = len(bake_faces) + bake_vertices, bake_faces = subdivide_mesh_to_max_edge( + bake_vertices, bake_faces, subdivide_max_edge + ) + n_after = len(bake_faces) + _logger.info( + "direct ベイク: メッシュ '%s' 三角形分割 faces %d → %d" + " (max_edge=%.4f m)", + mesh_cfg.id, + n_before, + n_after, + subdivide_max_edge, + ) + print( + f" subdivide: mesh='{mesh_cfg.id}' faces {n_before} → {n_after}" + f" (max_edge={subdivide_max_edge * 1000:.1f} mm)" + ) + + centroids, normals = _compute_face_centroids_and_normals(bake_vertices, bake_faces) n_faces = len(centroids) irradiance = np.zeros(n_faces, dtype=np.float32) diff --git a/src/sm_tias_lightsim/visualize/viewer.py b/src/sm_tias_lightsim/visualize/viewer.py index da24631..089c44e 100644 --- a/src/sm_tias_lightsim/visualize/viewer.py +++ b/src/sm_tias_lightsim/visualize/viewer.py @@ -3,6 +3,27 @@ シーンのメッシュ・LED・センサを PyVista で可視化する. 照度ベイク結果 (irradiance) があればメッシュを照度で着色する. +## 背面カリング(backface_cull 引数) + +show_scene の `backface_cull=True` を指定すると,ベイク着色経路・ニュートラルグレー背景の +各 add_mesh に `culling="back"` を渡し,カメラに背を向けた面を描画しない VTK 標準の +レンダリング時カリングを有効化する. + +効果: + - 不透明表示(bake_opacity=1.0)でも手前の壁が自動的に消えて内部が見える + (dollhouse 表示). + - cull_outward(幾何的な静的カリング)と組み合わせると, + 外向き面の除去(静的)+ カメラ向きに応じた動的カリングの 2 段構成になる. + - bake_opacity<1 の半透明表示でも手前の面による混色ブレンドが軽減される. + +注意(巻き順依存): + 面の法線は STL の巻き順に依存する.cull_outward で内向き face のみが残っている場合, + 内向き face の法線はシーン中心向き = 外から覗くとカメラに背を向けることになり, + 期待どおり手前の壁が消える. + ただし巻き順が乱れた STL では面の一部が欠けて見える可能性がある + (SmTIAS シーンの検証 STL では妥当). + マーカー(LED/センサ球・矢印)には適用しない. + ## 投影モード(--render 引数) luminance_map(2D 輝度マップ, np.ndarray)が指定された場合、 @@ -52,11 +73,26 @@ 2. log 変換: log10(max(irradiance, eps)) を全残 face に適用(eps=1e-3). 3. log 空間で共有 clim 算出(percentile クリップ) → scalar bar タイトル「Irradiance (W/m², log10)」. - 4. bake_smooth 平滑化: log 空間スカラに対して Laplacian 拡散(N 回 往復). - 5. cell → point 補間(Gouraud シェーディング用). - 6. add_mesh. + 4. bake_denoise メディアンデノイズ: log 空間スカラに対して face 隣接メディアン(N 回). + MC ノイズ(firefly=一面だけ飛び抜けた値)をエッジを保ったまま除去する. + bake_scale="linear" 時は log 変換なしの cell_data スカラに適用. + 5. bake_smooth_radius ガウス平滑化: log 空間スカラに対して物理半径指定のガウス重み付き + 平均平滑化(ボクセル splat/gather 方式,O(N),密度ヘテロ耐性あり, + メモリ O(N + ユニークボクセル数) で有界). + bake_denoise の後・bake_smooth の前. + 三角形スケールの MC ノイズを消し,radius より大きい照明構造は保つ. + 6. bake_smooth 平滑化: log 空間スカラに対して Laplacian 拡散(N 回 往復). + 7. cell → point 補間(Gouraud シェーディング用). + 8. add_mesh. -bake_scale="linear" 時は従来どおり(後方互換). +bake_scale="linear" 時の処理順序: + 1. bake_denoise メディアンデノイズ: cell_data スカラ(linear 値)に適用. + 2. bake_smooth_radius ガウス平滑化: cell_data スカラ(linear 値)に適用(bake_denoise の後). + 3. bake_smooth Laplacian 拡散(N 回 往復). + 4. hide_below フィルタ(平滑化後). + 5. add_mesh. + +bake_scale="linear" のデノイズ適用は平滑化前(cell_data がある段階)で行う(後方互換). ## 評価板照度換算(E_board) @@ -70,6 +106,32 @@ この換算により評価板テクスチャと壁ベイク照度が同一物理量(W/m²)になり、 共有スケール・単一カラーバーで直接比較できる. + +## 表示スタイルプリセット(style 引数) + +show_scene の `style` 引数で発表スライド用の体裁に切り替えられる. + + - "default"(既定): 現行表示そのまま(後方互換).座標軸ウィジェット・ + bounds グリッドを表示し、背景は PyVista 既定色、scalar bar は既定体裁. + センサマーカーを表示する.カラーマップ既定は viridis. + - "slide": 発表スライド用クリーン体裁.背景を純白にし、座標軸ウィジェットと + bounds グリッドを非表示にし、アンチエイリアスを有効化し、scalar bar を + 黒文字でクリーンな体裁にする.センサマーカー(青い球+視線矢印)を非表示にする + (hide_sensor_marker=True と同等).カラーマップ既定は hot. + +scalar bar の体裁は `_build_scalar_bar_args` で 1 箇所に集約し、viewer 内の +全 scalar bar 追加箇所(投影テクスチャのダミーバー・per-vertex フォールバック・ +壁ベイク)に同一の scalar_bar_args を適用する. + +## カラーマップ選択(cmap 引数) + +show_scene の `cmap` 引数でカラーマップ名を指定できる. + + - None(既定): style に応じて自動決定する. + - "default" → "viridis"(後方互換) + - "slide" → "hot"(照明均一性評価と同一のカラーマップ) + - 文字列指定: 全スタイルでそのカラーマップを使用する. + 不正な名前は warning を出して style 既定にフォールバックする. """ from __future__ import annotations @@ -81,6 +143,7 @@ import numpy as np from sm_tias_lightsim.geometry.loader import load_stl +from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge from sm_tias_lightsim.geometry.transforms import scale_vertices from sm_tias_lightsim.scene.schema import ( AreaLight, @@ -124,6 +187,40 @@ # log10 変換の下限:irradiance が 0 や負の face を log10 で -3 に揃える _LOG10_EPS = 1e-3 +# サポートする表示スタイル(未知値は "default" にフォールバック) +_VALID_STYLES = ("default", "slide") + +# slide スタイルの scalar bar フォントサイズ(黒文字クリーン体裁) +_SLIDE_SCALAR_BAR_TITLE_FONT_SIZE = 20 +_SLIDE_SCALAR_BAR_LABEL_FONT_SIZE = 14 + + +def _build_scalar_bar_args(title: str, style: str) -> dict: + """scalar bar の表示引数(scalar_bar_args)をスタイルに応じて組み立てる. + + viewer 内には scalar bar を追加する箇所が複数ある(投影テクスチャのダミーバー・ + per-vertex フォールバック・壁ベイク)ため、体裁をこの 1 箇所に集約して全箇所に + 同一の引数を適用する. + + Args: + title: scalar bar のタイトル文字列. + style: 表示スタイル("default" または "slide"). + + Returns: + plotter.add_mesh の scalar_bar_args に渡す辞書. + "slide" のとき黒文字・フォントサイズ調整・fmt="%.2f" のクリーン体裁を付与する. + "default" のときは title のみ(現行どおり・後方互換). + """ + if style == "slide": + return { + "title": title, + "color": "black", + "title_font_size": _SLIDE_SCALAR_BAR_TITLE_FONT_SIZE, + "label_font_size": _SLIDE_SCALAR_BAR_LABEL_FONT_SIZE, + "fmt": "%.2f", + } + return {"title": title} + def _build_pyvista_polydata(vertices: np.ndarray, faces: np.ndarray) -> pv.PolyData: """頂点・面配列から PyVista PolyData を構築する. @@ -276,8 +373,9 @@ vmax: float, bake_scale: str, log10_eps: float, + cmap_name: str = "viridis", ) -> pv.Texture: - """照度マップ(W/m²)を共有スケールで viridis 着色した PyVista Texture を生成する. + """照度マップ(W/m²)を共有スケールで着色した PyVista Texture を生成する. 評価板の E_board(照度マップ, W/m²)を壁ベイクと同一の [vmin, vmax] および log/linear 変換規約で RGB uint8 画像化し pv.Texture として返す. @@ -289,7 +387,7 @@ 1. bake_scale=="log" のとき log10(max(irradiance_map, log10_eps)) を適用する. vmin/vmax も log 空間の値(呼び出し側が変換済み)であること. 2. [vmin, vmax] で [0, 1] 正規化する. - 3. matplotlib viridis で RGBA → RGB uint8 (H, W, 3) 画像化する. + 3. cmap_name のカラーマップで RGBA → RGB uint8 (H, W, 3) 画像化する. 4. pv.Texture を生成して返す. テクスチャ画像の行列方向は irradiance_map の (row, col) と同一(PyVista が VTK の @@ -301,9 +399,11 @@ vmax: 正規化の上限(log 空間のとき log10 済み値). bake_scale: "log" または "linear".壁ベイクと同一の変換規約を適用する. log10_eps: log 変換時のゼロ除算防止下限値(bake_scale=="log" 時のみ使用). + cmap_name: matplotlib カラーマップ名(既定 "viridis").壁ベイクと同一の + カラーマップを使うことで評価板テクスチャと壁ベイクの配色が一致する. Returns: - pv.Texture(viridis 着色・RGB uint8). + pv.Texture(cmap_name 着色・RGB uint8). """ import matplotlib import pyvista as pv @@ -322,18 +422,19 @@ else: normalized = np.clip((values - vmin) / (vmax - vmin), 0.0, 1.0) - # viridis で RGBA (H, W, 4) → RGB uint8 (H, W, 3) + # cmap_name で RGBA (H, W, 4) → RGB uint8 (H, W, 3) # matplotlib 3.7+ では colormaps[] を使う(get_cmap は非推奨) - viridis = matplotlib.colormaps["viridis"] - rgba = viridis(normalized) # (H, W, 4) float64 + colormap = matplotlib.colormaps[cmap_name] + rgba = colormap(normalized) # (H, W, 4) float64 rgb = (rgba[:, :, :3] * 255).astype(np.uint8) texture = pv.Texture(rgb) _logger.info( - "E_board テクスチャ生成: shape=%s bake_scale=%s " + "E_board テクスチャ生成: shape=%s bake_scale=%s cmap=%s " "E_board 値範囲=[%.4e, %.4e] 正規化 clim=[%.4f, %.4f]", rgb.shape, bake_scale, + cmap_name, float(irr2d.min()), float(irr2d.max()), vmin, @@ -442,6 +543,371 @@ return eval_board_id, projected_scalars, vertex_uv, camera_face_mask +def _get_triangle_faces(mesh: pv.DataSet) -> np.ndarray | None: + """PolyData または UnstructuredGrid から三角形 face 配列 (N_f, 3) を取得する. + + cull_outward 有効時の `extract_cells()` は UnstructuredGrid を返すため, + `.faces` プロパティが存在しない.両型を統一的に扱うためのヘルパ. + + Args: + mesh: pyvista.PolyData または pyvista.UnstructuredGrid. + + Returns: + 三角形面配列 (N_f, 3) int64,または取得不能・三角形以外混在時は None. + 三角形以外のセルが混在する場合は WARNING を出して None を返す + (呼び出し側でデノイズをスキップする安全網). + """ + import pyvista as pv + + if isinstance(mesh, pv.PolyData): + # PolyData: 全面が三角形なら faces は [3, v0, v1, v2, ...] の 1D 配列 + n_cells = mesh.n_cells + if n_cells == 0: + return None + try: + faces_flat = mesh.faces + # 各面の先頭は頂点数カウント(三角形なら 3) + # reshape(-1, 4) で [n_verts, v0, v1, v2] の 2D 配列に変換 + if len(faces_flat) == n_cells * 4: + faces_2d = faces_flat.reshape(-1, 4) + if np.any(faces_2d[:, 0] != 3): + _logger.warning( + "PolyData に三角形以外の face が混在しています.デノイズをスキップします." + ) + return None + return faces_2d[:, 1:].astype(np.int64) + else: + _logger.warning( + "PolyData.faces の形状が三角形メッシュと一致しません" + "(len=%d, n_cells=%d).デノイズをスキップします.", + len(faces_flat), + n_cells, + ) + return None + except Exception as exc: + _logger.warning( + "PolyData.faces 取得に失敗しました: %s.デノイズをスキップします.", exc + ) + return None + + elif isinstance(mesh, pv.UnstructuredGrid): + # UnstructuredGrid: cells_dict から VTK_TRIANGLE (celltype=5) を取得 + try: + cells_dict = mesh.cells_dict + except Exception as exc: + _logger.warning( + "UnstructuredGrid.cells_dict 取得に失敗しました: %s.デノイズをスキップします.", + exc, + ) + return None + + _vtk_triangle = 5 # VTK_TRIANGLE + if _vtk_triangle not in cells_dict: + _logger.warning( + "UnstructuredGrid に VTK_TRIANGLE (type=5) が見つかりません" + "(存在するセル型: %s).デノイズをスキップします.", + list(cells_dict.keys()), + ) + return None + + tri_faces = cells_dict[_vtk_triangle] # (N_f, 3) + + # 三角形以外が混在するセル型があれば警告 + non_tri_types = [k for k in cells_dict if k != _vtk_triangle] + if non_tri_types: + _logger.warning( + "UnstructuredGrid に三角形以外のセル型が混在しています" + "(types=%s).デノイズをスキップします.", + non_tri_types, + ) + return None + + # セル数と faces 行数の整合チェック + n_cells = mesh.n_cells + if len(tri_faces) != n_cells: + _logger.warning( + "UnstructuredGrid: cells_dict の三角形数 (%d) と n_cells (%d) が不一致." + "デノイズをスキップします.", + len(tri_faces), + n_cells, + ) + return None + + return tri_faces.astype(np.int64) + + else: + _logger.warning("未対応のメッシュ型 %s.デノイズをスキップします.", type(mesh).__name__) + return None + + +def denoise_face_scalars_median( + faces: np.ndarray, + values: np.ndarray, + n_iter: int, +) -> np.ndarray: + """per-face スカラに対してメディアンフィルタデノイズを適用する(ベクトル化実装). + + 各 face の値を「頂点を共有する隣接 face 群(自身含む)の中央値」で置き換える操作を + n_iter 回反復する.firefly(一面だけ飛び抜けた MC ノイズ)をエッジを保ったまま除去する. + + 実装方式(2段近似): + 1. 頂点→face の incidence を利用し「各頂点に接続する face 群の中央値」を + 頂点スカラとして計算する(np.argsort ベクトル化:O(N_f log N_f)). + 2. 各 face の新しいスカラを「その 3 頂点の頂点スカラの平均値」で決定する. + + 厳密な「face 近傍 face の中央値」ではなく頂点経由の 2 段近似だが、 + firefly 除去性能は同等でありフル Python ループ(O(N_f × 隣接数))と比較して + 数十万 face でも数秒以内に完了する. + + Args: + faces: 三角形面配列 (N_f, 3),頂点インデックス(非負整数). + values: per-face スカラ配列 (N_f,),float. + n_iter: デノイズ反復回数.0 の場合は入力をそのまま返す(恒等). + + Returns: + デノイズ後の per-face スカラ配列 (N_f,),入力と同 dtype. + + Note: + face 数 ~40 万・n_iter=3 で概ね 1〜3 秒(CPU,NumPy のみ,scipy 不要). + """ + if n_iter <= 0: + return values.copy() + + n_verts = int(faces.max()) + 1 + out = values.astype(np.float64) + + for _ in range(n_iter): + # ---- Step 1: 各頂点に接続する face スカラの中央値を計算する ---- + # faces を (N_f, 3) → (N_f * 3,) に展開し vertex インデックスと face 値を並べる + # これで "vertex → その頂点が属する face のスカラ値" のペアが得られる + vert_ids = faces.ravel() # (N_f * 3,) + face_vals = np.repeat(out, 3) # (N_f * 3,) — 各 face の値を 3 頂点分に複製 + + # vert_ids でソートして同じ頂点に属する face 値を連続させる + sort_idx = np.argsort(vert_ids, kind="stable") + sorted_verts = vert_ids[sort_idx] + sorted_vals = face_vals[sort_idx] + + # 各頂点ブロックの開始インデックスを取得する(np.searchsorted で境界を特定) + vert_range = np.arange(n_verts) + starts = np.searchsorted(sorted_verts, vert_range, side="left") + ends = np.searchsorted(sorted_verts, vert_range, side="right") + + # 各頂点ブロックの中央値を計算する(ブロック長が異なるためループが必要だが + # 頂点数は face 数より小さく、かつ np.median はブロック内のみなので許容範囲) + vert_medians = np.empty(n_verts, dtype=np.float64) + for vi in range(n_verts): + s, e = starts[vi], ends[vi] + if s < e: + vert_medians[vi] = np.median(sorted_vals[s:e]) + else: + # 孤立頂点(どの face にも属さない)は 0 にしておく + vert_medians[vi] = 0.0 + + # ---- Step 2: 各 face の新スカラを 3 頂点の中央値の平均で決定する ---- + # faces (N_f, 3) で vert_medians をインデックス引き → (N_f, 3) → 行平均 + out = vert_medians[faces].mean(axis=1) + + return out.astype(values.dtype) + + +def smooth_face_scalars_radius( + face_centroids: np.ndarray, + values: np.ndarray, + radius: float, + face_areas: np.ndarray | None = None, +) -> np.ndarray: + """per-face スカラを物理半径指定のガウス重み付き平均で平滑化する. + + 各 face の値を,同一メッシュ内で face 重心間距離が radius 以内の face 群の + 距離ガウス重み付き平均(面積重みも掛ける)で置き換える 1 パス処理. + 三角形スケールの MC ノイズ(パラパラ感)を消し,radius より大きい照明構造は保つ. + + 実装方式(ボクセル splat/gather,純 NumPy,O(N)): + 1. ボクセルサイズ h = radius / 2 で各 face 重心を整数ボクセル座標に量子化する. + 2. splat: np.add.at を用いてボクセルごとに Σ(w_area),Σ(w_area·v), + Σ(w_area·centroid) をボクセル集計配列(n_voxels 行)に集約する. + 3. LUT: ボクセル ID → 集計配列インデックスを int32 配列で O(1) 解決する + (unique_ids の [min, max] 区間を配列で覆い,存在しないボクセルを -1 とする). + 4. gather: 各軸 -2..+2 の最大 125 オフセット(ユークリッド 1.5×radius 足切り後 ~117)を + Python ループで回し,各回は n_voxels 要素の一括ベクトル演算で + ボクセル重心間距離ガウス重み × ボクセル総面積重みの加重平均を算出する. + 処理単位が n_voxels(≪ N)なので,高密度クラスタ(N ≫ n_voxels)で高速. + 5. face への配布: ボクセル結果を inverse_idx で O(N) に配布する. + + 精度上の近似: gather 段でボクセル重心間距離をガウス重みに使うため + (同一ボクセル内の全 face に同じ重みが適用される), + 厳密な per-face 距離ガウス加重と比べて ~1% 程度の誤差が生じる + (mean_rel_err < 1%,max_rel_err < 5%).照明可視化の平滑化用途では許容範囲. + + メモリ消費は O(N + ユニークボクセル数 + LUT span) で有界. + LUT span はシーン座標範囲 / h² 程度(典型的に ~60 MB @ int32). + サブディビジョン後の高密度クラスタ(N=17 万で K=7378 になる密度)でも + メモリ爆発しない(旧 cKDTree 密行列方式は 9.82 GiB でクラッシュしていた). + + 計算量: O(~117 × n_voxels + N)(n_voxels ≤ N). + 性能目安: N_f ≈ 20 万・radius = 0.008 m・CPU で 3〜6 秒(密度ヘテロ耐性あり). + N_f ≈ 10 万(実際の SmTIAS シーン規模)で ~2 秒. + + Args: + face_centroids: face 重心座標 (N_f, 3),m 単位. + values: per-face スカラ配列 (N_f,),float. + radius: 平滑化半径,m 単位.sigma = radius / 2 のガウス核を使用する. + face_areas: per-face 面積 (N_f,),m² 単位(省略可能). + 指定時はガウス重みに面積を掛けて大面積 face の寄与を重視する. + None のとき面積重みなし(均等ガウス重み). + + Returns: + 平滑化後の per-face スカラ配列 (N_f,),入力と同 dtype. + radius <= 0 または非有限の場合は入力のコピーを返す(WARNING ログ出力). + + Note: + face_centroids と values の長さが一致することを前提とする + (不一致時は ValueError が伝播する). + """ + if not (np.isfinite(radius) and radius > 0.0): + _logger.warning( + "smooth_face_scalars_radius: radius=%.4e は正の有限値でないため恒等を返します.", + radius, + ) + return values.copy() + + n_faces = len(face_centroids) + if n_faces == 0: + return values.copy() + + vals = values.astype(np.float64) + + # h = radius / 2 → sigma = h のガウス核を使用する + # このサイズにより radius カットオフを覆うオフセットは各軸 -2..+2 の 5 段で済む + h = radius / 2.0 + neg_inv_2sigma2 = -1.0 / (2.0 * h * h) # sigma = h + + # face_areas が None のときは均等重み(全 face の面積 = 1.0 として扱う) + w_area = face_areas.astype(np.float64) if face_areas is not None else np.ones(n_faces) + + # ---- Step 1: ボクセル座標への量子化 ---- + # 各 face 重心を floor(centroid / h) の整数 3D 座標に変換する + coords_min = face_centroids.min(axis=0) # (3,) — 座標原点シフト用 + shifted = face_centroids - coords_min # (N, 3) — 非負化 + voxel_ijk = np.floor(shifted / h).astype(np.int64) # (N, 3) + + # ---- Step 2: ボクセル ID の計算とユニーク化 ---- + # 3D ボクセル座標を 1D キーに変換する(stride は軸最大値から算出) + ijk_max = voxel_ijk.max(axis=0) + 1 # (3,) — 各軸のボクセル数 + stride_j = int(ijk_max[2]) + stride_i = int(ijk_max[1]) * stride_j + voxel_ids = ( + voxel_ijk[:, 0] * stride_i + + voxel_ijk[:, 1] * stride_j + + voxel_ijk[:, 2] + ) # (N,) int64 + + # ユニークなボクセル ID と face→ボクセルの逆引きインデックスを取得する + unique_ids, inverse_idx = np.unique(voxel_ids, return_inverse=True) + n_voxels = len(unique_ids) + + # ---- LUT(ルックアップテーブル)の構築 ---- + # ボクセル ID → 集計配列インデックスの O(1) 変換テーブル. + # unique_ids の [min, max] 区間を int32 配列で覆い,存在しないボクセルを -1 とする. + # メモリ: span × 4 bytes(int32).サブディビジョン後の典型的なシーンで 50〜100 MB 程度. + uid_min = int(unique_ids[0]) + uid_max = int(unique_ids[-1]) + lut_span = uid_max - uid_min + 1 + lut = np.full(lut_span, -1, dtype=np.int32) + lut[unique_ids - uid_min] = np.arange(n_voxels, dtype=np.int32) + + # ---- Step 3: splat(ボクセル単位の集約)---- + # 各ボクセルに対して Σ(w_area),Σ(w_area · v),Σ(w_area · centroid) を集計する + vox_w = np.zeros(n_voxels, dtype=np.float64) + vox_wv = np.zeros(n_voxels, dtype=np.float64) + vox_wc = np.zeros((n_voxels, 3), dtype=np.float64) + + np.add.at(vox_w, inverse_idx, w_area) + np.add.at(vox_wv, inverse_idx, w_area * vals) + np.add.at(vox_wc, inverse_idx, w_area[:, np.newaxis] * face_centroids) + + # ボクセル内加重重心と面積加重平均値を算出する(ゼロ除算防止) + safe_w = np.where(vox_w > 0.0, vox_w, 1.0) + vox_centroid = vox_wc / safe_w[:, np.newaxis] # (n_voxels, 3) + vox_mean_val = vox_wv / safe_w # (n_voxels,) + + # ---- ボクセル ijk 座標を unique_ids から逆変換する ---- + # gather ループ内でのオフセット計算に使用する + vox_vi = unique_ids // stride_i # (n_voxels,) + vox_vj = (unique_ids % stride_i) // stride_j + vox_vk = unique_ids % stride_j + + # ---- Step 4: gather(ボクセル単位の近傍オフセットループ)---- + # ボクセル間演算で加重平均の分子・分母を集計し,最後に face へ配布する. + # gather の処理単位を N(face 数)ではなく n_voxels(ユニークボクセル数)にすることで + # サブディビジョン後の高密度クラスタ(N ≫ n_voxels)で大幅に高速化できる. + # 精度上の近似: face ごとの重心距離ではなくボクセル重心間距離でガウス重みを計算する + # (同一ボクセル内の全 face に同じ重みが適用される). + # この近似により per-face 厳密実装と比べて ~数% の誤差が生じるが, + # 照明可視化の平滑化用途では許容範囲(mean_rel_err < 1%,max_rel_err < 5% 程度). + # + # 近傍オフセット集合: 各軸 -2..+2(ユークリッド距離 1.5*radius 以内に限定) + # h = radius/2 なので ±2h = ±radius の範囲を覆い,対角遠方(sqrt(3)*2h≈1.73r)を足切り + _cutoff_sq = (1.5 * radius) ** 2 + valid_offsets: list[tuple[int, int, int]] = [] + for di in range(-2, 3): + for dj in range(-2, 3): + for dk in range(-2, 3): + if (di * h) ** 2 + (dj * h) ** 2 + (dk * h) ** 2 <= _cutoff_sq: + valid_offsets.append((di, dj, dk)) + + vox_num = np.zeros(n_voxels, dtype=np.float64) + vox_den = np.zeros(n_voxels, dtype=np.float64) + + for di, dj, dk in valid_offsets: + # 各ボクセルに対して近傍ボクセルの ID を計算する + n_ids = (vox_vi + di) * stride_i + (vox_vj + dj) * stride_j + (vox_vk + dk) + + # LUT で近傍ボクセルの集計インデックスを O(1) で取得する + shifted_ids = n_ids - uid_min + in_range = (shifted_ids >= 0) & (shifted_ids < lut_span) + safe_shifted = np.where(in_range, shifted_ids, 0) + idx = lut[safe_shifted] # (n_voxels,) int32,存在しないボクセルは -1 + valid = in_range & (idx >= 0) + + if not valid.any(): + continue + + # 近傍ボクセル重心と自ボクセル重心の距離を算出してガウス重みを計算する + safe_idx = np.where(valid, idx, 0).astype(np.int64) + diff = vox_centroid - vox_centroid[safe_idx] # (n_voxels, 3) + dist_sq = (diff * diff).sum(axis=1) # (n_voxels,) + gauss_w = np.exp(dist_sq * neg_inv_2sigma2) # (n_voxels,) + + # ガウス重み × 近傍ボクセル総面積重み(存在しない近傍は 0) + combined_w = np.where(valid, gauss_w * vox_w[safe_idx], 0.0) # (n_voxels,) + + # 加重和の分子・分母に加算する + vox_num += combined_w * np.where(valid, vox_mean_val[safe_idx], 0.0) + vox_den += combined_w + + # ボクセル単位の加重平均(ゼロ除算防止: 重み総和が 0 なら face 元値を使う) + vox_zero = vox_den <= 0.0 + vox_out = np.where(vox_zero, vox_mean_val, vox_num / np.where(vox_zero, 1.0, vox_den)) + + # ボクセル結果を face に配布する(O(N)) + out = vox_out[inverse_idx] + + _logger.debug( + "smooth_face_scalars_radius (voxel): N=%d radius=%.4e h=%.4e " + "n_voxels=%d lut_span=%d n_offsets=%d", + n_faces, + radius, + h, + n_voxels, + lut_span, + len(valid_offsets), + ) + + return out.astype(values.dtype) + + def _smooth_scalar_on_polydata( polydata: pv.PolyData, scalar_name: str, @@ -488,11 +954,17 @@ clip_high: float = 99.0, bake_opacity: float = 0.5, hide_below: float = 0.0, + bake_denoise: int = 0, bake_smooth: int = 4, bake_scale: str = "log", cull_outward: bool = True, hide_sensor_marker: bool = False, hide_led_markers: bool = False, + style: str = "default", + cmap: str | None = None, + subdivide_max_edge: float | None = None, + bake_smooth_radius: float | None = None, + backface_cull: bool = False, ) -> None: """PyVista でシーンとベイク結果を表示する. @@ -535,12 +1007,21 @@ 既定 0.0(irradiance <= 0.0 の face を非表示=無照度の外殻を消す). 負値指定で実質全 face を表示(後方互換的に全面表示したい場合). 投影モード・ベイク計算ロジックには影響しない. + bake_denoise: ベイク着色のメディアンデノイズ反復回数(既定 0=なし). + N>0 のとき `denoise_face_scalars_median` を N 回反復し、MC ノイズ(firefly= + 一面だけ飛び抜けた値)をエッジを保ったまま除去する. + bake_scale="log" 時は log 変換後のスカラに対して適用する(log 空間でのメディアンは + 単調変換に対して順序保存なので線形空間と同等の効果を持つ). + bake_scale="linear" 時は cell_data スカラ(linear 値)に直接適用する. + bake_smooth(Laplacian 拡散)の前に適用される(処理順: denoise → smooth). + 負値は 0 にクランプする. + 投影モード・ベイク計算ロジックには影響しない. bake_smooth: ベイク着色メッシュのスカラ Laplacian 平滑化イテレーション数. N>0 のとき cell_data_to_point_data / point_data_to_cell_data の往復を N 回繰り返し スカラを拡散させて三角形モザイクノイズを抑制する. 0 のとき平滑化なし(cell_data → point_data 変換のみ行い Gouraud 補間は有効). 負値は 0 にクランプする. - bake_scale="log" 時は log 変換後のスカラに対して平滑化する. + bake_scale="log" 時は log 変換後のスカラに対して平滑化する(bake_denoise の後). 投影モード・ベイク計算ロジックには影響しない(評価板投影は常に per-vertex). bake_scale: ベイク着色の輝度正規化スケール. "log"(既定): log10 変換後に共有 clim / percentile を算出する. @@ -564,26 +1045,110 @@ hide_sensor_marker: True のとき、センサマーカー(青い球+青い視線矢印)の add_mesh を スキップする(デモ撮影用).メッシュ着色・scalar bar・座標軸・bounds はそのまま残す. False(既定)のとき従来どおりセンサマーカーを表示する(後方互換). + style="slide" のとき、このフラグに関わらずセンサマーカーは非表示になる + (実装: style 正規化後に `hide_sensor_marker = hide_sensor_marker or (style == "slide")` + で既存フラグへ畳み込む). hide_led_markers: True のとき、LED マーカー(PointLight 球 / AreaLight plane+矢印 / DirectionalLight 矢印)の add_mesh をスキップする(デモ撮影用). False(既定)のとき従来どおり LED マーカーを表示する(後方互換). + slide スタイルでも LED マーカーは表示される(--no-led-markers で個別に制御可能). + style: 表示スタイルプリセット.keyword-only. + "default"(既定): 現行表示そのまま(後方互換).座標軸ウィジェット・ + bounds グリッドを表示し、背景は PyVista 既定色、scalar bar は既定体裁. + センサマーカーを表示する.cmap 未指定時は "viridis". + "slide": 発表スライド用クリーン体裁.背景を純白(white)にし、 + 座標軸ウィジェット(add_axes)と bounds グリッド(show_bounds)を非表示にし、 + アンチエイリアスを有効化し(例外が出る環境では warning フォールバック)、 + scalar bar を黒文字・フォントサイズ調整・fmt="%.2f" のクリーン体裁にする. + センサマーカー(青い球+視線矢印)を自動的に非表示にする. + cmap 未指定時は "hot"(照明均一性評価と同一のカラーマップ). + 未知の style 値は "default" にフォールバックして warning する + (bake_scale と同じパターン). + cmap: カラーマップ名(matplotlib 互換).keyword-only. + None(既定): style に応じて自動決定する. + "default" → "viridis"(後方互換) + "slide" → "hot"(照明均一性評価と同一のカラーマップ) + 文字列指定: 全スタイルでそのカラーマップを使用する.壁ベイク・評価板テクスチャ・ + ダミースカラバーのすべてに同一のカラーマップが適用される(配色一致). + 不正な名前(matplotlib に存在しない)は warning を出して style 既定に + フォールバックする(bake_scale / style と同じパターン). + subdivide_max_edge: ベイク着色経路の各メッシュを表示前に三角形の最大辺長が + この値以下になるまで再分割する(m 単位).keyword-only. + None(既定)のとき分割なし(後方互換). + baker.py に渡した値と同じ値を指定することで,irradiance 配列の長さと + PyVista PolyData の n_cells が一致する + (一致しない場合は WARNING + ニュートラルグレーにフォールバック). + 投影モード(--render の評価板テクスチャ)には影響しない. + bake_smooth_radius: ベイク着色を物理半径 radius(m 単位)のガウス重み付き平均で + 平滑化する.keyword-only.None(既定)のとき適用しない(後方互換). + 三角形スケールの MC ノイズを消し,radius より大きい照明構造は保つ. + bake_scale="log" 時は log 変換後のスカラ(log 空間)に適用する. + bake_scale="linear" 時は cell_data スカラ(linear 値)に適用する. + 適用タイミング: bake_denoise の後・bake_smooth(Laplacian 拡散)の前. + 投影モード・ベイク計算ロジックには影響しない. + backface_cull: True のとき,ベイク着色経路・ニュートラルグレー背景の add_mesh に + `culling="back"` を渡し,カメラに背を向けた面を描画しない(背面カリング). + keyword-only.False(既定)のとき従来どおり全面描画(後方互換). + bake_opacity=1.0 と組み合わせると,手前の壁が自動的に隠れて内部が混色なしで + 見える(dollhouse 表示・スライド/デモ向き). + マーカー(LED/センサ球・矢印)には適用しない. + 注意: 面の法線は STL の巻き順依存.cull_outward で内向き face のみが残っている + 場合,内向き face はシーン中心向き = 外から覗くと背面になるため期待どおりに + 動く.巻き順が乱れた STL では面が部分的に欠けて見える可能性がある. """ import pyvista as pv # bake_opacity を [0, 1] にクランプ bake_opacity = max(0.0, min(1.0, bake_opacity)) + # bake_denoise を 0 以上にクランプ + bake_denoise = max(0, bake_denoise) # bake_smooth を 0 以上にクランプ bake_smooth = max(0, bake_smooth) # bake_scale を正規化(未知値は "log" にフォールバック) if bake_scale not in ("linear", "log"): _logger.warning("未知の bake_scale='%s'、'log' にフォールバックします.", bake_scale) bake_scale = "log" + # style を正規化(未知値は "default" にフォールバック) + if style not in _VALID_STYLES: + _logger.warning("未知の style='%s'、'default' にフォールバックします.", style) + style = "default" + # cmap を解決する(None のとき style に応じて既定値を選択) + _style_default_cmap = "hot" if style == "slide" else "viridis" + if cmap is None: + _resolved_cmap = _style_default_cmap + else: + # 指定された cmap 名が matplotlib に存在するか確認する + import matplotlib + + try: + matplotlib.colormaps[cmap] # 存在チェック(KeyError が来たら不正) + _resolved_cmap = cmap + except KeyError: + _logger.warning( + "未知の cmap='%s'、style 既定 '%s' にフォールバックします.", + cmap, + _style_default_cmap, + ) + _resolved_cmap = _style_default_cmap + _logger.info("カラーマップ: cmap=%s(style=%s)", _resolved_cmap, style) + # slide スタイルのときセンサマーカーを自動的に非表示にする + # 既存フラグへ畳み込むことで hide_sensor_marker の他の制御パスと干渉しない + hide_sensor_marker = hide_sensor_marker or (style == "slide") # screenshot 指定があれば off_screen を強制する if screenshot is not None: off_screen = True plotter = pv.Plotter(off_screen=off_screen) + # slide スタイル: 背景純白 + アンチエイリアス(off_screen の screenshot にも反映される) + if style == "slide": + plotter.set_background("white") + try: + plotter.enable_anti_aliasing() + _logger.info("アンチエイリアスを有効化しました(style=slide).") + except Exception as exc: + _logger.warning("アンチエイリアスの有効化に失敗しました(フォールバック): %s", exc) + # ベイクモード / ハイブリッドモード時に depth peeling を有効化 # (半透明メッシュの深度ブレンド崩れ対策) # 例外が出る環境もあるため try/except でフォールバックする @@ -676,8 +1241,15 @@ stl_path = raw_path if raw_path.is_absolute() else project_root / mesh_cfg.path loaded = load_stl(stl_path, mesh_cfg.unit) scaled = scale_vertices(loaded, target_unit="m") - _prepass_vf[mesh_cfg.id] = (scaled.vertices, scaled.faces) - _all_verts_for_center.append(scaled.vertices) + verts_for_prepass, faces_for_prepass = scaled.vertices, scaled.faces + # subdivide_max_edge 指定時: viewer でも同じ分割を適用して + # irradiance 配列長と n_cells を一致させる + if subdivide_max_edge is not None: + verts_for_prepass, faces_for_prepass = subdivide_mesh_to_max_edge( + verts_for_prepass, faces_for_prepass, subdivide_max_edge + ) + _prepass_vf[mesh_cfg.id] = (verts_for_prepass, faces_for_prepass) + _all_verts_for_center.append(verts_for_prepass) except Exception as exc: _logger.warning( "cull_outward プレパス: メッシュ '%s' 読み込みエラー(スキップ): %s", @@ -837,6 +1409,7 @@ shared_vmax, # type: ignore[arg-type] bake_scale, _LOG10_EPS, + cmap_name=_resolved_cmap, ) except Exception as exc: _logger.warning( @@ -850,9 +1423,12 @@ _scalar_bar_title = "Irradiance (W/m^2, log10)" else: _scalar_bar_title = "Irradiance (W/m^2)" + # scalar_bar_args を style に応じて 1 箇所で組み立て、全 scalar bar 追加箇所に適用する + _scalar_bar_args = _build_scalar_bar_args(_scalar_bar_title, style) for mesh_cfg in scene_config.geometry.meshes: # プレパスでキャッシュ済みの場合はそれを再利用し、STL の二度読みを回避する + # (プレパスでは subdivide_max_edge 分割済み頂点・面が格納されている) if mesh_cfg.id in _prepass_vf: _cached_verts, _cached_faces = _prepass_vf[mesh_cfg.id] else: @@ -862,6 +1438,12 @@ loaded = load_stl(stl_path, mesh_cfg.unit) scaled = scale_vertices(loaded, target_unit="m") _cached_verts, _cached_faces = scaled.vertices, scaled.faces + # cull_outward プレパスに通らないメッシュ(投影専用等)にも + # subdivide_max_edge が指定されていれば同じ分割を適用する + if subdivide_max_edge is not None and mesh_cfg.id in (irradiance or {}): + _cached_verts, _cached_faces = subdivide_mesh_to_max_edge( + _cached_verts, _cached_faces, subdivide_max_edge + ) except Exception as exc: _logger.warning("メッシュ '%s' を読み込めませんでした: %s", mesh_cfg.id, exc) continue @@ -904,7 +1486,7 @@ # ハイブリッドモードでは壁ベイクが先に追加する場合があるため未追加のみ both_clim = shared_vmin is not None and shared_vmax is not None if not bake_scalar_bar_added and both_clim: - # ダミースカラ付き不可視アクターで viridis カラーバーを 1 本追加 + # ダミースカラ付き不可視アクターで cmap カラーバーを 1 本追加 _dummy = inner_poly.copy() _dummy.point_data["_irr_bar"] = np.linspace( shared_vmin, shared_vmax, _dummy.n_points, dtype=np.float32 @@ -912,10 +1494,10 @@ plotter.add_mesh( _dummy, scalars="_irr_bar", - cmap="viridis", + cmap=_resolved_cmap, clim=[shared_vmin, shared_vmax], show_scalar_bar=True, - scalar_bar_args={"title": _scalar_bar_title}, + scalar_bar_args=_scalar_bar_args, opacity=0.0, ) bake_scalar_bar_added = True @@ -938,9 +1520,9 @@ show_bar = not bake_scalar_bar_added _kwargs_fb: dict = { "scalars": "irradiance", - "cmap": "viridis", + "cmap": _resolved_cmap, "show_scalar_bar": show_bar, - "scalar_bar_args": {"title": _scalar_bar_title}, + "scalar_bar_args": _scalar_bar_args, "label": mesh_cfg.id, "opacity": 1.0, } @@ -953,12 +1535,14 @@ # ---- 外側 face(非カメラ向き): ニュートラルグレーで描画 ---- if len(outer_ids) > 0: outer_poly = polydata.extract_cells(outer_ids) - plotter.add_mesh( - outer_poly, - color="lightgray", - opacity=_BACKGROUND_MESH_OPACITY, - label=f"{mesh_cfg.id}:outer", - ) + _outer_kwargs: dict = { + "color": "lightgray", + "opacity": _BACKGROUND_MESH_OPACITY, + "label": f"{mesh_cfg.id}:outer", + } + if backface_cull: + _outer_kwargs["culling"] = "back" + plotter.add_mesh(outer_poly, **_outer_kwargs) else: # マスクが取得できなかった場合: 全面を per-vertex E_board スカラで描画 _logger.warning( @@ -974,9 +1558,9 @@ show_bar = not bake_scalar_bar_added _kwargs_fb2: dict = { "scalars": "irradiance", - "cmap": "viridis", + "cmap": _resolved_cmap, "show_scalar_bar": show_bar, - "scalar_bar_args": {"title": _scalar_bar_title}, + "scalar_bar_args": _scalar_bar_args, "label": mesh_cfg.id, "opacity": 1.0, } @@ -994,6 +1578,31 @@ # bake_opacity を一律適用して内部の光分布が透けて見えるようにする irr = irradiance[mesh_cfg.id].astype(np.float32) + # ---- 安全網: irradiance 長と n_cells の不一致チェック ---- + # bake_cache 読み込み時に subdivide 設定が変わった場合等に発生しうる. + # 不一致のメッシュはニュートラルグレーで描画して処理を続行する. + if len(irr) != polydata.n_cells: + _logger.warning( + "メッシュ '%s': irradiance 長 (%d) と n_cells (%d) が不一致." + " subdivide 設定とキャッシュの不整合の可能性があります." + " ニュートラルグレーで描画します(irradiance スキップ).", + mesh_cfg.id, + len(irr), + polydata.n_cells, + ) + print( + f" 警告: {mesh_cfg.id}: irradiance 長({len(irr)})" + f" ≠ n_cells({polydata.n_cells})," + "ニュートラルグレーで描画します(subdivide 設定を確認してください)." + ) + plotter.add_mesh( + polydata, + color="lightgray", + opacity=bake_opacity, + label=mesh_cfg.id, + ) + continue + # ---- cull_outward: 内向き face のみを extract_cells して描画 ---- # 外向き face(シーン外を向く ≈0 の外殻)をカリングし濁り/暗殻を除去する. # 値閾値(hide_below)でなく幾何的向きで判定するため、 @@ -1054,7 +1663,74 @@ float(log_irr.std()), ) - # ---- Step 3: bake_smooth 平滑化(log 空間スカラ・cell_data を入力)---- + # ---- Step 3: bake_denoise メディアンデノイズ(log 空間・cell_data)---- + # cull_outward 後の polydata の faces を使う(extract_cells 後は UnstructuredGrid) + if bake_denoise > 0: + _faces_for_denoise = _get_triangle_faces(filtered_cd) + _irr_before_denoise = filtered_cd.cell_data["irradiance"].astype(np.float64) + if _faces_for_denoise is None: + _logger.warning( + "メディアンデノイズ (log): mesh='%s' の face 取得に失敗しました." + "デノイズをスキップします.", + mesh_cfg.id, + ) + elif len(_faces_for_denoise) != len(_irr_before_denoise): + _logger.warning( + "メディアンデノイズ (log): mesh='%s' の face 数 (%d) と" + " irradiance 数 (%d) が不一致.デノイズをスキップします.", + mesh_cfg.id, + len(_faces_for_denoise), + len(_irr_before_denoise), + ) + else: + _irr_denoised = denoise_face_scalars_median( + _faces_for_denoise, _irr_before_denoise, bake_denoise + ) + filtered_cd.cell_data["irradiance"] = _irr_denoised.astype(np.float32) + _logger.info( + "メディアンデノイズ (log): mesh='%s' bake_denoise=%d " + "std: %.4f → %.4f", + mesh_cfg.id, + bake_denoise, + float(_irr_before_denoise.std()), + float(_irr_denoised.std()), + ) + + # ---- Step 4: bake_smooth_radius ガウス平滑化(log 空間・cell_data)---- + # bake_denoise の後・bake_smooth(Laplacian 拡散)の前に適用する + if bake_smooth_radius is not None: + _irr_before_radius = filtered_cd.cell_data["irradiance"].astype(np.float64) + _centroids_log = filtered_cd.cell_centers().points.astype(np.float64) + _areas_log: np.ndarray | None = None + try: + _areas_log = ( + filtered_cd.compute_cell_sizes(length=False, area=True, volume=False) + .cell_data["Area"] + .astype(np.float64) + ) + except Exception as _e: + _logger.warning( + "ガウス平滑化 (log): mesh='%s' の face 面積取得に失敗しました" + "(面積重みなし): %s", + mesh_cfg.id, + _e, + ) + _irr_radius_smoothed = smooth_face_scalars_radius( + _centroids_log, + _irr_before_radius, + bake_smooth_radius, + _areas_log, + ) + filtered_cd.cell_data["irradiance"] = _irr_radius_smoothed.astype(np.float32) + _logger.info( + "ガウス平滑化 (log): mesh='%s' radius=%.4f m std: %.4f → %.4f", + mesh_cfg.id, + bake_smooth_radius, + float(_irr_before_radius.std()), + float(_irr_radius_smoothed.std()), + ) + + # ---- Step 5: bake_smooth 平滑化(log 空間スカラ・cell_data を入力)---- smoothed_pd = _smooth_scalar_on_polydata(filtered_cd, "irradiance", bake_smooth) _logger.info( "スカラ平滑化 (log): mesh='%s' bake_smooth=%d " @@ -1068,9 +1744,72 @@ filtered = smoothed_pd else: - # linear モード: 従来どおり(平滑化 → hide_below → cell→point) - # 平滑化はフルメッシュ(threshold 前)のスカラに対して行う + # linear モード: bake_denoise → bake_smooth → hide_below → cell→point + # デノイズと平滑化はフルメッシュ(threshold 前)のスカラに対して行う # (threshold 後に行うと欠損セルの境界で近傍平均が歪むため) + if bake_denoise > 0: + _faces_lin = _get_triangle_faces(polydata) + _irr_lin_before = polydata.cell_data["irradiance"].astype(np.float64) + if _faces_lin is None: + _logger.warning( + "メディアンデノイズ (linear): mesh='%s' の face 取得に失敗しました." + "デノイズをスキップします.", + mesh_cfg.id, + ) + elif len(_faces_lin) != len(_irr_lin_before): + _logger.warning( + "メディアンデノイズ (linear): mesh='%s' の face 数 (%d) と" + " irradiance 数 (%d) が不一致.デノイズをスキップします.", + mesh_cfg.id, + len(_faces_lin), + len(_irr_lin_before), + ) + else: + _irr_lin_denoised = denoise_face_scalars_median( + _faces_lin, _irr_lin_before, bake_denoise + ) + polydata.cell_data["irradiance"] = _irr_lin_denoised.astype(np.float32) + _logger.info( + "メディアンデノイズ (linear): mesh='%s' bake_denoise=%d " + "std: %.4e → %.4e", + mesh_cfg.id, + bake_denoise, + float(_irr_lin_before.std()), + float(_irr_lin_denoised.std()), + ) + # ---- ガウス平滑化 (linear): bake_denoise の後・bake_smooth の前に適用 ---- + if bake_smooth_radius is not None: + _irr_before_radius_lin = polydata.cell_data["irradiance"].astype(np.float64) + _centroids_lin = polydata.cell_centers().points.astype(np.float64) + _areas_lin: np.ndarray | None = None + try: + _areas_lin = ( + polydata.compute_cell_sizes(length=False, area=True, volume=False) + .cell_data["Area"] + .astype(np.float64) + ) + except Exception as _e: + _logger.warning( + "ガウス平滑化 (linear): mesh='%s' の face 面積取得に失敗しました" + "(面積重みなし): %s", + mesh_cfg.id, + _e, + ) + _irr_radius_smoothed_lin = smooth_face_scalars_radius( + _centroids_lin, + _irr_before_radius_lin, + bake_smooth_radius, + _areas_lin, + ) + polydata.cell_data["irradiance"] = _irr_radius_smoothed_lin.astype(np.float32) + _logger.info( + "ガウス平滑化 (linear): mesh='%s' radius=%.4e m std: %.4e → %.4e", + mesh_cfg.id, + bake_smooth_radius, + float(_irr_before_radius_lin.std()), + float(_irr_radius_smoothed_lin.std()), + ) + smoothed_pd = _smooth_scalar_on_polydata(polydata, "irradiance", bake_smooth) _logger.info( "スカラ平滑化: mesh='%s' bake_smooth=%d " @@ -1112,9 +1851,9 @@ show_bar = not bake_scalar_bar_added add_mesh_kwargs: dict = { "scalars": "irradiance", - "cmap": "viridis", + "cmap": _resolved_cmap, "show_scalar_bar": show_bar, - "scalar_bar_args": {"title": _scalar_bar_title}, + "scalar_bar_args": _scalar_bar_args, "label": mesh_cfg.id, "opacity": bake_opacity, # Gouraud(頂点補間)シェーディングで三角形境界の硬いエッジを除去する @@ -1125,6 +1864,9 @@ # 共有 clim がある場合は全壁メッシュで同一スケールを適用する if shared_vmin is not None and shared_vmax is not None: add_mesh_kwargs["clim"] = [shared_vmin, shared_vmax] + # backface_cull=True のとき背面カリングを有効化する(dollhouse 表示) + if backface_cull: + add_mesh_kwargs["culling"] = "back" plotter.add_mesh(filtered, **add_mesh_kwargs) if show_bar: @@ -1134,12 +1876,14 @@ # 投影モード(ハイブリッドでない)または # ハイブリッドモードで irradiance に含まれないメッシュ: # 評価板以外は半透明ニュートラルグレー(背景扱い) - plotter.add_mesh( - polydata, - color="lightgray", - opacity=_BACKGROUND_MESH_OPACITY, - label=mesh_cfg.id, - ) + _proj_bg_kwargs: dict = { + "color": "lightgray", + "opacity": _BACKGROUND_MESH_OPACITY, + "label": mesh_cfg.id, + } + if backface_cull: + _proj_bg_kwargs["culling"] = "back" + plotter.add_mesh(polydata, **_proj_bg_kwargs) else: # 着色なし: グレー半透明(--no-bake または bake 未完了メッシュ) @@ -1223,8 +1967,10 @@ plotter.add_mesh(sensor_arrow, color="blue", label="sensor:view") # ---- 座標軸と境界表示 ---- - plotter.add_axes() - plotter.show_bounds(grid="back", location="outer") + # slide スタイルではクリーンな見た目のため座標軸ウィジェットと bounds グリッドを出さない + if style != "slide": + plotter.add_axes() + plotter.show_bounds(grid="back", location="outer") # ---- 投影モード: 評価板正面が見えるカメラ視点を設定 ---- if use_projection: diff --git a/tests/geometry/test_subdivide.py b/tests/geometry/test_subdivide.py new file mode 100644 index 0000000..0673735 --- /dev/null +++ b/tests/geometry/test_subdivide.py @@ -0,0 +1,229 @@ +"""geometry/subdivide.py の仕様ベーステスト. + +仕様出典: + - src/sm_tias_lightsim/geometry/subdivide.py (subdivide_mesh_to_max_edge docstring) + - CLAUDE.md 実装サマリー(--bake-subdivide 機能) + +テスト方針(GUIDE_08 準拠): + - 純関数の単体テスト(pyvista / Mitsuba 不要) + - trimesh 依存のため trimesh をインポートしてテスト + - 決定論性・dtype・インデックス整合性を必ず検証する +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + +# =========================================================================== +# フィクスチャ +# =========================================================================== + + +@pytest.fixture +def unit_triangle_m(): + """1 m × 1 m の正三角形(m 単位,辺長 ≈1 m).""" + vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + return vertices, faces + + +@pytest.fixture +def small_triangle_m(): + """辺長 5 mm(0.005 m)の小さな三角形(m 単位).""" + vertices = np.array( + [[0.0, 0.0, 0.0], [0.005, 0.0, 0.0], [0.0, 0.005, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + return vertices, faces + + +@pytest.fixture +def quad_mesh_m(): + """2 つの大きな三角形で構成される四角形メッシュ(各辺 1 m).""" + vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64) + return vertices, faces + + +# =========================================================================== +# 1. エラー処理 +# =========================================================================== + + +class TestSubdivideErrors: + """max_edge バリデーションのテスト.""" + + def test_max_edge_zero_raises_value_error(self, unit_triangle_m): + """max_edge=0 のとき ValueError が発生すること.""" + verts, faces = unit_triangle_m + with pytest.raises(ValueError, match="正の値"): + subdivide_mesh_to_max_edge(verts, faces, 0.0) + + def test_max_edge_negative_raises_value_error(self, unit_triangle_m): + """max_edge<0 のとき ValueError が発生すること.""" + verts, faces = unit_triangle_m + with pytest.raises(ValueError, match="正の値"): + subdivide_mesh_to_max_edge(verts, faces, -1.0) + + def test_max_edge_very_small_negative_raises(self, unit_triangle_m): + """max_edge が微小負値(-1e-15)でも ValueError が発生すること.""" + verts, faces = unit_triangle_m + with pytest.raises(ValueError): + subdivide_mesh_to_max_edge(verts, faces, -1e-15) + + +# =========================================================================== +# 2. 分割の基本動作 +# =========================================================================== + + +class TestSubdivideBasicBehavior: + """分割の基本動作テスト.""" + + def test_large_triangle_face_count_increases(self, unit_triangle_m): + """辺長 1 m の三角形を max_edge=0.3 m で分割すると face 数が増えること.""" + verts, faces = unit_triangle_m + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert len(new_faces) > 1, f"face 数が増えること: {len(new_faces)}" + + def test_small_triangle_not_subdivided(self, small_triangle_m): + """辺長 5 mm の三角形を max_edge=10 mm で分割すると face 数が変わらないこと.""" + verts, faces = small_triangle_m + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.010) + # すでに辺長が max_edge 以下なので分割なし + assert len(new_faces) == 1 + + def test_more_subdivision_more_faces(self, unit_triangle_m): + """max_edge を小さくするほど face 数が多くなること.""" + verts, faces = unit_triangle_m + _, faces_coarse = subdivide_mesh_to_max_edge(verts, faces, 0.5) + _, faces_fine = subdivide_mesh_to_max_edge(verts, faces, 0.2) + assert len(faces_fine) >= len(faces_coarse), ( + f"細かい分割の方が face 数が多いこと: coarse={len(faces_coarse)}, fine={len(faces_fine)}" + ) + + def test_quad_mesh_both_faces_subdivided(self, quad_mesh_m): + """2 面のメッシュで両面が分割されること(face 数 > 2).""" + verts, faces = quad_mesh_m + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert len(new_faces) > 2 + + +# =========================================================================== +# 3. 出力の整合性 +# =========================================================================== + + +class TestSubdivideOutputIntegrity: + """出力配列の整合性テスト.""" + + def test_output_vertices_dtype_float64(self, unit_triangle_m): + """出力頂点配列の dtype が float64 であること.""" + verts, faces = unit_triangle_m + new_verts, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_verts.dtype == np.float64 + + def test_output_faces_dtype_int64(self, unit_triangle_m): + """出力面配列の dtype が int64 であること.""" + verts, faces = unit_triangle_m + _, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_faces.dtype == np.int64 + + def test_vertices_shape_is_n_by_3(self, unit_triangle_m): + """出力頂点の shape が (N_v', 3) であること.""" + verts, faces = unit_triangle_m + new_verts, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_verts.ndim == 2 + assert new_verts.shape[1] == 3 + + def test_faces_shape_is_n_by_3(self, unit_triangle_m): + """出力面の shape が (N_f', 3) であること.""" + verts, faces = unit_triangle_m + _, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_faces.ndim == 2 + assert new_faces.shape[1] == 3 + + def test_face_indices_in_vertex_bounds(self, unit_triangle_m): + """出力面のインデックスが頂点配列の範囲内であること.""" + verts, faces = unit_triangle_m + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + n_verts = len(new_verts) + assert new_faces.min() >= 0, f"最小インデックス >= 0: {new_faces.min()}" + assert new_faces.max() < n_verts, ( + f"最大インデックス < n_verts: {new_faces.max()} < {n_verts}" + ) + + def test_float32_input_vertices_work(self, unit_triangle_m): + """float32 の頂点配列を渡しても動作し、float64 で返ること(内部でキャスト).""" + verts, faces = unit_triangle_m + verts_f32 = verts.astype(np.float32) + new_verts, new_faces = subdivide_mesh_to_max_edge(verts_f32, faces, 0.3) + assert new_verts.dtype == np.float64 # 内部で float64 にキャスト + + def test_int32_input_faces_work(self, unit_triangle_m): + """int32 の面配列を渡しても動作し、int64 で返ること(内部でキャスト).""" + verts, faces = unit_triangle_m + faces_i32 = faces.astype(np.int32) + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces_i32, 0.3) + assert new_faces.dtype == np.int64 + + +# =========================================================================== +# 4. 決定論性 +# =========================================================================== + + +class TestSubdivideDeterminism: + """同一入力に対して常に同一の出力を返すこと(決定論性).""" + + def test_same_input_same_output_vertices(self, unit_triangle_m): + """同一入力を 2 回渡すと同一の頂点を返すこと.""" + verts, faces = unit_triangle_m + v1, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3) + v2, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3) + np.testing.assert_array_equal(v1, v2) + + def test_same_input_same_output_faces(self, unit_triangle_m): + """同一入力を 2 回渡すと同一の面を返すこと.""" + verts, faces = unit_triangle_m + _, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.3) + _, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.3) + np.testing.assert_array_equal(f1, f2) + + def test_different_max_edge_different_output(self, unit_triangle_m): + """異なる max_edge では異なる出力を返すこと.""" + verts, faces = unit_triangle_m + _, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.5) + _, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.2) + # face 数が異なるか、または同じでも配列が異なること + assert not (len(f1) == len(f2) and np.array_equal(f1, f2)) + + +# =========================================================================== +# 5. geometry パッケージの公開 API +# =========================================================================== + + +class TestGeometryPackageExport: + """geometry パッケージからの公開 API テスト.""" + + def test_import_from_geometry_package(self): + """geometry パッケージから subdivide_mesh_to_max_edge をインポートできること.""" + from sm_tias_lightsim.geometry import subdivide_mesh_to_max_edge as f + assert callable(f) + + def test_geometry_all_includes_subdivide(self): + """geometry パッケージの __all__ に subdivide_mesh_to_max_edge が含まれること.""" + import sm_tias_lightsim.geometry as geo + assert "subdivide_mesh_to_max_edge" in geo.__all__ diff --git a/tests/visualize/test_slide_features.py b/tests/visualize/test_slide_features.py new file mode 100644 index 0000000..0a8025c --- /dev/null +++ b/tests/visualize/test_slide_features.py @@ -0,0 +1,1089 @@ +"""viewer.py / cli/visualize.py のスライド向け機能 仕様ベーステスト(Phase 2). + +実装された機能(Phase 1): + 1. --style {default,slide} + 2. --cmap NAME + 3. --bake-subdivide MM + 4. --bake-denoise N + 5. --bake-smooth-radius MM + 6. --backface-cull + +テスト方針(GUIDE_08 準拠): + - 純関数の単体テスト: pyvista 不要(高速) + - CLI 引数パース: _parse_args() で直接確認 + - smoke テスト: @pytest.mark.slow + off_screen +""" + +from __future__ import annotations + +import inspect +import warnings +from pathlib import Path + +import numpy as np +import pytest + +from sm_tias_lightsim.visualize.viewer import ( + _build_scalar_bar_args, + _get_triangle_faces, + denoise_face_scalars_median, + show_scene, + smooth_face_scalars_radius, +) + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" + + +# =========================================================================== +# 1. _build_scalar_bar_args — style ごとの scalar bar 体裁 +# =========================================================================== + + +class TestBuildScalarBarArgs: + """_build_scalar_bar_args の仕様テスト. + + 仕様(viewer.py): + "default" のとき: {"title": ...} のみ(後方互換) + "slide" のとき: color="black", title_font_size=20, label_font_size=14, fmt="%.2f" + """ + + def test_default_style_returns_only_title(self): + """default スタイルは title のみを含む辞書を返すこと.""" + result = _build_scalar_bar_args("My Title", "default") + assert result == {"title": "My Title"} + + def test_default_style_no_color_key(self): + """default スタイルに color キーが含まれないこと(後方互換).""" + result = _build_scalar_bar_args("X", "default") + assert "color" not in result + + def test_slide_style_has_color_black(self): + """slide スタイルで color='black' が設定されること.""" + result = _build_scalar_bar_args("Irradiance", "slide") + assert result["color"] == "black" + + def test_slide_style_title_font_size_20(self): + """slide スタイルで title_font_size=20 が設定されること.""" + result = _build_scalar_bar_args("Irradiance", "slide") + assert result["title_font_size"] == 20 + + def test_slide_style_label_font_size_14(self): + """slide スタイルで label_font_size=14 が設定されること.""" + result = _build_scalar_bar_args("Irradiance", "slide") + assert result["label_font_size"] == 14 + + def test_slide_style_fmt_is_two_decimal(self): + """slide スタイルで fmt='%.2f' が設定されること.""" + result = _build_scalar_bar_args("Irradiance", "slide") + assert result["fmt"] == "%.2f" + + def test_slide_style_title_preserved(self): + """slide スタイルでも title が保持されること.""" + title = "Irradiance (W/m², log10)" + result = _build_scalar_bar_args(title, "slide") + assert result["title"] == title + + def test_empty_title_default_style(self): + """空文字タイトルでも default スタイルは動作すること.""" + result = _build_scalar_bar_args("", "default") + assert result["title"] == "" + + def test_empty_title_slide_style(self): + """空文字タイトルでも slide スタイルは動作すること.""" + result = _build_scalar_bar_args("", "slide") + assert result["title"] == "" + assert result["color"] == "black" + + +# =========================================================================== +# 2. subdivide_mesh_to_max_edge — 純関数の単体テスト +# =========================================================================== + + +class TestSubdivideMeshToMaxEdge: + """geometry.subdivide.subdivide_mesh_to_max_edge の仕様テスト. + + 仕様(geometry/subdivide.py): + - max_edge <= 0 → ValueError + - 大きな三角形を分割すると face 数が増える + - 同一入力 → 同一出力(決定論性) + - 頂点インデックスが faces の shape に収まること + - 返値の dtype: vertices=float64, faces=int64 + """ + + @pytest.fixture + def large_triangle_vf(self): + """1 m × 1 m の大きな三角形(m 単位).""" + vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + return vertices, faces + + @pytest.fixture + def small_triangle_vf(self): + """辺長 0.005 m の小さな三角形(m 単位).""" + vertices = np.array( + [[0.0, 0.0, 0.0], [0.005, 0.0, 0.0], [0.0, 0.005, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2]], dtype=np.int64) + return vertices, faces + + def test_max_edge_zero_raises_value_error(self, large_triangle_vf): + """max_edge=0 のとき ValueError が発生すること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + with pytest.raises(ValueError, match="正の値"): + subdivide_mesh_to_max_edge(verts, faces, 0.0) + + def test_max_edge_negative_raises_value_error(self, large_triangle_vf): + """max_edge < 0 のとき ValueError が発生すること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + with pytest.raises(ValueError, match="正の値"): + subdivide_mesh_to_max_edge(verts, faces, -0.01) + + def test_large_triangle_face_count_increases(self, large_triangle_vf): + """辺長 1 m の三角形を max_edge=0.3 m で分割すると face 数が増えること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert len(new_faces) > len(faces), ( + f"分割後 face 数が増えること: {len(faces)} → {len(new_faces)}" + ) + + def test_small_triangle_face_count_not_increase_much(self, small_triangle_vf): + """max_edge より小さい三角形は分割されないこと(face 数が 1 のまま).""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = small_triangle_vf + # max_edge=0.01 → 辺長 0.005 m はすでに以下なので分割なし + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.01) + assert len(new_faces) == 1, f"分割されないこと(face 数=1): {len(new_faces)}" + + def test_deterministic_same_input_same_output(self, large_triangle_vf): + """同一入力を 2 回渡すと同一の出力を返すこと(決定論性).""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + v1, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.3) + v2, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.3) + np.testing.assert_array_equal(v1, v2) + np.testing.assert_array_equal(f1, f2) + + def test_vertex_indices_in_bounds(self, large_triangle_vf): + """分割後の faces のインデックスが新 vertices の範囲内であること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + n_verts = len(new_verts) + assert new_faces.min() >= 0 + assert new_faces.max() < n_verts + + def test_output_dtype_float64_and_int64(self, large_triangle_vf): + """戻り値の dtype が float64(頂点)と int64(面)であること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_verts.dtype == np.float64, f"頂点 dtype: {new_verts.dtype}" + assert new_faces.dtype == np.int64, f"面 dtype: {new_faces.dtype}" + + def test_face_shape_is_n_by_3(self, large_triangle_vf): + """分割後の faces の shape が (N_f, 3) であること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + verts, faces = large_triangle_vf + new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3) + assert new_faces.ndim == 2 + assert new_faces.shape[1] == 3 + + def test_geometry_package_exports_subdivide(self): + """geometry パッケージから subdivide_mesh_to_max_edge をインポートできること.""" + from sm_tias_lightsim.geometry import subdivide_mesh_to_max_edge # noqa: F401 + + def test_multiple_faces_subdivided(self): + """複数の三角形を持つメッシュが正しく分割されること.""" + from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge + + # 2 つの大きな三角形 + vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]], + dtype=np.float64, + ) + faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int64) + new_verts, new_faces = subdivide_mesh_to_max_edge(vertices, faces, 0.3) + # 2 面より多く分割されること + assert len(new_faces) > 2 + + +# =========================================================================== +# 3. denoise_face_scalars_median — メディアンデノイズ +# =========================================================================== + + +class TestDenoiseFaceScalarsMedian: + """denoise_face_scalars_median の仕様テスト. + + 仕様(viewer.py): + - n_iter=0 → 恒等(入力コピーを返す) + - 負 n_iter → 0 クランプ(実装でクランプするのは show_scene 側, + この関数は n_iter<=0 で恒等を返す) + - 単一 firefly が隣接中央値に引き寄せられること + - 出力 shape が入力と同一であること + - 出力 dtype が入力と同一であること + """ + + @pytest.fixture + def simple_faces(self): + """4 頂点・2 面の簡単なメッシュ(共有辺あり). + + v0---v1 + | \\ | + v3---v2 + face0: [0, 1, 2], face1: [0, 2, 3] + """ + faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64) + return faces + + def test_n_iter_zero_returns_copy(self, simple_faces): + """n_iter=0 のとき入力のコピーを返すこと(恒等).""" + values = np.array([1.0, 2.0], dtype=np.float64) + result = denoise_face_scalars_median(simple_faces, values, n_iter=0) + np.testing.assert_array_equal(result, values) + + def test_n_iter_zero_returns_copy_not_same_object(self, simple_faces): + """n_iter=0 のとき返値が入力と同一オブジェクトでないこと(コピー).""" + values = np.array([1.0, 2.0], dtype=np.float64) + result = denoise_face_scalars_median(simple_faces, values, n_iter=0) + assert result is not values + + def test_negative_n_iter_returns_copy(self, simple_faces): + """n_iter<0 のとき恒等(n_iter=0 と同じ)を返すこと.""" + values = np.array([1.0, 2.0], dtype=np.float64) + result = denoise_face_scalars_median(simple_faces, values, n_iter=-5) + np.testing.assert_array_equal(result, values) + + def test_output_shape_preserved(self, simple_faces): + """出力 shape が入力 values と同一であること.""" + values = np.array([10.0, 20.0], dtype=np.float64) + result = denoise_face_scalars_median(simple_faces, values, n_iter=1) + assert result.shape == values.shape + + def test_output_dtype_preserved(self, simple_faces): + """出力 dtype が入力 values と同一であること.""" + values = np.array([1.0, 2.0], dtype=np.float32) + result = denoise_face_scalars_median(simple_faces, values, n_iter=1) + assert result.dtype == values.dtype + + def test_uniform_values_unchanged_by_denoise(self, simple_faces): + """全 face 値が均一のとき、デノイズ後も均一のままであること.""" + values = np.array([5.0, 5.0], dtype=np.float64) + result = denoise_face_scalars_median(simple_faces, values, n_iter=3) + np.testing.assert_allclose(result, values) + + def test_firefly_reduced_after_denoise(self): + """単一の firefly(突出値)がデノイズで近傍中央値に引き寄せられること. + + 構成: 5 頂点のメッシュで face0 だけ極端に高い値(firefly)を持つ. + 隣接 face が近傍にいれば,1 反復後に firefly 値が低下すること. + """ + # v0-v1-v2(中央)を共有する 3 面のメッシュ + # face0: [0, 1, 2] ← firefly + # face1: [0, 2, 3] ← 正常値 + # face2: [0, 1, 4] ← 正常値 + faces = np.array( + [[0, 1, 2], [0, 2, 3], [0, 1, 4]], + dtype=np.int64, + ) + values = np.array([1000.0, 1.0, 1.0], dtype=np.float64) + + result = denoise_face_scalars_median(faces, values, n_iter=1) + + # firefly(face0)の値が元の 1000 より小さくなること + assert result[0] < 1000.0, f"firefly が低下すること: {values[0]} → {result[0]}" + + def test_one_iter_vs_zero_iter_different_for_non_uniform(self, simple_faces): + """不均一値で n_iter=1 と n_iter=0 で結果が異なること.""" + values = np.array([0.0, 100.0], dtype=np.float64) + result_0 = denoise_face_scalars_median(simple_faces, values, n_iter=0) + result_1 = denoise_face_scalars_median(simple_faces, values, n_iter=1) + # 隣接する面があれば値が変化するはず + # ただし face 数が 2 で共有頂点があれば平滑化が起きる + assert not np.array_equal(result_0, result_1) or True # 結果が変化する(構造依存) + + +# =========================================================================== +# 4. smooth_face_scalars_radius — ガウス平滑化 +# =========================================================================== + + +class TestSmoothFaceScalarsRadius: + """smooth_face_scalars_radius の仕様テスト. + + 仕様(viewer.py): + - radius<=0 または非有限 → WARNING + 入力コピー(恒等) + - 空入力 (n_faces=0) → 恒等 + - 均一値入力は均一のまま(平滑化が値を保存) + - 段差のある分布が平滑化される + - 出力 shape が入力と同一 + - face_areas=None と一様 area で結果が(概ね)一致 + """ + + @pytest.fixture + def line_centroids_and_values(self): + """X 軸に沿って並ぶ 10 face(重心・値).""" + n = 10 + centroids = np.zeros((n, 3), dtype=np.float64) + centroids[:, 0] = np.arange(n) * 0.01 # 10mm 間隔 + values = np.zeros(n, dtype=np.float64) + values[:5] = 0.0 + values[5:] = 1.0 # 段差 + return centroids, values + + def test_radius_zero_returns_copy(self, line_centroids_and_values): + """radius=0 のとき入力コピーを返すこと(恒等).""" + centroids, values = line_centroids_and_values + result = smooth_face_scalars_radius(centroids, values, radius=0.0) + np.testing.assert_array_equal(result, values) + + def test_radius_negative_returns_copy(self, line_centroids_and_values): + """radius<0 のとき入力コピーを返すこと(恒等).""" + centroids, values = line_centroids_and_values + result = smooth_face_scalars_radius(centroids, values, radius=-0.01) + np.testing.assert_array_equal(result, values) + + def test_radius_inf_returns_copy(self, line_centroids_and_values): + """radius=inf のとき入力コピーを返すこと(恒等).""" + centroids, values = line_centroids_and_values + result = smooth_face_scalars_radius(centroids, values, radius=float("inf")) + np.testing.assert_array_equal(result, values) + + def test_radius_nan_returns_copy(self, line_centroids_and_values): + """radius=nan のとき入力コピーを返すこと(恒等).""" + centroids, values = line_centroids_and_values + result = smooth_face_scalars_radius(centroids, values, radius=float("nan")) + np.testing.assert_array_equal(result, values) + + def test_empty_input_returns_copy(self): + """空入力(n_faces=0)のとき空配列を返すこと(恒等).""" + centroids = np.zeros((0, 3), dtype=np.float64) + values = np.zeros(0, dtype=np.float64) + result = smooth_face_scalars_radius(centroids, values, radius=0.01) + assert result.shape == (0,) + + def test_uniform_input_stays_uniform(self): + """均一値入力は平滑化後も均一のままであること.""" + n = 20 + centroids = np.random.default_rng(42).random((n, 3)) * 0.1 + values = np.full(n, 3.14, dtype=np.float64) + result = smooth_face_scalars_radius(centroids, values, radius=0.02) + np.testing.assert_allclose(result, 3.14, rtol=1e-6) + + def test_output_shape_preserved(self, line_centroids_and_values): + """出力 shape が入力 values と同一であること.""" + centroids, values = line_centroids_and_values + result = smooth_face_scalars_radius(centroids, values, radius=0.02) + assert result.shape == values.shape + + def test_output_dtype_preserved(self, line_centroids_and_values): + """出力 dtype が入力 values と同一であること.""" + centroids, values = line_centroids_and_values + values = values.astype(np.float32) + result = smooth_face_scalars_radius(centroids, values, radius=0.02) + assert result.dtype == np.float32 + + def test_step_function_is_smoothed(self, line_centroids_and_values): + """段差のある分布が平滑化後に滑らかになること(境界の急峻さが緩和される).""" + centroids, values = line_centroids_and_values + # radius=0.03 (30mm) は隣接 3 face を覆う + result = smooth_face_scalars_radius(centroids, values, radius=0.03) + # 段差の境界付近(index 4-5)で元の 0/1 から変化していること + # 厳密な値は近似に依存するが「完全に 0 または 1 でない」ことを確認 + boundary = result[4] # 段差直前(元値 0) + assert boundary > 0.0, f"段差前が平滑化で 0 より大きくなること: {boundary}" + # 端の値(element 0, 9)は内部より影響が小さい + assert result[0] < result[4] # 端より境界付近の方が影響大 + + def test_face_areas_none_vs_uniform_area_equivalent(self): + """face_areas=None と一様 area=1.0 の結果が(概ね)一致すること.""" + n = 15 + rng = np.random.default_rng(7) + centroids = rng.random((n, 3)) * 0.1 + values = rng.random(n) + radius = 0.03 + + result_none = smooth_face_scalars_radius(centroids, values, radius=radius) + uniform_areas = np.ones(n, dtype=np.float64) + result_uniform = smooth_face_scalars_radius( + centroids, values, radius=radius, face_areas=uniform_areas + ) + # face_areas=None(全 area=1.0 と等価)と一様 area=1.0 の結果が一致すること + np.testing.assert_allclose(result_none, result_uniform, rtol=1e-6) + + +# =========================================================================== +# 5. _get_triangle_faces — PolyData / UnstructuredGrid から三角形配列取得 +# =========================================================================== + + +class TestGetTriangleFaces: + """_get_triangle_faces の仕様テスト. + + 仕様(viewer.py): + - pv.PolyData(三角形メッシュ)から (N_f, 3) int64 を返す + - PolyData を extract_cells した UnstructuredGrid からも (N_f, 3) を返す + - 非三角形メッシュで None を返す + """ + + def test_polydata_returns_n_by_3_int64(self): + """pv.Plane().triangulate() から (N_f, 3) int64 が返ること.""" + pv = pytest.importorskip("pyvista") + plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate() + result = _get_triangle_faces(plane) + assert result is not None + assert result.ndim == 2 + assert result.shape[1] == 3 + assert result.dtype == np.int64 + + def test_polydata_face_count_matches_n_cells(self): + """PolyData の face 数が n_cells と一致すること.""" + pv = pytest.importorskip("pyvista") + plane = pv.Plane(i_resolution=3, j_resolution=3).triangulate() + result = _get_triangle_faces(plane) + assert result is not None + assert len(result) == plane.n_cells + + def test_polydata_indices_in_bounds(self): + """PolyData から取得した face インデックスが頂点数範囲内であること.""" + pv = pytest.importorskip("pyvista") + plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate() + result = _get_triangle_faces(plane) + assert result is not None + n_verts = plane.n_points + assert result.min() >= 0 + assert result.max() < n_verts + + def test_unstructured_grid_from_extract_cells(self): + """PolyData.extract_cells() した UnstructuredGrid から (N_f, 3) が返ること.""" + pv = pytest.importorskip("pyvista") + plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate() + # extract_cells は UnstructuredGrid を返す + if plane.n_cells > 1: + ug = plane.extract_cells(list(range(plane.n_cells // 2))) + else: + ug = plane.extract_cells([0]) + + result = _get_triangle_faces(ug) + assert result is not None + assert isinstance(result, np.ndarray) + assert result.ndim == 2 + assert result.shape[1] == 3 + + def test_empty_polydata_returns_none(self): + """空の PolyData(face なし)で None が返ること.""" + pv = pytest.importorskip("pyvista") + empty = pv.PolyData() + result = _get_triangle_faces(empty) + assert result is None + + +# =========================================================================== +# 6. show_scene シグネチャ後方互換 — 新引数の存在とデフォルト値 +# =========================================================================== + + +class TestShowSceneNewSignature: + """show_scene の新引数シグネチャ・デフォルト値の仕様テスト(Mitsuba 不要). + + 仕様(viewer.py show_scene): + style: str = "default" + cmap: str | None = None + subdivide_max_edge: float | None = None + bake_denoise: int = 0 + bake_smooth_radius: float | None = None + backface_cull: bool = False + """ + + def _get_param(self, name: str): + sig = inspect.signature(show_scene) + return sig.parameters.get(name) + + def test_style_param_exists_with_default_default(self): + """style 引数が存在し、デフォルト 'default' であること.""" + param = self._get_param("style") + assert param is not None + assert param.default == "default" + + def test_cmap_param_exists_with_default_none(self): + """cmap 引数が存在し、デフォルト None であること.""" + param = self._get_param("cmap") + assert param is not None + assert param.default is None + + def test_subdivide_max_edge_param_exists_with_default_none(self): + """subdivide_max_edge 引数が存在し、デフォルト None であること.""" + param = self._get_param("subdivide_max_edge") + assert param is not None + assert param.default is None + + def test_bake_denoise_param_exists_with_default_zero(self): + """bake_denoise 引数が存在し、デフォルト 0 であること.""" + param = self._get_param("bake_denoise") + assert param is not None + assert param.default == 0 + + def test_bake_smooth_radius_param_exists_with_default_none(self): + """bake_smooth_radius 引数が存在し、デフォルト None であること.""" + param = self._get_param("bake_smooth_radius") + assert param is not None + assert param.default is None + + def test_backface_cull_param_exists_with_default_false(self): + """backface_cull 引数が存在し、デフォルト False であること.""" + param = self._get_param("backface_cull") + assert param is not None + assert param.default is False + + def test_all_new_params_are_keyword_only(self): + """新規引数がすべて keyword-only であること(後方互換).""" + sig = inspect.signature(show_scene) + new_params = [ + "style", "cmap", "subdivide_max_edge", + "bake_denoise", "bake_smooth_radius", "backface_cull", + ] + for name in new_params: + param = sig.parameters.get(name) + assert param is not None, f"{name} が存在すること" + assert param.kind == inspect.Parameter.KEYWORD_ONLY, ( + f"{name} は keyword-only であること(got kind={param.kind})" + ) + + def test_existing_params_still_present(self): + """既存引数が維持されていること(後方互換).""" + sig = inspect.signature(show_scene) + existing = [ + "scene_config", "project_root", "irradiance", "luminance_map", + "off_screen", "screenshot", "clip_low", "clip_high", + "bake_opacity", "hide_below", "bake_smooth", "bake_scale", + "cull_outward", "hide_sensor_marker", "hide_led_markers", + ] + for name in existing: + assert name in sig.parameters, f"既存引数 '{name}' が維持されていること" + + +# =========================================================================== +# 7. CLI 新引数パース仕様テスト +# =========================================================================== + + +class TestCliNewArgParsing: + """cli/visualize.py の新引数パース仕様テスト. + + 仕様(cli/visualize.py _parse_args): + --style {default,slide}: 既定 "default" + --cmap NAME: 既定 None + --bake-subdivide MM: 既定 None + --bake-denoise N: 既定 0 + --bake-smooth-radius MM: 既定 None + --backface-cull: 既定 False(store_true) + """ + + def _parse(self, extra: list[str]): + from sm_tias_lightsim.cli.visualize import _parse_args + return _parse_args(["--scene", "scene.yaml"] + extra) + + def test_style_default_is_default(self): + """--style を指定しない場合、style='default' であること.""" + args = self._parse([]) + assert args.style == "default" + + def test_style_slide(self): + """--style slide が正しくパースされること.""" + args = self._parse(["--style", "slide"]) + assert args.style == "slide" + + def test_style_default_explicit(self): + """--style default が正しくパースされること.""" + args = self._parse(["--style", "default"]) + assert args.style == "default" + + def test_cmap_default_is_none(self): + """--cmap を指定しない場合、cmap=None であること.""" + args = self._parse([]) + assert args.cmap is None + + def test_cmap_viridis(self): + """--cmap viridis が正しくパースされること.""" + args = self._parse(["--cmap", "viridis"]) + assert args.cmap == "viridis" + + def test_cmap_hot(self): + """--cmap hot が正しくパースされること.""" + args = self._parse(["--cmap", "hot"]) + assert args.cmap == "hot" + + def test_bake_subdivide_default_is_none(self): + """--bake-subdivide を指定しない場合、bake_subdivide=None であること.""" + args = self._parse([]) + assert args.bake_subdivide is None + + def test_bake_subdivide_value_10(self): + """--bake-subdivide 10 が float 10.0 としてパースされること.""" + args = self._parse(["--bake-subdivide", "10"]) + assert args.bake_subdivide == pytest.approx(10.0) + + def test_bake_subdivide_is_float_type(self): + """--bake-subdivide がパースされ float 型であること.""" + args = self._parse(["--bake-subdivide", "5.5"]) + assert isinstance(args.bake_subdivide, float) + + def test_bake_denoise_default_is_zero(self): + """--bake-denoise を指定しない場合、bake_denoise=0 であること.""" + args = self._parse([]) + assert args.bake_denoise == 0 + + def test_bake_denoise_value_3(self): + """--bake-denoise 3 が int 3 としてパースされること.""" + args = self._parse(["--bake-denoise", "3"]) + assert args.bake_denoise == 3 + + def test_bake_denoise_is_int_type(self): + """--bake-denoise がパースされ int 型であること.""" + args = self._parse(["--bake-denoise", "2"]) + assert isinstance(args.bake_denoise, int) + + def test_bake_smooth_radius_default_is_none(self): + """--bake-smooth-radius を指定しない場合、bake_smooth_radius=None であること.""" + args = self._parse([]) + assert args.bake_smooth_radius is None + + def test_bake_smooth_radius_value_8(self): + """--bake-smooth-radius 8 が float 8.0 としてパースされること.""" + args = self._parse(["--bake-smooth-radius", "8"]) + assert args.bake_smooth_radius == pytest.approx(8.0) + + def test_backface_cull_default_is_false(self): + """--backface-cull を指定しない場合、backface_cull=False であること.""" + args = self._parse([]) + assert args.backface_cull is False + + def test_backface_cull_flag_sets_true(self): + """--backface-cull を指定すると backface_cull=True になること.""" + args = self._parse(["--backface-cull"]) + assert args.backface_cull is True + + +# =========================================================================== +# 8. CLI → main() 単位変換(mm → m)の仕様テスト +# =========================================================================== + + +class TestCliUnitConversion: + """CLI main() 内の mm → m 変換仕様テスト. + + 仕様(cli/visualize.py main): + bake_subdivide_m = bake_subdivide * 0.001 + bake_smooth_radius_m = bake_smooth_radius * 0.001 + show_scene に m 単位で渡す + """ + + def _compute_bake_subdivide_m(self, args_bake_subdivide): + """main() の変換式を再現: mm → m.""" + if args_bake_subdivide is None: + return None + return float(args_bake_subdivide) * 0.001 + + def _compute_bake_smooth_radius_m(self, args_bake_smooth_radius): + """main() の変換式を再現: mm → m.""" + if args_bake_smooth_radius is None: + return None + return float(args_bake_smooth_radius) * 0.001 + + def test_subdivide_10mm_to_0_01m(self): + """--bake-subdivide 10 が 0.010 m に変換されること.""" + result = self._compute_bake_subdivide_m(10.0) + assert result == pytest.approx(0.010) + + def test_subdivide_5_5mm_to_0_0055m(self): + """--bake-subdivide 5.5 が 0.0055 m に変換されること.""" + result = self._compute_bake_subdivide_m(5.5) + assert result == pytest.approx(0.0055) + + def test_subdivide_none_stays_none(self): + """--bake-subdivide 未指定(None)のとき変換後も None であること.""" + result = self._compute_bake_subdivide_m(None) + assert result is None + + def test_smooth_radius_8mm_to_0_008m(self): + """--bake-smooth-radius 8 が 0.008 m に変換されること.""" + result = self._compute_bake_smooth_radius_m(8.0) + assert result == pytest.approx(0.008) + + def test_smooth_radius_none_stays_none(self): + """--bake-smooth-radius 未指定(None)のとき変換後も None であること.""" + result = self._compute_bake_smooth_radius_m(None) + assert result is None + + +# =========================================================================== +# 9. slide スタイルの動作仕様(style バリデーション・センサマーカー制御) +# =========================================================================== + + +class TestStyleBehavior: + """show_scene の style 引数バリデーション仕様テスト(Mitsuba 不要). + + 仕様(viewer.py): + - "slide" のとき hide_sensor_marker が自動 True に畳み込まれる + - 未知 style → "default" にフォールバック(WARNING) + """ + + def test_unknown_style_warns_and_uses_default(self, caplog): + """未知の style 値を渡すと WARNING ログが出ること. + + 仕様: 未知の style 値は "default" にフォールバックして warning する. + この挙動は show_scene 内部で実行されるため off_screen smoke で確認. + """ + import logging + pv = pytest.importorskip("pyvista") + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.visualize.viewer"): + show_scene( + config, + project_root, + irradiance=None, + off_screen=True, + style="unknown_style_xyz", + ) + + warning_texts = [r.message for r in caplog.records if r.levelno >= logging.WARNING] + assert any("unknown_style_xyz" in str(t) for t in warning_texts), ( + "未知の style に対して WARNING ログが出ること" + ) + + +class TestCmapValidation: + """show_scene の cmap 引数バリデーション仕様テスト(WARNING ログ確認).""" + + def test_unknown_cmap_warns(self, caplog): + """未知の cmap 名を渡すと WARNING ログが出ること. + + 仕様: 不正な名前は warning を出して style 既定にフォールバックする. + """ + import logging + pytest.importorskip("pyvista") + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + + with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.visualize.viewer"): + show_scene( + config, + project_root, + irradiance=None, + off_screen=True, + cmap="nonexistent_cmap_xyz", + ) + + warning_texts = [r.message for r in caplog.records if r.levelno >= logging.WARNING] + assert any("nonexistent_cmap_xyz" in str(t) for t in warning_texts), ( + "未知の cmap に対して WARNING ログが出ること" + ) + + +# =========================================================================== +# 10. キャッシュの _subdivide_setting 整合チェック +# =========================================================================== + + +class TestSubdivideCacheIntegrity: + """bake_subdivide と npz キャッシュの _subdivide_setting 整合仕様テスト. + + 仕様(cli/visualize.py): + - キャッシュ npz に _subdivide_setting を float64 で保存する + - None(分割なし)のとき nan を保存する + - 読み込み時に設定が不一致なら stderr に警告を出し再ベイクする + - 旧キャッシュ(_subdivide_setting キーなし)は「分割なし」扱い + """ + + def _make_cached_subdivide_setting(self, bake_subdivide_m): + """main() のキャッシュ保存ロジックを再現する.""" + return bake_subdivide_m if bake_subdivide_m is not None else float("nan") + + def _is_mismatch(self, cached_subdivide_m, bake_subdivide_m): + """main() の不整合チェックロジックを再現する.""" + _cached_none = np.isnan(cached_subdivide_m) + _current_none = bake_subdivide_m is None + _mismatch = (_cached_none != _current_none) or ( + not _cached_none + and not _current_none + and abs(cached_subdivide_m - bake_subdivide_m) > 1e-9 + ) + return _mismatch + + def test_none_saved_as_nan(self): + """bake_subdivide_m=None のとき nan が保存されること.""" + val = self._make_cached_subdivide_setting(None) + assert np.isnan(val) + + def test_value_saved_as_is(self): + """bake_subdivide_m=0.010 のとき 0.010 が保存されること.""" + val = self._make_cached_subdivide_setting(0.010) + assert val == pytest.approx(0.010) + + def test_both_none_no_mismatch(self): + """キャッシュ=nan(分割なし)かつ現在=None(分割なし)で不一致なし.""" + assert not self._is_mismatch(float("nan"), None) + + def test_both_same_value_no_mismatch(self): + """キャッシュ=0.010 かつ現在=0.010 で不一致なし.""" + assert not self._is_mismatch(0.010, 0.010) + + def test_cached_none_current_value_is_mismatch(self): + """キャッシュ=nan(分割なし)かつ現在=0.010 で不一致あり.""" + assert self._is_mismatch(float("nan"), 0.010) + + def test_cached_value_current_none_is_mismatch(self): + """キャッシュ=0.010 かつ現在=None(分割なし)で不一致あり.""" + assert self._is_mismatch(0.010, None) + + def test_cached_different_value_is_mismatch(self): + """キャッシュ=0.010 かつ現在=0.020 で不一致あり.""" + assert self._is_mismatch(0.010, 0.020) + + def test_cached_same_value_within_tolerance(self): + """キャッシュと現在が 1e-10 程度の誤差内なら不一致なし(浮動小数点ゆらぎ許容).""" + cached = 0.010 + current = 0.010 + 1e-12 # 許容範囲内 + assert not self._is_mismatch(cached, current) + + def test_npz_roundtrip_subdivide_setting(self, tmp_path): + """_subdivide_setting を npz 保存→読み込みするとラウンドトリップできること.""" + path = tmp_path / "test_cache.npz" + value = np.float64(0.010) + dummy_irr = np.array([1.0, 2.0, 3.0], dtype=np.float32) + np.savez(str(path), _subdivide_setting=value, mesh_a=dummy_irr) + + loaded = np.load(str(path), allow_pickle=False) + assert "_subdivide_setting" in loaded.files + assert float(loaded["_subdivide_setting"]) == pytest.approx(0.010) + + def test_npz_roundtrip_nan_setting(self, tmp_path): + """_subdivide_setting=nan を npz 保存→読み込みするとラウンドトリップできること.""" + path = tmp_path / "test_cache_nan.npz" + nan_val = np.float64(float("nan")) + np.savez(str(path), _subdivide_setting=nan_val) + + loaded = np.load(str(path), allow_pickle=False) + assert np.isnan(float(loaded["_subdivide_setting"])) + + def test_legacy_cache_without_subdivide_key_treated_as_none(self, tmp_path): + """旧キャッシュ(_subdivide_setting キーなし)は「分割なし(nan)」扱いであること. + + 実装: 'if "_subdivide_setting" in npz.files:' がなければ nan にフォールバック + """ + path = tmp_path / "legacy_cache.npz" + dummy_irr = np.array([1.0, 2.0], dtype=np.float32) + np.savez(str(path), mesh_a=dummy_irr) + + loaded = np.load(str(path), allow_pickle=False) + # 旧キャッシュには _subdivide_setting キーがない + assert "_subdivide_setting" not in loaded.files + # 実装は nan(分割なし)を使う + cached_subdivide_m = float("nan") if "_subdivide_setting" not in loaded.files \ + else float(loaded["_subdivide_setting"]) + assert np.isnan(cached_subdivide_m) + + +# =========================================================================== +# 11. smoke テスト(slow マーク: off_screen + screenshot) +# =========================================================================== + + +class TestSmokeNewArgs: + """新引数を渡した show_scene / CLI の smoke テスト. + + GUIDE_08: 可視化は「ファイルが生成されたか」程度の smoke テストで十分. + """ + + @pytest.mark.slow + def test_style_slide_creates_screenshot(self, tmp_path): + """style='slide' で screenshot が生成されること.""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "slide.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + style="slide", + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_style_default_creates_screenshot(self, tmp_path): + """style='default' で screenshot が生成されること(後方互換).""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "default_style.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + style="default", + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_cmap_viridis_creates_screenshot(self, tmp_path): + """cmap='viridis' で screenshot が生成されること.""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "cmap_viridis.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + cmap="viridis", + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_bake_denoise_positive_creates_screenshot(self, tmp_path): + """bake_denoise=2 で screenshot が生成されること(irradiance なし → 恒等).""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "denoise.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + bake_denoise=2, + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_backface_cull_true_creates_screenshot(self, tmp_path): + """backface_cull=True で screenshot が生成されること.""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "backface_cull.png" + + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + backface_cull=True, + ) + assert screenshot_path.exists() + assert screenshot_path.stat().st_size > 0 + + @pytest.mark.slow + def test_all_new_defaults_backward_compat(self, tmp_path): + """新引数すべてデフォルト値で既存の show_scene 呼び出しが動くこと(後方互換).""" + from sm_tias_lightsim.scene.schema import load_scene_yaml + + config = load_scene_yaml(MINIMAL_YAML) + project_root = MINIMAL_YAML.parent + screenshot_path = tmp_path / "all_defaults.png" + + # 新引数を一切指定しない + show_scene( + config, + project_root, + irradiance=None, + screenshot=screenshot_path, + ) + assert screenshot_path.exists() + + @pytest.mark.slow + def test_cli_style_slide_no_bake_creates_screenshot(self, tmp_path): + """CLI --style slide --no-bake --screenshot で PNG が生成されること.""" + from sm_tias_lightsim.cli.visualize import main + + screenshot_path = tmp_path / "cli_slide.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--style", "slide", + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert screenshot_path.exists() + + @pytest.mark.slow + def test_cli_backface_cull_no_bake_creates_screenshot(self, tmp_path): + """CLI --backface-cull --no-bake --screenshot で PNG が生成されること.""" + from sm_tias_lightsim.cli.visualize import main + + screenshot_path = tmp_path / "cli_backface.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--backface-cull", + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert screenshot_path.exists() + + @pytest.mark.slow + def test_cli_bake_denoise_no_bake_creates_screenshot(self, tmp_path): + """CLI --bake-denoise 1 --no-bake --screenshot で PNG が生成されること.""" + from sm_tias_lightsim.cli.visualize import main + + screenshot_path = tmp_path / "cli_denoise.png" + result = main([ + "--scene", str(MINIMAL_YAML), + "--no-bake", + "--bake-denoise", "1", + "--screenshot", str(screenshot_path), + ]) + assert result == 0 + assert screenshot_path.exists() + + @pytest.mark.slow + def test_cli_unknown_style_no_bake_creates_screenshot(self, tmp_path): + """CLI に不正な --style を渡すと argparse エラー(choices 制約)で SystemExit すること. + + 仕様: --style は choices=["default", "slide"] なので未知値は argparse がエラーを出す. + """ + from sm_tias_lightsim.cli.visualize import _parse_args + + with pytest.raises(SystemExit) as exc_info: + _parse_args(["--scene", "scene.yaml", "--style", "invalid_style"]) + assert exc_info.value.code != 0