Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / evaluation / oetf.py
"""sRGB OETF(光電変換関数)順方向適用モジュール.

linear radiance → sRGB 符号化空間への標準変換を提供する.
シミュの linear 値を実測 sRGB PNG と同じドメインへ揃えることで,
均一性指標の直接比較を正当化する(SPEC_03「手法訂正」参照).

無彩色シーン(LED・材料が RGB 等価)では luminance への直接 OETF 適用は
チャネル別適用後 Rec.709 luma と厳密一致する.これはシミュのシーンが
R=G=B の条件を満たすためであり,本モジュールはその前提を明記した上で
luminance への直接適用パスを提供する(参照実装: output/sweep_reflectance/apply_oetf.py).

将来カラー LED・色付き材料を扱う場合は,cli/evaluate.py での
「チャネル別 OETF → Rec.709 luma」順を使うこと(そちらが一般式).
"""

from __future__ import annotations

import numpy


def srgb_oetf(c: numpy.ndarray) -> numpy.ndarray:
    """sRGB OETF(IEC 61966-2-1).線形 [0, 1] → 符号化 [0, 1] へ変換する.

    入力は [0, 1] にクリップしてから変換する.
    トイ部(c ≤ 0.0031308)は線形 12.92c,それ以外は 1.055·c^(1/2.4) − 0.055.

    Args:
        c: 線形 radiance 配列.値域は任意(内部でクリップ).

    Returns:
        sRGB 符号化値(float64, 値域 [0, 1]).

    References:
        IEC 61966-2-1:1999 — sRGB
    """
    c = numpy.clip(c, 0.0, 1.0)
    lo = c <= 0.0031308
    out = numpy.empty_like(c)
    out[lo] = 12.92 * c[lo]
    out[~lo] = 1.055 * numpy.power(c[~lo], 1.0 / 2.4) - 0.055
    return out


def solve_exposure(
    lin_roi: numpy.ndarray,
    target_mean: float,
) -> tuple[float, float]:
    """OETF 後 ROI mean が target_mean になる線形スケールを対数二分で求める.

    OETF は単調増加 → encode_with_exposure(lin_roi, scale).mean() も scale に
    対して単調増加 → 二分法で一意解が存在する.
    対数二分(初期区間 [1e-12, 1e6],200 回反復)で収束する.

    実測 mean=146.71 は ROI 上の uint8 mean であるため,露出ソルバも
    ROI 切り出し後の配列 lin_roi を渡すこと.

    Args:
        lin_roi: linear 輝度 ROI 配列(H x W, float64).
        target_mean: 目標 ROI mean(uint8 スケール [0, 255]).

    Returns:
        (scale, clipped_fraction) のタプル.
        scale: 線形ゲイン(lin_roi に掛ける値).
        clipped_fraction: OETF 入力が 1.0 を超えるピクセルの割合 [0, 1].
    """
    lo, hi = 1e-12, 1e6
    for _ in range(200):
        mid = (lo * hi) ** 0.5  # 対数二分(乗法的中点)
        m = (srgb_oetf(lin_roi * mid) * 255.0).mean()
        if m < target_mean:
            lo = mid
        else:
            hi = mid
    scale = (lo * hi) ** 0.5
    clipped = float(numpy.mean(lin_roi * scale >= 1.0))
    return scale, clipped


def apply_srgb_oetf(
    lin: numpy.ndarray,
    scale: float,
    *,
    quantize_uint8: bool = True,
) -> numpy.ndarray:
    """linear 配列に露出スケールを掛けて sRGB OETF を適用し,[0, 255] に変換する.

    内部演算は float64 で行い,quantize_uint8=True のとき
    ``np.clip(np.round(x * 255), 0, 255)`` で uint8 量子化する(実測 PNG 同等).

    Args:
        lin: linear 輝度配列(任意次元 float64).
        scale: 線形ゲイン(solve_exposure で取得した値を渡す).
        quantize_uint8: True のとき uint8 量子化を適用し float64 で返す.

    Returns:
        OETF 適用後の配列(float64, quantize_uint8=True なら [0, 255] の整数値).
    """
    encoded = srgb_oetf(lin * scale) * 255.0
    if quantize_uint8:
        encoded = numpy.clip(numpy.round(encoded), 0.0, 255.0)
    return encoded