"""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