Newer
Older
TIASJudgeModel / tests / evaluation / test_segmentation_eval.py
"""セグメンテーションの評価(`evaluation/segmentation_eval.py`)のテスト.

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

- 評価セットの種類ごとの方式: `cropped` は `whole`,`fullsize` は `whole`・`gt_bbox`・`detector`
  (検出モデルが無ければ警告して `detector` を省く.`models.detection: null` も省く)
- 方式ごとに IoU・Dice(後処理あり)・後処理なし・もう一方のチャンネルの IoU を集計する
- 検出できない画像は空マスク(IoU 0)として数え,`n_not_detected` に入れる
- 正解マスクに前景が無い・入力画像とサイズが違うと ValueError(評価セット名と何件目かで示し,
  ファイル名を出さない)

合成データ(scripts/make_synthetic_segmentation.py)とダミーの ONNX で動かす.
"""

from __future__ import annotations

import json
import logging
import shutil
from pathlib import Path

import numpy as np
import pytest
from PIL import Image

from tias_judge_model.evaluation.segmentation_eval import (
    METHOD_DETECTOR,
    METHOD_GT_BBOX,
    METHOD_WHOLE,
    ImageScore,
    SegEvalResult,
    methods_for,
    run_segmentation_eval,
    summarize_segmentation,
)
from tias_judge_model.schema import SegEvalConfig

PER_SET = 4
SECRET = "SECRETPT"


def _config(data_root: Path, model_root: Path, **overrides) -> SegEvalConfig:
    raw = {
        "data": {
            "root": data_root.as_posix(),
            "sets": [
                {"name": "test_cropped", "dir": "test/cropped", "kind": "cropped"},
                {"name": "test_fullsize", "dir": "test/fullsize", "kind": "fullsize"},
            ],
        },
        "models": {"root": model_root.as_posix(), "detection": "Detection_Normal.onnx"},
    }
    for key, value in overrides.items():
        raw[key] = {**raw.get(key, {}), **value}
    return SegEvalConfig.model_validate(raw)


@pytest.fixture(scope="module")
def seg_data(tmp_path_factory: pytest.TempPathFactory, script_loader) -> Path:
    out = tmp_path_factory.mktemp("seg_eval") / "synthetic_seg"
    script_loader("make_synthetic_segmentation").generate(out, PER_SET, seed=0)
    return out / "input"


# --- 方式 ---


def test_methods_for_each_kind() -> None:
    assert methods_for("cropped", True) == [METHOD_WHOLE]
    assert methods_for("cropped", False) == [METHOD_WHOLE]
    assert methods_for("fullsize", True) == [METHOD_WHOLE, METHOD_GT_BBOX, METHOD_DETECTOR]
    assert methods_for("fullsize", False) == [METHOD_WHOLE, METHOD_GT_BBOX]


# --- 集計(手で作った個票) ---


def _score(method: str, iou: float, **kw) -> ImageScore:
    base = {
        "set_name": "s",
        "index": 1,
        "stem": SECRET,
        "method": method,
        "iou": iou,
        "dice": 2 * iou / (1 + iou),
        "iou_no_post": iou,
        "dice_no_post": 2 * iou / (1 + iou),
        "iou_other_channel": 0.0,
        "postprocess_applied": False,
        "detected": None,
        "detection_score": None,
        "seconds": 0.01,
    }
    base.update(kw)
    return ImageScore(**base)


def test_summarize_counts_not_detected_as_zero_iou() -> None:
    config = SegEvalConfig.model_validate(
        {
            "data": {"root": "r", "sets": [{"name": "s", "dir": "d", "kind": "fullsize"}]},
            "models": {"root": "m"},
        }
    )
    result = SegEvalResult(
        scores=[
            _score(METHOD_WHOLE, 0.9, postprocess_applied=True),
            _score(METHOD_WHOLE, 0.7),
            _score(METHOD_DETECTOR, 0.85, detected=True, detection_score=0.9),
            _score(METHOD_DETECTOR, 0.0, detected=False, detection_score=0.3),
            _score(METHOD_DETECTOR, 0.0, detected=False, detection_score=None),
        ],
        methods={"s": [METHOD_WHOLE, METHOD_DETECTOR]},
        detector_used=True,
    )
    sets = summarize_segmentation(result, config)
    s = sets["s"]
    assert s["kind"] == "fullsize"
    assert s["dir"] == "d"
    whole = s["methods"][METHOD_WHOLE]
    assert whole["iou"]["n"] == 2
    assert whole["iou"]["mean"] == pytest.approx(0.8)
    assert whole["iou"]["n_below"] == 1
    assert whole["n_postprocess_applied"] == 1
    det = s["methods"][METHOD_DETECTOR]
    assert det["iou"]["n"] == 3
    assert det["iou"]["min"] == 0.0
    assert det["detection"]["n_detected"] == 1
    assert det["detection"]["n_not_detected"] == 2
    assert det["detection"]["top_score"]["n"] == 2
    assert det["detection"]["score_threshold"] == 0.5
    assert "detection" not in whole
    # 集計に stem を含めない
    assert SECRET not in json.dumps(sets, ensure_ascii=False)


# --- 合成データでの評価 ---


