"""spatial.py の単体テスト.
仕様 (SPEC_02_照明均一性評価アルゴリズム.md):
- calc_distance_map: 各ピクセルの中心からの正規化楕円距離(0.0〜1.0)
- classify_zones: 3 ゾーン分類(0=center, 1=middle, 2=periphery)
- calc_zone_stats: ゾーン別 mean/std + center_periphery_ratio + gradient_magnitude(%)
- calc_radial_profile: 放射状プロファイル(num_bins x 2)
- calc_spatial_uniformity: ファサード関数
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- numpy で合成した画像を使用
- 既知の値で期待値と比較
"""
import numpy
import pytest
from src.analysis.spatial import (
calc_distance_map,
calc_radial_profile,
calc_spatial_uniformity,
calc_zone_stats,
classify_zones,
)
# ---------------------------------------------------------------------------
# calc_distance_map テスト
# ---------------------------------------------------------------------------
class TestCalcDistanceMap:
"""calc_distance_map 関数のテスト群."""
def test_center_pixel_is_zero(self) -> None:
"""正常: 中心ピクセルの距離が 0.0 であること.
実装は cy = height / 2, cx = width / 2 を使う.
偶数サイズでは cy, cx が整数座標と一致するため
(cy, cx) のピクセルが距離 0 になる.
例: 10x10 → cy=5.0, cx=5.0 → dist[5, 5] == 0.0
"""
height, width = 10, 10
dist = calc_distance_map(height, width)
# 10x10 では cy=5.0, cx=5.0 → ピクセル (5, 5) が中心
assert numpy.isclose(dist[5, 5], 0.0)
def test_output_shape_matches_input(self) -> None:
"""正常: 出力配列の shape が (height, width) と一致すること."""
height, width = 10, 20
dist = calc_distance_map(height, width)
assert dist.shape == (height, width)
def test_values_are_in_0_to_1_range(self) -> None:
"""正常: 全要素が 0.0〜1.0 の範囲内であること."""
dist = calc_distance_map(20, 20)
assert numpy.all(dist >= 0.0)
assert numpy.all(dist <= 1.0)
def test_maximum_value_is_1(self) -> None:
"""正常: 最大値が 1.0 であること(再正規化の確認)."""
dist = calc_distance_map(20, 30)
assert numpy.isclose(dist.max(), 1.0)
def test_corner_pixels_are_far_from_center(self) -> None:
"""正常: 角のピクセルは中心から離れており、最小値ピクセルよりも大きな値を持つこと."""
height, width = 20, 20
dist = calc_distance_map(height, width)
min_val = dist.min()
corner_val = dist[0, 0]
assert corner_val > min_val
def test_1x1_image_boundary_condition(self) -> None:
"""境界: 1x1 画像でゼロ除算・例外が発生しないこと.
1x1 の場合 cy=0.5, cx=0.5 のため唯一のピクセル (0,0) は中心外となる.
最大値で再正規化されるため戻り値は 1.0 となる.
重要なのは例外が発生しないこと(形状が正しいこと).
"""
dist = calc_distance_map(1, 1)
assert dist.shape == (1, 1)
assert numpy.isfinite(dist[0, 0])
def test_output_dtype_is_float64(self) -> None:
"""正常: 戻り値の dtype が float64 であること."""
dist = calc_distance_map(8, 8)
assert dist.dtype == numpy.float64
def test_minimum_value_is_at_single_pixel(self) -> None:
"""正常: 距離マップの最小値が唯一の点(中心)で発生すること.
実装は cy = height / 2, cx = width / 2 を使う.
偶数サイズでは (cy, cx) のピクセルが最小距離 0.0 となる.
"""
dist = calc_distance_map(10, 10)
min_idx = numpy.unravel_index(dist.argmin(), dist.shape)
# 10x10 では cy=5, cx=5 → ピクセル (5, 5) が最小
assert min_idx == (5, 5)
def test_distance_increases_from_center(self) -> None:
"""正常: 中心から離れるほど距離値が大きくなること."""
dist = calc_distance_map(20, 20)
# cy=10, cx=10 → 中心ピクセル (10, 10)
center_val = dist[10, 10]
# 中心から離れた数点を確認
assert dist[10, 15] > center_val
assert dist[10, 5] > center_val
assert dist[15, 10] > center_val
assert dist[5, 10] > center_val
# ---------------------------------------------------------------------------
# classify_zones テスト
# ---------------------------------------------------------------------------
class TestClassifyZones:
"""classify_zones 関数のテスト群."""
def test_center_zone_has_value_0(self) -> None:
"""正常: 距離 < center_threshold のピクセルがゾーン 0(center)であること."""
# 距離が 0.0 のピクセルは中心ゾーンに分類される
distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66)
assert zone_map[0, 0] == 0 # 0.0 < 0.33 → center
def test_middle_zone_has_value_1(self) -> None:
"""正常: center_threshold <= 距離 < periphery_threshold のピクセルがゾーン 1(middle)であること."""
distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66)
assert zone_map[0, 1] == 1 # 0.5 → middle
def test_periphery_zone_has_value_2(self) -> None:
"""正常: 距離 >= periphery_threshold のピクセルがゾーン 2(periphery)であること."""
distance_map = numpy.array([[0.0, 0.5, 0.9]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66)
assert zone_map[0, 2] == 2 # 0.9 >= 0.66 → periphery
def test_output_shape_matches_input(self) -> None:
"""正常: 出力配列の shape が入力と一致すること."""
distance_map = numpy.zeros((10, 20), dtype=numpy.float64)
zone_map = classify_zones(distance_map)
assert zone_map.shape == (10, 20)
def test_output_dtype_is_uint8(self) -> None:
"""正常: 戻り値の dtype が uint8 であること."""
distance_map = numpy.zeros((8, 8), dtype=numpy.float64)
zone_map = classify_zones(distance_map)
assert zone_map.dtype == numpy.uint8
def test_boundary_value_at_center_threshold(self) -> None:
"""境界: center_threshold ちょうどの値はゾーン 1(middle)に分類されること."""
distance_map = numpy.array([[0.33]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66)
assert zone_map[0, 0] == 1 # distance_map >= center_threshold → middle
def test_boundary_value_at_periphery_threshold(self) -> None:
"""境界: periphery_threshold ちょうどの値はゾーン 2(periphery)に分類されること."""
distance_map = numpy.array([[0.66]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.33, periphery_threshold=0.66)
assert zone_map[0, 0] == 2 # distance_map >= periphery_threshold → periphery
def test_custom_thresholds(self) -> None:
"""正常: カスタム閾値が正しく適用されること."""
distance_map = numpy.array([[0.1, 0.4, 0.8]], dtype=numpy.float64)
zone_map = classify_zones(distance_map, center_threshold=0.2, periphery_threshold=0.6)
assert zone_map[0, 0] == 0 # 0.1 < 0.2 → center
assert zone_map[0, 1] == 1 # 0.2 <= 0.4 < 0.6 → middle
assert zone_map[0, 2] == 2 # 0.8 >= 0.6 → periphery
def test_all_center_when_distance_all_zero(self) -> None:
"""正常: 距離が全て 0.0 の場合、全ピクセルがゾーン 0(center)であること."""
distance_map = numpy.zeros((5, 5), dtype=numpy.float64)
zone_map = classify_zones(distance_map)
assert numpy.all(zone_map == 0)
# ---------------------------------------------------------------------------
# calc_zone_stats テスト
# ---------------------------------------------------------------------------
class TestCalcZoneStats:
"""calc_zone_stats 関数のテスト群."""
@pytest.fixture
def uniform_inputs(self) -> tuple[numpy.ndarray, numpy.ndarray]:
"""均一輝度(200.0)と単純なゾーンマップを返す fixture."""
luminance = numpy.full((9, 9), 200.0, dtype=numpy.float64)
# 中央 3x3 = center (0), 外周 = periphery (2), 残り = middle (1)
zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8)
zone_map[3:6, 3:6] = 0
zone_map[2:7, 2:7][zone_map[2:7, 2:7] != 0] = 1
return luminance, zone_map
def test_return_value_has_all_required_keys(self) -> None:
"""正常: 戻り値のキーが全て存在すること."""
luminance = numpy.full((8, 8), 128.0, dtype=numpy.float64)
zone_map = numpy.zeros((8, 8), dtype=numpy.uint8)
zone_map[4:, :] = 1
zone_map[6:, :] = 2
result = calc_zone_stats(luminance, zone_map)
expected_keys = {
"center_mean", "center_std",
"middle_mean", "middle_std",
"periphery_mean", "periphery_std",
"center_periphery_ratio", "gradient_magnitude",
}
assert set(result.keys()) == expected_keys
def test_uniform_image_ratio_is_1(self, uniform_inputs) -> None:
"""正常: 均一輝度画像では center_periphery_ratio が 1.0 であること."""
luminance, zone_map = uniform_inputs
result = calc_zone_stats(luminance, zone_map)
assert numpy.isclose(result["center_periphery_ratio"], 1.0)
def test_uniform_image_gradient_is_0(self, uniform_inputs) -> None:
"""正常: 均一輝度画像では gradient_magnitude が 0.0 であること."""
luminance, zone_map = uniform_inputs
result = calc_zone_stats(luminance, zone_map)
assert numpy.isclose(result["gradient_magnitude"], 0.0)
def test_uniform_image_std_is_0(self, uniform_inputs) -> None:
"""正常: 均一輝度画像では各ゾーンの std が 0.0 であること."""
luminance, zone_map = uniform_inputs
result = calc_zone_stats(luminance, zone_map)
assert numpy.isclose(result["center_std"], 0.0)
assert numpy.isclose(result["middle_std"], 0.0)
assert numpy.isclose(result["periphery_std"], 0.0)
def test_brighter_center_yields_ratio_greater_than_1(self) -> None:
"""正常: 中心が明るい場合に center_periphery_ratio > 1.0 であること."""
luminance = numpy.full((9, 9), 100.0, dtype=numpy.float64)
zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8)
# 中心ゾーン(zone=0)を明るくする
zone_map[3:6, 3:6] = 0
luminance[3:6, 3:6] = 200.0
result = calc_zone_stats(luminance, zone_map)
assert result["center_mean"] > result["periphery_mean"]
assert result["center_periphery_ratio"] > 1.0
def test_center_periphery_ratio_inf_when_periphery_mean_is_zero(self) -> None:
"""異常: periphery_mean が 0.0 の場合に center_periphery_ratio が inf であること."""
luminance = numpy.full((5, 5), 0.0, dtype=numpy.float64)
luminance[2, 2] = 100.0
zone_map = numpy.full((5, 5), 2, dtype=numpy.uint8)
zone_map[2, 2] = 0
result = calc_zone_stats(luminance, zone_map)
assert result["center_periphery_ratio"] == float("inf")
def test_gradient_magnitude_inf_when_center_mean_is_zero(self) -> None:
"""異常: center_mean が 0.0 の場合に gradient_magnitude が inf であること."""
luminance = numpy.full((5, 5), 100.0, dtype=numpy.float64)
luminance[2, 2] = 0.0
zone_map = numpy.full((5, 5), 2, dtype=numpy.uint8)
zone_map[2, 2] = 0
result = calc_zone_stats(luminance, zone_map)
assert result["gradient_magnitude"] == float("inf")
def test_gradient_magnitude_formula(self) -> None:
"""正常: gradient_magnitude が (center_mean - periphery_mean) / center_mean * 100 の公式に従うこと."""
luminance = numpy.full((9, 9), 50.0, dtype=numpy.float64)
zone_map = numpy.full((9, 9), 2, dtype=numpy.uint8)
zone_map[3:6, 3:6] = 0
luminance[3:6, 3:6] = 100.0
result = calc_zone_stats(luminance, zone_map)
center_mean = result["center_mean"]
periphery_mean = result["periphery_mean"]
expected_gradient = (center_mean - periphery_mean) / center_mean * 100
assert numpy.isclose(result["gradient_magnitude"], expected_gradient)
def test_return_values_are_float(self) -> None:
"""正常: 戻り値の各フィールドが Python float 型であること."""
luminance = numpy.full((8, 8), 128.0, dtype=numpy.float64)
zone_map = numpy.zeros((8, 8), dtype=numpy.uint8)
zone_map[4:, :] = 1
zone_map[6:, :] = 2
result = calc_zone_stats(luminance, zone_map)
for key in result:
assert isinstance(result[key], float), f"{key} が float でない"
# ---------------------------------------------------------------------------
# calc_radial_profile テスト
# ---------------------------------------------------------------------------
class TestCalcRadialProfile:
"""calc_radial_profile 関数のテスト群."""
@pytest.fixture
def uniform_inputs(self) -> tuple[numpy.ndarray, numpy.ndarray]:
"""均一輝度と距離マップを返す fixture."""
from src.analysis.spatial import calc_distance_map
luminance = numpy.full((20, 20), 150.0, dtype=numpy.float64)
distance_map = calc_distance_map(20, 20)
return luminance, distance_map
def test_output_shape_matches_num_bins(self, uniform_inputs) -> None:
"""正常: 戻り値の shape が (num_bins, 2) であること."""
luminance, distance_map = uniform_inputs
num_bins = 15
profile = calc_radial_profile(luminance, distance_map, num_bins=num_bins)
assert profile.shape == (num_bins, 2)
def test_default_num_bins_is_20(self, uniform_inputs) -> None:
"""正常: デフォルトの num_bins=20 で shape が (20, 2) であること."""
luminance, distance_map = uniform_inputs
profile = calc_radial_profile(luminance, distance_map)
assert profile.shape == (20, 2)
def test_column_0_is_bin_center_distance(self, uniform_inputs) -> None:
"""正常: 列 0 がビン中心距離(0.0〜1.0 の範囲内)であること."""
luminance, distance_map = uniform_inputs
profile = calc_radial_profile(luminance, distance_map, num_bins=10)
assert numpy.all(profile[:, 0] >= 0.0)
assert numpy.all(profile[:, 0] <= 1.0)
def test_column_0_is_monotonically_increasing(self, uniform_inputs) -> None:
"""正常: 列 0(ビン中心距離)が単調増加であること."""
luminance, distance_map = uniform_inputs
profile = calc_radial_profile(luminance, distance_map, num_bins=20)
diffs = numpy.diff(profile[:, 0])
assert numpy.all(diffs > 0)
def test_uniform_image_yields_constant_luminance(self, uniform_inputs) -> None:
"""正常: 均一輝度画像では全ビンで輝度がほぼ等しいこと."""
luminance, distance_map = uniform_inputs
profile = calc_radial_profile(luminance, distance_map, num_bins=20)
# 均一画像では全ビンの平均輝度が 150.0 に近いこと
assert numpy.allclose(profile[:, 1], 150.0, atol=1e-6)
def test_output_dtype_is_float64(self, uniform_inputs) -> None:
"""正常: 戻り値の dtype が float64 であること."""
luminance, distance_map = uniform_inputs
profile = calc_radial_profile(luminance, distance_map)
assert profile.dtype == numpy.float64
def test_custom_num_bins(self, uniform_inputs) -> None:
"""正常: カスタム num_bins が正しく反映されること."""
luminance, distance_map = uniform_inputs
for num_bins in [5, 10, 30, 50]:
profile = calc_radial_profile(luminance, distance_map, num_bins=num_bins)
assert profile.shape == (num_bins, 2)
# ---------------------------------------------------------------------------
# calc_spatial_uniformity テスト
# ---------------------------------------------------------------------------
class TestCalcSpatialUniformity:
"""calc_spatial_uniformity ファサード関数のテスト群."""
@pytest.fixture
def sample_luminance(self) -> numpy.ndarray:
"""テスト用の 20x20 均一輝度画像を返す fixture."""
return numpy.full((20, 20), 128.0, dtype=numpy.float64)
def test_return_value_has_zone_stats_key(self, sample_luminance) -> None:
"""正常: 戻り値に 'zone_stats' キーが存在すること."""
result = calc_spatial_uniformity(sample_luminance)
assert "zone_stats" in result
def test_return_value_has_radial_profile_key(self, sample_luminance) -> None:
"""正常: 戻り値に 'radial_profile' キーが存在すること."""
result = calc_spatial_uniformity(sample_luminance)
assert "radial_profile" in result
def test_return_value_has_zone_map_key(self, sample_luminance) -> None:
"""正常: 戻り値に 'zone_map' キーが存在すること."""
result = calc_spatial_uniformity(sample_luminance)
assert "zone_map" in result
def test_return_value_has_exactly_3_keys(self, sample_luminance) -> None:
"""正常: 戻り値のキー数が 3 であること."""
result = calc_spatial_uniformity(sample_luminance)
assert len(result) == 3
def test_zone_stats_is_dict(self, sample_luminance) -> None:
"""正常: zone_stats が dict 型であること."""
result = calc_spatial_uniformity(sample_luminance)
assert isinstance(result["zone_stats"], dict)
def test_radial_profile_is_ndarray(self, sample_luminance) -> None:
"""正常: radial_profile が numpy.ndarray 型であること."""
result = calc_spatial_uniformity(sample_luminance)
assert isinstance(result["radial_profile"], numpy.ndarray)
def test_zone_map_is_ndarray(self, sample_luminance) -> None:
"""正常: zone_map が numpy.ndarray 型であること."""
result = calc_spatial_uniformity(sample_luminance)
assert isinstance(result["zone_map"], numpy.ndarray)
def test_zone_map_shape_matches_input(self, sample_luminance) -> None:
"""正常: zone_map の shape が入力輝度と同じであること."""
result = calc_spatial_uniformity(sample_luminance)
assert result["zone_map"].shape == sample_luminance.shape
def test_radial_profile_default_shape(self, sample_luminance) -> None:
"""正常: radial_profile のデフォルト形状が (20, 2) であること."""
result = calc_spatial_uniformity(sample_luminance)
assert result["radial_profile"].shape == (20, 2)
def test_zone_stats_has_all_required_keys(self, sample_luminance) -> None:
"""正常: zone_stats が全ての必須キーを持つこと."""
result = calc_spatial_uniformity(sample_luminance)
expected_keys = {
"center_mean", "center_std",
"middle_mean", "middle_std",
"periphery_mean", "periphery_std",
"center_periphery_ratio", "gradient_magnitude",
}
assert set(result["zone_stats"].keys()) == expected_keys