Newer
Older
SmTIAS-LightSim / tests / evaluation / test_dng_loader.py
"""evaluation/dng_loader.py および evaluation/radial.py の仕様ベーステスト.

仕様出典:
- docs/05_TECH/TECH_06_SmTIAS-Evaluation引き継ぎ受領.md
- docs/05_TECH/TECH_05_SmTIAS-Evaluation_DNG対応要求仕様.md
- .claude/rules/testing.md

テスト対象:
    A. load_image_dng() — DNG 読み込み I/F の仕様検証
    B. calc_radial_min_max_ratio() — 動径 min/max 比の仕様検証
    C. 検証 V4 — 実機 DNG での再現性統合テスト(TECH_06 §6 V4)

テスト方針:
    - 実 DNG ファイル必須のテスト(A5〜A8, V4)は DNG 未存在時にスキップ
    - A5〜A8 / V4 は @pytest.mark.slow を付ける(DNG 処理は数秒かかる)
    - ファイル I/O はモックしない(testing.md)
    - 乱数不使用のため seed 固定不要
"""

from __future__ import annotations

import json
from pathlib import Path

import numpy
import numpy.testing
import pytest

from sm_tias_lightsim.evaluation.dng_loader import load_image_dng
from sm_tias_lightsim.evaluation.radial import calc_radial_min_max_ratio, calc_radial_profile

# ---------------------------------------------------------------------------
# テストデータのパス定義
# ---------------------------------------------------------------------------

_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_QUANTITATIVE_DIR = _REPO_ROOT / "reference_measurement" / "quantitative"
_DNG_PATH = _QUANTITATIVE_DIR / "MiniTIAS_QM_20260601_124500.dng"
_META_PATH = _QUANTITATIVE_DIR / "MiniTIAS_QM_20260601_124500.meta.json"

_DNG_EXISTS = _DNG_PATH.exists()

# ---------------------------------------------------------------------------
# A. load_image_dng の単体仕様テスト
# ---------------------------------------------------------------------------


class TestLoadImageDngValidation:
    """A1〜A4: 引数バリデーション・ファイルエラーの仕様検証."""

    def test_a1_invalid_demosaic_raises_value_error(self, tmp_path):
        """A1: 未知の demosaic 引数は ValueError を送出すること.

        仕様: demosaic は "nearest" / "bilinear" / "ahd" のいずれか (TECH_06 §2)
        存在しないファイルパスでも引数バリデーションが先に実行される実装であれば
        FileNotFoundError になる可能性がある.実装上の順序を鑑み,demosaic エラーが
        送出されることを確認する(存在するファイルを用意して確実に demosaic チェックに到達させる).
        """
        # 空ファイルを用意して FileNotFoundError を回避する
        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"")
        # ValueError (demosaic) または ValueError (形式不正) のどちらかになりうる
        # 実装では demosaic チェックは exist チェックより後にある
        # まず存在しないパスで demosaic バリデーションが動作するか確認
        with pytest.raises(ValueError, match="demosaic"):
            load_image_dng(str(dummy), demosaic="invalid_algo")

    def test_a1_valid_demosaic_nearest_passes_validation(self, tmp_path):
        """A1: 有効な demosaic 引数 "nearest" はバリデーションを通過すること.

        バリデーション通過後に壊れたファイルで ValueError が出ることを確認する.
        注: pytest の tmp_path はテスト名からディレクトリ名を生成するため,
        エラーメッセージのパスにテスト名由来の文字列が含まれる可能性がある.
        そのため,エラーメッセージの内容チェックは行わず,
        ValueError が送出されること(= demosaic バリデーション通過後にエラー)のみ確認する.
        """
        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"")
        # bilinear(有効)と同様に ValueError が出ること(FileNotFoundError ではない)
        # = demosaic 引数が有効と判定された後,ファイル読み込み段階でエラーになる
        with pytest.raises(ValueError):
            load_image_dng(str(dummy), demosaic="nearest")

    def test_a1_valid_demosaic_ahd_passes_validation(self, tmp_path):
        """A1: 有効な demosaic 引数 "ahd" はバリデーションを通過すること."""
        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"")
        with pytest.raises(ValueError):
            load_image_dng(str(dummy), demosaic="ahd")

    def test_a2_invalid_output_format_raises_value_error(self, tmp_path):
        """A2: 未知の output_format 引数は ValueError を送出すること.

        仕様: output_format は "rgb_float32" / "luma_float32" / "rgb_uint8" のいずれか
        (dng_loader.py docstring, TECH_06 §2)
        """
        dummy = tmp_path / "dummy.dng"
        dummy.write_bytes(b"")
        with pytest.raises(ValueError, match="output_format"):
            load_image_dng(str(dummy), output_format="invalid_format")

    def test_a3_file_not_found_raises_file_not_found_error(self):
        """A3: 存在しないパスは FileNotFoundError を送出すること.

        仕様: FileNotFoundError: ファイルが存在しない場合 (dng_loader.py docstring)
        """
        nonexistent = "/nonexistent/path/to/file.dng"
        with pytest.raises(FileNotFoundError):
            load_image_dng(nonexistent)

    def test_a4_broken_dng_raises_value_error(self, tmp_path):
        """A4: 壊れた DNG ファイル(空ファイル)は ValueError を送出すること.

        仕様: ValueError: rawpy が DNG を読み込めなかった場合(形式が不正等)
        (dng_loader.py docstring)
        """
        broken = tmp_path / "broken.dng"
        broken.write_bytes(b"")
        with pytest.raises(ValueError):
            load_image_dng(str(broken))


