diff --git a/CLAUDE.md b/CLAUDE.md index 6739d8e..5a76d5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ 補足: **DNG 対応を MiniTias-Evaluation 側にも依頼(2026-06-01)**.PNG から DNG への撮影フォーマット変更に伴い,MiniTias-Evaluation も実機データの一次窓口として DNG 読み込み I/F の追加が必要.既存パターン(`uniformity.py` を MiniTias-Evaluation から SmTIAS-LightSim にコピー)を踏襲する役割分担を採用:**MiniTias-Evaluation 側で正規実装 → SmTIAS-LightSim 側にコピー**.[TECH_05 MiniTias-Evaluation DNG 対応要求仕様](docs/05_TECH/TECH_05_MiniTiasEvaluation_DNG対応要求仕様.md) に要求書を作成.主要 I/F: `load_image_dng(path, demosaic, black_level, output_format)` / `load_metadata(path)` / `load_lsc_map(path)` / `load_hot_pixel_list(path)` / `mask_hot_pixels` / `apply_lsc_inverse`(将来検証用).既存 PNG 経路と並列対応(後方互換).依存追加: `rawpy>=0.21.0`.想定実装工数 ~3.5 日(6 Phase).完了後 SmTIAS-LightSim 側 Claude セッションで `src/sm_tias_lightsim/evaluation/dng_loader.py` 等にコピー.次のアクション: MiniTias-Evaluation 側 Claude セッションで TECH_05 を受領 → Phase 1 から `/implement`.SmTIAS-LightSim 側のフェーズ A 受け入れ実装は MiniTias-Evaluation 完了後に着手. 補足: **MiniTias-Evaluation 側 Phase 1 完了とハンドオフ受領(2026-06-01)**.MiniTias-Evaluation 側で TECH_05 Phase 1 (DNG 読み込み I/F) と LSC 順適用プロトタイプ・新指標 (動径 min/max 比) が完成し,ハンドオフパッケージとして SmTIAS-LightSim 側に渡された.受領記録は [TECH_06 MiniTias-Evaluation 引き継ぎ受領](docs/05_TECH/TECH_06_MiniTias-Evaluation引き継ぎ受領.md),関連分析は [TEST_05 PNG/DNG 評価比較](docs/06_TEST/TEST_05_PNG_DNG評価比較.md) と [TEST_06 外乱光影響評価](docs/06_TEST/TEST_06_外乱光影響評価.md).**5 つの重要発見**: (1) **TECH_05 の `apply_lsc_inverse` は誤り**(DNG は LSC 未補正で逆ではなく順方向適用が正しい.TECH_05 §4 を訂正済).(2) **過去 Step 8 キャリブの残差 2.1× はドメイン不一致**(実機 PNG sRGB 0.0423 vs シミュ sRGB/linear 比較):正しい linear 比較では DNG linear+LSC 0.0689 vs シミュ linear 0.183 = **真の残差 2.66×**.(3) **新実機ターゲット = `MiniTIAS_QM_20260601_124500`(暗闇撮影)**: CoV=0.0695, 勾配=8.35%, 中心/周辺=1.0911, 動径 min/max=0.860.Step 8-13 以降の正規 baseline.(4) **外乱光ロバスト確認**: 室内灯 ON/OFF で指標差 < 1%.SmTIAS 自前照明が支配的で外乱光モデル化不要.(5) **新指標 `calc_radial_min_max_ratio`** (20 点輪帯平均 min/max 比) を追加.次フェーズ: S2 (dng_loader.py コピー + 新指標マージ + rawpy 依存追加) と S3 (sm-evaluate DNG 対応 + Step 8-13 突合) を別 `/implement` パイプラインで実施. 補足: **S2 完了 (2026-06-01)**.`src/sm_tias_lightsim/evaluation/dng_loader.py` を `handoff_smtias_lightsim/src/dng_loader.py` の完全コピーで新規作成 (docstring のみ差し替え).`src/sm_tias_lightsim/evaluation/radial.py` に `calc_radial_min_max_ratio` をマージ追加 (nan ビン除外を移植時に補強).`pyproject.toml` に `rawpy>=0.21.0` 追加 (実 rawpy==0.27.0).新規テスト 32 件追加 (`tests/evaluation/test_dng_loader.py` 341 行).既存 537 + 新規 32 = **569 件全通過** (slow 除外).**検証 V4 が完全一致**: 実 DNG (`MiniTIAS_QM_20260601_124500.dng`) を読み込んで LSC 順方向適用 + 白板 ROI で評価指標 6 件 (CoV/std/max-min/mean/max/min) が **相対誤差 < 1e-6 で期待値完全一致**,空間指標 3 件 (中心/周辺/勾配/動径 min/max) も絶対誤差 < 0.003.Refactorer 判断: `dng_loader.py` 内部の `_apply_srgb_oetf` は既存 `evaluation/oetf.py:apply_srgb_oetf` と実装重複だが「コピー方針優先」「用途が異なる」「違反基準 (3 箇所以上) 未満」の理由でそのまま放置.次フェーズ: S3 (sm-evaluate に DNG 入力経路追加 + 新規 baseline config + シミュ vs 実機 DNG 突合 → TEST_01 Step 8-13 記録) を別 /implement パイプラインで実施. +補足: **S3 方針見直しと Step D 完了 (2026-06-01)**.ユーザー指摘により「sm-evaluate に DNG 入力経路を追加する」当初案は **不採用**.理由: 実機 DNG 評価は MiniTias-Evaluation の本業で完結.SmTIAS-LightSim 側で重複実装する必要なし.S3 の作業範囲を **3 項目に絞り込み**: Step D (動径 min/max 比を sm-evaluate に統合)・Step C' (新規シミュ用 baseline config 作成)・Step G (シミュ再評価 → Step 8-13 として TEST_01 に記録).**Step D 完了**: `MetricsConfig.radial_min_max: bool = False` をスキーマ追加 (`schema.py`),`cli/evaluate.py` で `_run_radial_min_max` 関数追加して有効時に metrics.csv に 4 列 (radial_min_max_ratio / radial_min_distance / radial_min / radial_max) を末尾追加,コンソールサマリに 2 行追加.`radial_profile` を再利用 (重複算出回避),未算出時は内部算出.後方互換維持 (既存 3 configs は無修正で動作).新規テスト 39 件追加 (`tests/scene/test_metrics_radial_min_max_schema.py` 250 行 + `tests/cli/test_evaluate_radial_min_max.py` 740 行) で仕様 A1-D2 全カバー.既存 569 + 新規 39 = **608 件全通過**.SPEC_04 §動径 min/max 比 セクションを追記.次: Step C' (configs/eval_baseline_dng_target.yaml 作成: 既存 eval_baseline_linear.yaml ベース,target_mean=207.82,radial_min_max=true) → Step G (Step 8-13 計測). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) 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 197058b..cd15419 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" @@ -235,6 +235,17 @@ 正規化距離 `d` を **20 ビン**(既定)に分け,各ビンの平均輝度を取る.`figures/radial_profile.png` でグラフ化. +### 動径 min/max 比(オプション) + +`metrics.radial_min_max: true` で有効化.動径プロファイル(20 ビン平均)の **最小ビン値 ÷ 最大ビン値**.画素単位 `max_min` 比よりノイズ・外れ値に対しロバスト. + +| 指標 | 定義 | 値域 | +| --- | --- | --- | +| **動径 min/max 比** | `min(bins) / max(bins)`(空ビンは nan で除外) | 0〜1(1.0 で均一.小さいほどムラ大) | +| **動径 min ビン距離** | 最小値ビンの正規化距離 | 0〜1(1.0 が最周辺ビン) | + +`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` からマージ). + ## 可視化規約 (Figure Conventions) `sm-evaluate` は `figures/` に以下 4 種を生成.MiniTias-Evaluation 互換スタイル. diff --git a/src/sm_tias_lightsim/cli/evaluate.py b/src/sm_tias_lightsim/cli/evaluate.py index f2a6b56..de6afe0 100644 --- a/src/sm_tias_lightsim/cli/evaluate.py +++ b/src/sm_tias_lightsim/cli/evaluate.py @@ -26,7 +26,10 @@ from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor # noqa: E402 from sm_tias_lightsim.evaluation.oetf import apply_srgb_oetf, solve_exposure # noqa: E402 -from sm_tias_lightsim.evaluation.radial import calc_radial_profile # noqa: E402 +from sm_tias_lightsim.evaluation.radial import ( # noqa: E402 + calc_radial_min_max_ratio, + calc_radial_profile, +) from sm_tias_lightsim.evaluation.roi import extract_roi # noqa: E402 from sm_tias_lightsim.evaluation.visualize import ( # noqa: E402 save_histogram, @@ -278,6 +281,36 @@ return calc_radial_profile(roi_luminance) +def _run_radial_min_max( + roi_luminance: numpy.ndarray, + metrics_config: MetricsConfig | None, + radial_profile: numpy.ndarray | None, +) -> dict: + """動径 min/max 比を算出して返す. + + radial_min_max が True のとき,radial_profile が既に算出されていればそれを再利用し, + 未算出(None)の場合はその場で calc_radial_profile を呼び出して算出する. + 重複算出を避けるため,radial_profile の結果を優先的に使用する. + + Args: + roi_luminance: ROI 輝度配列(H x W, float64). + metrics_config: MetricsConfig インスタンス(radial_min_max フラグを持つ). + None の場合は無効扱い. + radial_profile: _run_radial_profile で算出済みのプロファイル配列. + None の場合はここで算出する. + + Returns: + 動径 min/max 比指標の辞書(radial_min_max_ratio / radial_min_distance / + radial_min / radial_max の 4 キー).無効時は空辞書. + """ + if metrics_config is None or not metrics_config.radial_min_max: + return {} + + # radial_profile が未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) + profile = radial_profile if radial_profile is not None else calc_radial_profile(roi_luminance) + return calc_radial_min_max_ratio(profile) + + def _save_metrics_csv(metrics: dict, csv_path: Path) -> None: """均一性指標を CSV ファイルとして保存する. @@ -333,6 +366,10 @@ print(f" C/P ratio : {metrics['center_periphery_ratio']:.4f}") print(f" gradient [%] : {metrics['gradient_magnitude']:.2f}") + if "radial_min_max_ratio" in metrics: + print(f" radial min/max : {metrics['radial_min_max_ratio']:.4f}") + print(f" radial min dist: {metrics['radial_min_distance']:.4f}") + print(f"\nmetrics.csv : {csv_path}") print(f"figures/ : {figures_dir}") @@ -351,9 +388,10 @@ 8. calc_uniformity で均一性指標を算出する. 9. zones.enabled のときゾーン分析を実行する. 10. metrics.radial_profile のとき放射状プロファイルを算出する. - 11. metrics.csv を保存する. - 12. 可視化 PNG を figures/ に保存する. - 13. 算出指標のサマリーを標準出力に表示する. + 11. metrics.radial_min_max のとき動径 min/max 比を算出する(radial_profile 再利用). + 12. metrics.csv を保存する. + 13. 可視化 PNG を figures/ に保存する. + 14. 算出指標のサマリーを標準出力に表示する. Args: argv: 引数リスト.None のとき sys.argv[1:] を使用. @@ -443,6 +481,15 @@ print(f"エラー: 放射状プロファイルの算出に失敗しました — {exc}", file=sys.stderr) return 1 + try: + radial_min_max_stats = _run_radial_min_max( + roi_luminance, eval_config.metrics, radial_profile + ) + metrics.update(radial_min_max_stats) + except Exception as exc: # noqa: BLE001 + print(f"エラー: 動径 min/max 比の算出に失敗しました — {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/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index c8a1f83..4054431 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -188,8 +188,12 @@ position: Vec3 = Field(..., description="位置 [mm]") direction: Vec3 = Field(..., description="光軸方向ベクトル") cutoff_angle: float = Field(..., description="光が完全に 0 になる角度(度,0 < cutoff ≤ 90)") - beam_width: float = Field(..., description="中央フラット領域の半角(度,0 ≤ beam_width ≤ cutoff_angle)") - intensity: float | Vec3 = Field(..., description="軸上強度 I_0 [W/sr](スカラまたは RGB タプル)") + beam_width: float = Field( + ..., description="中央フラット領域の半角(度,0 ≤ beam_width ≤ cutoff_angle)" + ) + intensity: float | Vec3 = Field( + ..., description="軸上強度 I_0 [W/sr](スカラまたは RGB タプル)" + ) # type フィールドで識別される discriminated union @@ -354,6 +358,14 @@ std: bool = Field(default=True, description="標準偏差") max_min: bool = Field(default=True, description="最大値/最小値比") radial_profile: bool = Field(default=True, description="放射状プロファイル") + radial_min_max: bool = Field( + default=False, + description=( + "動径 min/max 比(radial_min_max_ratio / radial_min_distance / " + "radial_min / radial_max の 4 列を metrics.csv に追加)." + "True のとき radial_profile の有無に関わらず内部で radial_profile を算出する." + ), + ) class EvaluationConfig(_FrozenBase): diff --git a/tests/cli/test_evaluate_radial_min_max.py b/tests/cli/test_evaluate_radial_min_max.py new file mode 100644 index 0000000..f32b20b --- /dev/null +++ b/tests/cli/test_evaluate_radial_min_max.py @@ -0,0 +1,741 @@ +"""cli/evaluate.py の _run_radial_min_max 関数と CLI 統合動作の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- src/sm_tias_lightsim/cli/evaluate.py(_run_radial_min_max 実装) +- src/sm_tias_lightsim/evaluation/radial.py(calc_radial_min_max_ratio 実装) + +テスト方針: + - subprocess ではなく main() / _run_radial_min_max() を直接呼ぶ(速度・カバレッジのため) + - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08) + - 乱数を使う場合は seed を必ず固定する (GUIDE_08) + - monkeypatch で calc_radial_profile の呼び出し有無を検証する + +注意: + matplotlib の Agg バックエンドは evaluate.py 内部で設定される. + テスト側でも安全のため import 前に設定する. +""" + +from __future__ import annotations + +import matplotlib # noqa: I001 + +# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する +matplotlib.use("Agg") + +from pathlib import Path +from unittest.mock import patch + +import numpy +import pytest + +from sm_tias_lightsim.cli.evaluate import _run_radial_min_max, main +from sm_tias_lightsim.evaluation.radial import calc_radial_profile +from sm_tias_lightsim.scene.schema import MetricsConfig + +# --------------------------------------------------------------------------- +# テスト用ヘルパー +# --------------------------------------------------------------------------- + + +def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray: + """ゼロを含まない合成輝度配列を返す(seed 固定). + + center が明るく periphery が暗い放射状勾配で radial_min_max が意味を持つ値になる. + """ + rng = numpy.random.RandomState(seed) + h, w = size, size + 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, 2.0, (h, w)) + # ゼロを避けるためクリップ + return numpy.clip(luminance, 1.0, None).astype(numpy.float64) + + +def _make_eval_yaml_with_radial_min_max( + tmp_path: Path, + radial_min_max: bool = True, + radial_profile: bool = True, + x: int = 0, + y: int = 0, + width: int = 128, + height: int = 128, +) -> Path: + """radial_min_max フラグを含む評価 YAML を tmp_path に書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: {x} + y: {y} + width: {width} + height: {height} +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: {"true" if radial_profile else "false"} + radial_min_max: {"true" if radial_min_max else "false"} +""" + yaml_path = tmp_path / "eval_radial_min_max.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)] + ) + + +# --------------------------------------------------------------------------- +# B1. 無効時は空辞書: metrics_config is None で {} を返す +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_empty_dict_when_metrics_config_is_none(): + """metrics_config が None のとき _run_radial_min_max が {} を返すこと. + + 仕様: metrics_config is None → 無効扱いで空辞書を返す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=0) + result = _run_radial_min_max(roi_luminance, None, None) + assert result == {} + + +# --------------------------------------------------------------------------- +# B2. 無効時は空辞書(False): radial_min_max=False で {} を返す +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_empty_dict_when_radial_min_max_false(): + """MetricsConfig(radial_min_max=False) で _run_radial_min_max が {} を返すこと. + + 仕様: metrics_config.radial_min_max=False → 無効扱いで空辞書を返す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=1) + metrics_cfg = MetricsConfig(radial_min_max=False) + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + assert result == {} + + +def test_run_radial_min_max_returns_empty_dict_when_radial_min_max_default(): + """MetricsConfig() のデフォルト(radial_min_max=False)で {} を返すこと. + + 仕様: MetricsConfig.radial_min_max の既定値 False → 無効扱い + """ + roi_luminance = _make_luminance_array(seed=2) + metrics_cfg = MetricsConfig() # radial_min_max=False がデフォルト + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + assert result == {} + + +# --------------------------------------------------------------------------- +# B3. 有効時は 4 キー辞書 +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_returns_four_keys_when_enabled(): + """MetricsConfig(radial_min_max=True) + プロファイル提供で 4 キー辞書を返すこと. + + 仕様: radial_min_max=True のとき + radial_min_max_ratio / radial_min_distance / radial_min / radial_max + の 4 キーを持つ辞書を返す(cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=3) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + +def test_run_radial_min_max_ratio_in_valid_range(): + """radial_min_max_ratio が [0, 1] の範囲内であること(均一性指標として妥当). + + 仕様: calc_radial_min_max_ratio — 最小輝度 / 最大輝度(0〜1) + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=4) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + ratio = result["radial_min_max_ratio"] + assert 0.0 <= ratio <= 1.0 + + +def test_run_radial_min_max_radial_min_lte_radial_max(): + """radial_min が radial_max 以下であること(min ≤ max). + + 仕様: radial_min = プロファイルの最小輝度値,radial_max = 最大輝度値 + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=5) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert result["radial_min"] <= result["radial_max"] + + +def test_run_radial_min_max_ratio_equals_min_over_max(): + """radial_min_max_ratio が radial_min / radial_max と一致すること. + + 仕様: radial_min_max_ratio = 最小輝度 / 最大輝度 + (evaluation/radial.py calc_radial_min_max_ratio) + """ + roi_luminance = _make_luminance_array(seed=6) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + result = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + expected_ratio = result["radial_min"] / result["radial_max"] + assert result["radial_min_max_ratio"] == pytest.approx(expected_ratio, rel=1e-10) + + +# --------------------------------------------------------------------------- +# B4. radial_profile 再利用: プロファイル提供時は calc_radial_profile を呼ばない +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_reuses_provided_profile_and_does_not_recalculate(): + """radial_profile 引数が None でない時は calc_radial_profile が呼ばれないこと. + + 仕様: radial_profile が既に算出されていればそれを再利用し,重複算出を避ける + (cli/evaluate.py _run_radial_min_max docstring) + """ + roi_luminance = _make_luminance_array(seed=7) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + with patch("sm_tias_lightsim.cli.evaluate.calc_radial_profile") as mock_calc: + _run_radial_min_max(roi_luminance, metrics_cfg, profile) + mock_calc.assert_not_called() + + +def test_run_radial_min_max_reuses_profile_values_unchanged(): + """提供した radial_profile のデータが再利用されて結果が変わらないこと. + + 仕様: radial_profile を再利用する — プロファイルの値は変更されない + """ + roi_luminance = _make_luminance_array(seed=8) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + # 同じプロファイルを 2 回渡しても結果が同じ + result1 = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + result2 = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + + assert result1["radial_min_max_ratio"] == pytest.approx(result2["radial_min_max_ratio"]) + assert result1["radial_min_distance"] == pytest.approx(result2["radial_min_distance"]) + + +# --------------------------------------------------------------------------- +# B5. radial_profile 内部算出: None + radial_min_max=True で calc_radial_profile が呼ばれる +# --------------------------------------------------------------------------- + + +def test_run_radial_min_max_calls_calc_radial_profile_when_profile_is_none(): + """radial_profile=None かつ radial_min_max=True で calc_radial_profile が呼ばれること. + + 仕様: radial_profile が None(未算出)の場合はその場で calc_radial_profile を呼び出す + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=9) + metrics_cfg = MetricsConfig(radial_min_max=True) + + real_profile = calc_radial_profile(roi_luminance) + + with patch( + "sm_tias_lightsim.cli.evaluate.calc_radial_profile", + return_value=real_profile, + ) as mock_calc: + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + mock_calc.assert_called_once_with(roi_luminance) + + # 結果も正常に返ること + assert "radial_min_max_ratio" in result + + +def test_run_radial_min_max_with_none_profile_returns_valid_result(): + """radial_profile=None で呼んでも有効な 4 キー辞書が返ること(内部算出). + + 仕様: 未算出の場合はここで算出する(重複算出を避けるため既存結果を優先) + (cli/evaluate.py _run_radial_min_max) + """ + roi_luminance = _make_luminance_array(seed=10) + metrics_cfg = MetricsConfig(radial_min_max=True) + result = _run_radial_min_max(roi_luminance, metrics_cfg, None) + + assert "radial_min_max_ratio" in result + assert "radial_min_distance" in result + assert "radial_min" in result + assert "radial_max" in result + + +def test_run_radial_min_max_none_profile_yields_same_result_as_provided_profile(): + """radial_profile=None の内部算出結果がプロファイル提供版と一致すること. + + 仕様: 内部算出は calc_radial_profile(roi_luminance) を呼ぶので同じ結果になる + """ + roi_luminance = _make_luminance_array(seed=11) + metrics_cfg = MetricsConfig(radial_min_max=True) + profile = calc_radial_profile(roi_luminance) + + result_with_profile = _run_radial_min_max(roi_luminance, metrics_cfg, profile) + result_without_profile = _run_radial_min_max(roi_luminance, metrics_cfg, None) + + assert result_with_profile["radial_min_max_ratio"] == pytest.approx( + result_without_profile["radial_min_max_ratio"], rel=1e-10 + ) + assert result_with_profile["radial_min_distance"] == pytest.approx( + result_without_profile["radial_min_distance"], rel=1e-10 + ) + + +# --------------------------------------------------------------------------- +# C1. 既存 config では radial_min_max_ratio 列が存在しない +# --------------------------------------------------------------------------- + + +def test_existing_config_without_radial_min_max_flag_no_column(tmp_path: Path): + """radial_min_max を指定しない既存 config では CSV に radial_min_max_ratio 列が存在しないこと. + + 仕様: radial_min_max 既定 False で既存 configs は無修正で動作する + (cli/evaluate.py 実装サマリー「後方互換」) + """ + import pandas + + luminance = _make_luminance_array(seed=100) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # radial_min_max を含まない既存相当 YAML + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 128 + height: 128 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + config_path = tmp_path / "eval_no_radial_min_max.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 "radial_min_max_ratio" not in df.columns + assert "radial_min_distance" not in df.columns + assert "radial_min" not in df.columns + assert "radial_max" not in df.columns + + +# --------------------------------------------------------------------------- +# C2. 新フラグで 4 列追加される +# --------------------------------------------------------------------------- + + +def test_radial_min_max_true_adds_four_columns_to_csv(tmp_path: Path): + """metrics.radial_min_max: true を有効化すると CSV に 4 列が追加されること. + + 仕様: 出力 CSV: 既存末尾に radial_min_max_ratio / radial_min_distance / + radial_min / radial_max の 4 列追加 + (cli/evaluate.py 実装サマリー「出力 CSV」) + """ + import pandas + + luminance = _make_luminance_array(seed=101) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=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") + assert "radial_min_max_ratio" in df.columns + assert "radial_min_distance" in df.columns + assert "radial_min" in df.columns + assert "radial_max" in df.columns + + +def test_radial_min_max_true_csv_values_are_float(tmp_path: Path): + """radial_min_max 列の値が float であること. + + 仕様: すべて float(metrics.csv カラム定義,SPEC_04) + """ + import pandas + + luminance = _make_luminance_array(seed=102) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + # 型として float に変換可能であること + ratio = float(df["radial_min_max_ratio"].iloc[0]) + dist = float(df["radial_min_distance"].iloc[0]) + rmin = float(df["radial_min"].iloc[0]) + rmax = float(df["radial_max"].iloc[0]) + + # 値域チェック + assert 0.0 <= ratio <= 1.0 + assert 0.0 <= dist <= 1.0 + assert rmin <= rmax + + +def test_radial_min_max_true_main_returns_0(tmp_path: Path): + """radial_min_max: true を含む YAML で main() が 0 を返すこと. + + 仕様: 正常終了時は 0 を返す(cli/evaluate.py main) + """ + luminance = _make_luminance_array(seed=103) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + +# --------------------------------------------------------------------------- +# C3. CSV 列順序の後方互換 +# --------------------------------------------------------------------------- + + +def test_radial_min_max_columns_appended_after_existing_columns(tmp_path: Path): + """radial_min_max の 4 列が既存列(cov/std/max_min_ratio 等)の後に追加されること. + + 仕様: 既存列(cov/std/max 等)の順序が変わらない(4 列は末尾に追加) + (cli/evaluate.py 実装サマリー「出力 CSV」) + """ + import pandas + + luminance = _make_luminance_array(seed=104) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + cols = list(df.columns) + + # 既存列が先頭側に存在すること + expected_early_cols = ["cov", "std", "max_min_ratio", "mean", "max", "min"] + for col in expected_early_cols: + assert col in cols, f"列 {col} が CSV に存在しない" + + # radial_min_max_ratio の位置が cov より後ろであること + cov_idx = cols.index("cov") + ratio_idx = cols.index("radial_min_max_ratio") + assert ratio_idx > cov_idx, "radial_min_max_ratio は cov より後ろにあるべき" + + +def test_existing_columns_order_unchanged_with_radial_min_max(tmp_path: Path): + """radial_min_max 有効時と無効時で既存列の順序が変わらないこと. + + 仕様: 既存列の順序が変わらない(後方互換) + """ + import pandas + + luminance = _make_luminance_array(seed=105) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + # radial_min_max=False(既存相当) + cfg_dir_no = tmp_path / "no" + cfg_dir_no.mkdir(parents=True, exist_ok=True) + config_no = _make_eval_yaml_with_radial_min_max(cfg_dir_no, radial_min_max=False) + out_no = tmp_path / "output_no" + _run_main(npy_path, config_no, out_no) + df_no = pandas.read_csv(out_no / "metrics.csv") + + # radial_min_max=True + cfg_dir_yes = tmp_path / "yes" + cfg_dir_yes.mkdir(parents=True, exist_ok=True) + config_yes = _make_eval_yaml_with_radial_min_max(cfg_dir_yes, radial_min_max=True) + out_yes = tmp_path / "output_yes" + _run_main(npy_path, config_yes, out_yes) + df_yes = pandas.read_csv(out_yes / "metrics.csv") + + # 既存列は両方の CSV に存在し,df_no の列順序が df_yes でも保たれること + for col in df_no.columns: + assert col in df_yes.columns, f"既存列 {col} が radial_min_max=True の CSV に存在しない" + + # 既存列部分の順序が同じ + existing_in_yes = [c for c in df_yes.columns if c in list(df_no.columns)] + assert existing_in_yes == list(df_no.columns) + + +# --------------------------------------------------------------------------- +# C4. radial_profile: false + radial_min_max: true で独立動作 +# --------------------------------------------------------------------------- + + +def test_radial_profile_false_with_radial_min_max_true_no_png(tmp_path: Path): + """radial_profile: false かつ radial_min_max: true のとき + radial_profile.png が生成されないこと. + + 仕様: radial_min_max は radial_profile フラグとは独立して動作する + radial_profile.png は metrics.radial_profile=True のときのみ生成される + (cli/evaluate.py 実装サマリー「C4. radial_profile: false + radial_min_max: true」) + """ + luminance = _make_luminance_array(seed=106) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=False + ) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + # radial_profile.png は生成されない + assert not (output_dir / "figures" / "radial_profile.png").exists() + + +def test_radial_profile_false_with_radial_min_max_true_has_four_csv_columns(tmp_path: Path): + """radial_profile: false かつ radial_min_max: true のとき CSV に 4 列が追加されること. + + 仕様: radial_min_max は radial_profile フラグとは独立して動作する + radial_profile.png がなくても 4 列は CSV に追加される + """ + import pandas + + luminance = _make_luminance_array(seed=107) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=False + ) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + assert "radial_min_distance" in df.columns + assert "radial_min" in df.columns + assert "radial_max" in df.columns + + +def test_radial_profile_true_with_radial_min_max_true_both_work(tmp_path: Path): + """radial_profile: true かつ radial_min_max: true のとき PNG も 4 列も生成されること. + + 仕様: radial_profile と radial_min_max は独立して有効化できる + 両方 True のとき radial_profile.png + 4 列が揃う + """ + import pandas + + luminance = _make_luminance_array(seed=108) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, radial_min_max=True, radial_profile=True + ) + output_dir = tmp_path / "output" + result = _run_main(npy_path, config_path, output_dir) + assert result == 0 + + # radial_profile.png が生成される + assert (output_dir / "figures" / "radial_profile.png").exists() + + # 4 列も存在する + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + + +# --------------------------------------------------------------------------- +# C5. エラー処理: _run_radial_min_max 内で例外が出たとき main() が 1 を返す +# --------------------------------------------------------------------------- + + +def test_radial_min_max_exception_returns_1(tmp_path: Path): + """_run_radial_min_max が例外を投げたとき main() が 1 を返すこと. + + 仕様: 動径 min/max 比の算出に失敗したとき return 1 + (cli/evaluate.py main: except Exception → return 1) + """ + luminance = _make_luminance_array(seed=109) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + side_effect=RuntimeError("テスト用エラー"), + ): + result = _run_main(npy_path, config_path, output_dir) + + assert result == 1 + + +def test_radial_min_max_exception_writes_stderr(tmp_path: Path, capsys): + """_run_radial_min_max が例外を投げたとき stderr にエラーメッセージが出ること. + + 仕様: 動径 min/max 比の算出に失敗しました — {exc} が stderr に出力される + (cli/evaluate.py main) + """ + luminance = _make_luminance_array(seed=110) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate._run_radial_min_max", + side_effect=RuntimeError("テスト用エラー"), + ): + main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]) + + captured = capsys.readouterr() + assert "エラー" in captured.err + + +def test_radial_min_max_inner_exception_returns_1(tmp_path: Path): + """calc_radial_min_max_ratio が例外を投げたとき main() が 1 を返すこと. + + 仕様: 内部関数の例外もキャッチして return 1 になる + (cli/evaluate.py main: except Exception → return 1) + """ + luminance = _make_luminance_array(seed=111) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + + with patch( + "sm_tias_lightsim.cli.evaluate.calc_radial_min_max_ratio", + side_effect=ValueError("テスト用内部エラー"), + ): + result = _run_main(npy_path, config_path, output_dir) + + assert result == 1 + + +# --------------------------------------------------------------------------- +# コンソール出力の確認 +# --------------------------------------------------------------------------- + + +def test_radial_min_max_summary_printed_when_enabled(tmp_path: Path, capsys): + """radial_min_max: true のとき radial min/max と radial min dist がコンソールに出ること. + + 仕様: コンソール出力: 代表 2 行(radial min/max, radial min dist)のみ追加 + (cli/evaluate.py _print_summary) + """ + luminance = _make_luminance_array(seed=112) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=True) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + assert "radial min/max" in captured.out + assert "radial min dist" in captured.out + + +def test_radial_min_max_summary_not_printed_when_disabled(tmp_path: Path, capsys): + """radial_min_max: false のとき radial min/max がコンソールに出ないこと. + + 仕様: radial_min_max_ratio がメトリクスにないとき _print_summary は出力しない + (cli/evaluate.py _print_summary: if "radial_min_max_ratio" in metrics) + """ + luminance = _make_luminance_array(seed=113) + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, luminance) + + config_path = _make_eval_yaml_with_radial_min_max(tmp_path, radial_min_max=False) + output_dir = tmp_path / "output" + _run_main(npy_path, config_path, output_dir) + + captured = capsys.readouterr() + assert "radial min/max" not in captured.out + + +# --------------------------------------------------------------------------- +# D2. slow テスト(実 output/baseline/luminance.npy がある場合) +# --------------------------------------------------------------------------- + +#: output/baseline/luminance.npy の絶対パス +BASELINE_LUMINANCE_NPY = ( + Path(__file__).parent.parent.parent / "output" / "baseline" / "luminance.npy" +) + + +@pytest.mark.slow +@pytest.mark.skipif( + not BASELINE_LUMINANCE_NPY.exists(), + reason="output/baseline/luminance.npy が存在しない場合はスキップ", +) +def test_radial_min_max_with_real_baseline_npy(tmp_path: Path): + """実 output/baseline/luminance.npy で radial_min_max: true が動作すること. + + 仕様: 動径 min/max 比 — 20 点輪帯平均の min/max で 1.0 で均一(SPEC_04 動径指標) + """ + import pandas + + config_path = _make_eval_yaml_with_radial_min_max( + tmp_path, + radial_min_max=True, + radial_profile=True, + # baseline の ROI に合わせる + x=24, + y=37, + width=464, + height=450, + ) + output_dir = tmp_path / "output" + result = main( + [ + "--image", + str(BASELINE_LUMINANCE_NPY), + "--config", + str(config_path), + "--output", + str(output_dir), + ] + ) + assert result == 0 + + df = pandas.read_csv(output_dir / "metrics.csv") + assert "radial_min_max_ratio" in df.columns + + ratio = float(df["radial_min_max_ratio"].iloc[0]) + # シミュ linear の均一性は悪いため ratio は 1.0 より相当小さいはず + # 存在確認として 0.0〜1.0 の範囲内であることのみチェック + assert 0.0 < ratio <= 1.0 diff --git a/tests/scene/test_metrics_radial_min_max_schema.py b/tests/scene/test_metrics_radial_min_max_schema.py new file mode 100644 index 0000000..92c4585 --- /dev/null +++ b/tests/scene/test_metrics_radial_min_max_schema.py @@ -0,0 +1,251 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +"""scene/schema.py の MetricsConfig.radial_min_max フィールド仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.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)への非干渉を確認する + - YAML ロード経由の動作も確認する +""" + +from __future__ import annotations + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, MetricsConfig + +# --------------------------------------------------------------------------- +# A1. 既定値: MetricsConfig() で radial_min_max == False +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_default_is_false(): + """MetricsConfig() のインスタンスで radial_min_max の既定値が False であること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + (schema.py MetricsConfig) + """ + cfg = MetricsConfig() + assert cfg.radial_min_max is False + + +# --------------------------------------------------------------------------- +# A2. 明示 True: MetricsConfig(radial_min_max=True) で True になること +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_explicit_true(): + """MetricsConfig(radial_min_max=True) で radial_min_max が True になること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + True を明示指定すると True が設定される(schema.py MetricsConfig) + """ + cfg = MetricsConfig(radial_min_max=True) + assert cfg.radial_min_max is True + + +def test_metrics_radial_min_max_explicit_false(): + """MetricsConfig(radial_min_max=False) で radial_min_max が False になること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + """ + cfg = MetricsConfig(radial_min_max=False) + assert cfg.radial_min_max is False + + +# --------------------------------------------------------------------------- +# A3. 既存フィールドへの非干渉 +# --------------------------------------------------------------------------- + + +def test_metrics_radial_min_max_does_not_affect_cov_default(): + """radial_min_max 指定有無で cov の既定値が変わらないこと. + + 仕様: MetricsConfig.cov: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.cov == cfg_true.cov + assert cfg_default.cov is True + + +def test_metrics_radial_min_max_does_not_affect_std_default(): + """radial_min_max 指定有無で std の既定値が変わらないこと. + + 仕様: MetricsConfig.std: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.std == cfg_true.std + assert cfg_default.std is True + + +def test_metrics_radial_min_max_does_not_affect_max_min_default(): + """radial_min_max 指定有無で max_min の既定値が変わらないこと. + + 仕様: MetricsConfig.max_min: bool = Field(default=True, ...) — 非干渉 + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.max_min == cfg_true.max_min + assert cfg_default.max_min is True + + +def test_metrics_radial_min_max_does_not_affect_radial_profile_default(): + """radial_min_max 指定有無で radial_profile の既定値が変わらないこと. + + 仕様: MetricsConfig.radial_profile: bool = Field(default=True, ...) — 非干渉 + radial_min_max とは独立したフィールド(schema.py MetricsConfig) + """ + cfg_default = MetricsConfig() + cfg_true = MetricsConfig(radial_min_max=True) + + assert cfg_default.radial_profile == cfg_true.radial_profile + assert cfg_default.radial_profile is True + + +def test_metrics_config_all_fields_together(): + """radial_min_max=True を含む全フィールド指定で正常にパースされること. + + 仕様: MetricsConfig の全フィールドが独立して指定可能(schema.py MetricsConfig) + """ + cfg = MetricsConfig( + cov=True, std=False, max_min=True, radial_profile=False, radial_min_max=True + ) + assert cfg.cov is True + assert cfg.std is False + assert cfg.max_min is True + assert cfg.radial_profile is False + assert cfg.radial_min_max is True + + +def test_metrics_config_is_frozen_for_radial_min_max(): + """MetricsConfig が frozen(イミュータブル)で radial_min_max も変更不可であること. + + 仕様: _FrozenBase → frozen=True(schema.py ConfigDict) + """ + cfg = MetricsConfig() + with pytest.raises(ValidationError): + cfg.radial_min_max = True # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# A4. YAML ロード: YAML で metrics.radial_min_max: true を指定すると True +# --------------------------------------------------------------------------- + + +def test_yaml_load_metrics_radial_min_max_true(): + """YAML で metrics.radial_min_max: true を指定したとき True が反映されること. + + 仕様: EvaluationConfig.metrics.radial_min_max が YAML 経由で設定される + (schema.py MetricsConfig / EvaluationConfig) + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is True + + +def test_yaml_load_metrics_radial_min_max_false(): + """YAML で metrics.radial_min_max: false を指定したとき False が反映されること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + radial_min_max: false +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False + + +def test_yaml_load_metrics_radial_min_max_omitted_defaults_false(): + """YAML で metrics.radial_min_max を省略したとき既定値 False が設定されること. + + 仕様: MetricsConfig.radial_min_max: bool = Field(default=False, ...) + 既存 YAML(eval_baseline.yaml / eval_baseline_linear.yaml / eval_real.yaml 相当)は + radial_min_max を含まないため後方互換として False になる必要がある + """ + yaml_content = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False + + +def test_yaml_load_eval_baseline_linear_equivalent_has_radial_min_max_false(): + """eval_baseline_linear.yaml 相当の YAML では radial_min_max が False であること. + + 仕様: 既存 configs(eval_baseline.yaml / eval_baseline_linear.yaml)は + radial_min_max を含まず,未指定時は False(後方互換) + """ + yaml_content = """\ +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 +normalization: + enabled: true + type: mean_anchor + target_mean: 146.7146 +""" + data = yaml.safe_load(yaml_content) + cfg = EvaluationConfig.model_validate(data) + assert cfg.metrics is not None + assert cfg.metrics.radial_min_max is False