"""reproducibility.py の単体テスト.
仕様 (SPEC_02_照明均一性評価アルゴリズム.md — 「再現性評価」節):
calc_pairwise_ssim:
- N 枚の輝度マップから全 C(N, 2) ペアの SSIM を算出する
- data_range = 255.0 固定
- 返り値: pairwise_scores, mean_ssim, min_ssim, max_ssim, std_ssim, n_pairs
- 2 枚未満で ValueError
calc_pairwise_ssim_registered:
- 各ペアで位置合わせなし SSIM と位置合わせ後 SSIM の両方を算出する
- 位置合わせ: phase_correlate → apply_shift → compute_valid_mask でクロップ
- 有効ピクセル数 <= 49 の場合は位置合わせなし SSIM で代替
- 返り値: pairwise_scores に ssim, ssim_registered, dx, dy, shift_magnitude を含む
- 集計キー: mean_ssim, min_ssim, max_ssim, std_ssim,
mean_ssim_registered, min_ssim_registered, max_ssim_registered,
std_ssim_registered, mean_shift_magnitude, max_shift_magnitude, n_pairs
- 2 枚未満で ValueError
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- numpy で合成した画像を使用し,実画像に依存しない
"""
import numpy as np
import pytest
from src.analysis.reproducibility import calc_pairwise_ssim, calc_pairwise_ssim_registered
# ---------------------------------------------------------------------------
# ヘルパー
# ---------------------------------------------------------------------------
def _make_luminance(height: int = 64, width: int = 64, seed: int = 42) -> np.ndarray:
"""テスト用合成輝度マップ (float64, 0〜255 範囲) を生成する."""
rng = np.random.default_rng(seed)
return rng.uniform(100.0, 200.0, size=(height, width))
REQUIRED_REGISTERED_KEYS = {
"pairwise_scores",
"mean_ssim",
"min_ssim",
"max_ssim",
"std_ssim",
"mean_ssim_registered",
"min_ssim_registered",
"max_ssim_registered",
"std_ssim_registered",
"mean_shift_magnitude",
"max_shift_magnitude",
"n_pairs",
}
REQUIRED_PAIRWISE_SCORE_KEYS = {
"image_a",
"image_b",
"ssim",
"ssim_registered",
"dx",
"dy",
"shift_magnitude",
}
# ---------------------------------------------------------------------------
# calc_pairwise_ssim テスト
# ---------------------------------------------------------------------------
class TestCalcPairwiseSsim:
"""calc_pairwise_ssim 関数のテスト群."""
def test_identical_images_return_ssim_one(self) -> None:
"""正常: 同一画像 2 枚では SSIM = 1.0 が返ること."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img])
assert result["mean_ssim"] == pytest.approx(1.0, abs=1e-6)
def test_raises_value_error_for_single_image(self) -> None:
"""異常: 輝度マップが 1 枚の場合に ValueError が送出されること."""
img = _make_luminance()
with pytest.raises(ValueError):
calc_pairwise_ssim([img])
def test_raises_value_error_for_empty_list(self) -> None:
"""異常: 空リストを渡した場合に ValueError が送出されること."""
with pytest.raises(ValueError):
calc_pairwise_ssim([])
def test_return_value_has_all_required_keys(self) -> None:
"""正常: 戻り値が全必須キーを持つこと."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img])
expected_keys = {
"pairwise_scores",
"mean_ssim",
"min_ssim",
"max_ssim",
"std_ssim",
"n_pairs",
}
assert expected_keys.issubset(result.keys())
def test_pairwise_scores_has_correct_entry_for_two_images(self) -> None:
"""正常: 2 枚の場合 pairwise_scores が 1 要素を持つこと."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img])
assert result["n_pairs"] == 1
assert len(result["pairwise_scores"]) == 1
def test_pairwise_scores_entry_has_required_keys(self) -> None:
"""正常: pairwise_scores の各要素が image_a, image_b, ssim キーを持つこと."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img])
pair = result["pairwise_scores"][0]
assert "image_a" in pair
assert "image_b" in pair
assert "ssim" in pair
def test_n_pairs_is_correct_for_three_images(self) -> None:
"""正常: 3 枚の場合 n_pairs = 3 (C(3,2)=3) であること."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img, img])
assert result["n_pairs"] == 3
def test_ssim_range_is_valid(self) -> None:
"""正常: SSIM が [-1, 1] の範囲内であること."""
img_a = _make_luminance(seed=1)
img_b = _make_luminance(seed=2)
result = calc_pairwise_ssim([img_a, img_b])
for pair in result["pairwise_scores"]:
assert -1.0 <= pair["ssim"] <= 1.0
def test_mean_ssim_is_average_of_pairwise_scores(self) -> None:
"""正常: mean_ssim が pairwise_scores の平均値と一致すること."""
img_a = _make_luminance(seed=1)
img_b = _make_luminance(seed=2)
img_c = _make_luminance(seed=3)
result = calc_pairwise_ssim([img_a, img_b, img_c])
scores = [p["ssim"] for p in result["pairwise_scores"]]
assert result["mean_ssim"] == pytest.approx(float(np.mean(scores)), abs=1e-10)
def test_std_ssim_is_zero_for_identical_images(self) -> None:
"""正常: 全ペアが同一画像の場合 std_ssim = 0.0 であること."""
img = _make_luminance()
result = calc_pairwise_ssim([img, img, img])
assert result["std_ssim"] == pytest.approx(0.0, abs=1e-10)
# ---------------------------------------------------------------------------
# calc_pairwise_ssim_registered テスト
# ---------------------------------------------------------------------------
class TestCalcPairwiseSsimRegistered:
"""calc_pairwise_ssim_registered 関数のテスト群."""
def test_identical_images_return_ssim_one(self) -> None:
"""正常: 同一画像 2 枚では ssim = 1.0, ssim_registered = 1.0 が返ること."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img])
assert result["mean_ssim"] == pytest.approx(1.0, abs=1e-6)
assert result["mean_ssim_registered"] == pytest.approx(1.0, abs=1e-6)
def test_identical_images_return_near_zero_shift(self) -> None:
"""正常: 同一画像 2 枚では shift_magnitude が 0 に近い値を返すこと."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img])
assert result["mean_shift_magnitude"] == pytest.approx(0.0, abs=0.5)
def test_raises_value_error_for_single_image(self) -> None:
"""異常: 輝度マップが 1 枚の場合に ValueError が送出されること."""
img = _make_luminance()
with pytest.raises(ValueError):
calc_pairwise_ssim_registered([img])
def test_raises_value_error_for_empty_list(self) -> None:
"""異常: 空リストを渡した場合に ValueError が送出されること."""
with pytest.raises(ValueError):
calc_pairwise_ssim_registered([])
def test_return_value_has_all_required_keys(self) -> None:
"""正常: 戻り値が全必須キーを持つこと."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img])
assert REQUIRED_REGISTERED_KEYS.issubset(result.keys())
def test_pairwise_scores_entry_has_all_required_keys(self) -> None:
"""正常: pairwise_scores の各要素が全必須キーを持つこと."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img])
pair = result["pairwise_scores"][0]
assert REQUIRED_PAIRWISE_SCORE_KEYS.issubset(pair.keys())
def test_n_pairs_is_correct_for_two_images(self) -> None:
"""正常: 2 枚の場合 n_pairs = 1 であること."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img])
assert result["n_pairs"] == 1
def test_n_pairs_is_correct_for_three_images(self) -> None:
"""正常: 3 枚の場合 n_pairs = 3 (C(3,2)=3) であること."""
img = _make_luminance()
result = calc_pairwise_ssim_registered([img, img, img])
assert result["n_pairs"] == 3
def test_shifted_image_pair_registered_ssim_not_worse(self) -> None:
"""正常: シフトした画像ペアでは ssim_registered >= ssim となること (位置合わせ後の改善).
np.roll でシフトした場合,位相相関が正確にシフトを検出するので
位置合わせ後の SSIM が位置合わせなしより改善されるか同等になること.
"""
img = _make_luminance(64, 64)
shifted = np.roll(np.roll(img, 3, axis=0), -5, axis=1)
result = calc_pairwise_ssim_registered([img, shifted])
pair = result["pairwise_scores"][0]
# 位置合わせ後の SSIM は位置合わせなしより低下しないこと
assert pair["ssim_registered"] >= pair["ssim"] - 0.01
def test_shift_magnitude_is_nonnegative(self) -> None:
"""正常: shift_magnitude が 0 以上の値であること."""
img_a = _make_luminance(seed=1)
img_b = _make_luminance(seed=2)
result = calc_pairwise_ssim_registered([img_a, img_b])
for pair in result["pairwise_scores"]:
assert pair["shift_magnitude"] >= 0.0
def test_shift_magnitude_equals_euclidean_distance_of_dy_dx(self) -> None:
"""正常: shift_magnitude が sqrt(dy^2 + dx^2) と一致すること."""
img = _make_luminance()
shifted = np.roll(img, 3, axis=0)
result = calc_pairwise_ssim_registered([img, shifted])
pair = result["pairwise_scores"][0]
expected_magnitude = float(np.sqrt(pair["dy"] ** 2 + pair["dx"] ** 2))
assert pair["shift_magnitude"] == pytest.approx(expected_magnitude, abs=1e-10)
def test_mean_ssim_registered_equals_average_of_pairwise(self) -> None:
"""正常: mean_ssim_registered が pairwise_scores の平均値と一致すること."""
img_a = _make_luminance(seed=1)
img_b = _make_luminance(seed=2)
img_c = _make_luminance(seed=3)
result = calc_pairwise_ssim_registered([img_a, img_b, img_c])
scores_reg = [p["ssim_registered"] for p in result["pairwise_scores"]]
assert result["mean_ssim_registered"] == pytest.approx(
float(np.mean(scores_reg)), abs=1e-10
)
def test_max_shift_magnitude_is_max_of_pairwise(self) -> None:
"""正常: max_shift_magnitude が pairwise_scores の最大値と一致すること."""
img_a = _make_luminance(seed=1)
img_b = _make_luminance(seed=2)
result = calc_pairwise_ssim_registered([img_a, img_b])
shifts = [p["shift_magnitude"] for p in result["pairwise_scores"]]
assert result["max_shift_magnitude"] == pytest.approx(max(shifts), abs=1e-10)
def test_ssim_registered_values_are_finite(self) -> None:
"""正常: ssim_registered が有限値であること."""
img_a = _make_luminance(seed=10)
img_b = _make_luminance(seed=20)
result = calc_pairwise_ssim_registered([img_a, img_b])
for pair in result["pairwise_scores"]:
assert np.isfinite(pair["ssim_registered"])
def test_mean_ssim_equals_calc_pairwise_ssim_for_same_input(self) -> None:
"""正常: mean_ssim は位置合わせなし SSIM であり,calc_pairwise_ssim と同じ値になること."""
img_a = _make_luminance(seed=5)
img_b = _make_luminance(seed=6)
result_plain = calc_pairwise_ssim([img_a, img_b])
result_reg = calc_pairwise_ssim_registered([img_a, img_b])
assert result_reg["mean_ssim"] == pytest.approx(result_plain["mean_ssim"], abs=1e-10)