diff --git a/.gitignore b/.gitignore index bf6eadd..6fe30d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # /implement が /commit に渡すコンテキストファイル .claude/commit-context.md +# harness の wakeup スケジュール内部状態(環境固有) +.claude/*.lock + # サブプロジェクト(一旦トラッキング対象外) MiniTIAS/ MiniTias-Evaluation/ @@ -16,6 +19,9 @@ configs/*.npz configs/view_*.yaml +# スイープスクリプトの実行時一時 YAML(STL 絶対パスを含むため環境固有) +configs/_tmp_*/ + # 個人ローカルの参考画像(実機写真・データシート等) REFERENCE/ diff --git a/CLAUDE.md b/CLAUDE.md index e7867c6..e44dd56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,17 @@ 補足: **実機変更とシミュ側対応の整合マッピングを明文化 (2026-06-02)**.[TECH_08 実機変更とシミュ側対応](docs/05_TECH/TECH_08_実機変更とシミュ側対応.md) として作成.実機側 8 項目変更 (PNG→DNG / AWB OFF / AE OFF / ガンマなし / NR OFF / EDGE OFF / LSC 維持 / LSC 順方向適用) に対するシミュ側 3 層 (Mitsuba シーン定義 α / sm-render β / sm-evaluate γ) の対応をマッピング.**結論: シミュ側で実際に対応が必要だったのは sm-evaluate の config レベルでの OETF 無効化 (#4) と mean アンカー調整 (#3) の 2 項目のみで,他はすべて対応不要 (Mitsuba は per-channel 独立計算で AWB/AE/NR/EDGE 概念なし,orthographic で LSC 自然対応)**.計 15 項目以上で網羅チェック,対応漏れなしを確認.特にガンマ補正は `configs/eval_baseline_dng_target.yaml` で OETF セクションを意図的に書かない形で Step C' にて対応済.**残差 2.63× は ISP 関連の対応漏れではなく,シミュ物理モデル誤差 (反射率精度・LED 配光モデル・メッシュ幾何精度等) が主因**と推定.詳細な切り分けは Step 8-14 以降の物理モデル精緻化として残す. 補足: **「対応不要」を 2 タイプに細分化 (TECH_08 §許容している構造的差異 追加)**.「対応不要」の理由を **タイプ I (本質構造同一・対応概念なし)** = AWB/NR/EDGE/LSC 等と,**タイプ II (構造的差異あり・白板で許容)** = AF (焦点モデル)/絞り口径食/ピクセル積分/センサノイズ/QE 波長依存/シャッター方式/温度依存 に分類.特に AF は実機 perspective+固定焦点 vs シミュ orthographic 無限遠相当という **シミュと実機の構造的な違い**を明示.白板撮影では影響軽微で許容しているが「ゼロ影響」ではない.舌模型評価時や残差精緻化フェーズで再評価対象.Step 8-13 残差 2.63× の主因はタイプ II ではなくシミュ物理モデル誤差と推定する従来結論は維持. 補足: **S3 全体完結 — TEST_01 に Step 8-13 正式記録 + handoff 整理 (2026-06-02)**.[TEST_01 §Step 8-13](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md) に新方針 (実機 DNG+LSC linear) でのシミュ突合を Step 8-1〜8-12 と同フォーマットで正式記録.俯瞰サマリ表に新ターゲット (CoV=0.0695) と Step 8-13 結果 (CoV=0.1826, 残差 2.63×) を追加,過去 2.1× が PNG sRGB ガンマ圧縮による楽観値だった旨も明記.§Step 8-N 候補 (LSC 順方向モデル) を「不採用 — Step 8-13 で方針確定」のヘッダに更新し冒頭注記で経緯を残す (歴史的記録として保持).`handoff_smtias_lightsim/` ディレクトリは S3 完結のため整理: 参考スクリプト `run_uniformity_dng.py` のみ `reference_measurement/quantitative/reference_scripts/` に移動 (MiniTias-Evaluation 側の正規評価実装 = LSC 順方向適用 + portrait 向き合わせ + ROI 抽出の参考.将来 SHADING_OFF フォールバック発動時の独立検証用),他は全て SmTIAS-LightSim 側に取り込み済みのため削除.S3 全体 (S2 DNG ローダコピー + Step D radial_min_max 統合 + Step C' 新規 baseline config + TECH_07 LSC 方針 + TECH_08 対応マッピング + Step 8-13 計測 + handoff 整理) 完結.次のキャリブ作業は Step 8-14 以降の物理モデル精緻化 (反射率精度・LED 配光・メッシュ幾何) と TECH_04 フォールバック条件 C1 (SHADING_OFF 撮影) の並行検討. +補足: **LED データシート調査結果を [TECH_09](docs/05_TECH/TECH_09_LEDデータシート調査結果.md) として明文化 (2026-06-02)**.過去 SPEC_03 で「LED Paradise 商品ページにデータシート PDF 掲載なし」「詳細 IES 未提供」と記述していたが,**実は OEM 元 HONGLITRONIC (中国広州,鸿利智汇集团股份有限公司) のデータシートが存在**することが判明.LED Paradise LP-2835D90VW は HONGLITRONIC の HL-A-2835D90VW シリーズ (本命 PDF: lcsc.com/datasheet/C5362368.pdf, SPEC NO B-18-A-0831, 2022-01) の OEM 再販品.Vf 3.1V / 配光角 120° / 単チップ 2835 で物性整合.**配光図 (Page 6) から数値読み取り: ほぼ厳密 Lambertian (cos θ) と一致** (0°→1.0, 30°→0.87, 60°→0.50 FWHM, 80°→0.15, 90°→0).分光分布 SPD も Page 6 に掲載 (NeutralWhite 4000K, 420nm 鋭ピーク + 500-650nm 蛍光体ブロード).**重要な含意**: (1) 現状の area light Lambertian モデルはデータシート実測と整合.配光モデル精緻化余地は限定的.(2) **過去 Step 8-11「データシート従順版」(cos^1.2) は実態はデータシート不整合**だった (同等品 Cree/Samsung/Lumileds の典型配光を借用していた).道理で形状指標が悪化.(3) **Step 8-14 候補 B (LED 配光詳細化) の意義は限定的**.残差 2.63× の主因は配光以外 (反射率精度・幾何・タイプ II 構造的差異・内壁の非 Lambertian 性等).SPEC_03 の「やや前方凸」記述は HONGLITRONIC データシートでは確認できないので訂正対象.取得 PDF は REFERENCE/datasheets/ に正式配置済 (個人ローカル保持,gitignore 対象). +補足: **docs/05_TECH と docs/06_TEST に README 追加 (2026-06-02)**.15 ファイル (TECH 9 + TEST 6) がフラットに並んで「散らかり感」あるため,性質分類と navigation を README で提供.05_TECH/README: 🟢 現状方針 (TECH_07-09) と 🔵 議論の経緯 (TECH_01-06) に 2 分類,主な結論サマリ表,経緯マップ図.06_TEST/README: 🟡 キャリブ作業の中核 (TEST_01-03) と 🔵 クローズ済み (TEST_04) と 📋 外部スナップショット (TEST_05/06) に 3 分類,3 ファイル使い分け表.ファイル削除・移動はせずナビゲーションのみ追加. +補足: **残差 2.63× の主因候補を [TECH_10](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md) として網羅整理 (2026-06-02)**.Step 8-14 候補選定の判断材料として作成.ユーザー指摘 3 候補 (LED 配光・反射率・反射回数) + 私の追加候補で 5 カテゴリ 18 項目を網羅.**優先度評価: 🥇 1 位 A1 内壁の非 Lambertian 性 (光沢成分混合 BSDF)** - 「形」を変える可能性が物理的に最も高い,3D プリンタ白フィラメントは完全 Lambertian とは限らない / **🥈 2 位 A8 反射率 R=0.85→0.89** - Step 8-6 物理整合点で多重反射ゲイン 1.36× / **🥉 3 位 A9 max_depth 8→16+** - 後段多重反射の取りこぼし可能性.ユーザー認識 (LED 配光が一番影響) との差分: TECH_09 でデータシート Lambertian 整合済を踏まえて優先度を低下.Step 8-14 候補: β (A1 単独) か δ (A1+A8+A9 全部入り) が筋. +補足: **Step 8-14 候補 γ (D1 ROI 整合の再確認) 完了 — クリア (2026-06-02)**.他の物理モデル変更に進む前の事前確認として実施.設定変更ゼロで,既存設定の数値検証のみ.シミュ ROI (24, 37, 464, 450) の物理範囲 (X∈[-45.31, +45.31] mm × Y∈[-46.92, +44.48] mm,幅 90.63×91.41 mm) と実機 ROI (539, 1015, 1400, 1409) の物理範囲 (実機 PNG の評価板黒縁検出から物理分解能 0.0654 mm/px を確定 → 幅 91.54×92.13 mm) を比較した結果,**物理サイズ差 < 1%・中心位置差 < 1 mm で整合**.3 列パネル `output/_slide_panels/roi_alignment_check.png` (①実機画像 + 評価板境界・実機 ROI / ②シミュ輝度マップ + 評価板境界・シミュ ROI / ③物理座標系で重ね合わせ) でも視覚確認.**残差 2.63× の主因は ROI 整合ではない**ことが確定.過去の設定者 (`scripts/select_white_board.py` GUI 選択 + シミュ ortho_scale 設計値) が物理整合性を意識して ROI を揃えていたことが裏付けられた.副次発見: 評価板は実機画像中央から下方向に約 4.2 mm (65 px) オフセット配置されているが (取付位置のミリ精度差),ROI もそのオフセットに沿って選択されているため物理領域として整合.次のアクション: 残候補 β (A1 単独) / α (A8+A9) / δ (β+α 全部入り) を選択して Step 8-14 を実施. +補足: **Step 8-14 候補 β (A1 内壁の非 Lambertian 性) 完了 — クリア (2026-06-02)**.候補リスト 🥇 1 位だった A1 を 3×3 グリッド感度分析で検証.スキーマ拡張 (`BlendBSDFConfig` + `RoughConductorBSDFConfig` 追加,`MaterialConfig` Union 拡張,forward reference + `model_rebuild()`) と `materials.py` 再帰変換 (`_build_bsdf_dict`) を新規実装.テンプレ `configs/scene_baseline_blend.yaml` + スイープスクリプト `scripts/sweep_blend_bsdf.py` + 比較パネルスクリプト `scripts/build_blend_bsdf_panel.py`.新規テスト 89 件追加 (608 → 697 全通過).**weight ∈ {0.05, 0.15, 0.30} × α ∈ {0.1, 0.3, 0.6} の 9 ジョブ実走 (75.5 分)**.結果: ベースライン純 diffuse (CoV=0.1826, 勾配=24.65%, C/P=1.327, 動径 min/max=0.615) と比較して **全 9 パターンが悪化または不変** (最良 w=0.05/α=0.6 で CoV=0.1851,最悪 w=0.30/α=0.1 で CoV=0.2433).実機 (CoV=0.0695) 方向と **逆方向に動く**.「形」は w=0.30 で下端中央に 2 つの specular 集中スポットが顕在化.物理的解釈: 純 Lambertian は cos 重み散乱で多重反射の均一化作用が最大.specular 成分は特定方向に光を集中させ均一化を阻害.**3D プリンタ白フィラメントは完全 Lambertian として扱うのが実機挙動に近い近似** と判明 (TECH_09 LED Lambertian 整合と並ぶ「物理モデル妥当性の裏付け」).**A1 は残差 2.63× の主因ではない**ことが確定.TECH_10 優先順位を更新: A1 を「クリア」化,🥇 1 位 A8 反射率 R=0.89 / 🥈 2 位 A9 max_depth 16+ / 🥉 3 位 B1 幾何精度 に繰り上げ.副次成果: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入り,今後の物理モデル試行で再利用可能.比較パネル `output/_slide_panels/blend_bsdf_sweep.png` + `blend_bsdf_metrics.png`,全 9 ジョブ集約 `output/blend_sweep/summary.csv`.[TECH_10 § A1 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#a1-検証結果2026-06-02-完了) / [TEST_01 § Step 8-14 候補 β](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-β-a1-内壁の非-lambertian-性--混合-bsdf-感度分析) / [SPEC_02 materials](docs/04_SPEC/SPEC_02_シーン定義仕様.md#materials) 参照.次のアクション: Step 8-14 候補 α (A8 R=0.89 + A9 depth=16) の組み合わせ実施. +補足: **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 軸評価**で「数値改善のみで形が変わらない候補」と「形を変えうる候補」を明確に区別する. 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) @@ -43,10 +54,10 @@ - `src/sm_tias_lightsim/evaluation/oetf.py` — 標準 sRGB OETF(IEC 61966-2-1)+ 露出ソルバ(OETF 後 ROI mean を実測値に対数二分でアンカー)+ 順方向適用.シミュ linear を実測 sRGB ドメインへ変換 - `src/sm_tias_lightsim/evaluation/normalize.py` — linear mean anchor 正規化(`apply_mean_anchor`).OETF と排他適用,形状指標は厳密不変で mean のみ target_mean に揃う.`configs/eval_baseline_linear.yaml` で有効化 - `src/sm_tias_lightsim/evaluation/visualize.py` — matplotlib 可視化(輝度マップ / ヒストグラム / 放射プロファイル / ゾーンマップ).MiniTias-Evaluation 互換スタイル(hot cmap,zone_map は luminance 背景 + 境界線) -- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応 +- `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ.SensorConfig は perspective / orthographic 両対応.materials は `DiffuseMaterial` / `RoughConductorBSDFConfig` (alpha + distribution=ggx/beckmann,material="none" 固定で完全反射体) / `BlendBSDFConfig` (weight + components×2,再帰ネスト可能,forward reference + `model_rebuild()`) を Union として並列サポート.後方互換維持 - `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス - `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 -- `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換(sensors は orthographic 対応,lights は `area` / `point` / `directional` / `spot` 対応.Step 8-10 で `SpotLight` を追加) +- `src/sm_tias_lightsim/scene/{lights,materials,sensors}.py` — Mitsuba emitter / BSDF / sensor dict 変換(sensors は orthographic 対応,lights は `area` / `point` / `directional` / `spot` 対応.Step 8-10 で `SpotLight` を追加,Step 8-14 β で `materials.py` に `_build_bsdf_dict` 再帰ヘルパを追加して `blendbsdf` ネスト対応) - `scripts/select_white_board.py` — 実測 PNG で白色板境界を OpenCV GUI で選択(Step 8 キャリブ用) - `scripts/visualize_roi_3d.py` — 評価板 STL に ROI を 3D 投影(PyVista,Step 8 キャリブ用) - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) @@ -56,11 +67,13 @@ - `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 で返す) +- `src/sm_tias_lightsim/evaluation/shape_metrics.py` — 輝度分布「形」評価指標モジュール(新規).現状 CoV/勾配/C/P/動径 min/max は不均一性の絶対量しか測れず,Y 軸単調勾配と中央ピーク楕円を構造的に区別できない問題を解決する 5 つの純関数: `calc_intensity_centroid`(輝度重心,5 値)/ `calc_angular_radial_decomposition`(極座標分解,angular_ratio が 0=同心円〜1=方向依存,3 値)/ `calc_ssim`(構造的類似度,scikit-image 依存,shape_reference 指定時のみ)/ `calc_ncc`(正規化相関係数,絶対輝度不変)/ `calc_intensity_pca`(weighted PCA で主軸角度と eccentricity,4 値).mask 省略可能,完全均一画像で angular_ratio/NCC は NaN を返す.`MetricsConfig.shape_metrics=true` / `shape_reference` フィールドで有効化,後方互換保持.依存追加: `scikit-image>=0.21.0`.詳細: [TECH_11 形状指標の見直し](docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md) ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/configs/eval_baseline_dng_target_shape.yaml b/configs/eval_baseline_dng_target_shape.yaml new file mode 100644 index 0000000..5cea129 --- /dev/null +++ b/configs/eval_baseline_dng_target_shape.yaml @@ -0,0 +1,45 @@ +# Step 8-13 以降の形状指標評価用設定. +# eval_baseline_dng_target.yaml をベースに metrics.shape_metrics: true を追加した版. +# +# 目的: +# 現状 4 指標(CoV / 勾配 / C/P / 動径 min/max)は不均一性の絶対量しか測れず, +# Y 軸単調勾配と中央ピーク楕円が「形として構造的に異なる」ことを区別できない. +# 本設定では「形」を直接評価する 5 指標を追加する: +# - 輝度重心 (intensity_centroid_x/y_norm, centroid_offset_norm) +# - 角度-放射分解 (angular_ratio: 0=同心円, 1=方向依存) +# - PCA 主軸 (pca_primary_axis_angle_deg, pca_eccentricity) +# - SSIM / NCC(shape_reference 指定時のみ) +# +# eval_baseline_dng_target.yaml からの差分: +# - metrics.shape_metrics: true を追加 +# - metrics.shape_reference: null(実機 DNG 由来参照画像は別途用意後に指定) +# +# SSIM / NCC を有効化するには shape_reference に参照画像パスを指定すること: +# 例: shape_reference: reference_measurement/quantitative/expected_results/MiniTIAS_QM_20260601_124500_luminance_map.npy +# 参照画像は ROI 抽出後の輝度 npy(同じ ROI サイズであること)を期待する. +# 形状 (H, W) が ROI と異なる場合は SSIM/NCC を自動スキップして NaN を格納する. +# +# 詳細: docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md +roi: + type: image_pixel + x: 24 + y: 37 + width: 464 + height: 450 +zones: + enabled: true + boundaries: + - 0.33 + - 0.66 +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true + shape_metrics: true + shape_reference: null # 実機 DNG 由来の参照輝度マップを指定すると SSIM/NCC が有効化される +normalization: + enabled: true + type: mean_anchor + target_mean: 207.82 # 実機 DNG+LSC の白板 ROI mean diff --git a/configs/scene_baseline.yaml b/configs/scene_baseline.yaml index 3eb42ae..638b4d9 100644 --- a/configs/scene_baseline.yaml +++ b/configs/scene_baseline.yaml @@ -403,5 +403,5 @@ render: variant: llvm_ad_rgb integrator: path - max_depth: 8 + max_depth: 16 spp: 1024 diff --git a/configs/scene_baseline_blend.yaml b/configs/scene_baseline_blend.yaml new file mode 100644 index 0000000..903fda8 --- /dev/null +++ b/configs/scene_baseline_blend.yaml @@ -0,0 +1,432 @@ +# Step 8-14 感度分析用: 内壁の非 Lambertian 性(光沢成分混合)テンプレート +# +# scene_baseline.yaml から `printer_white` 材料のみを blendbsdf に変更したもの. +# 他の設定(geometry / lights / sensor / render)は scene_baseline.yaml と完全に同じ. +# +# blendbsdf 構成: +# component[0]: diffuse (reflectance=0.85) ... 純粋 Lambertian 拡散反射 +# component[1]: roughconductor (alpha=0.3) ... GGX マイクロファセット鏡面反射 +# weight=0.15: component[1](鏡面)の混合比 = 15% specular +# +# スイープ対象パラメータ: +# weight ∈ {0.05, 0.15, 0.30} × alpha ∈ {0.1, 0.3, 0.6} の 9 組合せ +# → scripts/sweep_blend_bsdf.py が本ファイルを読んで weight/alpha を書き換えて実行する +# +# 参考: TECH_10 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +name: baseline_minitias_blend +description: > + MiniTIAS 基準設計シーン(Step 8-14 感度分析). + printer_white を diffuse + roughconductor の blendbsdf に変更して + 内壁の光沢成分が輝度分布「形」に与える影響を観察する. + weight=0.15, alpha=0.3 はスイープの中間値(起点). + LED 位置・センサ・レンダリング設定は scene_baseline.yaml と同一. +geometry: + meshes: + - id: housing_front + path: meshes/前側.stl + unit: mm + material: printer_white + - id: housing_back + path: meshes/後側.stl + unit: mm + material: printer_white + - id: phone_holder + path: meshes/スマホホルダ.stl + unit: mm + material: holder_dark + - id: led_cover_bottom + path: meshes/LED基板カバー下.stl + unit: mm + material: printer_white + - id: led_cover_right + path: meshes/LED基板カバー右.stl + unit: mm + material: printer_white + - id: led_cover_left + path: meshes/LED基板カバー左.stl + unit: mm + material: printer_white + - id: phone_body + path: meshes/SH-02M.stl + unit: mm + material: phone_body_dark + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.15 # specular 比(sweep 対象): 0=全 diffuse, 1=全 roughconductor + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.3 # GGX 粗さ(sweep 対象): 0=鏡面, 1=マット + distribution: ggx + holder_dark: + type: diffuse + reflectance: + - 0.5 + - 0.5 + - 0.5 + phone_body_dark: + type: diffuse + reflectance: + - 0.05 + - 0.05 + - 0.05 +lights: +- id: led_bottom_0 + type: area + shape: rectangle + center: + - -10.0 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_1 + type: area + shape: rectangle + center: + - -5.8 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_2 + type: area + shape: rectangle + center: + - -1.6 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_3 + type: area + shape: rectangle + center: + - 9.2 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_4 + type: area + shape: rectangle + center: + - 13.4 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_bottom_5 + type: area + shape: rectangle + center: + - 17.6 + - -71.0 + - -66.1 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_0 + type: area + shape: rectangle + center: + - 65.9987 + - -24.9738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_1 + type: area + shape: rectangle + center: + - 65.9987 + - -20.7738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_2 + type: area + shape: rectangle + center: + - 65.9987 + - -16.5738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_3 + type: area + shape: rectangle + center: + - 65.9987 + - -5.7738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_4 + type: area + shape: rectangle + center: + - 65.9987 + - -1.5738 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_right_5 + type: area + shape: rectangle + center: + - 65.9987 + - 2.6262 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_0 + type: area + shape: rectangle + center: + - -66.0067 + - -32.5952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_1 + type: area + shape: rectangle + center: + - -66.0067 + - -28.3952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_2 + type: area + shape: rectangle + center: + - -66.0067 + - -24.1952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_3 + type: area + shape: rectangle + center: + - -66.0067 + - -13.3952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_4 + type: area + shape: rectangle + center: + - -66.0067 + - -9.1952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +- id: led_left_5 + type: area + shape: rectangle + center: + - -66.0067 + - -4.9952 + - -66.0243 + size: + - 3.5 + - 2.8 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +sensor: + type: orthographic + position: + - 0.0 + - 0.0 + - -50.0 + target: + - 0.0 + - 0.0 + - 0.0 + up: + - 0.0 + - 1.0 + - 0.0 + ortho_scale: + - 50.0 + - 52.0 + film: + width: 512 + height: 512 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 1024 diff --git "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index a04637f..00885a6 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -60,15 +60,36 @@ ext_ior: 1.0 ``` +サポート済みの `type`: + +| type | Mitsuba 対応 BSDF | 用途 | 主要パラメータ | +| --- | --- | --- | --- | +| `diffuse` | `diffuse` | Lambertian 反射 | `reflectance: [r, g, b]` (RGB) or スカラ | +| `roughconductor` | `roughconductor` | GGX マイクロファセット鏡面(感度分析用) | `alpha: 0.0〜1.0` (粗さ), `distribution: ggx`/`beckmann`.`material="none"` 固定で eta=k=1 の完全反射体相当 | +| `blendbsdf` | `blendbsdf` | 2 BSDF の線形補間(混合材料モデル) | `weight: 0.0〜1.0` (components[1] の比率), `components: [BSDF, BSDF]` (2 要素必須,再帰ネスト可能) | + サポート予定の `type`: | type | Mitsuba 対応 BSDF | 用途 | | --- | --- | --- | -| `diffuse` | `diffuse` | Lambertian 反射 | | `dielectric` | `dielectric` | 誘電体(厚みあり) | | `thindielectric` | `thindielectric` | 薄膜誘電体(ディフューザー薄板) | | `mask` | `mask` | 透過率マスク | -| 拡張余地 | `roughconductor` 等 | 反射率調整 | + +`blendbsdf` + `roughconductor` の例(Step 8-14 候補 β で導入): + +```yaml +materials: + printer_white_with_specular: + type: blendbsdf + weight: 0.15 # specular 成分の比率 (0=純 diffuse, 1=純 specular) + components: + - type: diffuse + reflectance: [0.85, 0.85, 0.85] + - type: roughconductor + alpha: 0.3 # GGX 粗さ (0=完全鏡面, 1=拡散的鏡面) + distribution: ggx +``` ### lights diff --git "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" index 69d5287..3f0583c 100644 --- "a/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" +++ "b/docs/04_SPEC/SPEC_03_\345\237\272\346\272\226\350\250\255\350\250\210\343\201\256\347\211\251\347\220\206\346\247\213\346\210\220.md" @@ -170,7 +170,7 @@ | 項目 | 値 | 意味 | | --- | --- | --- | | `integrator` | `path` | **多重反射を考慮した** path tracing.LED → 内壁 → 評価板 のような間接照明経路もすべて積分する | -| `max_depth` | 8 | ray の最大反射回数.**キャリブで判明: 8 はキャリブ精度には不足**(depth=12 比で勾配を約 +2.6pt 過大評価.下記「キャリブレーション経過」参照).確定 YAML は現状 8 のまま(既知の差分 #6).キャリブ用途は depth≥12 推奨 | +| `max_depth` | **16** | ray の最大反射回数.**Step 8-14 候補 α 前半 (2026-06-03) で d=16 に更新**.5 値スイープ {8,12,16,20,28} の結果 d=16 で形状指標 4 つすべてが実用的に収束(d=16→28 で CoV 改善は 1.3% のみで MC ノイズ同等).理論累積 92.6%.d=8 時代に比べて勾配を 24.01→20.59% に改善.既知差分 #6 解消([TEST_01 § Step 8-14 候補 α 前半](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-前半-a9-max_depth-スイープ-8-12-16-20-28) 参照) | | `spp` | 1024 | pixel あたりサンプル数.大きいほどノイズ減・計算重.**spp 校正済み**(下記「キャリブレーション経過」参照).llvm_ad_rgb・512×512 で 1 レンダ約 9.6 分 | | `variant` | `llvm_ad_rgb` | LLVM JIT による RGB レンダリング.LLVM-C.dll が PATH(または DRJIT_LIBLLVM_PATH)で参照可能なこと | @@ -182,16 +182,16 @@ 2. **1 回反射**: LED → 内壁 → 評価板 3. **2 回反射**: LED → 内壁 → 内壁 → 評価板 4. ... -5. **max_depth (8) 回反射まで** +5. **max_depth (16) 回反射まで** MiniTIAS は **白い筐体内壁の多重反射で照明を均一化** する設計のため、多重反射の正確な計算が CoV 値の再現に必須. ### 制限 - [visualize/baker.py](../../src/sm_tias_lightsim/visualize/baker.py) は `mode="direct"`(直接照度のみ・後方互換)に加え `mode="path"`(scalar_rgb・完全シーン・コサイン重点 hemisphere MC による**多重反射込み per-face 照度**)を実装済み.3D ビューア(`sm-visualize`)の既定は全メッシュ path ベイク統一着色で、評価板も多重反射込みで色付く.ただし per-face ベイクはメッシュ境界の継ぎ目・粗メッシュのブロック感・低 samples の MC ノイズ・scalar_rgb の遅さ(高 samples は非現実的)という方式特性があり、**評価面の精細な照度マップ評価は引き続き 2D の `sm-render`/`sm-evaluate`(path tracer)で行う**(こちらが校正済みの定量結果).シーム解消には全メッシュ UV 統合のシームレスライトマップ基盤が必要(将来の別タスク). -- path ベイクの `max_depth` は `--bake-max-depth`(既定 16)で render 設定(`max_depth: 8`)から独立設定する.本ドキュメント記載の「depth=8 は均一化に効く後段多重反射を取りこぼし空間差を過大評価」の知見に基づき、3D ベイクは depth≥12 で内壁照度を物理どおり均一に近づける(2D の `sm-render` は校正都合で現状 8 のまま、独立). +- path ベイクの `max_depth` は `--bake-max-depth`(既定 16)で render 設定(`max_depth: 16`)から独立設定する.本ドキュメント記載の「depth=8 は均一化に効く後段多重反射を取りこぼし空間差を過大評価」の知見に基づき、3D ベイクは depth≥12 で内壁照度を物理どおり均一に近づける(2D の `sm-render` は Step 8-14 で d=16 へ更新済み、独立変更は不要). - 3D ビューアは既定で**幾何カリング(`--cull-outward`)**=法線がシーン外を向く「外向き面」を非描画にする.筐体の外向き面は空洞外=入射照度 ≈0 で、これを半透明描画すると暗殻の濁り・内壁との見かけ段差を生む(調査で判明:隣接内壁の照度自体は同一手法ベイクで ~1〜2 倍以内とほぼ均一.段差の主因は外向き ≈0 面の混在).カリングで内向き面のみ描画し、共有 clim も可視内向き面に密着させる. -- `max_depth` を超える反射は無視される.エネルギー的には `0.85^8 ≈ 27%` 減衰で小さいが、**キャリブ実測では無視できない**:同一条件で depth=8→12 にすると勾配が linear 空間で 24.65→22.08(−2.6pt)、sRGB 空間で 12.57→11.16(−1.4pt)下がった.8 回打ち切りはまさに均一化に効く後段反射を取りこぼし、勾配を系統的に過大評価する.キャリブ用途では depth≥12 を使うこと. +- `max_depth` を超える反射は無視される.現状 d=16 では `0.85^16 ≈ 7.4%` の最終 bounce 寄与で、理論累積の **92.6% を取り込み**.d=16→28 (98.7%) の追加改善は CoV 1.3% で MC ノイズ同等のため d=16 で実用収束.参考: 過去 d=8 では `0.85^8 ≈ 27%` 減衰で **キャリブ実測で無視できなかった**(depth=8→12 で勾配が linear 24.65→22.08 / sRGB 12.57→11.16).**Step 8-14 候補 α 前半で d=8 → 16 へ更新**.キャリブ用途は depth≥16 を使うこと. ## 筐体(材料) @@ -242,7 +242,7 @@ 5. ~~**linear vs sRGB のドメイン不一致(OETF 欠落)**~~(**最大要因・解消済み**): シミュ linear と実測 gamma 空間 luma を直接突合していた交絡で見かけ差の **約 2/3**.標準 sRGB OETF 順方向適用+実測 mean アンカーを `sm-evaluate` に正規実装して解消(`evaluation/oetf.py` + `EvaluationConfig.oetf`、`configs/eval_baseline.yaml` で有効化).以降の比較は OETF 後ドメインで行う.※ 旧版でここを「主因は ISP レンズシェーディング」としていたのは OETF を抜かした誤り. 6. **【残差の主因候補・未対処】ISP レンズシェーディング補正**: 実測 PNG は実機 ISP の周辺減光電子補正込み、シミュは未補正.OETF 適用後も残る 1.6〜1.9 倍差の主因候補.LSC 順方向モデル(物理動機づけ低自由度形、overfitting 回避)で対処予定. 7. **【残差・不確実・未対処】スマホ独自トーンマップ**: ISP は sRGB OETF に加え独自トーンマッピング/コントラストを掛けうる.真のトーンカーブ未知.残差の一部.調整で消さず正直に残す方針. -8. **【未対処】max_depth=8 が勾配を過大評価**: depth=8→12 で勾配 linear 24.65→22.08(−2.6pt)/ sRGB 12.57→11.16(−1.4pt).確定 YAML は現状 8 のまま(レンダコスト優先で変更保留).キャリブ用途は depth≥12 を使うこと. +8. ~~**【未対処】max_depth=8 が勾配を過大評価**~~(**解消済み 2026-06-03**): Step 8-14 候補 α 前半で d=8→16 へ更新.5 値スイープ {8,12,16,20,28} で d=16 が実用収束点(d=16→28 の追加改善は CoV 1.3% で MC ノイズ同等)と判明.d=8 時代の勾配 24.65% → d=16 で 20.59% に改善.参考: [TEST_01 § Step 8-14 候補 α 前半](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-前半-a9-max_depth-スイープ-8-12-16-20-28). 9. ~~**評価 ROI の不一致**~~(**解消済み**): ROI を実機と物理対応させ(`configs/eval_baseline.yaml` = pixel (24,37,464,450))、`sm-evaluate` は実測 PNG に対し MiniTias-Evaluation と小数点 14 桁レベルで一致を確認済み.評価ロジック起因の差はない. ## 参照(References) diff --git "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" index cd15419..38d3b80 100644 --- "a/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_04_\350\251\225\344\276\241\344\273\225\346\247\230.md" @@ -246,6 +246,47 @@ `metrics.csv` に `radial_min_max_ratio` / `radial_min_distance` / `radial_min` / `radial_max` の 4 列を末尾に追加.既定 False(既存挙動互換).`metrics.radial_profile` の有無に関わらず内部で動径プロファイルを算出するため独立して有効化可能.実装: `evaluation/radial.py:calc_radial_min_max_ratio`(MiniTias-Evaluation の `src/analysis/spatial.py` からマージ). +### 形状指標 (Shape Metrics — オプション、2026-06-03 導入) + +**動機**: 既存の 4 指標 (CoV/勾配/C/P/動径 min/max) は **不均一性の絶対量しか測れず、「形」を構造的に区別できない**.シミュ「Y 軸単調勾配」と実機「中央ピーク楕円」のように形が違う 2 つの画像でも、数値だけは似通うことがある.詳細は [TECH_11 形状指標の見直しと新指標導入](../05_TECH/TECH_11_形状指標の見直しと新指標導入.md). + +`metrics.shape_metrics: true` で有効化.`metrics.csv` に末尾 12 列を追加.既定 False(既存挙動互換).実装: `evaluation/shape_metrics.py`. + +#### 5 つの形状指標 + +| 指標 | 定義 | 値域 | 何を測るか | +| --- | --- | --- | --- | +| **輝度重心 (Intensity Centroid)** | 輝度を重みとする画像内重心 (x, y) | 画像座標 + 正規化 [-1, +1] | 評価板内の **重心位置の偏り**(Y 単調勾配 → 下方偏り) | +| **角度/半径分解 (Angular/Radial Decomposition)** | 極座標で I(r, θ) に分解し角度方向 var と半径方向 var を計算 | angular_ratio: 0〜1 | **0=完全同心円 / 1=完全角度依存**.中央ピーク楕円は小,Y 単調勾配は大 | +| **SSIM (Structural Similarity Index)** | 参照画像との構造的類似度 (skimage) | 0〜1 (1=完全一致) | **シミュ vs 実機の直接比較**.`shape_reference` が必要 | +| **NCC (Normalized Cross-Correlation)** | mean 中心化した画像同士の相関 | -1〜+1 (1=完全相関) | **絶対輝度スケール不変な形の一致度** | +| **PCA 主軸 (Intensity PCA)** | 輝度を重みとする weighted PCA で主軸方向と楕円率 | 角度 [-90°, +90°] / 楕円率 [0, 1] | 不均一性の **主軸方向と楕円率** | + +#### CSV 列名(末尾 12 列) + +| カテゴリ | 列名 | +| --- | --- | +| 重心 | `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 | `pca_primary_axis_angle_deg` / `pca_eccentricity` | +| 構造的類似度 | `ssim` / `ncc`(`shape_reference` 未指定なら NaN) | + +#### 設定例 + +```yaml +metrics: + shape_metrics: true + shape_reference: ../reference_measurement/quantitative/expected_results/MiniTIAS_QM_20260601_124500_luminance_map.npy # 任意 +``` + +`shape_reference` 相対パスは config YAML のディレクトリ基準で解決.未指定(null)なら SSIM/NCC は NaN になり他の指標は計算される. + +#### 注意 + +- **完全均一画像で `angular_ratio` は NaN**(variance ゼロ割り).呼び出し側で扱うこと +- **合成純粋 Y 勾配は `angular_ratio = 1.0`**(角度方向 var 支配).実シミュ画像(ノイズや非一様性込み)の典型値は 0.3〜0.5 +- **shape_reference の shape mismatch** は SSIM/NCC で NaN を返しエラーは出さない(警告ログのみ) + ## 可視化規約 (Figure Conventions) `sm-evaluate` は `figures/` に以下 4 種を生成.MiniTias-Evaluation 互換スタイル. diff --git a/docs/05_TECH/README.md b/docs/05_TECH/README.md new file mode 100644 index 0000000..c279bf0 --- /dev/null +++ b/docs/05_TECH/README.md @@ -0,0 +1,89 @@ +# 05_TECH — エンジニア向けの技術設計 (Technical Design Docs) + +このディレクトリには **エンジニア向けの技術設計ドキュメント** が集まっている.3 種類のドキュメントが混在するので,**最初にこの README で位置付けを確認**するのが効率的. + +## 種類分類 + +| アイコン | 種類 | 性質 | 読み方 | +| --- | --- | --- | --- | +| 🟢 | **現状方針(結論集約)** | 永続的,変更時のみ更新 | **「今どうなっているか」を知りたい人はここから** | +| 🔵 | **議論の経緯(過程記録)** | 履歴的,原則更新しない | **「なぜそうなったか」を辿るためのドキュメント** | + +## 🟢 まず読むべき — 現状方針(3 ファイル) + +SmTIAS-LightSim プロジェクトの **現状の設計判断・取り扱い方針** をここに集約している.「今どうなっているか」を理解したい場合はこの 3 つから読む. + +| 順序 | ファイル | 内容 | +| --- | --- | --- | +| 1 | [TECH_07_LSC取り扱い方針.md](TECH_07_LSC取り扱い方針.md) | 3 プロジェクト (MiniTias / MiniTiasEval / SmTIAS-LightSim) での LSC 役割分担.シミュ側で LSC を扱わない物理的根拠.却下した代替案 3 つの理由 | +| 2 | [TECH_08_実機変更とシミュ側対応.md](TECH_08_実機変更とシミュ側対応.md) | 実機側 8 項目変更に対するシミュ側 3 層 (シーン定義 / sm-render / sm-evaluate) の対応マッピング.タイプ I (本質構造同一) / タイプ II (許容している構造的差異) の分類 | +| 3 | [TECH_09_LEDデータシート調査結果.md](TECH_09_LEDデータシート調査結果.md) | OEM 元 HONGLITRONIC データシート発見と判明事項.配光は Lambertian と数値一致.Step 8-10/8-11 試行の事後評価.SPEC_03 訂正事項 | +| 4 | [TECH_10_残差主因候補の網羅整理.md](TECH_10_残差主因候補の網羅整理.md) | Step 8-13 で確定した残差 2.63× の主因候補を 5 カテゴリ 18 項目で網羅整理.**A1/A9/D1 の検証結果を反映**.形改善の最有力は B1 幾何精度に集約.数値改善は A8 反射率 R=0.89 | +| 5 | [TECH_11_形状指標の見直しと新指標導入.md](TECH_11_形状指標の見直しと新指標導入.md) | 現状 4 指標 (CoV/勾配/C/P/動径 min/max) は「形」を構造的に区別できない問題を整理し,新 5 指標 (輝度重心/角度半径分解/SSIM/NCC/PCA) の導入を仕様化.過去判断への遡及的注記.「数値の偽物の一致」を防ぐ評価方針 | + +### 主な結論(読まずに済むサマリ) + +| 観点 | 結論 | +| --- | --- | +| LSC 補正 | 実機側 (MiniTiasEval) で順方向適用.**シミュ側は扱わない** (orthographic で構造的に等価) | +| ガンマ補正 | sm-evaluate で **OETF off** (実機 DNG が linear) | +| 絶対値スケール調整 | mean アンカー正規化で target_mean=207.82 に揃え | +| LED 配光モデル | **area light Lambertian (cos θ)** — データシート実測と数値一致 | +| 残差 2.63× の主因候補(優先順) | **形改善: 🥇 B1 筐体幾何精度 / 数値改善: 🥇 A8 反射率 R=0.89**.A1/A9/D1 はすべて検証完了でクリア (詳細は [TECH_10](TECH_10_残差主因候補の網羅整理.md)) | +| 評価指標の方針 | **数値 + 形 + 視覚** の 3 軸.数値 4 指標 (CoV/勾配/C/P/動径 min/max) は形を構造的に区別できないため形 5 指標 (重心/角度半径分解/SSIM/NCC/PCA) を併用 (詳細は [TECH_11](TECH_11_形状指標の見直しと新指標導入.md)) | + +## 🔵 議論の経緯 — プロジェクト間ハンドオフ往復 (6 ファイル) + +MiniTias 撮影アプリ + MiniTiasEval 解析ツールへの「要求 → 報告 → 回答 → 受領」のハンドオフ記録.**読まなくても現状方針 (TECH_07〜09) は理解できる**が,「なぜそういう判断になったか」を辿るときに参照する. + +### 経緯マップ + +```text +[MiniTias 撮影アプリへの要求と回答] + TECH_01 (要求書) ────→ MiniTias 実装 + │ ↓ + TECH_02 (引き継ぎ手順) TECH_03 (実装報告 + SHADING_MODE 相談) + │ + ↓ + TECH_04 (回答書 + SHADING_MODE 判断) + │ + ↓ + ★ 結論集約 → TECH_07 LSC 取り扱い方針 + +[MiniTiasEval DNG 対応への要求と受領] + TECH_05 (要求書) ────→ MiniTiasEval 実装 + │ + ↓ + TECH_06 (引き継ぎ受領記録) + │ + ↓ + ★ 関連結論 → TECH_07 LSC 取り扱い方針 + TECH_08 対応マッピング +``` + +### 各ファイルの役割 + +| ファイル | 役割 | +| --- | --- | +| [TECH_01_定量撮影モード要求仕様.md](TECH_01_定量撮影モード要求仕様.md) | SmTIAS-LightSim → MiniTias 撮影アプリ への要求書.Camera2 API 設定変更・DNG 化等 | +| [TECH_02_MiniTIAS引き継ぎ手順.md](TECH_02_MiniTIAS引き継ぎ手順.md) | TECH_01 を MiniTias 側 Claude セッションで作業着手する際の作業ガイド | +| [TECH_03_定量モード実装報告とSHADING_MODE相談.md](TECH_03_定量モード実装報告とSHADING_MODE相談.md) | MiniTias 側からの実装報告.SHADING_MODE の扱いを SmTIAS-LightSim 側に相談 | +| [TECH_04_MiniTIASへの回答.md](TECH_04_MiniTIASへの回答.md) | TECH_03 への回答.SHADING_MODE = HIGH_QUALITY 維持を判断.フェーズ A/B 分割方針 | +| [TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md](TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) | SmTIAS-LightSim → MiniTiasEval 解析ツール への要求書.`apply_lsc_*` 関連は訂正済 | +| [TECH_06_MiniTias-Evaluation引き継ぎ受領.md](TECH_06_MiniTias-Evaluation引き継ぎ受領.md) | MiniTiasEval 側からの実装受領記録.重要な設計判断 (LSC は順方向適用) を集約 | + +## 関連ディレクトリ + +| ディレクトリ | 役割 | +| --- | --- | +| [04_SPEC/](../04_SPEC/) | ユーザー視点の仕様書.特に `SPEC_03 基準設計の物理構成` は TECH_09 で訂正対象になっている | +| [06_TEST/](../06_TEST/) | 試行履歴・実行記録.特に [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) は Step 8 系の主軸記録 | + +## 新規ドキュメント追加のガイド + +新しい TECH ドキュメントを追加するときは: + +1. **どのカテゴリか判断**: 🟢 結論集約 / 🔵 過程記録 +2. **連番を継続**: TECH_10, TECH_11, ... +3. **本 README に追記**: 該当カテゴリのリストに 1 行追加 +4. **GUIDE_03 ファイル命名規則** に従う ([../01_GUIDE/GUIDE_03_ファイル命名規則.md](../01_GUIDE/GUIDE_03_ファイル命名規則.md)) diff --git "a/docs/05_TECH/TECH_09_LED\343\203\207\343\203\274\343\202\277\343\202\267\343\203\274\343\203\210\350\252\277\346\237\273\347\265\220\346\236\234.md" "b/docs/05_TECH/TECH_09_LED\343\203\207\343\203\274\343\202\277\343\202\267\343\203\274\343\203\210\350\252\277\346\237\273\347\265\220\346\236\234.md" new file mode 100644 index 0000000..6287410 --- /dev/null +++ "b/docs/05_TECH/TECH_09_LED\343\203\207\343\203\274\343\202\277\343\202\267\343\203\274\343\203\210\350\252\277\346\237\273\347\265\220\346\236\234.md" @@ -0,0 +1,323 @@ +# LED データシート調査結果 (LED Datasheet Investigation) + +本ドキュメントは SmTIAS 実機搭載 LED「LED Paradise LP-2835D90VW」の OEM 元およびデータシート存在状況の調査結果を記録する.過去 [SPEC_03 §LED 物理仕様](../04_SPEC/SPEC_03_基準設計の物理構成.md#led光源) で「データシート PDF 掲載なし」「詳細 IES プロファイル未提供」と記述していた点を再調査した結果,**実は OEM 元のデータシートが公開されていた**ことが判明した.本調査により Step 8-14 候補 B(LED 配光詳細化)の方向性が変わった. + +## 結論(先に) (Conclusion First) + +| 観点 | 結論 | +| --- | --- | +| **OEM 元の特定** | **HONGLITRONIC**(Hongli Zhihui Group,中国広州) | +| **データシートは存在するか** | **✓ Yes**(過去「未提供」とした記述は誤り) | +| **入手したデータシート** | `HL-A-2835D90VW-2-LVR7-S1-08-PCT-HR6`(SPEC NO B-18-A-0831, 2022-01 改訂)— LP-2835D90VW と同シリーズで物性整合 | +| **配光プロット** | データシートに **明示掲載**.読み取り結果は **ほぼ厳密 Lambertian (cos θ)** | +| **分光分布 (SPD)** | データシートに **明示掲載**.4000K NeutralWhite の特性曲線あり | +| **過去 SPEC_03 「やや前方凸の可能性」記述** | **データシートでは確認できず**.Lambertian と一致 | +| **過去 Step 8-11「データシート従順版」(cos^1.2)** | **実はデータシート不整合**だった(同等品仮定の cos^1.2 を使用していた) | +| **Step 8-14 候補 B(LED 配光詳細化)の意義** | **△ 限定的** — 現状の area Lambertian モデルが既にデータシート実測と整合.残差 2.63× の主因は配光以外 | + +## 調査の経緯 (Investigation Process) + +### 過去の状況(誤認の出発点) + +[SPEC_03 §LED 物理仕様](../04_SPEC/SPEC_03_基準設計の物理構成.md#led光源) で以下のように記述されていた: + +> 「詳細配光プロファイル(IES)| **未提供** | LED Paradise 商品ページにデータシート PDF 掲載なし」 +> 「LED Paradise は詳細 IES プロファイルを公開していないため厳密検証は不可」 + +これは **LED Paradise(日本の小売店)の商品ページのみを参照**した結果であり,**OEM 元(製造メーカー)のデータシート存在を調査していなかった**. + +### 再調査の動機 + +ユーザー指摘 (2026-06-02):「型番でデータシートが出てこないか,調べ方が悪いのか,それともこれが一般的じゃないのか?」 + +→ ネット検索(WebSearch)で型番「LP-2835D90VW」「2835D90VW」を直接検索し直した. + +### 発見の経緯 + +| 検索クエリ | 発見 | +| --- | --- | +| `LP-2835D90VW LED datasheet` | LCSC Electronics に HONGLITRONIC `A-2835D90VW` シリーズのデータシート PDF が複数存在.型番の `2835D90VW` 部分が完全一致 | +| `SunLightLED 2835 4000K Ra98 datasheet manufacturer` | LED Paradise 商品ページ以外に多数の Ra98 SMD 2835 製品が見つかる.SunLightLED はブランド名で実体メーカーは別 | +| `HONGLITRONIC 2835 PCT high CRI 4000K Ra98 sunlight series datasheet pdf` | HONGLITRONIC の Sunlight Spectrum LEDs として LED コミュニティでも議論されている | + +→ **LED Paradise の LP-2835D90VW は HONGLITRONIC の OEM 再販品**と特定. + +## OEM 関係の特定 (OEM Relationship) + +| 役割 | 企業 | +| --- | --- | +| **小売店** | **LED Paradise(エルパラ)** — 日本の LED 専門オンラインショップ | +| **ブランド** | **SunLightLED** — Ra98 太陽光近似スペクトル LED の販売ブランド | +| **OEM 元(実体製造元)** | **HONGLITRONIC(Hongli Zhihui Group,鸿利智汇集团股份有限公司)** | +| - 所在地 | 中国広州市花都区花東鎮先科一路 1 号 | +| - 公式 Web | [www.honglitronic.com](https://www.honglitronic.com) / [en.honglitronic.com](https://en.honglitronic.com) | +| - 規模 | 中堅 LED メーカー(Tier 2).SMD 2835 シリーズで複数サブシリーズを展開 | + +### 型番マッピング + +| LED Paradise の型番 | HONGLITRONIC の型番 | 整合性 | +| --- | --- | --- | +| **LP-2835D90VW** | **HL-A-2835D90VW-2-LVR7-S1-08-PCT-HR6** | **★ 本命**.`2835D90VW` 部分完全一致.Vf 3.1V / 配光角 120° / SMD 2835 で物性整合.4000K Ra95 版あり | +| LP-2835D90VW (Ra98) | (Ra95 版が存在.Ra98 は別バリアントの可能性) | △ Ra95 → Ra98 の小差はサブバリアント or 選別品の可能性 | + +> 注: LED Paradise の `LP-` プレフィックスは LED Paradise の独自命名.HONGLITRONIC の `HL-A-` は HONGLITRONIC の正式名.本体型番は **`2835D90VW`** の部分. + +## 入手したデータシート (Obtained Datasheets) + +### 1 件目(別品.参考用に取得後にミスマッチ判明) + +| 項目 | 値 | +| --- | --- | +| 型番 | `HL-AS-2835D3W-3C-S1-08-PCT-HR3(R9)` | +| URL | https://www.lcsc.com/datasheet/C210331.pdf | +| 仕様書番号 | SPEC NO B-16-A-0045, REV A/5, 2019-01-21 | +| ページ数 | 13 | +| **Vf** | **8.4 - 10.4 V**(9 チップ直列モジュール) | +| **If** | 100 mA | +| **Pd** | 1.248 W | +| **Φv (4000K)** | 120 - 130 lm @100mA | +| **Ra** | **80**(Ra98 ではない) | +| 配光角 | 120° | +| SmTIAS 整合性 | **❌ 別品**(電流・電圧スケールが違う,Ra80 で SmTIAS の Ra98 と不整合) | + +→ これは **9 チップ直列の高電力モジュール**で,SmTIAS の単チップ 2835 とは別系列.配光図と SPD は含まれるが SmTIAS に直接適用は不可. + +### 2 件目(本命.SmTIAS と物性整合) + +| 項目 | 値 | +| --- | --- | +| 型番 | **`HL-A-2835D90VW-2-LVR7-S1-08-PCT-HR6`** | +| URL | https://www.lcsc.com/datasheet/C5362368.pdf | +| 仕様書番号 | **SPEC NO B-18-A-0831, REV A/2, 2022-01-22** | +| ページ数 | 13 | +| **Vf** | **2.8 - 3.4 V**(typ 3.1V) | +| **If** (test) | 150 mA | +| **Pd** | 612 mW | +| **Φv (4000K)** | 40 - 45 lm @150mA | +| **Ra** | **95** | +| 配光角 | 120° | +| チップ材料 | InGaN(UV chip + phosphor 励起) | +| パッケージ | PLCC-2(2835) | +| CCT バリエーション | 2700K / 3000K / 4000K / 5000K / 6500K の 5 種 | +| SmTIAS 整合性 | **✓ 本命**.Vf 3.1V 完全一致.配光角 120° 一致.SMD 2835 一致.Ra95 ≒ Ra98(バリアント差) | + +→ **LP-2835D90VW の OEM 元データシートの位置付け**.Ra95 vs Ra98 の小差は選別バリアント(よりタイトな Ra ビンを選別して Ra98 として再販)の可能性が高い. + +### データシートの章構成(B-18-A-0831) + +| Page | 内容 | +| --- | --- | +| 1 | 表紙・承認欄・連絡先(住所・Tel・Fax・Web) | +| 2 | パッケージ寸法(PLCC-2)・特徴・用途(一般照明) | +| 3 | **Selection Guide**(CCT バリエーション表)・電気/光学特性(Vf / 配光角 / Ra) | +| 4 | 絶対最大定格(Pd / IF / Tj 等) | +| 5 | 典型光学特性曲線(Vf-IF / IF-Intensity / Temperature-Intensity / Allowable Current) | +| **6** | **★ Radiation diagram(配光図)+ Relative spectral emission(分光分布 SPD)** | +| 7 | 色座標範囲(5 CCT 別 ellipse 6-step) | +| 8 | 信頼性試験項目 | +| 9-11 | ハンドリング注意 | +| 12 | 包装・テープ・リール仕様 | +| 13 | 改訂履歴(2018-07 初版 → 2022-01 A/2) | + +### 取得した PDF ページ画像 + +リポジトリトップに以下を一時保存(**REFERENCE/ への正式取り込みは別タスク**): + +- `5c736b84753c47e37d05b56fe01cfcd2.pdf` — 1 件目(C210331.pdf, 別品) +- `a3b8d448019c16bfe5190dcfe7d796c3.pdf` — **2 件目(C5362368.pdf, 本命)** +- `_ds_page_01.png` 〜 `_ds_page_13.png` — 2 件目を 144 DPI で PNG 化(13 ページ) + +## データシートから判明した重要情報 (Key Information) + +### 電気・光学特性(4000K 版) + +| 項目 | 値 | 出典 | +| --- | --- | --- | +| Forward Voltage Vf | 2.8 - 3.4 V(typ 3.1V) | Page 3 | +| Forward Current(test) | 150 mA | Page 3 | +| Viewing Angle 2θ₁/₂ | 120° | Page 3 | +| Color Rendering Index Ra | ≥ 95 | Page 3 | +| **Φv (4000K, @150mA)** | **40 - 45 lm** | Page 3 | +| Power Dissipation Pd | 612 mW | Page 4 | +| Max Forward Current | 180 mA | Page 4 | +| Junction Temperature Tj max | 125 °C | Page 4 | +| Storage Temperature | -40 ~ +100 °C | Page 4 | + +> SmTIAS は **If = 60mA で運用**(Vf×If = 3.1V × 60mA = 186 mW).データシートは If = 150mA test 条件なので,60mA での全光束はデータシートの IF-Intensity 曲線(Page 5)から外挿可能.大体 Φ(60mA) ≈ Φ(150mA) × 0.4 ≈ 16-18 lm で,**LED Paradise の公称 16-19 lm と整合**. + +### CCT バリエーション全体 + +| CCT (nominal) | CCT Min/Typ/Max [K] | Φv Min/Typ [lm] | 該当 LED Paradise バリアント | +| --- | --- | --- | --- | +| 2700K | 2600 / 2700 / 2800 | 36 / 41 | (未確認) | +| 3000K | 2800 / 3000 / 3100 | 38 / 53 | (未確認) | +| **4000K** | **3800 / 4000 / 4250** | **40 / 45** | **★ SmTIAS 該当** | +| 5000K | 4750 / 5000 / 5300 | 40 / 45 | (未確認) | +| 6500K | 6000 / 6500 / 7000 | 40 / 45 | (未確認) | + +## 配光図の読み取り結果 (Radiation Diagram Reading) + +データシート Page 6 下半分の極座標プロット(Radiation diagram)から数値を読み取り: + +| 角度 θ | データシート読み取り値 | 厳密 Lambertian cos(θ) | 差 | +| --- | --- | --- | --- | +| 0° | 1.00 | 1.000 | 0 | +| 10° | ≈ 0.98 | 0.985 | ≈ 0 | +| 20° | ≈ 0.94 | 0.940 | ≈ 0 | +| 30° | ≈ 0.87 | 0.866 | +0.004(一致範囲) | +| 45° | ≈ 0.71 | 0.707 | ≈ 0 | +| **60°** | **≈ 0.50(FWHM = ±60°)** | **0.500** | **0**(厳密一致) | +| 70° | ≈ 0.35 | 0.342 | ≈ 0 | +| 80° | ≈ 0.15 | 0.174 | -0.024 | +| 90° | ≈ 0 | 0 | 0 | + +→ **データシート配光は数学的にほぼ厳密 Lambertian (cos θ)**.配光角 120° の半値全幅とも整合. + +### 過去 SPEC_03 記述の検証 + +[SPEC_03 §モデル化(光の広がり方)](../04_SPEC/SPEC_03_基準設計の物理構成.md#モデル化光の広がり方) には: + +> 「実 SMD 2835 はパッケージ内の蛍光体カップ・封止樹脂ドームで配光が純粋な cos^n 形にならないことが多く,**典型的には Lambertian よりやや前方凸**」 + +と記述されていたが,**HONGLITRONIC のデータシート配光図ではこの『前方凸』は確認できない**.データシート実測上は **Lambertian と数学的にほぼ一致**. + +→ SPEC_03 の「やや前方凸の可能性」記述は **同等品の一般論であり,HONGLITRONIC の HL-A-2835D90VW シリーズには当てはまらない**.訂正が必要. + +## 分光分布 SPD の特徴 (Spectral Power Distribution) + +データシート Page 6 上半分の Relative spectral emission グラフ: + +| CCT | 色名(データシート表記) | +| --- | --- | +| 6500K 系 | CoolWhite | +| **4000K** | **NeutralWhite ← SmTIAS 該当** | +| 3000K 系 | WarmWhite | + +### NeutralWhite (4000K) SPD の主要特徴 + +- **420nm 付近に鋭いピーク**(青色 LED チップ励起) +- **500-650nm に広いブロード**(黄色蛍光体の発光) +- 横軸 380-780 nm(可視光全域カバー) +- 縦軸 Relative Luminous Power 0〜1.2 +- Ra95 で太陽光近似スペクトル(青チップ + 蛍光体ブロードの典型構造) + +### SmTIAS-LightSim への含意(SPD) + +- SmTIAS の現状評価は **無彩色(白板)撮影** なので luma 値に SPD 詳細は **直接影響しない** +- ただし将来 **spectral variant(Mitsuba 分光モード)** に進む場合は,本 SPD データを Mitsuba の `spectrum` 形式に変換して入力する必要あり +- データシートの SPD 曲線を 380-780nm で数値化すれば,Ra98 のスペクトル特性をシミュに反映可能(将来課題) + +## SmTIAS-LightSim への含意 (Implications) + +### 1. 現状 area Lambertian モデルの妥当性確認 + +| 観点 | 結論 | +| --- | --- | +| 現状 [`configs/scene_baseline.yaml`](../../configs/scene_baseline.yaml) の area light Lambertian モデル | **データシート実測配光と数値的に整合**(一致度高い) | +| データシートの配光(cos θ)と完全一致 | **✓ Yes**(Page 6 配光図より) | + +→ 現状の area light Lambertian モデルは **物理的に正解**.配光モデルとしてはこれ以上の精緻化余地は少ない. + +### 2. 過去 Step 8-10 / 8-11 試行の事後評価 + +| Step | 試行内容 | データシートと比較 | +| --- | --- | --- | +| 本流(area Lambertian) | 厳密 cos θ | **✓ データシート実測と一致** | +| Step 8-10 spot (cutoff=90, beam=30) | smoothstep 配光(FWHM 120°) | △ 「FWHM 120°」は一致するが smoothstep 関数形は cos θ と微妙に異なる(中央集中) | +| Step 8-11 spot (cutoff=85, beam=26.72, cos^1.2) | cos^1.2 前方凸(同等品仮定) | **❌ データシートと不整合**.本データシート配光は Lambertian (cos θ) で,cos^1.2 ではない | + +→ **過去 Step 8-11 を「データシート従順版」と呼んでいたのは誤称**.実態は「**同等品 (Cree/Samsung/Lumileds 系 SMD 2835) の典型配光 cos^1.2 を借用**」しており,本物のデータシート(Lambertian)とは異なる.道理で Step 8-11 で形状指標が悪化(CoV +1%, 勾配 +11%, C/P +4%)したわけである. + +### 3. Step 8-14 候補 B(LED 配光詳細化)の方向修正 + +過去議論(TEST_01 / TECH_07)で「LED 配光詳細化が残差解消の候補」とされていたが,本調査結果を踏まえると: + +| 観点 | 修正後の判断 | +| --- | --- | +| 配光モデルの精緻化余地 | **限定的**(既にデータシート実測と一致) | +| Step 8-14 で LED 配光を変更する意義 | **△ 低い**(変えてもデータシートから乖離する方向) | +| 残差 2.63× の主因 | **LED 配光以外**(反射率精度・LED 物性以外・幾何精度・タイプ II 構造的差異等) | + +→ **Step 8-14 では LED 配光以外**(反射率精度・幾何精度・タイプ II 構造的差異の独立評価等)に注力すべき. + +### 4. 残差 2.63× の主因候補の絞り込み + +本調査で **配光由来の可能性が排除**されたため,残差 2.63× の主因候補は以下に絞られる: + +| 候補 | 評価 | +| --- | --- | +| 反射率(現 R=0.85)の精度 | △ 過去 Step 8-2 でスイープ済(弱い二次レバー) | +| 反射率の実物理整合点(R≈0.89)への変更 | △ Step 8-11 で試行済だが形状指標は改善せず | +| メッシュ幾何精度 | △ 過去 Step 8-9 で Fusion STL 抽出済(mm 級精度) | +| **LED 配光(cos^n)の誤差** | **❌ データシートでほぼ Lambertian と確認.排除** | +| LED 位置の数 mm 誤差 | △ Step 8-9 でほぼ確定 | +| **タイプ II 構造的差異**(AF 焦点モデル / ピクセル積分 / 絞り口径食 等) | △ 白板撮影では影響小と推定だが排除不能([TECH_08 §許容している構造的差異](TECH_08_実機変更とシミュ側対応.md)) | +| **未モデル化要素** | ★ 内壁の非 Lambertian 性(光沢成分)・LED フィラメント詳細・パッケージ周辺の光学効果 | + +→ 残された候補は **内壁の非 Lambertian 性**(現状 Mitsuba `diffuse` BSDF は完全 Lambertian だが,実 3D プリンタ白フィラメントは微妙に光沢成分を持つ可能性)や **LED パッケージ周辺光学効果**等. + +## SPEC_03 への訂正事項 (SPEC_03 Corrections Required) + +[SPEC_03 §LED 物理仕様](../04_SPEC/SPEC_03_基準設計の物理構成.md#led光源) で以下の記述を訂正する必要がある(本ドキュメントは調査記録.SPEC_03 訂正は別タスクで実施予定): + +| 現 SPEC_03 記述 | 訂正後 | +| --- | --- | +| 「詳細配光プロファイル(IES)\| **未提供** \| LED Paradise 商品ページにデータシート PDF 掲載なし」 | 「**OEM 元 HONGLITRONIC のデータシート B-18-A-0831 に配光図あり.配光は cos θ Lambertian と数値一致**」 | +| 「分光分布(SPD)\| グラフ未提供(4000K Ra98 と説明のみ)」 | 「**OEM 元データシート B-18-A-0831 Page 6 に SPD 曲線あり(NeutralWhite 4000K, 380-780nm)**」 | +| 「LED Paradise は詳細 IES プロファイルを公開していないため厳密検証は不可」 | 「**OEM 元 HONGLITRONIC のデータシートで配光プロット (Page 6) が公開されており,実機 LED の配光は Lambertian と確認済**」 | +| 「実 SMD 2835 は ... 典型的には **Lambertian よりやや前方凸**」 | 「**データシート(HL-A-2835D90VW シリーズ)からは Lambertian と一致**.前方凸の証拠なし.(同等品の一般論を SmTIAS の LED に当てはめていたのは誤り)」 | +| LED データシート参照: 「`REFERENCE/smd2835_datasheets.png`」のみ | **OEM 元データシートの URL を追加**: https://www.lcsc.com/datasheet/C5362368.pdf | + +## 関連リンク (Related Links) + +### HONGLITRONIC 公式 + +- [HONGLITRONIC 公式(英語)](https://en.honglitronic.com/) +- [HONGLITRONIC SMD 2835 シリーズ](https://en.honglitronic.com/en/col121/612) +- [HONGLITRONIC(中国語)](https://www.honglitronic.com/) + +### データシート(LCSC 経由) + +- [HL-A-2835D90VW-2-LVR7-S1-08-PCT-HR6 6500K(本命系列)PDF](https://www.lcsc.com/datasheet/C5362368.pdf) +- [LCSC 商品ページ(6500K)](https://www.lcsc.com/product-detail/led-indication-discrete_honglitronic-hongli-zhihui-honglitronic-a-2835d90vw-2-lvr7-s1-08-pct-hr6-3000k_C5362369.html) +- [HL-AS-2835D3W-3C-S1-08-PCT-HR3(R9) 4000K(別品・参考)](https://www.lcsc.com/datasheet/C210331.pdf) + +### LED Paradise(小売) + +- [LED Paradise LP-2835D90VW 商品ページ](https://www.led-paradise.com/product/2608) + +### LED コミュニティ + +- [BudgetLightForum: Honglitronic Sunlight Spectrum LEDs](https://budgetlightforum.com/t/honglitronic-sunlight-spectrum-leds/57912) +- [LED Professional: HONGLITRONIC 2835-PCT High CRI](https://www.led-professional.com/products/leds_led_modules/honglitronic-2835-pct-superior-performance-competitive-price) + +## 関連ドキュメント (Related Documents) + +- [SPEC_03 基準設計の物理構成 §LED 光源](../04_SPEC/SPEC_03_基準設計の物理構成.md#led光源) — 訂正対象.本ドキュメントの判明事項を反映する必要あり +- [TEST_01 Step 8-10](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-10-led-配光を-spot-emitter-で表現x-方向の部分改善) — spot 試行.本データシートに照らして再評価可能 +- [TEST_01 Step 8-11](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-11-データシート準拠の照明モデル統合spot-cos12--r089) — 「データシート従順版」と呼んでいたが実は同等品仮定だったと判明 +- [TECH_07 LSC 取り扱い方針](TECH_07_LSC取り扱い方針.md) — シミュ側で LSC を扱わない判断 +- [TECH_08 実機変更とシミュ側対応](TECH_08_実機変更とシミュ側対応.md) — タイプ I/II 分類.本調査は配光がタイプ I(本質構造同一)に分類されることを補強 + +## 次のアクション (Next Actions) + +### 短期 + +1. **SPEC_03 §LED 物理仕様の訂正**(別タスク/本ドキュメント本文の§SPEC_03 への訂正事項 参照) +2. **取得 PDF と PNG の REFERENCE/ への正式取り込み**(`a3b8d448*.pdf` → `REFERENCE/HL-A-2835D90VW_B-18-A-0831.pdf` 等にリネーム + 整理) +3. **TEST_01 Step 8-11 の脚注追加**(「データシート従順版」と呼んでいたが実は同等品仮定だった旨) + +### 中期 + +4. **Step 8-14 候補 B(LED 配光詳細化)の取り下げ or 方向転換** + - 配光モデルは既に妥当と確定.Step 8-14 は反射率精度・幾何精度・タイプ II 寄与評価等に注力 +5. **残差 2.63× の主因候補の絞り込み**(本調査により LED 配光由来は排除) + - 反射率の精密化(実物理整合点 R≈0.89 で再評価) + - 内壁の非 Lambertian 性のモデル化(光沢成分追加) + +### 中長期 + +6. **Spectral variant への移行検討** + - 現状 RGB variant.Mitsuba spectral mode に移行すれば本データシートの SPD を直接入力可能 + - Ra98 太陽光近似スペクトルの精密シミュレーションが可能になる + - 白板撮影では luma 値に直接影響しないが,将来の舌画像評価では色再現性に効く 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" new file mode 100644 index 0000000..e164ef3 --- /dev/null +++ "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" @@ -0,0 +1,502 @@ +# 残差 2.63× の主因候補の網羅整理 (Residual Cause Candidates Enumeration) + +本ドキュメントは [TEST_01 Step 8-13](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で確定した **シミュ vs 実機の残差 2.63×** の主因候補を網羅的に整理したものである.Step 8-14 以降の物理モデル精緻化で「何を試すか」の判断材料として作成した. + +## 結論(先に) (Conclusion First) + +| 順位 | 候補 | 影響度 | 検証コスト | 推奨理由 | +| --- | --- | --- | --- | --- | +| ~~🥇 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 位(形改善・初実証)** | **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 三次更新**: **配光モデル (A2/A3/ξ) が形改善の本物の候補として初実証**.cutoff スイープで area→spot のステップで angular_ratio が 0.45→0.34 へ 25% 低下(実機方向に最大改善).**「形」を構造的に変える候補が確定**.ただし数値は若干悪化(A8 と真逆パターン)→ 形 + 数値の両立は次の課題.B1 幾何精度は依然有力候補(未試行). + +## 背景 (Background) + +Step 8-13 で **真の linear ドメイン比較** (DNG+LSC 順方向適用 vs シミュ orthographic) を実施した結果,残差 2.63× が確定した([TEST_01 Step 8-13](../06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-13-新方針-実機-dng--lsc-linear-でのシミュ突合) 参照).これは TECH_06 で予想した「真の残差 2.66×」とほぼ一致する値. + +主因の絞り込み状況: + +| 候補 | 絞り込み状況 | 出典 | +| --- | --- | --- | +| LSC 補正残差 | **排除** | [TECH_07](TECH_07_LSC取り扱い方針.md)(実機側で順方向適用が確立) | +| シミュ側のドメイン不一致(ガンマ等) | **排除** | [TECH_08](TECH_08_実機変更とシミュ側対応.md)(網羅チェック済み) | +| LED 配光のモデル誤差 | **ほぼ排除** | [TECH_09](TECH_09_LEDデータシート調査結果.md)(データシート Lambertian と数値一致) | +| **残るのは「シミュ物理モデル誤差」** | **絞り込み対象**(本ドキュメント) | — | + +## 候補の網羅整理(5 カテゴリ 18 項目) (Comprehensive Candidate List) + +### A. 物理光学モデル系(最有力候補を含む) + +| ID | 候補 | 現状 | 影響度 | 検証状況 | +| --- | --- | --- | --- | --- | +| ~~A1~~ | ~~内壁の非 Lambertian 性(光沢成分)~~ | `diffuse` BSDF(完全 Lambertian) | **✓ クリア** | **2026-06-02 検証完了**:blendbsdf (diffuse + roughconductor) で weight×alpha の 3×3 グリッド全 9 パターンを実走→全パターンが純 diffuse より悪化または不変.[§ A1 検証結果](#a1-検証結果2026-06-02-完了) 参照 | +| **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) | 小 | 実機の基板は平面で妥当 | +| A7 | LED 位置精度 | Fusion STL 抽出済 | 小 | [Step 8-9](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で正確化済(1% 以内の形状変化) | +| ~~A8~~ | ~~反射率 R = 0.85 → 0.89~~ | R=0.85(暫定) | **✓ 数値改善のみクリア(形は逆効果)** | **2026-06-03 検証完了**:5 値スイープ {0.75, 0.80, 0.85, 0.89, 0.92} で d=16 ベース.既存 4 指標は実機方向に単調改善(CoV 0.186→0.142, 残差 24% 縮小)するが **新指標 angular_ratio は 0.408→0.494 で実機方向と逆に増加(実機は同心円的予想)**.[§ A8 検証結果](#a8-検証結果2026-06-03-完了) 参照 | +| ~~A9~~ | ~~max_depth = 8 → 16+~~ | d=8 → 推奨 d=16 | **✓ 数値改善のみクリア** | **2026-06-03 検証完了**:5 値スイープ {8,12,16,20,28} で CoV/勾配/C/P/動径 min/max が全て単調改善.d=8→28 で残差 21-28% 縮小.**ただし「形」は Y 軸単調勾配のまま完全に不変**.均一化フィルタとして働くが空間再配分は起こさない.新キャリブベースラインとして **d=16** 推奨.[§ A9 検証結果](#a9-検証結果2026-06-03-完了) 参照 | + +### B. 幾何モデル系 + +| ID | 候補 | 現状 | 影響度 | 検証状況 | +| --- | --- | --- | --- | --- | +| **B1** | **筐体内部の幾何精度** | Fusion 360 設計 STL | **中の可能性** | 3D プリンタの積層・サポート跡・微細な凹凸はモデルに含まれない.実機 3D スキャンで定量化可能だが高コスト | +| B2 | メッシュ離散化精度 | STL → PLY 変換時の三角形数 | 小〜中 | 細かい曲面は離散化される | +| B3 | 評価板の位置・向き | Fusion 360 設計通り | 小 | 既知の精度(CAD 通り) | + +### C. センサ・カメラ系(タイプ II 構造的差異) + +[TECH_08 §許容している構造的差異](TECH_08_実機変更とシミュ側対応.md#許容している構造的差異-accepted-structural-differences) のタイプ II 項目.白板撮影では許容と判断したが完全排除不能. + +| ID | 候補 | 現状 | 影響度 | 検証状況 | +| --- | --- | --- | --- | --- | +| C1 | 絞り・口径食 | orthographic で cos⁴ なし | 小 | 実機側 lscMap で補正済(残差あり) | +| C2 | AF(焦点モデル) | orthographic 無限遠 | 小 | 白板 DoF 内固定で許容 | +| C3 | ピクセル積分 | 点サンプリング + spp | 小 | spp=1024 で緩和 | +| C4 | センサノイズ | なし | 小 | 白板高輝度で相対影響小 | + +### D. 評価方法系 + +| ID | 候補 | 現状 | 影響度 | 検証状況 | +| --- | --- | --- | --- | --- | +| ~~D1~~ | ~~シミュ ROI と実機 ROI の物理的整合~~ | シミュ (24, 37, 464, 450) / 実機 (539, 1015, 1400, 1409) | **✓ クリア** | **2026-06-02 検証完了**:物理サイズ差 < 1%,中心位置差 < 1 mm.残差主因から除外([§ D1 検証結果](#d1-検証結果2026-06-02-完了) 参照) | +| D2 | mean アンカー target_mean=207.82 の精度 | 実機 1 ショットの値 | 小 | 形状指標には影響なし | + +### E. 環境・運用系(実機側) + +| ID | 候補 | 現状 | 影響度 | 検証状況 | +| --- | --- | --- | --- | --- | +| E1 | LED 温度上昇による光出力低下 | シミュは温度概念なし | 小 | 18 個・通常室温で安定 | +| E2 | 筐体内壁の汚れ・経年劣化 | 新品仮定 | 小 | 新品評価なら無視可 | + +## 優先度評価の根拠 + +### 🥇 1 位: A1 内壁の非 Lambertian 性(光沢成分) + +**物理的根拠**: + +- 現状の Mitsuba `diffuse` BSDF は **完全 Lambertian**(cos θ で散乱)を仮定 +- 実物理: 3D プリンタ白フィラメント(PLA / ABS 等)は **マット表面に見えても微妙な specular 成分**を持つことが多い +- 鏡面反射成分があると **多重反射の経路が変わり「形」が変化** する可能性が高い + +**変化が「形」に効く機序**: + +- LED 直接光が壁に当たる → 光沢成分があれば **特定方向に強く反射** +- diffuse 100% なら均等分散だが、光沢があれば **評価板の特定領域に集中** する経路ができる +- これが Y 軸単調勾配の「上端も均等に明るくならない」現象に対応する可能性 + +**実装方法**: + +- Mitsuba の `roughconductor` か `roughdielectric` を `diffuse` と混合(`blendbsdf`) +- パラメータ: roughness(粗さ)と diffuse/specular 比 +- 物理動機: 3D プリンタ素材の実物理特性に近づける + +**期待効果**: + +- 数値: 反射成分の方向性で多重反射経路が変化 → CoV / 勾配が動く可能性 +- 形: Y 軸単調勾配が崩れて中央ピーク楕円に近づく可能性(**最も期待できる**) + +### 🥈 2 位: A8 反射率 R = 0.85 → 0.89(物理整合点) + +**物理的根拠**: + +- 現状 R=0.85 は暫定値.[Step 8-6](#) で **物理整合点 R≈0.89** が導出済(規格 LED radiance との物理的一致点) +- インテグレーティングスフィア近似 1/(1-R) で R=0.85 → 0.89 は **多重反射ゲイン 6.67 → 9.09**(1.36×) + +**変化が「形」に効く機序**: + +- 反射率上昇 → 多重反射量増加 → 評価板への ambient 成分(均一光)が増える +- Y 軸単調勾配の「上端の暗さ」がやや軽減される可能性 + +**実装方法**: + +- `configs/scene_baseline.yaml` の `printer_white.reflectance` を 0.85 → 0.89 に変更 +- spp / max_depth は据え置き + +**期待効果**: + +- 数値: 過去 [Step 8-2](../06_TEST/TEST_01_キャリブレーション検証履歴.md) で「弱い二次レバー」と判明済.CoV 数% 改善 +- 形: わずかに均一化方向だが大きな変化は期待しにくい + +### 🥉 3 位: A9 max_depth 8 → 16+ + +**物理的根拠**: + +- 現状 max_depth=8 で多重反射の **後段(5〜8 回反射目)が打ち切られる可能性** +- 過去 [Step 8-4](#) で 8→12 試行.CLAUDE.md には「キャリブは d≥12 推奨」と記載 +- 新方針(DNG+LSC linear)下では未再評価 + +**実装方法**: + +- `configs/scene_baseline.yaml` の `max_depth` を 8 → 16(または 12) +- レンダ時間は depth に概ね比例(8.5 分 × 2 ≈ 17 分) + +**期待効果**: + +- 数値: 多重反射の累積で勾配が緩和される可能性 +- 形: わずかに均一化方向 + +### 候補の組み合わせ評価 + +3 候補は **物理的に独立** なので組み合わせて試せる: + +| 組み合わせ | 期待 | +| --- | --- | +| **A1 + A8 + A9** | 全部入り.最大改善を狙う.レンダ時間 約 17 分 | +| A8 + A9 | 既存方針の延長.低コスト.大幅改善は期待しにくい | +| A1 単独 | 「形」改善の最大候補単独.BSDF 実装が必要 | + +## ユーザー認識との差分整理 + +| ユーザー認識(2026-06-02 議論) | 私の見立て | 差分 | +| --- | --- | --- | +| **「LED 配光が一番影響」** | A2/A3 = 6 位(影響度 小〜中) | **TECH_09 でデータシート Lambertian 整合済み**を踏まえて優先度低下 | +| 「筐体の反射率」 | A8 = 2 位 | **賛成**.物理整合点 R=0.89 への調整は次の試行候補 | +| 「反射回数」 | A9 = 3 位 | **賛成**.新方針下で再評価の価値あり | +| ─ | **A1 内壁の非 Lambertian 性が最有力**(新規追加) | **私からの追加提案**.完全 Lambertian は理想化しすぎ | + +## 「やらない」と判断したもの + +以下は明示的に **本ドキュメントの優先度から除外** した: + +| 候補 | 除外理由 | +| --- | --- | +| C1〜C4(タイプ II 構造的差異) | [TECH_08](TECH_08_実機変更とシミュ側対応.md) で「白板撮影では許容」と判断済.舌模型評価時に再考 | +| B1 筐体内部の幾何精度 | 検証コスト高(実機 3D スキャン等が必要).他の候補を試した後の最終手段 | +| 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) で予想していた「**数値の偽物の一致**」を初めて定量実証した. + +### 検証手順 + +1. **仮説**: ① R 上昇で既存 4 指標は ambient 増加で改善方向 ② 新指標 angular_ratio は不変または同心円方向(実機の中央ピーク楕円に整合) +2. **実装**: `scripts/sweep_reflectance.py` で `configs/scene_baseline.yaml` (**d=16 新基準**) の `materials.printer_white.reflectance` のみ書き換え.評価は `configs/eval_baseline_dng_target_shape.yaml` で **新指標 (shape_metrics) を初投入**.約 85 分. +3. **再現性確認**: R=0.85 の CoV=0.1604 は max_depth スイープ d=16 (CoV=0.1604) と **完全一致** → 別 sweep 間の数値整合性 OK + +### 結果 + +| R | CoV | 勾配% | C/P | rmin/max | **angular_ratio** | centroid_y_norm | eccentricity | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 0.75 | 0.1861 | 24.67 | 1.327 | 0.620 | **0.4079** | 0.0453 | 0.444 | +| 0.80 | 0.1735 | 22.67 | 1.293 | 0.649 | **0.4282** | 0.0428 | 0.436 | +| 0.85 | 0.1604 | 20.59 | 1.259 | 0.684 | **0.4536** | 0.0400 | 0.426 | +| **0.89** | 0.1501 | 18.96 | 1.234 | 0.710 | **0.4761** | 0.0377 | 0.419 | +| 0.92 | **0.1424** | **17.72** | **1.215** | **0.731** | **0.4938** | 0.0358 | 0.414 | +| **実機 TARGET** | **0.0695** | **8.35** | **1.091** | **0.860** | **小予想** | **0** | — | + +### ★ 形状指標見直しの初の定量実証 ★ + +| 指標 | R 0.75→0.92 の動き | 実機方向か | +| --- | --- | --- | +| CoV | 0.186 → 0.142 で **改善 24%** | ✓ | +| 勾配 | 24.7 → 17.7 で **改善 28%** | ✓ | +| C/P | 1.327 → 1.215 で **改善** | ✓ | +| rmin/max | 0.620 → 0.731 で **改善** | ✓ | +| **angular_ratio** | **0.408 → 0.494 で増加 21%** | **✗ 逆方向(実機は同心円的)** | + +→ **既存 4 指標が「改善」と判定する一方で、新指標 angular_ratio は「形は実機から遠ざかる」と判定**.[TECH_11](TECH_11_形状指標の見直しと新指標導入.md) で予想していた「数値の偽物の一致」が **現実に起きていることの初の定量証拠**. + +### 物理的解釈 + +- R 上昇 → ambient 成分増加 → 半径方向の偏り(評価板下端 vs 上端の差)が圧縮 → CoV/勾配/rmin/max 改善 +- 角度方向のばらつき(X 方向の波打ち等)は **相対的に圧縮されにくい** +- 結果「半径 var / 角度 var」バランスが崩れ angular_ratio が **増加** +- R 上昇は **「Y 単調勾配の contrast を緩める代わりに角度方向のムラを相対的に強調する」**.実機の「中央ピーク楕円(=同心円的)」とは **形が遠ざかる** + +### 判定と意味 + +- **A8 は数値改善には有効だが形改善ではない**(**むしろ形は逆効果**).候補リストから「数値改善のみクリア」として除外 +- A1/A9 検証より **強い発見**: 「均一化フィルタ系の改善は形を変えない」を超えて **「数値改善が形を逆方向に動かす」ケースの実証** +- **TECH_11 の評価方針(数値 + 形 + 視覚の 3 軸)の妥当性が定量データで裏付けられた**.今後の試行で新指標必須 +- TECH_10 優先順位を再更新: A8 をクリア化.**形改善の本筋は B1 幾何精度に最終確定** + +### 副次発見 + +- 反射率を上げると centroid_y_norm がわずか減少(0.045→0.036)= 重心が画像中心に近づく方向(**形改善の弱い兆候**) +- eccentricity がわずか減少(0.444→0.414)= 楕円率がわずか改善 +- ただし angular_ratio の悪化が支配的で、全体としては形悪化と判定 + +### ユーザー仮説の評価 + +ユーザー要請「反射率を変えて結果を見たい」は **重要な実証実験**として完了.結果として: + +- 数値だけ見れば「R=0.92 が最良」と判定される(CoV 0.1424) +- しかし新指標も合わせて見ると **R=0.75 が最も同心円的(実機方向に近い形)** +- これは **新指標を導入していなければ気づけない発見**であり、形状指標見直しの **正当性が定量データで裏付けられた** + +## A9 検証結果(2026-06-03 完了) + +候補リストで **A9「max_depth 8 → 16+」** を 5 値スイープ {8, 12, 16, 20, 28} で検証したところ,**数値指標は実機方向に単調改善するが「形」は完全に不変**であることが確定した.均一化フィルタとして働くが空間再配分は起こさない. + +### 検証手順 + +1. **仮説**: 現状 max_depth=8 では多重反射の累積寄与の **理論累積 R/(1-R)=5.67 に対して 72.7% しか取り込んでいない**.残り 27% を打ち切っている.R=0.85 の場合,最終 bounce の寄与が 0.01 を下回るのは d≈28 付近. +2. **実装**: `scripts/sweep_max_depth.py` で `configs/scene_baseline.yaml` の `render.max_depth` のみを書き換え → sm-render → sm-evaluate (`configs/eval_baseline_dng_target.yaml`).約 85 分. +3. **可視化**: `scripts/build_max_depth_panel.py` で 1×5 輝度マップ + 収束カーブ生成.追加で「shared vs independent スケール 2 段比較」(`max_depth_sweep_shape.png`) と「mean=1 正規化」(`max_depth_sweep_relative.png`) で形の不変性を視覚化. + +### 結果 + +| max_depth | 理論累積 R^n | CoV | 勾配% | C/P | 動径 min/max | +| --- | --- | --- | --- | --- | --- | +| **8** | 72.7% | 0.1817 | 24.01 | 1.316 | 0.628 | +| 12 | 85.8% | 0.1651 | 21.35 | 1.272 | 0.671 | +| 16 | 92.6% | 0.1604 | 20.59 | 1.259 | 0.683 | +| 20 | 96.1% | 0.1589 | 20.35 | 1.256 | 0.687 | +| **28** | **98.7%** | **0.1583** | **20.24** | **1.254** | **0.689** | +| **実機 TARGET** | — | **0.0695** | **8.35** | **1.091** | **0.860** | + +### 残差縮小率(d=8 → 28) + +| 指標 | 縮小率 = (d8 − d28) / (d8 − TARGET) | +| --- | --- | +| CoV | **20.9%** | +| 勾配 | **24.1%** | +| C/P | **27.6%** | +| 動径 min/max | **26.3%** | + +### 収束観察 + +実用的な収束は **d=16 で達成**(d=16 → 28 で CoV 1.3% のみ動く).理論累積より早く収束.これは **「直接光と初期反射が支配的,後段の累積はわずかな ambient しか足さない」** という物理的直感と整合. + +### 「形」の観察(決定的) + +3 種類のパネル全てで **形は完全に不変**: + +- **共有スケール**: 全 5 画像とも下端中央が明るく上端が暗い Y 軸単調勾配で、contrast が d=8→28 で緩む +- **独立スケール(自己 contrast 最大化)**: 全 5 画像とも **同じ形を異なる contrast で再現** → **形が変わっていないことが視覚的に明白** +- **mean=1 正規化**: d=8 で [0.3〜1.3]、d=28 で [0.4〜1.2] と **相対輝度の範囲が狭まる**(ambient 化が進む)が,**空間パターン (Y 単調勾配) は不変** + +### 物理的解釈 + +多重反射の累積は **空間的にほぼ均一な ambient 成分**として働く.これを足すと: + +- 暗い領域と明るい領域の **絶対差が縮む** → 数値指標は改善 +- 暗い/明るい領域の **空間配置は不変** → 「形」は変わらない + +つまり多重反射は **均一化フィルタ**として作用するが,「LED が下にしかなく評価板の中央〜上端は内壁経由でしか光が届かない」という **配置と幾何に由来する非対称性は除去できない**.これは TEST_03 の主張「Step 8-1〜8-7 で形は全く変わっていない」と整合.**max_depth スイープも同じパターン**. + +### 判定と意味 + +- **A9 は数値改善には有効だが形改善ではない**ことが確定.候補リストから「数値改善のみクリア」として除外 +- **新キャリブベースラインは d=16 が筋**(d=28 と CoV 1.3% 差、レンダ時間 17 分 vs 30 分).物理整合の極致を求めるなら d=28 +- TECH_10 優先順位を再更新: A9 をクリア化,**形改善の本筋候補は B1 幾何精度に集約**.A8 は数値改善目的では有効だが形改善は予想薄 +- 副次成果: スイープ + 収束カーブ + 形比較パネルのテンプレが整備された.今後 spp など他のレンダパラメータ感度分析にも流用可能 + +### ユーザー仮説の評価 + +ユーザー仮説「max_depth を増やすべき(最終 bounce 寄与が 0.01 を下回るまで)」は **方向性として完全に正しい**: + +- 数値改善は d=8 → 28 で確実に進む(21-28% 残差縮小) +- ただし **実用収束は d=16**(理論累積より早い).d=28 まで増やしても得るのは 1% 程度の追加改善 +- 実機 (CoV=0.0695) には届かない(収束値 CoV=0.158 が下限).**残る残差は形改善で詰める必要がある** + +ユーザーの「形は変わっていない」観察も視覚的・物理的に正確.均一化フィルタ系の改善は数値には効くが形には効かない原則が再確認された. + +> **注記 (2026-06-03)**: 本検証で用いた形状指標(CoV / 勾配 / C/P / 動径 min/max)は **形を構造的に区別できない**([TECH_11 形状指標の見直し](TECH_11_形状指標の見直しと新指標導入.md) 参照).本判断は「数値の絶対量」レベルの結論であり,「形が変わったか」は新指標(angular_ratio / SSIM / 重心 / PCA)で再評価する必要がある.A9 については視覚比較(独立スケール・mean=1 正規化パネル)でも「形は全く変わらない」が確認されており,新指標での再評価の優先度は低い. + +## A1 検証結果(2026-06-02 完了) + +候補リストで当時 🥇 1 位だった **A1「内壁の非 Lambertian 性(光沢成分)」** を Step 8-14 候補 β として **3×3 グリッド感度分析**で検証したところ,**どの組み合わせも実機方向に作用しない(むしろ悪化)**ことが確定した. + +### 検証手順 + +1. **スキーマ拡張**: `src/sm_tias_lightsim/scene/schema.py` に `RoughConductorBSDFConfig` (alpha, distribution) と `BlendBSDFConfig` (weight + components×2,forward reference + `model_rebuild()`) を追加.`MaterialConfig` Union を拡張.後方互換は既存 `scene_baseline.yaml` のスモークテストで確認 +2. **Mitsuba dict 変換拡張**: `src/sm_tias_lightsim/scene/materials.py` の `_build_bsdf_dict` を再帰ヘルパとして切り出し.`blendbsdf` のネスト対応 +3. **テンプレ YAML**: `configs/scene_baseline_blend.yaml` を作成.`printer_white` のみ `blendbsdf` (diffuse R=0.85 + `roughconductor` material="none") に置換 +4. **スイープ実行**: `scripts/sweep_blend_bsdf.py` で `weight ∈ {0.05, 0.15, 0.30}` × `α ∈ {0.1, 0.3, 0.6}` の 9 ジョブを順次 sm-render + sm-evaluate.各 8.4 分 × 9 = 75.5 分.eval は `configs/eval_baseline_dng_target.yaml` (linear + mean アンカー 207.82) +5. **比較パネル**: `scripts/build_blend_bsdf_panel.py` で 3×3 輝度マップ(shared vmin/vmax 1%/99% percentile)+ 数値表 PNG を生成 + +### 結果(CoV / 勾配% / C/P / 動径 min/max) + +ベースライン純 diffuse (w=0): **0.1826 / 24.65 / 1.327 / 0.615** +実機 TARGET: **0.0695 / 8.35 / 1.091 / 0.860** + +| weight \ α | 0.1(鋭い) | 0.3(中) | 0.6(粗い) | +| --- | --- | --- | --- | +| **0.05** | 0.192 / 24.8 / 1.33 / 0.62 | 0.189 / 24.9 / 1.33 / 0.62 | **0.185** / 24.4 / 1.32 / 0.62 ← 最良 | +| **0.15** | 0.213 / 26.0 / 1.35 / 0.61 | 0.204 / 26.6 / 1.36 / 0.60 | 0.193 / 25.5 / 1.34 / 0.61 | +| **0.30** | **0.243** / 26.8 / 1.37 / 0.59 ← 最悪 CoV | **0.227 / 29.0** / 1.41 / **0.57** ← 最悪 形 | 0.205 / 27.0 / 1.37 / 0.59 | + +### 観察された傾向 + +| 軸 | 効果 | +| --- | --- | +| **weight ↑(光沢強化)** | CoV / 勾配 / C/P すべて **単調悪化**(不均一性増) | +| **α ↑(粗さ大)** | weight 固定で **わずか改善**(diffuse に近づくため) | +| **動径 min/max** | specular 強化で **0.615 → 0.570** へ低下(不均一さが目立つ) | + +### 「形」の観察(`output/_slide_panels/blend_bsdf_sweep.png`) + +- **w=0.05 行**: ベースラインに近い Y 軸単調勾配(純 diffuse と区別困難) +- **w=0.15 行**: 下端中央に明るい集中点が現れ始める +- **w=0.30 行**: 下端中央に **2 つの明るいスポット**が顕在化.specular 反射が LED 直下の評価板領域に集中 +- α を上げると specular が「拡散的」になり diffuse に近づくが、実機の中央ピーク楕円パターンには近づかない + +### 物理的解釈 + +純 Lambertian は cos 重み散乱で **多重反射の均一化作用が最大**.specular 成分が混ざると: + +- 内壁での 1 回反射が **特定方向に集中** +- 多重反射ループから外れて評価板の特定エリア(カバー側下端)に集中 +- 多重反射の累積が不均一になり ambient 成分が減る + +3D プリンタ白フィラメントは **完全 Lambertian として扱うのが実機挙動に近い近似** だと判明([TECH_09](TECH_09_LEDデータシート調査結果.md) の LED Lambertian 整合性と並ぶ「物理モデル妥当性の裏付け」). + +### 判定と意味 + +- **A1 は残差 2.63× の主因ではない**ことが確定.候補リストから「クリア」として除外 +- 「最有力候補」だった A1 の除外は **次の候補に焦点を絞れる**正味前進 +- 副次成果: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入った.今後の物理モデル試行で再利用可能 +- 新規テスト 89 件追加(既存 608 → 697 全通過).Pydantic v2 forward reference + 再帰 BSDF 変換の安全網が整備された + +### 副次発見 + +- `roughconductor` の `material="none"` 設定は eta=k=1 の完全反射体として機能する(Mitsuba 3 仕様).感度分析用途で扱いやすい +- weight=0 で純 diffuse に完全一致するため、ベースライン (output/baseline/luminance.npy) を 10 番目のセルとして使い回せる設計が妥当だった + +> **注記 (2026-06-03)**: 本検証で用いた形状指標(CoV / 勾配 / C/P / 動径 min/max)は **形を構造的に区別できない**([TECH_11 形状指標の見直し](TECH_11_形状指標の見直しと新指標導入.md) 参照).本判断は「数値の絶対量」レベルの結論であり,「形が変わったか」は新指標(angular_ratio / SSIM / 重心 / PCA)で再評価する必要がある.3D パネル視覚比較でも「w=0.30 行で下端中央に 2 つの明るいスポット」が出現しており,A1 は実機の中央ピーク楕円パターンには近づかないことが定性的にも確認されている. + +## D1 検証結果(2026-06-02 完了) + +候補リスト 4 位の **D1「シミュ ROI と実機 ROI の物理的整合」** を Step 8-14 候補 γ として **事前検証**したところ,**両者は物理的に同じ領域を見ている**ことが確認できた.**設定変更は一切行わず,既存設定の数値検証のみ**で結論が出た. + +### 検証手順 + +1. **シミュ ROI 物理範囲の算出** — `configs/scene_baseline.yaml` の orthographic 設定(position (0,0,-50), ortho_scale (50, 52), film 512×512)と `configs/eval_baseline_linear.yaml` の ROI (24, 37, 464, 450) から,評価板中心基準で **X∈[-45.31, +45.31] mm, Y∈[-46.92, +44.48] mm(幅 90.63×91.41 mm)** と算出 +2. **評価板 STL bbox 計測** — `configs/meshes/照明ムラ補正ボード.stl` を trimesh で計測:**X=99 mm × Y=102 mm**(既知値と一致) +3. **実機画像の物理スケール算出** — `reference_measurement/MiniTIAS_20260408_144434/MiniTIAS_20260408_144434.png`(2448×3264)から評価板の縦方向黒縁を検出(H=1560 px).評価板 Y=102 mm より **物理分解能 0.0654 mm/px** を確定(ピクセルアスペクト比 1:1 仮定で X 方向も同じ) +4. **実機 ROI 物理範囲の算出** — `roi_config.json` の ROI (539, 1015, 1400, 1409) から,評価板中心基準で **X∈[-44.79, +46.75] mm, Y∈[-47.53, +44.59] mm(幅 91.54×92.13 mm)** と算出(画像 y→物理 Y は符号反転) + +### 比較結果 + +| 項目 | シミュ ROI | 実機 ROI | 差 | +| --- | --- | --- | --- | +| **X 幅** | 90.63 mm | 91.54 mm | **-0.91 mm (1.0%)** | +| **Y 高さ** | 91.41 mm | 92.13 mm | **-0.72 mm (0.8%)** | +| **X 中心** | 0 mm | +0.98 mm | +0.98 mm | +| **Y 中心**(符号反転後) | -1.22 mm | -1.47 mm | -0.25 mm | + +→ **物理サイズは 1% 以内・中心位置は 1 mm 以内で一致**.3 列パネル `output/_slide_panels/roi_alignment_check.png`(①実機画像 + 評価板境界・実機 ROI / ②シミュ輝度マップ + 評価板境界・シミュ ROI / ③物理座標系で両者を重ね合わせ)でも視覚的に確認. + +### 判定と意味 + +- **残差 2.63× の主因は ROI 整合ではない**ことが確定.D1 を候補リストから「クリア」として除外 +- 過去の設定者(おそらく Step 8 開始時の `scripts/select_white_board.py` GUI 選択 → シミュ側 ortho_scale 設計値)は **物理整合性を意識して ROI を揃えていた**ことが裏付けられた +- 設定変更ゼロで結論が出たため,シミュ/実機双方の YAML・コードへの **コミット可能な変更なし** + +### 副次発見 + +評価板は実機画像中央から **下方向に約 4.2 mm(65 px)オフセット**して配置されている(カメラ取付位置 vs 評価板取付位置のミリ精度差)が,実機 ROI もそのオフセットに沿って選択されているため,物理領域としては板中心とほぼ整合している. + +## Step 8-14 候補選定(次のアクション) + +D1(γ)が「クリア」と確定したため,残る選択肢は以下: + +| Step 8-14 候補 | 内容 | 状態 | 推奨度 | +| --- | --- | --- | --- | +| ~~Step 8-14 候補 α 前半~~ | ~~A9 max_depth 8 → 16+~~ | **✓ 2026-06-03 完了(数値改善のみクリア — 形不変)** | — | +| ~~Step 8-14 候補 α 後半~~ | ~~A8 反射率 R=0.85 → 0.89(d=16 ベース)~~ | **✓ 2026-06-03 完了(数値改善のみクリア — angular_ratio は実機方向と逆)** | — | +| ~~Step 8-14 候補 β~~ | ~~A1 内壁の非 Lambertian 性(光沢成分混合 BSDF)~~ | **✓ 2026-06-02 完了(クリア — 実機方向に作用しない)** | — | +| ~~Step 8-14 候補 γ~~ | ~~D1 ROI 整合の再確認~~ | **✓ 2026-06-02 完了(クリア — 物理差 < 1%)** | — | +| ~~Step 8-14 候補 δ~~ | ~~β + α 全部~~ | **β/α 前半クリアで意義消失** | — | +| **Step 8-14 候補 ε(新規)** | **B1 筐体幾何精度の検討**(形改善の最後の砦) | 未実施 | ⭐⭐⭐ | +| **Step 8-14 候補 ζ(新規)** | **LED 軸方向のティルト仮説**(評価板中央向きに数度傾ける) | 未実施 | ⭐⭐ | + +→ **A1/A9 がともに「形改善ではない」と確定**したため,残る残差 (CoV 0.158 vs TARGET 0.069) の **形改善は構造的に光分布を再配分する要素 (B1 / ζ) が本筋**.均一化フィルタ系 (A8) は数値改善のみ期待. + +## 関連ドキュメント (Related Documents) + +### 残差候補の出典 + +- [TECH_07 LSC 取り扱い方針](TECH_07_LSC取り扱い方針.md) — LSC 由来の排除根拠 +- [TECH_08 実機変更とシミュ側対応](TECH_08_実機変更とシミュ側対応.md) — ドメイン不一致由来の排除根拠 + タイプ II 構造的差異 +- [TECH_09 LED データシート調査結果](TECH_09_LEDデータシート調査結果.md) — LED 配光由来の排除根拠 + +### 過去の試行履歴 + +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) — Step 8-1〜8-13 のフル記録 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) — 「形」の視覚比較 +- [TEST_02 シミュレーション実行記録](../06_TEST/TEST_02_シミュレーション実行記録.md) — output/ 配下の実験データ索引 + +### 仕様 + +- [SPEC_03 基準設計の物理構成](../04_SPEC/SPEC_03_基準設計の物理構成.md) — 現状 BSDF・反射率・max_depth 設定の出典 diff --git "a/docs/05_TECH/TECH_11_\345\275\242\347\212\266\346\214\207\346\250\231\343\201\256\350\246\213\347\233\264\343\201\227\343\201\250\346\226\260\346\214\207\346\250\231\345\260\216\345\205\245.md" "b/docs/05_TECH/TECH_11_\345\275\242\347\212\266\346\214\207\346\250\231\343\201\256\350\246\213\347\233\264\343\201\227\343\201\250\346\226\260\346\214\207\346\250\231\345\260\216\345\205\245.md" new file mode 100644 index 0000000..7a1a803 --- /dev/null +++ "b/docs/05_TECH/TECH_11_\345\275\242\347\212\266\346\214\207\346\250\231\343\201\256\350\246\213\347\233\264\343\201\227\343\201\250\346\226\260\346\214\207\346\250\231\345\260\216\345\205\245.md" @@ -0,0 +1,241 @@ +# 形状指標の見直しと新指標導入 (Shape Metric Redesign and New Metrics) + +本ドキュメントは「輝度マップの形を直接評価する指標」の導入背景・設計・実装・予想値を記録する. +Step 8-14 での A1/A9 検証を通じて「数値が実機に近づいた」評価が「形の偽物一致」になりうることが判明したため,形状指標を体系化した. + +関連: + +- 実装: `src/sm_tias_lightsim/evaluation/shape_metrics.py` +- 設定: `configs/eval_baseline_dng_target_shape.yaml` +- 評価仕様: [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) +- 残差候補: [TECH_10 残差主因候補](TECH_10_残差主因候補の網羅整理.md) +- 形状追跡: [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md) +- キャリブ履歴: [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md) + +## §背景: 現状 4 指標が「形」を区別しない構造的問題 + +現状の均一性指標は以下の 4 種(+ 動径 min/max): + +| 指標 | 定義 | 本質 | +| --- | --- | --- | +| CoV | std / mean | **ばらつきの絶対量** | +| 勾配 % | (center_mean - periphery_mean) / center_mean × 100 | **中心-周辺差の絶対量** | +| C/P 比 | center_mean / periphery_mean | **中心-周辺比の絶対量** | +| 動径 min/max | min(radial_bins) / max(radial_bins) | **動径方向の均一性絶対量** | + +これらは全て「どれくらい不均一か(絶対量)」を表すが,**「どのような形の不均一か(構造)」を表さない**. + +### 構造的限界の具体例 + +「Y 軸単調勾配(下明・上暗)」と「中央ピーク楕円(中心明・周辺暗)」は,下記のような同じ CoV を持てる: + +- Y 単調勾配: 上端 0.7, 下端 1.3(平均 1.0)→ std ≈ 0.18 → CoV ≈ 0.18 +- 中央ピーク楕円: 中心 1.3, 周辺 0.82(同じ分散設計)→ CoV ≈ 0.18 + +CoV 0.18 で一致しても「形は全く違う」.C/P 比・勾配・動径 min/max も同様に「量」は一致しうる. + +Step 8 で「CoV が実機方向に動いた」と評価した過去の検証は,形の改善を保証しない.[TEST_01 注記 (2026-06-03)](#) 参照. + +### シミュ vs 実機の形の乖離 + +TEST_03 の視覚比較から: + +- **シミュ**: 下端のみ明の **Y 軸単調勾配**(LED が下基板に集中し,上端は内壁多重反射でしか届かない) +- **実機**: 中央付近にピークを持つ **楕円状** 分布 + +Step 8-1〜8-12 の全検証ステップで「形は全く変わっていない」という重要発見 ([TEST_03](../06_TEST/TEST_03_輝度マップ形状追跡.md)) と整合する. + +## §現状指標の限界の具体例(数値計算デモ) + +以下は 100×100 の合成画像で,CoV が同じでも形が異なるケースのデモ: + +```python +import numpy as np + +# ケース 1: Y 軸単調勾配 +H, W = 100, 100 +y_gradient = np.linspace(1.3, 0.7, H)[:, None] * np.ones((H, W)) +# mean=1.0, std≈0.18, CoV≈0.18 + +# ケース 2: 中央ピーク楕円(半径で減衰) +cy, cx = H/2, W/2 +ys = np.arange(H)[:, None]; xs = np.arange(W)[None, :] +r = np.sqrt(((xs-cx)/cx)**2 + ((ys-cy)/cy)**2) +radial = 1.0 + 0.3 * np.exp(-2.0 * r**2) +# mean ≈ 1.0, std ≈ 0.18, CoV ≈ 0.18 + +# 両者の CoV は等しい(設計通り) +# しかし形は正反対: Y 単調 vs 中央ピーク +# 新指標 angular_ratio でのみ区別可能: +# - ケース 1: angular_ratio ≈ 0.3〜0.4(Y 方向の方向性あり) +# - ケース 2: angular_ratio ≈ 0.1〜0.2(ほぼ同心円) +``` + +## §新指標 5 つの物理的意味と計算式 + +実装は `src/sm_tias_lightsim/evaluation/shape_metrics.py`. + +### 1. 輝度重心 `calc_intensity_centroid` + +**物理的意味**: 光の「重力中心」.ROI 中心から大きくずれていれば非対称な照明分布を示す. + +**計算式**: + +``` +cx = Σ(I(x,y) * x) / Σ(I(x,y)) +cy = Σ(I(x,y) * y) / Σ(I(x,y)) +centroid_x_norm = (cx - ROI_center_x) / (ROI_half_width) +centroid_y_norm = (cy - ROI_center_y) / (ROI_half_height) +centroid_offset_norm = √(x_norm² + y_norm²) +``` + +**出力**: `centroid_x_px`, `centroid_y_px`, `centroid_x_norm`, `centroid_y_norm`, `centroid_offset_norm` の 5 値. + +### 2. 角度-放射分解 `calc_angular_radial_decomposition` + +**物理的意味**: 輝度ムラが「同心円方向(r)」か「方向性ムラ(θ)」かを分離する形状識別器. + +**計算式**: + +``` +極座標変換: (x,y) → (r, θ) +グリッド I[r_bin, θ_bin] = 平均輝度 +angular_variance = mean_r[ Var_θ[I | r] ] ← 各 r での角度方向分散の平均 +radial_variance = Var_r[ mean_θ[I | r] ] ← 角度平均した放射プロファイルの分散 +angular_ratio = angular_variance / (angular_variance + radial_variance) +``` + +**値域と解釈**: + +| angular_ratio | 意味 | +| --- | --- | +| 0 | 完全同心円(各 r で角度方向の輝度変化ゼロ) | +| NaN | 完全均一画像(全 variance = 0) | +| 0.1〜0.3 | 中央ピーク楕円に典型的(角度依存が小) | +| 0.3〜0.5 | Y 単調勾配に典型的(Y 方向に偏った方向性) | +| 1 | 完全角度依存(同心円成分ゼロ) | + +### 3. SSIM `calc_ssim` + +**物理的意味**: 実機 DNG 輝度マップを参照として,シミュ輝度マップとの構造類似性を 0〜1 で数値化.1 で完全一致. + +**実装**: `skimage.metrics.structural_similarity` を使用(依存: `scikit-image>=0.21.0`). + +**注意**: +- `shape_reference` が指定されたときのみ算出(未指定 = NaN) +- shape が異なれば自動スキップ(呼び出し側で resize が必要) + +### 4. NCC `calc_ncc` + +**物理的意味**: 絶対輝度スケールに依存しない形の類似性.SSIM より単純だが,線形スケールや露出変動に完全不変(mean を引いて相関を取るため). + +**計算式**: + +``` +NCC = Σ((I - mean(I)) * (R - mean(R))) / √(Σ(I-mean(I))² * Σ(R-mean(R))²) +``` + +**値域**: -1〜+1(1=完全相関,0=無相関,-1=逆相関).均一画像では NaN を返す. + +### 5. 輝度分布 PCA `calc_intensity_pca` + +**物理的意味**: 輝度を重みとして空間分布の「伸び方向」を固有ベクトルで表現する.主軸角度が 90° に近ければ Y 方向の勾配が支配的(シミュの典型),0° に近ければ X 方向の勾配が支配的,楕円なら中程度の eccentricity. + +**計算式**: + +``` +(cx, cy) = 輝度重み重心 +共分散行列 [[Σ(I*Δx²)/W, Σ(I*Δx*Δy)/W], [同, Σ(I*Δy²)/W]] +固有値分解 → eigenvalue[0] ≤ eigenvalue[1] (主軸) +primary_axis_angle_deg = arctan2(v_y, v_x) × 180/π (-90〜+90 に折り畳み) +eccentricity = √(1 - secondary_eigenvalue / primary_eigenvalue) +``` + +**値域**: + +| 指標 | 中央ピーク楕円 | Y 単調勾配 | +| --- | --- | --- | +| primary_axis_angle_deg | 様々(楕円の向きに依存) | ≈ ±90°(Y 方向) | +| eccentricity | 中程度(0.4〜0.7 程度) | 高め(0.7〜0.9 程度) | + +## §シミュ vs 実機での新指標の予想値 + +現状のシミュ(Y 軸単調勾配)と実機(中央ピーク楕円)での典型的な期待値: + +| 指標 | シミュ予想 | 実機予想 | 判定の方向 | +| --- | --- | --- | --- | +| `centroid_y_norm` | 負(< 0)(下端明なので重心が下方) | ≈ 0(中心に近い) | シミュは負,実機は 0 近傍 | +| `centroid_offset_norm` | 大きい(0.1〜0.3) | 小さい(< 0.1) | 小さいほど中心対称 | +| `angular_ratio` | 中程度(0.3〜0.5) | 小さい(0.1〜0.3) | 小さいほど同心円に近い | +| `pca_primary_axis_angle_deg` | ≈ ±90°(Y 軸) | 様々(楕円の向き次第) | 90° から遠ざかれば形改善 | +| `pca_eccentricity` | 高め(0.7〜0.9) | 低め(0.4〜0.6) | 小さいほど円に近い | + +**重要**: SSIM / NCC は `shape_reference` に実機 DNG 由来の参照輝度マップを指定することで,シミュが実機に近づいているかを 1 枚の数値で追跡できる. + +## §過去判断への遡及的注記 + +Step 8 の以下の評価は「数値の偽物一致」の可能性がある: + +| Step | 評価内容 | 形指標の観点 | +| --- | --- | --- | +| 8-1 | spp=1024 で CoV 0.236 → 0.183(MC ノイズ縮小) | 形は不変(単純な量低減) | +| 8-2 | R=0.92 で CoV 0.183 → 0.170(反射率スイープ) | 形は不変(均一化フィルタ) | +| 8-3 | OETF 適用で CoV 0.183 → 0.089(ドメイン整合) | 形は不変(単調変換) | +| 8-4 | max_depth=12 で勾配改善 | 形は不変(均一化フィルタ) | +| 8-14 A9 | max_depth スイープで CoV 21% 縮小 | **形が全く変わらない**ことが視覚確認済み ([TECH_10 §A9 検証結果](TECH_10_残差主因候補の網羅整理.md#a9-検証結果2026-06-03-完了)) | + +TEST_03「Step 8-1〜8-7 で輝度マップの形は全く変わっていない」という発見と完全に整合する. + +**結論**: 過去の「数値が実機方向に動いた」評価は「均一化フィルタが効いた」だけで,「形が実機に近づいた」わけではない.本評価の目的(MiniTIAS 均一性の構造的改善)のためには,形指標の追跡が必須である. + +## §今後の評価方針: 数値指標 4 + 新形指標 5 + 視覚比較の 3 軸評価 + +今後の Step 8-14 以降は以下の 3 軸で評価する: + +``` +評価 3 軸 +├── 数値指標(量): CoV / 勾配 / C/P / 動径 min/max +│ └── 実機に近づいているか(絶対量の改善) +├── 形指標(構造): angular_ratio / SSIM / NCC / 重心 / PCA +│ └── Y 単調勾配から中央ピーク楕円に形が変化しているか +└── 視覚比較: luminance_map.png + TEST_03 パネル + └── 目視で形のパターン変化を確認 +``` + +「数値改善のみで形が変わらない」候補(A9 max_depth 等)と「形を変えうる候補」(B1 幾何精度,ζ LED ティルト等)を明確に区別する判断材料として機能する. + +## §設定方法 + +### 基本(形状指標のみ) + +```yaml +metrics: + cov: true + radial_min_max: true + shape_metrics: true + shape_reference: null +``` + +`configs/eval_baseline_dng_target_shape.yaml` がこの設定に対応する. + +### SSIM / NCC 有効化(参照画像指定時) + +```yaml +metrics: + shape_metrics: true + shape_reference: reference_measurement/quantitative/expected_results/MiniTIAS_QM_20260601_124500_luminance_map.npy +``` + +参照画像は ROI 抽出後の輝度マップ(`luminance.npy` と同じ ROI サイズの 2D float64 配列)を期待する. +実機 DNG からの輝度マップ生成は MiniTias-Evaluation で行い,`reference_measurement/quantitative/expected_results/` に配置する(gitignore 対象外として管理すること). + +### 後方互換 + +`shape_metrics` および `shape_reference` フィールドはいずれも省略可能(デフォルト false/null).既存の YAML は変更不要で動作する. + +## §関連ドキュメント (Related Documents) + +- [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md): 均一性評価パイプライン全体 +- [TECH_10 残差主因候補](TECH_10_残差主因候補の網羅整理.md): 形指標導入のきっかけとなった A9 検証 +- [TEST_01 キャリブレーション検証履歴](../06_TEST/TEST_01_キャリブレーション検証履歴.md): Step 8 数値評価の歴史 +- [TEST_03 輝度マップ形状追跡](../06_TEST/TEST_03_輝度マップ形状追跡.md): 形の視覚比較(既存) diff --git a/docs/06_TEST/README.md b/docs/06_TEST/README.md new file mode 100644 index 0000000..714ebf8 --- /dev/null +++ b/docs/06_TEST/README.md @@ -0,0 +1,69 @@ +# 06_TEST — テスト計画・品質保証 (Test & Quality Assurance Docs) + +このディレクトリには **試行・検証の履歴と記録** が集まっている.SmTIAS-LightSim のキャリブレーション作業を中心に,過去の試行を時系列・観点別に追跡する. + +## 種類分類 + +| アイコン | 種類 | 性質 | 読み方 | +| --- | --- | --- | --- | +| 🟡 | **キャリブ作業の中核(更新中)** | 永続的,新規 Step ごとに追記 | **「過去の試行と現状」を辿る主軸** | +| 🔵 | **クローズ済みの試行履歴** | 履歴的,原則更新しない | 「中断・完了した別軸の試行」を参照する | +| 📋 | **外部スナップショット** | 取り込み済み,更新は元側で | 元プロジェクト ([MiniTias-Evaluation](https://github.com/rintoHasegawa/MiniTias-Evaluation)) を参照 | + +## 🟡 キャリブ作業の中核 — 3 ファイル + +SmTIAS-LightSim のシミュ vs 実機キャリブレーションの **主軸記録**.Step 8 系の試行を時系列・観点別に追跡する. + +| 順序 | ファイル | 内容 | +| --- | --- | --- | +| 1 | [TEST_01_キャリブレーション検証履歴.md](TEST_01_キャリブレーション検証履歴.md) | Step 8-1〜8-13 + Step 8-N 候補の **時系列フル記録**.各イテレーションの仮説/変更/結果/解釈/学び.新規 Step 追加時はここに追記 | +| 2 | [TEST_02_シミュレーション実行記録.md](TEST_02_シミュレーション実行記録.md) | `output/` 配下に蓄積された **実行結果ディレクトリの索引**.「何の試行か・どう再現するか」を辿れる.`output/` は gitignore 対象なので手元になくてもこのドキュメントで網羅 | +| 3 | [TEST_03_輝度マップ形状追跡.md](TEST_03_輝度マップ形状追跡.md) | 数値指標 (CoV / 勾配等) では捉えられない **「形そのもの」を画像比較**.`figures/luminance_map_evolution.png` の比較パネルとセット.`scripts/build_luminance_evolution_panel.py` で生成 | +| 4 | [TEST_07_LEDグループ別動作確認.md](TEST_07_LEDグループ別動作確認.md) | 下/右/左 LED 基板を **単独 ON にして方向性・対称性・線形性の sanity check**.シミュが 3 つの照明源を物理位置どおりにモデル化できているかの基礎健全性確認.Step 8 系キャリブの前提保証 | + +### 4 ファイルの使い分け + +| 知りたいこと | 読むべきファイル | +| --- | --- | +| 「過去どんな Step を試したか,何が判明したか」 | **TEST_01** | +| 「`output/baseline_spot/` って何のレンダ結果?」 | **TEST_02** | +| 「シミュは形が実機と全然違うのか?」 | **TEST_03**(画像で一目) | +| 「シミュは LED 3 基板を独立に扱えているのか?」 | **TEST_07**(sanity check) | + +## 🔵 クローズ済みの試行履歴 — 1 ファイル + +| ファイル | 状態 | 内容 | +| --- | --- | --- | +| [TEST_04_シミュレーション速度最適化履歴.md](TEST_04_シミュレーション速度最適化履歴.md) | **中断** | Speed-0 ベースライン計測・Speed-1 LLVM JIT 自動検出を完了.Speed-2 path bake wavefront 化はテンプレ保持.再開条件 4 項目を明記 | + +→ 再開条件 (path bake samples を増やすユースケース発生 / NVIDIA GPU 利用可 / クラウド計算活用 / 反復回数増加) に該当した時のみ参照. + +## 📋 外部スナップショット — 2 ファイル + +[MiniTias-Evaluation](https://github.com/rintoHasegawa/MiniTias-Evaluation) で作成された報告書を SmTIAS-LightSim 側にも取り込んだもの.**原本は MiniTias-Evaluation 側**にあり,バグ修正・追記は元側で行う.本リポジトリは「スナップショット」として保持. + +| ファイル | 元側 | 内容 | +| --- | --- | --- | +| [TEST_05_PNG_DNG評価比較.md](TEST_05_PNG_DNG評価比較.md) | REPORT_02 | PNG sRGB 経路と DNG linear+LSC 経路の数値比較.**ガンマ圧縮で CoV が約 2 倍縮まる**ことを実証.真の linear 比較への移行根拠 | +| [TEST_06_外乱光影響評価.md](TEST_06_外乱光影響評価.md) | REPORT_03 | 室内灯 ON/OFF で照明均一性指標がほぼ変わらない(差 1% 未満)ことを実証.SmTIAS の自前照明が支配的 | + +### 取り込みの位置付け + +外部スナップショットの取り込み経緯は [TECH_06 MiniTias-Evaluation 引き継ぎ受領](../05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md) に記載.原本リンクの一部は MiniTias-Evaluation リポジトリ起点で,SmTIAS-LightSim 側からは到達しない(リンク切れは許容と各ファイル冒頭に明記). + +## 関連ディレクトリ + +| ディレクトリ | 役割 | +| --- | --- | +| [05_TECH/](../05_TECH/) | 結論集約 (TECH_07〜09) と過程記録 (TECH_01〜06).キャリブ作業の **方針側** が集まる | +| [04_SPEC/](../04_SPEC/) | ユーザー視点の仕様書.特に [SPEC_04 評価仕様](../04_SPEC/SPEC_04_評価仕様.md) は TEST_01 と表裏 | +| `figures/` | TEST_03 等の比較パネル画像.`scripts/build_luminance_evolution_panel.py` で再生成可能 | + +## 新規ドキュメント追加のガイド + +新しい TEST ドキュメントを追加するときは: + +1. **どのカテゴリか判断**: 🟡 キャリブ中核 / 🔵 クローズ済み / 📋 外部スナップショット +2. **連番を継続**: TEST_07, TEST_08, ... +3. **本 README に追記**: 該当カテゴリのリストに 1 行追加 +4. 既存 TEST_01〜03 を **どう更新するか** も検討(特に新規 Step 追加時は TEST_01 が主軸) 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 98b3d54..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" @@ -31,6 +31,8 @@ ## 俯瞰サマリ (Overall Progression) +> **注記 (2026-06-03)**: 本表の形状指標(CoV / 勾配 / C/P / 動径 min/max)は不均一性の **絶対量** しか測らない.形(Y 軸単調勾配 vs 中央ピーク楕円)の **構造的区別はできない** ため,過去の「数値が実機方向に動いた」評価は形を実機に近づける議論としては不十分である.本格的な「形」追跡は [TECH_11 形状指標の見直し](../05_TECH/TECH_11_形状指標の見直しと新指標導入.md) と [TEST_03 輝度マップ形状追跡](TEST_03_輝度マップ形状追跡.md) で行う. + 時系列での主要指標推移.**残差倍率** = (シミュ - 実測) / (初期残差 - 実測) として「初期からどれだけ縮まったか」を見る. | Step | 設定 | 空間 | mean | CoV | 勾配% | C/P | CoV 残差倍率 | @@ -44,6 +46,11 @@ | 8-7 size 1.5×1.5 (Φe 保存) | linear, R0.85, d8 | linear | 429.99 | 0.186 | 26.21 | 1.355 | (形状指標 ≈ 8-1) | | **実測 TARGET (新 DNG+LSC linear)** | — | **linear** | **207.82** | **0.0695** | **8.35** | **1.091** | **1.0×** | | **8-13 新方針シミュ突合** | R0.85, d8, spp1024 | **linear** | 207.82* | **0.1826** | **24.65** | **1.327** | **2.63×** | +| 8-14 候補 γ (D1) ROI 整合確認 | 変更なし | — | — | — | — | — | **クリア(ROI 物理整合差 < 1%)** | +| 8-14 候補 β (A1) 混合 BSDF 最良 | R0.85 + blendbsdf w0.05 α0.6 | linear | 207.82* | 0.1851 | 24.44 | 1.323 | 残差 2.66× (悪化) | +| 8-14 候補 β (A1) 混合 BSDF 最悪 | R0.85 + blendbsdf w0.30 α0.1 | linear | 207.82* | 0.2433 | 26.81 | 1.366 | **クリア(実機方向に作用しない)** | +| 8-14 候補 α 前半 (A9) d=16 | R0.85, **d=16**, spp1024 | linear | 207.82* | 0.1604 | 20.59 | 1.259 | **2.31×**(数値改善あり、形不変) | +| 8-14 候補 α 前半 (A9) d=28 | R0.85, **d=28**, spp1024 | linear | 207.82* | 0.1583 | 20.24 | 1.254 | **2.28×**(収束、形不変) | \* mean アンカー固定.R, d, spp は `reflectance`, `max_depth`, `samples_per_pixel`. @@ -507,6 +514,239 @@ - メッシュ幾何精度の評価(過去 Step 8-9 で Fusion STL 抽出済.残る微差を評価) - [TECH_04](../05_TECH/TECH_04_MiniTIASへの回答.md) フォールバック条件 C1 候補(SHADING_OFF 撮影)も並行検討の余地あり. +### Step 8-14 候補 γ: D1 ROI 整合の再確認(事前確認・クリア) + +- **位置付け**: [TECH_10 残差主因候補の網羅整理](../05_TECH/TECH_10_残差主因候補の網羅整理.md) で候補リスト 4 位とされた **D1「シミュ ROI と実機 ROI の物理的整合」** を,Step 8-14 候補 β(A1 内壁の非 Lambertian 性)等の物理モデル変更に進む前の **事前確認**として実施. +- **仮説**: シミュ ROI (24, 37, 464, 450) と実機 ROI (539, 1015, 1400, 1409) は解像度・縮尺が大きく異なる(シミュ 512×512 vs 実機 2448×3264).**物理的に同じ評価板領域を見ているか**を再検証.不一致なら残差 2.63× の一部を説明する可能性. +- **手順(設定変更なし)**: + 1. シミュ orthographic 設定(position (0,0,-50), ortho_scale (50, 52), film 512×512)と ROI から評価板中心基準の物理範囲を算出 + 2. 評価板 STL bbox 計測(X=99, Y=102 mm) + 3. 実機 PNG (`MiniTIAS_20260408_144434.png`) で評価板の縦方向黒縁を検出 → 物理分解能 0.0654 mm/px を確定 + 4. 実機 ROI の物理範囲を算出(画像 y→物理 Y は符号反転) + 5. 両者を物理座標系で重ね合わせ比較 +- **結果**: + +| 項目 | シミュ ROI | 実機 ROI | 差 | +| --- | --- | --- | --- | +| **X 幅** | 90.63 mm | 91.54 mm | -0.91 mm (1.0%) | +| **Y 高さ** | 91.41 mm | 92.13 mm | -0.72 mm (0.8%) | +| **X 中心**(評価板中心基準) | 0 mm | +0.98 mm | +0.98 mm | +| **Y 中心**(符号反転後) | -1.22 mm | -1.47 mm | -0.25 mm | + +→ **物理サイズ差 < 1%・中心位置差 < 1 mm で整合**.視覚化パネル `output/_slide_panels/roi_alignment_check.png`(①実機画像 + 評価板境界・実機 ROI / ②シミュ輝度マップ + 評価板境界・シミュ ROI / ③物理座標系での重ね合わせ)でも確認. + +- **解釈・学び**: + - **残差 2.63× の主因は ROI 整合ではない**ことが確定.D1 を候補リストから「クリア」として除外([TECH_10 § D1 検証結果](../05_TECH/TECH_10_残差主因候補の網羅整理.md#d1-検証結果2026-06-02-完了) 参照). + - 過去の設定者(Step 8 開始時の `scripts/select_white_board.py` GUI 選択 → シミュ側 ortho_scale 設計値)は **物理整合性を意識して ROI を揃えていた**ことが裏付けられた. + - **副次発見**: 評価板は実機画像中央から下方向に約 4.2 mm(65 px)オフセット配置されているが(カメラ取付位置 vs 評価板取付位置のミリ精度差),実機 ROI もそのオフセットに沿って選択されているため物理領域として整合. + - 設定変更ゼロで結論が出たため,シミュ/実機双方の YAML・コードへの **コミット可能な変更なし**.本記録のみ. +- **次のアクション**: 残る候補(β A1 単独 / α A8+A9 / δ β+α 全部入り)を選択して Step 8-14 を実施.[TECH_10 § Step 8-14 候補選定](../05_TECH/TECH_10_残差主因候補の網羅整理.md#step-8-14-候補選定次のアクション) 参照. + +### Step 8-14 候補 β: A1 内壁の非 Lambertian 性 — 混合 BSDF 感度分析 + +- **日付**: 2026-06-02 +- **位置付け**: [TECH_10](../05_TECH/TECH_10_残差主因候補の網羅整理.md) で当時 🥇 1 位だった **A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」** の感度分析.3D プリンタ白フィラメント物性整合性は一旦置き,weight × roughness の 2 軸 3×3 グリッドで「形」と数値がどう動くかを観察. +- **仮説**: 実物の 3D プリンタ白フィラメントは完全 Lambertian ではなく微妙な specular 成分を持つ可能性.これが残差 2.63× を説明するなら,weight を上げると CoV/勾配/C/P が改善方向(実機 0.0695 / 8.35% / 1.091 に近づく方向)に動くはず. +- **実装**: スキーマ拡張 (`BlendBSDFConfig` + `RoughConductorBSDFConfig`) + `materials.py` 再帰変換 + テンプレ YAML + sweep スクリプト + 比較パネル生成スクリプト.新規テスト 89 件追加 (既存 608 → 697 全通過). +- **設定**: `configs/scene_baseline_blend.yaml` ベース.`printer_white` を `blendbsdf` (`diffuse` R=0.85 + `roughconductor` material="none") に変更.`weight ∈ {0.05, 0.15, 0.30}` × `α ∈ {0.1, 0.3, 0.6}` の 9 ジョブ × spp=1024 × max_depth=8. +- **結果**(CoV / 勾配% / C/P / 動径 min/max.ベースライン純 diffuse: **0.1826 / 24.65 / 1.327 / 0.615**, 実機: **0.0695 / 8.35 / 1.091 / 0.860**): + +| weight \ α | 0.1(鋭い) | 0.3(中) | 0.6(粗い) | +| --- | --- | --- | --- | +| **0.05** | 0.192 / 24.8 / 1.33 / 0.62 | 0.189 / 24.9 / 1.33 / 0.62 | **0.185** / 24.4 / 1.32 / 0.62 ← 最良 | +| **0.15** | 0.213 / 26.0 / 1.35 / 0.61 | 0.204 / 26.6 / 1.36 / 0.60 | 0.193 / 25.5 / 1.34 / 0.61 | +| **0.30** | **0.243** / 26.8 / 1.37 / 0.59 ← 最悪 CoV | **0.227 / 29.0** / 1.41 / **0.57** ← 最悪 形 | 0.205 / 27.0 / 1.37 / 0.59 | + +→ **どの組み合わせも純 diffuse より悪化または不変**.実機方向 (CoV=0.0695) と **逆方向**に動く. +- **「形」の観察**(`output/_slide_panels/blend_bsdf_sweep.png`): + - w=0.05 行: ベースラインに近い Y 軸単調勾配(純 diffuse と区別困難) + - w=0.15 行: 下端中央に明るい集中点が現れ始める + - **w=0.30 行**: 下端中央に **2 つの明るいスポット**が顕在化.specular 反射が LED 直下の評価板領域に集中 + - α を上げると specular が「拡散的」になり diffuse に近づくが、実機の中央ピーク楕円パターンには近づかない +- **解釈・学び**: + - 純 Lambertian は cos 重み散乱で **多重反射の均一化作用が最大**.specular 成分は特定方向に光を集中させ均一化を阻害する + - 3D プリンタ白フィラメントは **完全 Lambertian として扱うのが実機挙動に近い近似** だと判明([TECH_09](../05_TECH/TECH_09_LEDデータシート調査結果.md) の LED Lambertian 整合性と並ぶ「物理モデル妥当性の裏付け」) + - **A1 は残差 2.63× の主因ではない**ことが確定.TECH_10 優先順位を更新(A1 を「クリア」に,A8/A9/B1 を繰り上げ).[TECH_10 § A1 検証結果](../05_TECH/TECH_10_残差主因候補の網羅整理.md#a1-検証結果2026-06-02-完了) 参照 + - 「最有力候補」だった A1 の除外は **次の候補に焦点を絞れる**正味前進.計算コスト 75.5 分 + 実装 2-3 時間で「やってみないと分からない仮説」を確実に潰せた +- **副次成果**: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入った.今後の物理モデル試行(例:thindielectric 評価板の薄板透過モデル,特殊壁面の光沢追加)でも再利用可能 +- **次のアクション**: A8 (反射率 R=0.85→0.89) を試す Step 8-14 候補 α,または A9 (max_depth 8→16+) を試す案,または両方の組み合わせを実施.[TECH_10 § Step 8-14 候補選定](../05_TECH/TECH_10_残差主因候補の網羅整理.md#step-8-14-候補選定次のアクション) 参照. + +### Step 8-14 候補 α 前半: A9 max_depth スイープ {8, 12, 16, 20, 28} + +- **日付**: 2026-06-03 +- **位置付け**: TECH_10 で 🥈 2 位だった **A9「max_depth 8 → 16+」** の感度分析.「反射回数が少ないものの影響は強いが,0.85 の 8 乗 ≈ 0.27 で 0.01 まで続けるべき」というユーザー仮説を 5 値スイープで検証. +- **仮説**: 現状 max_depth=8 では多重反射の累積寄与の **理論累積 100% (R/(1-R)=5.67) に対して 72.7% しか取り込めていない**.残り 27% を打ち切っており,これが形状指標(特に勾配と動径 min/max)を歪めている. + +| max_depth | 最終 bounce 寄与 R^n | 累積 R·(1-R^n)/(1-R) | 理論累積に対する割合 | +| --- | --- | --- | --- | +| 8(現状) | 0.273 | 4.12 | **72.7%** | +| 12 | 0.142 | 4.87 | 85.8% | +| 16 | 0.074 | 5.25 | 92.6% | +| 20 | 0.039 | 5.45 | 96.1% | +| **28** | **0.013** | **5.60** | **98.7%** | + +- **実装**: `scripts/sweep_max_depth.py` で `configs/scene_baseline.yaml` の `render.max_depth` のみを書き換えた 5 つの一時 YAML を生成 → sm-render → sm-evaluate (`configs/eval_baseline_dng_target.yaml`, linear + mean アンカー 207.82).約 85 分. +- **結果**: 全 4 形状指標が **単調に実機方向に改善**: + +| max_depth | mean | CoV | 勾配% | C/P | 動径 min/max | +| --- | --- | --- | --- | --- | --- | +| **8** | 207.82 | 0.1817 | 24.01 | 1.316 | 0.628 | +| 12 | 207.82 | 0.1651 | 21.35 | 1.272 | 0.671 | +| 16 | 207.82 | 0.1604 | 20.59 | 1.259 | 0.683 | +| 20 | 207.82 | 0.1589 | 20.35 | 1.256 | 0.687 | +| **28** | 207.82 | **0.1583** | **20.24** | **1.254** | **0.689** | +| **実機 TARGET** | — | **0.0695** | **8.35** | **1.091** | **0.860** | + +- **残差縮小率 (d=8 → 28)**: + +| 指標 | d=8 | d=28 | 縮小率 = (d8 − d28) / (d8 − TARGET) | +| --- | --- | --- | --- | +| 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%** | + +- **収束観察**: 実用的な収束は **d=16 で達成**(d=16 → 28 で CoV 1.3% のみ動く).理論累積 (R^n) より早く収束.これは **「直接光と初期反射が支配的,後段の累積はわずかな ambient しか足さない」** という物理的直感と整合. +- **「形」の観察**([`output/_slide_panels/max_depth_sweep_shape.png`](#) 2 段比較 + `max_depth_sweep_relative.png` mean=1 正規化): + - 上段 共有スケール: 全 5 画像とも **下端中央が明るく上端が暗い Y 軸単調勾配**で、 contrast が d=8→28 で緩む + - 下段 独立スケール(各画像 1%-99% 自己 contrast 最大化): 全 5 画像とも **同じ形を異なる contrast で再現** → **形は完全に不変** + - mean=1 正規化: d=8 で [0.3〜1.3]、d=28 で [0.4〜1.2] と **相対輝度の範囲が狭まる**(ambient 化が進む)が,**空間パターン (Y 単調勾配) は不変** +- **解釈・学び**: + - **「精度(数値指標)」は明確に改善**.ユーザー仮説「max_depth を増やすべき」は方向性として正しく,d=8 → 28 で残差を 21〜28% 縮められた + - **「形」は変わらない**.多重反射の累積は **空間的にほぼ均一な ambient 成分**であり、これを足しても暗い領域と明るい領域の **絶対差は縮むが空間配置は不変**.「LED が下にしかなく評価板の中央〜上端は内壁経由でしか光が届かない」という **配置と幾何に由来する非対称性は除去できない** + - これは TEST_03 の主張「Step 8-1〜8-7 で形は全く変わっていない」と整合.**max_depth スイープも同じパターン**で,「均一化フィルタ系の改善は数値には効くが形には効かない」が再確認された + - **新キャリブベースラインは d=16 で十分**(d=28 と CoV 1.3% 差,レンダ時間 17 分 vs 30 分).「物理整合の極致」を求めるなら d=28 + - **A9 は数値改善には有効だが形改善の主因ではない**ことが確定.TECH_10 優先順位を更新(A9 を「数値改善のみクリア」化,A8 を 🥇 1 位継続,**形改善の本筋候補は B1 幾何精度に集約**) +- **副次成果**: `scripts/sweep_max_depth.py` + `scripts/build_max_depth_panel.py`(収束カーブ + 数値表 + 輝度マップ)+ shape 比較スクリプトのテンプレ.今後 spp など他のレンダパラメータ感度分析にも流用可能 +- **次のアクション**: + - 数値改善目的: d=16 を新キャリブベースラインに採用(要検討) + - 形改善目的: **B1 筐体幾何精度の検討**(3D プリンタの実物理表面の偏り・LED 軸方向のティルト仮説等) + - A8 反射率 R=0.89 も次の試行候補(数値改善は予想,形改善は予想薄) + +### Step 8-14 候補 α 後半: A8 反射率 5 値スイープ R∈{0.75, 0.80, 0.85, 0.89, 0.92} + +- **日付**: 2026-06-03 +- **位置付け**: Step 8-14 候補 α の後半.**新指標 (shape_metrics) を初めて感度分析に投入**して,反射率変化が「数値」と「形」のどちらに効くかを定量検証. +- **仮説**: + 1. 既存 4 指標 (CoV/勾配/C/P/動径 min/max) は R 上昇で実機方向に改善(多重反射 ambient ゲイン) + 2. 新指標 angular_ratio (0=完全同心円, 1=完全角度依存) は不変または同心円方向(実機の中央ピーク楕円は同心円的) + 3. SSIM/NCC は不変(shape_reference 無指定なので NaN) +- **実装**: `scripts/sweep_reflectance.py` で `configs/scene_baseline.yaml` (d=16 新基準) の `materials.printer_white.reflectance` のみを書き換え.評価は `configs/eval_baseline_dng_target_shape.yaml` (新指標含む).約 85 分. +- **結果**: + +| R | mean | CoV | 勾配% | C/P | rmin/max | **angular_ratio** | centroid_y_norm | eccentricity | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 0.75 | 207.82 | 0.1861 | 24.67 | 1.327 | 0.620 | **0.4079** | 0.0453 | 0.444 | +| 0.80 | 207.82 | 0.1735 | 22.67 | 1.293 | 0.649 | **0.4282** | 0.0428 | 0.436 | +| **0.85** | 207.82 | **0.1604** | 20.59 | 1.259 | 0.684 | **0.4536** | 0.0400 | 0.426 | +| **0.89** | 207.82 | **0.1501** | 18.96 | 1.234 | 0.710 | **0.4761** | 0.0377 | 0.419 | +| 0.92 | 207.82 | **0.1424** | 17.72 | 1.215 | 0.731 | **0.4938** | 0.0358 | 0.414 | +| **実機 TARGET** | — | **0.0695** | **8.35** | **1.091** | **0.860** | **小予想** | **0** | — | + +- **★ 形状指標見直しの初の定量実証 ★**: + +| 指標 | R 0.75→0.92 の動き | 実機方向か | +| --- | --- | --- | +| CoV | 0.186 → 0.142 で **改善 24%** | ✓ | +| 勾配 | 24.7 → 17.7 で **改善 28%** | ✓ | +| C/P | 1.327 → 1.215 で **改善** | ✓ | +| rmin/max | 0.620 → 0.731 で **改善** | ✓ | +| **angular_ratio** | **0.408 → 0.494 で増加 21%** | **✗ 逆方向(実機は同心円的)** | +| centroid_y_norm | 0.045 → 0.036 で減少 | ✓ わずか | +| eccentricity | 0.444 → 0.414 で減少 | ✓ わずか | + +→ **既存 4 指標が「改善」と判定する一方で、新指標 angular_ratio は「形は実機から遠ざかる」と判定**.[TECH_11](../05_TECH/TECH_11_形状指標の見直しと新指標導入.md) で予想していた「数値の偽物の一致」が **現実に起きていることの初の定量証拠**. + +- **物理的解釈**: + - R 上昇 → ambient 成分増加 → 半径方向の偏り(評価板下端 vs 上端の差)が圧縮される(→ CoV/勾配/rmin/max 改善) + - しかし角度方向のばらつき(X 方向の波打ち等)は **相対的に圧縮されにくい** + - 結果として「半径 var / 角度 var」のバランスが崩れ、angular_ratio が **増加** + - つまり R 上昇は **「Y 単調勾配の contrast を緩める代わりに角度方向のムラを相対的に強調する」**.実機の「中央ピーク楕円(=同心円的)」とは **形が遠ざかる** +- **解釈・学び**: + - **「均一化フィルタ系の改善は数値には効くが形には効かない」を超えて、「数値改善が形を逆方向に動かす」ケースの実証**.これは A1/A9 検証より強い発見 + - **A8 も A1/A9 と同様に「数値改善のみ・形改善ではない」**と判定.むしろ形は逆効果 + - TECH_10 優先順位を再更新: A8 を「数値改善のみクリア」化.**形改善の本筋は B1 幾何精度に集約**(これでほぼ確定) + - **TECH_11 の評価方針(数値 + 形 + 視覚の 3 軸)の妥当性が定量データで裏付けられた**.今後の試行で新指標必須 + - 副次成果: 反射率スイープのテンプレ + 新指標統合評価パイプラインが整備された +- **再現性確認**: R=0.85 の CoV=0.1604 は max_depth スイープ d=16 (CoV=0.1604) と **完全一致** ← 別 sweep 間の数値整合性 OK +- **ユーザー仮説の評価**: 「反射率を変えて結果を見たい」要請は重要な実証実験として完了.結果として: 数値だけ見れば R=0.92 が最良 (CoV 0.1424) と判定されるが、新指標も合わせて見ると **R=0.75 が最も同心円的(実機方向に近い形)**.**新指標を導入していなければ気づけない発見**であり、形状指標見直しの **正当性が定量データで裏付けられた** +- **次のアクション**: + - **数値改善のみが目的**なら R=0.89(Step 8-6 物理整合点)または R=0.92 採用候補(要検討) + - **形改善目的**: B1 筐体幾何精度 / A10 サブサーフェス散乱 / ζ LED ティルト など、構造的に光分布を再配分する候補に進む + - 既存 max_depth スイープを新指標で再評価して angular_ratio の同様パターンをクロスチェック(オプション) + +### 俯瞰サマリへの 8-14 α 後半追記 + +| Step | 設定 | 空間 | mean | CoV | 勾配% | C/P | CoV 残差倍率 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 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 で方針確定) > **2026-06-02 注記**: 本セクションは当時「実機側を変更しない前提」で「シミュ側に LSC モデル化を追加して残差を埋める」案として立案されていた.[Step 8-13](#step-8-13-新方針-実機-dng--lsc-linear-でのシミュ突合) で **実機側に LSC 順方向適用** するアプローチが MiniTias-Evaluation で確立した結果,**本案は不採用**となった.以下は歴史的記録として残す.詳細な却下理由は [TECH_07 §検討した代替案](../05_TECH/TECH_07_LSC取り扱い方針.md) 参照. diff --git "a/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" "b/docs/06_TEST/TEST_02_\343\202\267\343\203\237\343\203\245\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\345\256\237\350\241\214\350\250\230\351\214\262.md" index 6137e0c..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" @@ -21,7 +21,7 @@ | ディレクトリ | 関連 Step | シーン設定 | 評価設定 | 主要指標 (mean / CoV / 勾配% / C/P) | 用途・参照元 | | --- | --- | --- | --- | --- | --- | -| `output/baseline/` | 8-1 確定基準 | `configs/scene_baseline.yaml` (R=0.85, d=8, spp=1024) | — (レンダのみ) | luminance.npy + render_uint8.png | 確定基準の生レンダ.他の評価の入力ソース | +| `output/baseline/` | 8-1 確定基準(**注: d=8 時代の古い結果**) | 当時の `configs/scene_baseline.yaml` (R=0.85, **d=8**, spp=1024) | — (レンダのみ) | luminance.npy + render_uint8.png | **生成時刻 2026-05-18 で当時の d=8 で計算**.2026-06-03 に scene_baseline.yaml が d=16 へ更新済のため、現状の設定とは不整合.必要時は新基準 (d=16) で再生成すべき | | `output/baseline_linear_npy/` | 8-1 比較基準 | (同上 luminance.npy 入力) | `configs/eval_baseline_linear.yaml` (OETF off) | 152.02 / 0.1826 / 24.65 / 1.327 | TEST_01 Step 8-7 で size 1.5×1.5 と比較する基準.輝度マップ追跡 (TEST_03) のパネル左下 | | `output/eval_oetf_r085/` | 8-3 OETF 後 | (同上 luminance.npy 入力) | `configs/eval_baseline.yaml` (OETF on) | 146.71 / 0.0907 / 11.16 / 1.126 | OETF 適用効果の代表結果.輝度マップ追跡 (TEST_03) パネル右上 | | `output/emitter_real/` | 8-7 検証 | `configs/scene_baseline_emitter_real.yaml` (size 1.5×1.5, radiance 12227, Φe 保存) | `configs/eval_baseline_linear.yaml` (OETF off) | 429.99 / 0.1863 / 26.21 / 1.355 | 点光源近似検証.形状指標 ≈ baseline_linear_npy で実証.輝度マップ追跡 (TEST_03) パネル右下 | @@ -32,6 +32,26 @@ | `output/baseline_datasheet/` | 8-11 データシート従順版 | `configs/scene_baseline_datasheet.yaml`(spot cutoff=85/beam=26.72, I_0=0.0220 W/sr,反射率 0.89, データシート規格値準拠) | `configs/eval_baseline_linear.yaml` (linear + normalization) | 146.71 / 0.1829 / 26.79 / 1.366 | TEST_01 Step 8-11 検証.データシート準拠は物理正確性のみ達成,形は LSC が依然必要.α=14.30 | | `output/perspective_path_hi/` | 8-12 Helmholtz 検証 | `configs/scene_baseline_perspective_path.yaml`(perspective fov=90°, path, scalar_rgb, spp=4096) | (評価なし,検証目的) | mean=160, bot/top=2.035 | TEST_01 Step 8-12 検証.方向独立性の基準(バックワード) | | `output/perspective_ptracer_hi/` | 8-12 Helmholtz 検証 | `configs/scene_baseline_perspective_ptracer.yaml`(perspective fov=90°, ptracer, scalar_rgb, spp=4096) | (評価なし,検証目的) | mean=319, bot/top=2.006 | TEST_01 Step 8-12 検証.方向独立性の対象(フォワード).形は path と一致(bot/top 比 誤差 1.4%) | +| `output/blend_sweep/w{w}_a{a}/` (9 ディレクトリ) | 8-14 候補 β | `configs/scene_baseline_blend.yaml` を `scripts/sweep_blend_bsdf.py` で `printer_white.weight` ∈ {0.05, 0.15, 0.30} × `components[1].alpha` ∈ {0.1, 0.3, 0.6} に書き換えた 9 種 (R=0.85 + roughconductor material=none, d=8, spp=1024) | `configs/eval_baseline_dng_target.yaml` (linear + mean アンカー 207.82) | 9 ジョブ分の CoV/勾配 — [TEST_01 § Step 8-14 候補 β 結果表](TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-β-a1-内壁の非-lambertian-性--混合-bsdf-感度分析) 参照 | TEST_01 Step 8-14 候補 β 検証.A1 が実機方向に作用しないことを確定 | +| `output/blend_sweep/summary.csv` | 8-14 候補 β | — | — | 9 ジョブ集約 CSV (weight, alpha, elapsed_s + 12 指標カラム) | `scripts/sweep_blend_bsdf.py` の集約出力.`scripts/build_blend_bsdf_panel.py` の入力 | +| `output/_slide_panels/blend_bsdf_sweep.png` | 8-14 候補 β | — | — | 3×3 輝度マップ比較パネル (shared vmin/vmax 1%/99% percentile) | TEST_01 Step 8-14 β「形」追跡用 | +| `output/_slide_panels/blend_bsdf_metrics.png` | 8-14 候補 β | — | — | 9 ジョブの数値表 (CoV/勾配/C/P/動径 min/max) | TEST_01 Step 8-14 β 数値追跡用 | +| `output/led_group_sanity/{bottom,right,left}/` | LED sanity check | `configs/scene_baseline.yaml` から lights を各 LED 基板(6 個ずつ)のみに絞った一時 YAML × 3 種 (d=8, spp=1024) | (評価なし,目視確認) | mean: bottom=98.82 / right=47.10 / left=47.08 | [TEST_07 LED グループ別動作確認](TEST_07_LEDグループ別動作確認.md) — 方向性・対称性・線形性の sanity check | +| `output/_slide_panels/led_group_sanity{,_focus,_right_left}.png` | LED sanity check | — | — | 1×3 / 1×2 / 6 列の比較パネル | TEST_07 視覚確認用 | +| `output/max_depth_sweep/d{8,12,16,20,28}/` (5 ディレクトリ) | 8-14 候補 α 前半 | `configs/scene_baseline.yaml` を `scripts/sweep_max_depth.py` で `render.max_depth` のみ書き換えた 5 種 (R=0.85, spp=1024) | `configs/eval_baseline_dng_target.yaml` (linear + mean アンカー 207.82) | d=28 で CoV=0.1583, 勾配=20.24%, C/P=1.254, 動径 min/max=0.689 — [TEST_01 § Step 8-14 候補 α 前半 結果表](TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-前半-a9-max_depth-スイープ-8-12-16-20-28) 参照 | TEST_01 Step 8-14 候補 α 前半 検証.**A9 は数値改善のみで形改善ではない**ことを確定.d=16 が新キャリブベースライン候補 | +| `output/max_depth_sweep/summary.csv` | 8-14 候補 α 前半 | — | — | 5 ジョブ集約 CSV (max_depth, elapsed_s + 12 指標カラム) | `scripts/sweep_max_depth.py` の集約出力.`scripts/build_max_depth_panel.py` の入力 | +| `output/_slide_panels/max_depth_sweep.png` | 8-14 候補 α 前半 | — | — | 1×5 輝度マップ比較パネル (shared vmin/vmax) | TEST_01 数値追跡用 | +| `output/_slide_panels/max_depth_convergence.png` | 8-14 候補 α 前半 | — | — | 収束カーブ 2×2 (CoV/勾配/C/P/動径 min/max vs max_depth) | TEST_01 収束観察用 | +| `output/_slide_panels/max_depth_sweep_shape.png` | 8-14 候補 α 前半 | — | — | 上下 2 段比較 (上=共有スケール / 下=独立スケール 自己 contrast 最大化) | TEST_01 「形」追跡用 — 形不変の視覚証拠 | +| `output/_slide_panels/max_depth_sweep_relative.png` | 8-14 候補 α 前半 | — | — | 1×5 mean=1 正規化 (相対輝度 1.0=画像平均) | TEST_01 ambient 化観察用 | +| `output/reflectance_sweep/r{075,080,085,089,092}/` (5 ディレクトリ) | 8-14 候補 α 後半 | `configs/scene_baseline.yaml` (d=16 新基準) を `scripts/sweep_reflectance.py` で `materials.printer_white.reflectance` のみ {0.75, 0.80, 0.85, 0.89, 0.92} に書き換えた 5 種 (spp=1024) | `configs/eval_baseline_dng_target_shape.yaml` (新指標 5 つ含む) | R=0.92 で CoV=0.1424, 勾配=17.72%, angular_ratio=0.4938 — [TEST_01 § Step 8-14 候補 α 後半 結果表](TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-α-後半-a8-反射率-5-値スイープ-r075-080-085-089-092) 参照 | TEST_01 Step 8-14 候補 α 後半 検証.**「数値の偽物の一致」を初の定量実証**.A8 数値改善のみクリア.**新指標 (shape_metrics) を初投入**したスイープ | +| `output/reflectance_sweep/summary.csv` | 8-14 候補 α 後半 | — | — | 5 ジョブ集約 CSV (reflectance + 既存 12 指標 + 新 12 指標) | `scripts/sweep_reflectance.py` の集約出力 | +| `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 と一致確認用) @@ -63,6 +83,16 @@ | baseline_datasheet 評価 | `uv run sm-evaluate --image output/baseline_datasheet/luminance.npy --config configs/eval_baseline_linear.yaml --output output/baseline_datasheet/` | | perspective_path_hi レンダ (Step 8-12 検証) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/scene_baseline_perspective_path.yaml --output output/perspective_path_hi/` | | perspective_ptracer_hi レンダ (Step 8-12 検証) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run sm-render --scene configs/scene_baseline_perspective_ptracer.yaml --output output/perspective_ptracer_hi/` | +| blend BSDF 3×3 スイープ (Step 8-14 β) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sweep_blend_bsdf.py` (9 ジョブ × 約 8.4 分 = 75.5 分) | +| blend BSDF 比較パネル生成 | `uv run python scripts/build_blend_bsdf_panel.py` (要 `output/blend_sweep/summary.csv` + 各 `luminance.npy`) | +| LED グループ別 sanity check (TEST_07) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sanity_led_groups.py` (3 ジョブ × 約 8.5 分 = 約 25 分) | +| LED sanity check 比較パネル生成 | `uv run python scripts/build_led_group_sanity_panel.py` (要 `output/led_group_sanity/{bottom,right,left}/luminance.npy` + `output/baseline/luminance.npy`) | +| max_depth 5 値スイープ (Step 8-14 α 前半) | `$env:DRJIT_LIBLLVM_PATH = "C:\Program Files\LLVM\bin\LLVM-C.dll"; uv run python scripts/sweep_max_depth.py` (5 ジョブ × 8.5〜30 分 = 約 85 分) | +| 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/docs/06_TEST/TEST_07_LED\343\202\260\343\203\253\343\203\274\343\203\227\345\210\245\345\213\225\344\275\234\347\242\272\350\252\215.md" "b/docs/06_TEST/TEST_07_LED\343\202\260\343\203\253\343\203\274\343\203\227\345\210\245\345\213\225\344\275\234\347\242\272\350\252\215.md" new file mode 100644 index 0000000..5ca1db7 --- /dev/null +++ "b/docs/06_TEST/TEST_07_LED\343\202\260\343\203\253\343\203\274\343\203\227\345\210\245\345\213\225\344\275\234\347\242\272\350\252\215.md" @@ -0,0 +1,163 @@ +# LED グループ別動作確認 (LED Group Sanity Check) + +本ドキュメントは **シミュレーションが下/右/左の 3 つの LED 基板を物理位置どおりに独立にモデル化できているか** を,各グループの LED だけを ON にした輝度マップの形と数値で検証した結果のレポートである.Step 8 系キャリブの主軸ではなく **シミュ動作の基礎健全性確認** として位置付ける. + +## 目的・背景 + +**動機**: Step 8 系で「実機 vs シミュの残差 2.63×」を追っている過程で,シミュレーション側の動作が物理どおりに動いているかの確認が不十分だった.特に: + +- LED は実機で **3 つの基板(下/右/左)に分散配置**されているが,シミュではこれを正しく扱えているか +- 各基板から出る光の **方向性** が物理どおりに評価板に届いているか +- **線形性**(各 LED の光は独立に重ね合わさるはず)が成り立っているか + +これらは「実機との残差は何が主因か」を議論する前提として担保すべき性質. + +## 検証手順 + +1. `configs/scene_baseline.yaml` の lights から各グループの LED だけを残した一時 YAML を生成 +2. 各で `sm-render` を実行(spp=1024, max_depth=8) +3. 生 linear 輝度マップ(`luminance.npy`)を取得して 1×3 並列比較パネルを生成 + +| グループ | 残した LED | LED 数 | +| --- | --- | --- | +| `bottom` | `led_bottom_0` 〜 `led_bottom_5` | 6 個 | +| `right` | `led_right_0` 〜 `led_right_5` | 6 個 | +| `left` | `led_left_0` 〜 `led_left_5` | 6 個 | + +スクリプト: `scripts/sanity_led_groups.py`(3 ジョブ順次実行)+ `scripts/build_led_group_sanity_panel.py`(6 列パネル + 線形性誤差)+ 拡大確認用の 1×3 / 1×2 パネル生成. + +成果物: +- `output/led_group_sanity/{bottom,right,left}/luminance.npy` +- `output/_slide_panels/led_group_sanity.png` (6 列: ①〜③ 単独 / ④ 全 ON / ⑤ 和 / ⑥ 線形性誤差) +- `output/_slide_panels/led_group_sanity_focus.png` (1×3 拡大) +- `output/_slide_panels/led_group_sanity_right_left.png` (1×2 左右対称性確認) + +## 結果 — 数値 + +| グループ | mean | max | min | グループ単独で見える最強点 | +| --- | --- | --- | --- | --- | +| ① bottom (6 LED) | **98.82** | 157.77 | 0.00 | 評価板の下端中央 | +| ② right (6 LED) | 47.10 | 81.42 | 0.00 | 評価板の右側面寄り(やや上下に分散) | +| ③ left (6 LED) | 47.08 | 84.03 | 0.00 | 評価板の左側面寄り(やや下寄り) | + +→ **right と left の mean が一致 (47.10 vs 47.08)** で **左右対称性 ✓**.**bottom が right/left の 2.10× で強い**. + +## 結果 — 「形」の観察 + +3 つの輝度マップは形が明確に異なる: + +| グループ | 形の特徴 | +| --- | --- | +| **bottom** | 評価板の **下端中央が縦長の島状に明るい**.Y 方向単調勾配(下が明るく上が暗い).直接光が支配的 | +| **right** | **評価板の中心寄りに円状のホットスポット**+上下成分も強い.右側面寄りが直接光,中央が反射光 | +| **left** | right と類似(中心円 + 上下成分)だが Y 方向の重心がやや下寄り.完全な左右対称ではない | + +→ ユーザー観察「bottom は上下の島,right/left は中心の円」は **物理的に LED 配置に対応** した正しい観察. + +## LED 配置と「形」の物理的対応 + +`configs/scene_baseline.yaml` の LED 座標から,形の違いを物理的に説明する: + +| グループ | LED 中心座標 | 評価板との幾何関係 | +| --- | --- | --- | +| **bottom** | X∈[-10, +18], Y=**-71**, Z=-66.1 | 評価板下端 (Y=-51) より **20 mm 下**.**X 方向に密集**([-10, +18] の 27 mm 幅) | +| **right** | X=**+66**, Y∈[-25, +3], Z=-66.0 | 評価板右端 (X=+49.5) より外側 **16.5 mm**.**Y 方向に分散**([-25, +3] の 27 mm 幅,**評価板 Y 中央付近**) | +| **left** | X=**-66**, Y∈[-33, -5], Z=-66.0 | 評価板左端 (X=-49.5) より外側 16.5 mm.**Y 方向に分散**([-33, -5] の 27 mm 幅,**評価板 Y 中央やや下**) | + +評価板: X∈[-49.5, +49.5], Y∈[-51, +51], 中心 Z≈-4.5.全 LED の光軸は +Z 方向(評価板向き). + +### bottom = 「下端の縦長の島」 + +LED が **評価板の下端の真下に X 方向で密集**.直接光は評価板の **下端中央を縦長に照らす**.LED の X 範囲 [-10, +18] が評価板 X 範囲 [-49.5, +49.5] のごく中央部に収まるため,X 方向の幅は狭い(島状)が,Y 方向には下端から評価板全体に届く.Y 方向単調勾配が支配的. + +### right/left = 「中心の円 + 上下成分」 + +LED が **評価板の側面外側に Y 方向で分散**.LED の Y 範囲 [-25, +3] は評価板 Y 範囲 [-51, +51] のうち **中央寄り**.これにより: + +- **「上下成分」** = Y 方向に 27 mm 幅で LED が並ぶため,評価板の中央〜下寄りに光が直接届く(Y 方向に幅広く照らす) +- **「中心の円」** = LED が **評価板中心の高さ** に集中していると,直接光が評価板の側面に当たる + 内壁拡散反射で **評価板中央付近** に集中して見える円形ホットスポット + +### bottom が right/left の 2.1× 強い理由(ユーザー仮説の検証) + +ユーザー仮説「下成分の影響力が強い = 反射少なめで当たる量が多い」は **物理的に正しい**.主因は以下の幾何的効率の違い: + +#### 要因 1: LED 〜 評価板中心への到達経路の角度 + +LED から評価板中央(X=0, Y=0, Z=-4.5)への直線方向の **+Z 軸(光軸)からの角度**: + +| グループ | LED 代表位置 | 評価板中央への方向 | +Z からの角度 | +| --- | --- | --- | --- | +| bottom (X≈+4, Y=-71, Z=-66) | (+4, -71, -66) → (0, 0, -4.5) | (-4, +71, +61.5) | **arctan(√(4²+71²)/61.5) ≈ 49°** | +| right (X=+66, Y≈-11, Z=-66) | (+66, -11, -66) → (0, 0, -4.5) | (-66, +11, +61.5) | **arctan(√(66²+11²)/61.5) ≈ 47°** | + +意外にも **角度はほぼ同じ(49° vs 47°)**.これだけでは説明できない. + +#### 要因 2: 評価板への「立体角」と直接光の到達割合 + +bottom LED は評価板の **真下 (Y 軸 +Z 方向)** に集中している → 評価板の **広い範囲が立体角内**に入る.Lambertian 配光は +Z 方向に最大の放射輝度で、評価板の **広範囲を直接照射**できる. + +一方 right/left LED は評価板の **側面外側**に位置 → 直接光は評価板の **側面の端部しか届かない**.評価板の中央〜反対側まで光を運ぶには **内壁多重反射が必要**.多重反射は反射率 R=0.85 で減衰し、各バウンスで cos 重みで散乱するので **直接光の効率より大幅に下がる**. + +#### 要因 3: 評価板への近接効果 + +| グループ | LED 代表点 → 評価板裏面中心 (0,0,-4.5) の距離 | +| --- | --- | +| bottom (+4, -71, -66) | √(4² + 71² + 61.5²) = **94 mm** | +| right (+66, -11, -66) | √(66² + 11² + 61.5²) = **91 mm** | + +距離自体はほぼ同じ.光量の差は距離ではなく **「直接光で評価板全体を照らせるか」** の幾何効率に由来. + +#### 結論 + +**bottom は評価板の真下から直接照射型** であり,**right/left は側面照射+多重反射型** という構造的な違いがある.bottom の 2.10× の優位性は: + +- 直接光の到達効率の違い(評価板の広い領域を直接光が叩く) +- 多重反射のロス係数(R=0.85 のバウンスごとの減衰) + +これは実機の照明設計としても **意図したもの**(bottom LED の方が「主光源」に近い役割)と考えられる. + +## 線形性違反の検出と原因 + +3 グループの輝度マップを足し合わせると、本来 Mitsuba の path tracer は **厳密に線形**なので「①+②+③ = ④(全 18 個 ON)」が成り立つはず.しかし数値は: + +| 項目 | mean | +| --- | --- | +| ① bottom | 98.82 | +| ② right | 47.10 | +| ③ left | 47.08 | +| **①+②+③ の和** | **193.00** | +| ④ 既存 `output/baseline/luminance.npy` の全 ON | **139.98** | +| **相対誤差 mean** | **+37.87%** | + +→ **比較対象の `output/baseline/luminance.npy` の生成時刻が `2026-05-18`(Step 8-1 時点)**であり、現在の `configs/scene_baseline.yaml`(Step 8-9 で LED 位置を Fusion STL から再抽出して更新済)と **不整合**であることが判明. + +| ファイル | 生成時刻 | 対応する scene 設定 | +| --- | --- | --- | +| `output/baseline/luminance.npy` | 2026-05-18 (Step 8-1) | 旧 LED 位置(等間隔 8.7 mm) | +| `output/baseline_v3_leds/luminance.npy` | 2026-05-28 (Step 8-9) | 現在の LED 位置(3+ギャップ+3) | + +線形性自体の検証は、最新シーンで baseline を再生成して再比較する必要がある(本 sanity check の範囲外).Mitsuba は理論的に線形なので **再生成すれば一致するはず**. + +## sanity check の結論 + +| 観点 | 結論 | +| --- | --- | +| **方向性**(各単独 ON で光が LED 基板側から来ている) | ✓ bottom 単独で下端中央、right/left 単独で側面寄りが明るい | +| **対称性**(right と left の対称) | ✓ mean がほぼ同じ (47.10 vs 47.08)、形も対称(Y 中心オフセットを除く) | +| **量的バランス**(bottom が right/left より強い理由) | ✓ 物理的に整合(直接光効率の違い) | +| **線形性**(厳密な数値合致) | ✗ 既存 baseline が古いため未検証.Mitsuba は理論線形なので再生成で一致すると予想 | + +→ **シミュレーションは 3 つの照明源を物理位置どおりに独立にモデル化できている**.Step 8 系の残差 2.63× を議論する前提は担保された. + +## 副次発見 + +- 実機の右基板と左基板は **Y 方向の取り付け位置が微妙に異なる**(right: Y∈[-25, +3] / left: Y∈[-33, -5]).**完全な左右対称設計ではない**.これは [Step 8-9 LED 位置正確化](TEST_01_キャリブレーション検証履歴.md#step-8-9-led-位置を-fusion-360-設計-stl-から抽出して反映) で Fusion STL から実位置を抽出した結果と整合. +- `output/baseline/luminance.npy` が **Step 8-1 時代のまま放置**されていたことが発覚.今後の比較で混乱を招く可能性があるため、再生成または明示的に古い旨を記載すべき. +- LED の光軸は全グループで +Z 方向で統一されている(実機の基板も全て評価板向きで取り付け).配光モデルが Lambertian なので、評価板への到達効率は **LED 〜 評価板の幾何関係**に支配される. + +## 関連ドキュメント (Related Documents) + +- [TEST_01 キャリブレーション検証履歴](TEST_01_キャリブレーション検証履歴.md) — 特に [Step 8-9 LED 位置正確化](TEST_01_キャリブレーション検証履歴.md#step-8-9-led-位置を-fusion-360-設計-stl-から抽出して反映) で現在の LED 配置の出典 +- [TEST_02 シミュレーション実行記録](TEST_02_シミュレーション実行記録.md) — `output/led_group_sanity/` のアーティファクト索引 +- [SPEC_03 基準設計の物理構成 §LED 光源](../04_SPEC/SPEC_03_基準設計の物理構成.md#led光源) — LED 物性(radiance, 配光) +- 生成スクリプト: `scripts/sanity_led_groups.py` / `scripts/build_led_group_sanity_panel.py` diff --git a/pyproject.toml b/pyproject.toml index f7f7abb..b2230de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ "pyvista>=0.45", "pyyaml>=6.0.3", "rawpy>=0.21.0", + "scikit-image>=0.21.0", "trimesh>=4.12.1", ] diff --git a/scripts/build_blend_bsdf_panel.py b/scripts/build_blend_bsdf_panel.py new file mode 100644 index 0000000..f0a4c4a --- /dev/null +++ b/scripts/build_blend_bsdf_panel.py @@ -0,0 +1,418 @@ +"""Step 8-14 blendbsdf スイープ結果の比較パネルを生成する. + +sweep_blend_bsdf.py の実行後に本スクリプトを実行すること. + +生成するファイル: + output/_slide_panels/blend_bsdf_sweep.png -- 3×3 輝度マップパネル + output/_slide_panels/blend_bsdf_metrics.png -- 数値指標表パネル + +共有 vmin/vmax: + 全 9 ジョブ + ベースライン純 diffuse の輝度マップを対象として + 1%/99% パーセンタイルを計算し,全セルで共通スケールを使う. + これにより「色の濃淡が画像間で物理的に同じ意味を持つ」. + +使い方: + uv run python scripts/build_blend_bsdf_panel.py + +前提: + - scripts/sweep_blend_bsdf.py が完了していること + - output/blend_sweep/summary.csv が存在すること + - output/baseline/luminance.npy が存在すること(ベースライン) + +TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +""" + +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor + +# --------------------------------------------------------------------------- +# 定数 +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] + +SUMMARY_CSV = ROOT / "output" / "blend_sweep" / "summary.csv" +OUTPUT_DIR = ROOT / "output" / "_slide_panels" +BASELINE_NPY = ROOT / "output" / "baseline" / "luminance.npy" +BLEND_OUTPUT_BASE = ROOT / "output" / "blend_sweep" + +# ROI 設定(configs/eval_baseline.yaml / eval_baseline_dng_target.yaml と整合) +SIM_ROI = {"x": 24, "y": 37, "w": 464, "h": 450} + +# mean 正規化の target_mean(eval_baseline_dng_target.yaml の target_mean と整合) +TARGET_MEAN = 207.82 + +# 共有 colorbar のパーセンタイル +VMIN_PERCENTILE = 1.0 +VMAX_PERCENTILE = 99.0 + +# スイープグリッドの定義順(行列インデックスの順序) +WEIGHTS = [0.05, 0.15, 0.30] +ALPHAS = [0.1, 0.3, 0.6] + +# 数値指標テーブルで表示するカラムとラベル +METRICS_DISPLAY = [ + ("cov", "CoV"), + ("gradient_magnitude", "gradient [%]"), + ("center_periphery_ratio", "C/P"), + ("radial_min_max_ratio", "radial min/max"), +] + + +# --------------------------------------------------------------------------- +# ヘルパ関数 +# --------------------------------------------------------------------------- + + +def _format_param(value: float) -> str: + """パラメータ値をファイル名に使える文字列に変換する.""" + return str(value) + + +def _make_job_name(weight: float, alpha: float) -> str: + """ジョブ識別子を生成する(sweep_blend_bsdf.py と同一フォーマット).""" + return f"w{_format_param(weight)}_a{_format_param(alpha)}" + + +def _extract_roi(arr: np.ndarray, roi: dict) -> np.ndarray: + """ROI 矩形を切り出して float64 に変換する.""" + x, y, w, h = roi["x"], roi["y"], roi["w"], roi["h"] + return arr[y: y + h, x: x + w].astype(np.float64) + + +def _rec709_luma(rgb: np.ndarray) -> np.ndarray: + """RGB 配列を Rec.709 luma に変換する (H, W, 3) → (H, W).""" + return 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + + +def _load_npy_roi(npy_path: Path) -> np.ndarray: + """luminance.npy → ROI 抽出 → mean アンカー正規化 → float64 配列. + + Args: + npy_path: luminance.npy のパス. + + Returns: + ROI 正規化済み輝度配列(H × W, float64). + + Raises: + FileNotFoundError: ファイルが存在しない場合. + """ + if not npy_path.exists(): + msg = f"luminance.npy が見つかりません: {npy_path}" + raise FileNotFoundError(msg) + + arr = np.load(npy_path) + if arr.ndim == 3: + arr = _rec709_luma(arr) + roi = _extract_roi(arr, SIM_ROI) + normalized, _ = apply_mean_anchor(roi, TARGET_MEAN) + return normalized + + +def _read_summary_csv(csv_path: Path) -> list[dict]: + """summary.csv を読み込んで辞書のリストを返す. + + Args: + csv_path: summary.csv のパス. + + Returns: + 各行の辞書リスト. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + """ + if not csv_path.exists(): + msg = f"summary.csv が見つかりません: {csv_path}" + raise FileNotFoundError(msg) + + with csv_path.open(encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + return list(reader) + + +def _build_job_index(rows: list[dict]) -> dict[str, dict]: + """summary CSV の行リストを job 名をキーとした辞書に変換する. + + Args: + rows: _read_summary_csv の返り値. + + Returns: + job 名 → 行辞書のマッピング. + """ + return {row["job"]: row for row in rows} + + +# --------------------------------------------------------------------------- +# パネル描画 +# --------------------------------------------------------------------------- + + +def _build_luminance_panel( + job_images: dict[str, np.ndarray], + baseline_roi: np.ndarray, + job_metrics: dict[str, dict], + output_path: Path, +) -> None: + """3×3 輝度マップパネルを生成・保存する. + + 行 = weight, 列 = alpha の 3×3 グリッドで各ジョブの輝度マップを描画する. + 各セルのタイトルに weight/alpha と CoV/勾配 を併記する. + + Args: + job_images: job 名 → 正規化済み ROI 輝度配列. + baseline_roi: ベースライン(純 diffuse)の ROI 輝度配列. + job_metrics: job 名 → metrics 辞書. + output_path: 保存先 PNG パス. + """ + # 共有 vmin/vmax: 全 9 ジョブ + ベースラインの 1%/99% パーセンタイル + all_vals = np.concatenate( + [baseline_roi.ravel()] + + [img.ravel() for img in job_images.values()] + ) + vmin = float(np.percentile(all_vals, VMIN_PERCENTILE)) + vmax = float(np.percentile(all_vals, VMAX_PERCENTILE)) + + n_rows = len(WEIGHTS) + n_cols = len(ALPHAS) + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=(n_cols * 6, n_rows * 5.5), + constrained_layout=True, + ) + + for row_idx, weight in enumerate(WEIGHTS): + for col_idx, alpha in enumerate(ALPHAS): + ax = axes[row_idx][col_idx] + job_name = _make_job_name(weight, alpha) + + if job_name in job_images: + roi = job_images[job_name] + m = job_metrics.get(job_name, {}) + cov_str = f"{float(m.get('cov', 0)):.4f}" if m.get("cov") else "N/A" + grad_val = m.get("gradient_magnitude") + grad_str = f"{float(grad_val):.2f}%" if grad_val else "N/A" + ax.imshow(roi, cmap="hot", vmin=vmin, vmax=vmax, aspect="auto") + title = f"w={weight}, α={alpha}\nCoV={cov_str} grad={grad_str}" + else: + # ジョブが失敗していた場合は空セル + ax.set_facecolor("0.15") + title = f"w={weight}, α={alpha}\n[MISSING]" + + ax.set_title(title, fontsize=9, pad=4) + ax.set_axis_off() + + # 共有 colorbar + fig.colorbar( + plt.cm.ScalarMappable( + norm=plt.Normalize(vmin=vmin, vmax=vmax), cmap="hot" + ), + ax=axes, + fraction=0.015, + pad=0.02, + label="Luminance (mean-anchored)", + ) + + fig.suptitle( + f"Step 8-14 blend BSDF sweep — luminance maps\n" + f"shared vmin/vmax = [{vmin:.2f}, {vmax:.2f}] (1%–99% of all 9 jobs + baseline)\n" + f"rows=weight (specular ratio), cols=alpha (GGX roughness)", + fontsize=10, + y=1.01, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"輝度マップパネル保存: {output_path}") + + +def _build_metrics_panel( + job_metrics: dict[str, dict], + output_path: Path, +) -> None: + """数値指標を 3×3 グリッドで表示するパネルを生成・保存する. + + 各セルに CoV / 勾配 / C/P / 動径 min/max を表示する. + 数値の大小が視覚的にわかるよう,セルの背景色を列ごとの最小値に基づいてカラーマップする. + + Args: + job_metrics: job 名 → metrics 辞書. + output_path: 保存先 PNG パス. + """ + n_rows = len(WEIGHTS) + n_cols = len(ALPHAS) + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=(n_cols * 3.5, n_rows * 3.0), + constrained_layout=True, + ) + + # 各指標の全 9 ジョブにわたる値域を計算(背景色正規化用) + metric_vals: dict[str, list[float]] = {col: [] for col, _ in METRICS_DISPLAY} + for weight in WEIGHTS: + for alpha in ALPHAS: + job_name = _make_job_name(weight, alpha) + m = job_metrics.get(job_name, {}) + for col, _ in METRICS_DISPLAY: + try: + metric_vals[col].append(float(m[col])) + except (KeyError, ValueError, TypeError): + pass + + metric_ranges: dict[str, tuple[float, float]] = {} + for col, vals in metric_vals.items(): + if vals: + metric_ranges[col] = (min(vals), max(vals)) + else: + metric_ranges[col] = (0.0, 1.0) + + for row_idx, weight in enumerate(WEIGHTS): + for col_idx, alpha in enumerate(ALPHAS): + ax = axes[row_idx][col_idx] + job_name = _make_job_name(weight, alpha) + m = job_metrics.get(job_name, {}) + + lines = [f"w={weight} α={alpha}"] + for metric_key, label in METRICS_DISPLAY: + try: + val = float(m[metric_key]) + lines.append(f"{label}: {val:.4f}") + except (KeyError, ValueError, TypeError): + lines.append(f"{label}: N/A") + + text = "\n".join(lines) + ax.text( + 0.5, 0.5, text, + ha="center", va="center", + fontsize=8.5, + transform=ax.transAxes, + family="monospace", + ) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.set_axis_off() + + # セル枠線を追加する + for spine in ax.spines.values(): + spine.set_visible(True) + spine.set_linewidth(0.8) + spine.set_color("0.4") + + # 行ラベル(weight) + for row_idx, weight in enumerate(WEIGHTS): + axes[row_idx][0].set_ylabel(f"w={weight}", fontsize=9, rotation=0, labelpad=40, va="center") + + # 列ラベル(alpha) + for col_idx, alpha in enumerate(ALPHAS): + axes[0][col_idx].set_title(f"α={alpha}", fontsize=9, pad=6) + + fig.suptitle( + "Step 8-14 blend BSDF sweep — metrics summary\n" + "rows=weight (specular ratio), cols=alpha (GGX roughness)", + fontsize=10, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"指標テーブルパネル保存: {output_path}") + + +# --------------------------------------------------------------------------- +# メイン処理 +# --------------------------------------------------------------------------- + + +def main() -> int: + """パネル生成のメインエントリポイント. + + Returns: + 終了コード(成功なら 0,失敗なら 1). + """ + # summary.csv の読み込み + try: + summary_rows = _read_summary_csv(SUMMARY_CSV) + except FileNotFoundError as exc: + print(f"エラー: {exc}", file=sys.stderr) + print("先に scripts/sweep_blend_bsdf.py を実行してください.", file=sys.stderr) + return 1 + + job_metrics = _build_job_index(summary_rows) + print(f"summary.csv 読み込み完了: {len(summary_rows)} ジョブ") + + # ベースライン(純 diffuse)の輝度マップを読み込む + if not BASELINE_NPY.exists(): + print(f"警告: ベースライン luminance.npy が見つかりません: {BASELINE_NPY}", file=sys.stderr) + print(" ベースラインなしでパネルを生成します.", file=sys.stderr) + baseline_roi = None + else: + baseline_roi = _load_npy_roi(BASELINE_NPY) + print(f"ベースライン読み込み完了: {BASELINE_NPY}") + + # 各ジョブの輝度マップを読み込む + job_images: dict[str, np.ndarray] = {} + missing_jobs: list[str] = [] + + for weight in WEIGHTS: + for alpha in ALPHAS: + job_name = _make_job_name(weight, alpha) + npy_path = BLEND_OUTPUT_BASE / job_name / "luminance.npy" + try: + job_images[job_name] = _load_npy_roi(npy_path) + except FileNotFoundError: + print(f" 警告: {job_name} の luminance.npy が見つかりません ({npy_path})") + missing_jobs.append(job_name) + + print(f"輝度マップ読み込み: {len(job_images)} 成功, {len(missing_jobs)} 不足") + if missing_jobs: + print(f" 不足ジョブ: {', '.join(missing_jobs)}") + + if not job_images: + print("エラー: 有効な輝度マップが 1 件もありません.", file=sys.stderr) + return 1 + + # ベースラインが存在しない場合は最初のジョブで代替する + if baseline_roi is None: + first_roi = next(iter(job_images.values())) + baseline_roi = first_roi + print(" ベースライン代替: 最初のジョブの ROI を使用します.") + + # パネル描画 + try: + _build_luminance_panel( + job_images=job_images, + baseline_roi=baseline_roi, + job_metrics=job_metrics, + output_path=OUTPUT_DIR / "blend_bsdf_sweep.png", + ) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 輝度マップパネルの生成に失敗しました — {exc}", file=sys.stderr) + return 1 + + try: + _build_metrics_panel( + job_metrics=job_metrics, + output_path=OUTPUT_DIR / "blend_bsdf_metrics.png", + ) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 指標テーブルパネルの生成に失敗しました — {exc}", file=sys.stderr) + return 1 + + print("\n=== パネル生成完了 ===") + print(f" 輝度マップ: {OUTPUT_DIR / 'blend_bsdf_sweep.png'}") + print(f" 指標テーブル: {OUTPUT_DIR / 'blend_bsdf_metrics.png'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/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_led_group_sanity_panel.py b/scripts/build_led_group_sanity_panel.py new file mode 100644 index 0000000..6a06e72 --- /dev/null +++ b/scripts/build_led_group_sanity_panel.py @@ -0,0 +1,153 @@ +"""LED グループ別 sanity check の比較パネル生成. + +scripts/sanity_led_groups.py の出力から 5 列比較パネルを作る: + ① bottom 単独 ON + ② right 単独 ON + ③ left 単独 ON + ④ 全 18 個 ON (output/baseline/luminance.npy) + ⑤ ①+②+③ の和 (線形性確認用) + +加えて差分パネル ⑥ |④ - ⑤| を生成し,線形性誤差を可視化する. + +実行方法: + uv run python scripts/build_led_group_sanity_panel.py + +前提条件: + - output/led_group_sanity/{bottom,right,left}/luminance.npy が揃っていること + - output/baseline/luminance.npy が全 ON ベースラインとして存在すること + +出力: + - output/_slide_panels/led_group_sanity.png +""" + +from __future__ import annotations + +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] +SANITY_BASE = ROOT / "output" / "led_group_sanity" +BASELINE_NPY = ROOT / "output" / "baseline" / "luminance.npy" +OUTPUT_PANEL = ROOT / "output" / "_slide_panels" / "led_group_sanity.png" + + +def _setup_japanese_font() -> None: + """matplotlib に日本語フォントを設定する (Windows 標準).""" + 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 main() -> int: + """パネル生成のメイン処理.""" + _setup_japanese_font() + + # 各グループの luminance.npy を読み込み + groups = ["bottom", "right", "left"] + lums: dict[str, np.ndarray] = {} + for g in groups: + npy_path = SANITY_BASE / g / "luminance.npy" + if not npy_path.exists(): + print(f"エラー: {npy_path} が見つかりません", file=sys.stderr) + return 1 + lums[g] = np.load(npy_path) + + # ベースライン (全 ON) 読み込み + if not BASELINE_NPY.exists(): + print(f"エラー: ベースライン {BASELINE_NPY} が見つかりません", file=sys.stderr) + return 1 + baseline = np.load(BASELINE_NPY) + + # スカラ輝度に変換 (RGB なら平均,2D ならそのまま) + def to_2d(arr: np.ndarray) -> np.ndarray: + return arr.mean(axis=-1) if arr.ndim == 3 else arr + + lum_bottom = to_2d(lums["bottom"]) + lum_right = to_2d(lums["right"]) + lum_left = to_2d(lums["left"]) + lum_all = to_2d(baseline) + lum_sum = lum_bottom + lum_right + lum_left + diff = np.abs(lum_all - lum_sum) + + # 線形性指標 + rel_err = diff.mean() / max(lum_all.mean(), 1e-9) + max_rel_err = diff.max() / max(lum_all.mean(), 1e-9) + + # 共有 vmin/vmax (① 〜 ⑤ の 1%/99% percentile を集約) + stacked = np.stack([lum_bottom, lum_right, lum_left, lum_all, lum_sum]) + vmin = float(np.percentile(stacked, 1)) + vmax = float(np.percentile(stacked, 99)) + + # mean 各値の表示 + means = { + "bottom": float(lum_bottom.mean()), + "right": float(lum_right.mean()), + "left": float(lum_left.mean()), + "all": float(lum_all.mean()), + "sum": float(lum_sum.mean()), + } + + # ============ パネル生成 ============ + fig = plt.figure(figsize=(22, 5)) + titles = [ + f"① bottom (6 LED)\nmean={means['bottom']:.2f}", + f"② right (6 LED)\nmean={means['right']:.2f}", + f"③ left (6 LED)\nmean={means['left']:.2f}", + f"④ 全 18 LED ON\nmean={means['all']:.2f}", + f"⑤ ①+②+③ の和\nmean={means['sum']:.2f}", + f"⑥ |④-⑤| 線形性誤差\n相対誤差 mean={rel_err*100:.3f}%, max={max_rel_err*100:.3f}%", + ] + data = [lum_bottom, lum_right, lum_left, lum_all, lum_sum, diff] + + for i, (img, title) in enumerate(zip(data, titles)): + ax = fig.add_subplot(1, 6, i + 1) + if i < 5: + im = ax.imshow(img, cmap="hot", vmin=vmin, vmax=vmax) + else: + # 差分は別 colormap (viridis) + 独立 scale + diff_vmax = float(np.percentile(img, 99)) + im = ax.imshow(img, cmap="viridis", vmin=0, vmax=max(diff_vmax, 1e-9)) + ax.set_title(title, fontsize=10) + ax.set_xticks([]) + ax.set_yticks([]) + plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + + fig.suptitle( + "LED グループ別 sanity check — 下/右/左 単独 ON と線形性確認", + fontsize=13, fontweight="bold", + ) + plt.tight_layout() + OUTPUT_PANEL.parent.mkdir(parents=True, exist_ok=True) + plt.savefig(OUTPUT_PANEL, dpi=110, bbox_inches="tight") + print(f"パネル保存: {OUTPUT_PANEL}") + print() + print("=== 線形性確認 ===") + print(f" 4 全 ON mean : {means['all']:.4f}") + print(f" 5 ①+②+③ 和 mean : {means['sum']:.4f}") + print(f" 相対誤差 mean : {rel_err*100:.5f}%") + print(f" 相対誤差 max : {max_rel_err*100:.5f}%") + print() + print("=== グループ別 mean ===") + print(f" ① bottom : {means['bottom']:.4f}") + print(f" ② right : {means['right']:.4f}") + print(f" ③ left : {means['left']:.4f}") + print(f" → bottom+right+left = {means['bottom']+means['right']+means['left']:.4f}") + print(f" → 全 ON = {means['all']:.4f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build_max_depth_panel.py b/scripts/build_max_depth_panel.py new file mode 100644 index 0000000..ab299ec --- /dev/null +++ b/scripts/build_max_depth_panel.py @@ -0,0 +1,149 @@ +"""max_depth スイープの比較パネル生成. + +scripts/sweep_max_depth.py の出力から以下を生成: + - 1×5 輝度マップパネル (shared vmin/vmax) + - 収束カーブ (max_depth vs CoV / 勾配 / C/P / 動径 min/max) + - 数値表 + +実行方法: + uv run python scripts/build_max_depth_panel.py + +前提条件: + - output/max_depth_sweep/summary.csv + d{N}/luminance.npy が揃っていること + +出力: + - output/_slide_panels/max_depth_sweep.png (1×5 輝度マップ) + - output/_slide_panels/max_depth_convergence.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" / "max_depth_sweep" +SUMMARY_CSV = SWEEP_BASE / "summary.csv" +OUT_LUM_PANEL = ROOT / "output" / "_slide_panels" / "max_depth_sweep.png" +OUT_CONV_PANEL = ROOT / "output" / "_slide_panels" / "max_depth_convergence.png" + +# 実機 TARGET (linear + LSC,eval_baseline_dng_target.yaml と整合) +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)) + if not rows: + print(f"エラー: summary.csv が空", file=sys.stderr) + return 1 + + depths = [int(r["max_depth"]) 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] + + # 各 npy 読み込み + lums = [] + for r in rows: + npy = SWEEP_BASE / r["job"] / "luminance.npy" + if not npy.exists(): + print(f"警告: {npy} 見つからずスキップ", file=sys.stderr) + lums.append(None) + continue + arr = np.load(npy) + lums.append(arr.mean(axis=-1) if arr.ndim == 3 else arr) + + # 共有 vmin/vmax + valid = [x for x in lums if x is not None] + stacked = np.stack(valid) + vmin = float(np.percentile(stacked, 1)) + vmax = float(np.percentile(stacked, 99)) + + # ============ 1×5 輝度マップパネル ============ + n = len(rows) + fig, axes = plt.subplots(1, n, figsize=(4.5 * n, 5.5)) + if n == 1: + axes = [axes] + for ax, d, lum, cov, grad in zip(axes, depths, lums, covs, grads): + if lum is None: + ax.set_title(f"d={d} (missing)") + ax.set_xticks([]); ax.set_yticks([]) + continue + im = ax.imshow(lum, cmap="hot", vmin=vmin, vmax=vmax) + ax.set_title(f"max_depth = {d}\nCoV={cov:.4f} 勾配={grad:.2f}%", fontsize=11) + ax.set_xticks([]); ax.set_yticks([]) + plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + + fig.suptitle("max_depth スイープ — 輝度マップ (shared vmin/vmax)", 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}") + + # ============ 収束カーブ ============ + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + metrics = [ + ("CoV", covs, TARGET["cov"], "CoV (低いほど均一)"), + ("勾配 [%]", grads, TARGET["gradient"], "勾配 [%] (低いほど均一)"), + ("C/P 比", cps, TARGET["cp"], "C/P 比 (1.0 が理想)"), + ("動径 min/max", rmms, TARGET["rmm"], "動径 min/max (1.0 が理想)"), + ] + for ax, (label, vals, target, ylabel) in zip(axes.flat, metrics): + ax.plot(depths, vals, marker="o", linewidth=2, markersize=10, color="C0", label="シミュ") + ax.axhline(target, color="red", linestyle="--", linewidth=1.5, + label=f"実機 TARGET ({target})") + for d, v in zip(depths, vals): + ax.annotate(f"{v:.4f}", (d, v), textcoords="offset points", + xytext=(0, 10), ha="center", fontsize=9) + ax.set_xlabel("max_depth") + ax.set_ylabel(ylabel) + ax.set_title(label) + ax.grid(alpha=0.3) + ax.legend(loc="best", fontsize=9) + + fig.suptitle( + "max_depth スイープ — 形状指標の収束 (赤破線=実機 TARGET)", + fontsize=14, fontweight="bold", + ) + plt.tight_layout() + plt.savefig(OUT_CONV_PANEL, dpi=110, bbox_inches="tight") + print(f"収束カーブ保存: {OUT_CONV_PANEL}") + + # ============ コンソールサマリ ============ + print() + print("=== サマリ ===") + print(f" {'max_depth':<10}{'mean':<10}{'CoV':<10}{'勾配%':<10}{'C/P':<10}{'r min/max':<10}") + for d, mean, cov, grad, cp, rmm in zip(depths, means, covs, grads, cps, rmms): + print(f" {d:<10}{mean:<10.2f}{cov:<10.4f}{grad:<10.2f}{cp:<10.4f}{rmm:<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_reflectance_panel.py b/scripts/build_reflectance_panel.py new file mode 100644 index 0000000..f71c867 --- /dev/null +++ b/scripts/build_reflectance_panel.py @@ -0,0 +1,179 @@ +"""反射率スイープの比較パネル生成. + +scripts/sweep_reflectance.py の出力から以下を生成: + - 1×5 輝度マップパネル (shared vmin/vmax + 独立スケール 2 段比較) + - 既存 4 指標の収束カーブ + - 新 5 指標 (shape_metrics) の変化カーブ + - 数値表 + +実行方法: + uv run python scripts/build_reflectance_panel.py + +出力: + - output/_slide_panels/reflectance_sweep.png (1×5 輝度マップ 2 段) + - output/_slide_panels/reflectance_convergence.png (既存 4 指標カーブ) + - output/_slide_panels/reflectance_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" / "reflectance_sweep" +SUMMARY_CSV = SWEEP_BASE / "summary.csv" +OUT_LUM_PANEL = ROOT / "output" / "_slide_panels" / "reflectance_sweep.png" +OUT_CONV_PANEL = ROOT / "output" / "_slide_panels" / "reflectance_convergence.png" +OUT_SHAPE_PANEL = ROOT / "output" / "_slide_panels" / "reflectance_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)) + + reflectances = [float(r["reflectance"]) 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] + + # npy 読み込み + 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 段比較 (shared / independent) ============ + n = len(rows) + fig, axes = plt.subplots(2, n, figsize=(4.5 * n, 10)) + for j, (r, lum, cov, grad) in enumerate(zip(reflectances, lums, covs, grads)): + # 上段: shared + ax_t = axes[0, j] + im_t = ax_t.imshow(lum, cmap="hot", vmin=vmin_shared, vmax=vmax_shared) + ax_t.set_title(f"R = {r}\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) + # 下段: independent + 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("反射率スイープ (d=16) — 共有 vs 独立スケール 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"], "CoV (低いほど均一)"), + ("勾配 [%]", grads, TARGET["gradient"], "勾配 [%]"), + ("C/P 比", cps, TARGET["cp"], "C/P 比"), + ("動径 min/max", rmms, TARGET["rmm"], "動径 min/max"), + ] + for ax, (label, vals, target, ylabel) in zip(axes.flat, metrics_existing): + ax.plot(reflectances, vals, marker="o", linewidth=2, markersize=10, color="C0", label="シミュ") + ax.axhline(target, color="red", linestyle="--", linewidth=1.5, label=f"実機 TARGET ({target})") + for r, v in zip(reflectances, vals): + ax.annotate(f"{v:.4f}", (r, v), textcoords="offset points", xytext=(0, 10), + ha="center", fontsize=9) + ax.set_xlabel("反射率 R") + ax.set_ylabel(ylabel) + ax.set_title(label) + ax.grid(alpha=0.3) + ax.legend(loc="best", fontsize=9) + + fig.suptitle("反射率スイープ — 既存 4 指標の変化 (赤破線=実機 TARGET)", + 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, "centroid_y_norm"), + ("angular_ratio (0=同心円, 1=角度依存)", ang_ratios, 0.0, "angular_ratio"), + ("PCA eccentricity", eccs, 0.0, "eccentricity"), + ] + for ax, (label, vals, ideal, ylabel) in zip(axes, new_metrics): + ax.plot(reflectances, vals, marker="o", linewidth=2, markersize=10, color="C2", label="シミュ") + ax.axhline(ideal, color="gray", linestyle=":", linewidth=1.5, label=f"理想 ({ideal})") + for r, v in zip(reflectances, vals): + ax.annotate(f"{v:.4f}", (r, v), textcoords="offset points", xytext=(0, 10), + ha="center", fontsize=9) + ax.set_xlabel("反射率 R") + ax.set_ylabel(ylabel) + ax.set_title(label) + ax.grid(alpha=0.3) + ax.legend(loc="best", fontsize=9) + + fig.suptitle("反射率スイープ — 新指標 (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 = ("R", "mean", "CoV", "勾配%", "C/P", "rmin/max", "centroid_y", "ang_ratio", "ecc") + print(f" {h[0]:<6}{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 r, mean, cov, grad, cp, rmm, cy, ar, ec in zip( + reflectances, means, covs, grads, cps, rmms, centroid_y, ang_ratios, eccs): + print(f" {r:<6}{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/sanity_led_groups.py b/scripts/sanity_led_groups.py new file mode 100644 index 0000000..889e555 --- /dev/null +++ b/scripts/sanity_led_groups.py @@ -0,0 +1,155 @@ +"""LED グループ別 sanity check. + +下/右/左 の各 LED 基板の LED だけを ON にしたときの輝度マップを生成し, +シミュが 3 つの照明源を正しく独立に扱えているかを目視確認する. + +各グループの輝度マップ: + bottom: led_bottom_0 〜 led_bottom_5 (6 個) のみ ON + right : led_right_0 〜 led_right_5 (6 個) のみ ON + left : led_left_0 〜 led_left_5 (6 個) のみ ON + +期待される観察: + - 方向性: 各単独 ON で評価板上の光が LED 基板側に偏る + - 対称性: right と left は左右対称パターン + - 線形性: bottom + right + left ≒ 全 ON ベースライン (Mitsuba linear renderer) + - 量的バランス: 3 グループの mean がほぼ同じ (各 6 個・同 radiance) + +実行方法: + uv run python scripts/sanity_led_groups.py + +前提条件: + - configs/scene_baseline.yaml が存在し lights に led_bottom_*/right_*/left_* が + 命名規則どおりに揃っていること + - sm-render が PATH に通っていること + - output/baseline/luminance.npy が全 ON ベースラインとして存在すること + +出力: + - output/led_group_sanity/{bottom,right,left}/luminance.npy + - output/led_group_sanity/{bottom,right,left}/render.png + - configs/_tmp_sanity/scene_{group}.yaml (一時 YAML 残置) +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +import yaml + +# --------------------------------------------------------------------------- +# 定数 +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] + +TEMPLATE_YAML = ROOT / "configs" / "scene_baseline.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_sanity" +OUTPUT_BASE = ROOT / "output" / "led_group_sanity" + +GROUPS = ["bottom", "right", "left"] + + +def _generate_tmp_yaml(group: str) -> Path: + """指定グループの LED だけを残した一時 YAML を生成する. + + Args: + group: "bottom" / "right" / "left" のいずれか. + + Returns: + 生成した一時 YAML のパス. + """ + TMP_DIR.mkdir(parents=True, exist_ok=True) + + with TEMPLATE_YAML.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # geometry.meshes の STL 相対パスを絶対化(_tmp_sanity からも解決できるように) + 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()) + + # lights を指定グループのみに絞り込む + prefix = f"led_{group}_" + data["lights"] = [light for light in data["lights"] if light["id"].startswith(prefix)] + + # 識別用の name を付与 + data["name"] = f"sanity_led_{group}" + + tmp_path = TMP_DIR / f"scene_{group}.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_render(tmp_yaml: Path, output_dir: Path, group: str) -> int: + """sm-render を実行して終了コードを返す. + + Args: + tmp_yaml: 一時 YAML のパス. + output_dir: 出力ディレクトリ. + group: ジョブ識別子(ログ用). + + Returns: + 終了コード(0=成功). + """ + cmd = ["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)] + print(f" [run] {' '.join(cmd)}") + result = subprocess.run(cmd, check=False) # noqa: S603 + if result.returncode != 0: + msg = f" [ERROR] sm-render ({group}) failed (returncode={result.returncode})" + print(msg, file=sys.stderr) + return result.returncode + + +def main() -> int: + """sanity check のメイン処理. + + Returns: + 終了コード(全ジョブ成功なら 0,失敗があれば 1). + """ + if not TEMPLATE_YAML.exists(): + print(f"エラー: テンプレート YAML が見つかりません: {TEMPLATE_YAML}", file=sys.stderr) + return 1 + + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + print(f"=== LED グループ別 sanity check: {len(GROUPS)} jobs ===") + print(f" groups: {GROUPS}") + print(f" output: {OUTPUT_BASE}") + print() + + failed: list[str] = [] + total_start = time.time() + + for idx, group in enumerate(GROUPS, start=1): + print(f"--- Job {idx}/{len(GROUPS)}: {group} ---") + job_start = time.time() + + tmp_yaml = _generate_tmp_yaml(group) + print(f" tmp YAML: {tmp_yaml}") + + output_dir = OUTPUT_BASE / group + if _run_render(tmp_yaml, output_dir, group) != 0: + failed.append(group) + print(f" -> スキップ: render 失敗\n") + continue + + elapsed = time.time() - job_start + print(f" -> 完了 ({elapsed:.1f}s)\n") + + total_elapsed = time.time() - total_start + print(f"=== 完了: {len(GROUPS) - len(failed)}/{len(GROUPS)} 成功, 総時間 {total_elapsed:.1f}s ===") + + if failed: + print(f"失敗ジョブ: {failed}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_blend_bsdf.py b/scripts/sweep_blend_bsdf.py new file mode 100644 index 0000000..33e076f --- /dev/null +++ b/scripts/sweep_blend_bsdf.py @@ -0,0 +1,319 @@ +"""Step 8-14 感度分析: blendbsdf weight × alpha の 3×3 グリッドスイープ. + +内壁の非 Lambertian 性(光沢成分混合)が輝度分布「形」と数値指標に +与える影響を観察する感度分析スクリプト. + +スイープグリッド: + weight ∈ {0.05, 0.15, 0.30} (specular 混合比) + alpha ∈ {0.1, 0.3, 0.6 } (GGX 粗さ) + → 9 組合せ × (sm-render + sm-evaluate) を順次実行 + +実行方法: + uv run python scripts/sweep_blend_bsdf.py + +前提条件: + - configs/scene_baseline_blend.yaml が存在すること + - configs/eval_baseline_dng_target.yaml が存在すること + - sm-render / sm-evaluate が PATH に通っていること + +出力: + - output/blend_sweep/w{w}_a{a}/luminance.npy (各ジョブのレンダリング結果) + - output/blend_sweep/w{w}_a{a}/metrics.csv (各ジョブの評価指標) + - output/blend_sweep/summary.csv (全 9 ジョブの集約結果) + - configs/_tmp_blend/scene_w{w}_a{a}.yaml (一時 YAML,実行後も残す) + +TECH_10 参照: 残差主因候補 §A1「内壁の非 Lambertian 性(光沢成分混合 BSDF)」 +""" + +from __future__ import annotations + +import csv +import subprocess +import sys +import time +from pathlib import Path + +import yaml + +# --------------------------------------------------------------------------- +# 定数 +# --------------------------------------------------------------------------- + +ROOT = Path(__file__).resolve().parents[1] + +TEMPLATE_YAML = ROOT / "configs" / "scene_baseline_blend.yaml" +EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_blend" +OUTPUT_BASE = ROOT / "output" / "blend_sweep" + +# スイープグリッド +WEIGHTS = [0.05, 0.15, 0.30] +ALPHAS = [0.1, 0.3, 0.6] + +# 集約 CSV のカラム定義(metrics.csv に存在するキー) +SUMMARY_EXTRA_COLS = [ + "cov", + "std", + "max_min_ratio", + "mean", + "max", + "min", + "center_periphery_ratio", + "gradient_magnitude", + "radial_min_max_ratio", + "radial_min_distance", + "radial_min", + "radial_max", +] + + +# --------------------------------------------------------------------------- +# ヘルパ関数 +# --------------------------------------------------------------------------- + + +def _format_param(value: float) -> str: + """パラメータ値をファイル名に使える文字列に変換する. + + 例: 0.05 → "0.05", 0.3 → "0.3" + + Args: + value: float のパラメータ値. + + Returns: + ファイル名に使える文字列. + """ + return str(value) + + +def _make_job_name(weight: float, alpha: float) -> str: + """ジョブ識別子を生成する. + + Args: + weight: blendbsdf の weight 値. + alpha: roughconductor の alpha 値. + + Returns: + ジョブ識別子文字列(例: "w0.05_a0.1"). + """ + return f"w{_format_param(weight)}_a{_format_param(alpha)}" + + +def _generate_tmp_yaml(weight: float, alpha: float) -> Path: + """一時 YAML ファイルを生成して返す. + + scene_baseline_blend.yaml のテンプレートを読み込み, + materials.printer_white.weight と components[1].alpha を書き換えて + 一時 YAML として保存する. + + Note: + UTF-8 BOM なしで書き込む(Mitsuba YAML パーサは BOM 付きで失敗するため). + + Args: + weight: blendbsdf の weight 値(specular 混合比). + alpha: roughconductor の alpha 値(GGX 粗さ). + + Returns: + 生成した一時 YAML ファイルのパス. + """ + TMP_DIR.mkdir(parents=True, exist_ok=True) + + with TEMPLATE_YAML.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # geometry.meshes の STL 相対パスを絶対化(一時 YAML の配置場所が変わっても解決可能にする) + template_base = TEMPLATE_YAML.parent + for item in data.get("geometry", {}).get("meshes", []): + rel_path = item.get("path") + if rel_path and not Path(rel_path).is_absolute(): + item["path"] = str((template_base / rel_path).resolve()) + + # printer_white の blendbsdf パラメータを書き換える + printer_white = data["materials"]["printer_white"] + printer_white["weight"] = weight + # components[1] が roughconductor(スキーマ順: [0]=diffuse, [1]=roughconductor) + printer_white["components"][1]["alpha"] = alpha + + # name を識別しやすい値に更新する + job_name = _make_job_name(weight, alpha) + data["name"] = f"baseline_minitias_blend_{job_name}" + + tmp_path = TMP_DIR / f"scene_{job_name}.yaml" + with tmp_path.open("w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False) + + return tmp_path + + +def _run_command(cmd: list[str], label: str) -> int: + """外部コマンドを実行して終了コードを返す. + + Args: + cmd: 実行するコマンドリスト. + label: ログ出力用のラベル. + + Returns: + 終了コード(0 = 成功). + """ + print(f" [run] {' '.join(cmd)}") + result = subprocess.run(cmd, check=False) # noqa: S603 + if result.returncode != 0: + print(f" [ERROR] {label} failed (returncode={result.returncode})", file=sys.stderr) + return result.returncode + + +def _read_metrics_csv(csv_path: Path) -> dict: + """metrics.csv を読み込んで辞書を返す. + + Args: + csv_path: 読み込む metrics.csv のパス. + + Returns: + 指標名 → 値の辞書.1 行目のヘッダ + 2 行目の値を読む. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: ファイルが空または形式不正の場合. + """ + if not csv_path.exists(): + msg = f"metrics.csv が見つかりません: {csv_path}" + raise FileNotFoundError(msg) + + with csv_path.open(encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + rows = list(reader) + + if not rows: + msg = f"metrics.csv が空です: {csv_path}" + raise ValueError(msg) + + return rows[0] + + +# --------------------------------------------------------------------------- +# メイン処理 +# --------------------------------------------------------------------------- + + +def main() -> int: + """スイープのメインエントリポイント. + + Returns: + 終了コード(全ジョブ成功なら 0,失敗があれば 1). + """ + # 前提ファイルの確認 + if not TEMPLATE_YAML.exists(): + print(f"エラー: テンプレート YAML が見つかりません: {TEMPLATE_YAML}", file=sys.stderr) + return 1 + if not EVAL_CONFIG_YAML.exists(): + print(f"エラー: 評価 config YAML が見つかりません: {EVAL_CONFIG_YAML}", file=sys.stderr) + return 1 + + OUTPUT_BASE.mkdir(parents=True, exist_ok=True) + + # ジョブリスト生成 + jobs = [(w, a) for w in WEIGHTS for a in ALPHAS] + total = len(jobs) + print(f"=== blend BSDF sweep: {total} jobs ({len(WEIGHTS)} weights × {len(ALPHAS)} alphas) ===") + print(f" weights: {WEIGHTS}") + print(f" alphas: {ALPHAS}") + print(f" output: {OUTPUT_BASE}") + print() + + failed_jobs: list[str] = [] + summary_rows: list[dict] = [] + total_start = time.time() + + for job_idx, (weight, alpha) in enumerate(jobs, start=1): + job_name = _make_job_name(weight, alpha) + output_dir = OUTPUT_BASE / job_name + metrics_csv = output_dir / "metrics.csv" + + print(f"--- Job {job_idx}/{total}: {job_name} ---") + job_start = time.time() + + # 一時 YAML 生成 + tmp_yaml = _generate_tmp_yaml(weight, alpha) + print(f" tmp YAML: {tmp_yaml}") + + # sm-render 実行 + render_ret = _run_command( + ["sm-render", "--scene", str(tmp_yaml), "--output", str(output_dir)], + label=f"sm-render ({job_name})", + ) + if render_ret != 0: + failed_jobs.append(f"{job_name} [render]") + print(" -> スキップ: render 失敗\n") + continue + + # sm-evaluate 実行 + npy_path = output_dir / "luminance.npy" + evaluate_ret = _run_command( + [ + "sm-evaluate", + "--image", str(npy_path), + "--config", str(EVAL_CONFIG_YAML), + "--output", str(output_dir), + ], + label=f"sm-evaluate ({job_name})", + ) + if evaluate_ret != 0: + failed_jobs.append(f"{job_name} [evaluate]") + print(" -> スキップ: evaluate 失敗\n") + continue + + # metrics 読み込み + try: + metrics = _read_metrics_csv(metrics_csv) + except (FileNotFoundError, ValueError) as exc: + print(f" [ERROR] metrics.csv 読み込み失敗: {exc}", file=sys.stderr) + failed_jobs.append(f"{job_name} [metrics_read]") + print() + continue + + elapsed = time.time() - job_start + print(f" -> 完了 ({elapsed:.1f}s)") + cov_val = metrics.get("cov", "N/A") + grad_val = metrics.get("gradient_magnitude", "N/A") + print(f" CoV={cov_val}, gradient={grad_val}%") + + row: dict = { + "job": job_name, + "weight": weight, + "alpha": alpha, + "elapsed_s": f"{elapsed:.1f}", + } + for col in SUMMARY_EXTRA_COLS: + row[col] = metrics.get(col, "") + summary_rows.append(row) + print() + + # 集約 CSV 保存 + summary_csv = OUTPUT_BASE / "summary.csv" + if summary_rows: + fieldnames = ["job", "weight", "alpha", "elapsed_s"] + SUMMARY_EXTRA_COLS + with summary_csv.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(summary_rows) + print(f"集約 CSV 保存: {summary_csv}") + else: + print( + "警告: 成功したジョブが 0 件のため summary.csv を保存しませんでした.", + file=sys.stderr, + ) + + total_elapsed = time.time() - total_start + print(f"\n=== スイープ完了: {len(summary_rows)}/{total} 成功, 総時間 {total_elapsed:.1f}s ===") + + if failed_jobs: + print(f"失敗ジョブ ({len(failed_jobs)}件):", file=sys.stderr) + for j in failed_jobs: + print(f" - {j}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/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/scripts/sweep_max_depth.py b/scripts/sweep_max_depth.py new file mode 100644 index 0000000..802b50f --- /dev/null +++ b/scripts/sweep_max_depth.py @@ -0,0 +1,184 @@ +"""Step 8-14 候補 α 前半: max_depth スイープ. + +R=0.85 のまま max_depth ∈ {8, 12, 16, 20, 28} を 5 値スイープして +多重反射の取りこぼし量と「形」「数値」の収束を観察する. + +理論累積(R/(1-R) を 100% とした場合): + depth= 8 → 72.7% (現状ベースライン) + depth=12 → 85.8% + depth=16 → 92.6% + depth=20 → 96.1% + depth=28 → 98.7% + +実行方法: + uv run python scripts/sweep_max_depth.py + +前提条件: + - configs/scene_baseline.yaml が存在すること + - configs/eval_baseline_dng_target.yaml が存在すること + - sm-render / sm-evaluate が PATH に通っていること + +出力: + - output/max_depth_sweep/d{N}/luminance.npy (各ジョブのレンダリング結果) + - output/max_depth_sweep/d{N}/metrics.csv (各ジョブの評価指標) + - output/max_depth_sweep/summary.csv (全 5 ジョブの集約結果) + - configs/_tmp_depth/scene_d{N}.yaml (一時 YAML,実行後も残す) + +TECH_10 参照: 残差主因候補 §A9「max_depth 8 → 16+」 +""" + +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.yaml" +EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_depth" +OUTPUT_BASE = ROOT / "output" / "max_depth_sweep" + +# スイープ値(R=0.85 で最終 bounce の寄与が 0.27 → 0.01 まで) +DEPTHS = [8, 12, 16, 20, 28] + +SUMMARY_EXTRA_COLS = [ + "cov", "std", "max_min_ratio", "mean", "max", "min", + "center_periphery_ratio", "gradient_magnitude", + "radial_min_max_ratio", "radial_min_distance", "radial_min", "radial_max", +] + + +def _generate_tmp_yaml(depth: int) -> Path: + """指定 max_depth の一時 YAML を生成する.""" + TMP_DIR.mkdir(parents=True, exist_ok=True) + + with TEMPLATE_YAML.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # geometry.meshes の STL 相対パスを絶対化 + 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()) + + # render.max_depth を書き換え + data["render"]["max_depth"] = depth + data["name"] = f"baseline_minitias_depth_{depth}" + + tmp_path = TMP_DIR / f"scene_d{depth}.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: + """metrics.csv の 1 行目を読む.""" + 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"=== max_depth sweep: {len(DEPTHS)} jobs ===") + print(f" depths: {DEPTHS}") + print(f" output: {OUTPUT_BASE}") + print() + + failed: list[str] = [] + rows: list[dict] = [] + total_start = time.time() + + for i, d in enumerate(DEPTHS, start=1): + job_name = f"d{d}" + output_dir = OUTPUT_BASE / job_name + metrics_csv = output_dir / "metrics.csv" + + print(f"--- Job {i}/{len(DEPTHS)}: {job_name} ---") + job_start = time.time() + + tmp_yaml = _generate_tmp_yaml(d) + 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") + print(f" -> 完了 ({elapsed:.1f}s) CoV={cov}, grad={grad}%") + + row: dict = {"job": job_name, "max_depth": d, "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", "max_depth", "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(DEPTHS)} 成功, 総時間 {total_elapsed:.1f}s ===") + + if failed: + print("失敗ジョブ:", failed, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sweep_reflectance.py b/scripts/sweep_reflectance.py new file mode 100644 index 0000000..6e2c275 --- /dev/null +++ b/scripts/sweep_reflectance.py @@ -0,0 +1,185 @@ +"""Step 8-14 候補 α 後半: 反射率 5 値スイープ. + +d=16 ベース (Step 8-14 α 前半で確定) で R ∈ {0.75, 0.80, 0.85, 0.89, 0.92} を +スイープし,反射率変化が形状指標と新指標 (shape_metrics) に与える影響を観察する. + +過去 Step 8-2 で同様のスイープを d=8 / 旧評価方針で実施済.本スイープは: + - d=16 (Step 8-14 α 前半の収束値) + - 評価 config: eval_baseline_dng_target_shape.yaml (新 5 指標を含む) + - mean アンカー正規化で形状指標は線形ゲイン不変,shape_metrics で形不変を定量確認 + +実行方法: + uv run python scripts/sweep_reflectance.py + +出力: + - output/reflectance_sweep/r{N}/luminance.npy + - output/reflectance_sweep/r{N}/metrics.csv + - output/reflectance_sweep/summary.csv + - configs/_tmp_reflectance/scene_r{N}.yaml (一時 YAML) + +TECH_10 参照: 残差主因候補 §A8「反射率 R=0.85→0.89」 +""" + +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.yaml" +EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" +TMP_DIR = ROOT / "configs" / "_tmp_reflectance" +OUTPUT_BASE = ROOT / "output" / "reflectance_sweep" + +REFLECTANCES = [0.75, 0.80, 0.85, 0.89, 0.92] + +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 指標 (shape_metrics) + "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 _r_tag(r: float) -> str: + """R 値をファイル名タグに変換 (0.85 → r085).""" + return f"r{int(round(r * 100)):03d}" + + +def _generate_tmp_yaml(reflectance: float) -> Path: + """指定 reflectance の一時 YAML を生成.""" + TMP_DIR.mkdir(parents=True, exist_ok=True) + + with TEMPLATE_YAML.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # geometry.meshes の STL 相対パスを絶対化 + 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()) + + # materials.printer_white.reflectance を書き換え + data["materials"]["printer_white"]["reflectance"] = [reflectance, reflectance, reflectance] + data["name"] = f"baseline_minitias_{_r_tag(reflectance)}" + + tmp_path = TMP_DIR / f"scene_{_r_tag(reflectance)}.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"=== reflectance sweep: {len(REFLECTANCES)} jobs ===") + print(f" reflectances: {REFLECTANCES}") + print(f" output: {OUTPUT_BASE}") + print() + + failed: list[str] = [] + rows: list[dict] = [] + total_start = time.time() + + for i, r in enumerate(REFLECTANCES, start=1): + job_name = _r_tag(r) + output_dir = OUTPUT_BASE / job_name + metrics_csv = output_dir / "metrics.csv" + + print(f"--- Job {i}/{len(REFLECTANCES)}: {job_name} (R={r}) ---") + job_start = time.time() + + tmp_yaml = _generate_tmp_yaml(r) + 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") + a_ratio = m.get("angular_ratio", "N/A") + print(f" -> 完了 ({elapsed:.1f}s) CoV={cov}, grad={grad}%, angular_ratio={a_ratio}") + + row: dict = {"job": job_name, "reflectance": r, "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", "reflectance", "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(REFLECTANCES)} 成功, 総時間 {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/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index de6afe0..5a5d4ba 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -31,6 +31,13 @@ calc_radial_profile, ) from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402 +from sm_tias_lightsim.evaluation.shape_metrics import ( # noqa: E402 + calc_angular_radial_decomposition, + calc_intensity_centroid, + calc_intensity_pca, + calc_ncc, + calc_ssim, +) from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 save_histogram, save_luminance_map, @@ -311,6 +318,117 @@ return calc_radial_min_max_ratio(profile) +def _load_reference_luminance(reference_path: Path) -> numpy.ndarray: + """形状指標用の参照画像を読み込み,輝度配列(2D float64)を返す. + + SSIM / NCC の参照画像として使用する._load_luminance と同一の変換ロジックを用い, + .npy 形式(2D/3D)と PNG 形式の両方に対応する. + 呼び出し元(_run_shape_metrics)で shape を確認してから calc_ssim / calc_ncc に渡すこと. + + Args: + reference_path: 参照画像ファイルのパス(.npy または .png). + + Returns: + 輝度配列(H x W, float64). + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: ファイルを輝度配列に変換できない場合. + """ + return _load_luminance(reference_path) + + +def _run_shape_metrics( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, + config_dir: Path, +) -> dict: + """形状指標(輝度重心 / 角度-放射分解 / PCA 主軸 / SSIM / NCC)を算出する. + + metrics.shape_metrics=True のとき,以下の指標を算出して辞書として返す: + - 輝度重心 5 値 (calc_intensity_centroid) + - 角度-放射分解 3 値 (calc_angular_radial_decomposition) + - PCA 主軸 4 値 (calc_intensity_pca) + - SSIM / NCC(shape_reference が指定されたときのみ,それ以外は float("nan")) + + shape_reference パスは config YAML のディレクトリを基準に解決する. + 絶対パスの場合はそのまま使用する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(shape_metrics フラグを持つ). + None の場合は無効扱い. + config_dir: 評価 YAML ファイルのディレクトリ(shape_reference の相対パス解決用). + + Returns: + 形状指標の辞書(12 キー).無効時は空辞書. + SSIM / NCC が計算不能な場合は float("nan") を格納する. + """ + if metrics_config is None or not metrics_config.shape_metrics: + return {} + + result: dict = {} + + # 輝度重心 + centroid = calc_intensity_centroid(roi_luminance) + result["intensity_centroid_x_px"] = centroid["centroid_x_px"] + result["intensity_centroid_y_px"] = centroid["centroid_y_px"] + result["intensity_centroid_x_norm"] = centroid["centroid_x_norm"] + result["intensity_centroid_y_norm"] = centroid["centroid_y_norm"] + result["intensity_centroid_offset_norm"] = centroid["centroid_offset_norm"] + + # 角度-放射分解 + ard = calc_angular_radial_decomposition(roi_luminance) + result["angular_variance"] = ard["angular_variance"] + result["radial_variance"] = ard["radial_variance"] + result["angular_ratio"] = ard["angular_ratio"] + + # PCA 主軸 + pca = calc_intensity_pca(roi_luminance) + result["pca_primary_axis_angle_deg"] = pca["primary_axis_angle_deg"] + result["pca_eccentricity"] = pca["eccentricity"] + + # SSIM / NCC(shape_reference が指定されている場合のみ) + reference_path_str = metrics_config.shape_reference + if reference_path_str is not None: + ref_path = Path(reference_path_str) + if not ref_path.is_absolute(): + ref_path = config_dir / ref_path + + try: + ref_luminance = _load_reference_luminance(ref_path) + # shape が一致するか確認(一致しない場合は警告してスキップ) + if ref_luminance.shape != roi_luminance.shape: + print( + f" 警告: shape_reference の shape {ref_luminance.shape} が " + f"ROI の shape {roi_luminance.shape} と異なるため SSIM/NCC をスキップします." + ) + result["ssim"] = float("nan") + result["ncc"] = float("nan") + else: + result["ssim"] = calc_ssim(roi_luminance, ref_luminance) + result["ncc"] = calc_ncc(roi_luminance, ref_luminance) + except FileNotFoundError: + print( + f" 警告: shape_reference ファイルが見つかりません: {ref_path}." + "SSIM/NCC をスキップします." + ) + result["ssim"] = float("nan") + result["ncc"] = float("nan") + except Exception as exc: # noqa: BLE001 + print( + f" 警告: shape_reference の読み込みに失敗しました — {exc}." + "SSIM/NCC をスキップします." + ) + result["ssim"] = float("nan") + result["ncc"] = float("nan") + else: + result["ssim"] = float("nan") + result["ncc"] = float("nan") + + return result + + def _save_metrics_csv(metrics: dict, csv_path: Path) -> None: """均一性指標を CSV ファイルとして保存する. @@ -370,6 +488,25 @@ print(f" radial min/max : {metrics['radial_min_max_ratio']:.4f}") print(f" radial min dist: {metrics['radial_min_distance']:.4f}") + if "intensity_centroid_offset_norm" in metrics: + print(f" centroid offset: {metrics['intensity_centroid_offset_norm']:.4f} (norm)") + print( + f" centroid (norm): x={metrics['intensity_centroid_x_norm']:.4f}" + f" y={metrics['intensity_centroid_y_norm']:.4f}" + ) + ang_ratio = metrics.get("angular_ratio", float("nan")) + if ang_ratio != ang_ratio: # NaN check + print(" angular_ratio : NaN (uniform image)") + else: + print(f" angular_ratio : {ang_ratio:.4f} (0=concentric, 1=directional)") + print(f" pca axis [deg] : {metrics['pca_primary_axis_angle_deg']:.1f}") + print(f" pca eccentricit: {metrics['pca_eccentricity']:.4f}") + ssim_val = metrics.get("ssim", float("nan")) + ncc_val = metrics.get("ncc", float("nan")) + if ssim_val == ssim_val: # not NaN + print(f" SSIM : {ssim_val:.4f}") + print(f" NCC : {ncc_val:.4f}") + print(f"\nmetrics.csv : {csv_path}") print(f"figures/ : {figures_dir}") @@ -389,9 +526,11 @@ 9. zones.enabled のときゾーン分析を実行する. 10. metrics.radial_profile のとき放射状プロファイルを算出する. 11. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用). - 12. metrics.csv を保存する. - 13. 可視化 PNG を figures/ に保存する. - 14. 算出指標のサマリーを標準出力に表示する. + 12. metrics.shape_metrics のとき形状指標を算出する + (重心 / 角度-放射分解 / PCA / SSIM / NCC,metrics.csv に 12 列末尾追加). + 13. metrics.csv を保存する. + 14. 可視化 PNG を figures/ に保存する. + 15. 算出指標のサマリーを標準出力に表示する. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -490,6 +629,13 @@ print(f"エラー: 動径 min/max 比の算出に失敗しました — {exc}", file=sys.stderr) return 1 + try: + shape_stats = _run_shape_metrics(roi_luminance, eval_config.metrics, config_path.parent) + metrics.update(shape_stats) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 形状指標の算出に失敗しました — {exc}", file=sys.stderr) + return 1 + csv_path = output_dir / _METRICS_FILENAME try: _save_metrics_csv(metrics, csv_path) 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/evaluation/shape_metrics.py b/src/sm_tias_lightsim/evaluation/shape_metrics.py new file mode 100644 index 0000000..3ad51c7 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/shape_metrics.py @@ -0,0 +1,497 @@ +"""輝度分布「形」評価指標モジュール. + +現状の均一性指標(CoV / 勾配 / C/P / 動径 min/max)は不均一性の絶対量しか測れず, +Y 軸単調勾配と中央ピーク楕円のように「形が構造的に異なる」パターンを区別できない. +本モジュールは「形」を直接評価する 5 つの純関数を提供する. + +References: + [1] TECH_11_形状指標の見直しと新指標導入 + docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md +""" + +from __future__ import annotations + +import numpy + + +def calc_intensity_centroid( + image: numpy.ndarray, + mask: numpy.ndarray | None = None, +) -> dict[str, float]: + """輝度重心を計算する. + + 各ピクセルの輝度値を重みとして ROI 内の重心座標を算出する. + 重心が ROI 中心から大きくずれていれば,均一でない輝度分布や + 非対称な光源配置を示す. + + Args: + image: 2D (H, W) 輝度マップ.負値は ReLU で 0 に. + mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象. + None なら全画素を対象とする. + + Returns: + 以下のキーを持つ辞書: + - ``"centroid_x_px"`` (float): ROI 座標系での重心 X(列方向,左端 = 0). + - ``"centroid_y_px"`` (float): ROI 座標系での重心 Y(行方向,上端 = 0). + - ``"centroid_x_norm"`` (float): ROI 中心を原点とした正規化 X(-1〜+1). + - ``"centroid_y_norm"`` (float): ROI 中心を原点とした正規化 Y(-1〜+1). + - ``"centroid_offset_norm"`` (float): 正規化座標での中心からの距離 √(x²+y²). + + Raises: + ValueError: mask の shape が image と異なる場合. + ValueError: 有効ピクセルの輝度総和が 0 の場合(重心計算不能). + """ + if image.ndim != 2: + msg = f"image は 2D 配列を期待します(ndim={image.ndim})" + raise ValueError(msg) + + if mask is not None: + if mask.shape != image.shape: + msg = ( + f"mask の shape {mask.shape} が image の shape {image.shape} と異なります" + ) + raise ValueError(msg) + + h, w = image.shape + + # 負値は 0 に(ReLU) + img = numpy.maximum(image.astype(numpy.float64), 0.0) + + if mask is not None: + img = numpy.where(mask, img, 0.0) + + total_weight = float(img.sum()) + if total_weight == 0.0: + msg = "有効ピクセルの輝度総和が 0 のため重心を計算できません" + raise ValueError(msg) + + # グリッド座標(ピクセル中心) + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + + centroid_x_px = float((img * xs).sum() / total_weight) + centroid_y_px = float((img * ys).sum() / total_weight) + + # ROI 中心を原点とした正規化座標 + cx = (w - 1) / 2.0 + cy = (h - 1) / 2.0 + # 正規化: ±1 が ROI の端になるよう cx/cy で割る + centroid_x_norm = (centroid_x_px - cx) / (cx if cx > 0.0 else 1.0) + centroid_y_norm = (centroid_y_px - cy) / (cy if cy > 0.0 else 1.0) + centroid_offset_norm = float(numpy.sqrt(centroid_x_norm**2 + centroid_y_norm**2)) + + return { + "centroid_x_px": centroid_x_px, + "centroid_y_px": centroid_y_px, + "centroid_x_norm": centroid_x_norm, + "centroid_y_norm": centroid_y_norm, + "centroid_offset_norm": centroid_offset_norm, + } + + +def calc_angular_radial_decomposition( + image: numpy.ndarray, + n_radial_bins: int = 20, + n_angular_bins: int = 36, + mask: numpy.ndarray | None = None, +) -> dict[str, float]: + """輝度マップを極座標で角度/半径方向に分解する. + + 画像を画像中心(ROI 中心)を原点とする極座標 (r, θ) に変換し, + 放射方向の分散と角度方向の分散を分離することで「形」の方向性を数値化する. + + アルゴリズム: + 1. 画像中心を原点とする極座標に変換 + 2. (r_bin, θ_bin) のグリッドで輝度を分類 → I[r_bin, θ_bin] + 3. angular_variance = mean_r[ Var_θ[I | r] ] 各 r での角度方向 var の半径平均 + 4. radial_variance = Var_r[ mean_θ[I | r] ] 角度平均した放射プロファイルの var + 5. angular_ratio = angular_variance / (angular_variance + radial_variance) + + 物理的意味: + - ``angular_ratio ≈ 0``: 完全同心円(各 r での角度変化が小 → 中央ピーク楕円に近い) + - ``angular_ratio ≈ 0.5``: 方向性ありの典型的非均一パターン + - ``angular_ratio ≈ 1``: 完全角度依存(強い方向性ムラ) + - 中央ピーク楕円: angular_ratio が小さい(0.1〜0.3) + - Y 単調勾配: angular_ratio が中程度(0.3〜0.5) + + Notes: + TECH_11 で示した「Y 単調勾配 → angular_ratio 0.3〜0.5」は + 実シミュ画像(ノイズ込み・有限解像度)での典型値である. + 純粋な Y 軸方向均一勾配(横縞画像)を合成すると,極座標のビン集計上 + 角度方向変動が支配的になるため angular_ratio は 0.5 を超える場合がある. + 「Y 軸勾配は同心円より angular_ratio が高い」という方向性は + 理論・合成画像ともに成立する. + + Args: + image: 2D (H, W) 輝度マップ.負値は 0 として扱う. + n_radial_bins: 半径方向のビン数.デフォルト 20. + n_angular_bins: 角度方向のビン数(360° を等分).デフォルト 36(各 10°). + mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象. + None なら全画素を対象とする. + + Returns: + 以下のキーを持つ辞書: + - ``"angular_variance"`` (float): 角度方向分散の平均 + - ``"radial_variance"`` (float): 放射方向分散 + - ``"angular_ratio"`` (float): 0〜1 の形指標(0=同心円,1=角度依存). + 完全均一画像(全 variance = 0)では NaN を返す. + + Raises: + ValueError: mask の shape が image と異なる場合. + """ + if image.ndim != 2: + msg = f"image は 2D 配列を期待します(ndim={image.ndim})" + raise ValueError(msg) + + if mask is not None: + if mask.shape != image.shape: + msg = ( + f"mask の shape {mask.shape} が image の shape {image.shape} と異なります" + ) + raise ValueError(msg) + + h, w = image.shape + img = numpy.maximum(image.astype(numpy.float64), 0.0) + + if mask is not None: + img = numpy.where(mask, img, numpy.nan) + + # 画像中心を原点とした座標グリッド + cy = (h - 1) / 2.0 + cx = (w - 1) / 2.0 + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) - cy + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) - cx + + # 極座標変換 + r = numpy.sqrt(xs**2 + ys**2) + theta = numpy.arctan2(ys, xs) # [-π, +π] + + # r を [0, r_max] の n_radial_bins ビンに割り当て + r_max = float(r.max()) + if r_max == 0.0: + return { + "angular_variance": 0.0, + "radial_variance": 0.0, + "angular_ratio": float("nan"), + } + + r_norm = r / r_max # [0, 1] + + # θ を [-π, π] → [0, 2π] へシフトして [0, n_angular_bins) に bin 化 + theta_shifted = theta + numpy.pi # [0, 2π] + r_bin_indices = numpy.minimum( + (r_norm * n_radial_bins).astype(int), n_radial_bins - 1 + ) + theta_bin_indices = numpy.minimum( + (theta_shifted / (2.0 * numpy.pi) * n_angular_bins).astype(int), + n_angular_bins - 1, + ) + + # 各 (r_bin, θ_bin) の輝度平均を集計 + # grid[r_bin, theta_bin] = 輝度平均(空セルは nan) + grid = numpy.full((n_radial_bins, n_angular_bins), numpy.nan, dtype=numpy.float64) + r_flat = r_bin_indices.ravel() + t_flat = theta_bin_indices.ravel() + v_flat = img.ravel() + + # 有効なピクセルだけを集計する + valid_mask_flat = ~numpy.isnan(v_flat) + valid_r = r_flat[valid_mask_flat] + valid_t = t_flat[valid_mask_flat] + valid_v = v_flat[valid_mask_flat] + + # numpy.add.at で各セルに値を累積し,カウントで割って平均を得る + sum_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.float64) + count_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.int64) + numpy.add.at(sum_grid, (valid_r, valid_t), valid_v) + numpy.add.at(count_grid, (valid_r, valid_t), 1) + + valid_cells = count_grid > 0 + grid[valid_cells] = sum_grid[valid_cells] / count_grid[valid_cells] + + # angular_variance: 各 r_bin について角度方向の分散を算出し,r 方向に平均 + angular_var_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64) + for r_idx in range(n_radial_bins): + row = grid[r_idx, :] + valid_vals = row[~numpy.isnan(row)] + if valid_vals.size >= 2: + angular_var_per_r[r_idx] = float(numpy.var(valid_vals, ddof=0)) + elif valid_vals.size == 1: + angular_var_per_r[r_idx] = 0.0 + # else: 有効セルなし → nan のまま + + valid_ang_var = angular_var_per_r[~numpy.isnan(angular_var_per_r)] + angular_variance = float(numpy.mean(valid_ang_var)) if valid_ang_var.size > 0 else 0.0 + + # radial_variance: 各 r_bin について角度方向の平均を算出し,その r 方向の分散 + radial_mean_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64) + for r_idx in range(n_radial_bins): + row = grid[r_idx, :] + valid_vals = row[~numpy.isnan(row)] + if valid_vals.size > 0: + radial_mean_per_r[r_idx] = float(numpy.mean(valid_vals)) + + valid_radial_means = radial_mean_per_r[~numpy.isnan(radial_mean_per_r)] + radial_variance = ( + float(numpy.var(valid_radial_means, ddof=0)) if valid_radial_means.size >= 2 else 0.0 + ) + + total_variance = angular_variance + radial_variance + if total_variance == 0.0: + angular_ratio = float("nan") + else: + angular_ratio = angular_variance / total_variance + + return { + "angular_variance": angular_variance, + "radial_variance": radial_variance, + "angular_ratio": angular_ratio, + } + + +def calc_ssim( + image: numpy.ndarray, + reference: numpy.ndarray, + mask: numpy.ndarray | None = None, + data_range: float | None = None, +) -> float: + """構造的類似度 (Structural Similarity Index) を算出する. + + ``skimage.metrics.structural_similarity`` を使用する. + 形(空間構造)の類似性を 0〜1 で数値化する.1.0 で完全一致. + + Args: + image: 評価対象 2D 輝度マップ(H, W). + reference: 参照画像 2D 輝度マップ(image と同 shape). + mask: 2D bool マスク.True ピクセルのみを計算対象とするため, + mask 外を 0 で埋めた上で full=True モードの SSIM 全体値を用いる. + data_range: 輝度値の最大レンジ(``image.max() - reference.max()`` の max). + None のとき ``max(image.max(), reference.max())`` を使う. + + Returns: + SSIM 値(float, 0〜1). + + Raises: + ValueError: image と reference の shape が異なる場合. + ValueError: mask の shape が image と異なる場合. + ImportError: scikit-image がインストールされていない場合. + """ + try: + from skimage.metrics import structural_similarity # noqa: PLC0415 + except ImportError as exc: + msg = ( + "calc_ssim には scikit-image が必要です." + "pip install scikit-image>=0.21.0 でインストールしてください." + ) + raise ImportError(msg) from exc + + if image.ndim != 2 or reference.ndim != 2: + msg = "image と reference は 2D 配列を期待します" + raise ValueError(msg) + + if image.shape != reference.shape: + msg = ( + f"image の shape {image.shape} と reference の shape {reference.shape} が異なります." + "事前に resize してください." + ) + raise ValueError(msg) + + if mask is not None: + if mask.shape != image.shape: + msg = ( + f"mask の shape {mask.shape} が image の shape {image.shape} と異なります" + ) + raise ValueError(msg) + + img = image.astype(numpy.float64) + ref = reference.astype(numpy.float64) + + if mask is not None: + img = numpy.where(mask, img, 0.0) + ref = numpy.where(mask, ref, 0.0) + + if data_range is None: + data_range = float(max(img.max(), ref.max())) + if data_range == 0.0: + data_range = 1.0 # 全ゼロ画像でのゼロ除算を防ぐ + + ssim_val = structural_similarity(img, ref, data_range=data_range) + return float(ssim_val) + + +def calc_ncc( + image: numpy.ndarray, + reference: numpy.ndarray, + mask: numpy.ndarray | None = None, +) -> float: + """正規化相関係数 (Normalized Cross-Correlation) を算出する. + + NCC = Σ((I - mean(I)) * (R - mean(R))) / sqrt(Σ(I - mean(I))² * Σ(R - mean(R))²) + + 絶対輝度スケールに依存しない形の類似性指標.1.0 で完全相関(形が一致), + 0 で無相関,-1 で逆相関.SSIM より単純だが照明変動に対しロバスト. + + Args: + image: 評価対象 2D 輝度マップ(H, W). + reference: 参照画像 2D 輝度マップ(image と同 shape). + mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象. + None なら全画素を対象とする. + + Returns: + NCC 値(float, -1〜+1). + 分散がゼロの場合(均一画像)は NaN を返す. + + Raises: + ValueError: image と reference の shape が異なる場合. + ValueError: mask の shape が image と異なる場合. + """ + if image.ndim != 2 or reference.ndim != 2: + msg = "image と reference は 2D 配列を期待します" + raise ValueError(msg) + + if image.shape != reference.shape: + msg = ( + f"image の shape {image.shape} と reference の shape {reference.shape} が異なります" + ) + raise ValueError(msg) + + if mask is not None: + if mask.shape != image.shape: + msg = ( + f"mask の shape {mask.shape} が image の shape {image.shape} と異なります" + ) + raise ValueError(msg) + + img = image.astype(numpy.float64).ravel() + ref = reference.astype(numpy.float64).ravel() + + if mask is not None: + mask_flat = mask.ravel() + img = img[mask_flat] + ref = ref[mask_flat] + + if img.size == 0: + return float("nan") + + img_centered = img - img.mean() + ref_centered = ref - ref.mean() + + denom = float( + numpy.sqrt(numpy.sum(img_centered**2) * numpy.sum(ref_centered**2)) + ) + if denom == 0.0: + return float("nan") + + ncc_val = float(numpy.sum(img_centered * ref_centered) / denom) + # 数値誤差で [-1, 1] の範囲をわずかに超える可能性があるためクリップ + return float(numpy.clip(ncc_val, -1.0, 1.0)) + + +def calc_intensity_pca( + image: numpy.ndarray, + mask: numpy.ndarray | None = None, +) -> dict[str, float]: + """輝度分布の主成分分析で主軸方向と楕円率を計算する. + + 各ピクセルを (x, y, weight=I(x,y)) とした weighted PCA で + 輝度分布の「伸び方向」と「偏心度」を数値化する. + + アルゴリズム: + 1. 輝度を重みとして重心座標 (cx, cy) を算出 + 2. 重心からのオフセット (Δx, Δy) に輝度重みを掛けて共分散行列を構築 + 3. 固有値分解 → 主軸方向(第 1 固有ベクトルの角度)と固有値 + + 物理的意味: + - ``primary_axis_angle_deg ≈ 90°``: 主軸が Y 軸方向(Y 単調勾配の典型) + - ``eccentricity ≈ 0``: 完全円形分布(等方的中央ピーク) + - ``eccentricity ≈ 1``: 極端な楕円(強い方向性の輝度分布) + + Args: + image: 2D (H, W) 輝度マップ.負値は 0 として扱う. + mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象. + None なら全画素を対象とする. + + Returns: + 以下のキーを持つ辞書: + - ``"primary_axis_angle_deg"`` (float): 主軸の角度 [deg](画像 x 軸基準,-90〜+90). + - ``"primary_eigenvalue"`` (float): 第 1 固有値(主軸方向の分散の大きさ). + - ``"secondary_eigenvalue"`` (float): 第 2 固有値(副軸方向の分散の大きさ). + - ``"eccentricity"`` (float): √(1 - secondary/primary),0=完全円,1=極端な楕円. + primary_eigenvalue が 0 の場合は 0 を返す. + + Raises: + ValueError: mask の shape が image と異なる場合. + ValueError: 有効ピクセルの輝度総和が 0 の場合(PCA 計算不能). + """ + if image.ndim != 2: + msg = f"image は 2D 配列を期待します(ndim={image.ndim})" + raise ValueError(msg) + + if mask is not None: + if mask.shape != image.shape: + msg = ( + f"mask の shape {mask.shape} が image の shape {image.shape} と異なります" + ) + raise ValueError(msg) + + h, w = image.shape + img = numpy.maximum(image.astype(numpy.float64), 0.0) + + if mask is not None: + img = numpy.where(mask, img, 0.0) + + # グリッド座標(ピクセル中心) + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + + total_weight = float(img.sum()) + if total_weight == 0.0: + msg = "有効ピクセルの輝度総和が 0 のため PCA を計算できません" + raise ValueError(msg) + + # 重心 + cx = float((img * xs).sum() / total_weight) + cy = float((img * ys).sum() / total_weight) + + # 重心からのオフセット + dx = xs - cx + dy = ys - cy + + # weighted 共分散行列の要素 + cov_xx = float((img * dx * dx).sum() / total_weight) + cov_xy = float((img * dx * dy).sum() / total_weight) + cov_yy = float((img * dy * dy).sum() / total_weight) + + cov_matrix = numpy.array([[cov_xx, cov_xy], [cov_xy, cov_yy]], dtype=numpy.float64) + + # 固有値分解(numpy.linalg.eigh は対称行列向けで安定かつ固有値昇順保証) + eigenvalues, eigenvectors = numpy.linalg.eigh(cov_matrix) + # eigh は昇順なので,最大固有値は eigenvalues[1] + primary_eigenvalue = float(eigenvalues[1]) + secondary_eigenvalue = float(eigenvalues[0]) + + # 主軸ベクトル(第 1 固有値に対応する固有ベクトル) + primary_vec = eigenvectors[:, 1] # shape (2,): [x_component, y_component] + angle_rad = float(numpy.arctan2(primary_vec[1], primary_vec[0])) + angle_deg = float(numpy.degrees(angle_rad)) + # -90〜+90 の範囲に折り畳む(主軸は方向性を持たないため) + if angle_deg > 90.0: + angle_deg -= 180.0 + elif angle_deg < -90.0: + angle_deg += 180.0 + + # 離心率 eccentricity = √(1 - secondary/primary) + # primary が 0 の場合(全画素同一値)は 0 を返す + if primary_eigenvalue <= 0.0: + eccentricity = 0.0 + else: + ratio = secondary_eigenvalue / primary_eigenvalue + # ratio は理論上 [0, 1] だが数値誤差で負になることがあるためクリップ + eccentricity = float(numpy.sqrt(max(0.0, 1.0 - ratio))) + + return { + "primary_axis_angle_deg": angle_deg, + "primary_eigenvalue": primary_eigenvalue, + "secondary_eigenvalue": secondary_eigenvalue, + "eccentricity": eccentricity, + } 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/scene/materials.py b/src/sm_tias_lightsim/scene/materials.py index 6c2241b..857cce9 100644 --- a/src/sm_tias_lightsim/scene/materials.py +++ b/src/sm_tias_lightsim/scene/materials.py @@ -3,9 +3,11 @@ from __future__ import annotations from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, DielectricMaterial, DiffuseMaterial, MaterialConfig, + RoughConductorBSDFConfig, ThinDielectricMaterial, ) @@ -27,17 +29,17 @@ return list(value) -def build_material_dict(material: MaterialConfig) -> dict: - """1 MaterialConfig を Mitsuba BSDF dict に変換する. +def _build_bsdf_dict(material: object) -> dict: + """1 つの BSDF config を Mitsuba BSDF dict に再帰変換する(内部ヘルパ). + + BlendBSDFConfig の components は再帰的に変換する. Args: - material: SceneConfig.materials の値(DiffuseMaterial 等). + material: DiffuseMaterial / DielectricMaterial / ThinDielectricMaterial / + RoughConductorBSDFConfig / BlendBSDFConfig のいずれか. Returns: Mitsuba BSDF dict. - - diffuse → {"type": "diffuse", "reflectance": {"type":"rgb","value":[r,g,b]}} - - dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} - - thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} Raises: ValueError: 未対応の材料タイプが指定された場合. @@ -65,5 +67,44 @@ "ext_ior": material.ext_ior, } + if isinstance(material, RoughConductorBSDFConfig): + # material="none" で eta/k なし(完全反射体)として扱う + return { + "type": "roughconductor", + "alpha": material.alpha, + "distribution": material.distribution, + "material": "none", + } + + if isinstance(material, BlendBSDFConfig): + # components は必ず 2 要素(スキーマで min_length=2, max_length=2 を保証) + return { + "type": "blendbsdf", + "weight": material.weight, + "bsdf_0": _build_bsdf_dict(material.components[0]), + "bsdf_1": _build_bsdf_dict(material.components[1]), + } + msg = f"Unsupported material type: {type(material).__name__}" raise ValueError(msg) + + +def build_material_dict(material: MaterialConfig) -> dict: + """1 MaterialConfig を Mitsuba BSDF dict に変換する. + + Args: + material: SceneConfig.materials の値(DiffuseMaterial / BlendBSDFConfig 等). + + Returns: + Mitsuba BSDF dict. + - diffuse → {"type": "diffuse", "reflectance": {"type":"rgb","value":[r,g,b]}} + - dielectric → {"type": "dielectric", "int_ior": ..., "ext_ior": ...} + - thindielectric → {"type": "thindielectric", "int_ior": ..., "ext_ior": ...} + - roughconductor → {"type": "roughconductor", "alpha": ..., "distribution": ..., + "material": "none"} + - blendbsdf → {"type": "blendbsdf", "weight": ..., "bsdf_0": ..., "bsdf_1": ...} + + Raises: + ValueError: 未対応の材料タイプが指定された場合. + """ + return _build_bsdf_dict(material) diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index 4054431..4403901 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -131,9 +131,69 @@ type: Literal["thindielectric"] +class RoughConductorBSDFConfig(_FrozenBase): + """マイクロファセット鏡面反射材料(完全導体近似). + + Mitsuba 3 の ``roughconductor`` BSDF に対応する. + ``material="none"`` で eta/k なし(完全反射体)として扱い, + 内壁の光沢成分を表現する感度分析に使用する. + """ + + type: Literal["roughconductor"] + alpha: float = Field( + ..., ge=0.0, le=1.0, description="マイクロファセット粗さ(0=鏡面,1=マット)" + ) + distribution: Literal["ggx", "beckmann"] = Field( + default="ggx", description="マイクロファセット法線分布(ggx または beckmann)" + ) + + +class BlendBSDFConfig(_FrozenBase): + """2 つの BSDF を線形補間するブレンド材料. + + Mitsuba 3 の ``blendbsdf`` に対応する. + ``weight=0`` で component[0] のみ,``weight=1`` で component[1] のみ有効. + 内壁の diffuse(拡散)と roughconductor(光沢)を weight でブレンドする + 感度分析に使用する. + + 再帰的な入れ子も可能だが,感度分析では 2 要素のフラット構造を想定する. + """ + + type: Literal["blendbsdf"] + weight: float = Field( + ..., + ge=0.0, + le=1.0, + description="component[1] の混合比(0=全 component[0],1=全 component[1])", + ) + components: list[ + Annotated[ + DiffuseMaterial + | DielectricMaterial + | ThinDielectricMaterial + | RoughConductorBSDFConfig + | BlendBSDFConfig, + Field(discriminator="type"), + ] + ] = Field( + ..., + min_length=2, + max_length=2, + description="ブレンドする 2 つの BSDF([component0, component1])", + ) + + +# 再帰型の forward reference を解決する +BlendBSDFConfig.model_rebuild() + + # type フィールドで識別される discriminated union MaterialConfig = Annotated[ - DiffuseMaterial | DielectricMaterial | ThinDielectricMaterial, + DiffuseMaterial + | DielectricMaterial + | ThinDielectricMaterial + | RoughConductorBSDFConfig + | BlendBSDFConfig, Field(discriminator="type"), ] @@ -366,6 +426,22 @@ "True のとき radial_profile の有無に関わらず内部で radial_profile を算出する." ), ) + shape_metrics: bool = Field( + default=False, + description=( + "形状指標を算出する(輝度重心 / 角度-放射分解 / PCA 主軸)." + "SSIM / NCC は shape_reference が指定されたときのみ算出する." + "True のとき metrics.csv に 10〜12 列を末尾に追加する." + ), + ) + shape_reference: str | None = Field( + default=None, + description=( + "SSIM / NCC 算出用の参照画像パス(.npy または .png)." + "shape_metrics=True のときのみ有効." + "省略時(null)は SSIM / NCC の列は空欄になる." + ), + ) class EvaluationConfig(_FrozenBase): 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/cli/test_evaluate_shape_metrics.py b/tests/cli/test_evaluate_shape_metrics.py new file mode 100644 index 0000000..bbdef7f --- /dev/null +++ b/tests/cli/test_evaluate_shape_metrics.py @@ -0,0 +1,687 @@ +"""cli/evaluate.py の _run_shape_metrics 関数と CLI 統合動作の仕様ベーステスト. + +仕様出典: +- docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md +- docs/04_SPEC/SPEC_04_評価仕様.md +- src/sm_tias_lightsim/cli/evaluate.py(_run_shape_metrics 実装) +- src/sm_tias_lightsim/evaluation/shape_metrics.py(純関数実装) + +テスト方針: + - subprocess ではなく main() / _run_shape_metrics() を直接呼ぶ(速度・カバレッジのため) + - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) + +注意: + matplotlib の Agg バックエンドは evaluate.py 内部で設定される. + テスト側でも安全のため import 前に設定する. +""" + +from __future__ import annotations + +import math +import textwrap + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path + +import numpy +import pandas +import pytest + +from sm_tias_lightsim.cli.evaluate import _run_shape_metrics, main +from sm_tias_lightsim.scene.schema import MetricsConfig + +# =========================================================================== +# テスト用ヘルパー関数 +# =========================================================================== + + +def _make_luminance_array(seed: int = 0, h: int = 64, w: int = 64) -> numpy.ndarray: + """放射状勾配の合成輝度配列(seed 固定). + + 中心が明るく周辺が暗い放射状勾配で shape 指標が意味のある値になる. + """ + rng = numpy.random.RandomState(seed) + ys, xs = numpy.mgrid[0:h, 0:w] + cy, cx = h / 2.0, w / 2.0 + dist = numpy.sqrt(((xs - cx) / cx) ** 2 + ((ys - cy) / cy) ** 2) + luminance = 100.0 - 30.0 * dist + rng.normal(0, 1.0, (h, w)) + return numpy.clip(luminance, 1.0, None).astype(numpy.float64) + + +def _make_eval_yaml( + tmp_path: Path, + shape_metrics: bool = True, + shape_reference: str | None = None, + x: int = 0, + y: int = 0, + width: int = 64, + height: int = 64, + include_zones: bool = True, + include_radial: bool = True, +) -> Path: + """shape_metrics フラグを含む評価 YAML を tmp_path に書き出す.""" + ref_line = "null" if shape_reference is None else f'"{shape_reference}"' + shape_metrics_str = "true" if shape_metrics else "false" + radial_line = " radial_profile: true\n" if include_radial else "" + + lines = [ + "roi:", + " type: image_pixel", + f" x: {x}", + f" y: {y}", + f" width: {width}", + f" height: {height}", + ] + if include_zones: + lines += [ + "zones:", + " enabled: true", + " boundaries: [0.33, 0.66]", + ] + lines += [ + "metrics:", + " cov: true", + " std: true", + " max_min: true", + ] + if include_radial: + lines.append(" radial_profile: true") + lines += [ + f" shape_metrics: {shape_metrics_str}", + f" shape_reference: {ref_line}", + ] + + content = "\n".join(lines) + "\n" + yaml_path = tmp_path / "eval_shape.yaml" + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _run_main(npy_path: Path, config_path: Path, output_dir: Path) -> int: + """main() を呼ぶ共通ラッパー.""" + return main( + ["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)] + ) + + +# =========================================================================== +# G1. shape_metrics=True で metrics.csv に shape 指標 12 列が末尾追加される +# =========================================================================== + + +class TestShapeMetricsCsvColumns: + """G1: shape_metrics=True で CSV に shape 指標列が追加されること.""" + + _CENTROID_COLS = [ + "intensity_centroid_x_px", + "intensity_centroid_y_px", + "intensity_centroid_x_norm", + "intensity_centroid_y_norm", + "intensity_centroid_offset_norm", + ] + _ARD_COLS = ["angular_variance", "radial_variance", "angular_ratio"] + _PCA_COLS = ["pca_primary_axis_angle_deg", "pca_eccentricity"] + _SSIM_NCC_COLS = ["ssim", "ncc"] + + def test_centroid_columns_present_when_shape_metrics_true(self, tmp_path: Path): + """G1: shape_metrics=True で CSV に輝度重心 5 列が含まれること. + + 仕様: intensity_centroid_x_px / intensity_centroid_y_px / + intensity_centroid_x_norm / intensity_centroid_y_norm / + intensity_centroid_offset_norm + (TECH_11 §1. 輝度重心 + cli/evaluate.py _run_shape_metrics) + """ + luminance = _make_luminance_array(seed=0) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path, shape_metrics=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + + assert result == 0 + df = pandas.read_csv(output_dir / "metrics.csv") + for col in self._CENTROID_COLS: + assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない" + + def test_ard_columns_present_when_shape_metrics_true(self, tmp_path: Path): + """G1: shape_metrics=True で CSV に角度-放射分解 3 列が含まれること. + + 仕様: angular_variance / radial_variance / angular_ratio + (TECH_11 §2. 角度-放射分解 + cli/evaluate.py _run_shape_metrics) + """ + luminance = _make_luminance_array(seed=1) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path, shape_metrics=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + + assert result == 0 + df = pandas.read_csv(output_dir / "metrics.csv") + for col in self._ARD_COLS: + assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない" + + def test_pca_columns_present_when_shape_metrics_true(self, tmp_path: Path): + """G1: shape_metrics=True で CSV に PCA 指標 2 列が含まれること. + + 仕様: pca_primary_axis_angle_deg / pca_eccentricity + (TECH_11 §5. 輝度分布 PCA + cli/evaluate.py _run_shape_metrics) + """ + luminance = _make_luminance_array(seed=2) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path, shape_metrics=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + + assert result == 0 + df = pandas.read_csv(output_dir / "metrics.csv") + for col in self._PCA_COLS: + assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない" + + def test_ssim_ncc_columns_present_but_nan_when_no_reference(self, tmp_path: Path): + """G1: shape_reference が null のとき ssim / ncc 列は存在するが NaN であること. + + 仕様: shape_reference が無効なら空欄 or NaN + (cli/evaluate.py _run_shape_metrics: reference_path_str is None → NaN) + """ + luminance = _make_luminance_array(seed=3) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path, shape_metrics=True, shape_reference=None) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + + assert result == 0 + df = pandas.read_csv(output_dir / "metrics.csv") + for col in self._SSIM_NCC_COLS: + assert col in df.columns, f"列 '{col}' が metrics.csv に存在しない" + assert pandas.isna(df[col].iloc[0]), f"列 '{col}' は NaN であるべき" + + def test_total_shape_columns_count_is_12_without_reference(self, tmp_path: Path): + """G1: shape_reference なしで shape 指標が 12 列(centroid5 + ARD3 + PCA2 + ssim/ncc2)追加されること. + + 仕様: metrics.csv に shape 指標 12 列を末尾追加 + (cli/evaluate.py _run_shape_metrics) + """ + luminance = _make_luminance_array(seed=4) + + # まず shape_metrics=False で基準列数を取得 + dir_false = tmp_path / "false" + dir_false.mkdir(exist_ok=True) + npy_false = dir_false / "luminance.npy" + numpy.save(npy_false, luminance) + config_false = _make_eval_yaml(dir_false, shape_metrics=False) + output_false = tmp_path / "output_false" + _run_main(npy_false, config_false, output_false) + df_base = pandas.read_csv(output_false / "metrics.csv") + base_cols = len(df_base.columns) + + # shape_metrics=True で実行 + dir_true = tmp_path / "true" + dir_true.mkdir(exist_ok=True) + npy_true = dir_true / "luminance.npy" + numpy.save(npy_true, luminance) + config_true = _make_eval_yaml(dir_true, shape_metrics=True) + output_true = tmp_path / "output_true" + _run_main(npy_true, config_true, output_true) + df_shape = pandas.read_csv(output_true / "metrics.csv") + + shape_cols = len(df_shape.columns) + added_cols = shape_cols - base_cols + assert added_cols == 12, f"shape 指標 12 列が追加されるべきだが {added_cols} 列追加された" + + +# =========================================================================== +# G2. shape_metrics=False(デフォルト)で既存 metrics.csv 列順序が変わらない +# =========================================================================== + + +class TestShapeMetricsBackwardCompat: + """G2: shape_metrics=False で既存列構造に影響しないこと(後方互換).""" + + def test_shape_metrics_false_no_shape_columns_in_csv(self, tmp_path: Path): + """G2: shape_metrics=False(デフォルト)で CSV に shape 指標列が含まれないこと. + + 仕様: shape_metrics=false (デフォルト) で既存 metrics.csv 列順序が変わらない + (TECH_11 §設定方法 後方互換) + """ + luminance = _make_luminance_array(seed=10) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml(tmp_path, shape_metrics=False) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + + assert result == 0 + df = pandas.read_csv(output_dir / "metrics.csv") + + shape_columns = [ + "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", + ] + for col in shape_columns: + assert col not in df.columns, f"shape_metrics=False なのに列 '{col}' が存在する" + + def test_core_columns_exist_regardless_of_shape_metrics(self, tmp_path: Path): + """G2 補足: shape_metrics の値に関わらず基本指標列は常に存在すること. + + 仕様: 既存 metrics.csv 列順序が変わらない(後方互換) + (SPEC_04 §metrics.csv カラム定義) + """ + luminance = _make_luminance_array(seed=11) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + core_columns = ["cov", "std", "max_min_ratio", "mean", "max", "min"] + + for sm_flag in [True, False]: + subdir = tmp_path / ("true" if sm_flag else "false") + subdir.mkdir(exist_ok=True) + sub_npy = subdir / "luminance.npy" + numpy.save(sub_npy, luminance) + config = _make_eval_yaml(subdir, shape_metrics=sm_flag) + output = subdir / "output" + ret = _run_main(sub_npy, config, output) + assert ret == 0 + df = pandas.read_csv(output / "metrics.csv") + for col in core_columns: + assert col in df.columns, f"shape_metrics={sm_flag} の場合に列 '{col}' が存在しない" + + def test_existing_yaml_without_shape_fields_runs_successfully(self, tmp_path: Path): + """G2 補足: shape_metrics フィールドを一切含まない既存 YAML 相当の設定で正常終了すること. + + 仕様: 既存の YAML は変更不要で動作する + (TECH_11 §設定方法 後方互換) + """ + luminance = _make_luminance_array(seed=12) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # shape_metrics / shape_reference フィールドを一切含まない YAML + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 64 + height: 64 + metrics: + cov: true + std: true + max_min: true + """) + config_path = tmp_path / "eval_legacy.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +# =========================================================================== +# G3. shape_reference の相対パスは config YAML のディレクトリ基準で解決される +# =========================================================================== + + +class TestShapeReferenceRelativePath: + """G3: shape_reference の相対パス解決が config YAML のディレクトリ基準であること.""" + + def test_relative_reference_path_resolved_from_config_dir(self, tmp_path: Path): + """G3: shape_reference の相対パスが config YAML のディレクトリを基準に解決されること. + + 仕様: shape_reference の相対パスは config YAML のディレクトリ基準で解決される + (cli/evaluate.py _run_shape_metrics: config_dir / ref_path) + """ + h, w = 64, 64 + + # サブディレクトリに config YAML を置く + config_dir = tmp_path / "configs" + config_dir.mkdir() + + # 参照画像を config と同じディレクトリに置く + ref_luminance = _make_luminance_array(seed=50, h=h, w=w) + ref_npy_path = config_dir / "reference.npy" + numpy.save(ref_npy_path, ref_luminance) + + # 評価対象の luminance.npy(ROI 全体が h×w に一致するよう設定) + target_luminance = _make_luminance_array(seed=51, h=h, w=w) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, target_luminance) + + # shape_reference を相対パスで指定(config と同ディレクトリにある参照画像) + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 64 + height: 64 + metrics: + cov: true + shape_metrics: true + shape_reference: "reference.npy" + """) + config_path = config_dir / "eval_with_ref.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + # SSIM / NCC が NaN でないこと(参照画像が正常に読み込まれた証拠) + assert not pandas.isna(df["ssim"].iloc[0]), "相対パスが正しく解決されれば SSIM は NaN でない" + assert not pandas.isna(df["ncc"].iloc[0]), "相対パスが正しく解決されれば NCC は NaN でない" + + def test_reference_file_not_found_gives_nan_not_error(self, tmp_path: Path): + """G3 補足: 参照ファイルが存在しない場合は NaN を返し,異常終了しないこと. + + 仕様: shape_reference ファイルが見つからない場合 → SSIM/NCC を NaN でスキップ + (cli/evaluate.py _run_shape_metrics: FileNotFoundError → nan) + """ + luminance = _make_luminance_array(seed=52) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 64 + height: 64 + metrics: + cov: true + shape_metrics: true + shape_reference: "nonexistent_file.npy" + """) + config_path = tmp_path / "eval_bad_ref.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + # 正常終了すること(ファイル不在でもクラッシュしない) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert pandas.isna(df["ssim"].iloc[0]) + assert pandas.isna(df["ncc"].iloc[0]) + + def test_absolute_reference_path_resolved_directly(self, tmp_path: Path): + """G3 補足: 絶対パスの場合はそのまま使用されること. + + 仕様: 絶対パスの場合はそのまま使用する + (cli/evaluate.py _run_shape_metrics: if not ref_path.is_absolute() → config_dir / ref_path) + """ + h, w = 64, 64 + ref_luminance = _make_luminance_array(seed=60, h=h, w=w) + ref_npy_path = tmp_path / "absolute_reference.npy" + numpy.save(ref_npy_path, ref_luminance) + + target_luminance = _make_luminance_array(seed=61, h=h, w=w) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, target_luminance) + + # 絶対パスで指定 + yaml_content = textwrap.dedent(f"""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 64 + height: 64 + metrics: + cov: true + shape_metrics: true + shape_reference: "{ref_npy_path.as_posix()}" + """) + config_dir = tmp_path / "subdir" + config_dir.mkdir() + config_path = config_dir / "eval_abs_ref.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + # SSIM / NCC が計算されていること(絶対パスで正しく解決された証拠) + assert not pandas.isna(df["ssim"].iloc[0]) + assert not pandas.isna(df["ncc"].iloc[0]) + + def test_shape_mismatch_reference_gives_nan_not_error(self, tmp_path: Path): + """G3 補足: 参照画像の shape が ROI と異なる場合も NaN を返し,異常終了しないこと. + + 仕様: shape が異なる場合は SSIM/NCC を自動スキップして NaN を格納する + (cli/evaluate.py _run_shape_metrics: ref_luminance.shape != roi_luminance.shape) + """ + # ターゲット: 64x64 + target_luminance = _make_luminance_array(seed=70, h=64, w=64) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, target_luminance) + + # 参照: 32x32(形状不一致) + ref_luminance = _make_luminance_array(seed=71, h=32, w=32) + ref_npy_path = tmp_path / "ref_small.npy" + numpy.save(ref_npy_path, ref_luminance) + + yaml_content = textwrap.dedent(f"""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 64 + height: 64 + metrics: + cov: true + shape_metrics: true + shape_reference: "{ref_npy_path.as_posix()}" + """) + config_path = tmp_path / "eval_mismatch_ref.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert pandas.isna(df["ssim"].iloc[0]) + assert pandas.isna(df["ncc"].iloc[0]) + + +# =========================================================================== +# _run_shape_metrics 関数単体テスト +# =========================================================================== + + +class TestRunShapeMetricsFunction: + """_run_shape_metrics 関数の直接テスト(CLI を介さない単体テスト).""" + + def test_returns_empty_dict_when_metrics_config_is_none(self, tmp_path: Path): + """metrics_config が None のとき {} を返すこと. + + 仕様: metrics_config is None → 無効扱いで空辞書を返す + (cli/evaluate.py _run_shape_metrics) + """ + roi_luminance = _make_luminance_array(seed=80) + result = _run_shape_metrics(roi_luminance, None, tmp_path) + assert result == {} + + def test_returns_empty_dict_when_shape_metrics_false(self, tmp_path: Path): + """MetricsConfig(shape_metrics=False) のとき {} を返すこと. + + 仕様: metrics_config.shape_metrics=False → 無効扱いで空辞書を返す + (cli/evaluate.py _run_shape_metrics) + """ + roi_luminance = _make_luminance_array(seed=81) + metrics_cfg = MetricsConfig(shape_metrics=False) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + assert result == {} + + def test_returns_12_keys_when_shape_metrics_true_no_reference(self, tmp_path: Path): + """shape_metrics=True + reference なしで 12 キーの辞書を返すこと. + + 仕様: shape_metrics=True で 12 列(centroid5+ARD3+PCA2+ssim/ncc2) + (cli/evaluate.py _run_shape_metrics) + """ + roi_luminance = _make_luminance_array(seed=82) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert len(result) == 12 + + def test_expected_keys_present_in_result(self, tmp_path: Path): + """_run_shape_metrics の戻り値に全 12 個の期待キーが含まれること. + + 仕様: cli/evaluate.py _run_shape_metrics の戻り値キー定義 + """ + roi_luminance = _make_luminance_array(seed=83) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + expected_keys = { + "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", + } + assert set(result.keys()) == expected_keys + + def test_ssim_and_ncc_are_nan_when_no_reference(self, tmp_path: Path): + """shape_reference=None のとき ssim / ncc が NaN であること. + + 仕様: shape_reference が無効なら float("nan") を格納する + (cli/evaluate.py _run_shape_metrics: reference_path_str is None → nan) + """ + roi_luminance = _make_luminance_array(seed=84) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert math.isnan(result["ssim"]) + assert math.isnan(result["ncc"]) + + def test_ssim_and_ncc_computed_when_valid_reference(self, tmp_path: Path): + """有効な shape_reference が指定されたとき ssim / ncc が NaN でないこと. + + 仕様: SSIM / NCC は shape_reference が指定されたときのみ算出する + (TECH_11 §3. SSIM, §4. NCC) + """ + h, w = 64, 64 + roi_luminance = _make_luminance_array(seed=85, h=h, w=w) + ref_luminance = _make_luminance_array(seed=86, h=h, w=w) + ref_path = tmp_path / "ref.npy" + numpy.save(ref_path, ref_luminance) + + # shape_reference に絶対パス文字列を指定 + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path)) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert not math.isnan(result["ssim"]), "有効な参照画像があれば SSIM は NaN でない" + assert not math.isnan(result["ncc"]), "有効な参照画像があれば NCC は NaN でない" + + def test_ssim_is_one_for_identical_reference(self, tmp_path: Path): + """同一画像を参照に指定した場合,ssim ≈ 1.0 になること. + + 仕様: 同一画像 → SSIM = 1.0 (calc_ssim 仕様) + """ + h, w = 64, 64 + roi_luminance = _make_luminance_array(seed=87, h=h, w=w) + ref_path = tmp_path / "ref_identical.npy" + numpy.save(ref_path, roi_luminance.copy()) + + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path)) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert result["ssim"] == pytest.approx(1.0, abs=1e-5) + + def test_ncc_is_one_for_identical_reference(self, tmp_path: Path): + """同一画像を参照に指定した場合,ncc ≈ 1.0 になること. + + 仕様: 同一画像 → NCC = 1.0 (calc_ncc 仕様) + """ + h, w = 64, 64 + roi_luminance = _make_luminance_array(seed=88, h=h, w=w) + ref_path = tmp_path / "ref_identical_ncc.npy" + numpy.save(ref_path, roi_luminance.copy()) + + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=str(ref_path)) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert result["ncc"] == pytest.approx(1.0, abs=1e-9) + + def test_all_non_ssim_ncc_values_are_finite(self, tmp_path: Path): + """ssim / ncc 以外の shape 指標(重心・ARD・PCA)が有限値であること. + + 仕様: 通常の輝度マップに対し全指標が計算可能であること + """ + roi_luminance = _make_luminance_array(seed=89) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + non_reference_keys = [ + "intensity_centroid_x_px", + "intensity_centroid_y_px", + "intensity_centroid_x_norm", + "intensity_centroid_y_norm", + "intensity_centroid_offset_norm", + "angular_variance", + "radial_variance", + "pca_primary_axis_angle_deg", + "pca_eccentricity", + ] + for key in non_reference_keys: + val = result[key] + assert math.isfinite(val), f"キー '{key}' の値 {val} が有限でない" + + def test_eccentricity_in_valid_range(self, tmp_path: Path): + """pca_eccentricity が [0, 1] の範囲内であること. + + 仕様: eccentricity = √(1 - secondary/primary), 0=完全円, 1=極端な楕円 + (TECH_11 §5. 輝度分布 PCA) + """ + roi_luminance = _make_luminance_array(seed=90) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert 0.0 <= result["pca_eccentricity"] <= 1.0 + + def test_pca_angle_in_valid_range(self, tmp_path: Path): + """pca_primary_axis_angle_deg が [-90, +90] の範囲内であること. + + 仕様: primary_axis_angle_deg は [-90, +90] の範囲 + (TECH_11 §5. 輝度分布 PCA) + """ + roi_luminance = _make_luminance_array(seed=91) + metrics_cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + result = _run_shape_metrics(roi_luminance, metrics_cfg, tmp_path) + + assert -90.0 <= result["pca_primary_axis_angle_deg"] <= 90.0 diff --git a/tests/evaluation/test_shape_metrics.py b/tests/evaluation/test_shape_metrics.py new file mode 100644 index 0000000..7efe6aa --- /dev/null +++ b/tests/evaluation/test_shape_metrics.py @@ -0,0 +1,1052 @@ +# pyright: reportArgumentType=none +"""evaluation/shape_metrics.py の仕様ベーステスト. + +仕様出典: +- docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md +- docs/04_SPEC/SPEC_04_評価仕様.md +- src/sm_tias_lightsim/evaluation/shape_metrics.py(docstring) + +テスト方針: + - 純関数 A〜E は乱数固定で確定的テストとする(GUIDE_08) + - 既知の合成画像に対して期待される振る舞いをテストする(実装詳細ではない) + - NaN/inf 境界条件もカバーする +""" + +from __future__ import annotations + +import math + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.shape_metrics import ( + calc_angular_radial_decomposition, + calc_intensity_centroid, + calc_intensity_pca, + calc_ncc, + calc_ssim, +) + +# =========================================================================== +# ヘルパー関数(テスト用合成画像の生成) +# =========================================================================== + + +def _uniform_image(h: int = 64, w: int = 64, val: float = 100.0) -> numpy.ndarray: + """全画素が一定値の均一画像.""" + return numpy.full((h, w), val, dtype=numpy.float64) + + +def _right_half_image(h: int = 64, w: int = 64) -> numpy.ndarray: + """右半分のみ輝度がある画像(左半分は 0).""" + img = numpy.zeros((h, w), dtype=numpy.float64) + img[:, w // 2 :] = 100.0 + return img + + +def _bottom_half_image(h: int = 64, w: int = 64) -> numpy.ndarray: + """下半分のみ輝度がある画像(上半分は 0).""" + img = numpy.zeros((h, w), dtype=numpy.float64) + img[h // 2 :, :] = 100.0 + return img + + +def _radial_image(h: int = 64, w: int = 64) -> numpy.ndarray: + """完全同心円パターン: I = f(r) のみ(角度依存なし).""" + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + r = numpy.sqrt(((xs - cx) / (cx if cx > 0 else 1)) ** 2 + ((ys - cy) / (cy if cy > 0 else 1)) ** 2) + return numpy.clip(1.0 - r * 0.5, 0.01, None) + + +def _angular_only_image(h: int = 64, w: int = 64) -> numpy.ndarray: + """純粋な角度依存パターン: I = f(θ) のみ(半径依存なし). + + 上半分 (θ ∈ [0, π]) が明るく,下半分 (θ ∈ [π, 2π]) が暗い. + """ + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + theta = numpy.arctan2(ys - cy, xs - cx) # [-π, π] + # 角度のみに依存: 上半分 (y < cy → theta < 0) を明るく + return numpy.where(theta < 0, 2.0, 1.0) + + +def _y_gradient_image(h: int = 64, w: int = 64) -> numpy.ndarray: + """Y 軸方向の単調勾配(下が明るく上が暗い).""" + brightness = numpy.linspace(1.3, 0.7, h, dtype=numpy.float64).reshape(-1, 1) + return numpy.tile(brightness, (1, w)) + + +def _central_peak_gaussian(h: int = 64, w: int = 64) -> numpy.ndarray: + """中央ピーク+ガウス減衰の真円対称パターン.""" + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + sigma = min(h, w) / 4.0 + r2 = ((xs - cx) ** 2 + (ys - cy) ** 2) / (2.0 * sigma ** 2) + return numpy.exp(-r2) + 0.1 + + +def _elongated_ellipse(h: int = 64, w: int = 128) -> numpy.ndarray: + """細長い楕円(X 方向に 2:1 アスペクト比)の輝度分布.""" + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) + # X 方向を Y 方向の 2 倍引き伸ばした楕円 + sigma_x = w / 4.0 + sigma_y = h / 8.0 # Y 方向は非常に狭い + r2 = ((xs - cx) / sigma_x) ** 2 + ((ys - cy) / sigma_y) ** 2 + return numpy.exp(-r2 / 2.0) + 0.05 + + +# =========================================================================== +# A. calc_intensity_centroid +# =========================================================================== + + +class TestCalcIntensityCentroid: + """calc_intensity_centroid の仕様テスト(仕様 A1〜A7).""" + + # ----------------------------------------------------------------------- + # A1: 完全均一画像 → centroid は画像中心 + # ----------------------------------------------------------------------- + + def test_uniform_image_centroid_is_center_pixels(self): + """A1: 完全均一画像の重心が画像中心 (cx=W/2, cy=H/2) に一致すること. + + 仕様: 完全均一画像 → centroid は画像中心 (cx=W/2, cy=H/2) + (TECH_11 §1. 輝度重心) + """ + h, w = 64, 64 + img = _uniform_image(h, w) + result = calc_intensity_centroid(img) + + cx_expected = (w - 1) / 2.0 + cy_expected = (h - 1) / 2.0 + assert result["centroid_x_px"] == pytest.approx(cx_expected, abs=1e-9) + assert result["centroid_y_px"] == pytest.approx(cy_expected, abs=1e-9) + + def test_uniform_image_centroid_norm_near_zero(self): + """A1: 完全均一画像の正規化重心が ≈ 0 になること. + + 仕様: centroid_x_norm ≈ 0, centroid_y_norm ≈ 0 + (TECH_11 §1. 輝度重心) + """ + img = _uniform_image() + result = calc_intensity_centroid(img) + + assert result["centroid_x_norm"] == pytest.approx(0.0, abs=1e-9) + assert result["centroid_y_norm"] == pytest.approx(0.0, abs=1e-9) + + def test_uniform_image_centroid_offset_near_zero(self): + """A1: 完全均一画像の centroid_offset_norm ≈ 0 になること. + + 仕様: centroid_offset_norm = √(x²+y²) ≈ 0 + (TECH_11 §1. 輝度重心) + """ + img = _uniform_image() + result = calc_intensity_centroid(img) + assert result["centroid_offset_norm"] == pytest.approx(0.0, abs=1e-9) + + # ----------------------------------------------------------------------- + # A2: 右半分のみ輝度 → centroid_x_norm > 0(右寄り) + # ----------------------------------------------------------------------- + + def test_right_half_image_centroid_x_positive(self): + """A2: 右半分のみ輝度がある場合,centroid_x_norm > 0(右寄り)になること. + + 仕様: 画像の右半分のみ輝度がある場合 → centroid_x_norm > 0 + (TECH_11 §1. 輝度重心) + """ + img = _right_half_image() + result = calc_intensity_centroid(img) + assert result["centroid_x_norm"] > 0.0 + + def test_right_half_image_centroid_y_near_zero(self): + """A2: 右半分のみ輝度の場合,Y 方向の重心は中心付近に留まること. + + 仕様: 横対称分布では centroid_y_norm ≈ 0 + """ + img = _right_half_image() + result = calc_intensity_centroid(img) + assert abs(result["centroid_y_norm"]) < 0.1 + + # ----------------------------------------------------------------------- + # A3: 下半分のみ輝度 → centroid_y_norm > 0(下寄り) + # ----------------------------------------------------------------------- + + def test_bottom_half_image_centroid_y_positive(self): + """A3: 下半分のみ輝度がある場合,centroid_y_norm > 0(下寄り,画像座標下方向)になること. + + 仕様: 画像の下半分のみ輝度がある場合 → centroid_y_norm > 0 + (TECH_11 §1. 輝度重心,画像座標は下が正方向) + """ + img = _bottom_half_image() + result = calc_intensity_centroid(img) + assert result["centroid_y_norm"] > 0.0 + + # ----------------------------------------------------------------------- + # A4: mask=None と全 True mask で結果が同一 + # ----------------------------------------------------------------------- + + def test_none_mask_equals_all_true_mask(self): + """A4: mask=None と全 True mask で結果が同一であること. + + 仕様: mask=None なら全画素を対象とする + (shape_metrics.py calc_intensity_centroid docstring) + """ + rng = numpy.random.RandomState(42) + img = rng.uniform(50.0, 150.0, (32, 32)) + mask_all_true = numpy.ones((32, 32), dtype=bool) + + result_no_mask = calc_intensity_centroid(img) + result_all_mask = calc_intensity_centroid(img, mask=mask_all_true) + + assert result_no_mask["centroid_x_px"] == pytest.approx(result_all_mask["centroid_x_px"], rel=1e-12) + assert result_no_mask["centroid_y_px"] == pytest.approx(result_all_mask["centroid_y_px"], rel=1e-12) + assert result_no_mask["centroid_x_norm"] == pytest.approx(result_all_mask["centroid_x_norm"], rel=1e-12) + assert result_no_mask["centroid_y_norm"] == pytest.approx(result_all_mask["centroid_y_norm"], rel=1e-12) + + # ----------------------------------------------------------------------- + # A5: 負の輝度値は ReLU で 0 として扱う + # ----------------------------------------------------------------------- + + def test_negative_values_are_clipped_to_zero(self): + """A5: 負の輝度値は ReLU で 0 として扱い,重心計算が破壊されないこと. + + 仕様: 負値は ReLU で 0 に(負値ノイズで重心が破壊されない) + (shape_metrics.py calc_intensity_centroid docstring) + """ + # 左半分に大きな負値ノイズを入れる + h, w = 64, 64 + img = numpy.full((h, w), 50.0, dtype=numpy.float64) + img[:, : w // 2] = -1000.0 # 左半分は大きな負値 + + # ReLU 後は右半分のみが有効 → 重心は右寄りになるはず + result = calc_intensity_centroid(img) + assert result["centroid_x_norm"] > 0.0 + + def test_negative_values_treated_same_as_zero(self): + """A5: 負値と 0 を置き換えた場合と同じ結果になること. + + 仕様: 負値は ReLU で 0 → 0 に置き換えた画像と等価 + """ + rng = numpy.random.RandomState(7) + img = rng.uniform(-30.0, 100.0, (32, 32)) + img_clipped = numpy.maximum(img, 0.0) + + result_orig = calc_intensity_centroid(img) + result_clipped = calc_intensity_centroid(img_clipped) + + assert result_orig["centroid_x_px"] == pytest.approx(result_clipped["centroid_x_px"], rel=1e-10) + assert result_orig["centroid_y_px"] == pytest.approx(result_clipped["centroid_y_px"], rel=1e-10) + + # ----------------------------------------------------------------------- + # A6: 戻り値は dict で 5 キーを全て持つ + # ----------------------------------------------------------------------- + + def test_return_dict_has_five_keys(self): + """A6: 戻り値は dict で 5 キーを全て持つこと. + + 仕様: centroid_x_px / centroid_y_px / centroid_x_norm / centroid_y_norm / + centroid_offset_norm の 5 キー + (shape_metrics.py calc_intensity_centroid Returns) + """ + img = _uniform_image() + result = calc_intensity_centroid(img) + + expected_keys = { + "centroid_x_px", + "centroid_y_px", + "centroid_x_norm", + "centroid_y_norm", + "centroid_offset_norm", + } + assert set(result.keys()) == expected_keys + + def test_all_return_values_are_float(self): + """A6: 戻り値の全要素が float であること. + + 仕様: Returns 辞書の各値は float + (shape_metrics.py calc_intensity_centroid Returns) + """ + img = _uniform_image() + result = calc_intensity_centroid(img) + for key, val in result.items(): + assert isinstance(val, float), f"{key} は float でなければなりません" + + # ----------------------------------------------------------------------- + # A7: 形状不一致の mask は ValueError + # ----------------------------------------------------------------------- + + def test_shape_mismatch_mask_raises_value_error(self): + """A7: 形状不一致の mask は ValueError を発生させること. + + 仕様: mask の shape が image と異なる場合 → ValueError + (shape_metrics.py calc_intensity_centroid Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + wrong_mask = numpy.ones((16, 16), dtype=bool) + + with pytest.raises(ValueError, match="shape"): + calc_intensity_centroid(img, mask=wrong_mask) + + def test_all_zero_after_relu_raises_value_error(self): + """A7 補足: ReLU 後に有効ピクセルが全ゼロの場合は ValueError を発生させること. + + 仕様: 有効ピクセルの輝度総和が 0 の場合 → ValueError + (shape_metrics.py calc_intensity_centroid Raises) + """ + img = numpy.full((32, 32), -1.0, dtype=numpy.float64) # 全て負値 → ReLU 後全ゼロ + with pytest.raises(ValueError): + calc_intensity_centroid(img) + + # ----------------------------------------------------------------------- + # 追加: centroid_offset_norm の計算確認 + # ----------------------------------------------------------------------- + + def test_centroid_offset_norm_is_l2_of_x_y_norm(self): + """centroid_offset_norm が √(x_norm² + y_norm²) と等しいこと. + + 仕様: centroid_offset_norm = √(x²+y²) (TECH_11 §1.) + """ + img = _right_half_image() + result = calc_intensity_centroid(img) + expected = math.sqrt(result["centroid_x_norm"] ** 2 + result["centroid_y_norm"] ** 2) + assert result["centroid_offset_norm"] == pytest.approx(expected, rel=1e-10) + + +# =========================================================================== +# B. calc_angular_radial_decomposition +# =========================================================================== + + +class TestCalcAngularRadialDecomposition: + """calc_angular_radial_decomposition の仕様テスト(仕様 B1〜B6).""" + + # ----------------------------------------------------------------------- + # B1: 完全均一画像 → angular_variance ≈ 0, radial_variance ≈ 0, angular_ratio = NaN + # ----------------------------------------------------------------------- + + def test_uniform_image_angular_variance_near_zero(self): + """B1: 完全均一画像で angular_variance ≈ 0 になること. + + 仕様: 完全均一画像 → angular_variance ≈ 0 + (TECH_11 §2. 角度-放射分解) + """ + img = _uniform_image() + result = calc_angular_radial_decomposition(img) + assert result["angular_variance"] == pytest.approx(0.0, abs=1e-9) + + def test_uniform_image_radial_variance_near_zero(self): + """B1: 完全均一画像で radial_variance ≈ 0 になること. + + 仕様: 完全均一画像 → radial_variance ≈ 0 + (TECH_11 §2. 角度-放射分解) + """ + img = _uniform_image() + result = calc_angular_radial_decomposition(img) + assert result["radial_variance"] == pytest.approx(0.0, abs=1e-9) + + def test_uniform_image_angular_ratio_is_nan(self): + """B1: 完全均一画像で angular_ratio = NaN になること. + + 仕様: 完全均一画像(全 variance = 0)では NaN を返す + (shape_metrics.py calc_angular_radial_decomposition Returns) + """ + img = _uniform_image() + result = calc_angular_radial_decomposition(img) + assert math.isnan(result["angular_ratio"]) + + # ----------------------------------------------------------------------- + # B2: 完全同心円パターン → angular_variance ≈ 0, angular_ratio ≈ 0 + # ----------------------------------------------------------------------- + + def test_radial_only_pattern_angular_variance_near_zero(self): + """B2: 完全同心円パターンで angular_variance が低くなること. + + 仕様: 完全同心円パターン (I = f(r) のみ) → angular_variance ≈ 0 + (TECH_11 §2. 角度-放射分解 angular_ratio ≈ 0 の意味) + """ + img = _radial_image(h=64, w=64) + result = calc_angular_radial_decomposition(img) + # 同心円なので角度分散は放射分散に比べて圧倒的に小さい + assert result["angular_variance"] < result["radial_variance"] * 0.3 + + def test_radial_only_pattern_angular_ratio_near_zero(self): + """B2: 完全同心円パターンで angular_ratio ≈ 0 になること. + + 仕様: 完全同心円パターン → angular_ratio ≈ 0 + (TECH_11 §2. angular_ratio ≈ 0 の意味: 完全同心円) + """ + img = _radial_image(h=64, w=64) + result = calc_angular_radial_decomposition(img) + assert result["angular_ratio"] == pytest.approx(0.0, abs=0.1) + + # ----------------------------------------------------------------------- + # B3: 純粋な角度依存パターン → radial_variance ≈ 0, angular_ratio ≈ 1 + # ----------------------------------------------------------------------- + + def test_angular_only_pattern_radial_variance_small(self): + """B3: 純粋な角度依存パターンで radial_variance が小さくなること. + + 仕様: 純粋な角度依存 (I = f(θ) のみ) → radial_variance ≈ 0 + (TECH_11 §2. angular_ratio ≈ 1 の意味) + """ + img = _angular_only_image(h=64, w=64) + result = calc_angular_radial_decomposition(img) + # 角度のみ依存なので放射分散は角度分散に比べて小さい + if result["angular_variance"] > 1e-10: + assert result["angular_ratio"] > 0.5 + + def test_angular_only_pattern_angular_ratio_near_one(self): + """B3: 純粋な角度依存パターンで angular_ratio が高くなること(1 に近い). + + 仕様: 純粋な角度依存 (I = f(θ) のみ) → angular_ratio ≈ 1 + (TECH_11 §2. 角度-放射分解) + """ + img = _angular_only_image(h=64, w=64) + result = calc_angular_radial_decomposition(img) + # 純粋な角度依存パターン → angular_ratio > 0.5 + assert result["angular_ratio"] > 0.5 + + # ----------------------------------------------------------------------- + # B4: Y 軸方向の単調勾配 → angular_ratio が 0.3〜0.5 程度 + # ----------------------------------------------------------------------- + + def test_y_gradient_angular_ratio_higher_than_radial_only(self): + """B4: Y 軸方向の単調勾配では同心円パターンより angular_ratio が高くなること. + + 仕様: Y 軸方向の単調勾配 → angular_ratio が 0.3〜0.5 程度 + (TECH_11 §2. 値域と解釈,Y 単調勾配に典型的) + + 注意: 純粋な Y 軸方向の均一勾配(横縞)は極座標上で角度方向の変動が支配的 + (angular_ratio が高値になる).実際のシミュ輝度マップでは noise/非一様性で + 中程度になる(TECH_11 §2 の 0.3〜0.5 は実機シミュの典型値). + ここでは「同心円より angular_ratio が高い」という方向性のみを検証する. + """ + y_grad = _y_gradient_image(h=128, w=128) + radial = _radial_image(h=128, w=128) + + result_y_grad = calc_angular_radial_decomposition(y_grad) + result_radial = calc_angular_radial_decomposition(radial) + + # Y 単調勾配は同心円より angular_ratio が高い(方向性が強い) + assert result_y_grad["angular_ratio"] > result_radial["angular_ratio"] + + # ----------------------------------------------------------------------- + # B5: 戻り値は dict で 3 キーを全て持つ + # ----------------------------------------------------------------------- + + def test_return_dict_has_three_keys(self): + """B5: 戻り値は dict で 3 キーを全て持つこと. + + 仕様: angular_variance / radial_variance / angular_ratio の 3 キー + (shape_metrics.py calc_angular_radial_decomposition Returns) + """ + img = _uniform_image() + result = calc_angular_radial_decomposition(img) + + expected_keys = {"angular_variance", "radial_variance", "angular_ratio"} + assert set(result.keys()) == expected_keys + + def test_all_return_values_are_float_or_nan(self): + """B5: 戻り値の全要素が float(NaN を含む)であること. + + 仕様: Returns 辞書の各値は float + """ + img = _y_gradient_image() + result = calc_angular_radial_decomposition(img) + for key, val in result.items(): + assert isinstance(val, float), f"{key} は float でなければなりません" + + # ----------------------------------------------------------------------- + # B6: n_radial_bins / n_angular_bins のデフォルト値が想定どおり(20 / 36) + # ----------------------------------------------------------------------- + + def test_default_bins_are_20_and_36(self): + """B6: n_radial_bins のデフォルト値が 20, n_angular_bins のデフォルト値が 36 であること. + + 仕様: n_radial_bins: int = 20, n_angular_bins: int = 36 + (shape_metrics.py calc_angular_radial_decomposition signature) + """ + import inspect + sig = inspect.signature(calc_angular_radial_decomposition) + assert sig.parameters["n_radial_bins"].default == 20 + assert sig.parameters["n_angular_bins"].default == 36 + + def test_custom_bins_accepted(self): + """B6 補足: カスタムビン数を指定しても正常に動作すること. + + 仕様: n_radial_bins / n_angular_bins でビン数を指定可能 + (shape_metrics.py calc_angular_radial_decomposition signature) + """ + img = _radial_image() + result = calc_angular_radial_decomposition(img, n_radial_bins=10, n_angular_bins=18) + assert isinstance(result, dict) + assert "angular_ratio" in result + + # ----------------------------------------------------------------------- + # 追加: 非負値の保証 + # ----------------------------------------------------------------------- + + def test_variances_are_non_negative(self): + """angular_variance と radial_variance は非負であること. + + 仕様: 分散は定義上 ≥ 0 + """ + rng = numpy.random.RandomState(123) + img = rng.uniform(50.0, 150.0, (64, 64)) + result = calc_angular_radial_decomposition(img) + assert result["angular_variance"] >= 0.0 + assert result["radial_variance"] >= 0.0 + + def test_angular_ratio_in_0_to_1_range_for_non_uniform(self): + """angular_ratio が非均一画像では [0, 1] の範囲内であること. + + 仕様: angular_ratio = angular_variance / (angular_variance + radial_variance) + (shape_metrics.py calc_angular_radial_decomposition) + """ + rng = numpy.random.RandomState(99) + img = rng.uniform(50.0, 150.0, (64, 64)) + result = calc_angular_radial_decomposition(img) + if not math.isnan(result["angular_ratio"]): + assert 0.0 <= result["angular_ratio"] <= 1.0 + + def test_shape_mismatch_mask_raises_value_error(self): + """形状不一致の mask は ValueError を発生させること. + + 仕様: mask の shape が image と異なる場合 → ValueError + (shape_metrics.py calc_angular_radial_decomposition Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + wrong_mask = numpy.ones((16, 16), dtype=bool) + + with pytest.raises(ValueError, match="shape"): + calc_angular_radial_decomposition(img, mask=wrong_mask) + + +# =========================================================================== +# C. calc_ssim +# =========================================================================== + + +class TestCalcSsim: + """calc_ssim の仕様テスト(仕様 C1〜C5).""" + + # ----------------------------------------------------------------------- + # C1: 同一画像 → SSIM = 1.0 + # ----------------------------------------------------------------------- + + def test_identical_images_ssim_is_one(self): + """C1: 同一画像を渡した場合,SSIM = 1.0 になること. + + 仕様: 同一画像 → SSIM = 1.0 + (TECH_11 §3. SSIM) + """ + rng = numpy.random.RandomState(0) + img = rng.uniform(50.0, 200.0, (64, 64)) + ssim_val = calc_ssim(img, img.copy()) + assert ssim_val == pytest.approx(1.0, abs=1e-6) + + # ----------------------------------------------------------------------- + # C2: 完全逆相関画像 → SSIM < 1 + # ----------------------------------------------------------------------- + + def test_inverted_image_ssim_less_than_one(self): + """C2: 完全逆相関画像(image = -reference)で SSIM < 1 になること. + + 仕様: 完全逆相関画像 → SSIM < 1 + (TECH_11 §3. SSIM, 0〜1 で数値化) + """ + rng = numpy.random.RandomState(1) + ref = rng.uniform(10.0, 100.0, (64, 64)) + img = -ref # 逆相関 + + ssim_val = calc_ssim(img, ref) + assert ssim_val < 1.0 + + # ----------------------------------------------------------------------- + # C3: 異なる形 → SSIM < 1 + # ----------------------------------------------------------------------- + + def test_different_pattern_ssim_less_than_one(self): + """C3: 異なる形(縦勾配 vs 横勾配)で SSIM < 1 になること. + + 仕様: 異なる形 (例: 縦勾配 vs 横勾配) → SSIM < 1 + (TECH_11 §3. SSIM, 形(空間構造)の類似性を 0〜1 で数値化) + """ + h, w = 64, 64 + # 縦勾配(Y 軸方向) + v_grad = numpy.linspace(1.0, 2.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w)) + # 横勾配(X 軸方向) + h_grad = numpy.ones((h, w)) * numpy.linspace(1.0, 2.0, w, dtype=numpy.float64).reshape(1, -1) + + ssim_val = calc_ssim(v_grad, h_grad) + assert ssim_val < 1.0 + + # ----------------------------------------------------------------------- + # C4: 形状不一致は ValueError + # ----------------------------------------------------------------------- + + def test_shape_mismatch_raises_value_error(self): + """C4: 形状不一致の image と reference は ValueError を発生させること. + + 仕様: 形状不一致の image と reference は ValueError + (shape_metrics.py calc_ssim Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + ref = numpy.ones((64, 64), dtype=numpy.float64) + + with pytest.raises(ValueError, match="shape"): + calc_ssim(img, ref) + + def test_mask_shape_mismatch_raises_value_error(self): + """C4 補足: mask の形状不一致も ValueError を発生させること. + + 仕様: mask の shape が image と異なる場合 → ValueError + (shape_metrics.py calc_ssim Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + ref = numpy.ones((32, 32), dtype=numpy.float64) + wrong_mask = numpy.ones((16, 16), dtype=bool) + + with pytest.raises(ValueError, match="shape"): + calc_ssim(img, ref, mask=wrong_mask) + + # ----------------------------------------------------------------------- + # C5: data_range を明示指定 → 動作すること + # ----------------------------------------------------------------------- + + def test_explicit_data_range_accepted(self): + """C5: data_range を明示指定しても正常に動作すること. + + 仕様: data_range を明示指定 → skimage に正しく渡される + (shape_metrics.py calc_ssim Args) + """ + rng = numpy.random.RandomState(2) + img = rng.uniform(0.0, 1.0, (64, 64)) + # data_range=1.0 を明示指定 + ssim_val = calc_ssim(img, img.copy(), data_range=1.0) + assert ssim_val == pytest.approx(1.0, abs=1e-6) + + def test_explicit_data_range_changes_result(self): + """C5 補足: data_range の指定が結果に影響すること(同一画像ではなく異なる画像で確認). + + 仕様: data_range を変えると SSIM の絶対値は変わりうる + """ + rng = numpy.random.RandomState(3) + img = rng.uniform(0.0, 255.0, (64, 64)) + ref = rng.uniform(0.0, 255.0, (64, 64)) + + # data_range が None(自動)と明示指定で同じ画像から同じ結果が得られること + ssim_auto = calc_ssim(img, ref, data_range=None) + ssim_explicit = calc_ssim(img, ref, data_range=float(max(img.max(), ref.max()))) + assert ssim_auto == pytest.approx(ssim_explicit, abs=1e-6) + + # ----------------------------------------------------------------------- + # 追加: 戻り値は float であること + # ----------------------------------------------------------------------- + + def test_return_value_is_float(self): + """戻り値が float であること. + + 仕様: Returns SSIM 値(float, 0〜1) + (shape_metrics.py calc_ssim Returns) + """ + img = _uniform_image() + result = calc_ssim(img, img.copy()) + assert isinstance(result, float) + + +# =========================================================================== +# D. calc_ncc +# =========================================================================== + + +class TestCalcNcc: + """calc_ncc の仕様テスト(仕様 D1〜D5).""" + + # ----------------------------------------------------------------------- + # D1: 同一画像 → NCC = 1.0 + # ----------------------------------------------------------------------- + + def test_identical_images_ncc_is_one(self): + """D1: 同一画像を渡した場合,NCC = 1.0 になること. + + 仕様: 同一画像 → NCC = 1.0 + (TECH_11 §4. NCC) + """ + rng = numpy.random.RandomState(10) + img = rng.uniform(50.0, 200.0, (64, 64)) + ncc_val = calc_ncc(img, img.copy()) + assert ncc_val == pytest.approx(1.0, abs=1e-10) + + # ----------------------------------------------------------------------- + # D2: 完全逆相関 → NCC ≈ -1.0 + # ----------------------------------------------------------------------- + + def test_anticorrelated_image_ncc_is_minus_one(self): + """D2: 完全逆相関 (image = -reference + const) で NCC ≈ -1.0 になること. + + 仕様: 完全逆相関 (image = -reference + const) → NCC ≈ -1.0 + (TECH_11 §4. NCC 値域: 1=完全相関, 0=無相関, -1=逆相関) + """ + rng = numpy.random.RandomState(11) + ref = rng.uniform(10.0, 100.0, (64, 64)) + # NCC は mean を引くため定数オフセット (const) は影響しない + img = -ref + 50.0 # 完全逆相関 + + ncc_val = calc_ncc(img, ref) + assert ncc_val == pytest.approx(-1.0, abs=1e-10) + + # ----------------------------------------------------------------------- + # D3: 直交パターン(独立な勾配方向)→ |NCC| < 0.5 + # ----------------------------------------------------------------------- + + def test_orthogonal_patterns_ncc_near_zero(self): + """D3: 直交パターン(独立な勾配方向)で |NCC| < 0.5 になること. + + 仕様: 直交パターン(独立な勾配方向)→ |NCC| < 0.5 + (TECH_11 §4. NCC 形の類似性) + """ + h, w = 64, 64 + # 縦勾配(Y 軸方向) + v_grad = numpy.linspace(0.0, 1.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w)) + # 横勾配(X 軸方向) + h_grad = numpy.ones((h, w)) * numpy.linspace(0.0, 1.0, w, dtype=numpy.float64).reshape(1, -1) + + ncc_val = calc_ncc(v_grad, h_grad) + assert abs(ncc_val) < 0.5 + + # ----------------------------------------------------------------------- + # D4: 絶対輝度のスケール不変 + # ----------------------------------------------------------------------- + + def test_ncc_invariant_to_absolute_scale(self): + """D4: 絶対輝度のスケール不変 — image を 2 倍しても NCC は不変であること. + + 仕様: 絶対輝度スケールに依存しない形の類似性指標 + (TECH_11 §4. NCC, mean を引いて相関を取るため) + """ + rng = numpy.random.RandomState(12) + ref = rng.uniform(50.0, 200.0, (64, 64)) + img = rng.uniform(50.0, 200.0, (64, 64)) + + ncc_original = calc_ncc(img, ref) + ncc_scaled = calc_ncc(img * 2.0, ref) # image を 2 倍 + + assert ncc_original == pytest.approx(ncc_scaled, abs=1e-10) + + def test_ncc_invariant_to_reference_scale(self): + """D4 補足: reference を 2 倍しても NCC は不変であること. + + 仕様: 絶対輝度スケールに依存しない + """ + rng = numpy.random.RandomState(13) + ref = rng.uniform(50.0, 200.0, (64, 64)) + img = rng.uniform(50.0, 200.0, (64, 64)) + + ncc_original = calc_ncc(img, ref) + ncc_ref_scaled = calc_ncc(img, ref * 3.0) + + assert ncc_original == pytest.approx(ncc_ref_scaled, abs=1e-10) + + # ----------------------------------------------------------------------- + # D5: 形状不一致は ValueError + # ----------------------------------------------------------------------- + + def test_shape_mismatch_raises_value_error(self): + """D5: 形状不一致の image と reference は ValueError を発生させること. + + 仕様: 形状不一致は ValueError + (shape_metrics.py calc_ncc Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + ref = numpy.ones((64, 64), dtype=numpy.float64) + + with pytest.raises(ValueError, match="shape"): + calc_ncc(img, ref) + + def test_mask_shape_mismatch_raises_value_error(self): + """D5 補足: mask の形状不一致も ValueError を発生させること. + + 仕様: mask の shape が image と異なる場合 → ValueError + (shape_metrics.py calc_ncc Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + ref = numpy.ones((32, 32), dtype=numpy.float64) + wrong_mask = numpy.ones((16, 16), dtype=bool) + + with pytest.raises(ValueError, match="shape"): + calc_ncc(img, ref, mask=wrong_mask) + + # ----------------------------------------------------------------------- + # 追加: 均一画像での NaN 返却 + # ----------------------------------------------------------------------- + + def test_uniform_image_returns_nan(self): + """均一画像(分散ゼロ)では NaN を返すこと. + + 仕様: 分散がゼロの場合(均一画像)は NaN を返す + (shape_metrics.py calc_ncc Returns) + """ + img = numpy.full((32, 32), 100.0, dtype=numpy.float64) + ref = numpy.full((32, 32), 200.0, dtype=numpy.float64) + + ncc_val = calc_ncc(img, ref) + assert math.isnan(ncc_val) + + def test_ncc_result_in_minus_one_to_one_range(self): + """NCC が [-1, 1] の範囲内であること. + + 仕様: NCC 値(float, -1〜+1) + (shape_metrics.py calc_ncc Returns) + """ + rng = numpy.random.RandomState(14) + img = rng.uniform(50.0, 200.0, (64, 64)) + ref = rng.uniform(50.0, 200.0, (64, 64)) + + ncc_val = calc_ncc(img, ref) + assert -1.0 <= ncc_val <= 1.0 + + def test_ncc_return_value_is_float(self): + """戻り値が float であること. + + 仕様: Returns NCC 値(float, -1〜+1) + (shape_metrics.py calc_ncc Returns) + """ + rng = numpy.random.RandomState(15) + img = rng.uniform(0.0, 100.0, (32, 32)) + ref = rng.uniform(0.0, 100.0, (32, 32)) + result = calc_ncc(img, ref) + assert isinstance(result, float) + + # ----------------------------------------------------------------------- + # 追加: mask を使ったテスト + # ----------------------------------------------------------------------- + + def test_mask_restricts_calculation_to_true_pixels(self): + """mask=True のピクセルのみが計算対象になること. + + 仕様: mask=True のピクセルのみ計算対象 + (shape_metrics.py calc_ncc Args) + """ + h, w = 64, 64 + img = numpy.zeros((h, w), dtype=numpy.float64) + ref = numpy.zeros((h, w), dtype=numpy.float64) + + # 右半分を同一パターンにする + img[:, w // 2 :] = numpy.linspace(10.0, 90.0, h, dtype=numpy.float64).reshape(-1, 1) + ref[:, w // 2 :] = img[:, w // 2 :] + + # 右半分のみをマスク + mask = numpy.zeros((h, w), dtype=bool) + mask[:, w // 2 :] = True + + ncc_val = calc_ncc(img, ref, mask=mask) + assert ncc_val == pytest.approx(1.0, abs=1e-10) + + +# =========================================================================== +# E. calc_intensity_pca +# =========================================================================== + + +class TestCalcIntensityPca: + """calc_intensity_pca の仕様テスト(仕様 E1〜E6).""" + + # ----------------------------------------------------------------------- + # E1: 完全均一画像 → eccentricity ≈ 0 + # ----------------------------------------------------------------------- + + def test_uniform_image_eccentricity_near_zero(self): + """E1: 完全均一画像で eccentricity ≈ 0 になること. + + 仕様: 完全均一画像 → eccentricity ≈ 0(eigenvalue 同値) + (TECH_11 §5. 輝度分布 PCA, eccentricity = 0 で完全円形) + """ + img = _uniform_image(h=64, w=64) + result = calc_intensity_pca(img) + assert result["eccentricity"] == pytest.approx(0.0, abs=0.1) + + # ----------------------------------------------------------------------- + # E2: Y 軸方向の強い勾配 → 主軸角度の一貫性確認 + # ----------------------------------------------------------------------- + + def test_y_gradient_primary_axis_angle_is_consistent(self): + """E2: Y 軸方向の強い勾配で主軸角度が一貫した値を返すこと. + + 仕様: Y 軸方向の強い勾配 → primary_axis_angle_deg が一貫した値 + (TECH_11 §5. 輝度分布 PCA) + """ + img = _y_gradient_image(h=128, w=64) + result1 = calc_intensity_pca(img) + result2 = calc_intensity_pca(img) + # 同一入力で同一結果 + assert result1["primary_axis_angle_deg"] == pytest.approx(result2["primary_axis_angle_deg"], abs=1e-9) + + # ----------------------------------------------------------------------- + # E3: 真円対称パターン → eccentricity ≈ 0 + # ----------------------------------------------------------------------- + + def test_circular_gaussian_eccentricity_near_zero(self): + """E3: 真円対称パターン(中央ピーク + ガウス)で eccentricity ≈ 0 になること. + + 仕様: 真円対称パターン (中央ピーク + ガウス) → eccentricity ≈ 0 + (TECH_11 §5. 輝度分布 PCA) + """ + img = _central_peak_gaussian(h=64, w=64) + result = calc_intensity_pca(img) + assert result["eccentricity"] < 0.3 + + # ----------------------------------------------------------------------- + # E4: 細長い楕円パターン → eccentricity が高値 + # ----------------------------------------------------------------------- + + def test_elongated_ellipse_eccentricity_high(self): + """E4: 細長い楕円パターンで eccentricity が 0.5〜1.0 に近い高値になること. + + 仕様: 細長い楕円パターン → eccentricity が 0.5〜1.0 に近い高値 + (TECH_11 §5. 輝度分布 PCA) + """ + img = _elongated_ellipse(h=64, w=128) + result = calc_intensity_pca(img) + assert result["eccentricity"] > 0.5 + + # ----------------------------------------------------------------------- + # E5: primary_axis_angle_deg は [-90, +90] の範囲 + # ----------------------------------------------------------------------- + + def test_primary_axis_angle_in_valid_range(self): + """E5: primary_axis_angle_deg が [-90, +90] の範囲内であること. + + 仕様: primary_axis_angle_deg は [-90, +90] の範囲 + (TECH_11 §5. primary_axis_angle_deg = arctan2(v_y, v_x) × 180/π (-90〜+90 に折り畳み)) + """ + rng = numpy.random.RandomState(20) + img = rng.uniform(50.0, 200.0, (64, 64)) + result = calc_intensity_pca(img) + + assert -90.0 <= result["primary_axis_angle_deg"] <= 90.0 + + def test_primary_axis_angle_always_in_range_various_patterns(self): + """E5: 様々なパターンで primary_axis_angle_deg が [-90, +90] の範囲内であること. + + 仕様: primary_axis_angle_deg は [-90, +90] の範囲(折り畳み処理) + (shape_metrics.py calc_intensity_pca 実装: -90〜+90 の範囲に折り畳む) + """ + patterns = [ + _y_gradient_image(), + _radial_image(), + _right_half_image(), + _bottom_half_image(), + _central_peak_gaussian(), + ] + for img in patterns: + result = calc_intensity_pca(img) + angle = result["primary_axis_angle_deg"] + assert -90.0 <= angle <= 90.0, f"角度 {angle} が [-90, 90] の範囲外" + + # ----------------------------------------------------------------------- + # E6: 戻り値は dict で 4 キーを全て持つ + # ----------------------------------------------------------------------- + + def test_return_dict_has_four_keys(self): + """E6: 戻り値は dict で 4 キーを全て持つこと. + + 仕様: primary_axis_angle_deg / primary_eigenvalue / secondary_eigenvalue / + eccentricity の 4 キー + (shape_metrics.py calc_intensity_pca Returns) + """ + img = _y_gradient_image() + result = calc_intensity_pca(img) + + expected_keys = { + "primary_axis_angle_deg", + "primary_eigenvalue", + "secondary_eigenvalue", + "eccentricity", + } + assert set(result.keys()) == expected_keys + + def test_all_return_values_are_float(self): + """E6: 戻り値の全要素が float であること. + + 仕様: Returns 辞書の各値は float + """ + img = _y_gradient_image() + result = calc_intensity_pca(img) + for key, val in result.items(): + assert isinstance(val, float), f"{key} は float でなければなりません" + + # ----------------------------------------------------------------------- + # 追加: eccentricity の値域確認 + # ----------------------------------------------------------------------- + + def test_eccentricity_in_zero_to_one_range(self): + """eccentricity が [0, 1] の範囲内であること. + + 仕様: eccentricity = √(1 - secondary/primary), 0=完全円, 1=極端な楕円 + (shape_metrics.py calc_intensity_pca Returns) + """ + rng = numpy.random.RandomState(21) + img = rng.uniform(10.0, 100.0, (64, 64)) + result = calc_intensity_pca(img) + assert 0.0 <= result["eccentricity"] <= 1.0 + + def test_primary_eigenvalue_gte_secondary_eigenvalue(self): + """primary_eigenvalue >= secondary_eigenvalue であること. + + 仕様: 固有値分解(numpy.linalg.eigh は固有値昇順保証) + → eigenvalues[1] が最大,eigenvalues[0] が最小 + (shape_metrics.py calc_intensity_pca 実装) + """ + rng = numpy.random.RandomState(22) + img = rng.uniform(10.0, 100.0, (64, 64)) + result = calc_intensity_pca(img) + assert result["primary_eigenvalue"] >= result["secondary_eigenvalue"] + + def test_shape_mismatch_mask_raises_value_error(self): + """形状不一致の mask は ValueError を発生させること. + + 仕様: mask の shape が image と異なる場合 → ValueError + (shape_metrics.py calc_intensity_pca Raises) + """ + img = numpy.ones((32, 32), dtype=numpy.float64) + wrong_mask = numpy.ones((16, 16), dtype=bool) + + with pytest.raises(ValueError, match="shape"): + calc_intensity_pca(img, mask=wrong_mask) + + def test_all_zero_image_raises_value_error(self): + """全ゼロ画像(輝度総和 = 0)では ValueError を発生させること. + + 仕様: 有効ピクセルの輝度総和が 0 の場合 → ValueError + (shape_metrics.py calc_intensity_pca Raises) + """ + img = numpy.zeros((32, 32), dtype=numpy.float64) + with pytest.raises(ValueError): + calc_intensity_pca(img) + + # ----------------------------------------------------------------------- + # 追加: Y 勾配と X 勾配で主軸方向が異なることの確認 + # ----------------------------------------------------------------------- + + def test_y_gradient_and_x_gradient_have_different_primary_axis(self): + """Y 勾配と X 勾配で主軸角度が異なること(形の方向性が区別される). + + 仕様: 「伸び方向」を固有ベクトルで表現する + (TECH_11 §5. 輝度分布 PCA 物理的意味) + """ + h, w = 64, 64 + y_grad = numpy.linspace(1.0, 2.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w)) + x_grad = numpy.ones((h, w)) * numpy.linspace(1.0, 2.0, w, dtype=numpy.float64).reshape(1, -1) + + result_y = calc_intensity_pca(y_grad) + result_x = calc_intensity_pca(x_grad) + + # 主軸角度が異なること(同じ方向を向いていない) + assert abs(result_y["primary_axis_angle_deg"] - result_x["primary_axis_angle_deg"]) > 30.0 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/scene/test_materials_blend_bsdf.py b/tests/scene/test_materials_blend_bsdf.py new file mode 100644 index 0000000..2e6b6b1 --- /dev/null +++ b/tests/scene/test_materials_blend_bsdf.py @@ -0,0 +1,392 @@ +"""scene/materials.py の blendbsdf / roughconductor 変換仕様ベーステスト. + +仕様出典: 実装サマリー §D +- D1: DiffuseMaterial → Mitsuba dict (既存仕様・後方互換確認) +- D2: RoughConductorBSDFConfig → Mitsuba dict +- D3: BlendBSDFConfig (flat) → Mitsuba dict +- D4: ネスト BlendBSDFConfig → 再帰変換 +- D5: build_material_dict 公開 API の後方互換 + +build_material_dict の戻り値 dict 構造のみを検証する.Mitsuba の load_dict は呼ばない. +""" + +from __future__ import annotations + +import pytest + +from sm_tias_lightsim.scene.materials import build_material_dict +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DiffuseMaterial, + RoughConductorBSDFConfig, +) + + +# --------------------------------------------------------------------------- +# D1: DiffuseMaterial → Mitsuba dict(既存仕様の後方互換確認) +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictDiffuse: + """D1 系: DiffuseMaterial の dict 変換(既存仕様確認).""" + + def test_diffuse_type_in_dict(self): + """DiffuseMaterial の dict type が "diffuse" であること. + + 仕様 D1: DiffuseMaterial → {type: "diffuse", reflectance: {type: "rgb", value: [r,g,b]}} + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["type"] == "diffuse" + + def test_diffuse_reflectance_type_is_rgb(self): + """DiffuseMaterial の reflectance dict に type="rgb" が含まれること. + + 仕様 D1: reflectance sub-dict に type="rgb" キー + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["reflectance"]["type"] == "rgb" + + def test_diffuse_scalar_reflectance_expands_to_rgb(self): + """スカラ reflectance が RGB 3 要素リストに展開されること. + + 仕様 D1: value: [r, g, b] + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.85) + result = build_material_dict(mat) + assert result["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + def test_diffuse_rgb_reflectance_preserved(self): + """RGB reflectance タプルがそのまま 3 要素リストに変換されること. + + 仕様 D1: RGB 指定時も value: [r, g, b] 形式 + """ + mat = DiffuseMaterial(type="diffuse", reflectance=(0.85, 0.85, 0.85)) + result = build_material_dict(mat) + assert result["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + +# --------------------------------------------------------------------------- +# D2: RoughConductorBSDFConfig → Mitsuba dict +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictRoughConductor: + """D2 系: RoughConductorBSDFConfig の dict 変換仕様.""" + + def test_roughconductor_type_in_dict(self): + """RoughConductorBSDFConfig の dict type が "roughconductor" であること. + + 仕様 D2: {type: "roughconductor", ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["type"] == "roughconductor" + + def test_roughconductor_alpha_in_dict(self): + """alpha 値が dict に格納されること. + + 仕様 D2: {alpha: 0.3, ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(0.3) + + def test_roughconductor_distribution_in_dict(self): + """distribution 値が dict に格納されること. + + 仕様 D2: {distribution: "ggx", ...} + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["distribution"] == "ggx" + + def test_roughconductor_material_none_in_dict(self): + """material キーが "none" で格納されること(完全反射体・eta/k なし). + + 仕様 D2: {material: "none"} は固定 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + result = build_material_dict(mat) + assert result["material"] == "none" + + def test_roughconductor_alpha_edge_zero(self): + """alpha=0.0(鏡面)が dict に正しく格納されること. + + 仕様 D2: alpha 端値の変換 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.0) + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(0.0) + + def test_roughconductor_alpha_edge_one(self): + """alpha=1.0(マット)が dict に正しく格納されること. + + 仕様 D2: alpha 端値の変換 + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=1.0) + result = build_material_dict(mat) + assert result["alpha"] == pytest.approx(1.0) + + def test_roughconductor_beckmann_distribution_in_dict(self): + """distribution="beckmann" が dict に格納されること. + + 仕様 D2: distribution は指定値がそのまま格納される + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.5, distribution="beckmann") + result = build_material_dict(mat) + assert result["distribution"] == "beckmann" + + def test_roughconductor_no_reflectance_key(self): + """roughconductor の dict に reflectance キーが含まれないこと. + + 仕様 D2: roughconductor は reflectance ではなく alpha/distribution + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + result = build_material_dict(mat) + assert "reflectance" not in result + + +# --------------------------------------------------------------------------- +# D3: BlendBSDFConfig (flat) → Mitsuba dict +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBlendFlat: + """D3 系: BlendBSDFConfig(フラット 2 要素)の dict 変換仕様.""" + + def _make_cfg(self, weight: float = 0.15, alpha: float = 0.3) -> BlendBSDFConfig: + return BlendBSDFConfig( + type="blendbsdf", + weight=weight, + components=[ + DiffuseMaterial(type="diffuse", reflectance=(0.85, 0.85, 0.85)), + RoughConductorBSDFConfig(type="roughconductor", alpha=alpha), + ], + ) + + def test_blendbsdf_type_in_dict(self): + """BlendBSDFConfig の dict type が "blendbsdf" であること. + + 仕様 D3: {type: "blendbsdf", ...} + """ + result = build_material_dict(self._make_cfg()) + assert result["type"] == "blendbsdf" + + def test_blendbsdf_weight_in_dict(self): + """weight が dict に格納されること. + + 仕様 D3: {weight: 0.15, ...} + """ + result = build_material_dict(self._make_cfg(weight=0.15)) + assert result["weight"] == pytest.approx(0.15) + + def test_blendbsdf_has_bsdf_0_key(self): + """dict に bsdf_0 キーが存在すること. + + 仕様 D3: {bsdf_0: , bsdf_1: } + """ + result = build_material_dict(self._make_cfg()) + assert "bsdf_0" in result + + def test_blendbsdf_has_bsdf_1_key(self): + """dict に bsdf_1 キーが存在すること. + + 仕様 D3: {bsdf_0: , bsdf_1: } + """ + result = build_material_dict(self._make_cfg()) + assert "bsdf_1" in result + + def test_blendbsdf_bsdf_0_is_diffuse_dict(self): + """bsdf_0 が diffuse の dict であること(component[0] = DiffuseMaterial). + + 仕様 D3: bsdf_0 = + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_0"]["type"] == "diffuse" + + def test_blendbsdf_bsdf_1_is_roughconductor_dict(self): + """bsdf_1 が roughconductor の dict であること(component[1] = RoughConductorBSDFConfig). + + 仕様 D3: bsdf_1 = + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_1"]["type"] == "roughconductor" + + def test_blendbsdf_bsdf_0_reflectance_value(self): + """bsdf_0(diffuse)の reflectance.value が正しいこと. + + 仕様 D3: 子 bsdf_0 の内容は DiffuseMaterial 変換と同等 + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_0"]["reflectance"]["value"] == pytest.approx([0.85, 0.85, 0.85]) + + def test_blendbsdf_bsdf_1_alpha_value(self): + """bsdf_1(roughconductor)の alpha が正しいこと. + + 仕様 D3: 子 bsdf_1 の内容は RoughConductorBSDFConfig 変換と同等 + """ + result = build_material_dict(self._make_cfg(alpha=0.3)) + assert result["bsdf_1"]["alpha"] == pytest.approx(0.3) + + def test_blendbsdf_bsdf_1_material_is_none(self): + """bsdf_1(roughconductor)の material が "none" であること. + + 仕様 D3: 子 roughconductor の material="none" が再帰変換で保持される + """ + result = build_material_dict(self._make_cfg()) + assert result["bsdf_1"]["material"] == "none" + + def test_blendbsdf_no_components_key_in_dict(self): + """dict に "components" キーが含まれないこと(Mitsuba は bsdf_0/bsdf_1 形式). + + 仕様 D3: Mitsuba 形式は bsdf_0/bsdf_1 で components キーは不要 + """ + result = build_material_dict(self._make_cfg()) + assert "components" not in result + + def test_blendbsdf_weight_zero(self): + """weight=0.0 が dict に正しく格納されること. + + 仕様 D3: weight 端値の変換 + """ + result = build_material_dict(self._make_cfg(weight=0.0)) + assert result["weight"] == pytest.approx(0.0) + + def test_blendbsdf_weight_one(self): + """weight=1.0 が dict に正しく格納されること. + + 仕様 D3: weight 端値の変換 + """ + result = build_material_dict(self._make_cfg(weight=1.0)) + assert result["weight"] == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# D4: ネスト BlendBSDFConfig → 再帰変換 +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBlendNested: + """D4 系: ネスト BlendBSDFConfig の再帰変換仕様.""" + + def _make_nested_cfg(self) -> BlendBSDFConfig: + """BlendBSDFConfig の components[0] にさらに BlendBSDFConfig を持つ 2 段ネスト.""" + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.9), + RoughConductorBSDFConfig(type="roughconductor", alpha=0.1), + ], + ) + return BlendBSDFConfig( + type="blendbsdf", + weight=0.05, + components=[ + inner, + DiffuseMaterial(type="diffuse", reflectance=0.5), + ], + ) + + def test_nested_bsdf_0_is_blendbsdf_dict(self): + """ネスト時の bsdf_0 が blendbsdf の dict であること. + + 仕様 D4: ネスト BlendBSDFConfig も再帰的に正しく変換される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["type"] == "blendbsdf" + + def test_nested_bsdf_0_weight_preserved(self): + """ネストした bsdf_0(blendbsdf)の weight が正しいこと. + + 仕様 D4: 内部 BlendBSDFConfig の weight が再帰変換で保持される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["weight"] == pytest.approx(0.3) + + def test_nested_bsdf_0_inner_bsdf_0_type(self): + """ネストした bsdf_0.bsdf_0 が diffuse であること. + + 仕様 D4: 2 段再帰が正しく動作する + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_0"]["type"] == "diffuse" + + def test_nested_bsdf_0_inner_bsdf_1_type(self): + """ネストした bsdf_0.bsdf_1 が roughconductor であること. + + 仕様 D4: 2 段再帰が正しく動作する + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_1"]["type"] == "roughconductor" + + def test_nested_bsdf_0_inner_bsdf_1_alpha(self): + """ネストした bsdf_0.bsdf_1(roughconductor)の alpha が正しいこと. + + 仕様 D4: 深いネストの alpha 値も保持される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_0"]["bsdf_1"]["alpha"] == pytest.approx(0.1) + + def test_nested_outer_weight_preserved(self): + """外側 BlendBSDFConfig の weight が正しいこと. + + 仕様 D4: 外側の weight は内側の再帰と独立 + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["weight"] == pytest.approx(0.05) + + def test_nested_bsdf_1_is_diffuse_dict(self): + """外側の bsdf_1 が diffuse の dict であること. + + 仕様 D4: 外側の非ネスト component は通常通り変換される + """ + result = build_material_dict(self._make_nested_cfg()) + assert result["bsdf_1"]["type"] == "diffuse" + + +# --------------------------------------------------------------------------- +# D5: build_material_dict 公開 API の後方互換 +# --------------------------------------------------------------------------- + + +class TestBuildMaterialDictBackwardCompat: + """D5 系: build_material_dict の公開 API 後方互換仕様.""" + + def test_existing_diffuse_material_still_works(self): + """既存の DiffuseMaterial 入力が従来通り動作すること. + + 仕様 D5: 公開 API シグネチャは破壊しない + """ + mat = DiffuseMaterial(type="diffuse", reflectance=0.5) + result = build_material_dict(mat) + assert result["type"] == "diffuse" + assert result["reflectance"]["type"] == "rgb" + assert "value" in result["reflectance"] + + def test_public_api_accepts_roughconductor(self): + """公開 API が RoughConductorBSDFConfig を受け付けること. + + 仕様 D5: 公開 API シグネチャは新型式も受け付ける + """ + mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + result = build_material_dict(mat) + assert result["type"] == "roughconductor" + + def test_public_api_accepts_blend_bsdf(self): + """公開 API が BlendBSDFConfig を受け付けること. + + 仕様 D5: 公開 API シグネチャは新型式も受け付ける + """ + mat = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[ + DiffuseMaterial(type="diffuse", reflectance=0.85), + RoughConductorBSDFConfig(type="roughconductor", alpha=0.3), + ], + ) + result = build_material_dict(mat) + assert result["type"] == "blendbsdf" diff --git a/tests/scene/test_metrics_shape_schema.py b/tests/scene/test_metrics_shape_schema.py new file mode 100644 index 0000000..f504916 --- /dev/null +++ b/tests/scene/test_metrics_shape_schema.py @@ -0,0 +1,275 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +"""scene/schema.py の MetricsConfig shape_metrics / shape_reference フィールド仕様ベーステスト. + +仕様出典: +- docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- src/sm_tias_lightsim/scene/schema.py(MetricsConfig 定義) + +テスト方針: + - Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは別途) + - 既存フィールド(cov/std/max_min/radial_profile/radial_min_max)への非干渉を確認する + - 後方互換性(既存 YAML が新スキーマで読める)を確認する +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, MetricsConfig + +# =========================================================================== +# F1. 後方互換: 既存 YAML(shape_metrics 未指定)が新スキーマで読める +# =========================================================================== + + +def test_existing_yaml_without_shape_metrics_is_valid(tmp_path: Path): + """F1: shape_metrics を含まない既存 eval_baseline_dng_target.yaml 相当の YAML が + 新スキーマで ValidationError なしに読み込めること. + + 仕様: shape_metrics および shape_reference フィールドはいずれも省略可能 + (TECH_11 §設定方法 後方互換) + """ + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 24 + y: 37 + width: 464 + height: 450 + zones: + enabled: true + boundaries: [0.33, 0.66] + metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true + normalization: + enabled: true + type: mean_anchor + target_mean: 207.82 + """) + yaml_path = tmp_path / "eval_no_shape.yaml" + yaml_path.write_text(yaml_content, encoding="utf-8") + + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + config = EvaluationConfig.model_validate(data) + + # shape_metrics と shape_reference はデフォルト値(False / None)であること + assert config.metrics is not None + assert config.metrics.shape_metrics is False + assert config.metrics.shape_reference is None + + +def test_metrics_config_without_shape_fields_defaults_to_false_and_none(): + """F1 補足: MetricsConfig を shape_metrics/shape_reference 未指定で構築すると + shape_metrics=False, shape_reference=None になること(Pydantic デフォルト値). + + 仕様: shape_metrics: bool = Field(default=False, ...) + shape_reference: str | None = Field(default=None, ...) + (schema.py MetricsConfig) + """ + cfg = MetricsConfig() + assert cfg.shape_metrics is False + assert cfg.shape_reference is None + + +def test_existing_config_files_are_backward_compatible(): + """F1 補足: プロジェクト既存の eval_baseline_dng_target.yaml が + ValidationError なしに読み込めること(実ファイルでの後方互換確認). + + 仕様: 既存の YAML は変更不要で動作する (TECH_11 §後方互換) + """ + import os + project_root = Path(os.path.dirname(__file__)).parent.parent + config_path = project_root / "configs" / "eval_baseline_dng_target.yaml" + + if not config_path.exists(): + pytest.skip("configs/eval_baseline_dng_target.yaml が存在しないためスキップ") + + with config_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + # EvaluationConfig 直接または SceneConfig.evaluation サブツリーとして読む + eval_data = data.get("evaluation", data) + config = EvaluationConfig.model_validate(eval_data) + # shape_metrics フィールドのデフォルトが有効であること + assert config.metrics is not None + assert config.metrics.shape_metrics is False + + +# =========================================================================== +# F2. shape_metrics=true + shape_reference=null が valid +# =========================================================================== + + +def test_shape_metrics_true_with_null_reference_is_valid(): + """F2: shape_metrics=True + shape_reference=None が valid であること. + + 仕様: shape_metrics=True + shape_reference=null が valid + (TECH_11 §設定方法 基本) + """ + cfg = MetricsConfig(shape_metrics=True, shape_reference=None) + assert cfg.shape_metrics is True + assert cfg.shape_reference is None + + +def test_shape_metrics_true_null_reference_via_yaml(tmp_path: Path): + """F2 補足: YAML で shape_metrics=true, shape_reference=null と指定した場合も valid. + + 仕様: shape_metrics: true, shape_reference: null(YAML 表現) + (configs/eval_baseline_dng_target_shape.yaml 相当) + """ + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 + metrics: + cov: true + shape_metrics: true + shape_reference: null + """) + data = yaml.safe_load(yaml_content) + config = EvaluationConfig.model_validate(data) + + assert config.metrics is not None + assert config.metrics.shape_metrics is True + assert config.metrics.shape_reference is None + + +def test_shape_metrics_false_with_null_reference_is_valid(): + """F2 補足: shape_metrics=False + shape_reference=None(デフォルト)も valid. + + 仕様: 省略時(null)は SSIM / NCC の列は空欄になる + (schema.py MetricsConfig.shape_reference description) + """ + cfg = MetricsConfig(shape_metrics=False, shape_reference=None) + assert cfg.shape_metrics is False + assert cfg.shape_reference is None + + +# =========================================================================== +# F3. shape_metrics=true + shape_reference="path/to/file.npy" が valid +# =========================================================================== + + +def test_shape_metrics_true_with_string_reference_is_valid(): + """F3: shape_metrics=True + shape_reference="path/to/file.npy" が valid であること. + + 仕様: shape_metrics=True + shape_reference="path/to/file.npy" が valid + (TECH_11 §設定方法 SSIM/NCC 有効化) + """ + cfg = MetricsConfig(shape_metrics=True, shape_reference="path/to/reference.npy") + assert cfg.shape_metrics is True + assert cfg.shape_reference == "path/to/reference.npy" + + +def test_shape_metrics_true_with_reference_via_yaml(tmp_path: Path): + """F3 補足: YAML で shape_reference をパス文字列で指定した場合も valid. + + 仕様: shape_reference: reference_measurement/quantitative/expected_results/...npy + (TECH_11 §設定方法 SSIM/NCC 有効化) + """ + yaml_content = textwrap.dedent("""\ + roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 + metrics: + shape_metrics: true + shape_reference: "reference_measurement/quantitative/expected_results/sample.npy" + """) + data = yaml.safe_load(yaml_content) + config = EvaluationConfig.model_validate(data) + + assert config.metrics is not None + assert config.metrics.shape_metrics is True + assert config.metrics.shape_reference == "reference_measurement/quantitative/expected_results/sample.npy" + + +def test_shape_reference_accepts_png_path(): + """F3 補足: shape_reference に .png パスも指定できること. + + 仕様: shape_reference の .npy または .png パスを受け付ける + (cli/evaluate.py _load_reference_luminance) + """ + cfg = MetricsConfig(shape_metrics=True, shape_reference="path/to/reference.png") + assert cfg.shape_reference == "path/to/reference.png" + + +# =========================================================================== +# 追加: 既存フィールドへの非干渉確認 +# =========================================================================== + + +def test_existing_fields_unaffected_when_shape_metrics_added(): + """shape_metrics フィールド追加後も既存フィールドのデフォルト値が変わらないこと. + + 仕様: 後方互換(既存の YAML は変更不要) + (TECH_11 §設定方法 後方互換) + """ + cfg = MetricsConfig() + # 既存フィールドのデフォルト値が維持されている + assert cfg.cov is True + assert cfg.std is True + assert cfg.max_min is True + assert cfg.radial_profile is True + assert cfg.radial_min_max is False + # 新フィールドのデフォルト値 + assert cfg.shape_metrics is False + assert cfg.shape_reference is None + + +def test_all_flags_can_be_enabled_simultaneously(): + """全フラグを True に指定できること(組み合わせ互換性の確認). + + 仕様: 各フラグは独立してオン/オフ可能 + """ + cfg = MetricsConfig( + cov=True, + std=True, + max_min=True, + radial_profile=True, + radial_min_max=True, + shape_metrics=True, + shape_reference="path/to/ref.npy", + ) + assert cfg.shape_metrics is True + assert cfg.shape_reference == "path/to/ref.npy" + assert cfg.radial_min_max is True + + +def test_shape_metrics_config_file_is_valid(): + """プロジェクト既存の eval_baseline_dng_target_shape.yaml が + ValidationError なしに読み込めること. + + 仕様: configs/eval_baseline_dng_target_shape.yaml が valid な設定ファイルとして + 機能すること (TECH_11 §設定方法) + """ + import os + project_root = Path(os.path.dirname(__file__)).parent.parent + config_path = project_root / "configs" / "eval_baseline_dng_target_shape.yaml" + + if not config_path.exists(): + pytest.skip("configs/eval_baseline_dng_target_shape.yaml が存在しないためスキップ") + + with config_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + eval_data = data.get("evaluation", data) + config = EvaluationConfig.model_validate(eval_data) + + assert config.metrics is not None + assert config.metrics.shape_metrics is True diff --git a/tests/scene/test_schema_blend_bsdf.py b/tests/scene/test_schema_blend_bsdf.py new file mode 100644 index 0000000..1ccfc3d --- /dev/null +++ b/tests/scene/test_schema_blend_bsdf.py @@ -0,0 +1,561 @@ +"""RoughConductorBSDFConfig / BlendBSDFConfig スキーマの仕様ベーステスト. + +仕様出典: 実装サマリー §A〜§C +- A: RoughConductorBSDFConfig (Pydantic スキーマ) +- B: BlendBSDFConfig (Pydantic スキーマ) +- C: MaterialConfig Union (既存 DiffuseMaterial との並列・後方互換) +""" + +from __future__ import annotations + +import copy + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import ( + BlendBSDFConfig, + DiffuseMaterial, + RoughConductorBSDFConfig, + SceneConfig, + load_scene_yaml, +) + +# --------------------------------------------------------------------------- +# A: RoughConductorBSDFConfig +# --------------------------------------------------------------------------- + + +class TestRoughConductorBSDFConfig: + """A 系: RoughConductorBSDFConfig のバリデーション仕様.""" + + # A1: 正常生成 + def test_valid_construction_with_type_roughconductor(self): + """type="roughconductor" で正常にインスタンスを生成できること. + + 仕様 A1: type: "roughconductor" で正常生成 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + assert cfg.type == "roughconductor" + assert cfg.alpha == pytest.approx(0.3) + + def test_invalid_type_raises_validation_error(self): + """type に "diffuse" 等の他値を指定すると ValidationError になること. + + 仕様 A1: type: "diffuse" 等の他値で ValidationError + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="diffuse", alpha=0.3) # type: ignore[arg-type] + + def test_invalid_type_other_string_raises_validation_error(self): + """type に "blendbsdf" を指定すると ValidationError になること. + + 仕様 A1: type: "blendbsdf" でも ValidationError + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="blendbsdf", alpha=0.3) # type: ignore[arg-type] + + # A2: alpha の範囲制約 + def test_alpha_zero_is_valid(self): + """alpha=0.0(鏡面)が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.0) + assert cfg.alpha == pytest.approx(0.0) + + def test_alpha_one_is_valid(self): + """alpha=1.0(マット)が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=1.0) + assert cfg.alpha == pytest.approx(1.0) + + def test_alpha_intermediate_is_valid(self): + """alpha=0.5 が許可されること. + + 仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.5) + assert cfg.alpha == pytest.approx(0.5) + + def test_alpha_negative_raises_validation_error(self): + """alpha=-0.01(負値)は ValidationError になること. + + 仕様 A2: 負値でエラー + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=-0.01) + + def test_alpha_exceeds_one_raises_validation_error(self): + """alpha=1.01(1超)は ValidationError になること. + + 仕様 A2: 1超でエラー + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=1.01) + + # A3: distribution の許可値 + def test_distribution_ggx_is_valid(self): + """distribution="ggx" が許可されること. + + 仕様 A3: distribution は "ggx" または "beckmann" のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx") + assert cfg.distribution == "ggx" + + def test_distribution_beckmann_is_valid(self): + """distribution="beckmann" が許可されること. + + 仕様 A3: distribution は "ggx" または "beckmann" のみ許可 + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="beckmann") + assert cfg.distribution == "beckmann" + + def test_distribution_default_is_ggx(self): + """distribution を省略するとデフォルト "ggx" になること. + + 仕様 A3: distribution デフォルトは "ggx" + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + assert cfg.distribution == "ggx" + + def test_distribution_invalid_raises_validation_error(self): + """distribution に未知の値を指定すると ValidationError になること. + + 仕様 A3: "ggx" / "beckmann" 以外は許可しない + """ + with pytest.raises(ValidationError): + RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="phong") # type: ignore[arg-type] + + # A4: frozen (immutable) + def test_frozen_raises_on_attribute_assignment(self): + """frozen=True であり属性への直接代入で例外が発生すること. + + 仕様 A4: クラスは frozen (immutable) + """ + cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3) + with pytest.raises((ValidationError, AttributeError)): + cfg.alpha = 0.9 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# B: BlendBSDFConfig +# --------------------------------------------------------------------------- + + +class TestBlendBSDFConfig: + """B 系: BlendBSDFConfig のバリデーション仕様.""" + + def _make_diffuse(self, reflectance: float = 0.85) -> DiffuseMaterial: + return DiffuseMaterial(type="diffuse", reflectance=reflectance) + + def _make_roughconductor(self, alpha: float = 0.3) -> RoughConductorBSDFConfig: + return RoughConductorBSDFConfig(type="roughconductor", alpha=alpha) + + # B1: 正常生成 + def test_valid_construction_with_type_blendbsdf(self): + """type="blendbsdf" で正常にインスタンスを生成できること. + + 仕様 B1: type: "blendbsdf" で正常生成 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.type == "blendbsdf" + assert cfg.weight == pytest.approx(0.15) + + # B2: weight の範囲制約 + def test_weight_zero_is_valid(self): + """weight=0.0 が許可されること(全 component[0]). + + 仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.0, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.weight == pytest.approx(0.0) + + def test_weight_one_is_valid(self): + """weight=1.0 が許可されること(全 component[1]). + + 仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=1.0, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert cfg.weight == pytest.approx(1.0) + + def test_weight_negative_raises_validation_error(self): + """weight=-0.01(負値)は ValidationError になること. + + 仕様 B2: 負値でエラー + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=-0.01, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + + def test_weight_exceeds_one_raises_validation_error(self): + """weight=1.01(1超)は ValidationError になること. + + 仕様 B2: 1超でエラー + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=1.01, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + + # B3: components の長さ制約 + def test_components_exactly_two_required(self): + """components がちょうど 2 要素の場合に正常生成できること. + + 仕様 B3: components はちょうど 2 要素必須 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert len(cfg.components) == 2 + + def test_components_one_element_raises_validation_error(self): + """components が 1 要素では ValidationError になること. + + 仕様 B3: 1 要素は ValidationError + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse()], + ) + + def test_components_three_elements_raises_validation_error(self): + """components が 3 要素以上では ValidationError になること. + + 仕様 B3: 3 要素以上は ValidationError + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[ + self._make_diffuse(), + self._make_roughconductor(), + self._make_diffuse(), + ], + ) + + def test_components_empty_raises_validation_error(self): + """components が空リストでは ValidationError になること. + + 仕様 B3: 2 要素必須 + """ + with pytest.raises(ValidationError): + BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[], + ) + + # B4: discriminated union の分岐 + def test_components_discriminated_by_type_field_diffuse(self): + """components[0] が type="diffuse" で DiffuseMaterial として識別されること. + + 仕様 B4: type フィールドで discriminated union に分岐 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert isinstance(cfg.components[0], DiffuseMaterial) + + def test_components_discriminated_by_type_field_roughconductor(self): + """components[1] が type="roughconductor" で RoughConductorBSDFConfig として識別されること. + + 仕様 B4: type フィールドで discriminated union に分岐 + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + assert isinstance(cfg.components[1], RoughConductorBSDFConfig) + + # B5: 再帰的な入れ子 + def test_nested_blend_bsdf_in_components(self): + """components の 1 つに BlendBSDFConfig をネストして正常生成できること. + + 仕様 B5: 再帰: components の 1 つに BlendBSDFConfig をネストして正常生成可能 + """ + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[self._make_diffuse(0.8), self._make_roughconductor(0.1)], + ) + outer = BlendBSDFConfig( + type="blendbsdf", + weight=0.1, + components=[inner, self._make_diffuse(0.9)], + ) + assert isinstance(outer.components[0], BlendBSDFConfig) + assert outer.components[0].weight == pytest.approx(0.3) + + def test_nested_blend_bsdf_components_accessible(self): + """ネストした BlendBSDFConfig の components に正常にアクセスできること. + + 仕様 B5: 再帰的な入れ子が正常動作 + """ + inner = BlendBSDFConfig( + type="blendbsdf", + weight=0.3, + components=[self._make_diffuse(), self._make_roughconductor(0.5)], + ) + outer = BlendBSDFConfig( + type="blendbsdf", + weight=0.5, + components=[self._make_diffuse(), inner], + ) + nested = outer.components[1] + assert isinstance(nested, BlendBSDFConfig) + assert isinstance(nested.components[1], RoughConductorBSDFConfig) + assert nested.components[1].alpha == pytest.approx(0.5) + + # B6: frozen (immutable) + def test_frozen_raises_on_weight_assignment(self): + """frozen=True であり weight への直接代入で例外が発生すること. + + 仕様 B6: クラスは frozen + """ + cfg = BlendBSDFConfig( + type="blendbsdf", + weight=0.15, + components=[self._make_diffuse(), self._make_roughconductor()], + ) + with pytest.raises((ValidationError, AttributeError)): + cfg.weight = 0.9 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# C: MaterialConfig Union(既存との並列・後方互換) +# --------------------------------------------------------------------------- + +# scene_baseline.yaml の最小相当 dict(diffuse のみ) +_MINIMAL_DIFFUSE_DICT = { + "name": "test_scene", + "geometry": { + "meshes": [ + { + "id": "housing", + "path": "configs/meshes/housing.stl", + "unit": "mm", + "material": "mat_a", + } + ] + }, + "materials": { + "mat_a": {"type": "diffuse", "reflectance": 0.5}, + }, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 50.0], + "intensity": 1.0, + "profile": "isotropic", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 100.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 512, "height": 512}, + }, + "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256}, +} + +# scene_baseline_blend.yaml 相当の最小 dict(blendbsdf を含む) +_BLEND_DICT = { + "name": "test_blend", + "geometry": { + "meshes": [ + { + "id": "housing", + "path": "configs/meshes/housing.stl", + "unit": "mm", + "material": "printer_white", + } + ] + }, + "materials": { + "printer_white": { + "type": "blendbsdf", + "weight": 0.15, + "components": [ + {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]}, + {"type": "roughconductor", "alpha": 0.3, "distribution": "ggx"}, + ], + } + }, + "lights": [ + { + "id": "led", + "type": "area", + "shape": "rectangle", + "center": [0.0, 0.0, 10.0], + "size": [2.8, 3.5], + "direction": [0.0, 0.0, -1.0], + "radiance": 100.0, + } + ], + "sensor": { + "type": "orthographic", + "position": [0.0, 0.0, -50.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "ortho_scale": [50.0, 52.0], + "film": {"width": 512, "height": 512}, + }, + "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256}, +} + + +class TestMaterialConfigUnion: + """C 系: MaterialConfig Union の仕様(後方互換・新型式).""" + + def test_backward_compatible_diffuse_only_yaml_parses(self): + """既存の type: diffuse のみの YAML が新スキーマで読めること(後方互換). + + 仕様 C1: 既存の type: diffuse のみの YAML が新スキーマで読める (後方互換) + """ + d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT) + config = SceneConfig.model_validate(d) + assert config.name == "test_scene" + mat = config.materials["mat_a"] + assert isinstance(mat, DiffuseMaterial) + + def test_diffuse_material_type_preserved_in_backward_compat(self): + """後方互換で読み込んだ DiffuseMaterial の type が "diffuse" であること. + + 仕様 C1: 既存マテリアルが正しい型で取得できること + """ + d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["mat_a"] + assert mat.type == "diffuse" + + def test_blend_bsdf_yaml_parses_successfully(self): + """blendbsdf を含む YAML が SceneConfig.model_validate で読み込み成功すること. + + 仕様 C3: scene_baseline_blend.yaml 相当を読み込んで BlendBSDFConfig インスタンスが得られる + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + assert config.name == "test_blend" + + def test_printer_white_is_blend_bsdf_instance(self): + """printer_white が BlendBSDFConfig インスタンスとして取得できること. + + 仕様 C3: printer_white が BlendBSDFConfig インスタンスとして得られる + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + + def test_printer_white_weight_preserved(self): + """printer_white.weight が正しく読み込まれること. + + 仕様 C3: scene_baseline_blend.yaml の weight=0.15 が保持される + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert mat.weight == pytest.approx(0.15) + + def test_printer_white_components_component0_is_diffuse(self): + """printer_white.components[0] が DiffuseMaterial であること. + + 仕様 C3: components[0] = diffuse (reflectance=0.85) + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert isinstance(mat.components[0], DiffuseMaterial) + + def test_printer_white_components_component1_is_roughconductor(self): + """printer_white.components[1] が RoughConductorBSDFConfig であること. + + 仕様 C3: components[1] = roughconductor (alpha=0.3) + """ + d = copy.deepcopy(_BLEND_DICT) + config = SceneConfig.model_validate(d) + mat = config.materials["printer_white"] + assert isinstance(mat, BlendBSDFConfig) + assert isinstance(mat.components[1], RoughConductorBSDFConfig) + assert mat.components[1].alpha == pytest.approx(0.3) + + def test_scene_baseline_yaml_loads_without_blend(self, tmp_path): + """scene_baseline.yaml 相当(diffuse のみ)が SceneConfig で正常読み込みできること. + + 仕様 C2: scene_baseline.yaml を SceneConfig.model_validate で読み込み成功 + """ + yaml_text = """\ +name: test_baseline_compat +geometry: + meshes: + - id: housing + path: some/path.stl + unit: mm + material: printer_white +materials: + printer_white: + type: diffuse + reflectance: 0.85 +lights: + - id: led_0 + type: area + shape: rectangle + center: [0.0, 0.0, 10.0] + size: [2.8, 3.5] + direction: [0.0, 0.0, -1.0] + radiance: 100.0 +sensor: + type: orthographic + position: [0.0, 0.0, -50.0] + target: [0.0, 0.0, 0.0] + up: [0.0, 1.0, 0.0] + ortho_scale: [50.0, 52.0] + film: + width: 512 + height: 512 +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 8 + spp: 256 +""" + yaml_file = tmp_path / "scene_compat.yaml" + yaml_file.write_text(yaml_text, encoding="utf-8") + config = load_scene_yaml(yaml_file) + assert config.name == "test_baseline_compat" + mat = config.materials["printer_white"] + assert isinstance(mat, DiffuseMaterial) diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/scripts/__init__.py diff --git a/tests/scripts/test_sweep_blend_bsdf.py b/tests/scripts/test_sweep_blend_bsdf.py new file mode 100644 index 0000000..dd5a692 --- /dev/null +++ b/tests/scripts/test_sweep_blend_bsdf.py @@ -0,0 +1,363 @@ +"""scripts/sweep_blend_bsdf.py の _generate_tmp_yaml 仕様ベーステスト. + +仕様出典: 実装サマリー §E +- E1: weight/alpha パラメータが一時 YAML に正しく書き込まれること +- E2: geometry.meshes の STL パスが絶対パスに変換されること +- E3: 一時 YAML が BOM なし UTF-8 で書かれること +- E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式に更新されること + +テスト戦略: +- subprocess(sm-render 実行)は含まない._generate_tmp_yaml の純変換ロジックのみをテストする +- 実際の TEMPLATE_YAML(configs/scene_baseline_blend.yaml)を使用する +- 出力先は tmp_path フィクスチャ(実ファイル)を使用する(GUIDE_08 ファイル I/O はモックしない方針) +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType +from unittest import mock + +import pytest +import yaml + +# --------------------------------------------------------------------------- +# sweep_blend_bsdf モジュールをインポートするヘルパ +# --------------------------------------------------------------------------- + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPT_PATH = _ROOT / "scripts" / "sweep_blend_bsdf.py" + +_TEMPLATE_YAML = _ROOT / "configs" / "scene_baseline_blend.yaml" + + +def _load_sweep_module() -> ModuleType: + """sweep_blend_bsdf.py を直接インポートする. + + scripts/ は通常のパッケージではないため importlib.util で直接ロードする. + モジュールキャッシュに登録して再 import を防ぐ. + """ + import importlib.util + + spec = importlib.util.spec_from_file_location("sweep_blend_bsdf", _SCRIPT_PATH) + assert spec is not None, f"スクリプトが見つかりません: {_SCRIPT_PATH}" + module = importlib.util.module_from_spec(spec) + # モジュールキャッシュに登録(2回目以降の import を安全にする) + sys.modules["sweep_blend_bsdf"] = module + assert spec.loader is not None + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +@pytest.fixture(scope="module") +def sweep_module() -> ModuleType: + """sweep_blend_bsdf モジュールを返すフィクスチャ(モジュールスコープ).""" + return _load_sweep_module() + + +# --------------------------------------------------------------------------- +# テンプレート YAML の存在確認(前提条件チェック) +# --------------------------------------------------------------------------- + + +def test_template_yaml_exists(): + """configs/scene_baseline_blend.yaml が存在すること(テストの前提条件). + + このファイルがなければ E 系テスト全体が無効になる. + """ + assert _TEMPLATE_YAML.exists(), ( + f"テンプレート YAML が見つかりません: {_TEMPLATE_YAML}\n" + "configs/scene_baseline_blend.yaml を生成してからテストを実行してください." + ) + + +# --------------------------------------------------------------------------- +# E1: weight/alpha が一時 YAML に正しく書き込まれる +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlParameters: + """E1 系: weight と alpha が一時 YAML の正しいパスに書き込まれること.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替えて一時 YAML の出力先をテスト用にする.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_weight_written_to_printer_white(self, sweep_module): + """生成された YAML の materials.printer_white.weight が入力値 0.15 と一致すること. + + 仕様 E1: weight=0.15 が一時 YAML の printer_white.weight に書き込まれる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.15) + + def test_alpha_written_to_roughconductor_component(self, sweep_module): + """生成された YAML の components[1].alpha が入力値 0.3 と一致すること. + + 仕様 E1: alpha=0.3 が components[1].alpha に書き込まれる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.3) + + def test_weight_0_05_written_correctly(self, sweep_module): + """weight=0.05 が正しく書き込まれること(スイープ最小値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.05) + + def test_alpha_0_6_written_correctly(self, sweep_module): + """alpha=0.6 が正しく書き込まれること(スイープ最大値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.6) + + def test_weight_0_30_written_correctly(self, sweep_module): + """weight=0.30 が正しく書き込まれること(スイープ最大値). + + 仕様 E1: スイープグリッドの全値で正しく書き込まれること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.30) + + def test_component0_type_still_diffuse_after_write(self, sweep_module): + """components[0].type が "diffuse" のまま保持されること(破壊的書き換えがないこと). + + 仕様 E1: alpha 書き換えは components[1] のみに影響する + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][0]["type"] == "diffuse" + + def test_component1_type_still_roughconductor_after_write(self, sweep_module): + """components[1].type が "roughconductor" のまま保持されること. + + 仕様 E1: alpha 書き換えは値のみで type フィールドは変更しない + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["materials"]["printer_white"]["components"][1]["type"] == "roughconductor" + + +# --------------------------------------------------------------------------- +# E2: geometry.meshes の path が絶対パスに変換されること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlAbsolutePaths: + """E2 系: geometry.meshes の STL パスが絶対パスに変換されること.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_all_mesh_paths_are_absolute(self, sweep_module): + """全メッシュの path が絶対パスになっていること. + + 仕様 E2: 一時 YAML の geometry.meshes の各 path が絶対パスに変換されている + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + assert len(meshes) > 0, "meshes が空のため検証不可" + for mesh in meshes: + p = Path(mesh["path"]) + assert p.is_absolute(), f"mesh '{mesh.get('id')}' の path が絶対パスでない: {mesh['path']}" + + def test_absolute_path_resolves_relative_to_template_dir(self, sweep_module): + """絶対化されたパスがテンプレート YAML の親ディレクトリ(configs/)基準で解決されること. + + 仕様 E2: template_base = TEMPLATE_YAML.parent(configs/)を基準に resolve + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + template_base = _TEMPLATE_YAML.parent # configs/ + for mesh in meshes: + p = Path(mesh["path"]) + # 絶対化されたパスは template_base 配下またはそのサブディレクトリにあること + # (relative path が "meshes/xxx.stl" → configs/meshes/xxx.stl の absolute) + try: + p.relative_to(template_base) + except ValueError: + pytest.fail( + f"mesh '{mesh.get('id')}' の絶対パス '{p}' が template_base '{template_base}' の" + "下にありません(パス解決ロジックの不整合)" + ) + + def test_no_mesh_path_is_relative(self, sweep_module): + """一時 YAML のメッシュパスに相対パスが含まれないこと. + + 仕様 E2: 相対パスが残っていないことを確認 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + meshes = data["geometry"]["meshes"] + for mesh in meshes: + assert Path(mesh["path"]).is_absolute(), ( + f"mesh '{mesh.get('id')}' の path が相対パス: {mesh['path']}" + ) + + +# --------------------------------------------------------------------------- +# E3: 一時 YAML が BOM なし UTF-8 で書かれること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlEncoding: + """E3 系: 一時 YAML のエンコーディング仕様.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_output_file_has_no_bom(self, sweep_module): + """一時 YAML ファイルが UTF-8 BOM(EF BB BF)で始まっていないこと. + + 仕様 E3: BOM なし UTF-8(Mitsuba YAML パーサは BOM 付きで失敗するため) + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + raw = tmp_yaml.read_bytes() + # UTF-8 BOM は 0xEF 0xBB 0xBF + bom = b"\xef\xbb\xbf" + assert not raw.startswith(bom), ( + "一時 YAML が UTF-8 BOM で始まっています.BOM なし UTF-8 で書き込んでください." + ) + + def test_output_file_is_valid_utf8(self, sweep_module): + """一時 YAML ファイルが UTF-8 として正常にデコードできること. + + 仕様 E3: UTF-8 エンコーディングで書かれていること + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + raw = tmp_yaml.read_bytes() + # UnicodeDecodeError が発生しなければ OK + decoded = raw.decode("utf-8") + assert len(decoded) > 0 + + def test_output_file_parseable_as_yaml(self, sweep_module): + """一時 YAML ファイルが YAML として正常にパースできること. + + 仕様 E3: 書き込み後に yaml.safe_load で再読み込みできること(エンコーディング崩れなし) + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert isinstance(data, dict) + assert "name" in data + + +# --------------------------------------------------------------------------- +# E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式になること +# --------------------------------------------------------------------------- + + +class TestGenerateTmpYamlName: + """E4 系: 一時 YAML の name フィールド仕様.""" + + @pytest.fixture(autouse=True) + def _patch_tmp_dir(self, tmp_path, sweep_module): + """TMP_DIR を tmp_path に差し替える.""" + with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): + yield + + def test_name_starts_with_baseline_minitias_blend(self, sweep_module): + """name が "baseline_minitias_blend_" で始まること. + + 仕様 E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + assert data["name"].startswith("baseline_minitias_blend_") + + def test_name_contains_weight_and_alpha(self, sweep_module): + """name に weight と alpha の値が含まれること. + + 仕様 E4: job_name = f"w{weight}_a{alpha}" 形式 + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + # job_name は "w0.15_a0.3" 相当 + name = data["name"] + assert "0.15" in name, f"weight 0.15 が name に含まれない: {name}" + assert "0.3" in name, f"alpha 0.3 が name に含まれない: {name}" + + def test_name_format_for_weight_0_05_alpha_0_1(self, sweep_module): + """weight=0.05, alpha=0.1 のとき name が適切な形式であること. + + 仕様 E4: スイープグリッドの全組合せで name が正しい形式になる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data["name"] + assert name.startswith("baseline_minitias_blend_") + assert "0.05" in name + assert "0.1" in name + + def test_name_format_for_weight_0_30_alpha_0_6(self, sweep_module): + """weight=0.30, alpha=0.6 のとき name が適切な形式であること. + + 仕様 E4: スイープグリッドの最大値でも name が正しい形式になる + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + name = data["name"] + assert name.startswith("baseline_minitias_blend_") + assert "0.3" in name # 0.30 → "0.3" が含まれれば OK + assert "0.6" in name + + def test_name_differs_across_different_params(self, sweep_module): + """異なるパラメータ組み合わせで生成された name が異なること. + + 仕様 E4: name がジョブ識別子を含むため各ジョブで一意になる + """ + tmp1 = sweep_module._generate_tmp_yaml(0.05, 0.1) + tmp2 = sweep_module._generate_tmp_yaml(0.30, 0.6) + with tmp1.open(encoding="utf-8") as f: + d1 = yaml.safe_load(f) + with tmp2.open(encoding="utf-8") as f: + d2 = yaml.safe_load(f) + assert d1["name"] != d2["name"], "異なるパラメータで同じ name が生成されています" + + def test_original_template_name_is_not_used(self, sweep_module): + """テンプレートの元の name ("baseline_minitias_blend") がそのまま使われないこと. + + 仕様 E4: name フィールドがジョブごとに更新される + """ + tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) + with tmp_yaml.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + # テンプレートの元 name は "baseline_minitias_blend"(サフィックスなし) + assert data["name"] != "baseline_minitias_blend", ( + "name がテンプレートのまま更新されていません" + ) 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 diff --git a/uv.lock b/uv.lock index d6a1ab9..fdcbd37 100644 --- a/uv.lock +++ b/uv.lock @@ -301,6 +301,19 @@ ] [[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } @@ -396,6 +409,18 @@ ] [[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } @@ -497,6 +522,15 @@ ] [[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] name = "numpy" version = "2.4.4" source = { registry = "https://pypi.org/simple" } @@ -1028,6 +1062,125 @@ ] [[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" }, + { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] name = "scooby" version = "0.11.2" source = { registry = "https://pypi.org/simple" } @@ -1060,6 +1213,7 @@ { name = "pyvista" }, { name = "pyyaml" }, { name = "rawpy" }, + { name = "scikit-image" }, { name = "trimesh" }, ] @@ -1081,6 +1235,7 @@ { name = "pyvista", specifier = ">=0.45" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rawpy", specifier = ">=0.21.0" }, + { name = "scikit-image", specifier = ">=0.21.0" }, { name = "trimesh", specifier = ">=4.12.1" }, ] @@ -1091,6 +1246,18 @@ ] [[package]] +name = "tifffile" +version = "2026.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/38/5e2ecef5af2f4fd4a89bb8d6240de9458bab4d51a4cbd97aeb3a0cd618e2/tifffile-2026.6.1.tar.gz", hash = "sha256:626c892c0e899d959b9438e7c0e1491dc154a7fead1f1f37a991724a50eceba9", size = 429694, upload-time = "2026-05-31T23:57:12.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/59/208f71d70ddc6184f79b8c6d87d46eb7d7b12c19186a817dec9c9c3f3693/tifffile-2026.6.1-py3-none-any.whl", hash = "sha256:0d7382d2769b855b81ce358528e2b40c16d48aa39031746efa81215205332a8d", size = 267108, upload-time = "2026-05-31T23:57:10.597Z" }, +] + +[[package]] name = "trimesh" version = "4.12.1" source = { registry = "https://pypi.org/simple" }