# 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