Newer
Older
SmTIAS-LightSim / tests / evaluation / test_zones.py
"""evaluation/zones.py の仕様ベーステスト.

仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md「空間分析」節
"""

import numpy
import pytest

from sm_tias_lightsim.evaluation.zones import (
    calc_zone_stats,
    classify_zones,
    compute_normalized_distance_map,
)

# ---------------------------------------------------------------------------
# compute_normalized_distance_map のテスト
# ---------------------------------------------------------------------------


def test_distance_map_shape_and_dtype():
    """出力 shape が (height, width), dtype が float64 であること.

    仕様: 正規化距離マップ (shape: (height, width), dtype: numpy.float64)
    """
    dm = compute_normalized_distance_map(50, 80)
    assert dm.shape == (50, 80)
    assert dm.dtype == numpy.float64


def test_distance_map_normalized_to_unit_range():
    """値域が [0, 1] であり最大値が 1.0 になること.

    仕様: 値域を [0, 1] に再正規化 (d <- d / d_max) (SPEC_02)
    """
    dm = compute_normalized_distance_map(60, 80)
    assert float(numpy.min(dm)) >= 0.0
    assert float(numpy.max(dm)) == pytest.approx(1.0)


def test_distance_map_center_is_zero():
    """偶数サイズにおける中心付近のピクセルの値がほぼ 0 になること.

    仕様: d(cx, cy) = 0 (中心から距離 0) (SPEC_02)
    偶数サイズの場合 cx=W/2, cy=H/2 は 0-indexed グリッドの内側に落ちないため,
    中心寄りのピクセル (cy-1, cx-1) が最小値 (ほぼ 0) であることを確認する.
    """
    h, w = 100, 100
    dm = compute_normalized_distance_map(h, w)
    center_r, center_c = h // 2 - 1, w // 2 - 1
    assert dm[center_r, center_c] == pytest.approx(0.0, abs=0.05)


def test_distance_map_minimum_is_zero_even_size():
    """偶数サイズの ROI で最小値が 0 になること.

    仕様: cx = W/2, cy = H/2 で計算するため,偶数サイズでは arange(W) の
    index W/2 が cx に一致し,両軸の距離が 0 になるピクセルが存在する.
    例: W=100, cx=50.0 -> x=50 のとき (50-50)/50 = 0.
    """
    dm = compute_normalized_distance_map(100, 100)
    assert float(numpy.min(dm)) == pytest.approx(0.0, abs=1e-10)


def test_distance_map_row_symmetry_around_center():
    """同一行内で cx を挟む対称ピクセルが同じ距離をもつこと.

    仕様: cx = W/2 なので x と (W - x) は cx について等距離(dx は逆符号).
    ただし偶数 W での index 0 は鏡像 W が存在しないため,
    index 1..W-1 の範囲で成立する対称ペアを確認する.
    例: W=100, cx=50 -> x=1 と x=99 (100-1) は同じ |x-cx| をもつ.
    """
    h, w = 100, 100
    dm = compute_normalized_distance_map(h, w)
    cy_row = h // 2  # 中心行(y距離が最小の行)
    # x=1 と x=99 のペア(|1-50|=49, |99-50|=49)
    assert dm[cy_row, 1] == pytest.approx(dm[cy_row, w - 1], rel=1e-6)
    # x=10 と x=90 のペア
    assert dm[cy_row, 10] == pytest.approx(dm[cy_row, w - 10], rel=1e-6)


def test_distance_map_increases_from_center():
    """距離マップが中心から外側へ向かって単調増加すること(行方向の確認).

    仕様: d(x,y) は ROI 中心からの距離を表す (SPEC_02)
    中心列(x=cx 付近)で上から下へ増加する.
    """
    h, w = 100, 100
    dm = compute_normalized_distance_map(h, w)
    # 中心列(x=50)で上半分の各行が下の行より大きいこと(中心に近いほど小さい)
    cx = w // 2
    center_col = dm[:, cx]
    # 中心(行 50)から外側(行 0 または行 99)に向かって増加する
    mid = h // 2
    assert center_col[mid] <= center_col[mid - 1]
    assert center_col[mid] <= center_col[mid + 1]


# ---------------------------------------------------------------------------
# classify_zones のテスト
# ---------------------------------------------------------------------------


def test_classify_zones_thresholds():
    """boundaries=(0.33, 0.66) で 0/1/2 に正しく分類されること.

    仕様:
    - d < 0.33  → Center (0)
    - 0.33 <= d < 0.66 → Middle (1)
    - 0.66 <= d → Periphery (2)
    (SPEC_02 3 ゾーン分類節)
    """
    # 境界値を含む代表的な距離値で検証する
    dm = numpy.array([[0.0, 0.32, 0.33, 0.65, 0.66, 1.0]], dtype=numpy.float64)
    zones = classify_zones(dm)

    assert zones[0, 0] == 0  # d=0.0   -> center
    assert zones[0, 1] == 0  # d=0.32  -> center (< 0.33)
    assert zones[0, 2] == 1  # d=0.33  -> middle (>= 0.33)
    assert zones[0, 3] == 1  # d=0.65  -> middle (< 0.66)
    assert zones[0, 4] == 2  # d=0.66  -> periphery (>= 0.66)
    assert zones[0, 5] == 2  # d=1.0   -> periphery


