Newer
Older
TIASJudgeModel / tests / test_seg_config.py
"""セグメンテーションの評価の設定(`SegEvalConfig`・`load_seg_eval_config`)のテスト.

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

- `configs/seg/*.yaml` はすべて `load_seg_eval_config` で読める(`ExperimentConfig` とは別スキーマ)
- 環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` が空でなければ `data.root`・`models.root`
  を上書きする(`TIAS_DATA_ROOT` は使わない)
- 既定値は DeepTIAS 2.7 と同じ(入力 256・前景チャンネル 0・検出のしきい値 0.5・IoU 0.8)
- 評価セットの空・名前の重複・絶対パス・`..`・未知の kind・未知のキーは拒否
"""

from __future__ import annotations

from pathlib import Path

import pytest
import yaml
from pydantic import ValidationError

from tias_judge_model.schema import SegEvalConfig, load_seg_eval_config

CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs"
SEG_CONFIGS = sorted((CONFIGS_DIR / "seg").glob("*.yaml"))
NO_ENV: dict[str, str] = {}


def _raw(**overrides) -> dict:
    raw: dict = {
        "data": {"root": "data_root", "sets": [{"name": "s1", "dir": "a/b", "kind": "cropped"}]},
        "models": {"root": "model_root"},
    }
    raw.update(overrides)
    return raw


def _write(tmp_path: Path, raw: dict) -> Path:
    path = tmp_path / "seg.yaml"
    path.write_text(yaml.safe_dump(raw), encoding="utf-8")
    return path


# --- リポジトリの設定ファイル ---


def test_seg_configs_exist() -> None:
    assert {p.stem for p in SEG_CONFIGS} >= {"eval_synthetic", "eval_newton"}


@pytest.mark.parametrize("path", SEG_CONFIGS, ids=lambda p: p.name)
def test_every_seg_config_loads(path: Path) -> None:
    c = load_seg_eval_config(path, environ=NO_ENV)
    assert c.data.sets
    assert c.resunet.foreground_channel == 0
    assert c.resunet.input_size == 256
    assert c.detection.score_threshold == 0.5
    kinds = {s.kind for s in c.data.sets}
    assert kinds <= {"cropped", "fullsize"}


# --- 既定値 ---


def test_defaults_follow_deeptias() -> None:
    c = SegEvalConfig.model_validate(_raw())
    assert c.resunet.input_size == 256
    assert c.resunet.foreground_channel == 0
    assert c.detection.score_threshold == 0.5
    assert c.evaluation.iou_threshold == 0.8
    assert c.data.sample_dirname == "sample"
    assert c.data.label_dirname == "label"
    assert c.models.resunet_path == Path("model_root") / "ResUNet.onnx"
    assert c.models.detection_path is None


def test_detection_path_resolves_relative_to_models_root() -> None:
    c = SegEvalConfig.model_validate(
        _raw(models={"root": "mroot", "detection": "Detection_Normal.onnx"})
    )
    assert c.models.detection_path == Path("mroot") / "Detection_Normal.onnx"


# --- 環境変数による上書き ---


def test_env_overrides_data_and_model_roots(tmp_path: Path) -> None:
    path = _write(tmp_path, _raw())
    env = {"TIAS_SEG_DATA_ROOT": "/srv/seg_data", "TIAS_SEG_MODEL_ROOT": "/srv/seg_models"}
    c = load_seg_eval_config(path, environ=env)
    assert c.data.root == Path("/srv/seg_data")
    assert c.models.root == Path("/srv/seg_models")


def test_empty_env_does_not_override(tmp_path: Path) -> None:
    path = _write(tmp_path, _raw())
    c = load_seg_eval_config(path, environ={"TIAS_SEG_DATA_ROOT": "", "TIAS_SEG_MODEL_ROOT": ""})
    assert c.data.root == Path("data_root")
    assert c.models.root == Path("model_root")


def test_tias_data_root_is_not_used(tmp_path: Path) -> None:
    path = _write(tmp_path, _raw())
    c = load_seg_eval_config(path, environ={"TIAS_DATA_ROOT": "/other"})
    assert c.data.root == Path("data_root")
    assert c.models.root == Path("model_root")


def test_non_mapping_top_level_is_rejected(tmp_path: Path) -> None:
    path = tmp_path / "bad.yaml"
    path.write_text("- a\n- b\n", encoding="utf-8")
    with pytest.raises(ValueError):
        load_seg_eval_config(path, environ=NO_ENV)


# --- 不正な設定 ---


@pytest.mark.parametrize(
    "mutate",
    [
        lambda r: r["data"].update(sets=[]),
        lambda r: r["data"].update(
            sets=[
                {"name": "x", "dir": "a", "kind": "cropped"},
                {"name": "x", "dir": "b", "kind": "fullsize"},
            ]
        ),
        lambda r: r["data"].update(sets=[{"name": "x", "dir": "../a", "kind": "cropped"}]),
        lambda r: r["data"].update(sets=[{"name": "x", "dir": "a/../../b", "kind": "cropped"}]),
        lambda r: r["data"].update(sets=[{"name": "x", "dir": "a", "kind": "whole"}]),
        lambda r: r["data"].update(sets=[{"name": "x/y", "dir": "a", "kind": "cropped"}]),
        lambda r: r["data"].update(sets=[{"name": "", "dir": "a", "kind": "cropped"}]),
        lambda r: r["data"].update(label_threshold=0),
        lambda r: r["data"].update(label_threshold=256),
        lambda r: r["data"].update(unknown=1),
        lambda r: r.pop("models"),
        lambda r: r["data"].pop("sets"),
        lambda r: r.update(resunet={"foreground_channel": 2}),
        lambda r: r.update(postprocess={"min_area": -1}),
        lambda r: r.update(postprocess={"mask_threshold": 255}),
        lambda r: r.update(detection={"score_threshold": 1.0}),
        lambda r: r.update(detection={"input_color": "gray"}),
        lambda r: r.update(evaluation={"iou_threshold": 0.0}),
        lambda r: r.update(unknown={}),
    ],
    ids=[
        "empty_sets",
        "duplicate_names",
        "dotdot_dir",
        "nested_dotdot_dir",
        "unknown_kind",
        "name_with_slash",
        "empty_name",
        "label_threshold_0",
        "label_threshold_256",
        "unknown_data_key",
        "missing_models",
        "missing_sets",
        "bad_channel",
        "negative_min_area",
        "mask_threshold_255",
        "score_threshold_1",
        "bad_input_color",
        "iou_threshold_0",
        "unknown_top_key",
    ],
)
def test_invalid_seg_settings_are_rejected(mutate) -> None:
    raw = _raw()
    mutate(raw)
    with pytest.raises(ValidationError):
        SegEvalConfig.model_validate(raw)


def test_absolute_set_dir_is_rejected(tmp_path: Path) -> None:
    raw = _raw()
    raw["data"]["sets"] = [{"name": "x", "dir": tmp_path.as_posix(), "kind": "cropped"}]
    with pytest.raises(ValidationError):
        SegEvalConfig.model_validate(raw)