"""舌領域の色(Lab)と色差のテスト(SPEC_01「データセット集計の出力」).
- Lab の計算: sRGB(ガンマ補正を外して線形化)→ XYZ(D65)→ CIE L*a*b*(D65 白色点)
- 背景の定義: RGB がすべて 0 の画素を背景とし,それ以外を舌領域とする
- 色差: CIE76(L*a*b* のユークリッド距離)
期待値は sRGB の標準的な参照値(D65・2° 視野.例: 赤 (255,0,0) → L*≈53.24, a*≈80.09,
b*≈67.20).
"""
from __future__ import annotations
import numpy as np
import pytest
from tias_judge_model.data.color import delta_e_cie76, mean_tongue_lab, rgb8_to_lab, tongue_mask
# 参照値の丸めの違い(公開されている表どうしで小数第 2 位程度ずれる)を許す
TOL = 0.05
def _lab(rgb: tuple[int, int, int]) -> np.ndarray:
return rgb8_to_lab(np.array(rgb, dtype=np.uint8))
class TestRgbToLab:
@pytest.mark.parametrize(
("rgb", "expected"),
[
((255, 255, 255), (100.0, 0.0, 0.0)),
((0, 0, 0), (0.0, 0.0, 0.0)),
((255, 0, 0), (53.24, 80.09, 67.20)),
((0, 255, 0), (87.73, -86.18, 83.18)),
((0, 0, 255), (32.30, 79.19, -107.86)),
((128, 128, 128), (53.59, 0.0, 0.0)),
((119, 119, 119), (50.03, 0.0, 0.0)),
],
)
def test_known_colors(
self, rgb: tuple[int, int, int], expected: tuple[float, float, float]
) -> None:
assert _lab(rgb) == pytest.approx(expected, abs=TOL)
def test_gray_has_zero_chroma(self) -> None:
# D65 白色点で正規化しているため,無彩色は a* = b* = 0 になる
for v in (1, 50, 200, 254):
lab = _lab((v, v, v))
assert lab[1] == pytest.approx(0.0, abs=1e-6)
assert lab[2] == pytest.approx(0.0, abs=1e-6)
def test_lightness_is_monotonic_in_gray(self) -> None:
values = [_lab((v, v, v))[0] for v in range(0, 256, 5)]
assert values == sorted(values)
def test_vectorized_shape(self) -> None:
pixels = np.zeros((4, 5, 3), dtype=np.uint8)
pixels[..., 0] = 255
lab = rgb8_to_lab(pixels)
assert lab.shape == (4, 5, 3)
assert lab[2, 3] == pytest.approx((53.24, 80.09, 67.20), abs=TOL)
class TestTongueMask:
def test_only_all_zero_pixels_are_background(self) -> None:
pixels = np.array(
[[[0, 0, 0], [0, 0, 1]], [[1, 0, 0], [0, 1, 0]]],
dtype=np.uint8,
)
mask = tongue_mask(pixels)
assert mask.shape == (2, 2)
assert mask.tolist() == [[False, True], [True, True]]
class TestMeanTongueLab:
def test_background_is_excluded(self) -> None:
# 半分が背景(黒),半分が白 → 舌領域だけの平均なので白の Lab になる(黒と混ぜない)
pixels = np.zeros((10, 10, 3), dtype=np.uint8)
pixels[:, :5] = 255
assert mean_tongue_lab(pixels) == pytest.approx((100.0, 0.0, 0.0), abs=TOL)
def test_mean_over_tongue_pixels(self) -> None:
# 舌領域が白と赤の半々 → 各画素の Lab の平均
pixels = np.zeros((6, 6, 3), dtype=np.uint8)
pixels[:3, :3] = (255, 255, 255)
pixels[3:, :3] = (255, 0, 0)
expected = (_lab((255, 255, 255)) + _lab((255, 0, 0))) / 2
assert mean_tongue_lab(pixels) == pytest.approx(expected, abs=1e-6)
def test_no_tongue_pixels_does_not_crash(self) -> None:
pixels = np.zeros((4, 4, 3), dtype=np.uint8)
assert mean_tongue_lab(pixels) is None
class TestDeltaE:
def test_euclidean_distance(self) -> None:
assert delta_e_cie76(np.array([50.0, 0.0, 0.0]), np.array([53.0, 4.0, 0.0])) == (
pytest.approx(5.0)
)
assert delta_e_cie76(np.array([10.0, 20.0, 30.0]), np.array([10.0, 20.0, 30.0])) == 0.0
def test_symmetric(self) -> None:
a, b = _lab((255, 0, 0)), _lab((0, 0, 255))
assert delta_e_cie76(a, b) == pytest.approx(delta_e_cie76(b, a))
def test_white_vs_black_is_100(self) -> None:
assert delta_e_cie76(_lab((255, 255, 255)), _lab((0, 0, 0))) == pytest.approx(100.0)
def test_red_vs_white(self) -> None:
# sqrt((100-53.24)^2 + 80.09^2 + 67.20^2) ≈ 114.56
assert delta_e_cie76(_lab((255, 0, 0)), _lab((255, 255, 255))) == pytest.approx(
114.56, abs=0.05
)