Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / evaluation / shape_metrics.py
"""輝度分布「形」評価指標モジュール.

現状の均一性指標(CoV / 勾配 / C/P / 動径 min/max)は不均一性の絶対量しか測れず,
Y 軸単調勾配と中央ピーク楕円のように「形が構造的に異なる」パターンを区別できない.
本モジュールは「形」を直接評価する 5 つの純関数を提供する.

References:
    [1] TECH_11_形状指標の見直しと新指標導入
        docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md
"""

from __future__ import annotations

import numpy


def calc_intensity_centroid(
    image: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度重心を計算する.

    各ピクセルの輝度値を重みとして ROI 内の重心座標を算出する.
    重心が ROI 中心から大きくずれていれば,均一でない輝度分布や
    非対称な光源配置を示す.

    Args:
        image: 2D (H, W) 輝度マップ.負値は ReLU で 0 に.
        mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象.
            None なら全画素を対象とする.

    Returns:
        以下のキーを持つ辞書:
        - ``"centroid_x_px"`` (float): ROI 座標系での重心 X(列方向,左端 = 0).
        - ``"centroid_y_px"`` (float): ROI 座標系での重心 Y(行方向,上端 = 0).
        - ``"centroid_x_norm"`` (float): ROI 中心を原点とした正規化 X(-1〜+1).
        - ``"centroid_y_norm"`` (float): ROI 中心を原点とした正規化 Y(-1〜+1).
        - ``"centroid_offset_norm"`` (float): 正規化座標での中心からの距離 √(x²+y²).

    Raises:
        ValueError: mask の shape が image と異なる場合.
        ValueError: 有効ピクセルの輝度総和が 0 の場合(重心計算不能).
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します(ndim={image.ndim})"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape

    # 負値は 0 に(ReLU)
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)

    total_weight = float(img.sum())
    if total_weight == 0.0:
        msg = "有効ピクセルの輝度総和が 0 のため重心を計算できません"
        raise ValueError(msg)

    # グリッド座標(ピクセル中心)
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)

    centroid_x_px = float((img * xs).sum() / total_weight)
    centroid_y_px = float((img * ys).sum() / total_weight)

    # ROI 中心を原点とした正規化座標
    cx = (w - 1) / 2.0
    cy = (h - 1) / 2.0
    # 正規化: ±1 が ROI の端になるよう cx/cy で割る
    centroid_x_norm = (centroid_x_px - cx) / (cx if cx > 0.0 else 1.0)
    centroid_y_norm = (centroid_y_px - cy) / (cy if cy > 0.0 else 1.0)
    centroid_offset_norm = float(numpy.sqrt(centroid_x_norm**2 + centroid_y_norm**2))

    return {
        "centroid_x_px": centroid_x_px,
        "centroid_y_px": centroid_y_px,
        "centroid_x_norm": centroid_x_norm,
        "centroid_y_norm": centroid_y_norm,
        "centroid_offset_norm": centroid_offset_norm,
    }


