"""evaluation/pipeline.py(evaluate_image)の仕様ベーステスト.
仕様出典:
- docs/04_SPEC/SPEC_04_評価仕様.md
「出力ファイル仕様」: <dir>/metrics.csv と <dir>/figures/{luminance_map, histogram,
radial_profile, zone_map}.png
「metrics.csv カラム定義」: cov, std, max_min_ratio, mean, max, min,
center_*, middle_*, periphery_*, center_periphery_ratio, gradient_magnitude
- docs/04_SPEC/SPEC_02_シーン定義仕様.md「evaluation」セクション(ROI / zones / metrics)
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプラインの責務分離)
検証する仕様:
- evaluate_image() が CLI と同じ評価を行い,指標の辞書を返す
- save_figures=False のとき figures/ を作らず描画もしない(指標は変わらない)
- 段階ごとの失敗は EvaluationStepError として,どの段階かが分かる文言で送出される
(CLI はこれを「エラー: …」+ 終了コード 1 に変換するだけ)
テスト方針(testing.md):
- ファイル 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 pytest
from sm_tias_lightsim.evaluation.pipeline import EvaluationStepError, evaluate_image
# SPEC_04「metrics.csv カラム定義(ヘッダ順)」の必須列
_BASE_COLUMNS = ("cov", "std", "max_min_ratio", "mean", "max", "min")
_ZONE_COLUMNS = (
"center_mean",
"center_std",
"middle_mean",
"middle_std",
"periphery_mean",
"periphery_std",
"center_periphery_ratio",
"gradient_magnitude",
)
# SPEC_04「可視化規約」の 4 種
_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png")
def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray:
"""ゼロを含まない合成輝度配列を返す(seed 固定)."""
rng = numpy.random.RandomState(seed)
return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))
def _write_npy(tmp_path: Path, seed: int = 0, size: int = 128) -> Path:
path = tmp_path / "luminance.npy"
numpy.save(path, _make_luminance_array(seed=seed, size=size))
return path
def _write_eval_yaml(
tmp_path: Path,
x: int = 16,
y: int = 16,
width: int = 96,
height: int = 96,
zones_enabled: bool = True,
radial_profile: bool = True,
) -> Path:
"""評価専用 YAML(トップレベルが EvaluationConfig 互換)を書き出す."""
content = f"""\
roi:
type: image_pixel
x: {x}
y: {y}
width: {width}
height: {height}
zones:
enabled: {"true" if zones_enabled else "false"}
boundaries: [0.33, 0.66]
metrics:
cov: true
std: true
max_min: true
radial_profile: {"true" if radial_profile else "false"}
"""
path = tmp_path / "eval_config.yaml"
path.write_text(content, encoding="utf-8")
return path
# ---------------------------------------------------------------------------
# 正常系: 既定(save_figures=True)
# ---------------------------------------------------------------------------
def test_returns_metrics_dict(tmp_path: Path):
"""evaluate_image が指標の辞書を返すこと.
仕様: SPEC_04「metrics.csv カラム定義」の基本 6 指標を含む.
"""
metrics = evaluate_image(
_write_npy(tmp_path, seed=0), _write_eval_yaml(tmp_path), tmp_path / "out"
)
assert isinstance(metrics, dict)
for key in _BASE_COLUMNS:
assert key in metrics, key
assert isinstance(metrics[key], float)
def test_returned_metrics_are_physically_consistent(tmp_path: Path):
"""返された指標が互いに整合すること(min <= mean <= max・CoV = std/mean)."""
metrics = evaluate_image(
_write_npy(tmp_path, seed=1), _write_eval_yaml(tmp_path), tmp_path / "out"
)
assert metrics["min"] <= metrics["mean"] <= metrics["max"]
assert metrics["cov"] == pytest.approx(metrics["std"] / metrics["mean"], rel=1e-9)
assert metrics["max_min_ratio"] == pytest.approx(metrics["max"] / metrics["min"], rel=1e-9)
def test_zone_metrics_included_when_zones_enabled(tmp_path: Path):
"""zones.enabled のときゾーン指標が辞書に含まれること(SPEC_04 3 ゾーン分析)."""
metrics = evaluate_image(
_write_npy(tmp_path, seed=2), _write_eval_yaml(tmp_path), tmp_path / "out"
)
for key in _ZONE_COLUMNS:
assert key in metrics, key
def test_creates_metrics_csv(tmp_path: Path):
"""metrics.csv が出力ディレクトリ直下に作られること(SPEC_04 出力ファイル仕様)."""
out_dir = tmp_path / "out"
evaluate_image(_write_npy(tmp_path, seed=3), _write_eval_yaml(tmp_path), out_dir)
assert (out_dir / "metrics.csv").is_file()
def test_metrics_csv_matches_returned_dict(tmp_path: Path):
"""metrics.csv の内容が戻り値の辞書と一致すること."""
import csv
out_dir = tmp_path / "out"
metrics = evaluate_image(_write_npy(tmp_path, seed=4), _write_eval_yaml(tmp_path), out_dir)
with (out_dir / "metrics.csv").open(encoding="utf-8") as fh:
row = next(csv.DictReader(fh))
assert set(row) == set(metrics)
for key, value in row.items():
assert float(value) == pytest.approx(metrics[key], rel=1e-6)
def test_creates_four_figures_by_default(tmp_path: Path):
"""既定(save_figures 省略)で figures/ に 4 種の PNG が出ること(SPEC_04 可視化規約)."""
out_dir = tmp_path / "out"
evaluate_image(_write_npy(tmp_path, seed=5), _write_eval_yaml(tmp_path), out_dir)
for name in _FIGURE_FILES:
assert (out_dir / "figures" / name).is_file(), name
def test_creates_deep_nested_output_dir(tmp_path: Path):
"""出力ディレクトリが親ごと作られること."""
out_dir = tmp_path / "a" / "b" / "c"
evaluate_image(_write_npy(tmp_path, seed=6), _write_eval_yaml(tmp_path), out_dir)
assert (out_dir / "metrics.csv").is_file()
# ---------------------------------------------------------------------------
# save_figures=False(スイープ用の図なしモード)
# ---------------------------------------------------------------------------
def test_no_figures_dir_when_save_figures_false(tmp_path: Path):
"""save_figures=False のとき figures/ が作られないこと."""
out_dir = tmp_path / "out"
evaluate_image(
_write_npy(tmp_path, seed=7), _write_eval_yaml(tmp_path), out_dir, save_figures=False
)
assert not (out_dir / "figures").exists()
def test_metrics_csv_still_created_when_save_figures_false(tmp_path: Path):
"""save_figures=False でも metrics.csv は出ること(指標だけ欲しい用途)."""
out_dir = tmp_path / "out"
evaluate_image(
_write_npy(tmp_path, seed=8), _write_eval_yaml(tmp_path), out_dir, save_figures=False
)
assert (out_dir / "metrics.csv").is_file()
def test_metrics_identical_with_and_without_figures(tmp_path: Path):
"""図の有無で指標が変わらないこと(描画は評価結果に影響しない)."""
npy = _write_npy(tmp_path, seed=9)
config = _write_eval_yaml(tmp_path)
with_figs = evaluate_image(npy, config, tmp_path / "with", save_figures=True)
without_figs = evaluate_image(npy, config, tmp_path / "without", save_figures=False)
assert set(with_figs) == set(without_figs)
for key in with_figs:
assert with_figs[key] == pytest.approx(without_figs[key], rel=1e-12)
def test_summary_omits_figures_line_when_save_figures_false(tmp_path: Path, capsys):
"""save_figures=False のときサマリーに figures/ 行を出さないこと."""
evaluate_image(
_write_npy(tmp_path, seed=10),
_write_eval_yaml(tmp_path),
tmp_path / "out",
save_figures=False,
)
out = capsys.readouterr().out
assert "metrics.csv" in out
assert "figures/" not in out
def test_summary_includes_figures_line_by_default(tmp_path: Path, capsys):
"""既定ではサマリーに figures/ 行が出ること(従来どおり)."""
evaluate_image(_write_npy(tmp_path, seed=11), _write_eval_yaml(tmp_path), tmp_path / "out")
assert "figures/" in capsys.readouterr().out
def test_no_figure_files_written_anywhere_when_disabled(tmp_path: Path):
"""save_figures=False のとき出力ディレクトリ配下に PNG が 1 つも無いこと."""
out_dir = tmp_path / "out"
evaluate_image(
_write_npy(tmp_path, seed=12), _write_eval_yaml(tmp_path), out_dir, save_figures=False
)
assert list(out_dir.rglob("*.png")) == []
# ---------------------------------------------------------------------------
# 異常系: 段階別の EvaluationStepError
# ---------------------------------------------------------------------------
def test_missing_image_raises_step_error(tmp_path: Path):
"""画像が無いとき EvaluationStepError になること(見つからない旨を含む)."""
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(tmp_path / "nope.npy", _write_eval_yaml(tmp_path), tmp_path / "out")
assert "画像ファイルが見つかりません" in str(exc.value)
def test_broken_image_raises_load_step_error(tmp_path: Path):
"""壊れた .npy は「画像の読み込みに失敗しました」段階の例外になること."""
bad = tmp_path / "broken.npy"
bad.write_bytes(b"not a npy file")
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(bad, _write_eval_yaml(tmp_path), tmp_path / "out")
assert "画像の読み込みに失敗しました" in str(exc.value)
def test_missing_config_raises_step_error(tmp_path: Path):
"""設定 YAML が無いとき EvaluationStepError になること."""
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(_write_npy(tmp_path, seed=13), tmp_path / "nope.yaml", tmp_path / "out")
assert "設定 YAML ファイルが見つかりません" in str(exc.value)
def test_invalid_config_raises_config_step_error(tmp_path: Path):
"""ROI 欠落の YAML は「設定 YAML の読み込みに失敗しました」段階の例外になること."""
config = tmp_path / "bad.yaml"
config.write_text("zones:\n enabled: true\n", encoding="utf-8")
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(_write_npy(tmp_path, seed=14), config, tmp_path / "out")
assert "設定 YAML の読み込みに失敗しました" in str(exc.value)
def test_roi_out_of_bounds_raises_roi_step_error(tmp_path: Path):
"""ROI が画像外のとき「ROI 抽出に失敗しました」段階の例外になること."""
config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(_write_npy(tmp_path, seed=15), config, tmp_path / "out")
assert "ROI 抽出に失敗しました" in str(exc.value)
def test_zero_luminance_raises_metrics_step_error(tmp_path: Path):
"""全ゼロ画像は「均一性指標の算出に失敗しました」段階の例外になること."""
npy = tmp_path / "zeros.npy"
numpy.save(npy, numpy.zeros((128, 128), dtype=numpy.float64))
with pytest.raises(EvaluationStepError) as exc:
evaluate_image(npy, _write_eval_yaml(tmp_path), tmp_path / "out")
assert "均一性指標の算出に失敗しました" in str(exc.value)
def test_step_error_is_runtime_error_subclass():
"""EvaluationStepError は RuntimeError 派生であること(呼び出し側の捕捉粒度)."""
assert issubclass(EvaluationStepError, RuntimeError)
def test_no_output_written_when_roi_fails(tmp_path: Path):
"""ROI 抽出で失敗したとき metrics.csv は作られないこと(中途半端な出力を残さない)."""
out_dir = tmp_path / "out"
config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
with pytest.raises(EvaluationStepError):
evaluate_image(_write_npy(tmp_path, seed=16), config, out_dir)
assert not (out_dir / "metrics.csv").exists()