Newer
Older
SmTIAS-LightSim / tests / evaluation / test_shape_metrics.py
# pyright: reportArgumentType=none
"""evaluation/shape_metrics.py の仕様ベーステスト.

仕様出典:
- docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md
- docs/04_SPEC/SPEC_04_評価仕様.md
- src/sm_tias_lightsim/evaluation/shape_metrics.py(docstring)

テスト方針:
  - 純関数 A〜E は乱数固定で確定的テストとする(testing.md)
  - 既知の合成画像に対して期待される振る舞いをテストする(実装詳細ではない)
  - NaN/inf 境界条件もカバーする
"""

from __future__ import annotations

import math

import numpy
import pytest

from sm_tias_lightsim.evaluation.shape_metrics import (
    calc_angular_radial_decomposition,
    calc_intensity_centroid,
    calc_intensity_pca,
    calc_ncc,
    calc_ssim,
)

# ===========================================================================
# ヘルパー関数(テスト用合成画像の生成)
# ===========================================================================


def _uniform_image(h: int = 64, w: int = 64, val: float = 100.0) -> numpy.ndarray:
    """全画素が一定値の均一画像."""
    return numpy.full((h, w), val, dtype=numpy.float64)


def _right_half_image(h: int = 64, w: int = 64) -> numpy.ndarray:
    """右半分のみ輝度がある画像(左半分は 0)."""
    img = numpy.zeros((h, w), dtype=numpy.float64)
    img[:, w // 2 :] = 100.0
    return img


def _bottom_half_image(h: int = 64, w: int = 64) -> numpy.ndarray:
    """下半分のみ輝度がある画像(上半分は 0)."""
    img = numpy.zeros((h, w), dtype=numpy.float64)
    img[h // 2 :, :] = 100.0
    return img


def _radial_image(h: int = 64, w: int = 64) -> numpy.ndarray:
    """完全同心円パターン: I = f(r) のみ(角度依存なし)."""
    cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)
    r = numpy.sqrt(
        ((xs - cx) / (cx if cx > 0 else 1)) ** 2 + ((ys - cy) / (cy if cy > 0 else 1)) ** 2
    )
    return numpy.clip(1.0 - r * 0.5, 0.01, None)


def _angular_only_image(h: int = 64, w: int = 64) -> numpy.ndarray:
    """純粋な角度依存パターン: I = f(θ) のみ(半径依存なし).

    上半分 (θ ∈ [0, π]) が明るく,下半分 (θ ∈ [π, 2π]) が暗い.
    """
    cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)
    theta = numpy.arctan2(ys - cy, xs - cx)  # [-π, π]
    # 角度のみに依存: 上半分 (y < cy → theta < 0) を明るく
    return numpy.where(theta < 0, 2.0, 1.0)


def _y_gradient_image(h: int = 64, w: int = 64) -> numpy.ndarray:
    """Y 軸方向の単調勾配(下が明るく上が暗い)."""
    brightness = numpy.linspace(1.3, 0.7, h, dtype=numpy.float64).reshape(-1, 1)
    return numpy.tile(brightness, (1, w))


def _central_peak_gaussian(h: int = 64, w: int = 64) -> numpy.ndarray:
    """中央ピーク+ガウス減衰の真円対称パターン."""
    cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)
    sigma = min(h, w) / 4.0
    r2 = ((xs - cx) ** 2 + (ys - cy) ** 2) / (2.0 * sigma ** 2)
    return numpy.exp(-r2) + 0.1


def _elongated_ellipse(h: int = 64, w: int = 128) -> numpy.ndarray:
    """細長い楕円(X 方向に 2:1 アスペクト比)の輝度分布."""
    cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)
    # X 方向を Y 方向の 2 倍引き伸ばした楕円
    sigma_x = w / 4.0
    sigma_y = h / 8.0  # Y 方向は非常に狭い
    r2 = ((xs - cx) / sigma_x) ** 2 + ((ys - cy) / sigma_y) ** 2
    return numpy.exp(-r2 / 2.0) + 0.05


# ===========================================================================
# A. calc_intensity_centroid
# ===========================================================================


