Newer
Older
SmTIAS-Evaluation / tests / analysis / test_registration.py
"""registration.py の単体テスト.

仕様 (SPEC_02_照明均一性評価アルゴリズム.md — 「位置合わせ」節):
  - phase_correlate: 位相相関法で並進シフト (dy, dx) を推定する
    - scipy.fft.fft2/ifft2 でクロスパワースペクトルを算出
    - ピーク周辺の放物線補間でサブピクセル精度を達成
    - FFT の周期境界による折り返し補正 (シフト > 画像サイズ / 2 → 反転)
    - 画像サイズ不一致は ValueError
  - apply_shift: scipy.ndimage.shift (order=3, mode='constant', cval=0.0) でシフト適用
    - シフト (0, 0) は入力と同一
    - 出力形状は入力と同一
  - compute_valid_mask: シフト境界の無効領域マスクを生成
    - シフト (0, 0) → 全 True
    - 正のシフト → 上端・左端が False
    - 負のシフト → 下端・右端が False
    - 無効ピクセル幅は ceil(|shift|) 行/列

テスト方針 (GUIDE_08_テスト方針.md):
  - pytest を使用
  - numpy で合成した画像を使用し,実画像に依存しない
  - 既知のシフト量で推定値を検証する
"""

import numpy as np
import pytest
from scipy import ndimage

from src.analysis.registration import apply_shift, compute_valid_mask, phase_correlate


# ---------------------------------------------------------------------------
# ヘルパー
# ---------------------------------------------------------------------------


def _make_luminance(height: int = 64, width: int = 64, seed: int = 42) -> np.ndarray:
    """テスト用合成輝度マップ (float64) を生成する."""
    rng = np.random.default_rng(seed)
    return rng.uniform(100.0, 200.0, size=(height, width))


# ---------------------------------------------------------------------------
# phase_correlate テスト
# ---------------------------------------------------------------------------


class TestPhaseCorrelate:
    """phase_correlate 関数のテスト群."""

    def test_identical_images_return_zero_shift(self) -> None:
        """正常: 同一画像を渡すとシフト量 (0, 0) に近い値を返すこと."""
        img = _make_luminance()
        dy, dx = phase_correlate(img, img)
        assert abs(dy) < 0.5
        assert abs(dx) < 0.5

    def test_integer_shift_y_positive(self) -> None:
        """正常: np.roll で正方向 y シフトした画像のシフト量を推定できること.

        np.roll(img, +3, axis=0) は target を +3 行下にずらす操作.
        phase_correlate は「target を reference に合わせるためのシフト量」を返すため,
        推定値は -3 (上方向) となる.abs 値が expected_dy と一致することを確認する.
        """
        img = _make_luminance()
        roll_amount = 3
        shifted = np.roll(img, roll_amount, axis=0)
        dy, dx = phase_correlate(img, shifted)
        # target は ref より +3 下にずれているため,合わせるには -3 が必要
        assert abs(dy - (-roll_amount)) < 0.5
        assert abs(dx) < 0.5

    def test_integer_shift_x_negative(self) -> None:
        """正常: np.roll で負方向 x シフトした画像のシフト量を推定できること.

        np.roll(img, -5, axis=1) は target を -5 列左にずらす操作.
        phase_correlate は合わせるためのシフト量を返すため +5 となる.
        """
        img = _make_luminance()
        roll_amount = -5
        shifted = np.roll(img, roll_amount, axis=1)
        dy, dx = phase_correlate(img, shifted)
        assert abs(dy) < 0.5
        # target は ref より -5 左にずれているため,合わせるには +5 が必要
        assert abs(dx - (-roll_amount)) < 0.5

    def test_integer_shift_both_axes(self) -> None:
        """正常: 両軸に整数シフトした画像のシフト量を推定できること (誤差 < 0.5 px).

        phase_correlate の返り値は「target を ref に合わせる」シフト量なので,
        roll 量の符号反転が期待値となる.
        """
        img = _make_luminance()
        roll_dy, roll_dx = 3, -5
        shifted = np.roll(np.roll(img, roll_dy, axis=0), roll_dx, axis=1)
        dy, dx = phase_correlate(img, shifted)
        assert abs(dy - (-roll_dy)) < 0.5
        assert abs(dx - (-roll_dx)) < 0.5

    def test_subpixel_shift_accuracy(self) -> None:
        """正常: scipy.ndimage.shift によるサブピクセルシフトを整数精度より高精度で推定できること.

        仕様 (SPEC_02): 「放物線補間でサブピクセル精度を達成する」
        ndimage.shift(img, (dy, dx)) は target を (dy, dx) だけずらす操作.
        phase_correlate は合わせるためのシフト量(符号反転)を返す.
        誤差が整数シフト誤差 (0.5 px) より小さいことを確認する (サブピクセル精度の定義).
        """
        img = _make_luminance()
        shift_dy, shift_dx = 1.7, -2.3
        shifted = ndimage.shift(img, shift=(shift_dy, shift_dx), order=3, mode="wrap")
        dy, dx = phase_correlate(img, shifted)
        # 合わせるには符号反転が必要
        # サブピクセル精度 = 誤差が 0.5 px (整数精度) より小さいこと
        assert abs(dy - (-shift_dy)) < 0.5
        assert abs(dx - (-shift_dx)) < 0.5

    def test_raises_value_error_for_shape_mismatch(self) -> None:
        """異常: 画像サイズが不一致の場合に ValueError が送出されること."""
        ref = _make_luminance(64, 64)
        tgt = _make_luminance(32, 64)
        with pytest.raises((ValueError, Exception)):
            phase_correlate(ref, tgt)

    def test_return_type_is_tuple_of_floats(self) -> None:
        """正常: 戻り値が (float, float) のタプルであること."""
        img = _make_luminance()
        result = phase_correlate(img, img)
        assert len(result) == 2
        dy, dx = result
        assert isinstance(dy, float)
        assert isinstance(dx, float)

    def test_wraparound_correction_large_shift(self) -> None:
        """正常: 画像サイズの半分を超えるシフトは符号が反転して解釈されること.

        np.roll によるシフトは FFT の周期境界上では折り返す.
        例えば画像サイズ 64 の場合,roll=60 は -4 と等価.
        仕様では「シフト量が画像サイズの半分を超える場合は反対方向として解釈する」
        とされており,推定結果の絶対値が画像サイズの半分以下であることを確認する.
        """
        img = _make_luminance(64, 64)
        # roll=60 は -4 と等価(周期64)
        shifted = np.roll(img, 60, axis=0)
        dy, _ = phase_correlate(img, shifted)
        # 周期補正により -4 付近の値になるはず
        h = img.shape[0]
        assert abs(dy) <= h / 2

    def test_zero_shift_with_constant_image(self) -> None:
        """正常: 定数画像(全ピクセル同値)でも例外が発生しないこと."""
        img = np.full((32, 32), 128.0, dtype=np.float64)
        # 定数画像では FFT がほぼゼロになるが,微小値加算でゼロ除算を防ぐ
        dy, dx = phase_correlate(img, img)
        # 結果は有限値であること
        assert np.isfinite(dy)
        assert np.isfinite(dx)


