feat(review): 核对包改成医生读得懂;资料库超声一路只留给 agent 的参数
- ceus:灌注指标改为对病灶去暗区(roi_perf)和紫圈(roi_pan)两组算,roi_all 指标删除; D1–D4 仍要 roi_all、roi_perf 两条都过才定位(负责人 2026-10-17:医生要正常区与病灶对比看) - build/library:tic.json 只出两组 11 个指标与 7 个槽位的视频时间;医生截图挪进 ultrasound/keyframes/key.png,UltrasoundRoute 去掉 key_image、keyframes 多一个 key - review:TIC参数.csv 取代 TIC结果.csv;问题清单按原因对照表写成大白话,对照表外的原因报错; 程序原文进 snapshot/问题清单_原文.csv;说明.pdf 整份重写为合作者口吻 - 文档:结构.md、医生核对包、装配两个库、CEUS定量 任务书 04。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
数值全部编造,不调显卡。tic.png 和 overlay.mp4 只是带视频号的几个字节(核对包按字节拷贝,不打开)。
|
||||
|
||||
summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、
|
||||
失败原因代码、推注时间、紫圈检出率),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
|
||||
四条曲线的拟合成败与 R²、失败原因代码、推注时间、紫圈检出率与是否可用、灌注指标的口径标签和辅助
|
||||
时刻、关键帧状态与未定位原因),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
|
||||
`fake_delivery` 的假交付包(视频内容 `VIDEO_BYTES`、推注 5 秒)。关键帧 PNG 带文本块和 EXIF。
|
||||
"""
|
||||
|
||||
@@ -24,7 +25,8 @@ CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
|
||||
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
|
||||
|
||||
|
||||
def _perfusion(scale: float) -> dict[str, Any]:
|
||||
def perfusion(scale: float) -> dict[str, Any]:
|
||||
"""一组灌注指标的 summary 段:11 个指标依次是 `scale` × 1, 2, …, 11。"""
|
||||
names = ("PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au")
|
||||
more = ("WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s")
|
||||
return {
|
||||
@@ -88,7 +90,8 @@ def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]:
|
||||
"roi_pan": _fit(False, None),
|
||||
"background_circle": _fit(False, 0.2),
|
||||
},
|
||||
"perfusion_metrics": {"roi_all": _perfusion(1.0), "roi_perf": None},
|
||||
# 紫圈拟合没过(reference_valid 为 False),整组为 null;病灶去暗区拟合过了,有指标。
|
||||
"perfusion_metrics": {"roi_perf": perfusion(1.0), "roi_pan": None},
|
||||
"anchor_frames": anchors,
|
||||
"available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located],
|
||||
}
|
||||
|
||||
+183
-86
@@ -1,8 +1,9 @@
|
||||
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
|
||||
|
||||
预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²);
|
||||
病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用;
|
||||
7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。
|
||||
tic.json 的预期内容照任务书 04 第二部分手写:紫圈(`roi_pan`)和病灶去暗区(`roi_perf`)两组各
|
||||
11 个灌注指标(拟合没过整组为 null,单项算不出为 null),7 个槽位定位到的视频时间(没取到为
|
||||
null),此外什么都没有。超声一路的文件是 report.txt、tic.json 和 keyframes/ 下的 key.png 加定位到
|
||||
的槽位。
|
||||
"""
|
||||
|
||||
import csv
|
||||
@@ -17,33 +18,13 @@ 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_ceus_run import perfusion, 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",
|
||||
}
|
||||
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
|
||||
|
||||
def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary:
|
||||
@@ -66,13 +47,6 @@ def _png_chunks(data: bytes) -> list[str]:
|
||||
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,
|
||||
@@ -95,39 +69,173 @@ def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
||||
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,
|
||||
}
|
||||
# 四例各自定位到的槽位和 summary.json 的改法:
|
||||
# - U001:紫圈、病灶去暗区两组都有(紫圈 = perfusion(10.0)),7 个槽位都取到;
|
||||
# - U002:只有病灶去暗区一组(假运行的原样),其中 WoAUC、FT 两项算不出;只取到 D1、D2、F1;
|
||||
# - U003:两组都 null(病灶两条曲线拟合都没过,紫圈不可用);只取到 F1、F2;
|
||||
# - U004:只有紫圈一组,其中 RT 一项算不出;F2 没取到。
|
||||
_CASE_SLOTS: dict[str, tuple[str, ...]] = {
|
||||
"U001": ALL_SLOTS,
|
||||
"U002": LOCATED,
|
||||
"U003": ("F1", "F2"),
|
||||
"U004": ("B0", "D1", "D2", "D3", "D4", "F1"),
|
||||
}
|
||||
|
||||
|
||||
def _both_groups(content: dict[str, Any]) -> None:
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||
content["perfusion_metrics"]["roi_pan"] = perfusion(10.0)
|
||||
|
||||
|
||||
def _perf_items_missing(content: dict[str, Any]) -> None:
|
||||
values = content["perfusion_metrics"]["roi_perf"]["values"]
|
||||
values["WoAUC_au_s"] = None
|
||||
values["FT_s"] = None
|
||||
|
||||
|
||||
def _no_groups(content: dict[str, Any]) -> None:
|
||||
for curve in ("roi_all", "roi_perf"):
|
||||
content["fits"][curve].update(success=False, failure_reason="credible_peak_not_found")
|
||||
content["lesion_fit_ready"] = False
|
||||
content["perfusion_metrics"]["roi_perf"] = None
|
||||
|
||||
|
||||
def _pan_only(content: dict[str, Any]) -> None:
|
||||
_both_groups(content)
|
||||
content["fits"]["roi_perf"].update(success=False, failure_reason="parameter_on_boundary")
|
||||
content["lesion_fit_ready"] = False
|
||||
content["perfusion_metrics"]["roi_perf"] = None
|
||||
content["perfusion_metrics"]["roi_pan"]["values"]["RT_s"] = None
|
||||
|
||||
|
||||
_EDITS = {"U001": _both_groups, "U002": _perf_items_missing, "U003": _no_groups, "U004": _pan_only}
|
||||
|
||||
_PAN = {
|
||||
"PE_au": 10.0,
|
||||
"WiAUC_au_s": 20.0,
|
||||
"RT_s": 30.0,
|
||||
"mTTl_s": 40.0,
|
||||
"TTP_s": 50.0,
|
||||
"WiR_au_per_s": 60.0,
|
||||
"WiPI_au": 70.0,
|
||||
"WoAUC_au_s": 80.0,
|
||||
"WiWoAUC_au_s": 90.0,
|
||||
"FT_s": 100.0,
|
||||
"WoR_au_per_s": 110.0,
|
||||
}
|
||||
_PERF = {
|
||||
"PE_au": 1.0,
|
||||
"WiAUC_au_s": 2.0,
|
||||
"RT_s": 3.0,
|
||||
"mTTl_s": 4.0,
|
||||
"TTP_s": 5.0,
|
||||
"WiR_au_per_s": 6.0,
|
||||
"WiPI_au": 7.0,
|
||||
"WoAUC_au_s": 8.0,
|
||||
"WiWoAUC_au_s": 9.0,
|
||||
"FT_s": 10.0,
|
||||
"WoR_au_per_s": 11.0,
|
||||
}
|
||||
# 假运行里定位到的槽位,视频时间 = 槽位序号 × 2.5 秒。
|
||||
_ALL_TIMES = {"B0": 0.0, "D1": 2.5, "D2": 5.0, "D3": 7.5, "D4": 10.0, "F1": 12.5, "F2": 15.0}
|
||||
|
||||
EXPECTED_TIC = {
|
||||
"U001": {
|
||||
"perfusion_metrics": {"roi_pan": _PAN, "roi_perf": _PERF},
|
||||
"keyframe_times_s": _ALL_TIMES,
|
||||
},
|
||||
"U002": {
|
||||
"perfusion_metrics": {
|
||||
"roi_pan": None,
|
||||
"roi_perf": {**_PERF, "WoAUC_au_s": None, "FT_s": None},
|
||||
},
|
||||
"keyframe_times_s": {
|
||||
"B0": None,
|
||||
"D1": 2.5,
|
||||
"D2": 5.0,
|
||||
"D3": None,
|
||||
"D4": None,
|
||||
"F1": 12.5,
|
||||
"F2": None,
|
||||
},
|
||||
},
|
||||
"U003": {
|
||||
"perfusion_metrics": {"roi_pan": None, "roi_perf": None},
|
||||
"keyframe_times_s": {
|
||||
"B0": None,
|
||||
"D1": None,
|
||||
"D2": None,
|
||||
"D3": None,
|
||||
"D4": None,
|
||||
"F1": 12.5,
|
||||
"F2": 15.0,
|
||||
},
|
||||
},
|
||||
"U004": {
|
||||
"perfusion_metrics": {"roi_pan": {**_PAN, "RT_s": None}, "roi_perf": None},
|
||||
"keyframe_times_s": {**_ALL_TIMES, "F2": None},
|
||||
},
|
||||
}
|
||||
_FORBIDDEN_TIC_KEYS = {
|
||||
"fits",
|
||||
"r2",
|
||||
"success",
|
||||
"roi_all",
|
||||
"background_circle",
|
||||
"reason",
|
||||
"pan_curve_usable",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def four_cases(tmp_path: Path) -> BuildSummary:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", _CASE_SLOTS, [])
|
||||
for video_id, located in _CASE_SLOTS.items():
|
||||
content = summary(video_id, located)
|
||||
_EDITS[video_id](content)
|
||||
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||
return _build(tmp_path, 4, run)
|
||||
|
||||
|
||||
def _all_keys(node: object) -> set[str]:
|
||||
if isinstance(node, dict):
|
||||
items = cast(dict[str, Any], node)
|
||||
return set(items) | {k for value in items.values() for k in _all_keys(value)}
|
||||
return set()
|
||||
|
||||
|
||||
def test_tic_json_is_exactly_the_agent_parameters(four_cases: BuildSummary) -> None:
|
||||
by_video = {r.video_id: r.alias for r in read_aliases(four_cases.answers_dir)}
|
||||
assert sorted(by_video) == sorted(EXPECTED_TIC)
|
||||
for video_id, expected in EXPECTED_TIC.items():
|
||||
tic_path = four_cases.library_dir / "cases" / by_video[video_id] / "ultrasound" / "tic.json"
|
||||
assert json.loads(tic_path.read_text(encoding="utf-8")) == expected, video_id
|
||||
assert read_ultrasound(four_cases.library_dir, by_video[video_id]).tic == expected
|
||||
|
||||
|
||||
def test_tic_json_has_no_internal_keys(four_cases: BuildSummary) -> None:
|
||||
for alias in list_aliases(four_cases.library_dir):
|
||||
tic_path = four_cases.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
||||
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
||||
assert _all_keys(tic) & _FORBIDDEN_TIC_KEYS == set()
|
||||
|
||||
|
||||
def test_ultrasound_files_are_exactly_report_tic_and_keyframes(
|
||||
tmp_path: Path, four_cases: BuildSummary
|
||||
) -> None:
|
||||
for row in read_aliases(four_cases.answers_dir):
|
||||
folder = four_cases.library_dir / "cases" / row.alias / "ultrasound"
|
||||
files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())
|
||||
slots = [f"keyframes/{slot}.png" for slot in _CASE_SLOTS[row.video_id]]
|
||||
assert files == sorted(["report.txt", "tic.json", "keyframes/key.png", *slots])
|
||||
source = tmp_path / "delivery" / "CEUS_agent" / row.case_no / f"U{row.case_no}_key.png"
|
||||
with Image.open(source) as original, Image.open(folder / "keyframes" / "key.png") as copy:
|
||||
assert copy.mode == original.mode and copy.size == original.size
|
||||
assert copy.tobytes() == original.tobytes()
|
||||
route = read_ultrasound(four_cases.library_dir, row.alias)
|
||||
assert route.keyframes["key"] == folder / "keyframes" / "key.png"
|
||||
assert sorted(route.keyframes) == sorted(["key", *_CASE_SLOTS[row.video_id]])
|
||||
|
||||
|
||||
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||
@@ -135,8 +243,9 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||
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 sorted(route.keyframes) == sorted(["key", *LOCATED])
|
||||
for slot in LOCATED:
|
||||
copied = route.keyframes[slot]
|
||||
assert (
|
||||
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
|
||||
)
|
||||
@@ -149,18 +258,16 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||||
|
||||
|
||||
def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
|
||||
def test_null_ceus_run_has_only_key_image_in_keyframes(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",
|
||||
]
|
||||
folder = result.library_dir / "cases" / alias / "ultrasound"
|
||||
assert route.tic is None
|
||||
assert route.keyframes == {"key": folder / "keyframes" / "key.png"}
|
||||
files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())
|
||||
assert files == ["keyframes/key.png", "report.txt"]
|
||||
|
||||
|
||||
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
|
||||
@@ -173,22 +280,12 @@ def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> No
|
||||
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["keyframe_times_s"]["D4"] = "见 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}, []
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None:
|
||||
assert files == [
|
||||
"clinical/clinical.json",
|
||||
"mr/report.txt",
|
||||
"ultrasound/key.png",
|
||||
"ultrasound/keyframes/key.png",
|
||||
"ultrasound/report.txt",
|
||||
]
|
||||
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
|
||||
@@ -195,7 +195,7 @@ def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None:
|
||||
with Image.open(source) as original:
|
||||
assert original.info.get("PatientName") == "should-not-survive"
|
||||
original_mode, original_pixels = original.mode, original.tobytes()
|
||||
copied = read_ultrasound(summary.library_dir, alias).key_image
|
||||
copied = read_ultrasound(summary.library_dir, alias).keyframes["key"]
|
||||
with Image.open(copied) as image:
|
||||
assert image.mode == original_mode
|
||||
assert image.tobytes() == original_pixels
|
||||
@@ -282,7 +282,7 @@ def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
|
||||
library_root = tmp_path / "library"
|
||||
(library_dir,) = list(library_root.iterdir())
|
||||
by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)}
|
||||
copied = read_ultrasound(library_dir, by_case["002"]).key_image
|
||||
copied = read_ultrasound(library_dir, by_case["002"]).keyframes["key"]
|
||||
with Image.open(copied) as image:
|
||||
assert image.mode == "1"
|
||||
assert image.tobytes() == source.tobytes()
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
超过 40 秒的视频末尾,未定位。
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import math
|
||||
|
||||
@@ -22,10 +23,17 @@ import pytest
|
||||
from cadx.ceus import (
|
||||
ANCHOR_SLOTS,
|
||||
CeusConfig,
|
||||
LognormalFitResult,
|
||||
LognormalParameters,
|
||||
NoValidContourError,
|
||||
SegmentationInput,
|
||||
TicResult,
|
||||
VideoFile,
|
||||
compute_lognormal_perfusion_metrics,
|
||||
compute_tic_for_video,
|
||||
crop_frame,
|
||||
evaluate_lognormal,
|
||||
fit_lognormal_curve,
|
||||
keyframe_images,
|
||||
locate_contrast_side,
|
||||
pack_tic_curves,
|
||||
@@ -156,6 +164,9 @@ def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfi
|
||||
json.dumps(summary, allow_nan=False)
|
||||
assert summary["anchor_frames"]["F1"]["frame_index"] == 150
|
||||
assert summary["perfusion_metrics"]["roi_perf"] is not None
|
||||
# 没有紫圈:紫圈那一组整组为 null;病灶整体不算指标。
|
||||
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
|
||||
assert summary["perfusion_metrics"]["roi_pan"] is None
|
||||
|
||||
markers = tic_plot_markers(result.anchors)
|
||||
assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"]
|
||||
@@ -179,3 +190,73 @@ def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: Ce
|
||||
masks = _lesion_masks(prepared)[:-1]
|
||||
with pytest.raises(ValueError, match="分割掩膜"):
|
||||
quantify_case(prepared, masks, _INJECTION_OFFSET_S, config)
|
||||
|
||||
|
||||
def _clean_fit(values: np.ndarray, times: np.ndarray, config: CeusConfig) -> LognormalFitResult:
|
||||
"""直接调拟合函数拟一条曲线(基线 40、噪声 0.2,前 20 帧当基线段)。"""
|
||||
return fit_lognormal_curve(
|
||||
times,
|
||||
values,
|
||||
baseline=40.0,
|
||||
baseline_noise=0.2,
|
||||
baseline_end_index=20,
|
||||
duplicate_mask=np.zeros(times.size, dtype=bool),
|
||||
sample_mask=None,
|
||||
cfg=config.tic_analysis.fitting,
|
||||
anchor_cfg=config.tic_analysis.anchor_frames,
|
||||
)
|
||||
|
||||
|
||||
def test_pan_metrics_computed_and_lesion_gate_unchanged(
|
||||
prepared: SegmentationInput, config: CeusConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""紫圈拟合过、病灶整体拟合没过:紫圈与病灶去暗区各算一组指标,病灶整体不算;
|
||||
D1–D4 照「病灶两条曲线都过才定位」不定位。
|
||||
|
||||
合成视频里没有紫圈,所以把第 6–7 步交出的拟合换掉:紫圈换成一条干净 Lognormal 曲线的拟合
|
||||
(峰与病灶不同),病灶整体换成一条平线的拟合(没有可信的峰,拟合不过)。
|
||||
"""
|
||||
times = np.arange(FRAMES) / FPS
|
||||
pan_truth = LognormalParameters(
|
||||
area=600.0, mu=2.0, sigma=0.5, arrival_offset_s=3.0, baseline=40.0
|
||||
)
|
||||
pan_fit = _clean_fit(evaluate_lognormal(times, pan_truth), times, config)
|
||||
flat_fit = _clean_fit(np.full(times.size, 40.0), times, config)
|
||||
assert pan_fit.success is True and flat_fit.success is False
|
||||
|
||||
def patched(*args: object, **kwargs: object) -> TicResult:
|
||||
tic = compute_tic_for_video(*args, **kwargs) # pyright: ignore[reportArgumentType]
|
||||
return dataclasses.replace(tic, fits={**tic.fits, "roi_pan": pan_fit, "roi_all": flat_fit})
|
||||
|
||||
monkeypatch.setattr("cadx.ceus._pipeline.compute_tic_for_video", patched)
|
||||
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
|
||||
|
||||
assert result.tic.fits["roi_perf"].success is True
|
||||
assert result.lesion_fit_ready is False
|
||||
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||
assert result.anchors[slot].status != "ok"
|
||||
assert result.anchors[slot].frame_index is None
|
||||
assert set(result.perfusion) == {"roi_perf", "roi_pan"}
|
||||
assert result.perfusion["roi_pan"] == compute_lognormal_perfusion_metrics(pan_fit)
|
||||
|
||||
summary = quant_summary(result)
|
||||
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
|
||||
expected = compute_lognormal_perfusion_metrics(pan_fit)
|
||||
assert expected is not None
|
||||
# 11 项指标的键名与 LognormalPerfusionMetrics 字段的对应,照任务书 04 列的指标逐个手写。
|
||||
assert summary["perfusion_metrics"]["roi_pan"]["values"] == {
|
||||
"PE_au": expected.pe_au,
|
||||
"WiAUC_au_s": expected.wi_auc_au_s,
|
||||
"RT_s": expected.rt_s,
|
||||
"mTTl_s": expected.mttl_s,
|
||||
"TTP_s": expected.ttp_s,
|
||||
"WiR_au_per_s": expected.wir_au_per_s,
|
||||
"WiPI_au": expected.wipi_au,
|
||||
"WoAUC_au_s": expected.wo_auc_au_s,
|
||||
"WiWoAUC_au_s": expected.wiwo_auc_au_s,
|
||||
"FT_s": expected.ft_s,
|
||||
"WoR_au_per_s": expected.wor_au_per_s,
|
||||
}
|
||||
# 紫圈的峰与病灶不同:两组指标不是同一组数。
|
||||
assert summary["perfusion_metrics"]["roi_pan"] != summary["perfusion_metrics"]["roi_perf"]
|
||||
assert summary["lesion_fit_ready"] is False
|
||||
|
||||
@@ -238,11 +238,14 @@ def test_build_reads_real_run_layout(
|
||||
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 set(route.keyframes) == {"key", *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
|
||||
assert set(route.tic) == {"perfusion_metrics", "keyframe_times_s"}
|
||||
# 合成视频里没有紫圈:紫圈一组为 null,病灶去暗区一组有 11 个指标。
|
||||
assert route.tic["perfusion_metrics"]["roi_pan"] is None
|
||||
assert len(route.tic["perfusion_metrics"]["roi_perf"]) == 11
|
||||
assert route.tic["keyframe_times_s"]["F1"] == pytest.approx(15.0, abs=0.1)
|
||||
assert route.tic["keyframe_times_s"]["F2"] is None
|
||||
|
||||
# 医生核对包也读这个真实布局:TIC 曲线图和回放视频按字节拷进病例目录。
|
||||
pack_config = tmp_path / "review_pack.yaml"
|
||||
|
||||
+359
-132
@@ -1,15 +1,20 @@
|
||||
"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。
|
||||
"""医生核对包:目录结构、CSV 规格、TIC参数、问题清单的医生写法、程序原文快照、逐字节拷贝、PDF。
|
||||
|
||||
预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数
|
||||
(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。
|
||||
预期值照任务书 03、04 写:目录和文件名、列名、问题清单的「环节」与「问题」、TIC参数.csv 的中文名、
|
||||
单位和小数位都按任务书的文字手写,不从实现里推。原因对照表的逐条预期在 `test_review_reasons.py`。
|
||||
|
||||
假数据(6 例):
|
||||
- P001:进库;定位到 D1、D2、F1,其余 4 个槽位未取到;病灶去暗区曲线拟合失败
|
||||
(parameter_on_boundary);紫圈不可用。
|
||||
- P002:CEUS 第 2 步失败(C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
||||
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒(测一位小数);拟合都成功;紫圈可用。
|
||||
- P005:MR 表诊断与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录(被跳过但定量成功)。
|
||||
- P006:进库;7 个槽位都取到;病灶整体曲线拟合失败(credible_peak_not_found);紫圈可用。
|
||||
假数据(8 例):
|
||||
- P001:进库;病灶去暗区曲线拟合失败(parameter_on_boundary),B0、D1–D4 因此未取到;F2 落在视频
|
||||
之后;F1 取到;紫圈不可用。TIC参数两组都空。
|
||||
- P002:CEUS 第 2 步失败(两半都没有绿框,C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
||||
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒;拟合都成功;紫圈可用;两组指标都有,
|
||||
病灶一组观测不够的 4 项为 null。
|
||||
- P005:MR 表诊断、临床表年龄与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录。
|
||||
- P006:进库;病灶整体曲线拟合失败(credible_peak_not_found),B0、D1–D4 未取到;紫圈可用但拟合失败
|
||||
(fit_quality_failed)。TIC参数只有病灶一组。
|
||||
- P007:MR 表性别与超声表不同(C10 跳过);CEUS 第 1 步失败(两半配准不一致)。
|
||||
- P008:进库;拟合都成功;B0 基线是估计的、D2 和 D3 的候选帧被占、D4 落在视频之后、
|
||||
F2 附近只有重复帧。
|
||||
"""
|
||||
|
||||
import csv
|
||||
@@ -30,13 +35,27 @@ from cadx.build import BuildSummary, run_build
|
||||
from cadx.library import read_ultrasound
|
||||
from cadx.review import run_review_pack
|
||||
from cadx.review._pdf import markdown_to_pdf, report_markdown
|
||||
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
||||
from fake_ceus_run import perfusion, summary, write_run
|
||||
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
|
||||
|
||||
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
|
||||
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
|
||||
RUN_ID = "20261017-080000-abcdef0"
|
||||
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
DYNAMIC = ("B0", "D1", "D2", "D3", "D4")
|
||||
CONS = ",程序没法处理这段视频"
|
||||
SEX_NOTE = "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)"
|
||||
LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取"
|
||||
BOUNDARY = (
|
||||
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||||
"有一项卡在了程序允许范围的边上"
|
||||
)
|
||||
FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
|
||||
F2_AFTER = (
|
||||
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在视频有效帧范围 [0.00, 40.20] 秒之外"
|
||||
)
|
||||
STEP2 = "左右两半都没有找到绿框,选不出提示帧"
|
||||
STEP1 = "两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),相差 6 像素,超过门槛 4"
|
||||
|
||||
SUMMARY_HEADER = [
|
||||
"病例号",
|
||||
@@ -49,55 +68,132 @@ SUMMARY_HEADER = [
|
||||
*ALL_SLOTS,
|
||||
]
|
||||
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
|
||||
TIC_HEADER = ["指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)"]
|
||||
METRIC_NAMES = [
|
||||
["峰值增强 PE", "灰度"],
|
||||
["达峰时间 TTP", "秒"],
|
||||
["最大洗入斜率 WiR", "灰度/秒"],
|
||||
["上升时间 RT", "秒"],
|
||||
["洗入曲线下面积 WiAUC", "灰度·秒"],
|
||||
["洗入灌注指数 WiPI", "灰度"],
|
||||
["最大洗出斜率 WoR", "灰度/秒"],
|
||||
["下降时间 FT", "秒"],
|
||||
["洗出曲线下面积 WoAUC", "灰度·秒"],
|
||||
["洗入洗出曲线下面积 WiWoAUC", "灰度·秒"],
|
||||
["平均渡越时间 mTTl", "秒"],
|
||||
]
|
||||
# fake_ceus_run.perfusion(1.0) 的 11 个值按 TIC参数.csv 的行序、按小数位写出:
|
||||
# PE 1、WiAUC 2、RT 3、mTTl 4、TTP 5、WiR 6、WiPI 7、WoAUC 8、WiWoAUC 9、FT 10、WoR −11。
|
||||
SCALE1 = ["1.00", "5.0", "6.00", "3.0", "2.00", "7.00", "-11.00", "10.0", "8.00", "9.00", "4.0"]
|
||||
BLANK = [""] * 11
|
||||
ANSWER_HEADERS = {
|
||||
"化名对照.csv": ["化名", "病例号", "视频号"],
|
||||
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
|
||||
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
|
||||
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
|
||||
}
|
||||
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"}
|
||||
CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"}
|
||||
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键帧"}
|
||||
CEUS_FILES = {"TIC曲线.png", "TIC参数.csv", "回放视频.mp4"}
|
||||
# 任务书 04 验收第 3 条:问题清单里不许出现的东西。
|
||||
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||||
|
||||
|
||||
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
|
||||
content = summary(video_id, located)
|
||||
edit(content)
|
||||
# 最大洗出斜率 WoR 在真实运行里是负数,假数据也照此写。
|
||||
for group in content["perfusion_metrics"].values():
|
||||
if group is not None and group["values"]["WoR_au_per_s"] is not None:
|
||||
group["values"]["WoR_au_per_s"] = -abs(group["values"]["WoR_au_per_s"])
|
||||
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
|
||||
def _fail_fit(curve: str, code: str) -> Any:
|
||||
def edit(content: dict[str, Any]) -> None:
|
||||
content["fits"][curve].update(success=False, failure_reason=code)
|
||||
def _unlocated(content: dict[str, Any], reasons: dict[str, str]) -> None:
|
||||
for slot, reason in reasons.items():
|
||||
content["anchor_frames"][slot]["reason"] = reason
|
||||
|
||||
return edit
|
||||
|
||||
def _fail(content: dict[str, Any], curve: str, code: str) -> None:
|
||||
content["fits"][curve].update(success=False, failure_reason=code)
|
||||
if curve in content["perfusion_metrics"]:
|
||||
content["perfusion_metrics"][curve] = None
|
||||
|
||||
|
||||
def _pan_ok(content: dict[str, Any]) -> None:
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||
content["perfusion_metrics"]["roi_pan"] = perfusion(1.0)
|
||||
|
||||
|
||||
def _p001(content: dict[str, Any]) -> None:
|
||||
_fail(content, "roi_perf", "parameter_on_boundary")
|
||||
content["fits"]["roi_pan"]["failure_reason"] = "insufficient_valid_samples"
|
||||
_unlocated(content, {**dict.fromkeys(DYNAMIC, FIT_FAILED), "F2": F2_AFTER})
|
||||
|
||||
|
||||
def _p004(content: dict[str, Any]) -> None:
|
||||
content["reference_valid"] = True
|
||||
_pan_ok(content)
|
||||
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
|
||||
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
|
||||
values = content["perfusion_metrics"]["roi_perf"]["values"]
|
||||
values.update(
|
||||
PE_au=21.456,
|
||||
TTP_s=13.04,
|
||||
WiR_au_per_s=3.2,
|
||||
RT_s=6.26,
|
||||
WiAUC_au_s=80,
|
||||
WiPI_au=12.777,
|
||||
WoR_au_per_s=-1.234,
|
||||
FT_s=None,
|
||||
WoAUC_au_s=None,
|
||||
WiWoAUC_au_s=None,
|
||||
mTTl_s=None,
|
||||
)
|
||||
|
||||
|
||||
def _p006(content: dict[str, Any]) -> None:
|
||||
_fail_fit("roi_all", "credible_peak_not_found")(content)
|
||||
_fail(content, "roi_all", "credible_peak_not_found")
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"]["failure_reason"] = "fit_quality_failed"
|
||||
_unlocated(content, dict.fromkeys(DYNAMIC, FIT_FAILED))
|
||||
|
||||
|
||||
def _p008(content: dict[str, Any]) -> None:
|
||||
_pan_ok(content)
|
||||
taken = "{} 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序"
|
||||
_unlocated(
|
||||
content,
|
||||
{
|
||||
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||
"D2": taken.format("D2"),
|
||||
"D3": taken.format("D3"),
|
||||
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||||
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
LOCATED: dict[str, tuple[str, ...]] = {
|
||||
"U001": ("F1",),
|
||||
"U004": ALL_SLOTS,
|
||||
"U005": ALL_SLOTS,
|
||||
"U006": ("F1", "F2"),
|
||||
"U008": ("D1", "F1"),
|
||||
}
|
||||
|
||||
|
||||
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
|
||||
delivery = make_delivery(6)
|
||||
delivery = make_delivery(8)
|
||||
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
|
||||
delivery.case(5).clinical["Age"] = 28
|
||||
delivery.case(7).mr["性别"] = "男"
|
||||
delivery_dir = delivery.write(root / "delivery")
|
||||
run = write_run(
|
||||
root / "runs" / RUN_ID,
|
||||
delivery_dir,
|
||||
{"U001": ("D1", "D2", "F1"), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ALL_SLOTS},
|
||||
[("U002", 2, "两半都没有绿框")],
|
||||
root / "runs" / RUN_ID, delivery_dir, LOCATED, [("U002", 2, STEP2), ("U007", 1, STEP1)]
|
||||
)
|
||||
_rewrite_summary(
|
||||
run, "U001", ("D1", "D2", "F1"), _fail_fit("roi_perf", "parameter_on_boundary")
|
||||
)
|
||||
_rewrite_summary(run, "U004", ALL_SLOTS, _p004)
|
||||
_rewrite_summary(run, "U006", ALL_SLOTS, _p006)
|
||||
for video_id, edit in (("U001", _p001), ("U004", _p004), ("U006", _p006), ("U008", _p008)):
|
||||
_rewrite_summary(run, video_id, LOCATED[video_id], edit)
|
||||
_rewrite_summary(run, "U005", ALL_SLOTS, _pan_ok)
|
||||
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
|
||||
|
||||
|
||||
@@ -160,28 +256,32 @@ def test_directory_layout(packed: Packed) -> None:
|
||||
"病例",
|
||||
}
|
||||
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
|
||||
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"}
|
||||
for case in ("P001", "P004", "P006"):
|
||||
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006", "P008"}
|
||||
for case in ("P001", "P004", "P006", "P008"):
|
||||
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
|
||||
assert {p.name for p in (pack / "病例" / "P001" / "关键帧").iterdir()} == {
|
||||
"D1_第2.5秒.png",
|
||||
"D2_第5.0秒.png",
|
||||
"F1_第12.5秒.png",
|
||||
}
|
||||
assert {p.name for p in (pack / "病例" / "P004" / "关键帧").iterdir()} == {
|
||||
"B0_第0.0秒.png",
|
||||
"D1_第2.5秒.png",
|
||||
"D2_第5.0秒.png",
|
||||
"D3_第7.5秒.png",
|
||||
"D4_第10.0秒.png",
|
||||
"F1_第12.5秒.png",
|
||||
"F2_第15.0秒.png",
|
||||
assert not [p for p in pack.rglob("*") if p.name in ("TIC结果.csv", "关键图.png")]
|
||||
keyframes = {
|
||||
"P001": {"医生截图.png", "F1_第12.5秒.png"},
|
||||
"P004": {
|
||||
"医生截图.png",
|
||||
"B0_第0.0秒.png",
|
||||
"D1_第2.5秒.png",
|
||||
"D2_第5.0秒.png",
|
||||
"D3_第7.5秒.png",
|
||||
"D4_第10.0秒.png",
|
||||
"F1_第12.5秒.png",
|
||||
"F2_第15.0秒.png",
|
||||
},
|
||||
"P006": {"医生截图.png", "F1_第12.5秒.png", "F2_第15.0秒.png"},
|
||||
"P008": {"医生截图.png", "D1_第2.5秒.png", "F1_第12.5秒.png"},
|
||||
}
|
||||
for case, names in keyframes.items():
|
||||
assert {p.name for p in (pack / "病例" / case / "关键帧").iterdir()} == names
|
||||
|
||||
|
||||
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
|
||||
csvs = sorted(packed.pack.rglob("*.csv"))
|
||||
assert len(csvs) == 2 + 4 + 3 * 2
|
||||
assert len(csvs) == 2 + 4 + 4 * 2
|
||||
for path in csvs:
|
||||
for row in _csv(path):
|
||||
assert not {"null", "None", "NaN", "nan"} & set(row), path
|
||||
@@ -200,25 +300,11 @@ def test_case_summary(packed: Packed) -> None:
|
||||
rows = _csv(packed.pack / "病例总表.csv")[1:]
|
||||
blank = [""] * 7
|
||||
a = packed.alias
|
||||
no = "未取到"
|
||||
assert rows == [
|
||||
[
|
||||
"P001",
|
||||
"U001",
|
||||
a["001"],
|
||||
"是",
|
||||
"",
|
||||
"是",
|
||||
"",
|
||||
"未取到",
|
||||
"2.5",
|
||||
"5.0",
|
||||
"未取到",
|
||||
"未取到",
|
||||
"12.5",
|
||||
"未取到",
|
||||
],
|
||||
["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank],
|
||||
["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank],
|
||||
["P001", "U001", a["001"], "是", "", "是", "", no, no, no, no, no, "12.5", no],
|
||||
["P002", "U002", "", "否", f"视频里找不到画好的绿框{CONS}", "否", "2", *blank],
|
||||
["P003", "U003", "", "否", "造影视频没有处理结果", "", "", *blank],
|
||||
[
|
||||
"P004",
|
||||
"U004",
|
||||
@@ -235,45 +321,152 @@ def test_case_summary(packed: Packed) -> None:
|
||||
"12.46",
|
||||
"15.0",
|
||||
],
|
||||
["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank],
|
||||
[
|
||||
"P006",
|
||||
"U006",
|
||||
a["006"],
|
||||
"P005",
|
||||
"U005",
|
||||
"",
|
||||
"否",
|
||||
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||||
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁",
|
||||
"是",
|
||||
"",
|
||||
"是",
|
||||
"",
|
||||
"0.0",
|
||||
"2.5",
|
||||
"5.0",
|
||||
"7.5",
|
||||
"10.0",
|
||||
"12.5",
|
||||
"15.0",
|
||||
*blank,
|
||||
],
|
||||
["P006", "U006", a["006"], "是", "", "是", "", no, no, no, no, no, "12.5", "15.0"],
|
||||
[
|
||||
"P007",
|
||||
"U007",
|
||||
"",
|
||||
"否",
|
||||
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE}",
|
||||
"否",
|
||||
"1",
|
||||
*blank,
|
||||
],
|
||||
["P008", "U008", a["008"], "是", "", "是", "", no, "2.5", no, no, no, "12.5", no],
|
||||
]
|
||||
|
||||
|
||||
def test_problem_list_has_every_source(packed: Packed) -> None:
|
||||
rows = _csv(packed.pack / "问题清单.csv")[1:]
|
||||
EXPECTED_PROBLEMS = [
|
||||
[
|
||||
"P001",
|
||||
"U001",
|
||||
"TIC 曲线",
|
||||
f"病灶去暗区那条曲线没能拟合({BOUNDARY}){LESION},TIC 参数里病灶一组也空着",
|
||||
],
|
||||
[
|
||||
"P001",
|
||||
"U001",
|
||||
"关键帧",
|
||||
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)",
|
||||
],
|
||||
[
|
||||
"P001",
|
||||
"U001",
|
||||
"紫圈",
|
||||
"视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着",
|
||||
],
|
||||
["P002", "U002", "造影视频处理", f"视频里找不到画好的绿框{CONS},这一例没进资料库"],
|
||||
["P003", "U003", "造影视频处理", "造影视频没有处理结果,这一例没进资料库"],
|
||||
[
|
||||
"P005",
|
||||
"U005",
|
||||
"资料核对",
|
||||
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||||
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁,这一例没进资料库",
|
||||
],
|
||||
[
|
||||
"P006",
|
||||
"U006",
|
||||
"TIC 曲线",
|
||||
f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}",
|
||||
],
|
||||
[
|
||||
"P006",
|
||||
"U006",
|
||||
"TIC 曲线",
|
||||
"紫圈那条曲线没能拟合(曲线起伏杂乱,平滑曲线和它差得太多),TIC 参数里胰腺实质一组空着",
|
||||
],
|
||||
[
|
||||
"P007",
|
||||
"U007",
|
||||
"资料核对",
|
||||
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE},这一例没进资料库",
|
||||
],
|
||||
["P007", "U007", "造影视频处理", f"视频左右两屏的位置对不齐{CONS}"],
|
||||
[
|
||||
"P008",
|
||||
"U008",
|
||||
"关键帧",
|
||||
"B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||||
],
|
||||
[
|
||||
"P008",
|
||||
"U008",
|
||||
"关键帧",
|
||||
"D2、D3 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||||
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||||
],
|
||||
[
|
||||
"P008",
|
||||
"U008",
|
||||
"关键帧",
|
||||
"D4 没有取:算出的时刻不在视频能用的画面范围里"
|
||||
"(D4 在视频第 41.5 秒,能用的画面是第 0.0–40.2 秒)",
|
||||
],
|
||||
["P008", "U008", "关键帧", "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)"],
|
||||
]
|
||||
|
||||
|
||||
def test_problem_list_in_plain_words(packed: Packed) -> None:
|
||||
assert _csv(packed.pack / "问题清单.csv")[1:] == EXPECTED_PROBLEMS
|
||||
counts = {"资料核对": 2, "造影视频处理": 3, "TIC 曲线": 3, "关键帧": 5, "紫圈": 1}
|
||||
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def test_problem_list_has_no_program_words(packed: Packed) -> None:
|
||||
"""任务书 04 验收第 3 条:问题清单全文不含检查编号、字段名、下划线、括号、band、运行号。"""
|
||||
text = (packed.pack / "问题清单.csv").read_text(encoding="utf-8-sig")
|
||||
assert FORBIDDEN.findall(text) == []
|
||||
|
||||
|
||||
def test_raw_problem_list_in_snapshot(packed: Packed) -> None:
|
||||
"""程序原文照任务书 03 的写法留在快照里,不进医生的目录。"""
|
||||
assert not (packed.pack / "问题清单_原文.csv").exists()
|
||||
rows = _csv(packed.result.run_dir / "snapshot" / "问题清单_原文.csv")
|
||||
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
|
||||
unlocated = [
|
||||
["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"]
|
||||
for slot in ("B0", "D3", "D4", "F2")
|
||||
]
|
||||
fit_failed = [f"{slot} 未取到:{FIT_FAILED}" for slot in DYNAMIC]
|
||||
p008 = {
|
||||
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||
"D2": "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||
"D3": "D3 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||||
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||
}
|
||||
|
||||
def c10(no: str) -> list[str]:
|
||||
row = skipped[no]
|
||||
return [
|
||||
f"P{no}",
|
||||
f"U{no}",
|
||||
"装配资料库",
|
||||
f"C10 跨表一致(诊断、年龄、性别);文件:{row[3]};列:{row[4]};涉及值:{row[5]}",
|
||||
]
|
||||
|
||||
assert rows == [
|
||||
PROBLEM_HEADER,
|
||||
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
|
||||
*unlocated,
|
||||
*[["P001", "U001", "关键帧", text] for text in fit_failed],
|
||||
["P001", "U001", "关键帧", f"F2 未取到:{F2_AFTER}"],
|
||||
["P001", "U001", "紫圈", "紫圈曲线不可用"],
|
||||
[
|
||||
"P002",
|
||||
"U002",
|
||||
"装配资料库",
|
||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;"
|
||||
'涉及值:"第 2 步失败:两半都没有绿框"',
|
||||
f'C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;涉及值:"第 2 步失败:{STEP2}"',
|
||||
],
|
||||
["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"],
|
||||
["P002", "U002", "CEUS 第 2 步", f"第 2 步失败:{STEP2}"],
|
||||
[
|
||||
"P003",
|
||||
"U003",
|
||||
@@ -281,30 +474,75 @@ def test_problem_list_has_every_source(packed: Packed) -> None:
|
||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
|
||||
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
|
||||
],
|
||||
[
|
||||
"P005",
|
||||
"U005",
|
||||
"装配资料库",
|
||||
f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};"
|
||||
f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}",
|
||||
],
|
||||
c10("005"),
|
||||
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
|
||||
*[["P006", "U006", "关键帧", text] for text in fit_failed],
|
||||
c10("007"),
|
||||
["P007", "U007", "CEUS 第 1 步", f"第 1 步失败:{STEP1}"],
|
||||
*[["P008", "U008", "关键帧", f"{slot} 未取到:{text}"] for slot, text in p008.items()],
|
||||
]
|
||||
counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1}
|
||||
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def test_tic_params(packed: Packed) -> None:
|
||||
def table(case: str) -> list[list[str]]:
|
||||
return _csv(packed.pack / "病例" / case / "TIC参数.csv")
|
||||
|
||||
def rows(pan: list[str], perf: list[str]) -> list[list[str]]:
|
||||
return [[*m, a, b] for m, a, b in zip(METRIC_NAMES, pan, perf, strict=True)]
|
||||
|
||||
p004_perf = ["21.46", "13.0", "3.20", "6.3", "80.00", "12.78", "-1.23", "", "", "", ""]
|
||||
assert table("P004") == [TIC_HEADER, *rows(SCALE1, p004_perf)]
|
||||
assert table("P008") == [TIC_HEADER, *rows(SCALE1, SCALE1)]
|
||||
assert table("P006") == [
|
||||
TIC_HEADER,
|
||||
*rows(BLANK, SCALE1),
|
||||
["没算出的原因", "", "曲线起伏杂乱,平滑曲线和它差得太多,没能拟合", ""],
|
||||
]
|
||||
assert table("P001") == [
|
||||
TIC_HEADER,
|
||||
*rows(BLANK, BLANK),
|
||||
[
|
||||
"没算出的原因",
|
||||
"",
|
||||
"视频里大部分时间认不出紫圈",
|
||||
f"{BOUNDARY},没能拟合",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_tic_params_match_library(packed: Packed) -> None:
|
||||
"""TIC参数.csv 的每一格都是资料库 tic.json 里那个数按小数位写出(null 为空)。"""
|
||||
keys = ["PE_au", "TTP_s", "WiR_au_per_s", "RT_s", "WiAUC_au_s", "WiPI_au"]
|
||||
keys += ["WoR_au_per_s", "FT_s", "WoAUC_au_s", "WiWoAUC_au_s", "mTTl_s"]
|
||||
for case_no in ("001", "004", "006", "008"):
|
||||
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
|
||||
assert tic is not None
|
||||
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC参数.csv")[1:12]
|
||||
for row, key in zip(rows, keys, strict=True):
|
||||
for cell, group in zip(row[2:], ("roi_pan", "roi_perf"), strict=True):
|
||||
values = tic["perfusion_metrics"][group]
|
||||
value = None if values is None else values[key]
|
||||
digits = 1 if key in ("TTP_s", "RT_s", "FT_s", "mTTl_s") else 2
|
||||
assert cell == ("" if value is None else f"{round(value, digits):.{digits}f}")
|
||||
|
||||
|
||||
def test_copies_are_byte_identical(packed: Packed) -> None:
|
||||
for case_no, video_id in (("001", "U001"), ("004", "U004")):
|
||||
case_dir = packed.pack / "病例" / f"P{case_no}"
|
||||
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
|
||||
assert (case_dir / "关键图.png").read_bytes() == route.key_image.read_bytes()
|
||||
key = (case_dir / "关键帧" / "医生截图.png").read_bytes()
|
||||
assert key == route.keyframes["key"].read_bytes()
|
||||
assert route.tic is not None
|
||||
copied = {p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir()}
|
||||
assert sorted(copied) == sorted(route.keyframes)
|
||||
for slot, source in route.keyframes.items():
|
||||
copied = {
|
||||
p.name.split("_")[0]: p
|
||||
for p in (case_dir / "关键帧").iterdir()
|
||||
if p.name != "医生截图.png"
|
||||
}
|
||||
slots = {slot: path for slot, path in route.keyframes.items() if slot != "key"}
|
||||
assert sorted(copied) == sorted(slots)
|
||||
for slot, source in slots.items():
|
||||
assert copied[slot].read_bytes() == source.read_bytes()
|
||||
seconds = route.tic["keyframes"][slot]["video_time_s"]
|
||||
seconds = route.tic["keyframe_times_s"][slot]
|
||||
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
|
||||
run_case = packed.run / video_id
|
||||
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
|
||||
@@ -325,45 +563,21 @@ def _tool_output(tool: str, *args: str) -> str:
|
||||
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
|
||||
|
||||
|
||||
def _expected_cell(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return value if isinstance(value, str) else repr(value)
|
||||
|
||||
|
||||
def test_tic_results_match_tic_json(packed: Packed) -> None:
|
||||
for case_no in ("001", "004", "006"):
|
||||
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
|
||||
assert tic is not None
|
||||
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC结果.csv")
|
||||
assert rows[0] == ["项目", "值"]
|
||||
# 4 条曲线 × 2 + 病灶 2 条 ×(3 个口径 + 11 个指标 + 3 个辅助时刻)+ 紫圈 1 + 7 槽位 × 3。
|
||||
assert len(rows) - 1 == 8 + 34 + 1 + 21
|
||||
for item, value in rows[1:]:
|
||||
path = item[item.rindex("(") + 1 : -1]
|
||||
node: Any = tic
|
||||
for key in path.split("."):
|
||||
node = None if node is None else node[key]
|
||||
assert value == _expected_cell(node), (case_no, item)
|
||||
rows = dict(_csv(packed.pack / "病例" / "P001" / "TIC结果.csv")[1:])
|
||||
assert rows["病灶整体曲线 拟合成功(fits.roi_all.success)"] == "true"
|
||||
assert rows["病灶去暗区曲线 拟合成功(fits.roi_perf.success)"] == "false"
|
||||
assert rows["紫圈曲线可用(pan_curve_usable)"] == "false"
|
||||
assert rows["关键帧 D4 未取到的原因(keyframes.D4.reason)"] == UNLOCATED_REASON
|
||||
|
||||
|
||||
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
|
||||
pdfs = sorted(packed.pack.rglob("*.pdf"))
|
||||
assert len(pdfs) == packed.result.pdfs == 1 + 3 * 2
|
||||
assert len(pdfs) == packed.result.pdfs == 1 + 4 * 2
|
||||
for pdf in pdfs:
|
||||
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
|
||||
readme = packed.pack / "说明.pdf"
|
||||
fonts = _tool_output("pdffonts", str(readme))
|
||||
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
|
||||
pages = re.search(r"^Pages:\s+([0-9]+)$", _tool_output("pdfinfo", str(readme)), re.M)
|
||||
assert pages is not None and int(pages.group(1)) <= 4
|
||||
text = _tool_output("pdftotext", str(readme), "-")
|
||||
assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text
|
||||
assert packed.build.build_id in text and RUN_ID in text
|
||||
# 说明里不写检查编号、原因码、字段名。
|
||||
for word in ("C15", "parameter_on_boundary", "perfusion_metrics", "贵方", "MedSAM"):
|
||||
assert word not in text
|
||||
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
|
||||
assert report.startswith("病例号 P001 超声描述")
|
||||
|
||||
@@ -384,6 +598,17 @@ def test_existing_run_dir_stops(packed: Packed) -> None:
|
||||
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
|
||||
|
||||
|
||||
def test_unknown_reason_stops(tmp_path: Path) -> None:
|
||||
"""关键帧未取到的原因不在对照表里(假运行的默认原因是编造的):报错停下,不出包。"""
|
||||
delivery_dir = make_delivery(1).write(tmp_path / "delivery")
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, delivery_dir, {"U001": ("D1",)}, [])
|
||||
build = run_build(write_config(tmp_path, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW)
|
||||
config = _pack_config(tmp_path, build.library_dir, build.answers_dir)
|
||||
with pytest.raises(ValueError, match="不在对照表里"):
|
||||
run_review_pack(config, REPO_ROOT, PACK_NOW)
|
||||
assert not [p for p in (tmp_path / "review_pack").iterdir() if not p.name.endswith(".partial")]
|
||||
|
||||
|
||||
def test_build_without_ceus(tmp_path: Path) -> None:
|
||||
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
|
||||
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
|
||||
@@ -391,7 +616,9 @@ def test_build_without_ceus(tmp_path: Path) -> None:
|
||||
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
|
||||
)
|
||||
for case in ("P001", "P002"):
|
||||
assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES
|
||||
case_dir = result.pack_dir / "病例" / case
|
||||
assert {p.name for p in case_dir.iterdir()} == CASE_FILES
|
||||
assert {p.name for p in (case_dir / "关键帧").iterdir()} == {"医生截图.png"}
|
||||
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
|
||||
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
|
||||
assert [row[:5] for row in rows] == [
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
"""原因对照表:每种程序原因 → 给医生的那句话,逐条手写预期(任务书 04「原因的写法」)。
|
||||
|
||||
程序原因按源码里的写法造(`_anchor_frames.py`、`_tic_fitting.py`、`ceus/` 各步的异常、
|
||||
`ceus_quant.py` 的目视核对、delivery 的 C01–C13、build 的 C14、C15),数值是编的。
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from cadx.answers import SkippedRow
|
||||
from cadx.review._problems import (
|
||||
UnknownReasonError,
|
||||
case_label,
|
||||
ceus_failure,
|
||||
check_plain,
|
||||
curve_problems,
|
||||
group_reason,
|
||||
skip_problem,
|
||||
)
|
||||
from fake_ceus_run import summary
|
||||
|
||||
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||||
BROKEN = "视频文件本身有问题(帧率、帧数或时间信息不对),程序读不了"
|
||||
NO_SPLIT = "视频里找不到左右两屏的分界线"
|
||||
MISALIGNED = "视频左右两屏的位置对不齐"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("step", "reason", "expected"),
|
||||
[
|
||||
(1, "配准目视核对未通过", f"{MISALIGNED}(我们人工逐例看过,这一例没对上)"),
|
||||
(1, "cv2 打不开视频:/x/U001_roi2.mp4", "视频文件打不开"),
|
||||
(1, "探测到非法 fps=0.0:/x/U001.mp4", BROKEN),
|
||||
(1, "探测到非法元数据 frame_count=0 width=1 height=1:/x/U001.mp4", BROKEN),
|
||||
(1, "视频包含非法显示时间戳 -1.0:/x/U001.mp4", BROKEN),
|
||||
(1, "视频显示时间戳不严格递增:第 3 帧 0.1,前一帧 0.2:/x/U001.mp4", BROKEN),
|
||||
(1, "时间戳帧数与容器记录的帧数不一致:3 / 4:/x/U001.mp4", BROKEN),
|
||||
(1, "解码帧数 3 与容器记录的帧数 4 不一致:/x/U001.mp4", BROKEN),
|
||||
(1, "帧尺寸不一致:首帧 (4, 6, 3),第 2 帧 (4, 8, 3)。", "视频前后画面大小不一致"),
|
||||
(1, "帧序列为空,无法求跨帧平均灰度。", "视频里一帧画面都没有"),
|
||||
(1, "帧序列为空,无法找分屏线。", "视频里一帧画面都没有"),
|
||||
(
|
||||
1,
|
||||
"中部 band 内未找到稳定暗缝(谷值 40.2 未显著低于 中位 51.0),疑为无缝或全黑画面。",
|
||||
NO_SPLIT,
|
||||
),
|
||||
(1, "非法 search_band:要求 0<=lo<hi<=1,收到 (0.6, 0.4)。", NO_SPLIT),
|
||||
(1, "search_band 在宽度 10 下退化为空区间 [4, 4)。", NO_SPLIT),
|
||||
(1, "检测到的 split_x=9 落在搜索 band [3, 7) 外。", NO_SPLIT),
|
||||
(1, "切分线 split_x=0 使某一侧宽度退化(左 0 / 右 5)。", NO_SPLIT),
|
||||
(
|
||||
1,
|
||||
"左右宽度比 0.30 超出 [0.5, 2.0](左 100 / 右 330),切分线不可信。",
|
||||
"找到的左右两屏分界线不可信(两屏宽窄差太多)",
|
||||
),
|
||||
(
|
||||
1,
|
||||
"这一半画面里找不到扇形区(没有随时间变化的区域)",
|
||||
"视频有一屏里找不到超声扇形画面",
|
||||
),
|
||||
(
|
||||
1,
|
||||
"左半扇形内可用的模板块只有 2 块,少于 4 块",
|
||||
f"{MISALIGNED}(画面里能拿来比对的细节太少)",
|
||||
),
|
||||
(
|
||||
1,
|
||||
"两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),"
|
||||
"相差 6 像素,超过门槛 4",
|
||||
MISALIGNED,
|
||||
),
|
||||
(
|
||||
2,
|
||||
"左右两半都找到绿框(左半 3 个提示帧,右半 2 个),定不出造影画面在哪一半",
|
||||
"视频左右两屏都有绿框,分不出哪一屏是造影画面",
|
||||
),
|
||||
(2, "左右两半都没有找到绿框,选不出提示帧", "视频里找不到画好的绿框"),
|
||||
(
|
||||
4,
|
||||
"逐帧 mask 未产出任何有效 ROI 轮廓,无从时间插值。",
|
||||
"整段视频里程序都没能画出病灶轮廓",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_ceus_failure(step: int, reason: str, expected: str) -> None:
|
||||
assert ceus_failure(step, reason) == f"{expected},程序没法处理这段视频"
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
# ceus 各步一例失败时抛的 CeusCaseError 子类 → 第几步(各类的 *_STEP 常量)。
|
||||
_CASE_ERRORS = {
|
||||
"VideoDecodeError": 1,
|
||||
"SplitDetectionError": 1,
|
||||
"RegistrationError": 1,
|
||||
"ContrastSideError": 2,
|
||||
"NoValidContourError": 4,
|
||||
}
|
||||
|
||||
|
||||
def _raised_messages() -> list[tuple[str, int, str]]:
|
||||
"""源码里每处 raise 一例失败的地方:(位置,第几步,消息,格式化部分代成 1)。"""
|
||||
found: list[tuple[str, int, str]] = []
|
||||
files = [
|
||||
*sorted((REPO / "src" / "cadx" / "ceus").glob("*.py")),
|
||||
REPO / "scripts" / "ceus_quant.py",
|
||||
]
|
||||
for path in files:
|
||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||
if not (isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call)):
|
||||
continue
|
||||
func = node.exc.func
|
||||
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||||
args = node.exc.args
|
||||
if name in _CASE_ERRORS:
|
||||
step, message = _CASE_ERRORS[name], args[0]
|
||||
elif name == "CeusCaseError":
|
||||
first = args[0]
|
||||
assert isinstance(first, ast.Constant) and isinstance(first.value, int)
|
||||
step, message = first.value, args[1]
|
||||
else:
|
||||
continue
|
||||
if isinstance(message, ast.Constant):
|
||||
text = str(message.value)
|
||||
else:
|
||||
assert isinstance(message, ast.JoinedStr), f"{path}:{node.lineno}"
|
||||
text = "".join(
|
||||
str(v.value) if isinstance(v, ast.Constant) else "1" for v in message.values
|
||||
)
|
||||
found.append((f"{path.name}:{node.lineno}", step, text))
|
||||
return found
|
||||
|
||||
|
||||
def test_every_raised_ceus_failure_is_in_the_table() -> None:
|
||||
"""ceus 和 ceus_quant 里每一处 raise 一例失败的消息,对照表都认得(新加一处就会在这里报出)。"""
|
||||
messages = _raised_messages()
|
||||
assert len(messages) >= 20
|
||||
for where, step, text in messages:
|
||||
assert not FORBIDDEN.search(ceus_failure(step, text)), where
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("step", "reason"),
|
||||
[(3, "分割模型报错"), (2, "配准目视核对未通过"), (1, "编造的原因")],
|
||||
)
|
||||
def test_unknown_ceus_failure_stops(step: int, reason: str) -> None:
|
||||
with pytest.raises(UnknownReasonError):
|
||||
ceus_failure(step, reason)
|
||||
|
||||
|
||||
FIT_CASES = {
|
||||
"insufficient_valid_samples": "能用的画面太少(很多帧认不出这块区域,或是重复帧),连不成曲线",
|
||||
"candidate_grid_unavailable": "能用的画面时间点太少,连不成曲线",
|
||||
"credible_peak_not_found": "曲线太平,没有明显的峰",
|
||||
"candidate_arrival_not_found": "峰之前找不到亮度明确、持续升起来的时刻",
|
||||
"insufficient_bolus_samples": "造影剂第一次流过的那一段能用的画面太少",
|
||||
"long_gap_in_wash_in": "变亮的那一段中间缺了一大段画面",
|
||||
"arrival_bounds_invalid": "峰离视频开头太近,或造影剂第一次流过的那一段太短",
|
||||
"optimizer_failed": "曲线形状不规则,平滑曲线怎么也贴不上",
|
||||
"parameter_on_boundary": (
|
||||
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||||
"有一项卡在了程序允许范围的边上"
|
||||
),
|
||||
"model_peak_on_boundary": "试着贴出来的曲线,峰落在可用那一段的最开头或最末尾,峰的位置不可信",
|
||||
"fit_quality_failed": "曲线起伏杂乱,平滑曲线和它差得太多",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("code", "phrase"), FIT_CASES.items())
|
||||
def test_fit_reason_codes(code: str, phrase: str) -> None:
|
||||
content = summary("U001", ())
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=False, failure_reason=code)
|
||||
assert group_reason(content, "roi_pan") == f"{phrase},没能拟合"
|
||||
content["fits"]["roi_perf"].update(success=False, failure_reason=code)
|
||||
assert group_reason(content, "roi_perf") == f"{phrase},没能拟合"
|
||||
|
||||
|
||||
def test_every_fit_reject_code_is_in_the_table() -> None:
|
||||
source = (REPO / "src" / "cadx" / "ceus" / "_tic_fitting.py").read_text(encoding="utf-8")
|
||||
codes = set(re.findall(r'reject\("([a-z_]+)"\)', source))
|
||||
assert codes == set(FIT_CASES)
|
||||
|
||||
|
||||
def test_group_reason_pan_unusable_and_unknown_code() -> None:
|
||||
content = summary("U001", ())
|
||||
assert group_reason(content, "roi_pan") == "视频里大部分时间认不出紫圈"
|
||||
content["fits"]["roi_perf"].update(success=False, failure_reason="new_code")
|
||||
with pytest.raises(UnknownReasonError):
|
||||
group_reason(content, "roi_perf")
|
||||
|
||||
|
||||
BOUNDARY = FIT_CASES["parameter_on_boundary"]
|
||||
LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取"
|
||||
FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
|
||||
|
||||
|
||||
def _curves(located: tuple[str, ...], edit: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
content = summary("U001", located)
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||
for slot, reason in edit.items():
|
||||
content["anchor_frames"][slot]["reason"] = reason
|
||||
return curve_problems(content)
|
||||
|
||||
|
||||
ALL = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slot", "reason", "expected"),
|
||||
[
|
||||
(
|
||||
"B0",
|
||||
"B0 锚点 3.70 秒的容差窗口 [3.20, 4.20] 秒 内,"
|
||||
"观测到的基线段里、到达时刻之前没有可用帧",
|
||||
"B0 没有取:开始增强之前的那一小段里没有能用的画面",
|
||||
),
|
||||
(
|
||||
"B0",
|
||||
"拟合曲线升不过到达门槛,没有到达时刻",
|
||||
"B0 没有取:曲线上升得太少,定不出造影剂到达的时刻",
|
||||
),
|
||||
(
|
||||
"D1",
|
||||
"拟合曲线上没有 D1 时刻(升不过到达门槛)",
|
||||
"D1 没有取:曲线上升得太少,定不出造影剂到达的时刻",
|
||||
),
|
||||
(
|
||||
"B0",
|
||||
"视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||
"B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||||
),
|
||||
(
|
||||
"D4",
|
||||
"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4",
|
||||
"D4 没有取:峰值之后录得不够长,找不到变暗最快的时刻",
|
||||
),
|
||||
(
|
||||
"D3",
|
||||
"D3 时刻 -0.40 秒落在视频有效帧范围 [0.04, 40.20] 秒 之外",
|
||||
"D3 没有取:算出的时刻不在视频能用的画面范围里"
|
||||
"(D3 在视频第 -0.4 秒,能用的画面是第 0.0–40.2 秒)",
|
||||
),
|
||||
(
|
||||
"D2",
|
||||
"D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||
"D2 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||||
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||||
),
|
||||
(
|
||||
"D2",
|
||||
"D2 容差窗口 [4.70, 5.30] 秒 内没有可用帧(拟合区间内、不重复)",
|
||||
"D2 没有取:这个时刻附近没有能用的画面"
|
||||
"(附近都是重复帧,或已过了造影剂第一次流过的那一段)",
|
||||
),
|
||||
(
|
||||
"F1",
|
||||
"推注后第 20 秒在视频第 -5.00 秒(推注时间 X=25 秒),"
|
||||
"落在视频有效帧范围 [0.00, 40.20] 秒之外",
|
||||
"F1 没有取:换算到视频里的时刻不在录到的范围内"
|
||||
"(F1 是推注后第 20 秒,应在视频第 -5.0 秒,那时还没开始录)",
|
||||
),
|
||||
(
|
||||
"F2",
|
||||
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),"
|
||||
"落在视频有效帧范围 [0.00, 40.20] 秒之外",
|
||||
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)",
|
||||
),
|
||||
(
|
||||
"F2",
|
||||
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在没有不重复的帧之外",
|
||||
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频里没有能用的画面)",
|
||||
),
|
||||
(
|
||||
"F2",
|
||||
"视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||
"F2 没有取:这个时刻前后没有能用的画面(都是重复帧)",
|
||||
),
|
||||
(
|
||||
"F1",
|
||||
"没有推注时间,推注后时刻换算不到视频时间",
|
||||
"F1 没有取:没有推注时间,换算不出视频里的时刻",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_keyframe_reasons(slot: str, reason: str, expected: str) -> None:
|
||||
located = tuple(s for s in ALL if s != slot)
|
||||
assert _curves(located, {slot: reason}) == [("关键帧", expected)]
|
||||
|
||||
|
||||
def test_same_reason_merges_into_one_row() -> None:
|
||||
reason = "视频第 {} 秒 ±2 秒内没有不重复的帧"
|
||||
rows = _curves(("B0", "D1", "D2", "D3", "D4"), {"F1": reason.format(1), "F2": reason.format(2)})
|
||||
assert rows == [("关键帧", "F1、F2 没有取:这个时刻前后没有能用的画面(都是重复帧)")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failed", "expected"),
|
||||
[
|
||||
(
|
||||
{"roi_all": "parameter_on_boundary"},
|
||||
f"病灶整体那条曲线没能拟合({BOUNDARY}){LESION}",
|
||||
),
|
||||
(
|
||||
{"roi_all": "parameter_on_boundary", "roi_perf": "parameter_on_boundary"},
|
||||
f"病灶整体、病灶去暗区两条曲线都没能拟合(都是{BOUNDARY}){LESION}"
|
||||
",TIC 参数里病灶一组也空着",
|
||||
),
|
||||
(
|
||||
{"roi_all": "credible_peak_not_found", "roi_perf": "fit_quality_failed"},
|
||||
"病灶整体、病灶去暗区两条曲线都没能拟合(病灶整体:曲线太平,没有明显的峰;"
|
||||
f"病灶去暗区:曲线起伏杂乱,平滑曲线和它差得太多){LESION}"
|
||||
",TIC 参数里病灶一组也空着",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_lesion_fit_failure_is_one_row(failed: dict[str, str], expected: str) -> None:
|
||||
content = summary("U001", ("F1", "F2"))
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||
for curve, code in failed.items():
|
||||
content["fits"][curve].update(success=False, failure_reason=code)
|
||||
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||
content["anchor_frames"][slot]["reason"] = FIT_FAILED
|
||||
assert curve_problems(content) == [("TIC 曲线", expected)]
|
||||
|
||||
|
||||
def test_roi_perf_fit_reason_counts_as_fit_failure() -> None:
|
||||
content = summary("U001", ("F1", "F2"))
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||
content["fits"]["roi_perf"].update(success=False, failure_reason="optimizer_failed")
|
||||
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||
content["anchor_frames"][slot]["reason"] = (
|
||||
"roi_perf 曲线的 Lognormal 拟合没过质量门(optimizer_failed)"
|
||||
)
|
||||
assert [stage for stage, _ in curve_problems(content)] == ["TIC 曲线"]
|
||||
|
||||
|
||||
def test_fit_failure_without_its_keyframes_stops() -> None:
|
||||
content = summary("U001", ALL)
|
||||
content["fits"]["roi_all"].update(success=False, failure_reason="optimizer_failed")
|
||||
with pytest.raises(ValueError, match="对不上"):
|
||||
curve_problems(content)
|
||||
|
||||
|
||||
def test_pan_rows() -> None:
|
||||
content = summary("U001", ALL)
|
||||
assert curve_problems(content) == [
|
||||
("紫圈", "视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着")
|
||||
]
|
||||
content["reference_valid"] = True
|
||||
content["fits"]["roi_pan"]["failure_reason"] = "long_gap_in_wash_in"
|
||||
assert curve_problems(content) == [
|
||||
(
|
||||
"TIC 曲线",
|
||||
"紫圈那条曲线没能拟合(变亮的那一段中间缺了一大段画面),TIC 参数里胰腺实质一组空着",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_keyframe_reason_stops() -> None:
|
||||
with pytest.raises(UnknownReasonError):
|
||||
_curves(("B0",), {s: "编造的原因" for s in ALL if s != "B0"})
|
||||
|
||||
|
||||
CLINICAL = "Clinical_agent/临床工作簿_40例.xlsx"
|
||||
ULTRASOUND = "CEUS_agent/超声报告文本_40例.xlsx"
|
||||
MR = "MR_agent/MR报告文本_40例.xlsx"
|
||||
DIAGNOSIS = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
|
||||
|
||||
|
||||
def _row(case: str, check: str, files: tuple[str, ...], value: object) -> SkippedRow:
|
||||
return SkippedRow(
|
||||
case=case,
|
||||
check=check,
|
||||
description="(检查说明不用)",
|
||||
files=files,
|
||||
columns=(),
|
||||
value_json=json.dumps(value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
NOT_IN = ",这一例没进资料库"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("row", "expected"),
|
||||
[
|
||||
(
|
||||
_row(f"{CLINICAL} 第 5 行", "C01", (CLINICAL,), {"原始写法": "P1", "行号": 5}),
|
||||
"临床表第 5 行的病例编号认不出(「P1」)",
|
||||
),
|
||||
(
|
||||
_row("病例目录 1000", "C01", ("CEUS_agent/1000",), {"目录名": "1000"}),
|
||||
"病例文件夹名「1000」认不出是哪个病例",
|
||||
),
|
||||
(
|
||||
_row("003", "C02", (MR,), {"行号": [4, 9]}),
|
||||
"MR 表里这个病例号出现了 2 次(第 4、9 行)",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C03",
|
||||
(ULTRASOUND, "CEUS_agent/003/"),
|
||||
{"缺在": [ULTRASOUND, "CEUS_agent/003/"]},
|
||||
),
|
||||
"在超声表、病例文件夹里找不到这一例",
|
||||
),
|
||||
(
|
||||
_row("003", "C04", ("CEUS_agent/003",), {"文件": ["U003_key.png", "U003.mp4"]}),
|
||||
"病例文件夹里应当恰好有截图、造影视频、单例超声报告表三个文件,实际有 2 个",
|
||||
),
|
||||
(
|
||||
_row("003", "C05", (ULTRASOUND,), {"CEUS视频ID": "U030", "病例号": "003"}),
|
||||
"超声表里这一例的 CEUS视频ID 写的是「U030」,和病例号对不上",
|
||||
),
|
||||
(
|
||||
_row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"工作表": ["A", "B"]}),
|
||||
"单例超声报告表的工作表不对(应只有一张)",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"表头": "与超声汇总表不一致"}
|
||||
),
|
||||
"单例超声报告表的表头和超声汇总表不一样",
|
||||
),
|
||||
(
|
||||
_row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"数据行数": 2}),
|
||||
"单例超声报告表应只有一行数据,实际有 2 行",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C06",
|
||||
("CEUS_agent/003/U003_report.xlsx", ULTRASOUND),
|
||||
{
|
||||
"年龄": {"单例报告表": "40岁", "超声汇总表": "41岁"},
|
||||
"超声描述": {"单例报告表": "甲" * 31, "超声汇总表": "乙"},
|
||||
},
|
||||
),
|
||||
"单例超声报告表和超声汇总表对不上:"
|
||||
"年龄(单例表 「40岁」,汇总表 「41岁」);超声描述(内容不一样)",
|
||||
),
|
||||
(
|
||||
_row("003", "C07", (CLINICAL,), {"Sex": 3, "CA199_Level": None}),
|
||||
"临床表这几项的取值不合规:性别(3)、CA19-9 是否异常(空着)",
|
||||
),
|
||||
(
|
||||
_row("003", "C08", (ULTRASOUND,), {"年龄": "四十", "推注开始时间s": -1}),
|
||||
"超声表这几项的取值不合规:年龄(「四十」)、推注开始时间(-1)",
|
||||
),
|
||||
(
|
||||
_row("003", "C09", (MR,), {DIAGNOSIS: 5, "MR描述": None}),
|
||||
"MR 表这几项的取值不合规:病理诊断(5)、MR描述(空着)",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C10",
|
||||
(CLINICAL, ULTRASOUND, MR),
|
||||
{"年龄": {"临床表": 28, "超声表": "63岁", "MR 表": "63岁"}},
|
||||
),
|
||||
"三张表的年龄对不上:临床表 28 岁,超声表 63 岁,MR 表 63 岁",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C10",
|
||||
(CLINICAL, ULTRASOUND, MR),
|
||||
{"性别": {"超声表": "男", "MR 表": "男", "临床表(解码后)": "女"}},
|
||||
),
|
||||
"三张表的性别对不上:超声表 男,MR 表 男,临床表 女",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C10",
|
||||
(CLINICAL, ULTRASOUND, MR),
|
||||
{"性别": {"超声表": "女", "MR 表": "男"}},
|
||||
),
|
||||
"超声表和 MR 表的性别对不上:超声表 女,MR 表 男"
|
||||
"(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)",
|
||||
),
|
||||
(
|
||||
_row("003", "C08", (ULTRASOUND,), {"年龄": "datetime.datetime(2026, 1, 1, 0, 0)"}),
|
||||
"超声表这几项的取值不合规:年龄(这一格是日期或时间格式)",
|
||||
),
|
||||
(
|
||||
_row("003", "C07", (CLINICAL,), {"Symptom_Fever": True, "Hx_Alcohol": False}),
|
||||
"临床表这几项的取值不合规:发热(这一格填的是 TRUE)、饮酒史(这一格填的是 FALSE)",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C06",
|
||||
("CEUS_agent/003/U003_report.xlsx", ULTRASOUND),
|
||||
{"推注开始时间s": {"单例报告表": "datetime.time(0, 0, 5)", "超声汇总表": 5}},
|
||||
),
|
||||
"单例超声报告表和超声汇总表对不上:"
|
||||
"推注开始时间(单例表 这一格是日期或时间格式,汇总表 5)",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C11",
|
||||
(ULTRASOUND,),
|
||||
{"超声描述": [{"关键词": "超声提示", "句子": "超声提示:囊性灶"}]},
|
||||
),
|
||||
"超声报告的描述里有像结论段标题的写法「超声提示:」(所在句子:超声提示:囊性灶)",
|
||||
),
|
||||
(
|
||||
_row("003", "C12", (MR,), {"MR描述": ["SCN", "浆液性囊"]}),
|
||||
"MR报告的描述里出现了亚型名「SCN」、「浆液性囊」",
|
||||
),
|
||||
(
|
||||
_row("003", "C13", (ULTRASOUND, MR), {"超声描述": ["女"], "MR描述": ["45岁"]}),
|
||||
"超声报告的描述里出现了性别或年龄「女」;MR报告的描述里出现了性别或年龄「45岁」",
|
||||
),
|
||||
(
|
||||
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "不是 PNG"),
|
||||
"医生截图不是 PNG 格式",
|
||||
),
|
||||
(
|
||||
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "带 tRNS 透明色,重编码会丢透明"),
|
||||
"医生截图带透明色,另存会丢掉透明信息",
|
||||
),
|
||||
(
|
||||
_row(
|
||||
"003",
|
||||
"C14",
|
||||
("20261017-080000-abcdef0/U003/keyframes/D2.png",),
|
||||
"位深 16、颜色类型 2 不能逐像素保真重编码",
|
||||
),
|
||||
"程序从视频里取的 D2 关键帧的颜色格式特殊,程序没法一点不差地另存",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_skip_reasons(row: SkippedRow, expected: str) -> None:
|
||||
assert skip_problem(row, {}) == ("资料核对", expected, expected + NOT_IN)
|
||||
|
||||
|
||||
def test_c15_merges_with_failed_csv() -> None:
|
||||
failed = _row(
|
||||
"002", "C15", ("RUN/failed.csv",), "第 2 步失败:左右两半都没有找到绿框,选不出提示帧"
|
||||
)
|
||||
reason = "视频里找不到画好的绿框,程序没法处理这段视频"
|
||||
steps = {"002": (2, "左右两半都没有找到绿框,选不出提示帧")}
|
||||
assert skip_problem(failed, steps) == ("造影视频处理", reason, reason + NOT_IN)
|
||||
with pytest.raises(ValueError, match="对不上"):
|
||||
skip_problem(failed, {"002": (1, "两半配准不一致:")})
|
||||
missing = _row("003", "C15", ("RUN/U003",), "CEUS 运行目录里没有这一例的结果目录")
|
||||
assert skip_problem(missing, {}) == (
|
||||
"造影视频处理",
|
||||
"造影视频没有处理结果",
|
||||
"造影视频没有处理结果" + NOT_IN,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"row",
|
||||
[
|
||||
_row("003", "C99", (), {}),
|
||||
_row("003", "C07", (CLINICAL,), {"NewColumn": 1}),
|
||||
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "编造的原因"),
|
||||
_row("003", "C15", ("RUN/U003",), "编造的原因"),
|
||||
_row("003", "C10", (CLINICAL,), {"身高": {"临床表": 1}}),
|
||||
_row("003", "C03", ("x/y.xlsx",), {"缺在": ["x/y.xlsx"]}),
|
||||
],
|
||||
)
|
||||
def test_unknown_skip_reason_stops(row: SkippedRow) -> None:
|
||||
with pytest.raises(UnknownReasonError):
|
||||
skip_problem(row, {})
|
||||
|
||||
|
||||
def test_case_labels() -> None:
|
||||
assert case_label("007") == "P007"
|
||||
assert case_label(f"{CLINICAL} 第 5 行") == "临床表第 5 行"
|
||||
assert case_label("病例目录 1000") == "病例文件夹「1000」"
|
||||
|
||||
|
||||
def test_program_words_stop() -> None:
|
||||
for text in ("C15 失败", "roi_pan", "a_b", "{", "[x]", "band", "20261017-080000"):
|
||||
assert FORBIDDEN.search(text) is not None
|
||||
with pytest.raises(ValueError, match="程序用语"):
|
||||
check_plain(text)
|
||||
assert check_plain("视频里找不到画好的绿框") == "视频里找不到画好的绿框"
|
||||
Reference in New Issue
Block a user