Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / evaluation / radial.py
@rintoHasegawa rintoHasegawa on 1 Jun 5 KB [add] S2 DNG ローダと新指標を MiniTias-Evaluation からコピー — evaluation/dng_loader.py 新規 (handoff_smtias_lightsim/src/dng_loader.py の完全コピー+ docstring 差替: rawpy で 10-bit BGGR Bayer 展開・output_color=raw で linear カラーマトリクス非適用・gamma=(1,1)+no_auto_bright+use_camera_wb=False+user_wb=[1,1,1,1] で linear raw 保持・output_format rgb_float32/luma_float32/rgb_uint8 切替・Rec.709 luma は output_color=raw 時の固定加重プロキシで既存 PNG 経路と互換).evaluation/radial.py に calc_radial_min_max_ratio をマージ追加 (MiniTias-Evaluation の src/analysis/spatial.py から.動径 min/max 比 = 20 点輪帯平均の min/max で 1.0 で均一.画素単位 max/min よりノイズロバスト.nan ビン除外を移植時補強).pyproject.toml + uv.lock に rawpy>=0.21.0 追加 (実 rawpy==0.27.0).新規テスト 32 件 (tests/evaluation/test_dng_loader.py 341 行) で仕様 A1-D 全項目カバー.既存 537 + 新規 32 = 569 件全通過 (slow 除外).検証 V4 完全合格: 実 DNG (reference_measurement/quantitative/MiniTIAS_QM_20260601_124500.dng, 16MB, 暗闇撮影) を LSC 順方向適用 + portrait 向き合わせ (rot90 -1) + 白板 ROI で評価し,CoV/std/max-min/mean/max/min 6 件が相対誤差 < 1e-6 で MiniTias-Evaluation 期待値完全一致.中心/周辺比 1.0911 vs 1.091143・勾配 8.35 vs 8.352987・動径 min/max 0.8600 vs 0.859962 も絶対誤差 < 0.003.Refactorer 判断: _apply_srgb_oetf は既存 evaluation/oetf.py:apply_srgb_oetf と実装重複だがコピー方針優先で放置 (3 箇所以上の違反基準未満・用途異なる).副次変更: roi.py / test_oetf.py / test_visualize.py の軽微な lint 修正.handoff_smtias_lightsim/ ディレクトリは S3 完了後に削除予定で本コミットには含めない.CLAUDE.md 実装済みモジュール一覧に dng_loader.py と radial.py の calc_radial_min_max_ratio 追記+進捗欄に S2 完了 (V4 完全一致含む) を追記
"""放射状輝度プロファイルモジュール.

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


def calc_radial_min_max_ratio(radial_profile: numpy.ndarray) -> dict:
    """動径プロファイルの最小/最大輝度比を算出する.

    MiniTias-Evaluation の ``src/analysis/spatial.py:calc_radial_min_max_ratio`` を
    SmTIAS-LightSim にマージしたもの.

    ``calc_radial_profile`` で得た N 点の輪帯平均輝度のうち,最小値を最大値で割った比
    (0〜1)を返す.1 に近いほど均一で,中心明・周辺暗の単調減衰では
    「最外輪帯 / 最内輪帯」に一致する.全 N 点の min/max を使うため,
    非単調プロファイル(例: 中央暗・周辺明)にもロバストに対応する.
    ``nan`` ビン(空ビン)は無視する.

    References:
        MiniTias-Evaluation ``src/analysis/spatial.py:calc_radial_min_max_ratio``
        [TEST_05](docs/06_TEST/TEST_05_PNG_DNG評価比較.md) — 新指標の物理的意味

    Args:
        radial_profile: 放射状プロファイル配列(shape: (N, 2), dtype: numpy.float64).
            列 0 = ビン中心距離, 列 1 = 平均輝度.``calc_radial_profile`` の戻り値を渡す.
            空ビン(nan)は算出から除外される.

    Returns:
        動径 min/max 比を格納した辞書.キーは:

        - ``"radial_min_max_ratio"`` (float): 最小輝度 / 最大輝度(0〜1).
          最大輝度が 0 の場合は ``float("inf")``.
        - ``"radial_min_distance"`` (float): 最小輝度ビンの正規化距離.
        - ``"radial_min"`` (float): プロファイルの最小輝度値(nan 除外後).
        - ``"radial_max"`` (float): プロファイルの最大輝度値(nan 除外後).
    """
    luminances = radial_profile[:, 1]
    distances = radial_profile[:, 0]

    # 空ビン(nan)を除外して集計する
    valid_mask = ~numpy.isnan(luminances)
    valid_luminances = luminances[valid_mask]
    valid_distances = distances[valid_mask]

    if valid_luminances.size == 0:
        # 有効ビンがない場合は nan で返す
        return {
            "radial_min_max_ratio": float("nan"),
            "radial_min_distance": float("nan"),
            "radial_min": float("nan"),
            "radial_max": float("nan"),
        }

    radial_min = float(numpy.min(valid_luminances))
    radial_max = float(numpy.max(valid_luminances))

    # max が 0 のときはゼロ除算を回避して inf を返す(zones.py の既存パターンに倣う)
    radial_min_max_ratio = radial_min / radial_max if radial_max != 0.0 else float("inf")

    # 最小輝度をとるビンの正規化距離(補助情報)
    min_idx = int(numpy.argmin(valid_luminances))
    radial_min_distance = float(valid_distances[min_idx])

    return {
        "radial_min_max_ratio": radial_min_max_ratio,
        "radial_min_distance": radial_min_distance,
        "radial_min": radial_min,
        "radial_max": radial_max,
    }