"""evaluation/normalize.py の仕様ベーステスト.
仕様出典:
- 実装サマリー(apply_mean_anchor 関数仕様)
- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「形状指標への影響(重要)」節)
- docs/04_SPEC/SPEC_04_評価仕様.md(評価パイプライン・メトリクス定義)
- .claude/rules/testing.md(テスト方針)
テスト対象:
apply_mean_anchor(values, target_mean) -> (normalized, alpha)
テスト方針:
- 既知の入力・出力で数値精度を検証する(実装詳細でなく振る舞いを検証)
- ファイル I/O はモックせず実 ndarray を使う (testing.md)
- 乱数を使う場合は seed を必ず固定する (testing.md)
"""
from __future__ import annotations
import numpy
import numpy.testing
import pytest
from sm_tias_lightsim.evaluation.normalize import apply_mean_anchor
# ---------------------------------------------------------------------------
# 1. 正常系 — mean が target_mean に一致すること
# ---------------------------------------------------------------------------
def test_mean_equals_target_mean_uniform_array():
"""一様配列に apply_mean_anchor を適用すると mean が target_mean に厳密一致すること.
仕様: apply_mean_anchor: 出力の mean は target_mean に厳密一致(浮動小数点誤差を許容)
"""
values = numpy.ones((10, 10), dtype=numpy.float64) * 50.0
target_mean = 100.0
normalized, _ = apply_mean_anchor(values, target_mean)
assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10)
def test_mean_equals_target_mean_random_array():
"""乱数配列に apply_mean_anchor を適用すると mean が target_mean に一致すること.
仕様: apply_mean_anchor: α = target_mean / values.mean() を全要素に乗算
→ normalized.mean() == target_mean
"""
rng = numpy.random.RandomState(42)
values = rng.uniform(1.0, 100.0, (50, 50))
target_mean = 146.7146
normalized, _ = apply_mean_anchor(values, target_mean)
assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10)
def test_mean_equals_target_mean_1d_array():
"""1D 配列でも mean が target_mean に一致すること(任意形状サポート).
仕様: apply_mean_anchor: 入力 numpy.ndarray(任意形状・任意次元)
"""
values = numpy.array([10.0, 20.0, 30.0, 40.0, 50.0])
target_mean = 75.0
normalized, _ = apply_mean_anchor(values, target_mean)
assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10)
# ---------------------------------------------------------------------------
# 2. alpha 値の正確性
# ---------------------------------------------------------------------------
def test_alpha_equals_target_mean_over_src_mean():
"""返却される alpha が target_mean / values.mean() に一致すること.
仕様: apply_mean_anchor: alpha = target_mean / values.mean()
"""
values = numpy.ones((5, 5), dtype=numpy.float64) * 30.0
target_mean = 90.0
_, alpha = apply_mean_anchor(values, target_mean)
expected_alpha = target_mean / numpy.mean(values)
assert alpha == pytest.approx(expected_alpha, rel=1e-12)
def test_alpha_less_than_one_when_src_mean_larger():
"""src_mean > target_mean のとき alpha < 1 になること(スケールダウン).
仕様: apply_mean_anchor: α = target_mean / src_mean
src_mean > target_mean → α < 1
"""
values = numpy.ones((10,)) * 200.0
target_mean = 100.0
_, alpha = apply_mean_anchor(values, target_mean)
assert alpha < 1.0
def test_alpha_greater_than_one_when_src_mean_smaller():
"""src_mean < target_mean のとき alpha > 1 になること(スケールアップ).
仕様: apply_mean_anchor: α = target_mean / src_mean
src_mean < target_mean → α > 1
"""
values = numpy.ones((10,)) * 50.0
target_mean = 200.0
_, alpha = apply_mean_anchor(values, target_mean)
assert alpha > 1.0
def test_alpha_equals_one_when_src_mean_equals_target():
"""src_mean == target_mean のとき alpha == 1.0 になること.
仕様: α = target_mean / values.mean()
values.mean() == target_mean → α = 1.0
"""
values = numpy.ones((10,)) * 100.0
target_mean = 100.0
normalized, alpha = apply_mean_anchor(values, target_mean)
assert alpha == pytest.approx(1.0, rel=1e-12)
numpy.testing.assert_array_almost_equal(normalized, values)
def test_alpha_matches_documented_example():
"""実装サマリーの既知値(α=0.961934)に近いことを確認する.
仕様(実装サマリー「動作確認済み(参考)」):
入力: output/baseline/luminance.npy
α = 0.961934
NOTE: luminance.npy の実際の値は不明なため,
同等の src_mean から alpha を計算して検証する(alpha の計算式テスト).
仕様の target_mean=146.7146, α=0.961934 から逆算すると
src_mean = 146.7146 / 0.961934 ≈ 152.52
"""
src_mean = 146.7146 / 0.961934 # ≈ 152.52
values = numpy.ones((10, 10), dtype=numpy.float64) * src_mean
target_mean = 146.7146
_, alpha = apply_mean_anchor(values, target_mean)
assert alpha == pytest.approx(0.961934, rel=1e-5)
# ---------------------------------------------------------------------------
# 3. 線形ゲインの不変性(形状指標 CoV / max-min / C/P 比)
# ---------------------------------------------------------------------------
def test_cov_is_invariant_to_mean_anchor():
"""apply_mean_anchor の適用後に CoV(std/mean)が不変であること.
仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」):
線形ゲインは形状指標(CoV = std/mean)に厳密に不変
数学的根拠: (α·std) / (α·mean) = std / mean
"""
rng = numpy.random.RandomState(0)
values = rng.uniform(50.0, 150.0, (100, 100))
target_mean = 200.0
cov_before = numpy.std(values) / numpy.mean(values)
normalized, _ = apply_mean_anchor(values, target_mean)
cov_after = numpy.std(normalized) / numpy.mean(normalized)
assert cov_after == pytest.approx(cov_before, rel=1e-10)
def test_max_min_ratio_is_invariant_to_mean_anchor():
"""apply_mean_anchor の適用後に max/min 比が不変であること.
仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」):
線形ゲインは max/min 比に不変
数学的根拠: (α·max) / (α·min) = max / min
"""
rng = numpy.random.RandomState(1)
values = rng.uniform(10.0, 200.0, (50, 50)) # min > 0 を保証
target_mean = 80.0
ratio_before = numpy.max(values) / numpy.min(values)
normalized, _ = apply_mean_anchor(values, target_mean)
ratio_after = numpy.max(normalized) / numpy.min(normalized)
assert ratio_after == pytest.approx(ratio_before, rel=1e-10)
def test_center_periphery_ratio_is_invariant_to_mean_anchor():
"""apply_mean_anchor の適用後に中心/周辺比が不変であること.
仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」):
線形ゲインは C/P 比に不変
数学的根拠: (α·center_mean) / (α·periphery_mean) = center_mean / periphery_mean
"""
rng = numpy.random.RandomState(2)
# 中心と周辺で異なる輝度を持つ配列を合成する
arr = rng.uniform(50.0, 100.0, (60, 60))
center = arr[20:40, 20:40]
periphery = numpy.concatenate([arr[:20, :].ravel(), arr[40:, :].ravel()])
cp_before = numpy.mean(center) / numpy.mean(periphery)
normalized, _ = apply_mean_anchor(arr, 120.0)
center_norm = normalized[20:40, 20:40]
periphery_norm = numpy.concatenate([normalized[:20, :].ravel(), normalized[40:, :].ravel()])
cp_after = numpy.mean(center_norm) / numpy.mean(periphery_norm)
assert cp_after == pytest.approx(cp_before, rel=1e-10)
def test_gradient_magnitude_is_invariant_to_mean_anchor():
"""apply_mean_anchor の適用後に勾配((center-periphery)/center * 100)が不変であること.
仕様(SPEC_03「形状指標への影響(重要)」,実装サマリー「線形ゲインの不変性」):
線形ゲインは勾配 % に不変
数学的根拠: (α·c - α·p) / (α·c) * 100 = (c - p) / c * 100
"""
rng = numpy.random.RandomState(3)
arr = rng.uniform(50.0, 100.0, (60, 60))
center = arr[20:40, 20:40]
periphery = numpy.concatenate([arr[:20, :].ravel(), arr[40:, :].ravel()])
c_mean = numpy.mean(center)
p_mean = numpy.mean(periphery)
gradient_before = (c_mean - p_mean) / c_mean * 100.0
normalized, _ = apply_mean_anchor(arr, 200.0)
center_norm = normalized[20:40, 20:40]
periphery_norm = numpy.concatenate([normalized[:20, :].ravel(), normalized[40:, :].ravel()])
c_mean_norm = numpy.mean(center_norm)
p_mean_norm = numpy.mean(periphery_norm)
gradient_after = (c_mean_norm - p_mean_norm) / c_mean_norm * 100.0
assert gradient_after == pytest.approx(gradient_before, rel=1e-10)
# ---------------------------------------------------------------------------
# 4. 形状保持(入力 shape == 出力 shape)
# ---------------------------------------------------------------------------
def test_output_shape_matches_input_shape_2d():
"""2D 入力の shape が出力でも保持されること.
仕様: apply_mean_anchor: 入力 numpy.ndarray(任意形状・任意次元)
出力 normalized は values と同形状
"""
values = numpy.ones((100, 120), dtype=numpy.float64) * 50.0
normalized, _ = apply_mean_anchor(values, 100.0)
assert normalized.shape == (100, 120)
def test_output_shape_matches_input_shape_1d():
"""1D 入力の shape が出力でも保持されること."""
values = numpy.ones((500,), dtype=numpy.float64) * 30.0
normalized, _ = apply_mean_anchor(values, 60.0)
assert normalized.shape == (500,)
def test_output_shape_matches_input_shape_3d():
"""3D 入力の shape が出力でも保持されること(任意次元サポート)."""
values = numpy.ones((10, 20, 3), dtype=numpy.float64) * 50.0
normalized, _ = apply_mean_anchor(values, 100.0)
assert normalized.shape == (10, 20, 3)
# ---------------------------------------------------------------------------
# 5. dtype 保持
# ---------------------------------------------------------------------------
def test_dtype_preserved_float64():
"""float64 入力の dtype が出力でも保持されること.
仕様: apply_mean_anchor: 出力 dtype は values と同じ
"""
values = numpy.ones((10, 10), dtype=numpy.float64) * 50.0
normalized, _ = apply_mean_anchor(values, 100.0)
assert normalized.dtype == numpy.float64
def test_dtype_preserved_float32():
"""float32 入力の dtype が出力でも保持されること.
仕様: apply_mean_anchor: 出力 dtype は values と同じ
"""
values = numpy.ones((10, 10), dtype=numpy.float32) * 50.0
normalized, _ = apply_mean_anchor(values, 100.0)
assert normalized.dtype == numpy.float32
def test_dtype_float_int_input_casts_correctly():
"""整数配列を渡したとき,numpy の演算ルールで正しく動作すること.
仕様: apply_mean_anchor は任意の numpy 数値配列を受け付ける
NOTE: int 配列 * float alpha は numpy がキャストして float64 になる
"""
values = numpy.array([10, 20, 30, 40, 50], dtype=numpy.int32)
target_mean = 60.0
normalized, alpha = apply_mean_anchor(values, target_mean)
assert numpy.mean(normalized) == pytest.approx(target_mean, rel=1e-10)
# ---------------------------------------------------------------------------
# 6. エラー: values.mean() == 0 で ValueError
# ---------------------------------------------------------------------------
def test_zero_mean_raises_value_error():
"""values の mean が 0 のとき ValueError が発生すること.
仕様: apply_mean_anchor: values.mean() == 0 の場合は ValueError
"""
values = numpy.zeros((10, 10), dtype=numpy.float64)
with pytest.raises(ValueError, match="mean"):
apply_mean_anchor(values, 100.0)
def test_all_negative_sum_to_zero_raises_value_error():
"""[-1, 0, 1] のように mean が 0 になる配列でも ValueError が発生すること.
仕様: values.mean() == 0 の場合は ValueError
"""
values = numpy.array([-1.0, 0.0, 1.0])
with pytest.raises(ValueError):
apply_mean_anchor(values, 50.0)
# ---------------------------------------------------------------------------
# 7. 返却値の型チェック(tuple[ndarray, float])
# ---------------------------------------------------------------------------
def test_return_type_is_tuple_of_ndarray_and_float():
"""戻り値が (numpy.ndarray, float) のタプルであること.
仕様: apply_mean_anchor: 出力 (normalized_array, alpha) のタプル
"""
values = numpy.ones((10,), dtype=numpy.float64) * 50.0
result = apply_mean_anchor(values, 100.0)
assert isinstance(result, tuple)
assert len(result) == 2
normalized, alpha = result
assert isinstance(normalized, numpy.ndarray)
assert isinstance(alpha, float)
# ---------------------------------------------------------------------------
# 8. 連続適用の冪等性(同じ target_mean で 2 回適用すると 2 回目は変化なし)
# ---------------------------------------------------------------------------
def test_idempotent_when_applied_twice():
"""target_mean に揃えた配列に再度同じ target_mean で適用すると変化がないこと.
仕様: 正規化後 mean == target_mean → 2 回目の alpha == 1.0
"""
rng = numpy.random.RandomState(99)
values = rng.uniform(10.0, 100.0, (30, 30))
target_mean = 80.0
normalized_once, _ = apply_mean_anchor(values, target_mean)
normalized_twice, alpha2 = apply_mean_anchor(normalized_once, target_mean)
assert alpha2 == pytest.approx(1.0, rel=1e-10)
numpy.testing.assert_array_almost_equal(normalized_twice, normalized_once)