Newer
Older
TIASJudgeModel / tests / data / test_augmentation.py
"""データ拡張と Dataset のテスト(SPEC_02「データ拡張」「学習の設定」).

- **色を変える拡張は行わない**: 一様な色の画像に拡張をかけても,舌領域の画素は元の色のまま
  (はみ出しの黒との境界の補間を除き,黒と元の色の混合=元の色の定数倍にしかならない)
- 拡張は幾何変換(左右反転等)であり,実際に画像が変わりうる
- 評価時(augment=False)は拡張しない
- 正規化は ImageNet の mean / std
"""

from __future__ import annotations

import numpy as np
import pytest
import torch
from torchvision.transforms import v2

from tias_judge_model.data.dataset import (
    TongueImageDataset,
    make_train_augmentation,
    normalize_batch,
)

COLOR = np.array([190, 110, 95], dtype=np.uint8)
N_TRIALS = 40


def _uniform_tongue(size: int = 64) -> np.ndarray:
    """黒背景の中央に一様な色の領域がある画像 `(H, W, 3)`."""
    img = np.zeros((size, size, 3), dtype=np.uint8)
    img[8 : size - 8, 12 : size - 12] = COLOR
    return img


def _flatten_transforms(t: object) -> list[object]:
    inner = getattr(t, "transforms", None)
    if inner is None:
        return [t]
    out: list[object] = []
    for x in inner:
        out.extend(_flatten_transforms(x))
    return out


def test_augmentation_contains_no_color_transforms() -> None:
    forbidden = (
        v2.ColorJitter,
        v2.Grayscale,
        v2.RandomGrayscale,
        v2.RandomAutocontrast,
        v2.RandomEqualize,
        v2.RandomPosterize,
        v2.RandomSolarize,
        v2.RandomInvert,
        v2.RandomAdjustSharpness,
        v2.RandomPhotometricDistort,
        v2.RandomChannelPermutation,
        v2.AutoAugment,
        v2.RandAugment,
        v2.TrivialAugmentWide,
        v2.AugMix,
    )
    for t in _flatten_transforms(make_train_augmentation()):
        assert not isinstance(t, forbidden), f"色を変える変換が含まれる: {type(t).__name__}"


def test_augmentation_does_not_change_pixel_colors() -> None:
    torch.manual_seed(0)
    ds = TongueImageDataset(_uniform_tongue()[None], np.array([2]), augment=True)
    color = COLOR.astype(np.float64)
    for _ in range(N_TRIALS):
        image, label = ds[0]
        assert label == 2
        arr = image.permute(1, 2, 0).numpy().astype(np.float64).reshape(-1, 3)
        nonblack = arr[arr.any(axis=1)]
        assert len(nonblack) > 0
        # 画素は黒と元の色の混合(= 元の色の t 倍)でしかない(色相・彩度が変わらない)
        t = nonblack.sum(axis=1, keepdims=True) / color.sum()
        assert np.abs(nonblack - t * color).max() <= 2.0
        assert t.max() <= 1.0 + 1e-2  # 明るくならない
        # 舌領域の大半は元の色そのまま
        exact = np.all(np.abs(nonblack - color) <= 1, axis=1)
        assert exact.mean() > 0.8


def test_augmentation_keeps_shape_and_dtype() -> None:
    torch.manual_seed(1)
    ds = TongueImageDataset(_uniform_tongue(48)[None], np.array([0]), augment=True)
    image, _ = ds[0]
    assert image.shape == (3, 48, 48)
    assert image.dtype == torch.uint8


def test_augmentation_actually_transforms_geometry() -> None:
    # 左右非対称な画像で,拡張をかけると少なくとも 1 回は元と異なる
    img = np.zeros((32, 32, 3), dtype=np.uint8)
    img[4:28, 2:12] = COLOR
    ds = TongueImageDataset(img[None], np.array([0]), augment=True)
    original = torch.from_numpy(img).permute(2, 0, 1)
    torch.manual_seed(2)
    assert any(not torch.equal(ds[0][0], original) for _ in range(N_TRIALS))


def test_eval_dataset_is_not_augmented() -> None:
    img = _uniform_tongue(32)
    img[0, 0] = (1, 2, 3)  # 非対称にする
    ds = TongueImageDataset(img[None], np.array([4]), augment=False)
    original = torch.from_numpy(img).permute(2, 0, 1)
    for _ in range(10):
        image, label = ds[0]
        assert torch.equal(image, original)
        assert label == 4


def test_dataset_length_mismatch_raises() -> None:
    with pytest.raises(Exception):  # noqa: B017 件数不一致で止まれば型は問わない
        TongueImageDataset(np.zeros((2, 8, 8, 3), dtype=np.uint8), np.array([0]), augment=False)


def test_normalize_batch_uses_imagenet_stats() -> None:
    mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
    std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
    batch = torch.tensor(np.stack([_uniform_tongue(8)] * 2)).permute(0, 3, 1, 2)
    out = normalize_batch(batch)
    assert out.dtype == torch.float32
    expected = (batch.float() / 255.0 - mean) / std
    assert torch.allclose(out, expected, atol=1e-5)
    # 黒画素は -mean/std
    assert torch.allclose(out[0, :, 0, 0], (-mean / std).view(3), atol=1e-5)