"""セグメンテーションの評価(`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