def test_classify_zones_dtype_int8():
    """classify_zones の出力 dtype が numpy.int8 であること.

    仕様: ゾーンラベルマップ (dtype: numpy.int8) (zones.py docstring)
    """
    dm = numpy.array([[0.0, 0.5, 1.0]], dtype=numpy.float64)
    zones = classify_zones(dm)
    assert zones.dtype == numpy.int8


def test_classify_zones_custom_boundaries():
    """任意の境界 (0.5, 0.8) でも正しく分類されること."""
    dm = numpy.array([[0.0, 0.49, 0.5, 0.79, 0.8, 1.0]], dtype=numpy.float64)
    zones = classify_zones(dm, boundaries=(0.5, 0.8))

    assert zones[0, 0] == 0  # d=0.0  -> center
    assert zones[0, 1] == 0  # d=0.49 -> center (< 0.5)
    assert zones[0, 2] == 1  # d=0.5  -> middle (>= 0.5)
    assert zones[0, 3] == 1  # d=0.79 -> middle (< 0.8)
    assert zones[0, 4] == 2  # d=0.8  -> periphery (>= 0.8)
    assert zones[0, 5] == 2  # d=1.0  -> periphery


def test_classify_zones_shape_preserved():
    """入力と同じ shape を持つゾーンマップが返ること."""
    dm = numpy.zeros((30, 40), dtype=numpy.float64)
    zones = classify_zones(dm)
    assert zones.shape == (30, 40)


# ---------------------------------------------------------------------------
# calc_zone_stats のテスト
# ---------------------------------------------------------------------------


def test_calc_zone_stats_keys_present(uniform_luminance_100x100):
    """戻り値 dict に 8 キーが全て揃っていること.

    仕様: 8 キー = center/middle/periphery × mean/std + center_periphery_ratio
         + gradient_magnitude (SPEC_02 空間分析指標節)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    result = calc_zone_stats(uniform_luminance_100x100, zone_map)

    required_keys = {
        "center_mean",
        "center_std",
        "middle_mean",
        "middle_std",
        "periphery_mean",
        "periphery_std",
        "center_periphery_ratio",
        "gradient_magnitude",
    }
    assert set(result.keys()) == required_keys


def test_calc_zone_stats_uniform_image(uniform_luminance_100x100):
    """一様輝度では全ゾーン平均が同値, gradient==0, center_periphery_ratio==1 になること.

    仕様:
    - C/P Ratio = μ_center / μ_periphery = 1.0 (均一時)
    - Gradient = (μ_center - μ_periphery) / μ_center * 100 = 0% (均一時)
    (SPEC_02 空間分析指標節)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    result = calc_zone_stats(uniform_luminance_100x100, zone_map)

    assert result["center_mean"] == pytest.approx(100.0)
    assert result["middle_mean"] == pytest.approx(100.0)
    assert result["periphery_mean"] == pytest.approx(100.0)
    assert result["center_std"] == pytest.approx(0.0)
    assert result["middle_std"] == pytest.approx(0.0)
    assert result["periphery_std"] == pytest.approx(0.0)
    assert result["gradient_magnitude"] == pytest.approx(0.0)
    assert result["center_periphery_ratio"] == pytest.approx(1.0)


def test_calc_zone_stats_radial_gradient(radial_gradient_luminance_100x100):
    """中心が明るい合成輝度で gradient > 0, center_periphery_ratio > 1 になること.

    仕様:
    - gradient > 0 → 中心から周辺に向かって輝度が減衰 (SPEC_02)
    - C/P Ratio > 1.0 → 中心が明るい (SPEC_02)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    result = calc_zone_stats(radial_gradient_luminance_100x100, zone_map)

    assert result["gradient_magnitude"] > 0.0
    assert result["center_periphery_ratio"] > 1.0


def test_calc_zone_stats_gradient_formula():
    """gradient_magnitude が (μ_center - μ_periphery) / μ_center * 100 の公式に従うこと.

    仕様: Gradient = (μ_center - μ_periphery) / μ_center * 100 (SPEC_02)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64)
    # 中心ゾーン (zone==0) だけ値を変える
    luminance[zone_map == 0] = 150.0

    result = calc_zone_stats(luminance, zone_map)
    expected_gradient = (
        (result["center_mean"] - result["periphery_mean"]) / result["center_mean"] * 100.0
    )
    assert result["gradient_magnitude"] == pytest.approx(expected_gradient)


def test_calc_zone_stats_cp_ratio_formula():
    """center_periphery_ratio が μ_center / μ_periphery の公式に従うこと.

    仕様: C/P Ratio = μ_center / μ_periphery (SPEC_02)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64)
    luminance[zone_map == 0] = 150.0

    result = calc_zone_stats(luminance, zone_map)
    expected_ratio = result["center_mean"] / result["periphery_mean"]
    assert result["center_periphery_ratio"] == pytest.approx(expected_ratio)


def test_calc_zone_stats_raises_on_zero_periphery():
    """周辺ゾーンの平均輝度が 0 で ValueError が発生すること.

    仕様: Y_min = 0 の場合は計算不能 (SPEC_02)
    """
    from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map

    dm = compute_normalized_distance_map(100, 100)
    zone_map = classify_zones(dm)
    # 周辺 (zone==2) を 0 にする
    luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64)
    luminance[zone_map == 2] = 0.0

    with pytest.raises(ValueError):
        calc_zone_stats(luminance, zone_map)