diff --git a/CLAUDE.md b/CLAUDE.md index 247f2ef..fbbfd38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,13 @@ ## 開発進捗 -現在の進捗: フェーズ 1〜6 完了.フェーズ 7(実装開始)に移行. -次のアクション: `/implement Step 1: 均一性評価モジュールの移植`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1 完了. +次のアクション: `/implement Step 2: YAML スキーマ定義`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) + +実装済みモジュール: +- `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) +- `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 +- `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/src/sm_tias_lightsim/evaluation/__init__.py b/src/sm_tias_lightsim/evaluation/__init__.py new file mode 100644 index 0000000..e99c63a --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/__init__.py @@ -0,0 +1 @@ +# evaluation パッケージ — 空間分析・放射状プロファイル等の評価モジュール群 diff --git a/src/sm_tias_lightsim/evaluation/radial.py b/src/sm_tias_lightsim/evaluation/radial.py new file mode 100644 index 0000000..9987734 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/radial.py @@ -0,0 +1,54 @@ +"""放射状輝度プロファイルモジュール. + +ROI の中心からの正規化楕円距離を等分割したビンごとに平均輝度を集計し, +中心-周辺方向の輝度変化を連続的に把握するためのプロファイルを生成する. + +References: + [1] MiniTias-Evaluation SPEC_02 「放射状輝度プロファイル」節 + https://github.com/rintoHasegawa/MiniTias-Evaluation +""" + +import numpy + +from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + +def calc_radial_profile( + luminance: numpy.ndarray, + n_bins: int = 20, +) -> numpy.ndarray: + """輝度画像から放射状輝度プロファイルを算出する. + + 正規化楕円距離 $d \\in [0, 1]$ を `n_bins` 等分のビンに分割し,各ビン内の + 平均輝度を集計する [1].最外ビンは $d = 1.0$ の画素を欠損させないよう上端を含める. + + Args: + luminance: 輝度画像(shape: (H, W), dtype: float64). + n_bins: ビン数.デフォルトは 20. + + Returns: + 放射状プロファイル配列(shape: (n_bins, 2), dtype: numpy.float64). + 列 0: ビン中心距離(ビン両端の中点). + 列 1: そのビンの平均輝度.空ビンは numpy.nan. + """ + height, width = luminance.shape + distance_map = compute_normalized_distance_map(height, width) + + # [0, 1] を n_bins 等分するビン境界を生成する + bin_edges = numpy.linspace(0.0, 1.0, n_bins + 1) + bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 + + # numpy.digitize でビンインデックスを割り当てる(1 始まり, 右端開区間) + # 最外ビン(d == 1.0)が n_bins + 1 に割り振られないよう right=True を使用する + bin_indices = numpy.digitize(distance_map.ravel(), bin_edges, right=True) + flat_luminance = luminance.ravel() + + mean_luminance = numpy.full(n_bins, numpy.nan, dtype=numpy.float64) + for i in range(1, n_bins + 1): + mask = bin_indices == i + if numpy.any(mask): + mean_luminance[i - 1] = float(numpy.mean(flat_luminance[mask])) + # 空ビンは初期値 nan のまま維持する + + profile = numpy.column_stack([bin_centers, mean_luminance]) + return profile diff --git a/src/sm_tias_lightsim/evaluation/zones.py b/src/sm_tias_lightsim/evaluation/zones.py new file mode 100644 index 0000000..bb69855 --- /dev/null +++ b/src/sm_tias_lightsim/evaluation/zones.py @@ -0,0 +1,142 @@ +"""3 ゾーン空間分析モジュール. + +ROI を中心・中間・周辺の 3 同心楕円ゾーンに分割し,各ゾーンの輝度統計を算出する. + +References: + [1] MiniTias-Evaluation SPEC_02 「空間分析」節 + https://github.com/rintoHasegawa/MiniTias-Evaluation +""" + +import numpy + + +def compute_normalized_distance_map(height: int, width: int) -> numpy.ndarray: + """ROI の各ピクセルについて,中心からの正規化楕円距離マップを算出する. + + 正規化楕円距離 $d(x, y)$ を以下の式で定義する [1]: + + $$d(x, y) = \\sqrt{ + \\left(\\frac{x - c_x}{c_x}\\right)^2 + \\left(\\frac{y - c_y}{c_y}\\right)^2 + }$$ + + ここで $c_x = W/2$, $c_y = H/2$ は ROI の中心座標. + 楕円距離を用いることで,縦横比が異なる ROI でも等方的な評価が可能となる. + + 算出後,値域を $[0, 1]$ に再正規化する($d \\leftarrow d / d_{\\max}$). + + Args: + height: ROI の高さ(ピクセル数). + width: ROI の幅(ピクセル数). + + Returns: + 正規化距離マップ(shape: (height, width), dtype: numpy.float64). + 値域は [0, 1]. + """ + cx = width / 2.0 + cy = height / 2.0 + + # 各ピクセル座標をグリッドで生成する(ブロードキャストのため列ベクトル・行ベクトル) + ys = numpy.arange(height, dtype=numpy.float64).reshape(-1, 1) + xs = numpy.arange(width, dtype=numpy.float64).reshape(1, -1) + + distance_map = numpy.sqrt(((xs - cx) / cx) ** 2 + ((ys - cy) / cy) ** 2) + + # d_max で除算して [0, 1] に正規化する + d_max = float(numpy.max(distance_map)) + if d_max > 0.0: + distance_map = distance_map / d_max + + return distance_map + + +def classify_zones( + distance_map: numpy.ndarray, + boundaries: tuple[float, float] = (0.33, 0.66), +) -> numpy.ndarray: + """正規化距離マップを 3 ゾーンのラベルマップに変換する. + + SPEC_02 の 3 ゾーン分類に従い,以下の閾値でラベルを割り当てる [1]: + + | ゾーン | ラベル | 距離条件 | + |------------|--------|---------------------------------------| + | Center | 0 | $d < \\text{boundaries}[0]$ | + | Middle | 1 | $\\text{boundaries}[0] \\leq d < \\text{boundaries}[1]$ | + | Periphery | 2 | $d \\geq \\text{boundaries}[1]$ | + + Args: + distance_map: 正規化距離マップ(shape: (H, W), dtype: float). + boundaries: ゾーン境界の閾値ペア (lower, upper).デフォルトは (0.33, 0.66). + + Returns: + ゾーンラベルマップ(shape: (H, W), dtype: numpy.int8). + 値は 0(center), 1(middle), 2(periphery)のいずれか. + """ + lower, upper = boundaries + zone_map = numpy.full(distance_map.shape, 2, dtype=numpy.int8) + zone_map[distance_map < lower] = 0 + zone_map[(distance_map >= lower) & (distance_map < upper)] = 1 + return zone_map + + +def calc_zone_stats( + luminance: numpy.ndarray, + zone_map: numpy.ndarray, +) -> dict: + """各ゾーンの輝度統計と空間分析指標を算出する. + + ゾーン別に平均輝度・標準偏差を算出し,中心/周辺比と勾配量 (%) を導出する [1]: + + $$\\text{C/P Ratio} = \\frac{\\mu_{center}}{\\mu_{periphery}}$$ + + $$\\text{Gradient} = \\frac{\\mu_{center} - \\mu_{periphery}}{\\mu_{center}} \\times 100$$ + + Args: + luminance: 輝度画像(shape: (H, W), dtype: float64). + zone_map: ゾーンラベルマップ(shape: (H, W), dtype: int8). + 0=center, 1=middle, 2=periphery. + + Returns: + 空間分析指標を格納した辞書.以下の 8 キーを含む: + - center_mean: 中心ゾーンの平均輝度 + - center_std: 中心ゾーンの標準偏差 + - middle_mean: 中間ゾーンの平均輝度 + - middle_std: 中間ゾーンの標準偏差 + - periphery_mean: 周辺ゾーンの平均輝度 + - periphery_std: 周辺ゾーンの標準偏差 + - center_periphery_ratio: 中心/周辺比 (μ_center / μ_periphery) + - gradient_magnitude: 中心-周辺勾配量 (%) + + Raises: + ValueError: 周辺ゾーンの平均輝度が 0 の場合(ROI 設定の確認を促す). + """ + center_pixels = luminance[zone_map == 0] + middle_pixels = luminance[zone_map == 1] + periphery_pixels = luminance[zone_map == 2] + + center_mean = float(numpy.mean(center_pixels)) + center_std = float(numpy.std(center_pixels)) + middle_mean = float(numpy.mean(middle_pixels)) + middle_std = float(numpy.std(middle_pixels)) + periphery_mean = float(numpy.mean(periphery_pixels)) + periphery_std = float(numpy.std(periphery_pixels)) + + # 周辺輝度が 0 の場合は中心/周辺比が計算不能(ROI 設定の誤りを示す) + if periphery_mean == 0.0: + raise ValueError( + "周辺ゾーンの平均輝度が 0 のため中心/周辺比を計算できません." + "ROI の設定を確認してください." + ) + + center_periphery_ratio = center_mean / periphery_mean + gradient_magnitude = (center_mean - periphery_mean) / center_mean * 100.0 + + return { + "center_mean": center_mean, + "center_std": center_std, + "middle_mean": middle_mean, + "middle_std": middle_std, + "periphery_mean": periphery_mean, + "periphery_std": periphery_std, + "center_periphery_ratio": center_periphery_ratio, + "gradient_magnitude": gradient_magnitude, + } diff --git a/src/sm_tias_lightsim/uniformity.py b/src/sm_tias_lightsim/uniformity.py new file mode 100644 index 0000000..9779ffa --- /dev/null +++ b/src/sm_tias_lightsim/uniformity.py @@ -0,0 +1,79 @@ +"""輝度均一性評価モジュール. + +出典: https://github.com/rintoHasegawa/MiniTias-Evaluation +方針: PLAN_02 Step 1 の方針 (a) — コピーして独立運用 +ローカル参照ファイル: MiniTias-Evaluation/src/uniformity.py +""" + +import numpy + +# Rec.709 グレースケール変換係数 +# 参照: ITU-R BT.709 (https://www.itu.int/rec/R-REC-BT.709/en) +REC709_COEFF_R = 0.2126 +REC709_COEFF_G = 0.7152 +REC709_COEFF_B = 0.0722 + + +def to_grayscale(image: numpy.ndarray) -> numpy.ndarray: + """RGB 画像を Rec.709 輝度係数でグレースケール(輝度画像)に変換する. + + OpenCV の cvtColor ではなく Rec.709 の係数を明示的に使用する. + 参照: ITU-R BT.709 + + Args: + image: 入力 RGB 画像の NumPy 配列(H x W x 3, uint8). + + Returns: + 輝度画像の NumPy 配列(H x W, float64).値域は 0.0〜255.0. + """ + # float64 に変換してから係数を適用する(整数演算による丸め誤差を防ぐ) + img_float = image.astype(numpy.float64) + luminance = ( + REC709_COEFF_R * img_float[:, :, 0] + + REC709_COEFF_G * img_float[:, :, 1] + + REC709_COEFF_B * img_float[:, :, 2] + ) + return luminance + + +def calc_uniformity(luminance: numpy.ndarray) -> dict: + """輝度画像から均一性指標を算出する. + + 3 つの指標を算出する: + - CoV(変動係数): σ / μ — 小さいほど均一(無次元) + - std(標準偏差): 輝度のばらつきの絶対値 + - max_min_ratio(最大/最小比): max / min — 極端なムラの検出 + + Args: + luminance: 輝度画像の NumPy 配列(H x W, float64). + + Returns: + 均一性指標を格納した辞書.キーは "cov", "std", "max_min_ratio", + "mean", "max", "min". + + Raises: + ValueError: 輝度画像の最小値が 0 の場合(最大/最小比が計算不能). + """ + mean = float(numpy.mean(luminance)) + std = float(numpy.std(luminance)) + lmax = float(numpy.max(luminance)) + lmin = float(numpy.min(luminance)) + + # CoV: 相対的なばらつきの指標 + cov = std / mean if mean != 0.0 else float("inf") + + # 最大/最小比: 極端なムラを検出する指標 + if lmin == 0.0: + raise ValueError( + "輝度の最小値が 0 のため最大/最小比を計算できません.ROI の設定を確認してください." + ) + max_min_ratio = lmax / lmin + + return { + "cov": cov, + "std": std, + "max_min_ratio": max_min_ratio, + "mean": mean, + "max": lmax, + "min": lmin, + } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..db21735 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +"""共通フィクスチャ. + +全テストモジュールから利用可能な pytest フィクスチャを定義する. +""" + +import numpy +import pytest + + +@pytest.fixture +def uniform_luminance_100x100() -> numpy.ndarray: + """全画素が 100.0 の (100, 100) 輝度配列.""" + return numpy.full((100, 100), 100.0, dtype=numpy.float64) + + +@pytest.fixture +def radial_gradient_luminance_100x100() -> numpy.ndarray: + """中心が明るく周辺が暗い放射グラジェント輝度配列 (100, 100). + + 正規化楕円距離 d を用いて 200 - 100 * d で合成する. + 中心 (d=0) で 200,周辺 (d=1) で 100 程度となる. + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + d = compute_normalized_distance_map(100, 100) + return (200.0 - 100.0 * d).astype(numpy.float64) + + +@pytest.fixture +def red_pixel() -> numpy.ndarray: + """純赤 (255, 0, 0) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[255, 0, 0]]], dtype=numpy.uint8) + + +@pytest.fixture +def green_pixel() -> numpy.ndarray: + """純緑 (0, 255, 0) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[0, 255, 0]]], dtype=numpy.uint8) + + +@pytest.fixture +def blue_pixel() -> numpy.ndarray: + """純青 (0, 0, 255) の 1x1 uint8 RGB 画像.""" + return numpy.array([[[0, 0, 255]]], dtype=numpy.uint8) diff --git a/tests/evaluation/__init__.py b/tests/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/evaluation/__init__.py diff --git a/tests/evaluation/test_radial.py b/tests/evaluation/test_radial.py new file mode 100644 index 0000000..88ac990 --- /dev/null +++ b/tests/evaluation/test_radial.py @@ -0,0 +1,137 @@ +"""evaluation/radial.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md「放射状輝度プロファイル」節 +""" + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.radial import calc_radial_profile + +# --------------------------------------------------------------------------- +# 出力の形状・型テスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_shape_and_dtype(uniform_luminance_100x100): + """デフォルト n_bins=20 で出力 shape が (20, 2), dtype が float64 になること. + + 仕様: 出力 20×2 の配列 (列 0=ビン中心距離, 列 1=平均輝度), dtype=numpy.float64 + (SPEC_02 放射状輝度プロファイル節) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + assert profile.shape == (20, 2) + assert profile.dtype == numpy.float64 + + +def test_radial_profile_default_bins(uniform_luminance_100x100): + """デフォルト引数で 20 ビンのプロファイルが返ること.""" + profile = calc_radial_profile(uniform_luminance_100x100) + assert profile.shape[0] == 20 + + +def test_radial_profile_custom_bins(uniform_luminance_100x100): + """n_bins=10 で shape が (10, 2) になること. + + 仕様: n_bins でビン数を指定可能 (radial.py docstring) + """ + profile = calc_radial_profile(uniform_luminance_100x100, n_bins=10) + assert profile.shape == (10, 2) + + +# --------------------------------------------------------------------------- +# ビン中心距離のテスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_distances_increasing(uniform_luminance_100x100): + """列 0 (ビン中心距離) が単調増加であること. + + 仕様: 中心から周辺にかけての輝度変化を連続的に把握 (SPEC_02) + ビン中心は linspace(0, 1, n_bins+1) の隣接平均なので常に単調増加する. + """ + profile = calc_radial_profile(uniform_luminance_100x100) + distances = profile[:, 0] + diffs = numpy.diff(distances) + assert numpy.all(diffs > 0) + + +def test_radial_profile_distances_in_unit_range(uniform_luminance_100x100): + """列 0 の値が [0, 1] 範囲内であること. + + 仕様: 正規化楕円距離 d ∈ [0, 1] を n_bins 等分 (SPEC_02) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + distances = profile[:, 0] + assert float(numpy.min(distances)) >= 0.0 + assert float(numpy.max(distances)) <= 1.0 + + +def test_radial_profile_bin_centers_correct(): + """ビン中心距離が linspace(0, 1, n_bins+1) の隣接平均と一致すること.""" + n_bins = 5 + luminance = numpy.full((100, 100), 50.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=n_bins) + + bin_edges = numpy.linspace(0.0, 1.0, n_bins + 1) + expected_centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0 + assert numpy.allclose(profile[:, 0], expected_centers) + + +# --------------------------------------------------------------------------- +# 輝度値のテスト +# --------------------------------------------------------------------------- + + +def test_radial_profile_uniform_luminance(uniform_luminance_100x100): + """一様輝度では全ビンの平均輝度が同じ値 (NaN ビンを除く) になること. + + 仕様: 一様照明では放射プロファイルが平坦 (SPEC_02) + """ + profile = calc_radial_profile(uniform_luminance_100x100) + mean_values = profile[:, 1] + valid_mask = ~numpy.isnan(mean_values) + assert numpy.any(valid_mask), "有効なビンが 1 つ以上存在すること" + valid_values = mean_values[valid_mask] + assert numpy.allclose(valid_values, valid_values[0]) + + +def test_radial_profile_includes_d_one(): + """d == 1.0 の画素が最後のビンに含まれること(最外ビンが NaN にならない). + + 仕様: 最外ビンは上端を含める (d=1.0 の画素を欠損させない) (SPEC_02) + 100×100 の画像では隅のピクセルが d=1.0 付近に存在する. + """ + luminance = numpy.full((100, 100), 128.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=20) + last_bin_value = profile[-1, 1] + assert not numpy.isnan(last_bin_value), "最外ビンが NaN であってはならない" + + +def test_radial_profile_radial_gradient(radial_gradient_luminance_100x100): + """中心が明るいグラジェント輝度では, 先頭ビンの輝度が末尾ビンより大きいこと. + + 仕様: 中心-周辺方向の輝度変化を連続的に把握 (SPEC_02) + 合成輝度: 200 - 100 * d なので中心 (d≈0) が周辺 (d≈1) より明るい. + """ + profile = calc_radial_profile(radial_gradient_luminance_100x100) + mean_values = profile[:, 1] + valid_mask = ~numpy.isnan(mean_values) + valid_values = mean_values[valid_mask] + assert valid_values[0] > valid_values[-1] + + +def test_radial_profile_empty_bins_are_nan(): + """画素が存在しない空ビンは numpy.nan になること. + + 仕様: 空ビンは numpy.nan (radial.py docstring) + 非常に小さい画像 (3×3) で n_bins=20 のとき,距離が到達しないビンが発生しうる. + """ + luminance = numpy.full((3, 3), 50.0, dtype=numpy.float64) + profile = calc_radial_profile(luminance, n_bins=20) + # 3×3 画像で 20 ビンのうち少なくとも 1 ビンが空である可能性を確認 + # (実際にどのビンが空になるかは距離マップに依存するが,nan があることを検証) + # ここでは nan が存在する可能性を確認するだけとし,存在する場合は値が nan であること + mean_values = profile[:, 1] + for v in mean_values: + assert v == pytest.approx(float(v)) or numpy.isnan(v) diff --git a/tests/evaluation/test_zones.py b/tests/evaluation/test_zones.py new file mode 100644 index 0000000..a2f7408 --- /dev/null +++ b/tests/evaluation/test_zones.py @@ -0,0 +1,276 @@ +"""evaluation/zones.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md「空間分析」節 +""" + +import numpy +import pytest + +from sm_tias_lightsim.evaluation.zones import ( + calc_zone_stats, + classify_zones, + compute_normalized_distance_map, +) + +# --------------------------------------------------------------------------- +# compute_normalized_distance_map のテスト +# --------------------------------------------------------------------------- + + +def test_distance_map_shape_and_dtype(): + """出力 shape が (height, width), dtype が float64 であること. + + 仕様: 正規化距離マップ (shape: (height, width), dtype: numpy.float64) + """ + dm = compute_normalized_distance_map(50, 80) + assert dm.shape == (50, 80) + assert dm.dtype == numpy.float64 + + +def test_distance_map_normalized_to_unit_range(): + """値域が [0, 1] であり最大値が 1.0 になること. + + 仕様: 値域を [0, 1] に再正規化 (d <- d / d_max) (SPEC_02) + """ + dm = compute_normalized_distance_map(60, 80) + assert float(numpy.min(dm)) >= 0.0 + assert float(numpy.max(dm)) == pytest.approx(1.0) + + +def test_distance_map_center_is_zero(): + """偶数サイズにおける中心付近のピクセルの値がほぼ 0 になること. + + 仕様: d(cx, cy) = 0 (中心から距離 0) (SPEC_02) + 偶数サイズの場合 cx=W/2, cy=H/2 は 0-indexed グリッドの内側に落ちないため, + 中心寄りのピクセル (cy-1, cx-1) が最小値 (ほぼ 0) であることを確認する. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + center_r, center_c = h // 2 - 1, w // 2 - 1 + assert dm[center_r, center_c] == pytest.approx(0.0, abs=0.05) + + +def test_distance_map_minimum_is_zero_even_size(): + """偶数サイズの ROI で最小値が 0 になること. + + 仕様: cx = W/2, cy = H/2 で計算するため,偶数サイズでは arange(W) の + index W/2 が cx に一致し,両軸の距離が 0 になるピクセルが存在する. + 例: W=100, cx=50.0 -> x=50 のとき (50-50)/50 = 0. + """ + dm = compute_normalized_distance_map(100, 100) + assert float(numpy.min(dm)) == pytest.approx(0.0, abs=1e-10) + + +def test_distance_map_row_symmetry_around_center(): + """同一行内で cx を挟む対称ピクセルが同じ距離をもつこと. + + 仕様: cx = W/2 なので x と (W - x) は cx について等距離(dx は逆符号). + ただし偶数 W での index 0 は鏡像 W が存在しないため, + index 1..W-1 の範囲で成立する対称ペアを確認する. + 例: W=100, cx=50 -> x=1 と x=99 (100-1) は同じ |x-cx| をもつ. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + cy_row = h // 2 # 中心行(y距離が最小の行) + # x=1 と x=99 のペア(|1-50|=49, |99-50|=49) + assert dm[cy_row, 1] == pytest.approx(dm[cy_row, w - 1], rel=1e-6) + # x=10 と x=90 のペア + assert dm[cy_row, 10] == pytest.approx(dm[cy_row, w - 10], rel=1e-6) + + +def test_distance_map_increases_from_center(): + """距離マップが中心から外側へ向かって単調増加すること(行方向の確認). + + 仕様: d(x,y) は ROI 中心からの距離を表す (SPEC_02) + 中心列(x=cx 付近)で上から下へ増加する. + """ + h, w = 100, 100 + dm = compute_normalized_distance_map(h, w) + # 中心列(x=50)で上半分の各行が下の行より大きいこと(中心に近いほど小さい) + cx = w // 2 + center_col = dm[:, cx] + # 中心(行 50)から外側(行 0 または行 99)に向かって増加する + mid = h // 2 + assert center_col[mid] <= center_col[mid - 1] + assert center_col[mid] <= center_col[mid + 1] + + +# --------------------------------------------------------------------------- +# classify_zones のテスト +# --------------------------------------------------------------------------- + + +def test_classify_zones_thresholds(): + """boundaries=(0.33, 0.66) で 0/1/2 に正しく分類されること. + + 仕様: + - d < 0.33 → Center (0) + - 0.33 <= d < 0.66 → Middle (1) + - 0.66 <= d → Periphery (2) + (SPEC_02 3 ゾーン分類節) + """ + # 境界値を含む代表的な距離値で検証する + dm = numpy.array([[0.0, 0.32, 0.33, 0.65, 0.66, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm) + + assert zones[0, 0] == 0 # d=0.0 -> center + assert zones[0, 1] == 0 # d=0.32 -> center (< 0.33) + assert zones[0, 2] == 1 # d=0.33 -> middle (>= 0.33) + assert zones[0, 3] == 1 # d=0.65 -> middle (< 0.66) + assert zones[0, 4] == 2 # d=0.66 -> periphery (>= 0.66) + assert zones[0, 5] == 2 # d=1.0 -> periphery + + +def test_classify_zones_dtype_int8(): + """classify_zones の出力 dtype が numpy.int8 であること. + + 仕様: ゾーンラベルマップ (dtype: numpy.int8) (zones.py docstring) + """ + dm = numpy.array([[0.0, 0.5, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm) + assert zones.dtype == numpy.int8 + + +def test_classify_zones_custom_boundaries(): + """任意の境界 (0.5, 0.8) でも正しく分類されること.""" + dm = numpy.array([[0.0, 0.49, 0.5, 0.79, 0.8, 1.0]], dtype=numpy.float64) + zones = classify_zones(dm, boundaries=(0.5, 0.8)) + + assert zones[0, 0] == 0 # d=0.0 -> center + assert zones[0, 1] == 0 # d=0.49 -> center (< 0.5) + assert zones[0, 2] == 1 # d=0.5 -> middle (>= 0.5) + assert zones[0, 3] == 1 # d=0.79 -> middle (< 0.8) + assert zones[0, 4] == 2 # d=0.8 -> periphery (>= 0.8) + assert zones[0, 5] == 2 # d=1.0 -> periphery + + +def test_classify_zones_shape_preserved(): + """入力と同じ shape を持つゾーンマップが返ること.""" + dm = numpy.zeros((30, 40), dtype=numpy.float64) + zones = classify_zones(dm) + assert zones.shape == (30, 40) + + +# --------------------------------------------------------------------------- +# calc_zone_stats のテスト +# --------------------------------------------------------------------------- + + +def test_calc_zone_stats_keys_present(uniform_luminance_100x100): + """戻り値 dict に 8 キーが全て揃っていること. + + 仕様: 8 キー = center/middle/periphery × mean/std + center_periphery_ratio + + gradient_magnitude (SPEC_02 空間分析指標節) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(uniform_luminance_100x100, zone_map) + + required_keys = { + "center_mean", + "center_std", + "middle_mean", + "middle_std", + "periphery_mean", + "periphery_std", + "center_periphery_ratio", + "gradient_magnitude", + } + assert set(result.keys()) == required_keys + + +def test_calc_zone_stats_uniform_image(uniform_luminance_100x100): + """一様輝度では全ゾーン平均が同値, gradient==0, center_periphery_ratio==1 になること. + + 仕様: + - C/P Ratio = μ_center / μ_periphery = 1.0 (均一時) + - Gradient = (μ_center - μ_periphery) / μ_center * 100 = 0% (均一時) + (SPEC_02 空間分析指標節) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(uniform_luminance_100x100, zone_map) + + assert result["center_mean"] == pytest.approx(100.0) + assert result["middle_mean"] == pytest.approx(100.0) + assert result["periphery_mean"] == pytest.approx(100.0) + assert result["center_std"] == pytest.approx(0.0) + assert result["middle_std"] == pytest.approx(0.0) + assert result["periphery_std"] == pytest.approx(0.0) + assert result["gradient_magnitude"] == pytest.approx(0.0) + assert result["center_periphery_ratio"] == pytest.approx(1.0) + + +def test_calc_zone_stats_radial_gradient(radial_gradient_luminance_100x100): + """中心が明るい合成輝度で gradient > 0, center_periphery_ratio > 1 になること. + + 仕様: + - gradient > 0 → 中心から周辺に向かって輝度が減衰 (SPEC_02) + - C/P Ratio > 1.0 → 中心が明るい (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + result = calc_zone_stats(radial_gradient_luminance_100x100, zone_map) + + assert result["gradient_magnitude"] > 0.0 + assert result["center_periphery_ratio"] > 1.0 + + +def test_calc_zone_stats_gradient_formula(): + """gradient_magnitude が (μ_center - μ_periphery) / μ_center * 100 の公式に従うこと. + + 仕様: Gradient = (μ_center - μ_periphery) / μ_center * 100 (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + # 中心ゾーン (zone==0) だけ値を変える + luminance[zone_map == 0] = 150.0 + + result = calc_zone_stats(luminance, zone_map) + expected_gradient = ( + (result["center_mean"] - result["periphery_mean"]) / result["center_mean"] * 100.0 + ) + assert result["gradient_magnitude"] == pytest.approx(expected_gradient) + + +def test_calc_zone_stats_cp_ratio_formula(): + """center_periphery_ratio が μ_center / μ_periphery の公式に従うこと. + + 仕様: C/P Ratio = μ_center / μ_periphery (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + luminance[zone_map == 0] = 150.0 + + result = calc_zone_stats(luminance, zone_map) + expected_ratio = result["center_mean"] / result["periphery_mean"] + assert result["center_periphery_ratio"] == pytest.approx(expected_ratio) + + +def test_calc_zone_stats_raises_on_zero_periphery(): + """周辺ゾーンの平均輝度が 0 で ValueError が発生すること. + + 仕様: Y_min = 0 の場合は計算不能 (SPEC_02) + """ + from sm_tias_lightsim.evaluation.zones import compute_normalized_distance_map + + dm = compute_normalized_distance_map(100, 100) + zone_map = classify_zones(dm) + # 周辺 (zone==2) を 0 にする + luminance = numpy.full((100, 100), 100.0, dtype=numpy.float64) + luminance[zone_map == 2] = 0.0 + + with pytest.raises(ValueError): + calc_zone_stats(luminance, zone_map) diff --git a/tests/test_uniformity.py b/tests/test_uniformity.py new file mode 100644 index 0000000..469140e --- /dev/null +++ b/tests/test_uniformity.py @@ -0,0 +1,180 @@ +"""uniformity.py の仕様ベーステスト. + +仕様出典: MiniTias-Evaluation/SPEC_02_照明均一性評価アルゴリズム.md +""" + +import numpy +import pytest + +from sm_tias_lightsim.uniformity import ( + REC709_COEFF_B, + REC709_COEFF_G, + REC709_COEFF_R, + calc_uniformity, + to_grayscale, +) + +# --------------------------------------------------------------------------- +# REC709 定数のテスト +# --------------------------------------------------------------------------- + + +def test_rec709_coefficients_sum_to_one(): + """REC709 3 係数の和が 1.0 であること. + + 仕様: Y = 0.2126 R + 0.7152 G + 0.0722 B (SPEC_02 輝度変換節) + """ + total = REC709_COEFF_R + REC709_COEFF_G + REC709_COEFF_B + assert abs(total - 1.0) < 1e-10 + + +def test_rec709_coefficient_r_value(): + """REC709 R 係数が 0.2126 であること.""" + assert REC709_COEFF_R == pytest.approx(0.2126) + + +def test_rec709_coefficient_g_value(): + """REC709 G 係数が 0.7152 であること.""" + assert REC709_COEFF_G == pytest.approx(0.7152) + + +def test_rec709_coefficient_b_value(): + """REC709 B 係数が 0.0722 であること.""" + assert REC709_COEFF_B == pytest.approx(0.0722) + + +# --------------------------------------------------------------------------- +# to_grayscale のテスト +# --------------------------------------------------------------------------- + + +def test_grayscale_uses_rec709_coefficients_red(red_pixel): + """純赤入力で期待輝度が REC709_COEFF_R * 255 になること. + + 仕様: Y = 0.2126 R + 0.7152 G + 0.0722 B (SPEC_02) + """ + result = to_grayscale(red_pixel) + expected = REC709_COEFF_R * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_uses_rec709_coefficients_green(green_pixel): + """純緑入力で期待輝度が REC709_COEFF_G * 255 になること.""" + result = to_grayscale(green_pixel) + expected = REC709_COEFF_G * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_uses_rec709_coefficients_blue(blue_pixel): + """純青入力で期待輝度が REC709_COEFF_B * 255 になること.""" + result = to_grayscale(blue_pixel) + expected = REC709_COEFF_B * 255.0 + assert result[0, 0] == pytest.approx(expected) + + +def test_grayscale_returns_float64(red_pixel): + """to_grayscale の出力 dtype が numpy.float64 であること. + + 仕様: 出力値域 0.0〜255.0, float64 (SPEC_02 輝度変換節) + """ + result = to_grayscale(red_pixel) + assert result.dtype == numpy.float64 + + +def test_grayscale_preserves_shape_hxw(): + """入力 (H, W, 3) に対して出力が (H, W) になること. + + 仕様: 出力は H x W の輝度画像 (SPEC_02) + """ + image = numpy.zeros((7, 13, 3), dtype=numpy.uint8) + result = to_grayscale(image) + assert result.shape == (7, 13) + + +def test_grayscale_white_pixel_equals_255(): + """白 (255, 255, 255) の輝度が 255 になること. + + Rec.709 係数の和が 1.0 なので白は 255 のまま. + """ + white = numpy.array([[[255, 255, 255]]], dtype=numpy.uint8) + result = to_grayscale(white) + assert result[0, 0] == pytest.approx(255.0) + + +def test_grayscale_black_pixel_equals_zero(): + """黒 (0, 0, 0) の輝度が 0 になること.""" + black = numpy.array([[[0, 0, 0]]], dtype=numpy.uint8) + result = to_grayscale(black) + assert result[0, 0] == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# calc_uniformity のテスト +# --------------------------------------------------------------------------- + + +def test_calc_uniformity_uniform_image(uniform_luminance_100x100): + """全画素が同値の輝度マップでは cov==0, std==0, max_min_ratio==1.0 になること. + + 仕様: + - CoV = σ / μ (SPEC_02 均一性指標節) + - Max/Min Ratio = max / min; 理想的な均一照明では 1.0 (SPEC_02) + """ + result = calc_uniformity(uniform_luminance_100x100) + assert result["cov"] == pytest.approx(0.0) + assert result["std"] == pytest.approx(0.0) + assert result["max_min_ratio"] == pytest.approx(1.0) + assert result["mean"] == pytest.approx(100.0) + assert result["max"] == pytest.approx(100.0) + assert result["min"] == pytest.approx(100.0) + + +def test_calc_uniformity_known_values(): + """既知の配列 [50, 100, 150] で mean/std/cov が正しい値になること. + + 手計算: + - mean = 100.0 + - std = sqrt(((50-100)^2 + (100-100)^2 + (150-100)^2) / 3) + = sqrt((2500 + 0 + 2500) / 3) = sqrt(5000/3) ≈ 40.8248... + - cov = std / mean ≈ 0.40825... + - max_min_ratio = 150 / 50 = 3.0 + """ + luminance = numpy.array([[50.0, 100.0, 150.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + + expected_mean = 100.0 + expected_std = numpy.std(numpy.array([50.0, 100.0, 150.0])) + expected_cov = expected_std / expected_mean + expected_ratio = 150.0 / 50.0 + + assert result["mean"] == pytest.approx(expected_mean) + assert result["std"] == pytest.approx(float(expected_std)) + assert result["cov"] == pytest.approx(expected_cov) + assert result["max_min_ratio"] == pytest.approx(expected_ratio) + assert result["max"] == pytest.approx(150.0) + assert result["min"] == pytest.approx(50.0) + + +def test_calc_uniformity_result_contains_required_keys(): + """戻り値 dict が必須キーを全て含むこと.""" + luminance = numpy.array([[10.0, 20.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + assert set(result.keys()) == {"cov", "std", "max_min_ratio", "mean", "max", "min"} + + +def test_calc_uniformity_raises_on_zero_min(): + """最小値が 0 の輝度マップで ValueError が発生すること. + + 仕様: Y_min = 0 の場合は計算不能 (SPEC_02 均一性指標節) + """ + luminance = numpy.array([[0.0, 100.0, 200.0]], dtype=numpy.float64) + with pytest.raises(ValueError): + calc_uniformity(luminance) + + +def test_calc_uniformity_cov_formula(): + """CoV が σ / μ の公式に従うこと (SPEC_02).""" + luminance = numpy.array([[80.0, 100.0, 120.0]], dtype=numpy.float64) + result = calc_uniformity(luminance) + expected_cov = result["std"] / result["mean"] + assert result["cov"] == pytest.approx(expected_cov)