class TestCalcIntensityCentroid:
    """calc_intensity_centroid の仕様テスト(仕様 A1〜A7)."""

    # -----------------------------------------------------------------------
    # A1: 完全均一画像 → centroid は画像中心
    # -----------------------------------------------------------------------

    def test_uniform_image_centroid_is_center_pixels(self):
        """A1: 完全均一画像の重心が画像中心 (cx=W/2, cy=H/2) に一致すること.

        仕様: 完全均一画像 → centroid は画像中心 (cx=W/2, cy=H/2)
        (TECH_11 §1. 輝度重心)
        """
        h, w = 64, 64
        img = _uniform_image(h, w)
        result = calc_intensity_centroid(img)

        cx_expected = (w - 1) / 2.0
        cy_expected = (h - 1) / 2.0
        assert result["centroid_x_px"] == pytest.approx(cx_expected, abs=1e-9)
        assert result["centroid_y_px"] == pytest.approx(cy_expected, abs=1e-9)

    def test_uniform_image_centroid_norm_near_zero(self):
        """A1: 完全均一画像の正規化重心が ≈ 0 になること.

        仕様: centroid_x_norm ≈ 0, centroid_y_norm ≈ 0
        (TECH_11 §1. 輝度重心)
        """
        img = _uniform_image()
        result = calc_intensity_centroid(img)

        assert result["centroid_x_norm"] == pytest.approx(0.0, abs=1e-9)
        assert result["centroid_y_norm"] == pytest.approx(0.0, abs=1e-9)

    def test_uniform_image_centroid_offset_near_zero(self):
        """A1: 完全均一画像の centroid_offset_norm ≈ 0 になること.

        仕様: centroid_offset_norm = √(x²+y²) ≈ 0
        (TECH_11 §1. 輝度重心)
        """
        img = _uniform_image()
        result = calc_intensity_centroid(img)
        assert result["centroid_offset_norm"] == pytest.approx(0.0, abs=1e-9)

    # -----------------------------------------------------------------------
    # A2: 右半分のみ輝度 → centroid_x_norm > 0(右寄り)
    # -----------------------------------------------------------------------

    def test_right_half_image_centroid_x_positive(self):
        """A2: 右半分のみ輝度がある場合,centroid_x_norm > 0(右寄り)になること.

        仕様: 画像の右半分のみ輝度がある場合 → centroid_x_norm > 0
        (TECH_11 §1. 輝度重心)
        """
        img = _right_half_image()
        result = calc_intensity_centroid(img)
        assert result["centroid_x_norm"] > 0.0

    def test_right_half_image_centroid_y_near_zero(self):
        """A2: 右半分のみ輝度の場合,Y 方向の重心は中心付近に留まること.

        仕様: 横対称分布では centroid_y_norm ≈ 0
        """
        img = _right_half_image()
        result = calc_intensity_centroid(img)
        assert abs(result["centroid_y_norm"]) < 0.1

    # -----------------------------------------------------------------------
    # A3: 下半分のみ輝度 → centroid_y_norm > 0(下寄り)
    # -----------------------------------------------------------------------

    def test_bottom_half_image_centroid_y_positive(self):
        """A3: 下半分のみ輝度がある場合,centroid_y_norm > 0(下寄り,画像座標下方向)になること.

        仕様: 画像の下半分のみ輝度がある場合 → centroid_y_norm > 0
        (TECH_11 §1. 輝度重心,画像座標は下が正方向)
        """
        img = _bottom_half_image()
        result = calc_intensity_centroid(img)
        assert result["centroid_y_norm"] > 0.0

    # -----------------------------------------------------------------------
    # A4: mask=None と全 True mask で結果が同一
    # -----------------------------------------------------------------------

    def test_none_mask_equals_all_true_mask(self):
        """A4: mask=None と全 True mask で結果が同一であること.

        仕様: mask=None なら全画素を対象とする
        (shape_metrics.py calc_intensity_centroid docstring)
        """
        rng = numpy.random.RandomState(42)
        img = rng.uniform(50.0, 150.0, (32, 32))
        mask_all_true = numpy.ones((32, 32), dtype=bool)

        result_no_mask = calc_intensity_centroid(img)
        result_all_mask = calc_intensity_centroid(img, mask=mask_all_true)

        assert result_no_mask["centroid_x_px"] == pytest.approx(
            result_all_mask["centroid_x_px"], rel=1e-12
        )
        assert result_no_mask["centroid_y_px"] == pytest.approx(
            result_all_mask["centroid_y_px"], rel=1e-12
        )
        assert result_no_mask["centroid_x_norm"] == pytest.approx(
            result_all_mask["centroid_x_norm"], rel=1e-12
        )
        assert result_no_mask["centroid_y_norm"] == pytest.approx(
            result_all_mask["centroid_y_norm"], rel=1e-12
        )

    # -----------------------------------------------------------------------
    # A5: 負の輝度値は ReLU で 0 として扱う
    # -----------------------------------------------------------------------

    def test_negative_values_are_clipped_to_zero(self):
        """A5: 負の輝度値は ReLU で 0 として扱い,重心計算が破壊されないこと.

        仕様: 負値は ReLU で 0 に(負値ノイズで重心が破壊されない)
        (shape_metrics.py calc_intensity_centroid docstring)
        """
        # 左半分に大きな負値ノイズを入れる
        h, w = 64, 64
        img = numpy.full((h, w), 50.0, dtype=numpy.float64)
        img[:, : w // 2] = -1000.0  # 左半分は大きな負値

        # ReLU 後は右半分のみが有効 → 重心は右寄りになるはず
        result = calc_intensity_centroid(img)
        assert result["centroid_x_norm"] > 0.0

    def test_negative_values_treated_same_as_zero(self):
        """A5: 負値と 0 を置き換えた場合と同じ結果になること.

        仕様: 負値は ReLU で 0 → 0 に置き換えた画像と等価
        """
        rng = numpy.random.RandomState(7)
        img = rng.uniform(-30.0, 100.0, (32, 32))
        img_clipped = numpy.maximum(img, 0.0)

        result_orig = calc_intensity_centroid(img)
        result_clipped = calc_intensity_centroid(img_clipped)

        assert result_orig["centroid_x_px"] == pytest.approx(
            result_clipped["centroid_x_px"], rel=1e-10
        )
        assert result_orig["centroid_y_px"] == pytest.approx(
            result_clipped["centroid_y_px"], rel=1e-10
        )

    # -----------------------------------------------------------------------
    # A6: 戻り値は dict で 5 キーを全て持つ
    # -----------------------------------------------------------------------

    def test_return_dict_has_five_keys(self):
        """A6: 戻り値は dict で 5 キーを全て持つこと.

        仕様: centroid_x_px / centroid_y_px / centroid_x_norm / centroid_y_norm /
        centroid_offset_norm の 5 キー
        (shape_metrics.py calc_intensity_centroid Returns)
        """
        img = _uniform_image()
        result = calc_intensity_centroid(img)

        expected_keys = {
            "centroid_x_px",
            "centroid_y_px",
            "centroid_x_norm",
            "centroid_y_norm",
            "centroid_offset_norm",
        }
        assert set(result.keys()) == expected_keys

    def test_all_return_values_are_float(self):
        """A6: 戻り値の全要素が float であること.

        仕様: Returns 辞書の各値は float
        (shape_metrics.py calc_intensity_centroid Returns)
        """
        img = _uniform_image()
        result = calc_intensity_centroid(img)
        for key, val in result.items():
            assert isinstance(val, float), f"{key} は float でなければなりません"

    # -----------------------------------------------------------------------
    # A7: 形状不一致の mask は ValueError
    # -----------------------------------------------------------------------

    def test_shape_mismatch_mask_raises_value_error(self):
        """A7: 形状不一致の mask は ValueError を発生させること.

        仕様: mask の shape が image と異なる場合 → ValueError
        (shape_metrics.py calc_intensity_centroid Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        wrong_mask = numpy.ones((16, 16), dtype=bool)

        with pytest.raises(ValueError, match="shape"):
            calc_intensity_centroid(img, mask=wrong_mask)

    def test_all_zero_after_relu_raises_value_error(self):
        """A7 補足: ReLU 後に有効ピクセルが全ゼロの場合は ValueError を発生させること.

        仕様: 有効ピクセルの輝度総和が 0 の場合 → ValueError
        (shape_metrics.py calc_intensity_centroid Raises)
        """
        img = numpy.full((32, 32), -1.0, dtype=numpy.float64)  # 全て負値 → ReLU 後全ゼロ
        with pytest.raises(ValueError):
            calc_intensity_centroid(img)

    # -----------------------------------------------------------------------
    # 追加: centroid_offset_norm の計算確認
    # -----------------------------------------------------------------------

    def test_centroid_offset_norm_is_l2_of_x_y_norm(self):
        """centroid_offset_norm が √(x_norm² + y_norm²) と等しいこと.

        仕様: centroid_offset_norm = √(x²+y²) (TECH_11 §1.)
        """
        img = _right_half_image()
        result = calc_intensity_centroid(img)
        expected = math.sqrt(result["centroid_x_norm"] ** 2 + result["centroid_y_norm"] ** 2)
        assert result["centroid_offset_norm"] == pytest.approx(expected, rel=1e-10)


# ===========================================================================
# B. calc_angular_radial_decomposition
# ===========================================================================


class TestCalcAngularRadialDecomposition:
    """calc_angular_radial_decomposition の仕様テスト(仕様 B1〜B6)."""

    # -----------------------------------------------------------------------
    # B1: 完全均一画像 → angular_variance ≈ 0, radial_variance ≈ 0, angular_ratio = NaN
    # -----------------------------------------------------------------------

    def test_uniform_image_angular_variance_near_zero(self):
        """B1: 完全均一画像で angular_variance ≈ 0 になること.

        仕様: 完全均一画像 → angular_variance ≈ 0
        (TECH_11 §2. 角度-放射分解)
        """
        img = _uniform_image()
        result = calc_angular_radial_decomposition(img)
        assert result["angular_variance"] == pytest.approx(0.0, abs=1e-9)

    def test_uniform_image_radial_variance_near_zero(self):
        """B1: 完全均一画像で radial_variance ≈ 0 になること.

        仕様: 完全均一画像 → radial_variance ≈ 0
        (TECH_11 §2. 角度-放射分解)
        """
        img = _uniform_image()
        result = calc_angular_radial_decomposition(img)
        assert result["radial_variance"] == pytest.approx(0.0, abs=1e-9)

    def test_uniform_image_angular_ratio_is_nan(self):
        """B1: 完全均一画像で angular_ratio = NaN になること.

        仕様: 完全均一画像(全 variance = 0)では NaN を返す
        (shape_metrics.py calc_angular_radial_decomposition Returns)
        """
        img = _uniform_image()
        result = calc_angular_radial_decomposition(img)
        assert math.isnan(result["angular_ratio"])

    # -----------------------------------------------------------------------
    # B2: 完全同心円パターン → angular_variance ≈ 0, angular_ratio ≈ 0
    # -----------------------------------------------------------------------

    def test_radial_only_pattern_angular_variance_near_zero(self):
        """B2: 完全同心円パターンで angular_variance が低くなること.

        仕様: 完全同心円パターン (I = f(r) のみ) → angular_variance ≈ 0
        (TECH_11 §2. 角度-放射分解 angular_ratio ≈ 0 の意味)
        """
        img = _radial_image(h=64, w=64)
        result = calc_angular_radial_decomposition(img)
        # 同心円なので角度分散は放射分散に比べて圧倒的に小さい
        assert result["angular_variance"] < result["radial_variance"] * 0.3

    def test_radial_only_pattern_angular_ratio_near_zero(self):
        """B2: 完全同心円パターンで angular_ratio ≈ 0 になること.

        仕様: 完全同心円パターン → angular_ratio ≈ 0
        (TECH_11 §2. angular_ratio ≈ 0 の意味: 完全同心円)
        """
        img = _radial_image(h=64, w=64)
        result = calc_angular_radial_decomposition(img)
        assert result["angular_ratio"] == pytest.approx(0.0, abs=0.1)

    # -----------------------------------------------------------------------
    # B3: 純粋な角度依存パターン → radial_variance ≈ 0, angular_ratio ≈ 1
    # -----------------------------------------------------------------------

    def test_angular_only_pattern_radial_variance_small(self):
        """B3: 純粋な角度依存パターンで radial_variance が小さくなること.

        仕様: 純粋な角度依存 (I = f(θ) のみ) → radial_variance ≈ 0
        (TECH_11 §2. angular_ratio ≈ 1 の意味)
        """
        img = _angular_only_image(h=64, w=64)
        result = calc_angular_radial_decomposition(img)
        # 角度のみ依存なので放射分散は角度分散に比べて小さい
        if result["angular_variance"] > 1e-10:
            assert result["angular_ratio"] > 0.5

    def test_angular_only_pattern_angular_ratio_near_one(self):
        """B3: 純粋な角度依存パターンで angular_ratio が高くなること(1 に近い).

        仕様: 純粋な角度依存 (I = f(θ) のみ) → angular_ratio ≈ 1
        (TECH_11 §2. 角度-放射分解)
        """
        img = _angular_only_image(h=64, w=64)
        result = calc_angular_radial_decomposition(img)
        # 純粋な角度依存パターン → angular_ratio > 0.5
        assert result["angular_ratio"] > 0.5

    # -----------------------------------------------------------------------
    # B4: Y 軸方向の単調勾配 → angular_ratio が 0.3〜0.5 程度
    # -----------------------------------------------------------------------

    def test_y_gradient_angular_ratio_higher_than_radial_only(self):
        """B4: Y 軸方向の単調勾配では同心円パターンより angular_ratio が高くなること.

        仕様: Y 軸方向の単調勾配 → angular_ratio が 0.3〜0.5 程度
        (TECH_11 §2. 値域と解釈,Y 単調勾配に典型的)

        注意: 純粋な Y 軸方向の均一勾配(横縞)は極座標上で角度方向の変動が支配的
        (angular_ratio が高値になる).実際のシミュ輝度マップでは noise/非一様性で
        中程度になる(TECH_11 §2 の 0.3〜0.5 は実機シミュの典型値).
        ここでは「同心円より angular_ratio が高い」という方向性のみを検証する.
        """
        y_grad = _y_gradient_image(h=128, w=128)
        radial = _radial_image(h=128, w=128)

        result_y_grad = calc_angular_radial_decomposition(y_grad)
        result_radial = calc_angular_radial_decomposition(radial)

        # Y 単調勾配は同心円より angular_ratio が高い(方向性が強い)
        assert result_y_grad["angular_ratio"] > result_radial["angular_ratio"]

    # -----------------------------------------------------------------------
    # B5: 戻り値は dict で 3 キーを全て持つ
    # -----------------------------------------------------------------------

    def test_return_dict_has_three_keys(self):
        """B5: 戻り値は dict で 3 キーを全て持つこと.

        仕様: angular_variance / radial_variance / angular_ratio の 3 キー
        (shape_metrics.py calc_angular_radial_decomposition Returns)
        """
        img = _uniform_image()
        result = calc_angular_radial_decomposition(img)

        expected_keys = {"angular_variance", "radial_variance", "angular_ratio"}
        assert set(result.keys()) == expected_keys

    def test_all_return_values_are_float_or_nan(self):
        """B5: 戻り値の全要素が float(NaN を含む)であること.

        仕様: Returns 辞書の各値は float
        """
        img = _y_gradient_image()
        result = calc_angular_radial_decomposition(img)
        for key, val in result.items():
            assert isinstance(val, float), f"{key} は float でなければなりません"

    # -----------------------------------------------------------------------
    # B6: n_radial_bins / n_angular_bins のデフォルト値が想定どおり(20 / 36)
    # -----------------------------------------------------------------------

    def test_default_bins_are_20_and_36(self):
        """B6: n_radial_bins のデフォルト値が 20, n_angular_bins のデフォルト値が 36 であること.

        仕様: n_radial_bins: int = 20, n_angular_bins: int = 36
        (shape_metrics.py calc_angular_radial_decomposition signature)
        """
        import inspect
        sig = inspect.signature(calc_angular_radial_decomposition)
        assert sig.parameters["n_radial_bins"].default == 20
        assert sig.parameters["n_angular_bins"].default == 36

    def test_custom_bins_accepted(self):
        """B6 補足: カスタムビン数を指定しても正常に動作すること.

        仕様: n_radial_bins / n_angular_bins でビン数を指定可能
        (shape_metrics.py calc_angular_radial_decomposition signature)
        """
        img = _radial_image()
        result = calc_angular_radial_decomposition(img, n_radial_bins=10, n_angular_bins=18)
        assert isinstance(result, dict)
        assert "angular_ratio" in result

    # -----------------------------------------------------------------------
    # 追加: 非負値の保証
    # -----------------------------------------------------------------------

    def test_variances_are_non_negative(self):
        """angular_variance と radial_variance は非負であること.

        仕様: 分散は定義上 ≥ 0
        """
        rng = numpy.random.RandomState(123)
        img = rng.uniform(50.0, 150.0, (64, 64))
        result = calc_angular_radial_decomposition(img)
        assert result["angular_variance"] >= 0.0
        assert result["radial_variance"] >= 0.0

    def test_angular_ratio_in_0_to_1_range_for_non_uniform(self):
        """angular_ratio が非均一画像では [0, 1] の範囲内であること.

        仕様: angular_ratio = angular_variance / (angular_variance + radial_variance)
        (shape_metrics.py calc_angular_radial_decomposition)
        """
        rng = numpy.random.RandomState(99)
        img = rng.uniform(50.0, 150.0, (64, 64))
        result = calc_angular_radial_decomposition(img)
        if not math.isnan(result["angular_ratio"]):
            assert 0.0 <= result["angular_ratio"] <= 1.0

    def test_shape_mismatch_mask_raises_value_error(self):
        """形状不一致の mask は ValueError を発生させること.

        仕様: mask の shape が image と異なる場合 → ValueError
        (shape_metrics.py calc_angular_radial_decomposition Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        wrong_mask = numpy.ones((16, 16), dtype=bool)

        with pytest.raises(ValueError, match="shape"):
            calc_angular_radial_decomposition(img, mask=wrong_mask)


# ===========================================================================
# C. calc_ssim
# ===========================================================================


class TestCalcSsim:
    """calc_ssim の仕様テスト(仕様 C1〜C5)."""

    # -----------------------------------------------------------------------
    # C1: 同一画像 → SSIM = 1.0
    # -----------------------------------------------------------------------

    def test_identical_images_ssim_is_one(self):
        """C1: 同一画像を渡した場合,SSIM = 1.0 になること.

        仕様: 同一画像 → SSIM = 1.0
        (TECH_11 §3. SSIM)
        """
        rng = numpy.random.RandomState(0)
        img = rng.uniform(50.0, 200.0, (64, 64))
        ssim_val = calc_ssim(img, img.copy())
        assert ssim_val == pytest.approx(1.0, abs=1e-6)

    # -----------------------------------------------------------------------
    # C2: 完全逆相関画像 → SSIM < 1
    # -----------------------------------------------------------------------

    def test_inverted_image_ssim_less_than_one(self):
        """C2: 完全逆相関画像(image = -reference)で SSIM < 1 になること.

        仕様: 完全逆相関画像 → SSIM < 1
        (TECH_11 §3. SSIM, 0〜1 で数値化)
        """
        rng = numpy.random.RandomState(1)
        ref = rng.uniform(10.0, 100.0, (64, 64))
        img = -ref  # 逆相関

        ssim_val = calc_ssim(img, ref)
        assert ssim_val < 1.0

    # -----------------------------------------------------------------------
    # C3: 異なる形 → SSIM < 1
    # -----------------------------------------------------------------------

    def test_different_pattern_ssim_less_than_one(self):
        """C3: 異なる形(縦勾配 vs 横勾配)で SSIM < 1 になること.

        仕様: 異なる形 (例: 縦勾配 vs 横勾配) → SSIM < 1
        (TECH_11 §3. SSIM, 形(空間構造)の類似性を 0〜1 で数値化)
        """
        h, w = 64, 64
        # 縦勾配(Y 軸方向)
        v_grad = (
            numpy.linspace(1.0, 2.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w))
        )
        # 横勾配(X 軸方向)
        h_grad = (
            numpy.ones((h, w)) * numpy.linspace(1.0, 2.0, w, dtype=numpy.float64).reshape(1, -1)
        )

        ssim_val = calc_ssim(v_grad, h_grad)
        assert ssim_val < 1.0

    # -----------------------------------------------------------------------
    # C4: 形状不一致は ValueError
    # -----------------------------------------------------------------------

    def test_shape_mismatch_raises_value_error(self):
        """C4: 形状不一致の image と reference は ValueError を発生させること.

        仕様: 形状不一致の image と reference は ValueError
        (shape_metrics.py calc_ssim Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        ref = numpy.ones((64, 64), dtype=numpy.float64)

        with pytest.raises(ValueError, match="shape"):
            calc_ssim(img, ref)

    def test_mask_shape_mismatch_raises_value_error(self):
        """C4 補足: mask の形状不一致も ValueError を発生させること.

        仕様: mask の shape が image と異なる場合 → ValueError
        (shape_metrics.py calc_ssim Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        ref = numpy.ones((32, 32), dtype=numpy.float64)
        wrong_mask = numpy.ones((16, 16), dtype=bool)

        with pytest.raises(ValueError, match="shape"):
            calc_ssim(img, ref, mask=wrong_mask)

    # -----------------------------------------------------------------------
    # C5: data_range を明示指定 → 動作すること
    # -----------------------------------------------------------------------

    def test_explicit_data_range_accepted(self):
        """C5: data_range を明示指定しても正常に動作すること.

        仕様: data_range を明示指定 → skimage に正しく渡される
        (shape_metrics.py calc_ssim Args)
        """
        rng = numpy.random.RandomState(2)
        img = rng.uniform(0.0, 1.0, (64, 64))
        # data_range=1.0 を明示指定
        ssim_val = calc_ssim(img, img.copy(), data_range=1.0)
        assert ssim_val == pytest.approx(1.0, abs=1e-6)

    def test_explicit_data_range_changes_result(self):
        """C5 補足: data_range の指定が結果に影響すること(同一画像ではなく異なる画像で確認).

        仕様: data_range を変えると SSIM の絶対値は変わりうる
        """
        rng = numpy.random.RandomState(3)
        img = rng.uniform(0.0, 255.0, (64, 64))
        ref = rng.uniform(0.0, 255.0, (64, 64))

        # data_range が None(自動)と明示指定で同じ画像から同じ結果が得られること
        ssim_auto = calc_ssim(img, ref, data_range=None)
        ssim_explicit = calc_ssim(img, ref, data_range=float(max(img.max(), ref.max())))
        assert ssim_auto == pytest.approx(ssim_explicit, abs=1e-6)

    # -----------------------------------------------------------------------
    # 追加: 戻り値は float であること
    # -----------------------------------------------------------------------

    def test_return_value_is_float(self):
        """戻り値が float であること.

        仕様: Returns SSIM 値(float, 0〜1)
        (shape_metrics.py calc_ssim Returns)
        """
        img = _uniform_image()
        result = calc_ssim(img, img.copy())
        assert isinstance(result, float)


# ===========================================================================
# D. calc_ncc
# ===========================================================================


class TestCalcNcc:
    """calc_ncc の仕様テスト(仕様 D1〜D5)."""

    # -----------------------------------------------------------------------
    # D1: 同一画像 → NCC = 1.0
    # -----------------------------------------------------------------------

    def test_identical_images_ncc_is_one(self):
        """D1: 同一画像を渡した場合,NCC = 1.0 になること.

        仕様: 同一画像 → NCC = 1.0
        (TECH_11 §4. NCC)
        """
        rng = numpy.random.RandomState(10)
        img = rng.uniform(50.0, 200.0, (64, 64))
        ncc_val = calc_ncc(img, img.copy())
        assert ncc_val == pytest.approx(1.0, abs=1e-10)

    # -----------------------------------------------------------------------
    # D2: 完全逆相関 → NCC ≈ -1.0
    # -----------------------------------------------------------------------

    def test_anticorrelated_image_ncc_is_minus_one(self):
        """D2: 完全逆相関 (image = -reference + const) で NCC ≈ -1.0 になること.

        仕様: 完全逆相関 (image = -reference + const) → NCC ≈ -1.0
        (TECH_11 §4. NCC 値域: 1=完全相関, 0=無相関, -1=逆相関)
        """
        rng = numpy.random.RandomState(11)
        ref = rng.uniform(10.0, 100.0, (64, 64))
        # NCC は mean を引くため定数オフセット (const) は影響しない
        img = -ref + 50.0  # 完全逆相関

        ncc_val = calc_ncc(img, ref)
        assert ncc_val == pytest.approx(-1.0, abs=1e-10)

    # -----------------------------------------------------------------------
    # D3: 直交パターン(独立な勾配方向)→ |NCC| < 0.5
    # -----------------------------------------------------------------------

    def test_orthogonal_patterns_ncc_near_zero(self):
        """D3: 直交パターン(独立な勾配方向)で |NCC| < 0.5 になること.

        仕様: 直交パターン(独立な勾配方向)→ |NCC| < 0.5
        (TECH_11 §4. NCC 形の類似性)
        """
        h, w = 64, 64
        # 縦勾配(Y 軸方向)
        v_grad = (
            numpy.linspace(0.0, 1.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w))
        )
        # 横勾配(X 軸方向)
        h_grad = (
            numpy.ones((h, w)) * numpy.linspace(0.0, 1.0, w, dtype=numpy.float64).reshape(1, -1)
        )

        ncc_val = calc_ncc(v_grad, h_grad)
        assert abs(ncc_val) < 0.5

    # -----------------------------------------------------------------------
    # D4: 絶対輝度のスケール不変
    # -----------------------------------------------------------------------

    def test_ncc_invariant_to_absolute_scale(self):
        """D4: 絶対輝度のスケール不変 — image を 2 倍しても NCC は不変であること.

        仕様: 絶対輝度スケールに依存しない形の類似性指標
        (TECH_11 §4. NCC, mean を引いて相関を取るため)
        """
        rng = numpy.random.RandomState(12)
        ref = rng.uniform(50.0, 200.0, (64, 64))
        img = rng.uniform(50.0, 200.0, (64, 64))

        ncc_original = calc_ncc(img, ref)
        ncc_scaled = calc_ncc(img * 2.0, ref)  # image を 2 倍

        assert ncc_original == pytest.approx(ncc_scaled, abs=1e-10)

    def test_ncc_invariant_to_reference_scale(self):
        """D4 補足: reference を 2 倍しても NCC は不変であること.

        仕様: 絶対輝度スケールに依存しない
        """
        rng = numpy.random.RandomState(13)
        ref = rng.uniform(50.0, 200.0, (64, 64))
        img = rng.uniform(50.0, 200.0, (64, 64))

        ncc_original = calc_ncc(img, ref)
        ncc_ref_scaled = calc_ncc(img, ref * 3.0)

        assert ncc_original == pytest.approx(ncc_ref_scaled, abs=1e-10)

    # -----------------------------------------------------------------------
    # D5: 形状不一致は ValueError
    # -----------------------------------------------------------------------

    def test_shape_mismatch_raises_value_error(self):
        """D5: 形状不一致の image と reference は ValueError を発生させること.

        仕様: 形状不一致は ValueError
        (shape_metrics.py calc_ncc Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        ref = numpy.ones((64, 64), dtype=numpy.float64)

        with pytest.raises(ValueError, match="shape"):
            calc_ncc(img, ref)

    def test_mask_shape_mismatch_raises_value_error(self):
        """D5 補足: mask の形状不一致も ValueError を発生させること.

        仕様: mask の shape が image と異なる場合 → ValueError
        (shape_metrics.py calc_ncc Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        ref = numpy.ones((32, 32), dtype=numpy.float64)
        wrong_mask = numpy.ones((16, 16), dtype=bool)

        with pytest.raises(ValueError, match="shape"):
            calc_ncc(img, ref, mask=wrong_mask)

    # -----------------------------------------------------------------------
    # 追加: 均一画像での NaN 返却
    # -----------------------------------------------------------------------

    def test_uniform_image_returns_nan(self):
        """均一画像(分散ゼロ)では NaN を返すこと.

        仕様: 分散がゼロの場合(均一画像)は NaN を返す
        (shape_metrics.py calc_ncc Returns)
        """
        img = numpy.full((32, 32), 100.0, dtype=numpy.float64)
        ref = numpy.full((32, 32), 200.0, dtype=numpy.float64)

        ncc_val = calc_ncc(img, ref)
        assert math.isnan(ncc_val)

    def test_ncc_result_in_minus_one_to_one_range(self):
        """NCC が [-1, 1] の範囲内であること.

        仕様: NCC 値(float, -1〜+1)
        (shape_metrics.py calc_ncc Returns)
        """
        rng = numpy.random.RandomState(14)
        img = rng.uniform(50.0, 200.0, (64, 64))
        ref = rng.uniform(50.0, 200.0, (64, 64))

        ncc_val = calc_ncc(img, ref)
        assert -1.0 <= ncc_val <= 1.0

    def test_ncc_return_value_is_float(self):
        """戻り値が float であること.

        仕様: Returns NCC 値(float, -1〜+1)
        (shape_metrics.py calc_ncc Returns)
        """
        rng = numpy.random.RandomState(15)
        img = rng.uniform(0.0, 100.0, (32, 32))
        ref = rng.uniform(0.0, 100.0, (32, 32))
        result = calc_ncc(img, ref)
        assert isinstance(result, float)

    # -----------------------------------------------------------------------
    # 追加: mask を使ったテスト
    # -----------------------------------------------------------------------

    def test_mask_restricts_calculation_to_true_pixels(self):
        """mask=True のピクセルのみが計算対象になること.

        仕様: mask=True のピクセルのみ計算対象
        (shape_metrics.py calc_ncc Args)
        """
        h, w = 64, 64
        img = numpy.zeros((h, w), dtype=numpy.float64)
        ref = numpy.zeros((h, w), dtype=numpy.float64)

        # 右半分を同一パターンにする
        img[:, w // 2 :] = numpy.linspace(10.0, 90.0, h, dtype=numpy.float64).reshape(-1, 1)
        ref[:, w // 2 :] = img[:, w // 2 :]

        # 右半分のみをマスク
        mask = numpy.zeros((h, w), dtype=bool)
        mask[:, w // 2 :] = True

        ncc_val = calc_ncc(img, ref, mask=mask)
        assert ncc_val == pytest.approx(1.0, abs=1e-10)


# ===========================================================================
# E. calc_intensity_pca
# ===========================================================================


class TestCalcIntensityPca:
    """calc_intensity_pca の仕様テスト(仕様 E1〜E6)."""

    # -----------------------------------------------------------------------
    # E1: 完全均一画像 → eccentricity ≈ 0
    # -----------------------------------------------------------------------

    def test_uniform_image_eccentricity_near_zero(self):
        """E1: 完全均一画像で eccentricity ≈ 0 になること.

        仕様: 完全均一画像 → eccentricity ≈ 0(eigenvalue 同値)
        (TECH_11 §5. 輝度分布 PCA, eccentricity = 0 で完全円形)
        """
        img = _uniform_image(h=64, w=64)
        result = calc_intensity_pca(img)
        assert result["eccentricity"] == pytest.approx(0.0, abs=0.1)

    # -----------------------------------------------------------------------
    # E2: Y 軸方向の強い勾配 → 主軸角度の一貫性確認
    # -----------------------------------------------------------------------

    def test_y_gradient_primary_axis_angle_is_consistent(self):
        """E2: Y 軸方向の強い勾配で主軸角度が一貫した値を返すこと.

        仕様: Y 軸方向の強い勾配 → primary_axis_angle_deg が一貫した値
        (TECH_11 §5. 輝度分布 PCA)
        """
        img = _y_gradient_image(h=128, w=64)
        result1 = calc_intensity_pca(img)
        result2 = calc_intensity_pca(img)
        # 同一入力で同一結果
        assert result1["primary_axis_angle_deg"] == pytest.approx(
            result2["primary_axis_angle_deg"], abs=1e-9
        )

    # -----------------------------------------------------------------------
    # E3: 真円対称パターン → eccentricity ≈ 0
    # -----------------------------------------------------------------------

    def test_circular_gaussian_eccentricity_near_zero(self):
        """E3: 真円対称パターン(中央ピーク + ガウス)で eccentricity ≈ 0 になること.

        仕様: 真円対称パターン (中央ピーク + ガウス) → eccentricity ≈ 0
        (TECH_11 §5. 輝度分布 PCA)
        """
        img = _central_peak_gaussian(h=64, w=64)
        result = calc_intensity_pca(img)
        assert result["eccentricity"] < 0.3

    # -----------------------------------------------------------------------
    # E4: 細長い楕円パターン → eccentricity が高値
    # -----------------------------------------------------------------------

    def test_elongated_ellipse_eccentricity_high(self):
        """E4: 細長い楕円パターンで eccentricity が 0.5〜1.0 に近い高値になること.

        仕様: 細長い楕円パターン → eccentricity が 0.5〜1.0 に近い高値
        (TECH_11 §5. 輝度分布 PCA)
        """
        img = _elongated_ellipse(h=64, w=128)
        result = calc_intensity_pca(img)
        assert result["eccentricity"] > 0.5

    # -----------------------------------------------------------------------
    # E5: primary_axis_angle_deg は [-90, +90] の範囲
    # -----------------------------------------------------------------------

    def test_primary_axis_angle_in_valid_range(self):
        """E5: primary_axis_angle_deg が [-90, +90] の範囲内であること.

        仕様: primary_axis_angle_deg は [-90, +90] の範囲
        (TECH_11 §5. primary_axis_angle_deg = arctan2(v_y, v_x) × 180/π (-90〜+90 に折り畳み))
        """
        rng = numpy.random.RandomState(20)
        img = rng.uniform(50.0, 200.0, (64, 64))
        result = calc_intensity_pca(img)

        assert -90.0 <= result["primary_axis_angle_deg"] <= 90.0

    def test_primary_axis_angle_always_in_range_various_patterns(self):
        """E5: 様々なパターンで primary_axis_angle_deg が [-90, +90] の範囲内であること.

        仕様: primary_axis_angle_deg は [-90, +90] の範囲(折り畳み処理)
        (shape_metrics.py calc_intensity_pca 実装: -90〜+90 の範囲に折り畳む)
        """
        patterns = [
            _y_gradient_image(),
            _radial_image(),
            _right_half_image(),
            _bottom_half_image(),
            _central_peak_gaussian(),
        ]
        for img in patterns:
            result = calc_intensity_pca(img)
            angle = result["primary_axis_angle_deg"]
            assert -90.0 <= angle <= 90.0, f"角度 {angle} が [-90, 90] の範囲外"

    # -----------------------------------------------------------------------
    # E6: 戻り値は dict で 4 キーを全て持つ
    # -----------------------------------------------------------------------

    def test_return_dict_has_four_keys(self):
        """E6: 戻り値は dict で 4 キーを全て持つこと.

        仕様: primary_axis_angle_deg / primary_eigenvalue / secondary_eigenvalue /
        eccentricity の 4 キー
        (shape_metrics.py calc_intensity_pca Returns)
        """
        img = _y_gradient_image()
        result = calc_intensity_pca(img)

        expected_keys = {
            "primary_axis_angle_deg",
            "primary_eigenvalue",
            "secondary_eigenvalue",
            "eccentricity",
        }
        assert set(result.keys()) == expected_keys

    def test_all_return_values_are_float(self):
        """E6: 戻り値の全要素が float であること.

        仕様: Returns 辞書の各値は float
        """
        img = _y_gradient_image()
        result = calc_intensity_pca(img)
        for key, val in result.items():
            assert isinstance(val, float), f"{key} は float でなければなりません"

    # -----------------------------------------------------------------------
    # 追加: eccentricity の値域確認
    # -----------------------------------------------------------------------

    def test_eccentricity_in_zero_to_one_range(self):
        """eccentricity が [0, 1] の範囲内であること.

        仕様: eccentricity = √(1 - secondary/primary), 0=完全円, 1=極端な楕円
        (shape_metrics.py calc_intensity_pca Returns)
        """
        rng = numpy.random.RandomState(21)
        img = rng.uniform(10.0, 100.0, (64, 64))
        result = calc_intensity_pca(img)
        assert 0.0 <= result["eccentricity"] <= 1.0

    def test_primary_eigenvalue_gte_secondary_eigenvalue(self):
        """primary_eigenvalue >= secondary_eigenvalue であること.

        仕様: 固有値分解(numpy.linalg.eigh は固有値昇順保証)
        → eigenvalues[1] が最大,eigenvalues[0] が最小
        (shape_metrics.py calc_intensity_pca 実装)
        """
        rng = numpy.random.RandomState(22)
        img = rng.uniform(10.0, 100.0, (64, 64))
        result = calc_intensity_pca(img)
        assert result["primary_eigenvalue"] >= result["secondary_eigenvalue"]

    def test_shape_mismatch_mask_raises_value_error(self):
        """形状不一致の mask は ValueError を発生させること.

        仕様: mask の shape が image と異なる場合 → ValueError
        (shape_metrics.py calc_intensity_pca Raises)
        """
        img = numpy.ones((32, 32), dtype=numpy.float64)
        wrong_mask = numpy.ones((16, 16), dtype=bool)

        with pytest.raises(ValueError, match="shape"):
            calc_intensity_pca(img, mask=wrong_mask)

    def test_all_zero_image_raises_value_error(self):
        """全ゼロ画像(輝度総和 = 0)では ValueError を発生させること.

        仕様: 有効ピクセルの輝度総和が 0 の場合 → ValueError
        (shape_metrics.py calc_intensity_pca Raises)
        """
        img = numpy.zeros((32, 32), dtype=numpy.float64)
        with pytest.raises(ValueError):
            calc_intensity_pca(img)

    # -----------------------------------------------------------------------
    # 追加: Y 勾配と X 勾配で主軸方向が異なることの確認
    # -----------------------------------------------------------------------

    def test_y_gradient_and_x_gradient_have_different_primary_axis(self):
        """Y 勾配と X 勾配で主軸角度が異なること(形の方向性が区別される).

        仕様: 「伸び方向」を固有ベクトルで表現する
        (TECH_11 §5. 輝度分布 PCA 物理的意味)
        """
        h, w = 64, 64
        y_grad = (
            numpy.linspace(1.0, 2.0, h, dtype=numpy.float64).reshape(-1, 1) * numpy.ones((h, w))
        )
        x_grad = (
            numpy.ones((h, w)) * numpy.linspace(1.0, 2.0, w, dtype=numpy.float64).reshape(1, -1)
        )

        result_y = calc_intensity_pca(y_grad)
        result_x = calc_intensity_pca(x_grad)

        # 主軸角度が異なること(同じ方向を向いていない)
        assert abs(result_y["primary_axis_angle_deg"] - result_x["primary_axis_angle_deg"]) > 30.0