def calc_angular_radial_decomposition(
    image: numpy.ndarray,
    n_radial_bins: int = 20,
    n_angular_bins: int = 36,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度マップを極座標で角度/半径方向に分解する.

    画像を画像中心(ROI 中心)を原点とする極座標 (r, θ) に変換し,
    放射方向の分散と角度方向の分散を分離することで「形」の方向性を数値化する.

    アルゴリズム:
        1. 画像中心を原点とする極座標に変換
        2. (r_bin, θ_bin) のグリッドで輝度を分類 → I[r_bin, θ_bin]
        3. angular_variance = mean_r[ Var_θ[I | r] ]  各 r での角度方向 var の半径平均
        4. radial_variance  = Var_r[ mean_θ[I | r] ]  角度平均した放射プロファイルの var
        5. angular_ratio = angular_variance / (angular_variance + radial_variance)

    物理的意味:
        - ``angular_ratio ≈ 0``: 完全同心円(各 r での角度変化が小 → 中央ピーク楕円に近い)
        - ``angular_ratio ≈ 0.5``: 方向性ありの典型的非均一パターン
        - ``angular_ratio ≈ 1``: 完全角度依存(強い方向性ムラ)
        - 中央ピーク楕円: angular_ratio が小さい(0.1〜0.3)
        - Y 単調勾配: angular_ratio が中程度(0.3〜0.5)

    Notes:
        TECH_11 で示した「Y 単調勾配 → angular_ratio 0.3〜0.5」は
        実シミュ画像(ノイズ込み・有限解像度)での典型値である.
        純粋な Y 軸方向均一勾配(横縞画像)を合成すると,極座標のビン集計上
        角度方向変動が支配的になるため angular_ratio は 0.5 を超える場合がある.
        「Y 軸勾配は同心円より angular_ratio が高い」という方向性は
        理論・合成画像ともに成立する.

    Args:
        image: 2D (H, W) 輝度マップ.負値は 0 として扱う.
        n_radial_bins: 半径方向のビン数.デフォルト 20.
        n_angular_bins: 角度方向のビン数(360° を等分).デフォルト 36(各 10°).
        mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象.
            None なら全画素を対象とする.

    Returns:
        以下のキーを持つ辞書:
        - ``"angular_variance"`` (float): 角度方向分散の平均
        - ``"radial_variance"`` (float): 放射方向分散
        - ``"angular_ratio"`` (float): 0〜1 の形指標(0=同心円,1=角度依存).
          完全均一画像(全 variance = 0)では NaN を返す.

    Raises:
        ValueError: mask の shape が image と異なる場合.
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します(ndim={image.ndim})"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, numpy.nan)

    # 画像中心を原点とした座標グリッド
    cy = (h - 1) / 2.0
    cx = (w - 1) / 2.0
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1) - cy
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1) - cx

    # 極座標変換
    r = numpy.sqrt(xs**2 + ys**2)
    theta = numpy.arctan2(ys, xs)  # [-π, +π]

    # r を [0, r_max] の n_radial_bins ビンに割り当て
    r_max = float(r.max())
    if r_max == 0.0:
        return {
            "angular_variance": 0.0,
            "radial_variance": 0.0,
            "angular_ratio": float("nan"),
        }

    r_norm = r / r_max  # [0, 1]

    # θ を [-π, π] → [0, 2π] へシフトして [0, n_angular_bins) に bin 化
    theta_shifted = theta + numpy.pi  # [0, 2π]
    r_bin_indices = numpy.minimum(
        (r_norm * n_radial_bins).astype(int), n_radial_bins - 1
    )
    theta_bin_indices = numpy.minimum(
        (theta_shifted / (2.0 * numpy.pi) * n_angular_bins).astype(int),
        n_angular_bins - 1,
    )

    # 各 (r_bin, θ_bin) の輝度平均を集計
    # grid[r_bin, theta_bin] = 輝度平均(空セルは nan)
    grid = numpy.full((n_radial_bins, n_angular_bins), numpy.nan, dtype=numpy.float64)
    r_flat = r_bin_indices.ravel()
    t_flat = theta_bin_indices.ravel()
    v_flat = img.ravel()

    # 有効なピクセルだけを集計する
    valid_mask_flat = ~numpy.isnan(v_flat)
    valid_r = r_flat[valid_mask_flat]
    valid_t = t_flat[valid_mask_flat]
    valid_v = v_flat[valid_mask_flat]

    # numpy.add.at で各セルに値を累積し,カウントで割って平均を得る
    sum_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.float64)
    count_grid = numpy.zeros((n_radial_bins, n_angular_bins), dtype=numpy.int64)
    numpy.add.at(sum_grid, (valid_r, valid_t), valid_v)
    numpy.add.at(count_grid, (valid_r, valid_t), 1)

    valid_cells = count_grid > 0
    grid[valid_cells] = sum_grid[valid_cells] / count_grid[valid_cells]

    # angular_variance: 各 r_bin について角度方向の分散を算出し,r 方向に平均
    angular_var_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64)
    for r_idx in range(n_radial_bins):
        row = grid[r_idx, :]
        valid_vals = row[~numpy.isnan(row)]
        if valid_vals.size >= 2:
            angular_var_per_r[r_idx] = float(numpy.var(valid_vals, ddof=0))
        elif valid_vals.size == 1:
            angular_var_per_r[r_idx] = 0.0
        # else: 有効セルなし → nan のまま

    valid_ang_var = angular_var_per_r[~numpy.isnan(angular_var_per_r)]
    angular_variance = float(numpy.mean(valid_ang_var)) if valid_ang_var.size > 0 else 0.0

    # radial_variance: 各 r_bin について角度方向の平均を算出し,その r 方向の分散
    radial_mean_per_r = numpy.full(n_radial_bins, numpy.nan, dtype=numpy.float64)
    for r_idx in range(n_radial_bins):
        row = grid[r_idx, :]
        valid_vals = row[~numpy.isnan(row)]
        if valid_vals.size > 0:
            radial_mean_per_r[r_idx] = float(numpy.mean(valid_vals))

    valid_radial_means = radial_mean_per_r[~numpy.isnan(radial_mean_per_r)]
    radial_variance = (
        float(numpy.var(valid_radial_means, ddof=0)) if valid_radial_means.size >= 2 else 0.0
    )

    total_variance = angular_variance + radial_variance
    if total_variance == 0.0:
        angular_ratio = float("nan")
    else:
        angular_ratio = angular_variance / total_variance

    return {
        "angular_variance": angular_variance,
        "radial_variance": radial_variance,
        "angular_ratio": angular_ratio,
    }