@pytest.mark.slow
@pytest.mark.skipif(not _DNG_EXISTS, reason=f"実 DNG ファイルが存在しません: {_DNG_PATH}")
class TestLoadImageDngOutputFormats:
    """A5〜A8: 実 DNG ファイルを用いた出力形式の仕様検証.

    DNG ファイル: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng
    """

    def test_a5_rgb_float32_shape_dtype_range(self):
        """A5: rgb_float32 出力の形状 (H,W,3)・dtype float32・値域確認.

        仕様:
        - 形状 (H, W, 3)
        - dtype float32
        - 値域はおよそ [0, 1](飽和画素は 1.0 を超えることがある)
        (dng_loader.py docstring)
        """
        result = load_image_dng(str(_DNG_PATH), output_format="rgb_float32")
        assert result.ndim == 3
        assert result.shape[2] == 3
        assert result.dtype == numpy.float32
        # 値域: 下限は 0 以上,上限はおおよそ 1.x(飽和含む)
        assert float(result.min()) >= 0.0
        assert float(result.max()) <= 2.0  # 飽和含む上限の目安

    def test_a6_luma_float32_shape_dtype(self):
        """A6: luma_float32 出力の形状 (H,W)・dtype float32 確認.

        仕様:
        - 形状 (H, W)
        - dtype float32
        (dng_loader.py docstring)
        """
        result = load_image_dng(str(_DNG_PATH), output_format="luma_float32")
        assert result.ndim == 2
        assert result.dtype == numpy.float32

    def test_a7_rgb_uint8_shape_dtype_range(self):
        """A7: rgb_uint8 出力の形状 (H,W,3)・dtype uint8・値域 [0,255] 確認.

        仕様:
        - 形状 (H, W, 3)
        - dtype uint8
        - 値域 [0, 255]
        (dng_loader.py docstring)
        """
        result = load_image_dng(str(_DNG_PATH), output_format="rgb_uint8")
        assert result.ndim == 3
        assert result.shape[2] == 3
        assert result.dtype == numpy.uint8
        assert int(result.min()) >= 0
        assert int(result.max()) <= 255

    def test_a8_luma_rec709_weighting(self):
        """A8: luma_float32 が Rec.709 加重 (R×0.2126 + G×0.7152 + B×0.0722) と一致すること.

        仕様: luma = 0.2126*R + 0.7152*G + 0.0722*B (Rec.709 加重プロキシ)
        (TECH_06 §2.4, dng_loader.py _REC709_R/G/B 定数)

        実装では rgb_float32 から直接 Rec.709 加重を計算するため,
        相対誤差は理論上 0 (float32 の精度内)になる.
        """
        rgb = load_image_dng(str(_DNG_PATH), output_format="rgb_float32")
        luma = load_image_dng(str(_DNG_PATH), output_format="luma_float32")

        expected_luma = (
            0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1] + 0.0722 * rgb[:, :, 2]
        ).astype(numpy.float32)

        # float32 精度内で完全一致するはず (実装が単純な線形結合のため)
        numpy.testing.assert_allclose(luma, expected_luma, rtol=1e-5, atol=1e-6)

    def test_a5_landscape_orientation(self):
        """A5 補足: rgb_float32 は DNG センサ native の landscape 向き (幅 > 高さ) であること.

        仕様: DNG はセンサ native が landscape (TECH_06 §2.3)
        SH-02M: 3264×2448 (landscape) → (H=2448, W=3264) で読み込まれる
        """
        result = load_image_dng(str(_DNG_PATH), output_format="rgb_float32")
        height, width = result.shape[:2]
        assert width > height, f"landscape であること (width={width} > height={height})"

    def test_a6_luma_and_rgb_consistent_shape(self):
        """A6 補足: luma_float32 の (H,W) と rgb_float32 の (H,W) が一致すること."""
        rgb = load_image_dng(str(_DNG_PATH), output_format="rgb_float32")
        luma = load_image_dng(str(_DNG_PATH), output_format="luma_float32")
        assert rgb.shape[:2] == luma.shape


