Files
CADx-4/tests/test_ceus_quant.py
T
iomgaa c582fe426f feat(build): 任务书 02 第四阶段代码——build 装入 CEUS 结果
- build.yaml 加必填字段 ceus_run(CEUS 运行目录或 null)。
- 不为 null 时按视频号找结果;在 failed.csv 里或缺结果目录的整例跳过,记 C15;关键帧图沿用 C14 检查。
- 资料库超声一路加 keyframes/<槽位>.png(去附加数据重编码)和 tic.json(白名单逐字段构造:
  四条曲线的拟合成败与 R²、灌注指标、紫圈曲线是否可用、7 个槽位的定位与视频时间或原因)。
- manifest 的 CEUS 来源写运行号;泄露扫描已覆盖 tic.json,补命中测试。
- 真实构建等配准改完、全量 CEUS 运行之后再做。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 05:55:57 -04:00

321 lines
14 KiB
Python

"""`scripts/ceus_quant.py`:逐例循环、failed.csv、输出布局、分割缓存。
假交付包两例:第 1 例是能过质量门的合成分屏造影视频(造影在右半,绿框是病灶的空心描边),第 2 例
同样的画面但没有绿框。分割换成计数的假函数(monkeypatch 脚本里引用的 `segment_video`),交出病灶
方块挪到左半局部坐标的掩码,不调显卡。
手算的预期(视频 400×200、10 fps、40 秒,推注时间 X = 5 秒,同 test_ceus_pipeline):
- 第 2 例两半都没有绿框 → 第 2 步失败,进 failed.csv,没有输出目录,也不调分割;
- 第 1 例 F1 在视频第 20 − 5 = 15 秒,有关键帧图;F2 在第 55 秒,超出视频,未定位,没有图。
"""
import csv
import importlib.util
import json
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
from pathlib import Path
from types import ModuleType
from typing import Any
import cv2
import numpy as np
import numpy.typing as npt
import pytest
import yaml
from cadx.answers import read_aliases
from cadx.build import run_build
from cadx.ceus import ANCHOR_SLOTS, LognormalParameters, evaluate_lognormal
from cadx.library import list_aliases, read_ultrasound
from conftest import CEUS_YAML
from fake_delivery import REPO_ROOT, make_delivery, write_config
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_quant.py"
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
_FPS, _FRAMES = 10.0, 400
_LESION = (250, 60, 369, 159) # 整帧坐标 x1, y1, x2, y2(含端点)
_CYST = (290, 95, 320, 125)
_BOLUS = LognormalParameters(area=1300.0, mu=2.4, sigma=0.45, arrival_offset_s=4.0, baseline=40.0)
def _load_script() -> ModuleType:
spec = importlib.util.spec_from_file_location("ceus_quant", _SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_video(path: Path, green_box: bool) -> None:
rng = np.random.default_rng(3)
intensity = evaluate_lognormal(np.arange(_FRAMES) / _FPS, _BOLUS)
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), _FPS, (_WIDTH, _HEIGHT))
x1, y1, x2, y2 = _LESION
for index in range(_FRAMES):
frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8)
frame[:, :_SPLIT] = 120
frame[:, _SPLIT + 1 :] = 30
lesion = np.clip(
intensity[index] + rng.normal(0.0, 6.0, (y2 - y1 + 1, x2 - x1 + 1)), 0, 255
)
frame[y1 : y2 + 1, x1 : x2 + 1] = lesion.astype(np.uint8)[:, :, None]
cx1, cy1, cx2, cy2 = _CYST
frame[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10
if green_box:
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
def _config_dict(tmp_path: Path, delivery_dir: Path) -> dict[str, Any]:
document: dict[str, Any] = yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))
document["paths"] = {
"delivery_dir": str(delivery_dir),
"runs_root": str(tmp_path / "runs"),
"work_root": str(tmp_path / "work"),
}
checkpoint = tmp_path / "fake_checkpoint.pt"
checkpoint.write_bytes(b"fake weights")
document["segment"].update(
{"medsam2_dir": str(tmp_path / "MedSAM2"), "checkpoint": str(checkpoint), "device": "cpu"}
)
return document
def _write_config(tmp_path: Path, document: dict[str, Any], name: str) -> Path:
path = tmp_path / name
path.write_text(yaml.safe_dump(document, allow_unicode=True), encoding="utf-8")
return path
class _CountingSegment:
"""假分割:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。"""
def __init__(self) -> None:
self.calls = 0
def __call__(
self,
frames: Sequence[npt.NDArray[np.uint8]],
prompt_masks: Mapping[int, npt.NDArray[np.bool_]],
cfg: object,
) -> npt.NDArray[np.bool_]:
self.calls += 1
height, width = frames[0].shape[:2]
masks = np.zeros((len(frames), height, width), dtype=bool)
x1, y1, x2, y2 = _LESION
masks[:, y1 + 3 : y2 - 2, x1 - _SPLIT + 3 : x2 - _SPLIT - 2] = True
return masks
@pytest.fixture
def setup(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]]:
"""写假交付包(两例真视频),载入脚本并换掉分割、MedSAM2 提交号和运行号。"""
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
_write_video(delivery_dir / "CEUS_agent" / "001" / "U001_roi2.mp4", green_box=True)
_write_video(delivery_dir / "CEUS_agent" / "002" / "U002_roi2.mp4", green_box=False)
module = _load_script()
segment = _CountingSegment()
monkeypatch.setattr(module, "segment_video", segment)
run_ids: list[str] = []
def fake_commit(medsam2_dir: Path) -> str:
return "c" * 40
def fake_run_id(now: object, git: object) -> str:
return run_ids[-1]
monkeypatch.setattr(module, "medsam2_commit", fake_commit)
monkeypatch.setattr(module, "make_build_id", fake_run_id)
return module, segment, _config_dict(tmp_path, delivery_dir), run_ids.append
def test_run_writes_outputs_and_failed_case(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
capsys: pytest.CaptureFixture[str],
) -> None:
module, segment, document, next_run_id = setup
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
run_dir = tmp_path / "runs" / "run-a"
assert sorted(p.name for p in (tmp_path / "runs").iterdir()) == ["run-a"]
with (run_dir / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
assert rows[0] == ["video_id", "step", "reason"]
assert [row[:2] for row in rows[1:]] == [["U002", "2"]]
assert "都没有" in rows[1][2]
assert not (run_dir / "U002").exists()
assert segment.calls == 1
case_dir = run_dir / "U001"
for name in ("trajectory.npz", "tic_curves.npz", "summary.json", "tic.png", "overlay.mp4"):
assert (case_dir / name).stat().st_size > 0, name
summary = json.loads((case_dir / "summary.json").read_text(encoding="utf-8"))
assert summary["video_id"] == "U001"
assert summary["injection_offset_s"] == 5.0
assert list(summary["anchor_frames"]) == list(ANCHOR_SLOTS)
assert summary["perfusion_metrics"]["roi_perf"] is not None
assert "pan_detected_fraction" in summary
assert summary["anchor_frames"]["F2"]["status"] == "not_covered"
located = {s for s, a in summary["anchor_frames"].items() if a["frame_index"] is not None}
assert "F1" in located and "F2" not in located
keyframes = {p.stem for p in (case_dir / "keyframes").iterdir()}
assert keyframes == located
image = cv2.imread(str(case_dir / "keyframes" / "F1.png"))
assert image is not None and image.shape == (_HEIGHT, _WIDTH, 3) # 整幅原始画面
snapshot = run_dir / "snapshot"
assert (snapshot / "ceus.yaml").read_bytes() == (tmp_path / "ceus.yaml").read_bytes()
segment_record = json.loads((snapshot / "segment.json").read_text(encoding="utf-8"))
assert segment_record["medsam2_commit"] == "c" * 40
assert segment_record["cuda_peak_memory_bytes"] is None # device 是 cpu
assert (snapshot / "snapshot.json").is_file()
out = capsys.readouterr().out
assert "成功:1" in out and "第 2 步 1" in out
assert "U001" not in out and "U002" not in out
def test_build_reads_real_run_layout(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
"""build 读这个脚本真实写出的运行目录:第 1 例带 tic.json 和关键帧进库,第 2 例记 C15。"""
module, _, document, next_run_id = setup
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
run_dir = tmp_path / "runs" / "run-a"
delivery_dir = Path(document["paths"]["delivery_dir"])
build_dir = tmp_path / "build"
build_dir.mkdir()
result = run_build(
write_config(build_dir, delivery_dir, 1, None, run_dir),
REPO_ROOT,
datetime(2026, 10, 16, 12, 0, 0).astimezone(),
)
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
assert result.skipped_by_check == {"C15": 1}
(alias,) = list_aliases(result.library_dir)
route = read_ultrasound(result.library_dir, alias)
summary = json.loads((run_dir / "U001" / "summary.json").read_text(encoding="utf-8"))
located = {s for s, a in summary["anchor_frames"].items() if a["status"] == "ok"}
assert set(route.keyframes) == located
assert route.tic is not None
assert set(route.tic) == {"fits", "perfusion_metrics", "pan_curve_usable", "keyframes"}
assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1)
assert route.tic["keyframes"]["F2"]["located"] is False
def test_unreadable_video_fails_at_step_1_and_next_case_runs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
module, segment, document, next_run_id = setup
# 第 1 例换成损坏的视频(cv2 打不开),第 2 例换成正常视频。
videos = Path(document["paths"]["delivery_dir"]) / "CEUS_agent"
(videos / "001" / "U001_roi2.mp4").write_bytes(b"not a real video")
_write_video(videos / "002" / "U002_roi2.mp4", green_box=True)
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
run_dir = tmp_path / "runs" / "run-a"
with (run_dir / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
assert [row[:2] for row in rows[1:]] == [["U001", "1"]]
assert "打不开" in rows[1][2]
assert not (run_dir / "U001").exists()
assert (run_dir / "U002" / "summary.json").is_file()
assert segment.calls == 1
def test_segment_cache_reused_and_keyed_on_step_1_3_params(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
module, segment, document, next_run_id = setup
config = _write_config(tmp_path, document, "ceus.yaml")
next_run_id("run-a")
module.main(["--config", str(config)])
assert segment.calls == 1
cache = tmp_path / "work" / "segment_cache"
assert [p.name.endswith(".partial") for p in cache.iterdir()] == [False]
next_run_id("run-b")
module.main(["--config", str(config)])
assert segment.calls == 1 # 同样的输入,读缓存
# 同一进程里第二次跑(cv2 的随机数已被第一次推进过)结果也逐位相同:quantify_case 每例重设种子。
first = json.loads((tmp_path / "runs/run-a/U001/summary.json").read_text(encoding="utf-8"))
second = json.loads((tmp_path / "runs/run-b/U001/summary.json").read_text(encoding="utf-8"))
assert first == second
# 改设备不改结果,不重算;改第 1 步的参数,键变了,重新分割。
document["segment"]["device"] = "cuda:0"
document["segment"]["offload_state_to_cpu"] = True
next_run_id("run-c")
module.main(["--config", str(_write_config(tmp_path, document, "device.yaml"))])
assert segment.calls == 1
document["ceus"]["views"]["split_sample_frames"] = 20
next_run_id("run-d")
module.main(["--config", str(_write_config(tmp_path, document, "views.yaml"))])
assert segment.calls == 2
assert len(list(cache.iterdir())) == 2
def test_existing_run_directory_is_not_overwritten(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
module, _, document, next_run_id = setup
config = _write_config(tmp_path, document, "ceus.yaml")
next_run_id("run-a")
module.main(["--config", str(config)])
before = sorted(str(p) for p in (tmp_path / "runs").rglob("*"))
with pytest.raises(FileExistsError, match="运行目录已存在"):
module.main(["--config", str(config)])
assert sorted(str(p) for p in (tmp_path / "runs").rglob("*")) == before
def _key(module: ModuleType, document: dict[str, Any]) -> str:
config = module.QuantConfig.model_validate(document)
return module.segment_cache_key("v" * 64, config, "w" * 64, "c" * 40)
@pytest.mark.parametrize(
("section", "path", "value", "changes"),
[
("ceus", ("views", "split_search_band"), [0.3, 0.7], True),
("ceus", ("prompt_source", "min_area"), 9000, True),
("ceus", ("keyframes", "iou_threshold"), 0.9, True),
("segment", ("sam2_config",), "configs/other.yaml", True),
("segment", ("checkpoint",), "models/other.pt", True),
("segment", ("device",), "cuda:1", False),
("segment", ("offload_video_to_cpu",), True, False),
("ceus", ("smoothing", "temporal_window"), 9, False), # 第 4 步
("ceus", ("tic_analysis", "fitting", "minimum_r2"), 0.5, False), # 第 7 步
],
)
def test_cache_key_covers_exactly_step_1_3_params(
tmp_path: Path,
section: str,
path: tuple[str, ...],
value: object,
changes: bool,
) -> None:
module = _load_script()
document = _config_dict(tmp_path, tmp_path)
base = _key(module, document)
node = document[section]
for name in path[:-1]:
node = node[name]
node[path[-1]] = value
assert (_key(module, document) != base) is changes
assert _key(module, document) == _key(module, document) # 同输入同键
other = module.QuantConfig.model_validate(document)
assert module.segment_cache_key("x" * 64, other, "w" * 64, "c" * 40) != _key(module, document)