Newer
Older
TIASJudgeModel / tests / evaluation / test_segmentation_metrics.py
"""セグメンテーションの指標(`evaluation/segmentation_metrics.py`)のテスト.

仕様(実装サマリーを仕様として扱う):

- IoU = |P ∩ T| / |P ∪ T|,Dice = 2|P ∩ T| / (|P| + |T|)(前景について)
- 予測が空で正解に前景があれば IoU = Dice = 0(検出できなかった画像の扱い)
- 両方とも前景が無い場合は一致とみなして 1.0(評価では正解に前景が無い画像を ValueError で
  止めるため,評価の集計には現れない)
- 形状が違えば ValueError
- 集計: n・平均・中央値・p10・p25・最小・最大・しきい値**未満**の件数.0 件なら n = 0 で他は None
"""

from __future__ import annotations

import numpy as np
import pytest

from tias_judge_model.evaluation.segmentation_metrics import iou_and_dice, summarize_scores


def _mask(shape: tuple[int, int], *slices: tuple[slice, slice]) -> np.ndarray:
    m = np.zeros(shape, bool)
    for s in slices:
        m[s] = True
    return m


def test_identical_masks() -> None:
    m = _mask((10, 10), (slice(2, 6), slice(2, 6)))
    assert iou_and_dice(m, m) == (1.0, 1.0)


def test_known_overlap() -> None:
    # P: 4×4 = 16,T: 4×4 = 16,重なり 2×4 = 8 → IoU 8/24,Dice 16/32
    p = _mask((10, 10), (slice(0, 4), slice(0, 4)))
    t = _mask((10, 10), (slice(2, 6), slice(0, 4)))
    iou, dice = iou_and_dice(p, t)
    assert iou == pytest.approx(8 / 24)
    assert dice == pytest.approx(0.5)


def test_dice_iou_relation_holds() -> None:
    rng = np.random.default_rng(0)
    for _ in range(20):
        p = rng.random((16, 16)) > 0.5
        t = rng.random((16, 16)) > 0.5
        iou, dice = iou_and_dice(p, t)
        assert dice == pytest.approx(2 * iou / (1 + iou))


def test_disjoint_masks_are_zero() -> None:
    p = _mask((6, 6), (slice(0, 2), slice(0, 2)))
    t = _mask((6, 6), (slice(4, 6), slice(4, 6)))
    assert iou_and_dice(p, t) == (0.0, 0.0)


def test_empty_prediction_is_zero() -> None:
    t = _mask((6, 6), (slice(1, 3), slice(1, 3)))
    assert iou_and_dice(np.zeros((6, 6), bool), t) == (0.0, 0.0)


def test_empty_target_with_prediction_is_zero() -> None:
    p = _mask((6, 6), (slice(1, 3), slice(1, 3)))
    assert iou_and_dice(p, np.zeros((6, 6), bool)) == (0.0, 0.0)


def test_both_empty_counts_as_match() -> None:
    z = np.zeros((6, 6), bool)
    assert iou_and_dice(z, z) == (1.0, 1.0)


def test_non_bool_inputs_are_treated_as_masks() -> None:
    p = np.array([[0, 255], [255, 0]], np.uint8)
    t = np.array([[0, 1], [0, 0]], np.int64)
    iou, dice = iou_and_dice(p, t)
    assert iou == pytest.approx(0.5)
    assert dice == pytest.approx(2 / 3)


def test_shape_mismatch_raises() -> None:
    with pytest.raises(ValueError):
        iou_and_dice(np.zeros((4, 4), bool), np.zeros((4, 5), bool))


def test_summarize_scores_known_values() -> None:
    values = [0.9, 0.5, 0.8, 0.95, 0.7]
    s = summarize_scores(values, 0.8)
    assert s["n"] == 5
    assert s["mean"] == pytest.approx(np.mean(values))
    assert s["median"] == pytest.approx(0.8)
    assert s["p10"] == pytest.approx(np.percentile(values, 10))
    assert s["p25"] == pytest.approx(0.7)
    assert s["min"] == 0.5
    assert s["max"] == 0.95
    assert s["n_below"] == 2  # 0.5・0.7(0.8 ちょうどは数えない)
    assert s["below_threshold"] == 0.8


def test_summarize_scores_empty() -> None:
    s = summarize_scores([], 0.8)
    assert s["n"] == 0
    for key in ("mean", "median", "p10", "p25", "min", "max", "n_below"):
        assert s[key] is None
    assert s["below_threshold"] == 0.8


def test_summarize_scores_values_are_json_native() -> None:
    s = summarize_scores(np.array([0.1, 0.2], np.float32), 0.5)
    assert isinstance(s["n"], int) and isinstance(s["n_below"], int)
    assert all(isinstance(s[k], float) for k in ("mean", "median", "p10", "p25", "min", "max"))