# ---------------------------------------------------------------------------
# B. calc_radial_min_max_ratio の単体仕様テスト
# ---------------------------------------------------------------------------


class TestCalcRadialMinMaxRatio:
    """B1〜B6: calc_radial_min_max_ratio の仕様検証."""

    def test_b5_return_dict_has_required_keys(self):
        """B5: 戻り値辞書に必須キーがすべて存在すること.

        仕様: 戻り値キーは "radial_min_max_ratio" / "radial_min_distance" /
              "radial_min" / "radial_max" (radial.py docstring)
        """
        profile = numpy.array([[0.1, 1.0], [0.5, 0.8], [0.9, 0.5]])
        result = calc_radial_min_max_ratio(profile)
        assert "radial_min_max_ratio" in result
        assert "radial_min_distance" in result
        assert "radial_min" in result
        assert "radial_max" in result

    def test_b1_monotonic_decreasing_profile(self):
        """B1: 単調減衰プロファイル (中心 1.0 → 周辺 0.5) での典型ケース.

        仕様:
        - radial_min_max_ratio ≈ 0.5
        - radial_min_distance は最外ビンの距離
        (radial.py docstring: 「中心明・周辺暗の単調減衰では最外輪帯/最内輪帯に一致」)
        """
        # 距離 [0.1, 0.5, 0.9],輝度 [1.0, 0.75, 0.5]
        profile = numpy.array([[0.1, 1.0], [0.5, 0.75], [0.9, 0.5]])
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min_max_ratio"] == pytest.approx(0.5, rel=1e-6)
        assert result["radial_min_distance"] == pytest.approx(0.9, rel=1e-6)
        assert result["radial_min"] == pytest.approx(0.5, rel=1e-6)
        assert result["radial_max"] == pytest.approx(1.0, rel=1e-6)

    def test_b2_uniform_profile_ratio_is_one(self):
        """B2: 均一プロファイル(全ビン同値)では radial_min_max_ratio == 1.0.

        仕様: 1 に近いほど均一 (radial.py docstring)
        """
        profile = numpy.array([[0.1, 1.0], [0.5, 1.0], [0.9, 1.0]])
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min_max_ratio"] == pytest.approx(1.0, rel=1e-6)

    def test_b3_all_zero_luminance_returns_inf(self):
        """B3: 全ビン 0 の場合,radial_min_max_ratio == float("inf").

        仕様: 最大輝度が 0 の場合は float("inf") (radial.py docstring)
        """
        profile = numpy.array([[0.1, 0.0], [0.5, 0.0], [0.9, 0.0]])
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min_max_ratio"] == float("inf")

    def test_b4_nan_bins_are_excluded(self):
        """B4: nan ビンを含む場合,非 nan ビンのみで min/max を算出すること.

        仕様: nan ビン(空ビン)は算出から除外される (radial.py docstring,移植時補強)
        """
        # nan ビンを含む 5 点プロファイル
        profile = numpy.array(
            [
                [0.1, 0.9],
                [0.3, numpy.nan],  # nan ビン
                [0.5, 0.7],
                [0.7, numpy.nan],  # nan ビン
                [0.9, 0.5],
            ]
        )
        result = calc_radial_min_max_ratio(profile)
        # 有効ビン: [0.9, 0.7, 0.5] → min=0.5, max=0.9
        assert result["radial_min"] == pytest.approx(0.5, rel=1e-6)
        assert result["radial_max"] == pytest.approx(0.9, rel=1e-6)
        assert result["radial_min_max_ratio"] == pytest.approx(0.5 / 0.9, rel=1e-6)

    def test_b4_all_nan_returns_nan_for_all_keys(self):
        """B4 補足: 全ビン nan の場合,全キーが nan を返すこと.

        仕様: 全 nan なら全キー nan (radial.py docstring)
        """
        profile = numpy.array(
            [
                [0.1, numpy.nan],
                [0.5, numpy.nan],
                [0.9, numpy.nan],
            ]
        )
        result = calc_radial_min_max_ratio(profile)
        import math

        assert math.isnan(result["radial_min_max_ratio"])
        assert math.isnan(result["radial_min_distance"])
        assert math.isnan(result["radial_min"])
        assert math.isnan(result["radial_max"])

    def test_b6_non_monotonic_center_peak(self):
        """B6: 非単調プロファイル(中央ピーク)での min/max 計算確認.

        仕様: 全 N 点の min/max を使うため非単調プロファイルにもロバスト (radial.py docstring)
        例: [0.5, 1.0, 0.5] → min=0.5, max=1.0, ratio=0.5
        """
        profile = numpy.array([[0.1, 0.5], [0.5, 1.0], [0.9, 0.5]])
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min"] == pytest.approx(0.5, rel=1e-6)
        assert result["radial_max"] == pytest.approx(1.0, rel=1e-6)
        assert result["radial_min_max_ratio"] == pytest.approx(0.5, rel=1e-6)

    def test_b1_min_distance_is_outer_bin_for_monotonic_decrease(self):
        """B1 補足: 単調減衰プロファイルで radial_min_distance が最外ビンの距離になること.

        仕様: 「中心明・周辺暗の単調減衰では最外輪帯/最内輪帯に一致」(radial.py docstring)
        """
        # ビン距離を 5 点で設定
        distances = [0.1, 0.3, 0.5, 0.7, 0.9]
        luminances = [1.0, 0.9, 0.8, 0.7, 0.6]
        profile = numpy.array(list(zip(distances, luminances, strict=True)))
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min_distance"] == pytest.approx(0.9, rel=1e-6)

    def test_b_single_valid_bin(self):
        """単一有効ビン(残りは nan)では min == max となり ratio == 1.0 になること."""
        profile = numpy.array(
            [
                [0.1, numpy.nan],
                [0.5, 0.7],
                [0.9, numpy.nan],
            ]
        )
        result = calc_radial_min_max_ratio(profile)
        assert result["radial_min"] == pytest.approx(0.7, rel=1e-6)
        assert result["radial_max"] == pytest.approx(0.7, rel=1e-6)
        assert result["radial_min_max_ratio"] == pytest.approx(1.0, rel=1e-6)