def test_run_on_synthetic_data_with_detector(seg_data: Path, dummy_seg_models: dict) -> None:
    config = _config(seg_data, dummy_seg_models["root"])
    result = run_segmentation_eval(config)
    assert result.detector_used
    assert result.methods == {
        "test_cropped": [METHOD_WHOLE],
        "test_fullsize": [METHOD_WHOLE, METHOD_GT_BBOX, METHOD_DETECTOR],
    }
    assert len(result.scores) == PER_SET * (1 + 3)
    # 同じセット内の index は stem の昇順で 1 始まり
    idx = [s.index for s in result.scores if s.set_name == "test_cropped"]
    assert idx == list(range(1, PER_SET + 1))
    for s in result.scores:
        assert 0.0 <= s.iou <= 1.0 and 0.0 <= s.dice <= 1.0
        if s.method == METHOD_DETECTOR:
            assert s.detected is True
        else:
            assert s.detected is None and s.detection_score is None
    # ダミーのモデルは赤い楕円を前景とするため,gt_bbox・cropped はよく一致する
    for s in result.scores:
        if s.method in (METHOD_GT_BBOX,) or s.set_name == "test_cropped":
            assert s.iou > 0.8
        # もう一方のチャンネル(ダミーは反転)はほぼ一致しない
        assert s.iou_other_channel < 0.5
    sets = summarize_segmentation(result, config)
    assert sets["test_fullsize"]["n_images"] == PER_SET
    assert sets["test_fullsize"]["methods"][METHOD_DETECTOR]["detection"]["n_not_detected"] == 0


def test_not_detected_images_are_empty_masks(seg_data: Path, dummy_seg_models: dict) -> None:
    # ダミーの検出スコアは 0.9 → しきい値 0.95 では検出できない
    config = _config(seg_data, dummy_seg_models["root"], detection={"score_threshold": 0.95})
    result = run_segmentation_eval(config)
    det = [s for s in result.scores if s.method == METHOD_DETECTOR]
    assert len(det) == PER_SET
    assert all(s.detected is False for s in det)
    assert all(s.iou == 0.0 and s.dice == 0.0 and s.iou_no_post == 0.0 for s in det)
    assert all(s.detection_score == pytest.approx(0.9) for s in det)
    sets = summarize_segmentation(result, config)
    d = sets["test_fullsize"]["methods"][METHOD_DETECTOR]["detection"]
    assert d["n_not_detected"] == PER_SET
    assert d["n_detected"] == 0


def test_missing_detector_file_is_skipped_with_warning(
    seg_data: Path, dummy_seg_models: dict, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    models = tmp_path / "models"
    models.mkdir()
    shutil.copy(dummy_seg_models["resunet"], models / "ResUNet.onnx")
    config = _config(seg_data, models)
    with caplog.at_level(logging.WARNING):
        result = run_segmentation_eval(config)
    assert not result.detector_used
    assert result.detector_note
    assert result.methods["test_fullsize"] == [METHOD_WHOLE, METHOD_GT_BBOX]
    assert any(r.levelno == logging.WARNING for r in caplog.records)


def test_detection_null_skips_detector(seg_data: Path, dummy_seg_models: dict) -> None:
    config = _config(seg_data, dummy_seg_models["root"], models={"detection": None})
    result = run_segmentation_eval(config)
    assert not result.detector_used
    assert result.detector_note
    assert METHOD_DETECTOR not in result.methods["test_fullsize"]


def test_missing_resunet_raises(seg_data: Path, tmp_path: Path) -> None:
    with pytest.raises(ValueError):
        run_segmentation_eval(_config(seg_data, tmp_path))


def _copy_set(seg_data: Path, tmp_path: Path) -> Path:
    root = tmp_path / "input"
    shutil.copytree(seg_data / "test" / "cropped", root / "test" / "cropped")
    shutil.copytree(seg_data / "test" / "fullsize", root / "test" / "fullsize")
    # stem を秘密の文字列にする(例外に出ないことを確かめる)
    for sub in ("sample", "label"):
        for n, p in enumerate(sorted((root / "test" / "cropped" / sub).iterdir())):
            p.rename(p.with_name(f"{SECRET}{n:02d}{p.suffix}"))
    return root


def test_label_without_foreground_raises_with_position(
    seg_data: Path, dummy_seg_models: dict, tmp_path: Path
) -> None:
    root = _copy_set(seg_data, tmp_path)
    labels = sorted((root / "test" / "cropped" / "label").iterdir())
    with Image.open(labels[1]) as img:
        size = img.size
    Image.fromarray(np.zeros((size[1], size[0]), np.uint8)).save(labels[1])
    with pytest.raises(ValueError) as exc:
        run_segmentation_eval(_config(root, dummy_seg_models["root"]))
    msg = str(exc.value)
    assert "test_cropped" in msg
    assert "2 件目" in msg
    assert SECRET not in msg


def test_label_size_mismatch_raises_with_position(
    seg_data: Path, dummy_seg_models: dict, tmp_path: Path
) -> None:
    root = _copy_set(seg_data, tmp_path)
    labels = sorted((root / "test" / "cropped" / "label").iterdir())
    Image.fromarray(np.full((10, 12), 255, np.uint8)).save(labels[0])
    with pytest.raises(ValueError) as exc:
        run_segmentation_eval(_config(root, dummy_seg_models["root"]))
    msg = str(exc.value)
    assert "test_cropped" in msg
    assert "1 件目" in msg
    assert SECRET not in msg