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:
2026-10-17 10:22:15 -04:00
parent 77ee03f9a3
commit 7e7f32a13d
19 changed files with 2111 additions and 634 deletions
+359 -132
View File
@@ -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] == [