Newer
Older
SmTIAS-LightSim / tests / evaluation / test_visualize.py
"""evaluation/visualize.py の仕様ベーステスト(smoke テスト).

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluation/visualize 層の責務)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(可視化は smoke テストで十分)

テスト方針 (GUIDE_08):
  可視化の smoke テストは「ファイルが生成されたか」「ファイルサイズが 0 でないか」程度で十分.
  図の見栄えはテストしない.
  matplotlib.use("Agg") を pyplot の import より前に呼ぶこと.
"""

from __future__ import annotations

import matplotlib

# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する
matplotlib.use("Agg")

from pathlib import Path

import numpy

from sm_tias_lightsim.evaluation.visualize import (
    save_histogram,
    save_luminance_map,
    save_radial_profile,
    save_zone_map,
)

# ---------------------------------------------------------------------------
# テスト用ヘルパー:合成輝度配列
# ---------------------------------------------------------------------------


def _make_luminance(size: int = 16) -> numpy.ndarray:
    """テスト用の合成輝度配列(全画素 100.0 ± 小さなノイズ)を返す.

    乱数 seed を固定して再現性を保証する (GUIDE_08: seed を必ず固定する)
    """
    rng = numpy.random.RandomState(0)
    return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))


# ---------------------------------------------------------------------------
# 1. save_luminance_map
# ---------------------------------------------------------------------------


def test_save_luminance_map_creates_file(tmp_path: Path):
    """save_luminance_map を呼ぶと PNG ファイルが生成されること.

    仕様: 輝度マップを PNG として保存する(visualize.py save_luminance_map)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "luminance_map.png"
    save_luminance_map(luminance, output_path)
    assert output_path.exists()


def test_save_luminance_map_file_not_empty(tmp_path: Path):
    """生成された PNG ファイルのサイズが 0 より大きいこと.

    仕様: 正しい PNG として書き出されること(visualize.py save_luminance_map)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "luminance_map.png"
    save_luminance_map(luminance, output_path)
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 2. save_histogram
# ---------------------------------------------------------------------------


def test_save_histogram_creates_file(tmp_path: Path):
    """save_histogram を呼ぶと PNG ファイルが生成されること.

    仕様: 輝度ヒストグラムを PNG として保存する(visualize.py save_histogram)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "histogram.png"
    save_histogram(luminance, output_path)
    assert output_path.exists()


def test_save_histogram_file_not_empty(tmp_path: Path):
    """生成された histogram PNG ファイルのサイズが 0 より大きいこと."""
    luminance = _make_luminance()
    output_path = tmp_path / "histogram.png"
    save_histogram(luminance, output_path)
    assert output_path.stat().st_size > 0


def test_save_histogram_custom_bins(tmp_path: Path):
    """bins パラメータを変えても PNG ファイルが生成されること.

    仕様: bins=50(デフォルト)(visualize.py save_histogram)
    """
    luminance = _make_luminance()
    output_path = tmp_path / "histogram_10bins.png"
    save_histogram(luminance, output_path, bins=10)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 3. save_radial_profile
# ---------------------------------------------------------------------------


def test_save_radial_profile_creates_file(tmp_path: Path):
    """save_radial_profile を呼ぶと PNG ファイルが生成されること.

    仕様: 放射状輝度プロファイルを線グラフとして PNG に保存する
         profile shape: (n_bins, 2),列 0: 距離,列 1: 輝度(visualize.py)
    """
    rng = numpy.random.RandomState(1)
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = 100.0 + rng.normal(0, 5, 20)
    profile = numpy.column_stack([distances, luminances])  # (20, 2)
    output_path = tmp_path / "radial_profile.png"
    save_radial_profile(profile, output_path)
    assert output_path.exists()


def test_save_radial_profile_file_not_empty(tmp_path: Path):
    """生成された radial_profile PNG ファイルのサイズが 0 より大きいこと."""
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = numpy.linspace(120.0, 80.0, 20)  # 線形減衰
    profile = numpy.column_stack([distances, luminances])
    output_path = tmp_path / "radial_profile.png"
    save_radial_profile(profile, output_path)
    assert output_path.stat().st_size > 0


def test_save_radial_profile_with_nan_does_not_crash(tmp_path: Path):
    """NaN を含む行がある profile を渡してもクラッシュしないこと.

    仕様: 空ビンは nan で表現される(評価仕様の空ビン扱い)
    matplotlib の plot は NaN を自動でスキップする(線が途切れる挙動)ため,
    クラッシュせずにファイルが生成されること.
    """
    distances = numpy.linspace(0.0, 1.0, 20)
    luminances = numpy.linspace(120.0, 80.0, 20)
    # 空ビンを模擬するため,いくつかの行を NaN にする
    luminances[5] = float("nan")
    luminances[10] = float("nan")
    luminances[15] = float("nan")
    profile = numpy.column_stack([distances, luminances])
    output_path = tmp_path / "radial_profile_nan.png"
    save_radial_profile(profile, output_path)
    assert output_path.exists()
    assert output_path.stat().st_size > 0


# ---------------------------------------------------------------------------
# 4. save_zone_map
# ---------------------------------------------------------------------------


def test_save_zone_map_creates_file(tmp_path: Path):
    """save_zone_map を呼ぶと PNG ファイルが生成されること.

    仕様: ゾーンマップを 3 色で PNG として保存する(visualize.py save_zone_map)
         入力: int8 配列(0=center, 1=middle, 2=periphery)
    """
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    distance_map = compute_normalized_distance_map(16, 16)
    zone_map = classify_zones(distance_map)  # dtype: int8
    output_path = tmp_path / "zone_map.png"
    save_zone_map(zone_map, output_path)
    assert output_path.exists()


def test_save_zone_map_file_not_empty(tmp_path: Path):
    """生成された zone_map PNG ファイルのサイズが 0 より大きいこと."""
    from sm_tias_lightsim.evaluation.zones import classify_zones, compute_normalized_distance_map

    distance_map = compute_normalized_distance_map(16, 16)
    zone_map = classify_zones(distance_map)
    output_path = tmp_path / "zone_map.png"
    save_zone_map(zone_map, output_path)
    assert output_path.stat().st_size > 0


def test_save_zone_map_accepts_all_zone_values(tmp_path: Path):
    """全 3 ゾーン(0/1/2)が含まれる zone_map を渡しても PNG が生成されること.

    仕様: 値は 0(center), 1(middle), 2(periphery)のいずれか(zones.py classify_zones)
    """
    # 手動で 3 ゾーン全てを含む 3x3 マップを作成する
    zone_map = numpy.array(
        [[0, 0, 1], [0, 1, 2], [1, 2, 2]], dtype=numpy.int8
    )
    output_path = tmp_path / "zone_map_all_zones.png"
    save_zone_map(zone_map, output_path)
    assert output_path.exists()
    assert output_path.stat().st_size > 0