Newer
Older
SmTIAS-LightSim / tests / evaluation / test_pipeline.py
"""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()