# ---------------------------------------------------------------------------
# apply_shift テスト
# ---------------------------------------------------------------------------


class TestApplyShift:
    """apply_shift 関数のテスト群."""

    def test_zero_shift_returns_identical_image(self) -> None:
        """正常: シフト (0, 0) では入力画像と同一の配列を返すこと."""
        img = _make_luminance()
        result = apply_shift(img, 0.0, 0.0)
        np.testing.assert_array_almost_equal(result, img)

    def test_output_shape_matches_input(self) -> None:
        """正常: 出力形状が入力と同一であること."""
        img = _make_luminance(48, 64)
        result = apply_shift(img, 2.5, -1.3)
        assert result.shape == img.shape

    def test_positive_y_shift_moves_content_down(self) -> None:
        """正常: 正の dy シフトで上部に 0 パディングが生じること (下方向移動)."""
        img = _make_luminance()
        dy = 5
        result = apply_shift(img, float(dy), 0.0)
        # 上端 dy 行はゼロに近い(境界 cval=0.0 のパディング)
        assert np.allclose(result[:dy, :], 0.0, atol=1.0)

    def test_negative_x_shift_moves_content_left(self) -> None:
        """正常: 負の dx シフトで右端に 0 パディングが生じること (左方向移動)."""
        img = _make_luminance()
        dx = -5
        result = apply_shift(img, 0.0, float(dx))
        # 右端 abs(dx) 列はゼロに近い
        assert np.allclose(result[:, dx:], 0.0, atol=1.0)

    def test_known_integer_shift_center_region_matches_original(self) -> None:
        """正常: 既知の整数シフトを適用した際,有効領域(中央部)が元画像と近い値になること."""
        img = _make_luminance()
        dy, dx = 3, 4
        result = apply_shift(img, float(dy), float(dx))
        # 有効領域: dy 行目以降,dx 列目以降
        original_region = img[: img.shape[0] - dy, : img.shape[1] - dx]
        shifted_region = result[dy:, dx:]
        # 3 次スプライン補間で整数シフトは正確に再現される
        np.testing.assert_array_almost_equal(shifted_region, original_region, decimal=6)

    def test_output_dtype_is_float64(self) -> None:
        """正常: 出力配列の dtype が float64 であること."""
        img = _make_luminance()
        result = apply_shift(img, 1.0, -1.0)
        assert result.dtype == np.float64

    def test_non_integer_shift_produces_valid_output(self) -> None:
        """正常: サブピクセルシフトでも有限値の配列が返ること."""
        img = _make_luminance()
        result = apply_shift(img, 1.7, -2.3)
        assert np.all(np.isfinite(result))


