"""evaluation/fullimage.py(画像全体のノイズ補正付き min/max 比)の仕様ベーステスト.
仕様出典:
- docs/04_SPEC/SPEC_04_評価仕様.md「動径 min/max 比(オプション)」
(輪帯平均 min/max は動径プロファイル 20 ビンの最小/最大比.画素単位 max/min より
ノイズにロバストだが,方位方向に平均するので方位依存の不均一は測れない)
- docs/06_TEST/TEST_18_照明配置依存性の球と立方体の比較.md(オフ軸 LED の非対称な分布を
定量する必要性)
- 新指標の定義と検証条件(evaluation/fullimage.py モジュール docstring):
ratio = (min G_sigma(I) - n_min) / (max G_sigma(I) - n_max)
* G_sigma = ガウス平滑化,(n_min, n_max) = 同じ sigma で平滑化したノイズ場の極値の期待値
* 同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致する
* 一様像 + ノイズでの見かけの不均一(ノイズ床)は 1 から ±0.004 程度
* ノイズが空間的に独立であることを仮定する
テスト方針(.claude/rules/testing.md):
- 核計算ロジックなので合成画像(既知の真値)で単体テストする.output/ の実データには依存しない
- 乱数は必ず seed を固定する
- 期待値は実装ではなく上記の定義から導く
注記(定義そのものが持つバイアス):
ガウス平滑化は sigma スケールの構造をなまし,境界では折り返すため,平滑化後の min/max は
「平滑化しない真値」より必ず中央寄り(= ratio は真値より大きめ)になる.
256x256・sigma=12 の線形勾配では +0.013 程度.したがって真値との比較はこの構造バイアスを
見込んだ許容差で行い,ノイズに対する頑健性(ノイズ有無での差)はノイズ床の許容差で厳しく見る.
"""
from __future__ import annotations
import numpy
import pytest
from scipy.ndimage import gaussian_filter
from sm_tias_lightsim.evaluation.fullimage import (
calc_fullimage_min_max_ratio,
estimate_noise_extremes,
)
from sm_tias_lightsim.evaluation.radial import calc_radial_min_max_ratio, calc_radial_profile
# 仕様上の許容差
_NOISE_FLOOR_TOL = 0.01 # ノイズ有無での ratio の差(仕様のノイズ床 ±0.004 に余裕を持たせた値)
_ANNULUS_AGREEMENT_TOL = 0.01 # 同心円対称な分布での輪帯平均との一致(仕様値)
_SMOOTHING_BIAS_TOL = 0.02 # 平滑化による構造バイアスを見込んだ真値との許容差
_SIZE = 256
_MEAN_LEVEL = 100.0
def _uniform(size: int = _SIZE, level: float = _MEAN_LEVEL) -> numpy.ndarray:
"""一様な輝度配列(真の min/max 比 = 1.0)."""
return numpy.full((size, size), level, dtype=numpy.float64)
def _concentric_bump(size: int = _SIZE) -> numpy.ndarray:
"""中心 1.0・周辺 0.85 の同心円対称なガウス山(輪帯平均が基準として使える分布)."""
ys, xs = numpy.mgrid[0:size, 0:size]
center = (size - 1) / 2.0
d2 = ((xs - center) ** 2 + (ys - center) ** 2) / (center**2)
return (0.85 + 0.15 * numpy.exp(-1.5 * d2)) * _MEAN_LEVEL
def _linear_gradient(size: int = _SIZE, low: float = 0.8) -> numpy.ndarray:
"""左 1.0 → 右 low の線形勾配(方位依存=同心円対称でない分布).真の min/max 比 = low."""
_, xs = numpy.mgrid[0:size, 0:size]
return (1.0 - (1.0 - low) * xs / (size - 1)) * _MEAN_LEVEL
def _add_white_noise(base: numpy.ndarray, relative_sigma: float, seed: int) -> numpy.ndarray:
"""平均に対する相対 sigma の白色ガウスノイズを載せる(画素間で独立=仕様の前提)."""
rng = numpy.random.default_rng(seed)
return base + rng.normal(0.0, relative_sigma * float(base.mean()), base.shape)
def _ratio(image: numpy.ndarray, **kwargs) -> float:
return calc_fullimage_min_max_ratio(image, **kwargs)["fullimage_min_max_ratio"]
def _pixel_ratio(image: numpy.ndarray) -> float:
return float(image.min()) / float(image.max())
def _annulus_ratio(image: numpy.ndarray) -> float:
"""輪帯平均(動径プロファイル 20 ビン)の min/max 比(SPEC_04 の既存指標)."""
return calc_radial_min_max_ratio(calc_radial_profile(image))["radial_min_max_ratio"]
# ---------------------------------------------------------------------------
# A. 一様分布とノイズ床
# ---------------------------------------------------------------------------
def test_uniform_image_without_noise_gives_ratio_exactly_one():
"""ノイズの無い一様像では ratio が厳密に 1.0 になること.
仕様: 一様像は min = max なので不均一ゼロ(ratio = 1.0).
残差も 0 なのでノイズ補正量も 0 になる.
"""
result = calc_fullimage_min_max_ratio(_uniform())
assert result["fullimage_min_max_ratio"] == pytest.approx(1.0, abs=1e-12)
assert result["fullimage_min"] == pytest.approx(result["fullimage_max"], rel=1e-12)
assert result["fullimage_noise_min"] == pytest.approx(0.0, abs=1e-9)
assert result["fullimage_noise_max"] == pytest.approx(0.0, abs=1e-9)
@pytest.mark.parametrize("seed", [0, 1, 2])
@pytest.mark.parametrize("relative_sigma", [0.05, 0.10])
def test_uniform_image_with_noise_stays_near_one(relative_sigma: float, seed: int):
"""一様像 + 白色ガウスノイズでも ratio が 1 の近傍に留まること(ノイズ床).
仕様: 一様像にノイズだけを載せたときの見かけの不均一(ノイズ床)は小さい
(fullimage.py docstring: ±0.004.ここでは合成ノイズのばらつきを見込み ±0.01 で検証).
"""
image = _add_white_noise(_uniform(), relative_sigma, seed)
assert abs(1.0 - _ratio(image)) < _NOISE_FLOOR_TOL
def test_uniform_image_with_noise_beats_pixel_min_max():
"""一様像 + ノイズで,画素単位 min/max 比より遥かに 1 に近いこと(本指標の存在意義).
仕様: 画素単位の min/max は MC ノイズの裾を拾って不均一を過大評価する
(SPEC_04「max/min 比 … 外れ値に敏感(MC ノイズ感度高)」).
"""
image = _add_white_noise(_uniform(), 0.10, seed=3)
corrected_error = abs(1.0 - _ratio(image))
pixel_error = abs(1.0 - _pixel_ratio(image))
assert corrected_error < _NOISE_FLOOR_TOL
assert pixel_error > 0.1
assert corrected_error < pixel_error
def test_noise_floor_grows_with_noise_level():
"""ノイズ床がノイズ量とともに増える(補正が効いてもゼロにはならない)こと.
仕様: 補正はノイズ場の極値の「期待値」を差し引くので残差は残る.
ノイズが小さいほど 1 に近い.
"""
low = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.02, seed=4)))
high = abs(1.0 - _ratio(_add_white_noise(_uniform(), 0.20, seed=4)))
assert low < high
# ---------------------------------------------------------------------------
# B. 同心円対称な分布 — 輪帯平均との一致(仕様の検証根拠)
# ---------------------------------------------------------------------------
def test_concentric_bump_agrees_with_annulus_average_without_noise():
"""同心円対称な分布では輪帯平均 min/max と ±0.01 以内で一致すること.
仕様: fullimage.py「同心円対称な軸上スイープ 20 条件で輪帯平均の min/max と
±0.01 以内で一致」.輪帯平均は同心円分布では正しい基準になる.
"""
bump = _concentric_bump()
assert abs(_ratio(bump) - _annulus_ratio(bump)) < _ANNULUS_AGREEMENT_TOL
def test_concentric_bump_agrees_with_annulus_average_with_noise():
"""同心円対称な分布 + MC 相当のノイズでも輪帯平均と ±0.01 以内で一致すること.
仕様: 上記と同じ.レンダ画像相当の弱いノイズ(相対 2%)を載せて確認する.
"""
noisy = _add_white_noise(_concentric_bump(), 0.02, seed=5)
assert abs(_ratio(noisy) - _annulus_ratio(noisy)) < _ANNULUS_AGREEMENT_TOL
@pytest.mark.parametrize("seed", [6, 7])
def test_concentric_bump_ratio_is_robust_to_noise(seed: int):
"""同じ分布ならノイズの有無で ratio がほとんど変わらないこと(ノイズ頑健性).
仕様: 平滑化で MC ノイズを落とし,残るノイズの寄与を差し引く定義なので,
ノイズを載せても指標値は動かないはず(ノイズ床の範囲内).
"""
bump = _concentric_bump()
noisy = _add_white_noise(bump, 0.05, seed)
assert abs(_ratio(noisy) - _ratio(bump)) < _NOISE_FLOOR_TOL
def test_concentric_bump_ratio_is_closer_to_truth_than_pixel_min_max():
"""同心円分布 + ノイズで,画素単位 min/max より真値に近いこと.
仕様: 画素単位はノイズの裾を拾うため真値から大きく外れる.
真値 = ノイズを載せる前の分布の min/max 比.
"""
bump = _concentric_bump()
true_ratio = float(bump.min()) / float(bump.max())
noisy = _add_white_noise(bump, 0.10, seed=8)
assert abs(_ratio(noisy) - true_ratio) < _SMOOTHING_BIAS_TOL
assert abs(_pixel_ratio(noisy) - true_ratio) > abs(_ratio(noisy) - true_ratio)
# ---------------------------------------------------------------------------
# C. 非対称な分布 — 輪帯平均が測れない不均一を測る(新指標の動機)
# ---------------------------------------------------------------------------
def test_linear_gradient_ratio_tracks_true_value_without_noise():
"""左右の線形勾配(真値 0.8)を平滑化バイアスの範囲で再現すること.
仕様: 画像全体の min/max を使うので,方位依存の不均一もそのまま反映される.
平滑化と境界の折り返しにより値は真値よりわずかに大きくなる(モジュール注記).
"""
gradient = _linear_gradient(low=0.8)
ratio = _ratio(gradient)
assert ratio == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL)
assert ratio >= 0.8 # 平滑化バイアスは必ず 1 に近づく向き
@pytest.mark.parametrize("seed", [9, 10])
def test_linear_gradient_with_noise_tracks_true_value(seed: int):
"""線形勾配 + ノイズでも真値 0.8 を平滑化バイアスの範囲で再現すること."""
noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed)
assert _ratio(noisy) == pytest.approx(0.8, abs=_SMOOTHING_BIAS_TOL)
def test_annulus_average_overestimates_uniformity_for_asymmetric_distribution():
"""輪帯平均は非対称な分布の不均一を過小評価(= 均一性を過大評価)すること.
仕様(新指標の動機): 輪帯平均は方位方向に平均するため,左右勾配のような
方位依存の不均一を打ち消してしまう(SPEC_04 の動径 min/max は同心円分布向け).
本指標は真値に近く,輪帯平均は 1 に張り付く.
"""
noisy = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=11)
true_ratio = 0.8
fullimage_error = abs(_ratio(noisy) - true_ratio)
annulus_error = abs(_annulus_ratio(noisy) - true_ratio)
assert fullimage_error < _SMOOTHING_BIAS_TOL
assert _annulus_ratio(noisy) > 0.95 # 輪帯平均は「ほぼ均一」と誤判定する
assert annulus_error > 5.0 * fullimage_error
def test_asymmetric_distribution_is_ranked_less_uniform_than_uniform_image():
"""非対称な分布は一様像より明確に低い ratio になること(順序の妥当性)."""
uniform_ratio = _ratio(_add_white_noise(_uniform(), 0.10, seed=12))
gradient_ratio = _ratio(_add_white_noise(_linear_gradient(low=0.8), 0.10, seed=12))
assert gradient_ratio < uniform_ratio - 0.1
# ---------------------------------------------------------------------------
# D. 入力バリデーション(すべて ValueError: .claude/rules/error-handling.md)
# ---------------------------------------------------------------------------
def test_three_dimensional_image_raises_value_error():
"""3D 配列(RGB 画像等)は ValueError になること.
仕様: image は 2D 輝度配列(ROI 抽出後の輝度)を渡す.
"""
with pytest.raises(ValueError, match="2D"):
calc_fullimage_min_max_ratio(numpy.zeros((64, 64, 3)) + 1.0)
def test_one_dimensional_image_raises_value_error():
"""1D 配列も 2D でないので ValueError になること."""
with pytest.raises(ValueError, match="2D"):
calc_fullimage_min_max_ratio(numpy.ones(1024))
@pytest.mark.parametrize(
"array",
[
numpy.full((128, 128), "a"),
numpy.ones((128, 128), dtype=bool),
numpy.full((128, 128), None, dtype=object),
],
ids=["str", "bool", "object"],
)
def test_non_numeric_dtype_raises_value_error(array: numpy.ndarray):
"""数値でない dtype は ValueError になること."""
with pytest.raises(ValueError, match="数値"):
calc_fullimage_min_max_ratio(array)
@pytest.mark.parametrize("sigma", [0.0, -1.0, -12.0])
def test_non_positive_sigma_raises_value_error(sigma: float):
"""sigma が 0 以下は ValueError になること(平滑化が定義されない)."""
with pytest.raises(ValueError, match="sigma"):
calc_fullimage_min_max_ratio(_uniform(), sigma=sigma)
def test_image_smaller_than_four_sigma_raises_value_error():
"""min(H, W) < 4σ の小さい画像は ValueError になること.
仕様: 平滑化カーネルが画像からはみ出すと端の値が境界条件に支配されるため下限を設ける.
"""
small = numpy.full((47, 256), _MEAN_LEVEL)
with pytest.raises(ValueError, match="小さすぎます"):
calc_fullimage_min_max_ratio(small, sigma=12.0)
def test_image_exactly_four_sigma_is_accepted():
"""min(H, W) == 4σ はちょうど下限で受理されること(境界値)."""
boundary = numpy.full((48, 256), _MEAN_LEVEL)
assert _ratio(boundary, sigma=12.0) == pytest.approx(1.0, abs=1e-12)
def test_all_zero_image_raises_value_error():
"""全ゼロ画像(補正後の最大値が 0 以下)は ValueError になること."""
with pytest.raises(ValueError, match="ノイズ補正後の最大値"):
calc_fullimage_min_max_ratio(numpy.zeros((_SIZE, _SIZE)))
def test_integer_image_is_accepted():
"""整数 dtype の輝度配列も受理されること(内部で float64 に変換)."""
integer_image = numpy.full((_SIZE, _SIZE), 100, dtype=numpy.int32)
assert _ratio(integer_image) == pytest.approx(1.0, abs=1e-12)
# ---------------------------------------------------------------------------
# E. 決定論性と seed
# ---------------------------------------------------------------------------
def test_same_input_and_seed_gives_bitwise_identical_result():
"""同じ入力・同じ seed なら完全に同じ値を返すこと(決定論的).
仕様: 乱数は関数内で default_rng(seed) を生成するため呼び出し順に依存しない.
"""
image = _add_white_noise(_concentric_bump(), 0.10, seed=13)
assert calc_fullimage_min_max_ratio(image) == calc_fullimage_min_max_ratio(image)
def test_result_does_not_depend_on_call_order():
"""間に別の画像の計算を挟んでも結果が変わらないこと(グローバル乱数に依存しない)."""
image = _add_white_noise(_concentric_bump(), 0.10, seed=14)
first = calc_fullimage_min_max_ratio(image)
calc_fullimage_min_max_ratio(_add_white_noise(_linear_gradient(), 0.2, seed=15))
numpy.random.seed(12345) # グローバル乱数状態を動かしても影響しないこと
assert calc_fullimage_min_max_ratio(image) == first
def test_changing_seed_changes_value_only_within_noise_floor():
"""seed を変えるとノイズ場推定が変わるが,差はノイズ床以内であること."""
image = _add_white_noise(_concentric_bump(), 0.10, seed=16)
base = _ratio(image, seed=0)
other = _ratio(image, seed=17)
assert base != other
assert abs(base - other) < _NOISE_FLOOR_TOL
def test_estimate_noise_extremes_is_deterministic_for_fixed_seed():
"""estimate_noise_extremes が同じ入力・同じ seed で同じ値を返すこと."""
image = _add_white_noise(_uniform(), 0.10, seed=18)
assert estimate_noise_extremes(image, sigma=12.0) == estimate_noise_extremes(image, sigma=12.0)
def test_estimate_noise_extremes_brackets_zero_for_noisy_image():
"""ノイズ場の極値が min < 0 < max であること(補正量の符号).
仕様: min 側は負,max 側は正の補正量として返す.
"""
image = _add_white_noise(_uniform(), 0.10, seed=19)
noise_min, noise_max = estimate_noise_extremes(image, sigma=12.0)
assert noise_min < 0.0 < noise_max
def test_estimate_noise_extremes_is_zero_for_noiseless_uniform_image():
"""ノイズの無い一様像ではノイズ場の極値が 0 になること(残差が 0)."""
noise_min, noise_max = estimate_noise_extremes(_uniform(), sigma=12.0)
assert noise_min == pytest.approx(0.0, abs=1e-9)
assert noise_max == pytest.approx(0.0, abs=1e-9)
def test_estimate_noise_extremes_grows_with_noise_level():
"""ノイズが大きいほどノイズ場の極値の絶対値が大きいこと."""
weak = estimate_noise_extremes(_add_white_noise(_uniform(), 0.05, seed=20), sigma=12.0)
strong = estimate_noise_extremes(_add_white_noise(_uniform(), 0.20, seed=20), sigma=12.0)
assert abs(weak[0]) < abs(strong[0])
assert abs(weak[1]) < abs(strong[1])
# ---------------------------------------------------------------------------
# F. 戻り値の整合とスケール不変性
# ---------------------------------------------------------------------------
def test_returns_exactly_five_documented_keys():
"""戻り値が仕様どおりの 5 キーだけを持つこと(metrics.csv の 5 列に対応)."""
result = calc_fullimage_min_max_ratio(_add_white_noise(_concentric_bump(), 0.10, seed=21))
assert set(result) == {
"fullimage_min_max_ratio",
"fullimage_min",
"fullimage_max",
"fullimage_noise_min",
"fullimage_noise_max",
}
assert all(isinstance(value, float) for value in result.values())
def test_returned_values_satisfy_definition():
"""戻り値が定義式どおりであること.
仕様: min = min G_sigma(I) - n_min,max = max G_sigma(I) - n_max,ratio = min / max.
"""
image = _add_white_noise(_concentric_bump(), 0.10, seed=22)
sigma = 12.0
result = calc_fullimage_min_max_ratio(image, sigma=sigma)
smooth = gaussian_filter(image, sigma=sigma)
assert result["fullimage_min"] == pytest.approx(
float(smooth.min()) - result["fullimage_noise_min"], rel=1e-12
)
assert result["fullimage_max"] == pytest.approx(
float(smooth.max()) - result["fullimage_noise_max"], rel=1e-12
)
assert result["fullimage_min_max_ratio"] == pytest.approx(
result["fullimage_min"] / result["fullimage_max"], rel=1e-12
)
assert result["fullimage_min"] <= result["fullimage_max"]
@pytest.mark.parametrize("gain", [0.25, 3.7, 1000.0])
def test_ratio_is_invariant_under_linear_gain(gain: float):
"""輝度を定数倍しても ratio が不変であること(露出・ゲインに依存しない指標).
仕様: min も max もノイズ補正量も線形量なので比は変わらない.
"""
image = _add_white_noise(_concentric_bump(), 0.10, seed=23)
base = calc_fullimage_min_max_ratio(image)
scaled = calc_fullimage_min_max_ratio(image * gain)
assert scaled["fullimage_min_max_ratio"] == pytest.approx(
base["fullimage_min_max_ratio"], rel=1e-9
)
assert scaled["fullimage_min"] == pytest.approx(base["fullimage_min"] * gain, rel=1e-9)
assert scaled["fullimage_max"] == pytest.approx(base["fullimage_max"] * gain, rel=1e-9)
assert scaled["fullimage_noise_min"] == pytest.approx(
base["fullimage_noise_min"] * gain, rel=1e-9
)
assert scaled["fullimage_noise_max"] == pytest.approx(
base["fullimage_noise_max"] * gain, rel=1e-9
)
def test_default_sigma_is_twelve():
"""既定の sigma が 12.0 であること(MetricsConfig.fullimage_sigma の既定と一致)."""
image = _add_white_noise(_concentric_bump(), 0.10, seed=24)
assert _ratio(image) == pytest.approx(_ratio(image, sigma=12.0), rel=1e-12)
def test_larger_sigma_smooths_more_and_raises_ratio():
"""sigma を大きくすると平滑化が強まり ratio が 1 に近づくこと(パラメータの効き)."""
image = _add_white_noise(_linear_gradient(low=0.8), 0.10, seed=25)
assert _ratio(image, sigma=4.0) < _ratio(image, sigma=32.0)
def test_more_shuffles_do_not_change_result_beyond_noise_floor():
"""n_shuffle を増やしてもノイズ床を超えて値が動かないこと(期待値の推定回数)."""
image = _add_white_noise(_concentric_bump(), 0.10, seed=26)
assert abs(_ratio(image, n_shuffle=1) - _ratio(image, n_shuffle=8)) < _NOISE_FLOOR_TOL
def test_input_image_is_not_modified():
"""入力配列が変更されないこと(純関数)."""
image = _add_white_noise(_concentric_bump(), 0.10, seed=27)
snapshot = image.copy()
calc_fullimage_min_max_ratio(image)
assert numpy.array_equal(image, snapshot)