Newer
Older
TIASJudgeModel / tests / data / test_color.py
"""舌領域の色(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
        )