# ---------------------------------------------------------------------------
# C. 検証 V4 — 実機 DNG での再現性統合テスト (TECH_06 §6)
# ---------------------------------------------------------------------------


def _upsample_bilinear(grid: numpy.ndarray, shape: tuple[int, int]) -> numpy.ndarray:
    """(rows, cols) グリッドを画像サイズ shape へバイリニア補間する.

    run_uniformity_dng.py と同一実装.

    Args:
        grid: 補間元グリッド (rows x cols, float64).
        shape: 補間先の画像サイズ (height, width).

    Returns:
        補間後の配列 (height x width, float64).
    """
    rows, cols = grid.shape
    h, w = shape
    ys = numpy.linspace(0, rows - 1, h)
    xs = numpy.linspace(0, cols - 1, w)
    y0 = numpy.floor(ys).astype(int)
    y1 = numpy.minimum(y0 + 1, rows - 1)
    x0 = numpy.floor(xs).astype(int)
    x1 = numpy.minimum(x0 + 1, cols - 1)
    wy = (ys - y0)[:, None]
    wx = (xs - x0)[None, :]
    top = grid[y0][:, x0] * (1 - wx) + grid[y0][:, x1] * wx
    bot = grid[y1][:, x0] * (1 - wx) + grid[y1][:, x1] * wx
    return top * (1 - wy) + bot * wy


