"""`core/roi.py` の検証(SPEC_02 (0)「ROI 平均色」・PLAN_02 ステップ 4).
仕様上の要件は以下.
- クリック点ごとに 51×51 px の矩形 ROI の平均 BGR を算出する
- 複数点クリック時は各 ROI 平均の平均(全クリックの合算 ÷ クリック数)を診断に使用する
- クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」)
- ROI が画像外にはみ出す等の不正入力は `core` が例外で通知する(error-handling.md)
テストは実装ではなく仕様を基準とするため,期待値は解析的に手計算できる画像
(単色・座標に比例するグラデーション・単一点のみ明るい画像)で与える.
"""
import numpy as np
import pytest
from tiasjudge.core.roi import (
MAX_ROI_POINTS,
ROI_OFFSET,
ROI_SIZE,
mean_bgr,
mean_rgb,
roi_mean_bgr,
)
ROI_PIXEL_COUNT = ROI_SIZE * ROI_SIZE
# --- テスト用画像の生成ヘルパー ---
def uniform_image(bgr: tuple[int, int, int], width: int = 200, height: int = 200) -> np.ndarray:
"""全画素が同じ BGR の画像を作る."""
image = np.empty((height, width, 3), dtype=np.uint8)
image[:, :] = bgr
return image
def ramp_image(width: int = 200, height: int = 200, red: int = 7) -> np.ndarray:
"""B = x座標,G = y座標,R = 定数のグラデーション画像を作る.
ROI(一辺 51 px の奇数サイズ)の平均は中心座標に一致するため,
「クリック点を中心とする 51×51」であることを解析的に検証できる.
"""
image = np.zeros((height, width, 3), dtype=np.uint8)
image[:, :, 0] = np.arange(width, dtype=np.uint8)[np.newaxis, :]
image[:, :, 1] = np.arange(height, dtype=np.uint8)[:, np.newaxis]
image[:, :, 2] = red
return image
# --- 定数(SPEC_02 (0)・SPEC_01) ---
def test_roi_size_is_51_px() -> None:
"""ROI の一辺は 51 px(SPEC_02 (0))."""
assert ROI_SIZE == 51
def test_roi_offset_centers_the_roi_on_the_click_point() -> None:
"""オフセットは ROI をクリック点中心に置く値((51 - 1) / 2 = 25)である."""
assert ROI_OFFSET == (ROI_SIZE - 1) // 2 == 25
def test_max_roi_points_is_four() -> None:
"""クリック可能な最大点数は 4 点(SPEC_01「ROI 指定」)."""
assert MAX_ROI_POINTS == 4
# --- 単一 ROI の平均(SPEC_02 (0)) ---
def test_uniform_image_returns_the_same_bgr() -> None:
"""単色画像の ROI 平均は,その色そのものになる."""
image = uniform_image((10, 20, 30))
assert roi_mean_bgr(image, (100, 100)) == (10.0, 20.0, 30.0)
@pytest.mark.parametrize("point", [(25, 25), (100, 60), (174, 174)])
def test_ramp_image_mean_equals_the_click_point(point: tuple[int, int]) -> None:
"""B = x・G = y のグラデーションでは,51×51 の平均が中心(クリック点)と一致する.
ROI が「クリック点中心・一辺 51 px」でなければ平均は中心座標からずれる.
"""
image = ramp_image()
blue, green, red = roi_mean_bgr(image, point)
assert blue == pytest.approx(float(point[0]))
assert green == pytest.approx(float(point[1]))
assert red == pytest.approx(7.0)
def test_mean_is_not_rounded_to_integer() -> None:
"""平均は整数に丸めず実数で返す(後段の色変換の精度を落とさない)."""
image = np.zeros((200, 200, 3), dtype=np.uint8)
# ROI 内の 1 画素だけ 1 にすると,平均は 1/2601 の非整数になる
image[100, 100, 0] = 1
blue, _, _ = roi_mean_bgr(image, (100, 100))
assert blue == pytest.approx(1.0 / ROI_PIXEL_COUNT)
@pytest.mark.parametrize(
"offset",
[(-ROI_OFFSET, -ROI_OFFSET), (ROI_OFFSET, ROI_OFFSET), (-ROI_OFFSET, ROI_OFFSET), (0, 0)],
)
def test_pixels_inside_the_51x51_window_are_counted(offset: tuple[int, int]) -> None:
"""中心から ±25 px(= 51×51 の四隅・中央)の画素は ROI に含まれる."""
center = (100, 100)
image = np.zeros((200, 200, 3), dtype=np.uint8)
image[center[1] + offset[1], center[0] + offset[0], 0] = 255
blue, _, _ = roi_mean_bgr(image, center)
assert blue == pytest.approx(255.0 / ROI_PIXEL_COUNT)
@pytest.mark.parametrize(
"offset",
[(-ROI_OFFSET - 1, 0), (ROI_OFFSET + 1, 0), (0, -ROI_OFFSET - 1), (0, ROI_OFFSET + 1)],
)
def test_pixels_outside_the_51x51_window_are_ignored(offset: tuple[int, int]) -> None:
"""中心から 26 px 離れた画素は ROI に含まれない(ROI は 51×51 に収まる)."""
center = (100, 100)
image = np.zeros((200, 200, 3), dtype=np.uint8)
image[center[1] + offset[1], center[0] + offset[0], 0] = 255
assert roi_mean_bgr(image, center) == (0.0, 0.0, 0.0)
def test_mean_matches_an_independent_computation() -> None:
"""任意の画像でも ROI 内 2601 画素の単純平均になる(`cv::mean` 相当).
期待値は実装とは別経路(float64 での全画素平均)で計算する.
"""
rng = np.random.default_rng(seed=20240401)
image = rng.integers(0, 256, size=(200, 200, 3), dtype=np.uint8)
center = (100, 80)
window = image[
center[1] - ROI_OFFSET : center[1] + ROI_OFFSET + 1,
center[0] - ROI_OFFSET : center[0] + ROI_OFFSET + 1,
].astype(np.float64)
expected = tuple(window.reshape(-1, 3).mean(axis=0))
assert roi_mean_bgr(image, center) == pytest.approx(expected)
def test_accepts_a_cropped_view_of_an_image() -> None:
"""クロップ後の部分ビュー(非連続配列)でも同じ結果になる.
SPEC_01 の操作フローではクロップ後の画像上でクリックするため,
ビューを渡しても座標どおりに ROI を切り出せる必要がある.
"""
full = ramp_image(width=400, height=400)
cropped = full[100:300, 100:300] # 元画像の (100, 100) が原点になる
blue, green, red = roi_mean_bgr(cropped, (60, 40))
assert blue == pytest.approx(160.0)
assert green == pytest.approx(140.0)
assert red == pytest.approx(7.0)
def test_high_bit_depth_image_is_not_truncated() -> None:
"""8 bit を超える値の画像でも平均が桁あふれ・切り捨てされない."""
image = np.full((200, 200, 3), 1000, dtype=np.uint16)
assert roi_mean_bgr(image, (100, 100)) == (1000.0, 1000.0, 1000.0)
# --- 複数点の平均(SPEC_02 (0)「合算 ÷ クリック数」) ---
def test_single_point_mean_matches_roi_mean() -> None:
"""1 点だけのときは,その ROI 平均がそのまま診断に使われる."""
image = ramp_image()
assert mean_bgr(image, [(60, 80)]) == roi_mean_bgr(image, (60, 80))
def test_two_points_are_averaged() -> None:
"""2 点クリック時は 2 つの ROI 平均の平均になる(端数も保持する)."""
image = ramp_image()
blue, green, red = mean_bgr(image, [(40, 30), (101, 90)])
assert blue == pytest.approx((40.0 + 101.0) / 2.0)
assert green == pytest.approx((30.0 + 90.0) / 2.0)
assert red == pytest.approx(7.0)
def test_four_points_are_averaged() -> None:
"""最大クリック数(4 点)でも合算 ÷ クリック数になる."""
image = ramp_image()
points = [(30, 30), (60, 40), (90, 50), (120, 61)]
blue, green, _ = mean_bgr(image, points)
assert blue == pytest.approx(sum(x for x, _ in points) / len(points))
assert green == pytest.approx(sum(y for _, y in points) / len(points))
def test_mean_of_max_points_equals_the_average_of_each_roi_mean() -> None:
"""最大クリック数(4 点)の平均は,各 ROI 平均の合算 ÷ クリック数と一致する."""
image = ramp_image()
points = [(30, 31), (60, 42), (91, 50), (120, 64)][:MAX_ROI_POINTS]
per_point = [roi_mean_bgr(image, point) for point in points]
expected = tuple(sum(values) / MAX_ROI_POINTS for values in zip(*per_point, strict=True))
assert len(points) == MAX_ROI_POINTS
assert mean_bgr(image, points) == pytest.approx(expected)
def test_repeated_point_is_counted_each_time() -> None:
"""同じ点を複数回クリックした場合もクリック数で割る(重複を除去しない)."""
image = ramp_image()
assert mean_bgr(image, [(60, 80), (60, 80)]) == pytest.approx(roi_mean_bgr(image, (60, 80)))
def test_average_is_over_roi_means_not_over_all_pixels() -> None:
"""複数点の平均は「各 ROI 平均の平均」である(重なりも含めた画素平均ではない)."""
image = uniform_image((0, 0, 0))
# 左側の ROI だけ 100,右側は 0 のままにする
image[75:126, 75:126] = (100, 100, 100)
assert mean_bgr(image, [(100, 100), (160, 100)]) == pytest.approx((50.0, 50.0, 50.0))
def test_mean_rgb_reorders_channels() -> None:
"""`mean_rgb` は (R, G, B) の順で返す(`color.rgb_to_lab` の入力並び)."""
image = uniform_image((10, 20, 30))
points = [(100, 100)]
assert mean_rgb(image, points) == (30.0, 20.0, 10.0)
assert mean_rgb(image, points) == tuple(reversed(mean_bgr(image, points)))
# --- 異常系(error-handling.md) ---
@pytest.mark.parametrize(
"point",
[
(ROI_OFFSET - 1, 100), # 左にはみ出す
(100, ROI_OFFSET - 1), # 上にはみ出す
(200 - ROI_OFFSET, 100), # 右にはみ出す
(100, 200 - ROI_OFFSET), # 下にはみ出す
(-5, -5),
],
)
def test_out_of_range_point_raises(point: tuple[int, int]) -> None:
"""ROI が画像外にはみ出す場合はエラーにする(旧実装のクラッシュを防ぐ)."""
image = ramp_image(width=200, height=200)
with pytest.raises(ValueError):
roi_mean_bgr(image, point)
def test_roi_touching_the_image_edge_is_valid() -> None:
"""ROI が画像端にちょうど収まる位置は有効である(境界値)."""
image = uniform_image((1, 2, 3), width=51, height=51)
assert roi_mean_bgr(image, (ROI_OFFSET, ROI_OFFSET)) == (1.0, 2.0, 3.0)
def test_grayscale_image_raises() -> None:
"""BGR 3 チャンネル以外の画像は受け付けない."""
with pytest.raises(ValueError):
roi_mean_bgr(np.zeros((200, 200), dtype=np.uint8), (100, 100))
def test_empty_image_raises() -> None:
"""空の画像は受け付けない."""
with pytest.raises(ValueError):
roi_mean_bgr(np.zeros((0, 0, 3), dtype=np.uint8), (100, 100))
def test_no_points_raises() -> None:
"""クリック点が 0 個のときは診断に使う平均色を作れない."""
with pytest.raises(ValueError):
mean_bgr(ramp_image(), [])
def test_out_of_range_point_in_multi_point_raises() -> None:
"""複数点のうち 1 点でも範囲外ならエラーにする."""
image = ramp_image()
with pytest.raises(ValueError):
mean_bgr(image, [(100, 100), (5, 5)])