Newer
Older
SmTIAS-Evaluation / tests / analysis / test_spatial.py
"""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 つの基本キー(zone_stats/radial_profile/zone_map)が存在すること.

        仕様変更(動径 min/max 比の追加)によりトップレベルキー数は 3 超になりうる.
        基本 3 キーの存在を保証し,追加キーを許容する形に修正する.
        """
        result = calc_spatial_uniformity(sample_luminance)
        assert "zone_stats" in result
        assert "radial_profile" in result
        assert "zone_map" in result
        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