"""evaluation/zones.py の仕様ベーステスト.
仕様出典: SmTIAS-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)