"""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}"
)