Newer
Older
TIASjudge / src / tiasjudge / core / roi.py
"""ROI(関心領域)の平均色算出.

SPEC_02 (0) を実装する.旧実装(`org/src` の `CtonguediagnosisMFC2Dlg::getRGB()` と
`OnBnClickedButtonCalc()`)と同じく,クリック点を中心とする 51×51 px の矩形 ROI の
平均 BGR を求め,複数点クリック時は各 ROI 平均をさらに平均する.

※ 画像は numpy 配列(OpenCV の BGR 画像)として受け取り,GUI には依存しない
(`core` の依存方向は project-structure.md を参照).
"""

from __future__ import annotations

import logging
from collections.abc import Sequence

import numpy as np
from numpy.typing import NDArray

logger = logging.getLogger(__name__)

# --- 定数(SPEC_02 (0) / SPEC_01「ROI 指定」)---

#: ROI の一辺の長さ(px.旧実装 `cv::Rect(x - 25, y - 25, 51, 51)`)
ROI_SIZE = 51
#: ROI 中心から左上までのオフセット(px.旧実装の -25)
ROI_OFFSET = 25
#: クリック可能な最大点数(SPEC_01.5 点目はエラー)
MAX_ROI_POINTS = 4
#: BGR 画像のチャンネル数
CHANNEL_COUNT = 3
#: BGR 画像の軸数(高さ・幅・チャンネルの 3 軸.`CHANNEL_COUNT` とは意味が異なる)
_IMAGE_NDIM = 3


def roi_mean_bgr(image: NDArray[np.generic], point: tuple[int, int]) -> tuple[float, float, float]:
    """クリック点を中心とする 51×51 px の ROI の平均 BGR を返す.

    Args:
        image: BGR 画像(形状 (高さ, 幅, 3).OpenCV の `imread` と同じ並び).
        point: クリック点 (x, y).画像座標(クロップ後・縮小前)で指定する.

    Returns:
        (B, G, R) の平均値(float.丸めは行わない).

    Raises:
        ValueError: 画像が BGR 3 チャンネルでない場合,
            または ROI が画像の範囲外にはみ出す場合.
    """
    array = _as_bgr_image(image)
    x, y = _as_point(point)

    left = x - ROI_OFFSET
    top = y - ROI_OFFSET
    height, width = array.shape[:2]
    # 旧実装は範囲チェックを持たず,はみ出すと OpenCV が例外を投げる.
    # 新実装では原因の分かるエラーにして GUI 側でガードできるようにする
    if left < 0 or top < 0 or left + ROI_SIZE > width or top + ROI_SIZE > height:
        raise ValueError(
            f"ROI が画像の範囲外です: 中心 ({x}, {y})・ROI {ROI_SIZE}x{ROI_SIZE}・"
            f"画像 {width}x{height}"
        )

    region = array[top : top + ROI_SIZE, left : left + ROI_SIZE]
    # 旧実装の `cv::mean(roiBGR)` に対応する(各チャンネルの単純平均)
    totals = region.reshape(-1, CHANNEL_COUNT).sum(axis=0, dtype=np.float64)
    means = totals / float(ROI_SIZE * ROI_SIZE)
    return float(means[0]), float(means[1]), float(means[2])


def mean_bgr(
    image: NDArray[np.generic], points: Sequence[tuple[int, int]]
) -> tuple[float, float, float]:
    """複数のクリック点の ROI 平均 BGR を,さらに平均して返す(SPEC_02 (0)).

    旧実装の `m_meanColors += meanBGR` → `m_meanColors /= m_numClick` に対応する
    (各 ROI 平均の合算をクリック数で割る).

    Args:
        image: BGR 画像(形状 (高さ, 幅, 3)).
        points: クリック点 (x, y) の並び(1 点以上).

    Returns:
        (B, G, R) の平均値.

    Raises:
        ValueError: 点が 1 つも無い場合,画像が不正な場合,
            または ROI が画像の範囲外にはみ出す場合.
    """
    if len(points) == 0:
        raise ValueError("ROI の平均色には 1 点以上のクリック点が必要です")

    totals = np.zeros(CHANNEL_COUNT, dtype=np.float64)
    for point in points:
        totals += np.asarray(roi_mean_bgr(image, point), dtype=np.float64)
    means = totals / float(len(points))

    logger.info(
        "ROI 平均色: %d 点 -> BGR=(%.6f, %.6f, %.6f)",
        len(points),
        means[0],
        means[1],
        means[2],
    )
    return float(means[0]), float(means[1]), float(means[2])


def mean_rgb(
    image: NDArray[np.generic], points: Sequence[tuple[int, int]]
) -> tuple[float, float, float]:
    """複数のクリック点の ROI 平均色を (R, G, B) の順で返す.

    `core.color.rgb_to_lab` の入力に合わせた並べ替えのみを行う(値は `mean_bgr` と同じ).

    Args:
        image: BGR 画像(形状 (高さ, 幅, 3)).
        points: クリック点 (x, y) の並び(1 点以上).

    Returns:
        (R, G, B) の平均値.

    Raises:
        ValueError: 点が 1 つも無い場合,画像が不正な場合,
            または ROI が画像の範囲外にはみ出す場合(`mean_bgr` と同じ).
    """
    blue, green, red = mean_bgr(image, points)
    return red, green, blue


def _as_bgr_image(image: NDArray[np.generic]) -> NDArray[np.generic]:
    """入力が BGR 3 チャンネル画像であることを検証して返す."""
    array = np.asarray(image)
    if array.ndim != _IMAGE_NDIM or array.shape[2] != CHANNEL_COUNT:
        raise ValueError(
            f"画像は形状 (高さ, 幅, {CHANNEL_COUNT}) の BGR 画像が必要です: {array.shape}"
        )
    if array.shape[0] == 0 or array.shape[1] == 0:
        raise ValueError("画像が空です")
    return array


def _as_point(point: tuple[int, int]) -> tuple[int, int]:
    """クリック点を整数座標 (x, y) に整える."""
    values = tuple(point)
    if len(values) != 2:
        raise ValueError(f"クリック点は (x, y) の 2 要素が必要です: {point!r}")
    return int(values[0]), int(values[1])