3524b31e3e
- delivery 的 CaseRecord 加视频路径和推注时间(C04、C08 已检查过)。 - scripts/ceus_quant.py:读 ceus.yaml,跑检查全部通过的病例;runs/ceus/<运行号>/ 先写 .partial 再改名;第 1–6 步的单例失败记 failed.csv 后接着跑;终端只打印计数。 - 分割缓存 work/segment_cache/<键>/:键含视频 SHA-256、第 1–3 步参数、权重 SHA-256、MedSAM2 提交号。 - 视频解码错误改为第 1 步的单例失败(VideoDecodeError),不再停掉整次运行(审阅意见)。 - 暗区 kmeans 用 cv2 进程级随机数,结果受病例顺序影响;ceus 配置加必填 random_seed,每例第 4 步前设种子。 - segment 导出 cuda_peak_memory_bytes,供运行快照记录显存峰值。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
437 lines
17 KiB
Python
437 lines
17 KiB
Python
"""逐例检查 C01–C13:只让一例违反一项,这一例跳过、记对检查编号,其余病例照常通过。
|
||
|
||
预期值照任务书 01 第二部分「单个病例的问题」的文字写。
|
||
"""
|
||
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
from cadx.delivery import DeliveryResult, read_delivery
|
||
from cadx.delivery._text import conclusion_headings
|
||
from fake_delivery import (
|
||
CLINICAL_PATH,
|
||
DIAGNOSIS_COLUMN,
|
||
MR_PATH,
|
||
MR_TEXT,
|
||
SAMPLE_CODE_MAPS,
|
||
ULTRASOUND_HEADER,
|
||
ULTRASOUND_PATH,
|
||
ULTRASOUND_TEXT,
|
||
FakeDelivery,
|
||
make_delivery,
|
||
)
|
||
|
||
|
||
def _read(
|
||
tmp_path: Path, delivery: FakeDelivery, code_maps: dict[str, dict[int, str]] | None
|
||
) -> DeliveryResult:
|
||
return read_delivery(delivery.write(tmp_path / "delivery"), code_maps)
|
||
|
||
|
||
def _assert_only_skipped(result: DeliveryResult, case: str, check: str, total: int) -> None:
|
||
assert [(s.case, s.check) for s in result.skipped] == [(case, check)]
|
||
passed = [f"{n:03d}" for n in range(1, total + 1) if f"{n:03d}" != case]
|
||
assert [c.case_no for c in result.cases] == passed
|
||
assert result.total_cases == total
|
||
|
||
|
||
def test_all_pass(tmp_path: Path) -> None:
|
||
result = _read(tmp_path, make_delivery(4), None)
|
||
assert [c.case_no for c in result.cases] == ["001", "002", "003", "004"]
|
||
assert result.skipped == ()
|
||
assert result.unrecognized_rows == 0
|
||
|
||
|
||
def test_c01_unrecognized_clinical_id_is_its_own_row_and_case_missing(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(2).clinical["Case_ID"] = "P2" # 临床表要求 P 加三位数字
|
||
result = _read(tmp_path, delivery, None)
|
||
# 第 1 行是表头,病例 2 在第 3 行;同一个病人因此在 C03(缺在临床表)再记一条。
|
||
assert [(s.case, s.check) for s in result.skipped] == [
|
||
(f"{CLINICAL_PATH} 第 3 行", "C01"),
|
||
("002", "C03"),
|
||
]
|
||
assert result.unrecognized_rows == 1
|
||
assert result.total_cases == 4
|
||
assert [c.case_no for c in result.cases] == ["001", "003", "004"]
|
||
|
||
|
||
def test_c01_float_report_id_is_unrecognized(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(3).mr["患者ID"] = 3.5 # 浮点不是认得出的写法
|
||
result = _read(tmp_path, delivery, None)
|
||
assert [(s.case, s.check) for s in result.skipped] == [
|
||
(f"{MR_PATH} 第 4 行", "C01"),
|
||
("003", "C03"),
|
||
]
|
||
|
||
|
||
def test_c01_report_ids_accept_int_and_zero_padded_strings(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["患者ID"] = 1
|
||
delivery.case(2).mr["患者ID"] = "002"
|
||
delivery.case(2).ultrasound["患者ID"] = "02"
|
||
result = _read(tmp_path, delivery, None)
|
||
assert result.skipped == ()
|
||
assert len(result.cases) == 3
|
||
|
||
|
||
def test_c02_duplicate_id_in_one_table(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
duplicate = delivery.case(2).mr
|
||
delivery.extra_rows[MR_PATH].append([duplicate[h] for h in delivery.headers[MR_PATH]])
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C02", 4)
|
||
|
||
|
||
def test_c03_case_directory_missing(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
root = delivery.write(tmp_path / "delivery")
|
||
shutil.rmtree(root / "CEUS_agent" / "003")
|
||
_assert_only_skipped(read_delivery(root, None), "003", "C03", 4)
|
||
|
||
|
||
def test_c04_missing_key_image(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(4).dir_files.remove("U004_key.png")
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C04", 4)
|
||
|
||
|
||
def test_c04_extra_file(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).dir_files.append("U001.mp4") # 两个视频共四个文件
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
|
||
|
||
|
||
def test_c04_roi2_video_with_roi2_report_passes(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
assert delivery.case(2).dir_files == ["U002_key.png", "U002_roi2.mp4", "U002_roi2_report.xlsx"]
|
||
assert _read(tmp_path, delivery, None).skipped == ()
|
||
|
||
|
||
def test_c04_plain_video_with_plain_report_passes(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(3).dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
|
||
result = _read(tmp_path, delivery, None)
|
||
assert result.skipped == ()
|
||
assert len(result.cases) == 3
|
||
|
||
|
||
def test_c04_roi2_video_with_plain_report_is_skipped(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
# 文件名被 gitleaks 的通用密钥规则误报,不是密钥。
|
||
delivery.case(1).dir_files = [
|
||
"U001_key.png",
|
||
"U001_roi2.mp4", # gitleaks:allow
|
||
"U001_report.xlsx",
|
||
]
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C04", 3)
|
||
|
||
|
||
def test_c04_plain_video_with_roi2_report_is_skipped(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).dir_files = ["U002_key.png", "U002.mp4", "U002_roi2_report.xlsx"]
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C04", 3)
|
||
|
||
|
||
def test_c06_reads_plain_report_when_paired_with_plain_video(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
case = delivery.case(3)
|
||
case.dir_files = ["U003_key.png", "U003.mp4", "U003_report.xlsx"]
|
||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||
row[-1] = ULTRASOUND_TEXT + "补充一句。"
|
||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C06", 3)
|
||
|
||
|
||
def test_c05_video_id_mismatch(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(2).ultrasound["CEUS视频ID"] = "U003"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C05", 4)
|
||
|
||
|
||
def test_c06_case_report_cell_differs(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
case = delivery.case(3)
|
||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||
row[-1] = ULTRASOUND_TEXT + "补充一句。"
|
||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "003", "C06", 4)
|
||
assert result.skipped[0].columns == ("超声描述",)
|
||
|
||
|
||
def test_c06_case_report_cell_type_differs(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
case = delivery.case(1)
|
||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||
row[0] = 1 # 汇总表里是字符串 "1"
|
||
case.case_report_rows = [list(ULTRASOUND_HEADER), row]
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C06", 3)
|
||
|
||
|
||
def test_c06_case_report_wrong_sheet(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).case_report_sheet = "Sheet2"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
|
||
|
||
|
||
def test_c06_case_report_two_data_rows(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
case = delivery.case(2)
|
||
row = [case.ultrasound[h] for h in ULTRASOUND_HEADER]
|
||
case.case_report_rows = [list(ULTRASOUND_HEADER), row, row]
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C06", 3)
|
||
|
||
|
||
def test_c07_bool_sex_is_not_an_int(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).clinical["Sex"] = True # bool 是 int 的子类,不能当作 1
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 3)
|
||
|
||
|
||
def test_c01_bool_report_id_is_unrecognized(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["患者ID"] = True
|
||
result = _read(tmp_path, delivery, None)
|
||
assert [(s.case, s.check) for s in result.skipped] == [
|
||
(f"{ULTRASOUND_PATH} 第 2 行", "C01"),
|
||
("001", "C03"),
|
||
]
|
||
|
||
|
||
def test_c07_sex_out_of_range(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(1).clinical["Sex"] = 3
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C07", 4)
|
||
|
||
|
||
def test_c07_non_integer_age(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).clinical["Age"] = 32.5
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
|
||
|
||
|
||
def test_c07_symptom_empty(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).clinical["Symptom_Fever"] = None
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C07", 3)
|
||
|
||
|
||
def test_c07_lab_only_one_column_empty(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(2).clinical["CA199"] = None # 档位列仍是 0
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "002", "C07", 4)
|
||
assert set(result.skipped[0].columns) == {"CA199", "CA199_Level"}
|
||
|
||
|
||
def test_c07_lab_both_empty_is_fine(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).clinical["TBil"] = None
|
||
delivery.case(1).clinical["TBil_Level"] = None
|
||
assert _read(tmp_path, delivery, None).skipped == ()
|
||
|
||
|
||
def test_c07_value_missing_from_code_map(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
maps = {**SAMPLE_CODE_MAPS, "Hx_Alcohol": {0: "否"}} # 映射表里没有 1
|
||
delivery.case(2).clinical["Hx_Alcohol"] = 1
|
||
_assert_only_skipped(_read(tmp_path, delivery, maps), "002", "C07", 3)
|
||
|
||
|
||
def test_c08_ultrasound_sex_invalid(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(4).ultrasound["性别"] = "M"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C08", 4)
|
||
|
||
|
||
def test_c08_negative_bolus_time(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(3).ultrasound["推注开始时间s"] = -1
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C08", 3)
|
||
|
||
|
||
def test_c08_empty_description(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["超声描述"] = None
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C08", 3)
|
||
|
||
|
||
def test_c09_mr_age_not_number_plus_sui(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(3).mr["年龄"] = "33"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C09", 4)
|
||
|
||
|
||
def test_c09_mr_diagnosis_out_of_range(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).mr[DIAGNOSIS_COLUMN] = 5
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C09", 3)
|
||
|
||
|
||
def test_c10_diagnosis_mismatch(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(1).clinical["Diagnosis"] = 2 # 两张报告表仍是 1
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "001", "C10", 4)
|
||
assert result.skipped[0].columns == ("诊断",)
|
||
|
||
|
||
def test_c10_age_mismatch(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(2).mr["年龄"] = "99岁"
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "002", "C10", 4)
|
||
assert result.skipped[0].columns == ("年龄",)
|
||
|
||
|
||
def test_c10_sex_mismatch_between_reports(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(3).mr["性别"] = "男" if delivery.case(3).ultrasound["性别"] == "女" else "女"
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "003", "C10", 4)
|
||
assert result.skipped[0].columns == ("性别",)
|
||
|
||
|
||
def test_c10_clinical_sex_compared_only_when_decoding(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
case = delivery.case(2)
|
||
case.clinical["Sex"] = 1 if case.clinical["Sex"] == 2 else 2 # 报告表的性别不变
|
||
undecoded = _read(tmp_path / "a", delivery, None)
|
||
assert undecoded.skipped == ()
|
||
assert undecoded.uncompared == ("临床性别未比对",)
|
||
decoded = _read(tmp_path / "b", delivery, SAMPLE_CODE_MAPS)
|
||
_assert_only_skipped(decoded, "002", "C10", 3)
|
||
assert decoded.uncompared == ()
|
||
|
||
|
||
def test_c11_heading_at_start_with_fullwidth_colon(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(2).ultrasound["超声描述"] = "结论:胰体部囊性病灶。" + ULTRASOUND_TEXT
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C11", 4)
|
||
|
||
|
||
def test_c11_heading_after_newline(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(3).mr["MR描述"] = MR_TEXT + "\n印象 : 胰体部囊性病灶。"
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "003", "C11", 4)
|
||
assert result.skipped[0].columns == ("MR描述",)
|
||
|
||
|
||
def test_c11_heading_mid_text_is_not_a_failure(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["超声描述"] = "胰体部见一无回声区。造影提示:囊壁呈等增强。"
|
||
result = _read(tmp_path, delivery, None)
|
||
assert result.skipped == ()
|
||
assert len(result.cases) == 3
|
||
|
||
|
||
def test_c12_subtype_name_fullwidth_lowercase(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(4).mr["MR描述"] = MR_TEXT + "形态类似ipmn。"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "004", "C12", 4)
|
||
|
||
|
||
def test_c12_chinese_subtype_name(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "符合浆液性囊腺瘤表现。"
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C12", 3)
|
||
|
||
|
||
def test_c11_heading_after_vertical_tab_or_form_feed() -> None:
|
||
# xlsx(XML)里写不进 \x0b、\x0c,openpyxl 拒绝生成,所以直接测规则函数。
|
||
for separator in ("\x0b", "\x0c"):
|
||
text = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
|
||
assert [k for k, _ in conclusion_headings(text)] == ["诊断"]
|
||
|
||
|
||
def test_c11_heading_after_unicode_line_separator(tmp_path: Path) -> None:
|
||
for i, separator in enumerate(("\x85", "\u2028", "\u2029")):
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).mr["MR描述"] = MR_TEXT + separator + "诊断:胰体部囊性病灶。"
|
||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C11", 3)
|
||
|
||
|
||
def test_c11_ratio_sign_as_colon(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["超声描述"] = "超声提示∶胰体部囊性病灶。" + ULTRASOUND_TEXT
|
||
result = _read(tmp_path, delivery, None)
|
||
_assert_only_skipped(result, "001", "C11", 3)
|
||
assert '"关键词": "超声提示"' in result.skipped[0].value_json
|
||
|
||
|
||
def test_c12_new_subtype_terms(tmp_path: Path) -> None:
|
||
terms = (
|
||
"SPT",
|
||
"sptp",
|
||
"IPMT",
|
||
"导管内乳头",
|
||
"实性假乳头",
|
||
"黏液囊性",
|
||
"粘液囊性",
|
||
"囊腺瘤",
|
||
"浆液性微囊",
|
||
)
|
||
for i, term in enumerate(terms):
|
||
delivery = make_delivery(3)
|
||
delivery.case(3).ultrasound["超声描述"] = ULTRASOUND_TEXT + f"形态与{term}相似。"
|
||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "003", "C12", 3)
|
||
|
||
|
||
def test_c12_serous_alone_is_fine(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(1).ultrasound["超声描述"] = ULTRASOUND_TEXT + "囊液呈浆液性。"
|
||
assert _read(tmp_path, delivery, None).skipped == ()
|
||
|
||
|
||
def test_c13_age_in_report(tmp_path: Path) -> None:
|
||
delivery = make_delivery(4)
|
||
delivery.case(1).ultrasound["超声描述"] = "患者45岁。" + ULTRASOUND_TEXT
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "001", "C13", 4)
|
||
|
||
|
||
def test_c13_age_variants(tmp_path: Path) -> None:
|
||
for i, age in enumerate(("四十五岁", "45 岁", "6周岁", "三十歲", "12周岁")):
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).mr["MR描述"] = f"患者{age}。" + MR_TEXT
|
||
_assert_only_skipped(_read(tmp_path / str(i), delivery, None), "002", "C13", 3)
|
||
|
||
|
||
def test_c13_sex_character_in_report(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(3).mr["MR描述"] = "女性患者。" + MR_TEXT
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "003", "C13", 3)
|
||
|
||
|
||
def test_only_first_failing_check_is_recorded(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).ultrasound["性别"] = "M" # C08
|
||
delivery.case(2).mr["年龄"] = "99岁" # C10
|
||
_assert_only_skipped(_read(tmp_path, delivery, None), "002", "C08", 3)
|
||
|
||
|
||
def test_skip_record_names_file_and_column(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).ultrasound["推注开始时间s"] = 2.5
|
||
record = _read(tmp_path, delivery, None).skipped[0]
|
||
assert record.files == (ULTRASOUND_PATH,)
|
||
assert record.columns == ("推注开始时间s",)
|
||
assert record.value_json == '{"推注开始时间s": 2.5}'
|
||
|
||
|
||
def test_case_record_carries_video_path_and_bolus_time(tmp_path: Path) -> None:
|
||
delivery = make_delivery(3)
|
||
delivery.case(2).dir_files = ["U002_key.png", "U002.mp4", "U002_report.xlsx"]
|
||
delivery.case(3).ultrasound["推注开始时间s"] = 0 # 0 是合法取值:视频第 0 秒正好推注
|
||
root = delivery.write(tmp_path / "delivery")
|
||
result = read_delivery(root, None)
|
||
folder = root / "CEUS_agent"
|
||
assert [c.video for c in result.cases] == [
|
||
folder / "001" / "U001_roi2.mp4",
|
||
folder / "002" / "U002.mp4",
|
||
folder / "003" / "U003_roi2.mp4",
|
||
]
|
||
# 假交付包的推注时间是 5,第 3 例改成 0。
|
||
assert [c.bolus_start_s for c in result.cases] == [5, 5, 0]
|