Newer
Older
SmTIAS-LightSim / tests / cli / test_evaluate_fullimage.py
"""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