Newer
Older
TIASJudgeModel / tests / cli / test_seg.py
"""tias-seg eval の結合テスト.

仕様(実装サマリーを仕様として扱う.出力の規則は SPEC_01「実験の出力」「回収ホワイトリスト」):

- 出力: `metrics.json`・`log.txt`・`config.yaml`・`figures/seg_iou_hist_<評価セット>.png`
  (回収対象)と `predictions.csv`(stem を含む.回収対象外)
- 最後に実験 ID を標準出力へ出す
- 回収対象・標準出力・ログにファイル名(stem)を出さない
- 環境変数 `TIAS_SEG_DATA_ROOT`・`TIAS_SEG_MODEL_ROOT` で `data.root`・`models.root` を上書きする
- 入力の問題(正解マスクに前景が無い等)は終了コード 1(ログに評価セット名と何件目かを出し,
  ファイル名は出さない)
"""

from __future__ import annotations

import contextlib
import csv
import io
import json
import logging
import re
import shutil
from pathlib import Path

import numpy as np
import pytest
import yaml
from PIL import Image

import tias_judge_model.cli.seg as cli
from tias_judge_model.remote.whitelist import is_pull_allowed

SECRET = "SECRETPT"
GIT = "abc1234"
PER_SET = 3
NAME = "seg_eval_tiny"
SETS = ["train", "test_cropped", "test_extra_cropped", "test_fullsize"]
RECOVERABLE = {"metrics.json", "log.txt", "config.yaml"} | {
    f"figures/seg_iou_hist_{s}.png" for s in SETS
}


def _invoke(argv: list[str]) -> tuple[int, str, str]:
    root_logger = logging.getLogger()
    old_level = root_logger.level
    root_logger.setLevel(logging.INFO)
    out, err = io.StringIO(), io.StringIO()
    handler = logging.StreamHandler(err)
    root_logger.addHandler(handler)
    try:
        with contextlib.redirect_stdout(out):
            rc = cli.main(argv)
    finally:
        root_logger.removeHandler(handler)
        root_logger.setLevel(old_level)
    return rc, out.getvalue(), err.getvalue()


def _config_doc() -> dict:
    # data.root・models.root は環境変数で上書きする(設定には存在しない場所を書く)
    return {
        "data": {
            "root": "does/not/exist",
            "sets": [
                {"name": "train", "dir": "train", "kind": "cropped"},
                {"name": "test_cropped", "dir": "test/cropped", "kind": "cropped"},
                {"name": "test_extra_cropped", "dir": "test_extra/cropped", "kind": "cropped"},
                {"name": "test_fullsize", "dir": "test/fullsize", "kind": "fullsize"},
            ],
        },
        "models": {"root": "does/not/exist", "detection": "Detection_Normal.onnx"},
    }


def _rename_stems(root: Path) -> None:
    n = 0
    for label_dir in sorted(root.rglob("label")):
        sample_dir = label_dir.parent / "sample"
        for label in sorted(label_dir.iterdir()):
            sample = sample_dir / label.name
            new = f"{SECRET}{n:04d}{label.suffix}"
            label.rename(label_dir / new)
            sample.rename(sample_dir / new)
            n += 1


@pytest.fixture(scope="module")
def run(tmp_path_factory: pytest.TempPathFactory, dummy_seg_models: dict, script_loader) -> dict:
    tmp = tmp_path_factory.mktemp("seg_cli")
    script_loader("make_synthetic_segmentation").generate(tmp / "syn", PER_SET, seed=1)
    data_root = tmp / "syn" / "input"
    _rename_stems(data_root)
    config = tmp / f"{NAME}.yaml"
    config.write_text(yaml.safe_dump(_config_doc()), encoding="utf-8")
    out_root = tmp / "runs"
    mp = pytest.MonkeyPatch()
    mp.setenv("TIAS_SEG_DATA_ROOT", str(data_root))
    mp.setenv("TIAS_SEG_MODEL_ROOT", str(dummy_seg_models["root"]))
    mp.setenv("TIAS_GIT_COMMIT", GIT)
    try:
        rc, stdout, stderr = _invoke(["eval", "--config", str(config), "--out-root", str(out_root)])
    finally:
        mp.undo()
    run_dirs = [p for p in out_root.iterdir() if p.is_dir()] if out_root.exists() else []
    return {
        "tmp": tmp,
        "data_root": data_root,
        "models": dummy_seg_models,
        "rc": rc,
        "stdout": stdout,
        "stderr": stderr,
        "run_dirs": run_dirs,
        "run_dir": run_dirs[0] if run_dirs else None,
    }


def test_exit_code_and_single_run_dir(run: dict) -> None:
    assert run["rc"] == 0, run["stderr"][-2000:]
    assert len(run["run_dirs"]) == 1