# ---------------------------------------------------------------------------
# compute_valid_mask テスト
# ---------------------------------------------------------------------------


class TestComputeValidMask:
    """compute_valid_mask 関数のテスト群."""

    def test_zero_shift_returns_all_true(self) -> None:
        """正常: シフト (0, 0) では全ピクセルが True のマスクを返すこと."""
        mask = compute_valid_mask((32, 64), 0.0, 0.0)
        assert np.all(mask)

    def test_output_shape_matches_input_shape(self) -> None:
        """正常: 出力形状が入力 shape と同一であること."""
        shape = (48, 64)
        mask = compute_valid_mask(shape, 2.5, -1.3)
        assert mask.shape == shape

    def test_positive_dy_makes_top_rows_false(self) -> None:
        """正常: 正の dy シフトでは上端 ceil(dy) 行が False になること (仕様: 正=下方向シフト→上端無効)."""
        dy = 3.2
        shape = (32, 32)
        mask = compute_valid_mask(shape, dy, 0.0)
        invalid_rows = int(np.ceil(dy))
        # 上端 invalid_rows 行はすべて False
        assert not np.any(mask[:invalid_rows, :])
        # それ以降の行は True
        assert np.all(mask[invalid_rows:, :])

    def test_negative_dy_makes_bottom_rows_false(self) -> None:
        """正常: 負の dy シフトでは下端 ceil(|dy|) 行が False になること (仕様: 負=上方向シフト→下端無効)."""
        dy = -4.0
        shape = (32, 32)
        mask = compute_valid_mask(shape, dy, 0.0)
        invalid_rows = int(np.ceil(abs(dy)))
        # 下端 invalid_rows 行はすべて False
        assert not np.any(mask[-invalid_rows:, :])
        # それ以前の行は True
        assert np.all(mask[: shape[0] - invalid_rows, :])

    def test_positive_dx_makes_left_cols_false(self) -> None:
        """正常: 正の dx シフトでは左端 ceil(dx) 列が False になること (仕様: 正=右方向シフト→左端無効)."""
        dx = 5.0
        shape = (32, 32)
        mask = compute_valid_mask(shape, 0.0, dx)
        invalid_cols = int(np.ceil(dx))
        assert not np.any(mask[:, :invalid_cols])
        assert np.all(mask[:, invalid_cols:])

    def test_negative_dx_makes_right_cols_false(self) -> None:
        """正常: 負の dx シフトでは右端 ceil(|dx|) 列が False になること (仕様: 負=左方向シフト→右端無効)."""
        dx = -6.0
        shape = (32, 32)
        mask = compute_valid_mask(shape, 0.0, dx)
        invalid_cols = int(np.ceil(abs(dx)))
        assert not np.any(mask[:, -invalid_cols:])
        assert np.all(mask[:, : shape[1] - invalid_cols])

    def test_both_shifts_combine_correctly(self) -> None:
        """正常: 両軸のシフトが組み合わさり,対応する 2 辺が無効になること."""
        dy, dx = 2.0, 3.0
        shape = (32, 32)
        mask = compute_valid_mask(shape, dy, dx)
        idy = int(np.ceil(dy))
        idx = int(np.ceil(dx))
        # 上端・左端が False
        assert not np.any(mask[:idy, :])
        assert not np.any(mask[:, :idx])
        # 右下の有効領域は True
        assert np.all(mask[idy:, idx:])

    def test_output_dtype_is_bool(self) -> None:
        """正常: 出力配列の dtype が bool であること."""
        mask = compute_valid_mask((16, 16), 1.0, 1.0)
        assert mask.dtype == bool

    def test_fractional_shift_ceiling_behavior(self) -> None:
        """正常: 小数シフトは ceil で切り上げて無効行列数を決定すること."""
        dy = 1.1  # ceil = 2
        shape = (32, 32)
        mask = compute_valid_mask(shape, dy, 0.0)
        # 1 行だけ無効なら 1.1 の floor と ceil が異なる
        # 仕様では ceil を使うので 2 行が無効
        assert not np.any(mask[:2, :])
        assert np.all(mask[2:, :])

    def test_large_shift_all_false(self) -> None:
        """エッジケース: シフト量が画像サイズを超える場合,全ピクセルが False になること."""
        shape = (16, 16)
        dy = 20.0  # shape[0] より大きい
        mask = compute_valid_mask(shape, dy, 0.0)
        assert not np.any(mask)