def calc_ssim(
    image: numpy.ndarray,
    reference: numpy.ndarray,
    mask: numpy.ndarray | None = None,
    data_range: float | None = None,
) -> float:
    """構造的類似度 (Structural Similarity Index) を算出する.

    ``skimage.metrics.structural_similarity`` を使用する.
    形(空間構造)の類似性を 0〜1 で数値化する.1.0 で完全一致.

    Args:
        image: 評価対象 2D 輝度マップ(H, W).
        reference: 参照画像 2D 輝度マップ(image と同 shape).
        mask: 2D bool マスク.True ピクセルのみを計算対象とするため,
            mask 外を 0 で埋めた上で full=True モードの SSIM 全体値を用いる.
        data_range: 輝度値の最大レンジ(``image.max() - reference.max()`` の max).
            None のとき ``max(image.max(), reference.max())`` を使う.

    Returns:
        SSIM 値(float, 0〜1).

    Raises:
        ValueError: image と reference の shape が異なる場合.
        ValueError: mask の shape が image と異なる場合.
        ImportError: scikit-image がインストールされていない場合.
    """
    try:
        from skimage.metrics import structural_similarity  # noqa: PLC0415
    except ImportError as exc:
        msg = (
            "calc_ssim には scikit-image が必要です."
            "pip install scikit-image>=0.21.0 でインストールしてください."
        )
        raise ImportError(msg) from exc

    if image.ndim != 2 or reference.ndim != 2:
        msg = "image と reference は 2D 配列を期待します"
        raise ValueError(msg)

    if image.shape != reference.shape:
        msg = (
            f"image の shape {image.shape} と reference の shape {reference.shape} が異なります."
            "事前に resize してください."
        )
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    img = image.astype(numpy.float64)
    ref = reference.astype(numpy.float64)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)
        ref = numpy.where(mask, ref, 0.0)

    if data_range is None:
        data_range = float(max(img.max(), ref.max()))
        if data_range == 0.0:
            data_range = 1.0  # 全ゼロ画像でのゼロ除算を防ぐ

    ssim_val = structural_similarity(img, ref, data_range=data_range)
    return float(ssim_val)


