c7373efde2
- 新模块 review(组织层,只依赖 library、answers、runs):把一次构建和它装入的 CEUS 运行整理成医生可读的文件夹(CSV 带 BOM、说明与报告转 PDF、图片视频逐字节拷贝), 入口 scripts/review_pack.py + config/review_pack.yaml。任务书 03。 - 读 CEUS 运行目录(failed.csv、结果目录、关键帧文件)从 build 挪进 runs,build 与 review 都经它读;failed.csv 读取在边界上收严(步号须为整数、视频号不重复、列数固定)。 - library、answers 补只读函数;import-linter 增 review 契约。 - 文档:guides/医生核对包.md(过一轮硕士生阅读),结构.md 第三至八部分同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
13 KiB
Python
300 lines
13 KiB
Python
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
|
|
|
|
预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²);
|
|
病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用;
|
|
7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from cadx.answers import read_aliases
|
|
from cadx.build import BuildSummary, run_build
|
|
from cadx.library import list_aliases, read_ultrasound, scan_for_leaks
|
|
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
|
from fake_delivery import REPO_ROOT, make_delivery, write_config
|
|
|
|
NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone()
|
|
RUN_ID = "20261016-100000-abcdef0"
|
|
LOCATED = ("D1", "D2", "F1")
|
|
|
|
_PERFUSION_KEYS = {
|
|
"signal_domain",
|
|
"time_origin",
|
|
"area_curve",
|
|
"values",
|
|
"supporting_times_s",
|
|
}
|
|
_PERFUSION_VALUES = {
|
|
"PE_au",
|
|
"WiAUC_au_s",
|
|
"RT_s",
|
|
"mTTl_s",
|
|
"TTP_s",
|
|
"WiR_au_per_s",
|
|
"WiPI_au",
|
|
"WoAUC_au_s",
|
|
"WiWoAUC_au_s",
|
|
"FT_s",
|
|
"WoR_au_per_s",
|
|
}
|
|
|
|
|
|
def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary:
|
|
root = make_delivery(count).write(tmp_path / "delivery")
|
|
return run_build(write_config(tmp_path, root, 1, None, run_dir), REPO_ROOT, NOW)
|
|
|
|
|
|
def _skipped(answers_dir: Path) -> list[list[str]]:
|
|
with (answers_dir / "skipped.csv").open(encoding="utf-8-sig", newline="") as handle:
|
|
return list(csv.reader(handle))[1:]
|
|
|
|
|
|
def _png_chunks(data: bytes) -> list[str]:
|
|
chunks: list[str] = []
|
|
offset = 8
|
|
while offset < len(data):
|
|
length = int.from_bytes(data[offset : offset + 4], "big")
|
|
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
|
|
offset += 12 + length
|
|
return chunks
|
|
|
|
|
|
def _key_tree(node: Any) -> Any:
|
|
"""嵌套字典的键结构:字典换成「键 → 子结构」,其余值换成 None。"""
|
|
if isinstance(node, dict):
|
|
return {key: _key_tree(value) for key, value in cast(dict[str, Any], node).items()}
|
|
return None
|
|
|
|
|
|
def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
|
run = write_run(
|
|
tmp_path / "runs" / RUN_ID,
|
|
tmp_path / "delivery",
|
|
{"U001": LOCATED, "U004": LOCATED},
|
|
[("U002", 2, "两半都没有绿框")],
|
|
)
|
|
result = _build(tmp_path, 4, run)
|
|
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001", "004"]
|
|
assert len(list_aliases(result.library_dir)) == 2
|
|
skipped = _skipped(result.answers_dir)
|
|
assert [row[:4] for row in skipped] == [
|
|
["002", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/failed.csv"],
|
|
["003", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/U003"],
|
|
]
|
|
assert json.loads(skipped[0][5]) == "第 2 步失败:两半都没有绿框"
|
|
assert "没有这一例的结果目录" in json.loads(skipped[1][5])
|
|
assert result.skipped_by_check == {"C15": 2}
|
|
manifest = json.loads((result.library_dir / "manifest.json").read_text(encoding="utf-8"))
|
|
assert manifest["ceus_source"] == RUN_ID
|
|
|
|
|
|
def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None:
|
|
result = _build(
|
|
tmp_path,
|
|
1,
|
|
write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, []),
|
|
)
|
|
(alias,) = list_aliases(result.library_dir)
|
|
tic = read_ultrasound(result.library_dir, alias).tic
|
|
perfusion = {
|
|
**dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}),
|
|
"values": dict.fromkeys(_PERFUSION_VALUES),
|
|
"supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")),
|
|
}
|
|
slot = {"located": None, "video_time_s": None, "reason": None}
|
|
curves = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
|
assert _key_tree(tic) == {
|
|
"fits": {name: {"success": None, "r2": None} for name in curves},
|
|
"perfusion_metrics": {"roi_all": perfusion, "roi_perf": None},
|
|
"pan_curve_usable": None,
|
|
"keyframes": {s: slot for s in ("B0", "D1", "D2", "D3", "D4", "F1", "F2")},
|
|
}
|
|
assert tic is not None
|
|
assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93}
|
|
assert tic["fits"]["roi_pan"] == {"success": False, "r2": None}
|
|
assert tic["pan_curve_usable"] is False
|
|
assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0
|
|
# 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。
|
|
assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None}
|
|
assert tic["keyframes"]["D4"] == {
|
|
"located": False,
|
|
"video_time_s": None,
|
|
"reason": UNLOCATED_REASON,
|
|
}
|
|
|
|
|
|
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
result = _build(tmp_path, 1, run)
|
|
(alias,) = list_aliases(result.library_dir)
|
|
route = read_ultrasound(result.library_dir, alias)
|
|
assert sorted(route.keyframes) == sorted(LOCATED)
|
|
for slot, copied in route.keyframes.items():
|
|
assert (
|
|
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
|
|
)
|
|
with Image.open(run / "U001" / "keyframes" / f"{slot}.png") as original:
|
|
assert "Source" in original.info and "exif" in original.info
|
|
pixels = original.tobytes()
|
|
with Image.open(copied) as image:
|
|
assert image.mode == "RGB" and image.tobytes() == pixels
|
|
assert "Source" not in image.info and "exif" not in image.info
|
|
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
|
|
|
|
|
def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
|
|
root = make_delivery(2).write(tmp_path / "delivery")
|
|
result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
|
for alias in list_aliases(result.library_dir):
|
|
route = read_ultrasound(result.library_dir, alias)
|
|
assert route.tic is None and route.keyframes == {}
|
|
assert sorted(
|
|
p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir()
|
|
) == [
|
|
"key.png",
|
|
"report.txt",
|
|
]
|
|
|
|
|
|
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
|
|
run = write_run(
|
|
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED, "U002": LOCATED}, []
|
|
)
|
|
result = _build(tmp_path, 2, run)
|
|
forbidden = ["P001", "P002", "U001", "U002", "U001_roi2.mp4", "U002_roi2.mp4", "U999"]
|
|
assert scan_for_leaks(result.library_dir, forbidden, ("name",)) == []
|
|
alias = list_aliases(result.library_dir)[0]
|
|
tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
|
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
|
tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧"
|
|
tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8")
|
|
hits = scan_for_leaks(result.library_dir, forbidden, ("name",))
|
|
assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")]
|
|
|
|
|
|
def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
content = summary("U001", LOCATED)
|
|
content["anchor_frames"]["D4"]["reason"] = "U001 的 D4 不在视频里"
|
|
(run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
|
with pytest.raises(ValueError, match="视频号或路径"):
|
|
_build(tmp_path, 1, run)
|
|
assert not (tmp_path / "library").exists()
|
|
|
|
|
|
def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None:
|
|
run = write_run(
|
|
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED, "U002": LOCATED}, []
|
|
)
|
|
Image.new("I;16", (4, 4)).save(run / "U002" / "keyframes" / "D2.png", format="PNG")
|
|
result = _build(tmp_path, 2, run)
|
|
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
|
|
skipped = _skipped(result.answers_dir)
|
|
assert [row[:4] for row in skipped] == [
|
|
["002", "C14", "关键图能原样重编码", f"{RUN_ID}/U002/keyframes/D2.png"]
|
|
]
|
|
|
|
|
|
def _assert_stops(tmp_path: Path, run: Path, error: type[Exception]) -> None:
|
|
with pytest.raises(error):
|
|
_build(tmp_path, 1, run)
|
|
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
|
|
|
|
|
def test_video_changed_since_run_stops(tmp_path: Path) -> None:
|
|
"""交付包里的视频内容和 CEUS 运行时记的 SHA-256 不同:运行不是在这份交付包上跑的,整次停下。"""
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
root = make_delivery(1).write(tmp_path / "delivery")
|
|
(root / "CEUS_agent" / "001" / "U001_roi2.mp4").write_bytes(b"another video")
|
|
with pytest.raises(ValueError, match=r"U001_roi2\.mp4 的 SHA-256"):
|
|
run_build(write_config(tmp_path, root, 1, None, run), REPO_ROOT, NOW)
|
|
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("delivery_bolus", "summary_offset", "message"),
|
|
[
|
|
(7, 5.0, "推注时间 7 秒"), # 交付包的推注时间改了
|
|
(5, 5, "应为 float"), # summary 里的推注时间不是浮点数
|
|
],
|
|
)
|
|
def test_injection_offset_mismatch_stops(
|
|
tmp_path: Path, delivery_bolus: int, summary_offset: object, message: str
|
|
) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
content = summary("U001", LOCATED)
|
|
content["injection_offset_s"] = summary_offset
|
|
(run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
|
delivery = make_delivery(1)
|
|
delivery.case(1).ultrasound["推注开始时间s"] = delivery_bolus
|
|
root = delivery.write(tmp_path / "delivery")
|
|
with pytest.raises(ValueError, match=message):
|
|
run_build(write_config(tmp_path, root, 1, None, run), REPO_ROOT, NOW)
|
|
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
|
|
|
|
|
def test_run_on_other_delivery_dir_stops(tmp_path: Path) -> None:
|
|
"""快照里记的交付包路径与 build 的不同(规范化后比):整次停下;写法不同但同一路径照常装。"""
|
|
root = make_delivery(1).write(tmp_path / "delivery")
|
|
same = write_run(
|
|
tmp_path / "runs" / RUN_ID, tmp_path / "x" / ".." / "delivery", {"U001": LOCATED}, []
|
|
)
|
|
result = run_build(write_config(tmp_path, root, 1, None, same), REPO_ROOT, NOW)
|
|
assert len(list_aliases(result.library_dir)) == 1
|
|
other = write_run(tmp_path / "runs" / "other", tmp_path / "delivery_old", {"U001": LOCATED}, [])
|
|
build_dir = tmp_path / "second"
|
|
build_dir.mkdir()
|
|
with pytest.raises(ValueError, match=r"paths\.delivery_dir"):
|
|
run_build(write_config(build_dir, root, 1, None, other), REPO_ROOT, NOW)
|
|
assert not (build_dir / "library").exists() and not (build_dir / "answers").exists()
|
|
|
|
|
|
def test_run_dir_missing_stops(tmp_path: Path) -> None:
|
|
_assert_stops(tmp_path, tmp_path / "runs" / RUN_ID, FileNotFoundError)
|
|
|
|
|
|
def test_partial_run_dir_stops(tmp_path: Path) -> None:
|
|
run = write_run(
|
|
tmp_path / "runs" / f"{RUN_ID}.partial", tmp_path / "delivery", {"U001": LOCATED}, []
|
|
)
|
|
_assert_stops(tmp_path, run, ValueError)
|
|
|
|
|
|
def test_run_dir_without_failed_csv_stops(tmp_path: Path) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
(run / "failed.csv").unlink()
|
|
_assert_stops(tmp_path, run, FileNotFoundError)
|
|
|
|
|
|
def test_run_dir_without_snapshot_stops(tmp_path: Path) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
(run / "snapshot" / "snapshot.json").unlink()
|
|
_assert_stops(tmp_path, run, FileNotFoundError)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("extra", "remove"),
|
|
[
|
|
("B0.tmp", None), # B0.png 与同主干的 B0.tmp 并存:多一个文件
|
|
("B0.jpg", "B0.png"), # 只有 B0.jpg:扩展名不是 .png
|
|
],
|
|
)
|
|
def test_keyframe_files_must_be_exactly_slot_pngs(
|
|
tmp_path: Path, extra: str, remove: str | None
|
|
) -> None:
|
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": ("B0", "D1")}, [])
|
|
folder = run / "U001" / "keyframes"
|
|
(folder / extra).write_bytes((folder / "B0.png").read_bytes())
|
|
if remove is not None:
|
|
(folder / remove).unlink()
|
|
with pytest.raises(ValueError, match="定位到的槽位不一致"):
|
|
_build(tmp_path, 1, run)
|
|
assert not (tmp_path / "library").exists()
|