"""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])