def calc_ncc(
    image: numpy.ndarray,
    reference: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> float:
    """正規化相関係数 (Normalized Cross-Correlation) を算出する.

    NCC = Σ((I - mean(I)) * (R - mean(R))) / sqrt(Σ(I - mean(I))² * Σ(R - mean(R))²)

    絶対輝度スケールに依存しない形の類似性指標.1.0 で完全相関(形が一致),
    0 で無相関,-1 で逆相関.SSIM より単純だが照明変動に対しロバスト.

    Args:
        image: 評価対象 2D 輝度マップ(H, W).
        reference: 参照画像 2D 輝度マップ(image と同 shape).
        mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象.
            None なら全画素を対象とする.

    Returns:
        NCC 値(float, -1〜+1).
        分散がゼロの場合(均一画像)は NaN を返す.

    Raises:
        ValueError: image と reference の shape が異なる場合.
        ValueError: mask の shape が image と異なる場合.
    """
    if image.ndim != 2 or reference.ndim != 2:
        msg = "image と reference は 2D 配列を期待します"
        raise ValueError(msg)

    if image.shape != reference.shape:
        msg = (
            f"image の shape {image.shape} と reference の shape {reference.shape} が異なります"
        )
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    img = image.astype(numpy.float64).ravel()
    ref = reference.astype(numpy.float64).ravel()

    if mask is not None:
        mask_flat = mask.ravel()
        img = img[mask_flat]
        ref = ref[mask_flat]

    if img.size == 0:
        return float("nan")

    img_centered = img - img.mean()
    ref_centered = ref - ref.mean()

    denom = float(
        numpy.sqrt(numpy.sum(img_centered**2) * numpy.sum(ref_centered**2))
    )
    if denom == 0.0:
        return float("nan")

    ncc_val = float(numpy.sum(img_centered * ref_centered) / denom)
    # 数値誤差で [-1, 1] の範囲をわずかに超える可能性があるためクリップ
    return float(numpy.clip(ncc_val, -1.0, 1.0))


def calc_intensity_pca(
    image: numpy.ndarray,
    mask: numpy.ndarray | None = None,
) -> dict[str, float]:
    """輝度分布の主成分分析で主軸方向と楕円率を計算する.

    各ピクセルを (x, y, weight=I(x,y)) とした weighted PCA で
    輝度分布の「伸び方向」と「偏心度」を数値化する.

    アルゴリズム:
        1. 輝度を重みとして重心座標 (cx, cy) を算出
        2. 重心からのオフセット (Δx, Δy) に輝度重みを掛けて共分散行列を構築
        3. 固有値分解 → 主軸方向(第 1 固有ベクトルの角度)と固有値

    物理的意味:
        - ``primary_axis_angle_deg ≈ 90°``: 主軸が Y 軸方向(Y 単調勾配の典型)
        - ``eccentricity ≈ 0``: 完全円形分布(等方的中央ピーク)
        - ``eccentricity ≈ 1``: 極端な楕円(強い方向性の輝度分布)

    Args:
        image: 2D (H, W) 輝度マップ.負値は 0 として扱う.
        mask: 2D bool マスク(image と同 shape).True のピクセルのみ計算対象.
            None なら全画素を対象とする.

    Returns:
        以下のキーを持つ辞書:
        - ``"primary_axis_angle_deg"`` (float): 主軸の角度 [deg](画像 x 軸基準,-90〜+90).
        - ``"primary_eigenvalue"`` (float): 第 1 固有値(主軸方向の分散の大きさ).
        - ``"secondary_eigenvalue"`` (float): 第 2 固有値(副軸方向の分散の大きさ).
        - ``"eccentricity"`` (float): √(1 - secondary/primary),0=完全円,1=極端な楕円.
          primary_eigenvalue が 0 の場合は 0 を返す.

    Raises:
        ValueError: mask の shape が image と異なる場合.
        ValueError: 有効ピクセルの輝度総和が 0 の場合(PCA 計算不能).
    """
    if image.ndim != 2:
        msg = f"image は 2D 配列を期待します(ndim={image.ndim})"
        raise ValueError(msg)

    if mask is not None:
        if mask.shape != image.shape:
            msg = (
                f"mask の shape {mask.shape} が image の shape {image.shape} と異なります"
            )
            raise ValueError(msg)

    h, w = image.shape
    img = numpy.maximum(image.astype(numpy.float64), 0.0)

    if mask is not None:
        img = numpy.where(mask, img, 0.0)

    # グリッド座標(ピクセル中心)
    ys = numpy.arange(h, dtype=numpy.float64).reshape(-1, 1)
    xs = numpy.arange(w, dtype=numpy.float64).reshape(1, -1)

    total_weight = float(img.sum())
    if total_weight == 0.0:
        msg = "有効ピクセルの輝度総和が 0 のため PCA を計算できません"
        raise ValueError(msg)

    # 重心
    cx = float((img * xs).sum() / total_weight)
    cy = float((img * ys).sum() / total_weight)

    # 重心からのオフセット
    dx = xs - cx
    dy = ys - cy

    # weighted 共分散行列の要素
    cov_xx = float((img * dx * dx).sum() / total_weight)
    cov_xy = float((img * dx * dy).sum() / total_weight)
    cov_yy = float((img * dy * dy).sum() / total_weight)

    cov_matrix = numpy.array([[cov_xx, cov_xy], [cov_xy, cov_yy]], dtype=numpy.float64)

    # 固有値分解(numpy.linalg.eigh は対称行列向けで安定かつ固有値昇順保証)
    eigenvalues, eigenvectors = numpy.linalg.eigh(cov_matrix)
    # eigh は昇順なので,最大固有値は eigenvalues[1]
    primary_eigenvalue = float(eigenvalues[1])
    secondary_eigenvalue = float(eigenvalues[0])

    # 主軸ベクトル(第 1 固有値に対応する固有ベクトル)
    primary_vec = eigenvectors[:, 1]  # shape (2,): [x_component, y_component]
    angle_rad = float(numpy.arctan2(primary_vec[1], primary_vec[0]))
    angle_deg = float(numpy.degrees(angle_rad))
    # -90〜+90 の範囲に折り畳む(主軸は方向性を持たないため)
    if angle_deg > 90.0:
        angle_deg -= 180.0
    elif angle_deg < -90.0:
        angle_deg += 180.0

    # 離心率 eccentricity = √(1 - secondary/primary)
    # primary が 0 の場合(全画素同一値)は 0 を返す
    if primary_eigenvalue <= 0.0:
        eccentricity = 0.0
    else:
        ratio = secondary_eigenvalue / primary_eigenvalue
        # ratio は理論上 [0, 1] だが数値誤差で負になることがあるためクリップ
        eccentricity = float(numpy.sqrt(max(0.0, 1.0 - ratio)))

    return {
        "primary_axis_angle_deg": angle_deg,
        "primary_eigenvalue": primary_eigenvalue,
        "secondary_eigenvalue": secondary_eigenvalue,
        "eccentricity": eccentricity,
    }