"""色特徴量の抽出(`data/color_features.py`)のテスト.
仕様(実装サマリーの設計を仕様として扱う):
- `extract_color_features` は 107 次元(L*・a*・b*・C*・h ごとに平均・標準偏差・10/25/50/75/90
分位点 = 35,a*-b* 8×8 ヒストグラム = 64,L* 8 ビン = 8).並びは `color_feature_names`
- h は atan2(b*, a*) の度((-180, 180])
- ヒストグラムは割合(合計 1).範囲(a* [0, 50]・b* [-10, 30]・L* [20, 100])外は端のビン
- 舌領域(RGB がすべて 0 でない画素)だけを使う(背景の量・位置で特徴が変わらない)
- 舌領域が 0 画素なら ValueError(ファイル名は出さない)
- `downscale_nearest` は長辺が max_side を超えるときだけ最近傍法で縮小する(色が変わらない)
"""
from __future__ import annotations
import numpy as np
import pytest
from tias_judge_model.data.color import rgb8_to_lab
from tias_judge_model.data.color_features import (
NUM_COLOR_FEATURES,
color_feature_names,
downscale_nearest,
extract_color_features,
)
NAMES = color_feature_names()
AB_BINS = 8
L_BINS = 8
A_RANGE = (0.0, 50.0)
B_RANGE = (-10.0, 30.0)
L_RANGE = (20.0, 100.0)
def _idx(name: str) -> int:
return NAMES.index(name)
def _ab_hist(features: np.ndarray) -> np.ndarray:
return np.array(
[features[_idx(f"ab_hist_{i}_{j}")] for i in range(AB_BINS) for j in range(AB_BINS)]
).reshape(AB_BINS, AB_BINS)
def _l_hist(features: np.ndarray) -> np.ndarray:
return np.array([features[_idx(f"L_hist_{k}")] for k in range(L_BINS)])
def _uniform(rgb: tuple[int, int, int], shape: tuple[int, int] = (6, 5)) -> np.ndarray:
img = np.zeros((*shape, 3), dtype=np.uint8)
img[:] = rgb
return img
def _lab(rgb: tuple[int, int, int]) -> np.ndarray:
return rgb8_to_lab(np.array([rgb], dtype=np.uint8))[0]
def _random_tongue(rng: np.random.Generator, shape: tuple[int, int]) -> np.ndarray:
# 1〜255 の値だけにして,すべての画素を舌領域にする
return rng.integers(1, 256, size=(*shape, 3), dtype=np.uint8)
# --- 次元・名前 ---
def test_dimension_is_107() -> None:
assert NUM_COLOR_FEATURES == 107
assert len(NAMES) == 107
rng = np.random.default_rng(0)
f = extract_color_features(_random_tongue(rng, (8, 9)))
assert f.shape == (107,)
assert f.dtype == np.float64
assert np.all(np.isfinite(f))
def test_feature_names_are_unique_and_follow_layout() -> None:
assert len(set(NAMES)) == len(NAMES)
stats = ["mean", "std", "p10", "p25", "p50", "p75", "p90"]
expected_channel = [f"{ch}_{s}" for ch in ("L", "a", "b", "C", "h") for s in stats]
assert list(NAMES[:35]) == expected_channel
assert list(NAMES[35:99]) == [f"ab_hist_{i}_{j}" for i in range(8) for j in range(8)]
assert list(NAMES[99:]) == [f"L_hist_{k}" for k in range(8)]
# --- 統計量 ---
def test_uniform_color_statistics_match_lab() -> None:
rgb = (225, 140, 140)
f = extract_color_features(_uniform(rgb))
lightness, a, b = _lab(rgb)
chroma = np.hypot(a, b)
hue = np.degrees(np.arctan2(b, a))
for ch, value in zip("LabCh", (lightness, a, b, chroma, hue), strict=True):
assert f[_idx(f"{ch}_mean")] == pytest.approx(value, abs=1e-6)
assert f[_idx(f"{ch}_std")] == pytest.approx(0.0, abs=1e-6)
for q in (10, 25, 50, 75, 90):
assert f[_idx(f"{ch}_p{q}")] == pytest.approx(value, abs=1e-6)
def test_mean_and_median_over_mixed_pixels() -> None:
# 9 画素が色 B,1 画素が色 A: 平均は重み付き,中央値・p90 は B
img = np.zeros((1, 10, 3), dtype=np.uint8)
color_a, color_b = (120, 20, 30), (225, 140, 140)
img[0, 0] = color_a
img[0, 1:] = color_b
f = extract_color_features(img)
la, lb = _lab(color_a)[0], _lab(color_b)[0]
assert f[_idx("L_mean")] == pytest.approx((la + 9 * lb) / 10, abs=1e-6)
assert f[_idx("L_p50")] == pytest.approx(lb, abs=1e-6)
assert f[_idx("L_p90")] == pytest.approx(lb, abs=1e-6)
assert f[_idx("L_std")] > 0
# 分位点は単調非減少
ps = [f[_idx(f"L_p{q}")] for q in (10, 25, 50, 75, 90)]
assert ps == sorted(ps)
@pytest.mark.parametrize(
"rgb",
[(225, 140, 140), (110, 50, 140), (0, 255, 0), (0, 0, 255), (40, 200, 220)],
ids=str,
)
def test_hue_is_atan2_degrees_in_range(rgb: tuple[int, int, int]) -> None:
f = extract_color_features(_uniform(rgb))
_, a, b = _lab(rgb)
h = f[_idx("h_mean")]
assert -180.0 < h <= 180.0
assert h == pytest.approx(np.degrees(np.arctan2(b, a)), abs=1e-6)
def test_hue_sign_follows_b() -> None:
# 紫(a* > 0, b* < 0)は負の色相,淡紅(a* > 0, b* > 0)は 0〜90°
purple = extract_color_features(_uniform((110, 50, 140)))[_idx("h_mean")]
pink = extract_color_features(_uniform((225, 140, 140)))[_idx("h_mean")]
assert purple < 0
assert 0 < pink < 90
# --- ヒストグラム ---
def test_histograms_sum_to_one_and_are_proportions() -> None:
rng = np.random.default_rng(1)
img = _random_tongue(rng, (13, 17))
img[:3] = 0 # 背景
f = extract_color_features(img)
ab = _ab_hist(f)
lh = _l_hist(f)
assert ab.sum() == pytest.approx(1.0)
assert lh.sum() == pytest.approx(1.0)
assert np.all(ab >= 0) and np.all(lh >= 0)
# 割合は舌領域の画素数の倍数
n = 10 * 17
assert np.allclose(ab * n, np.round(ab * n))
assert np.allclose(lh * n, np.round(lh * n))
def test_histogram_proportions_three_to_one() -> None:
img = np.zeros((2, 3, 3), dtype=np.uint8) # 2 画素は背景
img[0, :3] = (225, 140, 140)
img[1, 0] = (255, 255, 255)
f = extract_color_features(img)
lh = _l_hist(f)
assert lh[7] == pytest.approx(0.25) # 白(L* = 100)は最上位ビン
assert sorted(lh[lh > 0].tolist()) == pytest.approx([0.25, 0.75])
def test_in_range_color_goes_to_expected_ab_bin() -> None:
rgb = (225, 140, 140)
lightness, a, b = _lab(rgb)
assert A_RANGE[0] < a < A_RANGE[1] and B_RANGE[0] < b < B_RANGE[1]
i = int((a - A_RANGE[0]) // ((A_RANGE[1] - A_RANGE[0]) / AB_BINS))
j = int((b - B_RANGE[0]) // ((B_RANGE[1] - B_RANGE[0]) / AB_BINS))
k = int((lightness - L_RANGE[0]) // ((L_RANGE[1] - L_RANGE[0]) / L_BINS))
f = extract_color_features(_uniform(rgb))
ab = _ab_hist(f)
assert ab[i, j] == pytest.approx(1.0)
assert _l_hist(f)[k] == pytest.approx(1.0)
@pytest.mark.parametrize(
("rgb", "ab_bin"),
[
((255, 0, 0), (7, 7)), # a* ≈ 80・b* ≈ 67(両方上限超え)
((0, 255, 0), (0, 7)), # a* ≈ -86(下限未満)・b* ≈ 83(上限超え)
((0, 0, 255), (7, 0)), # a* ≈ 79(上限超え)・b* ≈ -108(下限未満)
],
ids=["red", "green", "blue"],
)
def test_out_of_range_ab_goes_to_edge_bin(
rgb: tuple[int, int, int], ab_bin: tuple[int, int]
) -> None:
f = extract_color_features(_uniform(rgb))
ab = _ab_hist(f)
assert ab[ab_bin] == pytest.approx(1.0)
assert ab.sum() == pytest.approx(1.0)
def test_out_of_range_lightness_goes_to_edge_bin() -> None:
img = np.zeros((1, 4, 3), dtype=np.uint8)
img[0, 0] = (5, 5, 5) # L* ≈ 1.4(下限 20 未満)→ 最下位ビン
img[0, 1] = (3, 3, 3)
img[0, 2] = (255, 255, 255) # L* = 100(上限ちょうど)→ 最上位ビン
img[0, 3] = (255, 255, 255)
lh = _l_hist(extract_color_features(img))
assert lh[0] == pytest.approx(0.5)
assert lh[7] == pytest.approx(0.5)
assert lh.sum() == pytest.approx(1.0)
# --- 背景の扱い ---
def test_background_amount_and_position_do_not_change_features() -> None:
rng = np.random.default_rng(2)
tongue = _random_tongue(rng, (10, 12))
small = tongue.copy()
large = np.zeros((40, 55, 3), dtype=np.uint8)
large[17:27, 30:42] = tongue
np.testing.assert_allclose(
extract_color_features(small), extract_color_features(large), rtol=0, atol=1e-9
)
def test_black_holes_inside_tongue_are_ignored() -> None:
rng = np.random.default_rng(3)
tongue = _random_tongue(rng, (1, 20))
with_holes = np.zeros((3, 20, 3), dtype=np.uint8)
with_holes[1] = tongue
# 舌の画素の順序を変えても(並べ替え不変),黒を挟んでも同じ
shuffled = np.zeros((20, 2, 3), dtype=np.uint8)
shuffled[:, 0] = tongue[0][::-1]
np.testing.assert_allclose(
extract_color_features(tongue), extract_color_features(with_holes), atol=1e-9
)
np.testing.assert_allclose(
extract_color_features(tongue), extract_color_features(shuffled), atol=1e-9
)
def test_nearly_black_pixel_counts_as_tongue() -> None:
# RGB がすべて 0 でなければ舌領域(1 チャンネルだけ 1 でも数える)
img = np.zeros((3, 3, 3), dtype=np.uint8)
img[1, 1] = (0, 0, 1)
f = extract_color_features(img)
assert _l_hist(f).sum() == pytest.approx(1.0)
assert f[_idx("L_std")] == pytest.approx(0.0, abs=1e-9)
def test_no_tongue_pixels_raises_without_file_name() -> None:
with pytest.raises(ValueError) as excinfo:
extract_color_features(np.zeros((5, 5, 3), dtype=np.uint8))
msg = str(excinfo.value)
assert ".png" not in msg and ".jpg" not in msg
@pytest.mark.parametrize("shape", [(5, 5), (5, 5, 4), (5, 5, 1)], ids=str)
def test_invalid_shape_raises(shape: tuple[int, ...]) -> None:
with pytest.raises(ValueError):
extract_color_features(np.full(shape, 100, dtype=np.uint8))
# --- 最近傍縮小 ---
def test_downscale_keeps_small_images_unchanged() -> None:
rng = np.random.default_rng(4)
img = _random_tongue(rng, (30, 50))
np.testing.assert_array_equal(downscale_nearest(img, 50), img)
np.testing.assert_array_equal(downscale_nearest(img, 100), img)
@pytest.mark.parametrize(
("shape", "max_side", "expected"),
[((40, 100), 50, (20, 50)), ((100, 30), 50, (50, 15)), ((64, 64), 16, (16, 16))],
)
def test_downscale_limits_long_side_keeping_aspect(
shape: tuple[int, int], max_side: int, expected: tuple[int, int]
) -> None:
rng = np.random.default_rng(5)
out = downscale_nearest(_random_tongue(rng, shape), max_side)
assert out.shape == (*expected, 3)
assert out.dtype == np.uint8
assert max(out.shape[:2]) <= max_side
def test_downscale_does_not_create_new_colors() -> None:
rng = np.random.default_rng(6)
img = np.zeros((90, 70, 3), dtype=np.uint8)
img[10:80, 10:60] = _random_tongue(rng, (70, 50))
out = downscale_nearest(img, 23)
src_colors = {tuple(p) for p in img.reshape(-1, 3)}
out_colors = {tuple(p) for p in out.reshape(-1, 3)}
assert out_colors <= src_colors
def test_downscale_of_block_image_keeps_features() -> None:
# 2×2 の一様なブロックからなる画像を 1/2 に縮小しても,色の割合は変わらない
rng = np.random.default_rng(7)
small = _random_tongue(rng, (16, 24))
small[:4, :4] = 0 # 背景も含める
big = np.repeat(np.repeat(small, 2, axis=0), 2, axis=1) # (32, 48, 3)
out = downscale_nearest(big, 24)
assert out.shape == (16, 24, 3)
# 縮小後は各ブロックの色がちょうど 1 画素ずつ残る(色が混ざらず,割合も保たれる)
np.testing.assert_array_equal(out, small)
# 分位点は線形補間のため重複度で僅かに変わりうるので,平均・ヒストグラムで比べる
keep = [i for i, n in enumerate(NAMES) if not n.split("_")[-1].startswith("p")]
f_out, f_big = extract_color_features(out), extract_color_features(big)
np.testing.assert_allclose(f_out[keep], f_big[keep], atol=1e-9)
@pytest.mark.parametrize("max_side", [0, -1])
def test_downscale_rejects_non_positive_max_side(max_side: int) -> None:
with pytest.raises(ValueError):
downscale_nearest(np.ones((4, 4, 3), dtype=np.uint8), max_side)