def _build_lsc_gain_rgb(meta_path: Path, shape: tuple[int, int]) -> numpy.ndarray:
    """meta.json の lscMap (rows, cols, 4) を (H, W, 3) の RGB ゲインへ補間する.

    4ch = [R, G_even, G_odd, B].G は G_even/G_odd の平均を用いる.
    run_uniformity_dng.py の lsc_gain_rgb 関数と同一ロジック.

    Args:
        meta_path: meta.json ファイルのパス.
        shape: 補間先の画像サイズ (height, width).

    Returns:
        RGB ゲインの配列 (H x W x 3, float64).
    """
    meta = json.loads(meta_path.read_text(encoding="utf-8"))
    rows = meta["lscMapRowCount"]
    cols = meta["lscMapColumnCount"]
    lsc_arr = numpy.array(meta["lscMap"], dtype=numpy.float64).reshape(rows, cols, 4)

    gr = _upsample_bilinear(lsc_arr[:, :, 0], shape)
    gg = _upsample_bilinear((lsc_arr[:, :, 1] + lsc_arr[:, :, 2]) / 2.0, shape)
    gb = _upsample_bilinear(lsc_arr[:, :, 3], shape)
    return numpy.stack([gr, gg, gb], axis=2)


def _build_roi_luminance_from_dng(
    dng_path: Path,
    meta_path: Path,
    roi_x: int,
    roi_y: int,
    roi_w: int,
    roi_h: int,
) -> numpy.ndarray:
    """DNG から portrait + LSC 補正 + ROI 抽出済みの 0〜255 linear 輝度配列を構築する.

    評価フロー(run_uniformity_dng.py と同一):
    1. load_image_dng(rgb_float32) で landscape linear raw 取得
    2. lscMap をバイリニア補間して RGB ゲインを生成,順方向乗算
    3. Rec.709 加重で輝度化
    4. ×255 で 0〜255 スケール化
    5. rot90(-1) で portrait 回転
    6. ROI 抽出

    Args:
        dng_path: DNG ファイルのパス.
        meta_path: meta.json ファイルのパス.
        roi_x, roi_y: ROI 左上座標(portrait 座標系).
        roi_w, roi_h: ROI の幅・高さ.

    Returns:
        ROI 輝度配列 (roi_h, roi_w, float64),0〜255 linear スケール.
    """
    # 1. DNG 読み込み (landscape, linear, output_color=raw)
    rgb = load_image_dng(str(dng_path), output_format="rgb_float32")

    # 2. LSC 順方向適用 (TECH_06 §2.1: 順方向が正しい)
    lsc_gain = _build_lsc_gain_rgb(meta_path, rgb.shape[:2])
    rgb_corrected = rgb * lsc_gain

    # 3. Rec.709 加重輝度化 (uniformity.py と同じ係数)
    luma = (
        0.2126 * rgb_corrected[:, :, 0].astype(numpy.float64)
        + 0.7152 * rgb_corrected[:, :, 1].astype(numpy.float64)
        + 0.0722 * rgb_corrected[:, :, 2].astype(numpy.float64)
    )

    # 4. 0〜255 スケール化 (handoff CSV の mean≈207 と整合)
    luma *= 255.0

    # 5. portrait 向き合わせ (TECH_06 §2.3: rot90(-1))
    luma_portrait = numpy.rot90(luma, -1)

    # 6. ROI 抽出
    return luma_portrait[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w]


# 白板 ROI 座標 (SmTIAS-Evaluation/config/roi_config.json より)
# minitias.whiteboard: {x: 539, y: 1015, width: 1400, height: 1409}
_WHITEBOARD_ROI = {"x": 539, "y": 1015, "width": 1400, "height": 1409}

# 期待値 (expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv)
_EXPECTED_UNIFORMITY = {
    "cov": 0.06953368075883541,
    "std": 14.450766770999131,
    "max_min_ratio": 1.6166283764597607,
    "mean": 207.82398707065312,
    "max": 239.95092166595663,
    "min": 148.42676595311465,
}

# 期待値 (TECH_06 §4 の空間解析指標)
_EXPECTED_SPATIAL = {
    "center_periphery_ratio": 1.0911,
    "gradient_magnitude": 8.35,
    "radial_min_max_ratio": 0.8600,
}