def test_run_id_is_last_stdout_line(run: dict) -> None:
    run_id = run["run_dir"].name
    assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{NAME}_{GIT}", run_id)
    lines = [ln for ln in run["stdout"].splitlines() if ln.strip()]
    assert lines == [run_id]


def test_outputs_and_pull_whitelist(run: dict) -> None:
    d = run["run_dir"]
    files = {p.relative_to(d).as_posix() for p in d.rglob("*") if p.is_file()}
    assert files == RECOVERABLE | {"predictions.csv"}
    for name in RECOVERABLE:
        assert is_pull_allowed(name, with_checkpoints=False), name
    assert not is_pull_allowed("predictions.csv", with_checkpoints=True)


def test_no_file_names_in_recoverable_outputs_stdout_or_log(run: dict) -> None:
    d = run["run_dir"]
    for name in RECOVERABLE:
        data = (d / name).read_bytes()
        assert SECRET.encode() not in data, name
    assert SECRET not in run["stdout"]
    assert SECRET not in run["stderr"]


def test_predictions_csv_has_per_image_rows(run: dict) -> None:
    with (run["run_dir"] / "predictions.csv").open(encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    # cropped 3 セット × 1 方式 + fullsize × 3 方式
    assert len(rows) == PER_SET * (3 + 3)
    assert all(r["stem"].startswith(SECRET) for r in rows)


def test_metrics_structure(run: dict) -> None:
    m = json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8"))
    assert m["run_id"] == run["run_dir"].name
    assert m["git_commit"] == GIT
    assert m["detector"]["used"] is True
    assert set(m["sets"]) == set(SETS)
    for name in SETS:
        s = m["sets"][name]
        assert s["n_images"] == PER_SET
        expected = ["whole", "gt_bbox", "detector"] if name == "test_fullsize" else ["whole"]
        assert list(s["methods"]) == expected
        for method in s["methods"].values():
            for key in ("iou", "dice", "iou_no_post", "dice_no_post", "iou_other_channel"):
                summary = method[key]
                assert summary["n"] == PER_SET
                assert {"mean", "median", "p10", "p25", "min", "max", "n_below"} <= set(summary)
                assert summary["below_threshold"] == 0.8
    det = m["sets"]["test_fullsize"]["methods"]["detector"]["detection"]
    assert det["n_detected"] + det["n_not_detected"] == PER_SET
    assert {"total_seconds", "loading_seconds"} <= set(m["timing"])


def test_config_yaml_records_resolved_roots(run: dict) -> None:
    doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8"))
    assert doc["run"]["command"] == "seg eval"
    assert doc["run"]["git_commit"] == GIT
    assert Path(doc["config"]["data"]["root"]) == run["data_root"]


def test_bad_label_fails_with_position_but_no_file_name(run: dict) -> None:
    tmp = run["tmp"]
    root = tmp / "bad" / "input"
    shutil.copytree(run["data_root"] / "train", root / "train")
    label = sorted((root / "train" / "label").iterdir())[0]
    with Image.open(label) as img:
        w, h = img.size
    Image.fromarray(np.zeros((h, w), np.uint8)).save(label)
    doc = _config_doc()
    doc["data"]["root"] = root.as_posix()
    doc["data"]["sets"] = [{"name": "train", "dir": "train", "kind": "cropped"}]
    doc["models"]["root"] = run["models"]["root"].as_posix()
    config = tmp / "seg_bad.yaml"
    config.write_text(yaml.safe_dump(doc), encoding="utf-8")
    out_root = tmp / "runs_bad"
    mp = pytest.MonkeyPatch()
    mp.delenv("TIAS_SEG_DATA_ROOT", raising=False)
    mp.delenv("TIAS_SEG_MODEL_ROOT", raising=False)
    try:
        rc, stdout, stderr = _invoke(["eval", "--config", str(config), "--out-root", str(out_root)])
    finally:
        mp.undo()
    assert rc == 1
    assert stdout.strip() == ""
    assert "1 件目" in stderr
    assert SECRET not in stderr
    (run_dir,) = [p for p in out_root.iterdir() if p.is_dir()]
    log = (run_dir / "log.txt").read_text(encoding="utf-8")
    assert "1 件目" in log
    assert SECRET not in log


def test_invalid_config_returns_1(run: dict) -> None:
    config = run["tmp"] / "seg_invalid.yaml"
    config.write_text(yaml.safe_dump({"data": {"root": "x", "sets": []}}), encoding="utf-8")
    rc, stdout, _ = _invoke(["eval", "--config", str(config), "--out-root", str(run["tmp"] / "r")])
    assert rc == 1
    assert stdout.strip() == ""