diff --git a/tests/cli/test_evaluate_fullimage.py b/tests/cli/test_evaluate_fullimage.py new file mode 100644 index 0000000..b14debf --- /dev/null +++ b/tests/cli/test_evaluate_fullimage.py @@ -0,0 +1,320 @@ +"""sm-evaluate への画像全体 min/max 比の組み込みの仕様ベーステスト(統合テスト). + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」 + (オプション指標は metrics の bool フラグで有効化し,metrics.csv の末尾に列を追加する. + 既定 False で既存 config は無修正で従来どおり動く) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション) +- evaluation/fullimage.py(指標の定義.5 つの値を返す) +- schema.py MetricsConfig.fullimage_min_max / fullimage_sigma + +検証する仕様: +- metrics.fullimage_min_max: true で metrics.csv に 5 列 + (fullimage_min_max_ratio / fullimage_min / fullimage_max / + fullimage_noise_min / fullimage_noise_max)が追加される +- サマリーに `fullimage min/max: x.xxxx` 行が出る +- フィールドを書かない既存 config では列も出力行も増えない(後方互換) +- metrics.fullimage_sigma を変えると値が変わる +- ROI が平滑化 σ に対して小さすぎる等の不正入力は段階別エラーとして終了コード 1 になる + +テスト方針(.claude/rules/testing.md): +- subprocess ではなく main() / _run_fullimage_min_max() を直接呼ぶ +- ファイル I/O はモックせず tmp_path で実ファイルを使う +- 乱数は seed を固定する +""" + +from __future__ import annotations + +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 main +from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio +from sm_tias_lightsim.evaluation.pipeline import _run_fullimage_min_max +from sm_tias_lightsim.scene.schema import MetricsConfig + +_FULLIMAGE_COLUMNS = ( + "fullimage_min_max_ratio", + "fullimage_min", + "fullimage_max", + "fullimage_noise_min", + "fullimage_noise_max", +) + +_ROI_SIZE = 160 + + +def _make_luminance(seed: int = 0, size: int = _ROI_SIZE) -> numpy.ndarray: + """左右に勾配のある合成輝度配列(方位依存の不均一 + ノイズ,seed 固定).""" + rng = numpy.random.default_rng(seed) + _, xs = numpy.mgrid[0:size, 0:size] + base = (1.0 - 0.2 * xs / (size - 1)) * 100.0 + return base + rng.normal(0.0, 5.0, base.shape) + + +def _write_npy(tmp_path: Path, seed: int = 0) -> Path: + npy_path = tmp_path / "luminance.npy" + numpy.save(npy_path, _make_luminance(seed)) + return npy_path + + +def _write_eval_yaml( + tmp_path: Path, + *, + name: str = "eval_fullimage.yaml", + fullimage_lines: str = "", + roi_size: int = _ROI_SIZE, +) -> Path: + """metrics セクションに任意の行を足した評価 YAML を書き出す.""" + content = f"""\ +roi: + type: image_pixel + x: 0 + y: 0 + width: {roi_size} + height: {roi_size} +zones: + enabled: true + boundaries: [0.33, 0.66] +metrics: + cov: true + std: true + max_min: true + radial_profile: true + radial_min_max: true +{fullimage_lines}""" + yaml_path = tmp_path / name + yaml_path.write_text(content, encoding="utf-8") + return yaml_path + + +def _run_evaluate(npy_path: Path, config_path: Path, output_dir: Path) -> int: + return main( + [ + "--image", + str(npy_path), + "--config", + str(config_path), + "--output", + str(output_dir), + "--no-figures", + ] + ) + + +# --------------------------------------------------------------------------- +# A. _run_fullimage_min_max(パイプライン段の振る舞い) +# --------------------------------------------------------------------------- + + +def test_run_fullimage_returns_empty_dict_when_metrics_config_is_none(): + """metrics_config が None のとき空辞書を返すこと(無効扱い).""" + assert _run_fullimage_min_max(_make_luminance(), None) == {} + + +@pytest.mark.parametrize( + "metrics_config", + [MetricsConfig(), MetricsConfig(fullimage_min_max=False)], + ids=["default", "explicit_false"], +) +def test_run_fullimage_returns_empty_dict_when_disabled(metrics_config: MetricsConfig): + """fullimage_min_max が False(既定含む)のとき空辞書を返すこと.""" + assert _run_fullimage_min_max(_make_luminance(seed=1), metrics_config) == {} + + +def test_run_fullimage_returns_five_keys_when_enabled(): + """fullimage_min_max=True のとき仕様どおり 5 キーを返すこと.""" + result = _run_fullimage_min_max(_make_luminance(seed=2), MetricsConfig(fullimage_min_max=True)) + + assert set(result) == set(_FULLIMAGE_COLUMNS) + + +def test_run_fullimage_matches_module_function_with_configured_sigma(): + """段の結果が calc_fullimage_min_max_ratio(roi, sigma=fullimage_sigma) と一致すること. + + 仕様: MetricsConfig.fullimage_sigma が平滑化 σ としてそのまま渡される. + """ + roi = _make_luminance(seed=3) + metrics_config = MetricsConfig(fullimage_min_max=True, fullimage_sigma=8.0) + + assert _run_fullimage_min_max(roi, metrics_config) == calc_fullimage_min_max_ratio( + roi, sigma=8.0 + ) + + +def test_run_fullimage_default_sigma_is_used_when_not_specified(): + """fullimage_sigma を指定しないときは既定 12.0 で計算されること.""" + roi = _make_luminance(seed=4) + + assert _run_fullimage_min_max( + roi, MetricsConfig(fullimage_min_max=True) + ) == calc_fullimage_min_max_ratio(roi, sigma=12.0) + + +# --------------------------------------------------------------------------- +# B. CLI 統合(metrics.csv の列) +# --------------------------------------------------------------------------- + + +def test_enabled_config_adds_five_columns_to_metrics_csv(tmp_path: Path): + """metrics.fullimage_min_max: true で metrics.csv に 5 列追加されること.""" + npy_path = _write_npy(tmp_path, seed=10) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + for column in _FULLIMAGE_COLUMNS: + assert column in frame.columns + assert frame[column].dtype.kind == "f" + + +def test_csv_value_matches_direct_calculation(tmp_path: Path): + """CSV の値が ROI 輝度への直接計算と一致すること(ROI 抽出後の輝度で評価される).""" + npy_path = _write_npy(tmp_path, seed=11) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + _run_evaluate(npy_path, config_path, output_dir) + + frame = pandas.read_csv(output_dir / "metrics.csv") + expected = calc_fullimage_min_max_ratio(_make_luminance(seed=11), sigma=12.0) + + assert frame["fullimage_min_max_ratio"].iloc[0] == pytest.approx( + expected["fullimage_min_max_ratio"], rel=1e-9 + ) + + +def test_disabled_by_default_keeps_csv_unchanged(tmp_path: Path): + """フィールドを書かない既存 config では列が増えないこと(後方互換).""" + npy_path = _write_npy(tmp_path, seed=12) + config_path = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + for column in _FULLIMAGE_COLUMNS: + assert column not in frame.columns + # 既存指標は従来どおり出る + assert "cov" in frame.columns + assert "radial_min_max_ratio" in frame.columns + + +def test_explicit_false_keeps_csv_unchanged(tmp_path: Path): + """明示的に false を書いた場合も列が増えないこと.""" + npy_path = _write_npy(tmp_path, seed=13) + config_path = _write_eval_yaml( + tmp_path, name="eval_false.yaml", fullimage_lines=" fullimage_min_max: false\n" + ) + output_dir = tmp_path / "out" + + assert _run_evaluate(npy_path, config_path, output_dir) == 0 + + frame = pandas.read_csv(output_dir / "metrics.csv") + assert "fullimage_min_max_ratio" not in frame.columns + + +def test_enabling_fullimage_does_not_change_existing_metric_values(tmp_path: Path): + """新指標の有効化で既存指標の値が変わらないこと(追加のみ).""" + npy_path = _write_npy(tmp_path, seed=14) + legacy_config = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + new_config = _write_eval_yaml( + tmp_path, name="eval_new.yaml", fullimage_lines=" fullimage_min_max: true\n" + ) + + _run_evaluate(npy_path, legacy_config, tmp_path / "legacy") + _run_evaluate(npy_path, new_config, tmp_path / "new") + + legacy = pandas.read_csv(tmp_path / "legacy" / "metrics.csv") + new = pandas.read_csv(tmp_path / "new" / "metrics.csv") + + for column in legacy.columns: + assert new[column].iloc[0] == pytest.approx(legacy[column].iloc[0], rel=1e-9) + + +# --------------------------------------------------------------------------- +# C. CLI 統合(sigma 設定とサマリー表示) +# --------------------------------------------------------------------------- + + +def test_fullimage_sigma_in_yaml_changes_the_value(tmp_path: Path): + """metrics.fullimage_sigma を変えると指標値が変わること(設定が効いている).""" + npy_path = _write_npy(tmp_path, seed=15) + small_sigma = _write_eval_yaml( + tmp_path, + name="eval_sigma4.yaml", + fullimage_lines=" fullimage_min_max: true\n fullimage_sigma: 4.0\n", + ) + large_sigma = _write_eval_yaml( + tmp_path, + name="eval_sigma32.yaml", + fullimage_lines=" fullimage_min_max: true\n fullimage_sigma: 32.0\n", + ) + + _run_evaluate(npy_path, small_sigma, tmp_path / "sigma4") + _run_evaluate(npy_path, large_sigma, tmp_path / "sigma32") + + small = pandas.read_csv(tmp_path / "sigma4" / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + large = pandas.read_csv(tmp_path / "sigma32" / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + + assert small != pytest.approx(large, rel=1e-6) + # 平滑化を強めるほど極値がなまり ratio は 1 に近づく + assert small < large + + +def test_summary_prints_fullimage_line_when_enabled(tmp_path: Path, capsys): + """有効時にサマリーへ `fullimage min/max: x.xxxx` 行が出ること.""" + npy_path = _write_npy(tmp_path, seed=16) + config_path = _write_eval_yaml(tmp_path, fullimage_lines=" fullimage_min_max: true\n") + output_dir = tmp_path / "out" + + _run_evaluate(npy_path, config_path, output_dir) + + stdout = capsys.readouterr().out + ratio = pandas.read_csv(output_dir / "metrics.csv")["fullimage_min_max_ratio"].iloc[0] + + assert f"fullimage min/max: {ratio:.4f}" in stdout + + +def test_summary_has_no_fullimage_line_when_disabled(tmp_path: Path, capsys): + """無効時はサマリーに fullimage の行が出ないこと(従来どおりの出力).""" + npy_path = _write_npy(tmp_path, seed=17) + config_path = _write_eval_yaml(tmp_path, name="eval_legacy.yaml") + + _run_evaluate(npy_path, config_path, tmp_path / "out") + + # 出力パス文字列に "fullimage" が含まれ得るのでサマリー行のラベルで判定する + assert "fullimage min/max" not in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# D. エラー経路 +# --------------------------------------------------------------------------- + + +def test_roi_smaller_than_four_sigma_exits_with_code_one(tmp_path: Path, capsys): + """ROI が平滑化 σ に対して小さすぎる場合,段階別エラーで終了コード 1 になること. + + 仕様: 指標算出の失敗は EvaluationStepError として文言付きで終了コード 1 に変換される + (cli/evaluate.py main). + """ + npy_path = _write_npy(tmp_path, seed=18) + config_path = _write_eval_yaml( + tmp_path, + name="eval_small_roi.yaml", + fullimage_lines=" fullimage_min_max: true\n", + roi_size=32, # 4σ = 48 未満 + ) + + assert _run_evaluate(npy_path, config_path, tmp_path / "out") == 1 + assert "画像全体 min/max 比の算出に失敗しました" in capsys.readouterr().err diff --git a/tests/evaluation/test_fullimage.py b/tests/evaluation/test_fullimage.py new file mode 100644 index 0000000..2261041 --- /dev/null +++ b/tests/evaluation/test_fullimage.py @@ -0,0 +1,492 @@ +"""evaluation/fullimage.py(画像全体のノイズ補正付き min/max 比)の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」 + (輪帯平均 min/max は動径プロファイル 20 ビンの最小/最大比.画素単位 max/min より + ノイズにロバストだが,方位方向に平均するので方位依存の不均一は測れない) +- docs/06_TEST/TEST_18_照明配置依存性の球と立方体の比較.md(オフ軸 LED の非対称な分布を + 定量する必要性) +- 新指標の定義と検証条件(evaluation/fullimage.py モジュール docstring): + ratio = (min G_sigma(I) - n_min) / (max G_sigma(I) - n_max) + * G_sigma = ガウス平滑化,(n_min, n_max) = 同じ sigma で平滑化したノイズ場の極値の期待値 + * 同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致する + * 一様像 + ノイズでの見かけの不均一(ノイズ床)は 1 から ±0.004 程度 + * ノイズが空間的に独立であることを仮定する + +テスト方針(.claude/rules/testing.md): +- 核計算ロジックなので合成画像(既知の真値)で単体テストする.output/ の実データには依存しない +- 乱数は必ず seed を固定する +- 期待値は実装ではなく上記の定義から導く + +注記(定義そのものが持つバイアス): + ガウス平滑化は sigma スケールの構造をなまし,境界では折り返すため,平滑化後の min/max は + 「平滑化しない真値」より必ず中央寄り(= ratio は真値より大きめ)になる. + 256x256・sigma=12 の線形勾配では +0.013 程度.したがって真値との比較はこの構造バイアスを + 見込んだ許容差で行い,ノイズに対する頑健性(ノイズ有無での差)はノイズ床の許容差で厳しく見る. +""" + +from __future__ import annotations + +import numpy +import pytest +from scipy.ndimage import gaussian_filter + +from sm_tias_lightsim.evaluation.fullimage import ( + calc_fullimage_min_max_ratio, + estimate_noise_extremes, +) +from sm_tias_lightsim.evaluation.radial import calc_radial_min_max_ratio, calc_radial_profile + +# 仕様上の許容差 +_NOISE_FLOOR_TOL = 0.01 # ノイズ有無での ratio の差(仕様のノイズ床 ±0.004 に余裕を持たせた値) +_ANNULUS_AGREEMENT_TOL = 0.01 # 同心円対称な分布での輪帯平均との一致(仕様値) +_SMOOTHING_BIAS_TOL = 0.02 # 平滑化による構造バイアスを見込んだ真値との許容差 + +_SIZE = 256 +_MEAN_LEVEL = 100.0 + + +def _uniform(size: int = _SIZE, level: float = _MEAN_LEVEL) -> numpy.ndarray: + """一様な輝度配列(真の min/max 比 = 1.0).""" + return numpy.full((size, size), level, dtype=numpy.float64) + + +def _concentric_bump(size: int = _SIZE) -> numpy.ndarray: + """中心 1.0・周辺 0.85 の同心円対称なガウス山(輪帯平均が基準として使える分布).""" + ys, xs = numpy.mgrid[0:size, 0:size] + center = (size - 1) / 2.0 + d2 = ((xs - center) ** 2 + (ys - center) ** 2) / (center**2) + return (0.85 + 0.15 * numpy.exp(-1.5 * d2)) * _MEAN_LEVEL + + +def _linear_gradient(size: int = _SIZE, low: float = 0.8) -> numpy.ndarray: + """左 1.0 → 右 low の線形勾配(方位依存=同心円対称でない分布).真の min/max 比 = low.""" + _, xs = numpy.mgrid[0:size, 0:size] + return (1.0 - (1.0 - low) * xs / (size - 1)) * _MEAN_LEVEL + + +def _add_white_noise(base: numpy.ndarray, relative_sigma: float, seed: int) -> numpy.ndarray: + """平均に対する相対 sigma の白色ガウスノイズを載せる(画素間で独立=仕様の前提).""" + rng = numpy.random.default_rng(seed) + return base + rng.normal(0.0, relative_sigma * float(base.mean()), base.shape) + + +def _ratio(image: numpy.ndarray, **kwargs) -> float: + return calc_fullimage_min_max_ratio(image, **kwargs)["fullimage_min_max_ratio"] + + +def _pixel_ratio(image: numpy.ndarray) -> float: + return float(image.min()) / float(image.max()) + + +def _annulus_ratio(image: numpy.ndarray) -> float: + """輪帯平均(動径プロファイル 20 ビン)の min/max 比(SPEC_04 の既存指標).""" + return calc_radial_min_max_ratio(calc_radial_profile(image))["radial_min_max_ratio"] + + +# --------------------------------------------------------------------------- +# A. 一様分布とノイズ床 +# --------------------------------------------------------------------------- + + +def test_uniform_image_without_noise_gives_ratio_exactly_one(): + """ノイズの無い一様像では ratio が厳密に 1.0 になること. + + 仕様: 一様像は min = max なので不均一ゼロ(ratio = 1.0). + 残差も 0 なのでノイズ補正量も 0 になる. + """ + result = calc_fullimage_min_max_ratio(_uniform()) + + assert result["fullimage_min_max_ratio"] == pytest.approx(1.0, abs=1e-12) + assert result["fullimage_min"] == pytest.approx(result["fullimage_max"], rel=1e-12) + assert result["fullimage_noise_min"] == pytest.approx(0.0, abs=1e-9) + assert result["fullimage_noise_max"] == pytest.approx(0.0, abs=1e-9) + + +@pytest.mark.parametrize("seed", [0, 1, 2]) +@pytest.mark.parametrize("relative_sigma", [0.05, 0.10]) +def test_uniform_image_with_noise_stays_near_one(relative_sigma: float, seed: int): + """一様像 + 白色ガウスノイズでも ratio が 1 の近傍に留まること(ノイズ床). + + 仕様: 一様像にノイズだけを載せたときの見かけの不均一(ノイズ床)は小さい + (fullimage.py docstring: ±0.004.ここでは合成ノイズのばらつきを見込み ±0.01 で検証). + """ + image = _add_white_noise(_uniform(), relative_sigma, seed) + + assert abs(1.0 - _ratio(image)) < _NOISE_FLOOR_TOL + + +def test_uniform_image_with_noise_beats_pixel_min_max(): + """一様像 + ノイズで,画素単位 min/max 比より遥かに 1 に近いこと(本指標の存在意義). + + 仕様: 画素単位の min/max は MC ノイズの裾を拾って不均一を過大評価する + (SPEC_04「max/min 比 … 外れ値に敏感(MC ノイズ感度高)」). + """ + image = _add_white_noise(_uniform(), 0.10, seed=3) + + corrected_error = abs(1.0 - _ratio(image)) + pixel_error = abs(1.0 - _pixel_ratio(image)) + + assert corrected_error < _NOISE_FLOOR_TOL + assert pixel_error > 0.1 + assert corrected_error < pixel_error + + +def test_noise_floor_grows_with_noise_level(): + """ノイズ床がノイズ量とともに増える(補正が効いてもゼロにはならない)こと. + + 仕様: 補正はノイズ場の極値の「期待値」を差し引くので残差は残る. + ノイズが小さいほど 1 に近い. + """ + low = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.02, seed=4))) + high = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.20, seed=4))) + + assert low < high + + +# --------------------------------------------------------------------------- +# B. 同心円対称な分布 — 輪帯平均との一致(仕様の検証根拠) +# --------------------------------------------------------------------------- + + +def test_concentric_bump_agrees_with_annulus_average_without_noise(): + """同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致すること. + + 仕様: fullimage.py「同心円対称な軸上スイープ 20 条件で輪帯平均の min/max と + ±0.01 以内で一致」.輪帯平均は同心円分布では正しい基準になる. + """ + bump = _concentric_bump() + + assert abs(_ratio(bump) - _annulus_ratio(bump)) < _ANNULUS_AGREEMENT_TOL + + +def test_concentric_bump_agrees_with_annulus_average_with_noise(): + """同心円対称な分布 + MC 相当のノイズでも輪帯平均と ±0.01 以内で一致すること. + + 仕様: 上記と同じ.レンダ画像相当の弱いノイズ(相対 2%)を載せて確認する. + """ + noisy = _add_white_noise(_concentric_bump(), 0.02, seed=5) + + assert abs(_ratio(noisy) - _annulus_ratio(noisy)) < _ANNULUS_AGREEMENT_TOL + + +@pytest.mark.parametrize("seed", [6, 7]) +def test_concentric_bump_ratio_is_robust_to_noise(seed: int): + """同じ分布ならノイズの有無で ratio がほとんど変わらないこと(ノイズ頑健性). + + 仕様: 平滑化で MC ノイズを落とし,残るノイズの寄与を差し引く定義なので, + ノイズを載せても指標値は動かないはず(ノイズ床の範囲内). + """ + bump = _concentric_bump() + noisy = _add_white_noise(bump, 0.05, seed) + + assert abs(_ratio(noisy) - _ratio(bump)) < _NOISE_FLOOR_TOL + + +def test_concentric_bump_ratio_is_closer_to_truth_than_pixel_min_max(): + """同心円分布 + ノイズで,画素単位 min/max より真値に近いこと. + + 仕様: 画素単位はノイズの裾を拾うため真値から大きく外れる. + 真値 = ノイズを載せる前の分布の min/max 比. + """ + bump = _concentric_bump() + true_ratio = float(bump.min()) / float(bump.max()) + noisy = _add_white_noise(bump, 0.10, seed=8) + + assert abs(_ratio(noisy) - true_ratio) < _SMOOTHING_BIAS_TOL + assert abs(_pixel_ratio(noisy) - true_ratio) > abs(_ratio(noisy) - true_ratio) + + +# --------------------------------------------------------------------------- +# C. 非対称な分布 — 輪帯平均が測れない不均一を測る(新指標の動機) +# --------------------------------------------------------------------------- + + +def test_linear_gradient_ratio_tracks_true_value_without_noise(): + """左右の線形勾配(真値 0.8)を平滑化バイアスの範囲で再現すること. + + 仕様: 画像全体の min/max を使うので,方位依存の不均一もそのまま反映される. + 平滑化と境界の折り返しにより値は真値よりわずかに大きくなる(モジュール注記). + """ + gradient = _linear_gradient(low=0.8) + ratio = _ratio(gradient) + + assert ratio == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL) + assert ratio >= 0.8 # 平滑化バイアスは必ず 1 に近づく向き + + +@pytest.mark.parametrize("seed", [9, 10]) +def test_linear_gradient_with_noise_tracks_true_value(seed: int): + """線形勾配 + ノイズでも真値 0.8 を平滑化バイアスの範囲で再現すること.""" + noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed) + + assert _ratio(noisy) == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL) + + +def test_annulus_average_overestimates_uniformity_for_asymmetric_distribution(): + """輪帯平均は非対称な分布の不均一を過小評価(= 均一性を過大評価)すること. + + 仕様(新指標の動機): 輪帯平均は方位方向に平均するため,左右勾配のような + 方位依存の不均一を打ち消してしまう(SPEC_04 の動径 min/max は同心円分布向け). + 本指標は真値に近く,輪帯平均は 1 に張り付く. + """ + noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=11) + true_ratio = 0.8 + + fullimage_error = abs(_ratio(noisy) - true_ratio) + annulus_error = abs(_annulus_ratio(noisy) - true_ratio) + + assert fullimage_error < _SMOOTHING_BIAS_TOL + assert _annulus_ratio(noisy) > 0.95 # 輪帯平均は「ほぼ均一」と誤判定する + assert annulus_error > 5.0 * fullimage_error + + +def test_asymmetric_distribution_is_ranked_less_uniform_than_uniform_image(): + """非対称な分布は一様像より明確に低い ratio になること(順序の妥当性).""" + uniform_ratio = _ratio(_add_white_noise(_uniform(), 0.10, seed=12)) + gradient_ratio = _ratio(_add_white_noise(_linear_gradient(low=0.8), 0.10, seed=12)) + + assert gradient_ratio < uniform_ratio - 0.1 + + +# --------------------------------------------------------------------------- +# D. 入力バリデーション(すべて ValueError: .claude/rules/error-handling.md) +# --------------------------------------------------------------------------- + + +def test_three_dimensional_image_raises_value_error(): + """3D 配列(RGB 画像等)は ValueError になること. + + 仕様: image は 2D 輝度配列(ROI 抽出後の輝度)を渡す. + """ + with pytest.raises(ValueError, match="2D"): + calc_fullimage_min_max_ratio(numpy.zeros((64, 64, 3)) + 1.0) + + +def test_one_dimensional_image_raises_value_error(): + """1D 配列も 2D でないので ValueError になること.""" + with pytest.raises(ValueError, match="2D"): + calc_fullimage_min_max_ratio(numpy.ones(1024)) + + +@pytest.mark.parametrize( + "array", + [ + numpy.full((128, 128), "a"), + numpy.ones((128, 128), dtype=bool), + numpy.full((128, 128), None, dtype=object), + ], + ids=["str", "bool", "object"], +) +def test_non_numeric_dtype_raises_value_error(array: numpy.ndarray): + """数値でない dtype は ValueError になること.""" + with pytest.raises(ValueError, match="数値"): + calc_fullimage_min_max_ratio(array) + + +@pytest.mark.parametrize("sigma", [0.0, -1.0, -12.0]) +def test_non_positive_sigma_raises_value_error(sigma: float): + """sigma が 0 以下は ValueError になること(平滑化が定義されない).""" + with pytest.raises(ValueError, match="sigma"): + calc_fullimage_min_max_ratio(_uniform(), sigma=sigma) + + +def test_image_smaller_than_four_sigma_raises_value_error(): + """min(H, W) < 4σ の小さい画像は ValueError になること. + + 仕様: 平滑化カーネルが画像からはみ出すと端の値が境界条件に支配されるため下限を設ける. + """ + small = numpy.full((47, 256), _MEAN_LEVEL) + + with pytest.raises(ValueError, match="小さすぎます"): + calc_fullimage_min_max_ratio(small, sigma=12.0) + + +def test_image_exactly_four_sigma_is_accepted(): + """min(H, W) == 4σ はちょうど下限で受理されること(境界値).""" + boundary = numpy.full((48, 256), _MEAN_LEVEL) + + assert _ratio(boundary, sigma=12.0) == pytest.approx(1.0, abs=1e-12) + + +def test_all_zero_image_raises_value_error(): + """全ゼロ画像(補正後の最大値が 0 以下)は ValueError になること.""" + with pytest.raises(ValueError, match="ノイズ補正後の最大値"): + calc_fullimage_min_max_ratio(numpy.zeros((_SIZE, _SIZE))) + + +def test_integer_image_is_accepted(): + """整数 dtype の輝度配列も受理されること(内部で float64 に変換).""" + integer_image = numpy.full((_SIZE, _SIZE), 100, dtype=numpy.int32) + + assert _ratio(integer_image) == pytest.approx(1.0, abs=1e-12) + + +# --------------------------------------------------------------------------- +# E. 決定論性と seed +# --------------------------------------------------------------------------- + + +def test_same_input_and_seed_gives_bitwise_identical_result(): + """同じ入力・同じ seed なら完全に同じ値を返すこと(決定論的). + + 仕様: 乱数は関数内で default_rng(seed) を生成するため呼び出し順に依存しない. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=13) + + assert calc_fullimage_min_max_ratio(image) == calc_fullimage_min_max_ratio(image) + + +def test_result_does_not_depend_on_call_order(): + """間に別の画像の計算を挟んでも結果が変わらないこと(グローバル乱数に依存しない).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=14) + first = calc_fullimage_min_max_ratio(image) + + calc_fullimage_min_max_ratio(_add_white_noise(_linear_gradient(), 0.2, seed=15)) + numpy.random.seed(12345) # グローバル乱数状態を動かしても影響しないこと + + assert calc_fullimage_min_max_ratio(image) == first + + +def test_changing_seed_changes_value_only_within_noise_floor(): + """seed を変えるとノイズ場推定が変わるが,差はノイズ床以内であること.""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=16) + + base = _ratio(image, seed=0) + other = _ratio(image, seed=17) + + assert base != other + assert abs(base - other) < _NOISE_FLOOR_TOL + + +def test_estimate_noise_extremes_is_deterministic_for_fixed_seed(): + """estimate_noise_extremes が同じ入力・同じ seed で同じ値を返すこと.""" + image = _add_white_noise(_uniform(), 0.10, seed=18) + + assert estimate_noise_extremes(image, sigma=12.0) == estimate_noise_extremes(image, sigma=12.0) + + +def test_estimate_noise_extremes_brackets_zero_for_noisy_image(): + """ノイズ場の極値が min < 0 < max であること(補正量の符号). + + 仕様: min 側は負,max 側は正の補正量として返す. + """ + image = _add_white_noise(_uniform(), 0.10, seed=19) + + noise_min, noise_max = estimate_noise_extremes(image, sigma=12.0) + + assert noise_min < 0.0 < noise_max + + +def test_estimate_noise_extremes_is_zero_for_noiseless_uniform_image(): + """ノイズの無い一様像ではノイズ場の極値が 0 になること(残差が 0).""" + noise_min, noise_max = estimate_noise_extremes(_uniform(), sigma=12.0) + + assert noise_min == pytest.approx(0.0, abs=1e-9) + assert noise_max == pytest.approx(0.0, abs=1e-9) + + +def test_estimate_noise_extremes_grows_with_noise_level(): + """ノイズが大きいほどノイズ場の極値の絶対値が大きいこと.""" + weak = estimate_noise_extremes(_add_white_noise(_uniform(), 0.05, seed=20), sigma=12.0) + strong = estimate_noise_extremes(_add_white_noise(_uniform(), 0.20, seed=20), sigma=12.0) + + assert abs(weak[0]) < abs(strong[0]) + assert abs(weak[1]) < abs(strong[1]) + + +# --------------------------------------------------------------------------- +# F. 戻り値の整合とスケール不変性 +# --------------------------------------------------------------------------- + + +def test_returns_exactly_five_documented_keys(): + """戻り値が仕様どおりの 5 キーだけを持つこと(metrics.csv の 5 列に対応).""" + result = calc_fullimage_min_max_ratio(_add_white_noise(_concentric_bump(), 0.10, seed=21)) + + assert set(result) == { + "fullimage_min_max_ratio", + "fullimage_min", + "fullimage_max", + "fullimage_noise_min", + "fullimage_noise_max", + } + assert all(isinstance(value, float) for value in result.values()) + + +def test_returned_values_satisfy_definition(): + """戻り値が定義式どおりであること. + + 仕様: min = min G_sigma(I) - n_min,max = max G_sigma(I) - n_max,ratio = min / max. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=22) + sigma = 12.0 + + result = calc_fullimage_min_max_ratio(image, sigma=sigma) + smooth = gaussian_filter(image, sigma=sigma) + + assert result["fullimage_min"] == pytest.approx( + float(smooth.min()) - result["fullimage_noise_min"], rel=1e-12 + ) + assert result["fullimage_max"] == pytest.approx( + float(smooth.max()) - result["fullimage_noise_max"], rel=1e-12 + ) + assert result["fullimage_min_max_ratio"] == pytest.approx( + result["fullimage_min"] / result["fullimage_max"], rel=1e-12 + ) + assert result["fullimage_min"] <= result["fullimage_max"] + + +@pytest.mark.parametrize("gain", [0.25, 3.7, 1000.0]) +def test_ratio_is_invariant_under_linear_gain(gain: float): + """輝度を定数倍しても ratio が不変であること(露出・ゲインに依存しない指標). + + 仕様: min も max もノイズ補正量も線形量なので比は変わらない. + """ + image = _add_white_noise(_concentric_bump(), 0.10, seed=23) + + base = calc_fullimage_min_max_ratio(image) + scaled = calc_fullimage_min_max_ratio(image * gain) + + assert scaled["fullimage_min_max_ratio"] == pytest.approx( + base["fullimage_min_max_ratio"], rel=1e-9 + ) + assert scaled["fullimage_min"] == pytest.approx(base["fullimage_min"] * gain, rel=1e-9) + assert scaled["fullimage_max"] == pytest.approx(base["fullimage_max"] * gain, rel=1e-9) + assert scaled["fullimage_noise_min"] == pytest.approx( + base["fullimage_noise_min"] * gain, rel=1e-9 + ) + assert scaled["fullimage_noise_max"] == pytest.approx( + base["fullimage_noise_max"] * gain, rel=1e-9 + ) + + +def test_default_sigma_is_twelve(): + """既定の sigma が 12.0 であること(MetricsConfig.fullimage_sigma の既定と一致).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=24) + + assert _ratio(image) == pytest.approx(_ratio(image, sigma=12.0), rel=1e-12) + + +def test_larger_sigma_smooths_more_and_raises_ratio(): + """sigma を大きくすると平滑化が強まり ratio が 1 に近づくこと(パラメータの効き).""" + image = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=25) + + assert _ratio(image, sigma=4.0) < _ratio(image, sigma=32.0) + + +def test_more_shuffles_do_not_change_result_beyond_noise_floor(): + """n_shuffle を増やしてもノイズ床を超えて値が動かないこと(期待値の推定回数).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=26) + + assert abs(_ratio(image, n_shuffle=1) - _ratio(image, n_shuffle=8)) < _NOISE_FLOOR_TOL + + +def test_input_image_is_not_modified(): + """入力配列が変更されないこと(純関数).""" + image = _add_white_noise(_concentric_bump(), 0.10, seed=27) + snapshot = image.copy() + + calc_fullimage_min_max_ratio(image) + + assert numpy.array_equal(image, snapshot) diff --git a/tests/scene/test_schema_metrics_fullimage.py b/tests/scene/test_schema_metrics_fullimage.py new file mode 100644 index 0000000..aad7af1 --- /dev/null +++ b/tests/scene/test_schema_metrics_fullimage.py @@ -0,0 +1,214 @@ +# pyright: reportArgumentType=none, reportCallIssue=none +"""scene/schema.py の MetricsConfig.fullimage_min_max / fullimage_sigma の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション.オプション指標は + bool フラグで有効化し,既定 False で既存 YAML はそのまま動く) +- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」(既定 False・ + metrics.csv に列を追加するオプション指標の扱い) +- evaluation/fullimage.py(新指標の定義.sigma はガウス平滑化の σ で正の値のみ意味を持つ) + +検証する仕様: +- fullimage_min_max の既定は False(後方互換: フィールドを書いていない既存 YAML が読める) +- fullimage_sigma の既定は 12.0(464x450 の ROI で検証した値) +- fullimage_sigma は正の値のみ受理する(0・負は ValidationError) +- 既存フィールド(cov / std / max_min / radial_profile / radial_min_max / shape_metrics)に + 干渉しない + +テスト方針(.claude/rules/testing.md): 入力バリデーションは必ずテストする. +Pydantic モデルの直接構築と YAML ロードの両方で確認する. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from sm_tias_lightsim.scene.schema import EvaluationConfig, MetricsConfig + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +_MINIMAL_ROI_YAML = """\ +roi: + type: image_pixel + x: 0 + y: 0 + width: 100 + height: 100 +metrics: + cov: true + std: true + max_min: true + radial_profile: true +""" + + +def _eval_config_from_metrics_yaml(metrics_lines: str) -> EvaluationConfig: + """metrics セクションに追記した YAML を EvaluationConfig にする.""" + data = yaml.safe_load(_MINIMAL_ROI_YAML + metrics_lines) + return EvaluationConfig.model_validate(data) + + +# --------------------------------------------------------------------------- +# A. 既定値(後方互換) +# --------------------------------------------------------------------------- + + +def test_fullimage_min_max_default_is_false(): + """MetricsConfig() の fullimage_min_max 既定値が False であること. + + 仕様: オプション指標なので既定 False(既存挙動互換). + """ + assert MetricsConfig().fullimage_min_max is False + + +def test_fullimage_sigma_default_is_twelve(): + """MetricsConfig() の fullimage_sigma 既定値が 12.0 であること. + + 仕様: 既定 12.0 は 464x450 の ROI で検証した値(fullimage.py docstring). + """ + assert MetricsConfig().fullimage_sigma == pytest.approx(12.0) + + +def test_existing_yaml_without_fullimage_fields_is_loadable(): + """フィールドを持たない既存 YAML がそのまま読め,既定値になること(後方互換).""" + config = _eval_config_from_metrics_yaml("") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + assert config.metrics.fullimage_sigma == pytest.approx(12.0) + + +@pytest.mark.parametrize( + "config_name", + ["eval_baseline.yaml", "eval_baseline_linear.yaml", "eval_real.yaml"], +) +def test_shipped_existing_configs_keep_fullimage_disabled(config_name: str): + """同梱の既存評価 YAML が無修正で読め,新指標が無効のままであること(後方互換).""" + data = yaml.safe_load((_REPO_ROOT / "configs" / config_name).read_text(encoding="utf-8")) + config = EvaluationConfig.model_validate(data) + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + + +def test_shipped_dng_target_shape_config_enables_fullimage(): + """configs/eval_baseline_dng_target_shape.yaml で新指標が有効になっていること. + + 仕様: 実機比較用の評価 config で metrics.fullimage_min_max: true を有効化する. + """ + path = _REPO_ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" + config = EvaluationConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is True + assert config.metrics.fullimage_sigma == pytest.approx(12.0) + + +# --------------------------------------------------------------------------- +# B. 明示指定と YAML ロード +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_fullimage_min_max_explicit_value_is_kept(enabled: bool): + """MetricsConfig(fullimage_min_max=...) の明示指定がそのまま保持されること.""" + assert MetricsConfig(fullimage_min_max=enabled).fullimage_min_max is enabled + + +def test_yaml_load_fullimage_min_max_true(): + """YAML の metrics.fullimage_min_max: true が反映されること.""" + config = _eval_config_from_metrics_yaml(" fullimage_min_max: true\n") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is True + + +def test_yaml_load_fullimage_min_max_false(): + """YAML の metrics.fullimage_min_max: false が反映されること.""" + config = _eval_config_from_metrics_yaml(" fullimage_min_max: false\n") + + assert config.metrics is not None + assert config.metrics.fullimage_min_max is False + + +@pytest.mark.parametrize("sigma", [0.5, 8.0, 12.0, 30.0]) +def test_yaml_load_fullimage_sigma_positive_values(sigma: float): + """正の fullimage_sigma が YAML から読めること.""" + config = _eval_config_from_metrics_yaml( + f" fullimage_min_max: true\n fullimage_sigma: {sigma}\n" + ) + + assert config.metrics is not None + assert config.metrics.fullimage_sigma == pytest.approx(sigma) + + +def test_fullimage_sigma_accepts_integer_yaml_value(): + """YAML の整数値 sigma が float として読めること.""" + config = _eval_config_from_metrics_yaml(" fullimage_sigma: 8\n") + + assert config.metrics is not None + assert config.metrics.fullimage_sigma == pytest.approx(8.0) + + +# --------------------------------------------------------------------------- +# C. バリデーション(不正値は ValidationError) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("sigma", [0.0, -1.0, -12.0]) +def test_non_positive_fullimage_sigma_raises_validation_error(sigma: float): + """fullimage_sigma が 0 以下なら ValidationError になること. + + 仕様: sigma はガウス平滑化の σ なので正の値のみ(fullimage.py も 0 以下を拒否する). + """ + with pytest.raises(ValidationError): + MetricsConfig(fullimage_sigma=sigma) + + +@pytest.mark.parametrize("sigma", ["0", "-3.0"]) +def test_non_positive_fullimage_sigma_in_yaml_raises_validation_error(sigma: str): + """YAML 経由でも 0 以下の fullimage_sigma が ValidationError になること.""" + with pytest.raises(ValidationError): + _eval_config_from_metrics_yaml(f" fullimage_sigma: {sigma}\n") + + +def test_non_numeric_fullimage_sigma_raises_validation_error(): + """数値でない fullimage_sigma は ValidationError になること.""" + with pytest.raises(ValidationError): + MetricsConfig(fullimage_sigma="wide") + + +def test_non_boolean_fullimage_min_max_raises_validation_error(): + """bool に解釈できない fullimage_min_max は ValidationError になること.""" + with pytest.raises(ValidationError): + MetricsConfig(fullimage_min_max="maybe") + + +# --------------------------------------------------------------------------- +# D. 既存フィールドへの非干渉 +# --------------------------------------------------------------------------- + + +def test_enabling_fullimage_does_not_change_other_metric_flags(): + """新フィールドを有効にしても既存フラグの既定値が変わらないこと.""" + default = MetricsConfig() + enabled = MetricsConfig(fullimage_min_max=True, fullimage_sigma=20.0) + + assert enabled.cov == default.cov + assert enabled.std == default.std + assert enabled.max_min == default.max_min + assert enabled.radial_profile == default.radial_profile + assert enabled.radial_min_max == default.radial_min_max + assert enabled.shape_metrics == default.shape_metrics + + +def test_metrics_config_is_frozen(): + """MetricsConfig が immutable であること(読み込み後に書き換えない).""" + config = MetricsConfig(fullimage_min_max=True) + + with pytest.raises(ValidationError): + config.fullimage_min_max = False