diff --git a/CLAUDE.md b/CLAUDE.md index f9f8350..771eabc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ 補足: **Step 8-14 候補 β (A1 内壁の非 Lambertian 性) 完了 — クリア (2026-06-02)**.候補リスト 🥇 1 位だった A1 を 3×3 グリッド感度分析で検証.スキーマ拡張 (`BlendBSDFConfig` + `RoughConductorBSDFConfig` 追加,`MaterialConfig` Union 拡張,forward reference + `model_rebuild()`) と `materials.py` 再帰変換 (`_build_bsdf_dict`) を新規実装.テンプレ `configs/scene_baseline_blend.yaml` + スイープスクリプト `scripts/sweep_blend_bsdf.py` + 比較パネルスクリプト `scripts/build_blend_bsdf_panel.py`.新規テスト 89 件追加 (608 → 697 全通過).**weight ∈ {0.05, 0.15, 0.30} × α ∈ {0.1, 0.3, 0.6} の 9 ジョブ実走 (75.5 分)**.結果: ベースライン純 diffuse (CoV=0.1826, 勾配=24.65%, C/P=1.327, 動径 min/max=0.615) と比較して **全 9 パターンが悪化または不変** (最良 w=0.05/α=0.6 で CoV=0.1851,最悪 w=0.30/α=0.1 で CoV=0.2433).実機 (CoV=0.0695) 方向と **逆方向に動く**.「形」は w=0.30 で下端中央に 2 つの specular 集中スポットが顕在化.物理的解釈: 純 Lambertian は cos 重み散乱で多重反射の均一化作用が最大.specular 成分は特定方向に光を集中させ均一化を阻害.**3D プリンタ白フィラメントは完全 Lambertian として扱うのが実機挙動に近い近似** と判明 (TECH_09 LED Lambertian 整合と並ぶ「物理モデル妥当性の裏付け」).**A1 は残差 2.63× の主因ではない**ことが確定.TECH_10 優先順位を更新: A1 を「クリア」化,🥇 1 位 A8 反射率 R=0.89 / 🥈 2 位 A9 max_depth 16+ / 🥉 3 位 B1 幾何精度 に繰り上げ.副次成果: スキーマ層に `blendbsdf` + `roughconductor` のサポートが入り,今後の物理モデル試行で再利用可能.比較パネル `output/_slide_panels/blend_bsdf_sweep.png` + `blend_bsdf_metrics.png`,全 9 ジョブ集約 `output/blend_sweep/summary.csv`.[TECH_10 § A1 検証結果](docs/05_TECH/TECH_10_残差主因候補の網羅整理.md#a1-検証結果2026-06-02-完了) / [TEST_01 § Step 8-14 候補 β](docs/06_TEST/TEST_01_キャリブレーション検証履歴.md#step-8-14-候補-β-a1-内壁の非-lambertian-性--混合-bsdf-感度分析) / [SPEC_02 materials](docs/04_SPEC/SPEC_02_シーン定義仕様.md#materials) 参照.次のアクション: Step 8-14 候補 α (A8 R=0.89 + A9 depth=16) の組み合わせ実施. 補足: **LED グループ別 sanity check 完了 — TEST_07 として報告 (2026-06-03)**.Step 8 系キャリブの主軸ではなく「シミュが下/右/左の 3 LED 基板を物理位置どおりに独立にモデル化できているか」の基礎健全性確認.`scripts/sanity_led_groups.py` で各基板の 6 LED だけを ON にした一時 YAML を生成 → 3 ジョブ sm-render (約 25 分) → 比較パネル.結果: **方向性 ✓** (bottom は下端中央/right は右側面寄り/left は左側面寄り),**左右対称性 ✓** (right mean=47.10 vs left mean=47.08),**量的バランスは bottom が right/left の 2.10× で強い**.ユーザー観察「bottom は上下の島,right/left は中心の円」は LED 配置で説明: bottom LED は評価板の下端真下に X 中央寄り集中 → 直接光が広範囲を叩く / right/left LED は側面外側で Y 中央付近に分散 → 直接光は端のみ・中央は多重反射経由 → 「直接光寄与が大きい bottom」vs「多重反射寄与が大きい right/left」の構造的違い.**線形性検証は別途未実施**(既存 `output/baseline/luminance.npy` が Step 8-1 時点の古いもので和との比較不可.Mitsuba は理論線形なので baseline 再生成で一致見込み).副次発見: 実機の right (Y∈[-25,+3]) と left (Y∈[-33,-5]) は **完全な左右対称設計ではない**(Fusion STL 実位置と整合).[TEST_07 LED グループ別動作確認](docs/06_TEST/TEST_07_LEDグループ別動作確認.md) 参照.**シミュレーションは 3 つの照明源を物理位置どおりに独立にモデル化できている**ことが確定し,Step 8 系の残差 2.63× 議論の前提が担保された. 補足: **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 コピー) @@ -68,6 +69,7 @@ - `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/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 index 367d028..c279bf0 100644 --- a/docs/05_TECH/README.md +++ b/docs/05_TECH/README.md @@ -18,7 +18,8 @@ | 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 項目で網羅整理.優先度評価(1 位: 内壁の非 Lambertian 性,2 位: 反射率 R=0.89,3 位: max_depth=16+).Step 8-14 候補選定の判断材料 | +| 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) の導入を仕様化.過去判断への遡及的注記.「数値の偽物の一致」を防ぐ評価方針 | ### 主な結論(読まずに済むサマリ) @@ -28,7 +29,8 @@ | ガンマ補正 | sm-evaluate で **OETF off** (実機 DNG が linear) | | 絶対値スケール調整 | mean アンカー正規化で target_mean=207.82 に揃え | | LED 配光モデル | **area light Lambertian (cos θ)** — データシート実測と数値一致 | -| 残差 2.63× の主因候補(優先順) | **🥇 内壁の非 Lambertian 性(光沢成分) / 🥈 反射率 R=0.85→0.89 / 🥉 max_depth=8→16+** (詳細は [TECH_10](TECH_10_残差主因候補の網羅整理.md)) | +| 残差 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 ファイル) diff --git "a/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" "b/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" index 1b965b2..015e52a 100644 --- "a/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" +++ "b/docs/05_TECH/TECH_10_\346\256\213\345\267\256\344\270\273\345\233\240\345\200\231\350\243\234\343\201\256\347\266\262\347\276\205\346\225\264\347\220\206.md" @@ -242,6 +242,8 @@ ユーザーの「形は変わっていない」観察も視覚的・物理的に正確.均一化フィルタ系の改善は数値には効くが形には効かない原則が再確認された. +> **注記 (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 グリッド感度分析**で検証したところ,**どの組み合わせも実機方向に作用しない(むしろ悪化)**ことが確定した. @@ -302,6 +304,8 @@ - `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 候補 γ として **事前検証**したところ,**両者は物理的に同じ領域を見ている**ことが確認できた.**設定変更は一切行わず,既存設定の数値検証のみ**で結論が出た. 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/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 43db593..b8fa373 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 残差倍率 | 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/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/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/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index f033be7..4403901 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -426,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/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/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/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" }