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:
+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] == [
|
||||
|
||||
Reference in New Issue
Block a user