"""scene/schema.py の OetfConfig / OetfExposureConfig / EvaluationConfig.oetf 仕様ベーステスト.
仕様出典:
- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(「手法訂正」「既知の差分 item 5」節)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation セクション)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針)
- src/sm_tias_lightsim/scene/schema.py(OetfConfig, OetfExposureConfig 定義)
テスト方針:
- Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは不要)
- ファイル I/O はモックしない (GUIDE_08)
- 後方互換性(oetf キーなし = OETF 無効)を重点的に検証する
"""
from __future__ import annotations
import copy
import pytest
from pydantic import ValidationError
from sm_tias_lightsim.scene.schema import (
EvaluationConfig,
OetfConfig,
OetfExposureConfig,
)
# ---------------------------------------------------------------------------
# 1. OetfExposureConfig — フィールド検証
# ---------------------------------------------------------------------------
def test_oetf_exposure_config_valid_anchor_mean():
"""mode=anchor_mean + target_mean で正常にパースされること.
仕様: OetfExposureConfig: mode=anchor_mean(デフォルト), target_mean が必須(schema.py)
"""
cfg = OetfExposureConfig(mode="anchor_mean", target_mean=146.7146)
assert cfg.mode == "anchor_mean"
assert cfg.target_mean == pytest.approx(146.7146)
def test_oetf_exposure_config_default_mode():
"""mode を省略したとき anchor_mean がデフォルトになること.
仕様: mode: Literal["anchor_mean"] = Field(default="anchor_mean", ...)(schema.py)
"""
cfg = OetfExposureConfig(target_mean=100.0)
assert cfg.mode == "anchor_mean"
def test_oetf_exposure_config_missing_target_mean_raises():
"""target_mean を省略したとき ValidationError が発生すること.
仕様: target_mean: float = Field(..., ...) — 必須フィールド(schema.py)
"""
with pytest.raises(ValidationError):
OetfExposureConfig(mode="anchor_mean")
def test_oetf_exposure_config_invalid_mode_raises():
"""未対応の mode(例: fixed_scale)で ValidationError が発生すること.
仕様: mode: Literal["anchor_mean"] — anchor_mean のみ(schema.py)
"""
with pytest.raises(ValidationError):
OetfExposureConfig(mode="fixed_scale", target_mean=100.0) # type: ignore[call-arg]
# ---------------------------------------------------------------------------
# 2. OetfConfig — フィールド検証
# ---------------------------------------------------------------------------
def test_oetf_config_defaults():
"""OetfConfig のデフォルト値を確認する.
仕様:
- enabled: bool = False(省略時は OETF 無効)
- transfer: Literal["srgb"] = "srgb"
- exposure: None(省略可)
- quantize_uint8: bool = True
(schema.py OetfConfig)
"""
cfg = OetfConfig()
assert cfg.enabled is False
assert cfg.transfer == "srgb"
assert cfg.exposure is None
assert cfg.quantize_uint8 is True
def test_oetf_config_enabled_true():
"""enabled=True で OetfConfig が生成されること."""
cfg = OetfConfig(enabled=True, exposure=OetfExposureConfig(target_mean=146.0))
assert cfg.enabled is True
def test_oetf_config_invalid_transfer_raises():
"""transfer に未対応の値(例: bt709)を指定したとき ValidationError が発生すること.
仕様: transfer: Literal["srgb"] — srgb のみ(schema.py)
"""
with pytest.raises(ValidationError):
OetfConfig(transfer="bt709") # type: ignore[call-arg]
def test_oetf_config_with_full_exposure():
"""exposure を含む完全な OetfConfig が正常にパースされること.
仕様: eval_baseline.yaml の oetf セクション相当
"""
exposure = OetfExposureConfig(mode="anchor_mean", target_mean=146.7146)
cfg = OetfConfig(
enabled=True,
transfer="srgb",
exposure=exposure,
quantize_uint8=True,
)
assert cfg.enabled is True
assert cfg.exposure is not None
assert cfg.exposure.target_mean == pytest.approx(146.7146)
assert cfg.quantize_uint8 is True
def test_oetf_config_quantize_uint8_false():
"""quantize_uint8=False が正常にパースされること.
仕様: quantize_uint8: bool = True(False も許容)(schema.py)
"""
cfg = OetfConfig(quantize_uint8=False)
assert cfg.quantize_uint8 is False
def test_oetf_config_is_frozen():
"""OetfConfig が frozen(イミュータブル)であること.
仕様: _FrozenBase → frozen=True(schema.py ConfigDict)
"""
cfg = OetfConfig()
with pytest.raises(ValidationError):
cfg.enabled = True # type: ignore[misc]
# ---------------------------------------------------------------------------
# 3. EvaluationConfig.oetf — 後方互換性
# ---------------------------------------------------------------------------
def _make_minimal_eval_data(**overrides: object) -> dict:
"""最小構成の EvaluationConfig データを返す."""
data: dict = {
"roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100},
}
data.update(overrides)
return data
def test_evaluation_config_oetf_none_when_omitted():
"""oetf キーを省略したとき EvaluationConfig.oetf が None になること(後方互換).
仕様: oetf: OetfConfig | None = Field(default=None, ...)
省略時は OETF 無効(linear のまま評価)(schema.py EvaluationConfig)
SPEC_03: eval_real.yaml には oetf を設定しないこと
"""
data = _make_minimal_eval_data()
cfg = EvaluationConfig.model_validate(data)
assert cfg.oetf is None
def test_evaluation_config_oetf_none_means_disabled():
"""oetf=None のとき OETF 適用がされない(enabled=False 相当)を確認すること.
仕様: 省略時は OETF 無効扱い(linear のまま評価)(schema.py / cli/evaluate.py)
"""
data = _make_minimal_eval_data(oetf=None)
cfg = EvaluationConfig.model_validate(data)
assert cfg.oetf is None
# cli/evaluate.py では `if oetf_cfg is not None and oetf_cfg.enabled:` でスキップ
# → oetf=None なら OETF は適用されない
oetf_cfg = cfg.oetf
is_applied = oetf_cfg is not None and oetf_cfg.enabled
assert is_applied is False
def test_evaluation_config_oetf_disabled_explicit():
"""oetf.enabled=False を明示したとき OETF が無効であること.
仕様: enabled: bool = False(省略時デフォルト)(schema.py)
"""
data = _make_minimal_eval_data(oetf={"enabled": False})
cfg = EvaluationConfig.model_validate(data)
assert cfg.oetf is not None
assert cfg.oetf.enabled is False
# OETF は適用されない
is_applied = cfg.oetf is not None and cfg.oetf.enabled
assert is_applied is False
def test_evaluation_config_oetf_enabled_with_exposure():
"""oetf.enabled=True かつ exposure を設定した EvaluationConfig が正常にパースされること.
仕様: eval_baseline.yaml の oetf セクション相当(SPEC_03「キャリブレーション経過」)
"""
data = _make_minimal_eval_data(
oetf={
"enabled": True,
"transfer": "srgb",
"exposure": {"mode": "anchor_mean", "target_mean": 146.7146},
"quantize_uint8": True,
}
)
cfg = EvaluationConfig.model_validate(data)
assert cfg.oetf is not None
assert cfg.oetf.enabled is True
assert cfg.oetf.transfer == "srgb"
assert cfg.oetf.exposure is not None
assert cfg.oetf.exposure.target_mean == pytest.approx(146.7146)
assert cfg.oetf.quantize_uint8 is True
def test_evaluation_config_without_oetf_is_backward_compatible():
"""既存の eval_real.yaml 相当(oetf キーなし)が後方互換で動くこと.
仕様: oetf フィールドを省略した既存 YAML は後方互換(OETF 無効扱い)で動作する
(schema.py EvaluationConfig docstring)
eval_real.yaml は実測 PNG 向けで OETF 設定なし(二重適用禁止)
"""
# eval_real.yaml 相当の最小データ(oetf キーなし)
data = {
"roi": {"type": "image_pixel", "x": 24, "y": 37, "width": 464, "height": 450},
"zones": {"enabled": True, "boundaries": [0.33, 0.66]},
"metrics": {"cov": True, "std": True, "max_min": True, "radial_profile": True},
}
cfg = EvaluationConfig.model_validate(data)
# oetf は None(後方互換)
assert cfg.oetf is None
# OETF は適用されない(実測 PNG への二重適用を防ぐ)
is_applied = cfg.oetf is not None and cfg.oetf.enabled
assert is_applied is False
def test_evaluation_config_oetf_invalid_transfer_raises():
"""oetf.transfer に未対応の値を指定したとき ValidationError が発生すること.
仕様: transfer: Literal["srgb"](schema.py OetfConfig)
"""
data = _make_minimal_eval_data(
oetf={"enabled": True, "transfer": "gamma22"}
)
with pytest.raises(ValidationError):
EvaluationConfig.model_validate(data)
def test_evaluation_config_oetf_invalid_exposure_mode_raises():
"""oetf.exposure.mode に未対応の値を指定したとき ValidationError が発生すること.
仕様: mode: Literal["anchor_mean"](schema.py OetfExposureConfig)
"""
data = _make_minimal_eval_data(
oetf={
"enabled": True,
"exposure": {"mode": "fixed_gain", "target_mean": 100.0},
}
)
with pytest.raises(ValidationError):
EvaluationConfig.model_validate(data)
def test_evaluation_config_oetf_exposure_missing_target_mean_raises():
"""oetf.exposure.target_mean を省略したとき ValidationError が発生すること.
仕様: target_mean: float = Field(...) — 必須(schema.py OetfExposureConfig)
"""
data = _make_minimal_eval_data(
oetf={
"enabled": True,
"exposure": {"mode": "anchor_mean"},
}
)
with pytest.raises(ValidationError):
EvaluationConfig.model_validate(data)
# ---------------------------------------------------------------------------
# 4. eval_baseline.yaml 相当の YAML ロード検証
# ---------------------------------------------------------------------------
def test_eval_baseline_yaml_equivalent_parses_correctly():
"""eval_baseline.yaml 相当のデータが正しくパースされること.
仕様: configs/eval_baseline.yaml の oetf セクション(SPEC_03 正規実装)
"""
# configs/eval_baseline.yaml に対応するデータ
data = {
"roi": {"type": "image_pixel", "x": 24, "y": 37, "width": 464, "height": 450},
"zones": {"enabled": True, "boundaries": [0.33, 0.66]},
"metrics": {"cov": True, "std": True, "max_min": True, "radial_profile": True},
"oetf": {
"enabled": True,
"transfer": "srgb",
"exposure": {"mode": "anchor_mean", "target_mean": 146.7146},
"quantize_uint8": True,
},
}
cfg = EvaluationConfig.model_validate(data)
assert cfg.oetf is not None
assert cfg.oetf.enabled is True
assert cfg.oetf.transfer == "srgb"
assert cfg.oetf.exposure is not None
assert cfg.oetf.exposure.mode == "anchor_mean"
assert cfg.oetf.exposure.target_mean == pytest.approx(146.7146)
assert cfg.oetf.quantize_uint8 is True
assert cfg.roi.x == 24
assert cfg.roi.y == 37
assert cfg.roi.width == 464
assert cfg.roi.height == 450