"""cli/evaluate.py の仕様ベーステスト(統合テスト).
仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプライン・CLI エントリポイント)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation セクション)
- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 6 完了条件)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針)
Step 6 完了条件:
シミュ PNG と実測 PNG の両方で同じ指標 CSV が出ること
metrics.csv + figures/{luminance_map, histogram, radial_profile, zone_map}.png が出力される
テスト方針:
- subprocess ではなく main() を直接呼ぶ(速度・カバレッジのため)
- ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08)
- 乱数を使う場合は seed を必ず固定する (GUIDE_08)
注意:
matplotlib の Agg バックエンドは evaluate.py 内部で設定される.
テスト側でも安全のため import 前に設定する.
"""
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.cli.evaluate import main
# ---------------------------------------------------------------------------
# テスト用定数
# ---------------------------------------------------------------------------
#: tests/fixtures/eval_smoke.yaml の絶対パス
EVAL_SMOKE_YAML = Path(__file__).parent.parent / "fixtures" / "eval_smoke.yaml"
# ---------------------------------------------------------------------------
# テスト用ヘルパー
# ---------------------------------------------------------------------------
def _make_luminance_array(seed: int = 0, size: int = 256) -> numpy.ndarray:
"""ゼロを含まない合成輝度配列を返す(seed 固定).
max/min 計算が走るようゼロを含まない値にする (GUIDE_08 補助ガイダンス)
"""
rng = numpy.random.RandomState(seed)
return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))
def _make_eval_yaml(
tmp_path: Path,
x: int = 32,
y: int = 32,
width: int = 192,
height: int = 192,
zones_enabled: bool = True,
radial_profile: bool = True,
) -> Path:
"""評価専用 YAML(トップレベルが EvaluationConfig 互換)を tmp_path に書き出す.
仕様: evaluation キーなし → トップレベルを EvaluationConfig として検証する
(cli/evaluate.py _load_eval_config)
"""
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'}
"""
yaml_path = tmp_path / "eval_config.yaml"
yaml_path.write_text(content, encoding="utf-8")
return yaml_path
def _run_main(npy_path: Path, config_path: Path, output_dir: Path) -> int:
"""main() を呼ぶ共通ラッパー(行長削減のため)."""
return main(
["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]
)
# ---------------------------------------------------------------------------
# 1. 正常系 — .npy 2D 入力 + 評価専用 YAML
# ---------------------------------------------------------------------------
def test_npy_2d_returns_0(tmp_path: Path):
"""2D .npy 輝度配列を入力として main() が 0 を返すこと.
仕様: .npy は 2D ならそのまま使用(cli/evaluate.py _load_luminance)
"""
luminance = _make_luminance_array(seed=0)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = _run_main(npy_path, config_path, output_dir)
assert result == 0
def test_npy_2d_creates_metrics_csv(tmp_path: Path):
"""2D .npy 入力後に metrics.csv が生成されること.
仕様: 出力: output_dir/metrics.csv(cli/evaluate.py 処理フロー ステップ 8)
"""
luminance = _make_luminance_array(seed=0)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
assert (output_dir / "metrics.csv").exists()
def test_npy_2d_metrics_csv_contains_expected_columns(tmp_path: Path):
"""metrics.csv に必須列が含まれること.
仕様:
- 基本指標: cov, std, max_min_ratio, mean, max, min (uniformity.py calc_uniformity)
- ゾーン指標: center_mean, center_std, middle_mean, middle_std,
periphery_mean, periphery_std, center_periphery_ratio, gradient_magnitude
(zones.py calc_zone_stats)
"""
import pandas
luminance = _make_luminance_array(seed=1)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path, zones_enabled=True)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
df = pandas.read_csv(output_dir / "metrics.csv")
expected_cols = {
"cov", "std", "max_min_ratio", "mean", "max", "min",
"center_mean", "center_std", "middle_mean", "middle_std",
"periphery_mean", "periphery_std", "center_periphery_ratio", "gradient_magnitude",
}
assert expected_cols.issubset(set(df.columns))
def test_npy_2d_creates_figures(tmp_path: Path):
"""2D .npy 入力後に figures/ 内の 4 つの PNG が生成されること.
仕様: output_dir/figures/{luminance_map, histogram, radial_profile, zone_map}.png
(SPEC_01 全体パイプライン)
"""
luminance = _make_luminance_array(seed=2)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path, zones_enabled=True, radial_profile=True)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
figures_dir = output_dir / "figures"
assert (figures_dir / "luminance_map.png").exists()
assert (figures_dir / "histogram.png").exists()
assert (figures_dir / "radial_profile.png").exists()
assert (figures_dir / "zone_map.png").exists()
# ---------------------------------------------------------------------------
# 2. 正常系 — PNG 入力
# ---------------------------------------------------------------------------
def test_png_input_returns_0(tmp_path: Path):
"""合成 RGB PNG を入力として main() が 0 を返すこと.
仕様: PNG は PIL で RGB 読み込み → to_grayscale 経由(cli/evaluate.py _load_luminance)
"""
from PIL import Image
rng = numpy.random.RandomState(3)
rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8)
pil_img = Image.fromarray(rgb, mode="RGB")
png_path = tmp_path / "render.png"
pil_img.save(png_path)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = main(
["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)]
)
assert result == 0
def test_png_input_creates_metrics_csv(tmp_path: Path):
"""PNG 入力後に metrics.csv が生成されること."""
from PIL import Image
rng = numpy.random.RandomState(4)
rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8)
pil_img = Image.fromarray(rgb, mode="RGB")
png_path = tmp_path / "render.png"
pil_img.save(png_path)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
main(
["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)]
)
assert (output_dir / "metrics.csv").exists()
# ---------------------------------------------------------------------------
# 3. 正常系 — SceneConfig 形式 YAML(evaluation サブツリー)
# ---------------------------------------------------------------------------
def test_scene_config_yaml_with_evaluation_key_returns_0(tmp_path: Path):
"""evaluation キーを持つ YAML(SceneConfig 形式)でも成功すること.
仕様: "evaluation" キーがあれば SceneConfig 埋め込みとみなし,
data["evaluation"] を EvaluationConfig として検証する
(cli/evaluate.py _load_eval_config)
注意: SceneConfig の他フィールドは無視されるため,evaluation キーだけあれば十分
"""
luminance = _make_luminance_array(seed=5)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
# evaluation キーを持つ YAML(他フィールドはダミー)
yaml_content = """\
# SceneConfig 形式: evaluation サブツリーを持つダミー YAML
some_other_key: dummy_value
evaluation:
roi:
type: image_pixel
x: 32
y: 32
width: 192
height: 192
zones:
enabled: true
boundaries: [0.33, 0.66]
metrics:
cov: true
std: true
max_min: true
radial_profile: true
"""
config_path = tmp_path / "scene_with_eval.yaml"
config_path.write_text(yaml_content, encoding="utf-8")
output_dir = tmp_path / "output"
result = _run_main(npy_path, config_path, output_dir)
assert result == 0
assert (output_dir / "metrics.csv").exists()
# ---------------------------------------------------------------------------
# 4. 正常系 — .npy 3D(RGB)入力
# ---------------------------------------------------------------------------
def test_npy_3d_rgb_returns_0(tmp_path: Path):
"""3D の .npy(H, W, 3)を渡すと to_grayscale で 2D 化されて成功すること.
仕様: .npy が 3D(H, W, 3)だった場合も to_grayscale を通す
(cli/evaluate.py _load_luminance)
"""
rng = numpy.random.RandomState(6)
rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3))
npy_path = tmp_path / "luminance_rgb.npy"
numpy.save(npy_path, rgb)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = _run_main(npy_path, config_path, output_dir)
assert result == 0
def test_npy_3d_rgb_creates_metrics_csv(tmp_path: Path):
"""3D .npy 入力後に metrics.csv が生成されること."""
rng = numpy.random.RandomState(7)
rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3))
npy_path = tmp_path / "luminance_rgb.npy"
numpy.save(npy_path, rgb)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
assert (output_dir / "metrics.csv").exists()
# ---------------------------------------------------------------------------
# 5. 正常系 — zones.enabled = false
# ---------------------------------------------------------------------------
def test_zones_disabled_csv_no_zone_columns(tmp_path: Path):
"""zones.enabled=false の場合,metrics.csv に center_mean 等のカラムが含まれないこと.
仕様: zones.enabled のときゾーン分析を実行する(cli/evaluate.py 処理フロー ステップ 6)
"""
import pandas
luminance = _make_luminance_array(seed=8)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path, zones_enabled=False)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
df = pandas.read_csv(output_dir / "metrics.csv")
assert "center_mean" not in df.columns
assert "gradient_magnitude" not in df.columns
def test_zones_disabled_no_zone_map_png(tmp_path: Path):
"""zones.enabled=false の場合,zone_map.png が生成されないこと.
仕様: zone_map が None のとき save_zone_map は呼ばれない(cli/evaluate.py 処理フロー)
"""
luminance = _make_luminance_array(seed=9)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path, zones_enabled=False)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
assert not (output_dir / "figures" / "zone_map.png").exists()
# ---------------------------------------------------------------------------
# 6. 正常系 — metrics.radial_profile = false
# ---------------------------------------------------------------------------
def test_radial_profile_disabled_no_png(tmp_path: Path):
"""metrics.radial_profile=false の場合,radial_profile.png が生成されないこと.
仕様: metrics.radial_profile が有効のとき放射状プロファイルを算出する
(cli/evaluate.py 処理フロー ステップ 7)
"""
luminance = _make_luminance_array(seed=10)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path, radial_profile=False)
output_dir = tmp_path / "output"
_run_main(npy_path, config_path, output_dir)
assert not (output_dir / "figures" / "radial_profile.png").exists()
# ---------------------------------------------------------------------------
# 7. 正常系 — eval_smoke.yaml フィクスチャを使ったスモークテスト
# ---------------------------------------------------------------------------
def test_eval_smoke_yaml_fixture_returns_0(tmp_path: Path):
"""tests/fixtures/eval_smoke.yaml を使って main() が 0 を返すこと.
仕様: eval_smoke.yaml(評価専用,evaluation キーなし,トップレベルが EvaluationConfig)
"""
# eval_smoke.yaml の ROI: x=112, y=112, width=32, height=32
# → 画像は 144x144 以上必要
luminance = _make_luminance_array(seed=99, size=256)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
output_dir = tmp_path / "output"
result = main(
[
"--image", str(npy_path),
"--config", str(EVAL_SMOKE_YAML),
"--output", str(output_dir),
]
)
assert result == 0
# ---------------------------------------------------------------------------
# 8. 異常系 — 画像ファイルが存在しない
# ---------------------------------------------------------------------------
def test_nonexistent_image_returns_1(tmp_path: Path):
"""存在しない画像ファイルを渡すと main() が 1 を返すこと.
仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 1)
"""
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = main(
[
"--image", str(tmp_path / "nonexistent.npy"),
"--config", str(config_path),
"--output", str(output_dir),
]
)
assert result == 1
def test_nonexistent_image_stderr_message(tmp_path: Path, capsys):
"""存在しない画像ファイルのエラーメッセージが stderr に出ること."""
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
main(
[
"--image", str(tmp_path / "nonexistent.npy"),
"--config", str(config_path),
"--output", str(output_dir),
]
)
captured = capsys.readouterr()
assert "エラー" in captured.err
# ---------------------------------------------------------------------------
# 9. 異常系 — YAML ファイルが存在しない
# ---------------------------------------------------------------------------
def test_nonexistent_config_returns_1(tmp_path: Path):
"""存在しない YAML ファイルを渡すと main() が 1 を返すこと.
仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 2)
"""
luminance = _make_luminance_array(seed=11)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
output_dir = tmp_path / "output"
result = main(
[
"--image", str(npy_path),
"--config", str(tmp_path / "nonexistent.yaml"),
"--output", str(output_dir),
]
)
assert result == 1
# ---------------------------------------------------------------------------
# 10. 異常系 — YAML が EvaluationConfig として不正(roi が無い)
# ---------------------------------------------------------------------------
def test_invalid_config_no_roi_returns_1(tmp_path: Path):
"""roi フィールドが欠落した YAML を渡すと main() が 1 を返すこと.
仕様: Pydantic ValidationError → return 1(cli/evaluate.py 処理フロー ステップ 2)
"""
luminance = _make_luminance_array(seed=12)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
# roi フィールドが無い不正 YAML
invalid_content = """\
zones:
enabled: true
boundaries: [0.33, 0.66]
"""
config_path = tmp_path / "invalid_eval.yaml"
config_path.write_text(invalid_content, encoding="utf-8")
output_dir = tmp_path / "output"
result = main(
[
"--image", str(npy_path),
"--config", str(config_path),
"--output", str(output_dir),
]
)
assert result == 1
# ---------------------------------------------------------------------------
# 11. 異常系 — ROI が画像範囲外
# ---------------------------------------------------------------------------
def test_roi_out_of_bounds_returns_1(tmp_path: Path):
"""ROI が画像範囲外のとき main() が 1 を返すこと.
仕様: extract_roi が ValueError → return 1(cli/evaluate.py 処理フロー ステップ 4)
"""
# 小さい画像(64x64)だが ROI は x=200, y=200 → 範囲外
luminance = _make_luminance_array(seed=13, size=64)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
out_of_bounds_content = """\
roi:
type: image_pixel
x: 200
y: 200
width: 50
height: 50
"""
config_path = tmp_path / "out_of_bounds.yaml"
config_path.write_text(out_of_bounds_content, encoding="utf-8")
output_dir = tmp_path / "output"
result = main(
[
"--image", str(npy_path),
"--config", str(config_path),
"--output", str(output_dir),
]
)
assert result == 1
# ---------------------------------------------------------------------------
# 12. 異常系 — 輝度に 0 が含まれて max/min が計算不能
# ---------------------------------------------------------------------------
def test_zero_luminance_returns_1(tmp_path: Path):
"""輝度に 0 が含まれる場合,calc_uniformity が ValueError を投げて main() が 1 を返すこと.
仕様: 輝度の最小値が 0 の場合は ValueError(uniformity.py calc_uniformity)
→ return 1(cli/evaluate.py 処理フロー ステップ 5)
"""
# 全画素を 0 にする(min == 0 → ValueError)
luminance = numpy.zeros((256, 256), dtype=numpy.float64)
npy_path = tmp_path / "zero_luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = _run_main(npy_path, config_path, output_dir)
assert result == 1
# ---------------------------------------------------------------------------
# 13. 異常系 — 必須引数欠落(argparse が SystemExit を投げる)
# ---------------------------------------------------------------------------
def test_missing_image_arg_exits():
"""--image を渡さないと SystemExit が発生すること.
仕様: --image は必須引数(cli/evaluate.py _parse_args, required=True)
argparse の required=True は SystemExit を発生させる.
"""
with pytest.raises(SystemExit) as exc_info:
main(["--config", "dummy.yaml", "--output", "/tmp/out"])
assert exc_info.value.code != 0
def test_missing_config_arg_exits():
"""--config を渡さないと SystemExit が発生すること."""
with pytest.raises(SystemExit) as exc_info:
main(["--image", "dummy.npy", "--output", "/tmp/out"])
assert exc_info.value.code != 0
def test_missing_output_arg_exits():
"""--output を渡さないと SystemExit が発生すること."""
with pytest.raises(SystemExit) as exc_info:
main(["--image", "dummy.npy", "--config", "dummy.yaml"])
assert exc_info.value.code != 0
def test_no_args_exits():
"""引数なしで呼ぶと SystemExit が発生すること."""
with pytest.raises(SystemExit) as exc_info:
main([])
assert exc_info.value.code != 0
# ---------------------------------------------------------------------------
# 14. 短縮引数 -i / -c / -o
# ---------------------------------------------------------------------------
def test_short_args_work(tmp_path: Path):
"""-i / -c / -o の短縮形でも --image / --config / --output と同等に動くこと.
仕様: --image/-i, --config/-c, --output/-o の短縮形を提供する
(cli/evaluate.py _parse_args)
"""
luminance = _make_luminance_array(seed=14)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path)
output_dir = tmp_path / "output"
result = main(["-i", str(npy_path), "-c", str(config_path), "-o", str(output_dir)])
assert result == 0
# ---------------------------------------------------------------------------
# 15. 出力ディレクトリの自動作成
# ---------------------------------------------------------------------------
def test_deep_nested_output_dir_created(tmp_path: Path):
"""存在しない深いネストのディレクトリも自動作成されること.
仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ごと作成する
(cli/evaluate.py 処理フロー ステップ 3)
"""
luminance = _make_luminance_array(seed=15)
npy_path = tmp_path / "luminance.npy"
numpy.save(npy_path, luminance)
config_path = _make_eval_yaml(tmp_path)
deep_output = tmp_path / "deep" / "nested" / "out"
assert not deep_output.exists()
result = main(
[
"--image", str(npy_path),
"--config", str(config_path),
"--output", str(deep_output),
]
)
assert result == 0
assert deep_output.exists()
assert (deep_output / "metrics.csv").exists()