# 許容誤差
_UNIFORMITY_RTOL = 1e-2  # 相対誤差 1%(CSV 期待値との照合)
_SPATIAL_ATOL = 0.05  # 絶対誤差(TECH_06 §4 の丸め値との照合)


@pytest.mark.slow
@pytest.mark.skipif(not _DNG_EXISTS, reason=f"実 DNG ファイルが存在しません: {_DNG_PATH}")
class TestV4ReproducibilityWithRealDng:
    """検証 V4 — 実機 DNG での再現性統合テスト (TECH_06 §6).

    評価データ:
        DNG: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng
        meta.json: reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.meta.json
        期待値 CSV: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv

    評価条件:
        linear (output_color=raw) / portrait / LSC 補正あり / 白板 ROI
        (TECH_06 §4: 「暗闇 = SmTIAS のみ撮影」)
    """

    @pytest.fixture(scope="class")
    def roi_luminance(self):
        """クラス全体で共有する ROI 輝度配列(重い DNG 処理を 1 回だけ実行)."""
        roi = _WHITEBOARD_ROI
        return _build_roi_luminance_from_dng(
            _DNG_PATH,
            _META_PATH,
            roi_x=roi["x"],
            roi_y=roi["y"],
            roi_w=roi["width"],
            roi_h=roi["height"],
        )

    def test_v4_uniformity_cov(self, roi_luminance):
        """V4-1: CoV が期待値 ≈ 0.0695 と相対誤差 1% 以内で一致すること.

        仕様: CoV = std / mean (uniformity.py calc_uniformity)
        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["cov"]
        actual = results["cov"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"CoV: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_uniformity_std(self, roi_luminance):
        """V4-2: std が期待値 ≈ 14.45 と相対誤差 1% 以内で一致すること.

        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["std"]
        actual = results["std"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"std: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_uniformity_max_min_ratio(self, roi_luminance):
        """V4-3: max/min 比が期待値 ≈ 1.617 と相対誤差 1% 以内で一致すること.

        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["max_min_ratio"]
        actual = results["max_min_ratio"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"max_min_ratio: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_uniformity_mean(self, roi_luminance):
        """V4-4: mean が期待値 ≈ 207.82 と相対誤差 1% 以内で一致すること.

        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["mean"]
        actual = results["mean"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"mean: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_uniformity_max(self, roi_luminance):
        """V4-5: max が期待値 ≈ 239.95 と相対誤差 1% 以内で一致すること.

        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["max"]
        actual = results["max"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"max: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_uniformity_min(self, roi_luminance):
        """V4-6: min が期待値 ≈ 148.43 と相対誤差 1% 以内で一致すること.

        期待値出典: expected_results/MiniTIAS_QM_20260601_124500_uniformity.csv
        """
        from sm_tias_lightsim.uniformity import calc_uniformity

        results = calc_uniformity(roi_luminance)
        expected = _EXPECTED_UNIFORMITY["min"]
        actual = results["min"]
        assert actual == pytest.approx(expected, rel=_UNIFORMITY_RTOL), (
            f"min: expected={expected:.6f}, actual={actual:.6f}"
        )

    def test_v4_spatial_center_periphery_ratio(self, roi_luminance):
        """V4-7: 中心/周辺比が期待値 ≈ 1.0911 と絶対誤差 0.05 以内で一致すること.

        仕様: center_periphery_ratio = center_mean / periphery_mean (zones.py calc_zone_stats)
        期待値出典: TECH_06 §4
        """
        from sm_tias_lightsim.evaluation.zones import (
            calc_zone_stats,
            classify_zones,
            compute_normalized_distance_map,
        )

        h, w = roi_luminance.shape
        dmap = compute_normalized_distance_map(h, w)
        zone_map = classify_zones(dmap)
        zone_stats = calc_zone_stats(roi_luminance, zone_map)

        expected = _EXPECTED_SPATIAL["center_periphery_ratio"]
        actual = zone_stats["center_periphery_ratio"]
        assert abs(actual - expected) <= _SPATIAL_ATOL, (
            f"center_periphery_ratio: expected={expected:.4f}, actual={actual:.4f}"
        )

    def test_v4_spatial_gradient_magnitude(self, roi_luminance):
        """V4-8: 勾配 (%) が期待値 ≈ 8.35 と絶対誤差 0.05 以内で一致すること.

        仕様: gradient_magnitude = (center_mean - periphery_mean) / center_mean × 100
        (zones.py calc_zone_stats)
        期待値出典: TECH_06 §4
        """
        from sm_tias_lightsim.evaluation.zones import (
            calc_zone_stats,
            classify_zones,
            compute_normalized_distance_map,
        )

        h, w = roi_luminance.shape
        dmap = compute_normalized_distance_map(h, w)
        zone_map = classify_zones(dmap)
        zone_stats = calc_zone_stats(roi_luminance, zone_map)

        expected = _EXPECTED_SPATIAL["gradient_magnitude"]
        actual = zone_stats["gradient_magnitude"]
        assert abs(actual - expected) <= _SPATIAL_ATOL, (
            f"gradient_magnitude: expected={expected:.2f}%, actual={actual:.2f}%"
        )

    def test_v4_radial_min_max_ratio(self, roi_luminance):
        """V4-9: 動径 min/max 比が期待値 ≈ 0.8600 と絶対誤差 0.05 以内で一致すること.

        仕様: radial_min_max_ratio = min(profile) / max(profile),nan 除外
        (radial.py calc_radial_min_max_ratio)
        期待値出典: TECH_06 §4
        """
        profile = calc_radial_profile(roi_luminance)
        result = calc_radial_min_max_ratio(profile)

        expected = _EXPECTED_SPATIAL["radial_min_max_ratio"]
        actual = result["radial_min_max_ratio"]
        assert abs(actual - expected) <= _SPATIAL_ATOL, (
            f"radial_min_max_ratio: expected={expected:.4f}, actual={actual:.4f}"
        )

    def test_v4_lsc_forward_application_increases_mean(self, roi_luminance):
        """V4 補足: LSC 順方向適用によって ROI 平均輝度が増加すること.

        仕様: lscMap は gain > 1 の場合が多い(周辺を持ち上げる)ので,
        LSC 適用後の mean は未適用より高いかほぼ同等になるはずである.
        (TECH_06 §2.1: 「順方向適用が正しい」)

        この検証は LSC が「逆適用」でなく「順方向適用」されていることの副証となる.
        """
        # LSC なしの ROI を構築して比較
        rgb_no_lsc = load_image_dng(str(_DNG_PATH), output_format="rgb_float32")
        luma_no_lsc = (
            0.2126 * rgb_no_lsc[:, :, 0].astype(numpy.float64)
            + 0.7152 * rgb_no_lsc[:, :, 1].astype(numpy.float64)
            + 0.0722 * rgb_no_lsc[:, :, 2].astype(numpy.float64)
        ) * 255.0
        luma_portrait_no_lsc = numpy.rot90(luma_no_lsc, -1)
        roi_no_lsc = luma_portrait_no_lsc[
            _WHITEBOARD_ROI["y"] : _WHITEBOARD_ROI["y"] + _WHITEBOARD_ROI["height"],
            _WHITEBOARD_ROI["x"] : _WHITEBOARD_ROI["x"] + _WHITEBOARD_ROI["width"],
        ]

        mean_with_lsc = float(numpy.mean(roi_luminance))
        mean_without_lsc = float(numpy.mean(roi_no_lsc))

        # LSC ゲインはシェーディング補正(周辺を持ち上げる)なので,
        # 順方向適用では平均が上がるか同等のはず
        assert mean_with_lsc >= mean_without_lsc * 0.95, (
            f"LSC 順方向適用後の mean ({mean_with_lsc:.2f}) が "
            f"未適用 ({mean_without_lsc:.2f}) より著しく低い:逆適用になっていないか確認"
        )

    def test_v4_portrait_orientation_after_rot90(self, roi_luminance):
        """V4 補足: ROI が portrait 向き(height > width ではないが適切なサイズ)であること.

        仕様: rot90(-1) で portrait 向きに変換 (TECH_06 §2.3)
        白板 ROI は 1400×1409 (ほぼ正方形) なので向きの確認は ROI の親画像で行う.
        """
        # ROI の形状が指定通りであることを確認
        expected_h = _WHITEBOARD_ROI["height"]
        expected_w = _WHITEBOARD_ROI["width"]
        assert roi_luminance.shape == (expected_h, expected_w), (
            f"ROI shape: expected=({expected_h}, {expected_w}), actual={roi_luminance.shape}"
        )