Files
CADx-4/tests/test_build_outputs.py
T
iomgaa 9a916c65a5 feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14
任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:46:56 -04:00

390 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""构建产物:可见性、泄露扫描、化名、解码开关、关键图、清单与读取、不覆盖、答案库内容。"""
import csv
import json
import re
from datetime import datetime
from pathlib import Path
import pytest
from PIL import Image
from cadx.answers import read_aliases, read_diagnoses
from cadx.build import make_aliases, run_build
from cadx.library import (
LibraryCase,
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
scan_for_leaks,
write_library,
)
from fake_delivery import (
CLINICAL_HEADER,
MR_TEXT,
REPO_ROOT,
SAMPLE_CODE_MAPS,
ULTRASOUND_TEXT,
build,
make_delivery,
write_config,
)
NOW = datetime(2026, 10, 16, 9, 30, 0).astimezone()
# 独立写的泄露词表,不取自 delivery 的常量。
SUBTYPES = (
"IPMN",
"SPN",
"MCN",
"SCN",
"导管内乳头状黏液性肿瘤",
"实性假乳头状瘤",
"黏液性囊性肿瘤",
"浆液性囊性肿瘤",
"浆液性囊腺瘤",
)
DIAGNOSIS_COLUMNS = ("病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)", "病理类型", "Diagnosis", "Case_ID")
DELIVERY_FILE_NAMES = ("临床工作簿_40例.xlsx", "超声报告文本_40例.xlsx", "MR报告文本_40例.xlsx")
FORBIDDEN_KEYS = ("diagnosis", "label", "gold", "pathology", "name", "row")
def _forbidden_strings(case_nos: list[str]) -> list[str]:
per_case = [
term
for no in case_nos
for term in (
f"P{no}",
f"U{no}",
f"U{no}_key.png",
f"U{no}_roi2.mp4",
f"U{no}_roi2_report.xlsx",
)
]
return [*per_case, *DELIVERY_FILE_NAMES, *SUBTYPES, *DIAGNOSIS_COLUMNS]
def _case_dirs(library_dir: Path) -> list[Path]:
return sorted((library_dir / "cases").iterdir())
def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None:
delivery = make_delivery(4)
summary = build(tmp_path, delivery, 1, SAMPLE_CODE_MAPS, NOW)
clinical_names = [*CLINICAL_HEADER, "男", "女", "岁"]
for case_dir in _case_dirs(summary.library_dir):
files = sorted(p.relative_to(case_dir).as_posix() for p in case_dir.rglob("*.*"))
assert files == [
"clinical/clinical.json",
"mr/report.txt",
"ultrasound/key.png",
"ultrasound/report.txt",
]
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
assert set(clinical["fields"]) == set(CLINICAL_HEADER) - {"Case_ID", "Diagnosis"}
for report in ("ultrasound/report.txt", "mr/report.txt"):
text = (case_dir / report).read_text(encoding="utf-8")
assert [name for name in clinical_names if name in text] == []
ages = {f"{30 + n}" for n in range(1, 5)}
for alias in list_aliases(summary.library_dir):
assert str(read_clinical(summary.library_dir, alias).fields["Age"]) in ages
assert read_ultrasound(summary.library_dir, alias).report == ULTRASOUND_TEXT
assert read_mr(summary.library_dir, alias).report == MR_TEXT
def test_leak_scan_finds_nothing_in_clean_build(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(4), 1, None, NOW)
case_nos = [f"{n:03d}" for n in range(1, 5)]
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(case_nos), FORBIDDEN_KEYS)
assert hits == []
def test_leak_scan_detects_planted_leaks(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
case_dir = _case_dirs(summary.library_dir)[0]
(case_dir / "mr/report.txt").write_text(MR_TEXT + "见 P002 。", encoding="utf-8")
(case_dir / "ultrasound/note.json").write_text('{"Label": "x"}', encoding="utf-8")
(case_dir / "clinical/extra.json").write_text(
'{"k": "\\u6d46\\u6db2\\u6027\\u56ca\\u6027\\u80bf\\u7624"}', encoding="utf-8"
)
hits = scan_for_leaks(summary.library_dir, _forbidden_strings(["002"]), FORBIDDEN_KEYS)
found = {(Path(h.file).name, h.kind, h.term) for h in hits}
assert found == {
("report.txt", "字符串", "P002"),
("note.json", "键名", "Label"),
("extra.json", "字符串", "浆液性囊性肿瘤"),
}
def test_leak_scan_normalizes_fullwidth_and_case(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
case_dir = _case_dirs(summary.library_dir)[0]
leaked = MR_TEXT + "形态似ipmn,见p002。"
(case_dir / "mr/report.txt").write_text(leaked, encoding="utf-8")
hits = scan_for_leaks(summary.library_dir, ["IPMN", "P002", "U002"], FORBIDDEN_KEYS)
assert {(Path(h.file).name, h.term) for h in hits} == {
("report.txt", "IPMN"),
("report.txt", "P002"),
}
(case_dir / "mr/report.txt").write_text(MR_TEXT + "U002", encoding="utf-8")
hits = scan_for_leaks(summary.library_dir, ["U002"], FORBIDDEN_KEYS)
assert [(Path(h.file).name, h.term) for h in hits] == [("report.txt", "U002")]
def test_aliases_are_reproducible_and_well_formed(tmp_path: Path) -> None:
first = build(tmp_path / "a", make_delivery(5), 11, None, NOW)
second = build(tmp_path / "b", make_delivery(5), 11, None, NOW)
other = build(tmp_path / "c", make_delivery(5), 12, None, NOW)
aliases = list_aliases(first.library_dir)
assert aliases == list_aliases(second.library_dir)
assert aliases != list_aliases(other.library_dir)
assert len(set(aliases)) == 5
assert all(re.fullmatch(r"[a-z0-9]{4}", a) for a in aliases)
def test_aliases_avoid_subtype_substrings_and_repeats() -> None:
case_nos = [f"{n:03d}" for n in range(1, 1000)]
aliases = make_aliases(case_nos, 3)
assert len(set(aliases.values())) == 999
banned = ("ipmn", "spn", "mcn", "scn")
assert [a for a in aliases.values() if any(b in a for b in banned)] == []
def test_alias_order_does_not_depend_on_input_order() -> None:
case_nos = ["003", "001", "002"]
assert make_aliases(case_nos, 5) == make_aliases(sorted(case_nos), 5)
def test_decode_off_keeps_raw_numbers(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(3), 1, None, NOW)
for alias in list_aliases(summary.library_dir):
clinical = read_clinical(summary.library_dir, alias)
assert clinical.codes_decoded is False
assert clinical.fields["Sex"] in (1, 2)
assert clinical.fields["Symptom_Pain"] == 1
assert clinical.fields["CEA_Level"] == 1
assert [d.name for d in read_diagnoses(summary.answers_dir)] == [None, None, None]
manifest = json.loads((summary.library_dir / "manifest.json").read_text(encoding="utf-8"))
assert manifest["codes_decoded"] is False
assert manifest["uncompared"] == ["临床性别未比对"]
def test_decode_on_uses_code_maps(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(3), 1, SAMPLE_CODE_MAPS, NOW)
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
# 假交付包:病例 n 的性别编码是 1 + n % 2,诊断编码是 1 + (n - 1) % 4。
expected_sex = {"001": "女", "002": "男", "003": "女"}
for case_no, alias in by_case.items():
clinical = read_clinical(summary.library_dir, alias)
assert clinical.codes_decoded is True
assert clinical.fields["Sex"] == expected_sex[case_no]
assert clinical.fields["Symptom_Pain"] == "是"
assert clinical.fields["CEA_Level"] == "异常"
assert clinical.fields["Amylase_Level"] is None
assert clinical.fields["Age"] == 30 + int(case_no)
names = {d.case_no: d.name for d in read_diagnoses(summary.answers_dir)}
assert names == {"001": "IPMN", "002": "SPN", "003": "MCN"}
def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
by_case = {row.case_no: row.alias for row in read_aliases(summary.answers_dir)}
for case_no, alias in by_case.items():
source = tmp_path / "delivery" / "CEUS_agent" / case_no / f"U{case_no}_key.png"
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
with Image.open(copied) as image:
assert image.mode == original_mode
assert image.tobytes() == original_pixels
assert "PatientName" not in image.info
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
def _png_chunks(data: bytes) -> list[str]:
"""按 PNG 格式逐块读出块类型(8 字节签名后:4 字节长度、4 字节类型、数据、4 字节 CRC)。"""
chunks: list[str] = []
offset = 8
while offset < len(data):
length = int.from_bytes(data[offset : offset + 4], "big")
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
offset += 12 + length
return chunks
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
"""002 这一例记 C14 跳过、涉及值是原因;001 照常进库。"""
(library_dir,) = list((tmp_path / "library").iterdir())
answers_dir = tmp_path / "answers" / library_dir.name
assert [r.case_no for r in read_aliases(answers_dir)] == ["001"]
assert len(list_aliases(library_dir)) == 1
skipped = _csv_rows(answers_dir / "skipped.csv")
assert [row[:4] for row in skipped[1:]] == [
["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"]
]
assert json.loads(skipped[1][5]) == reason
def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("I;16", (4, 4)))
_assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码")
def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None:
image = Image.new("RGB", (4, 4))
image.info["transparency"] = (0, 0, 0)
_build_with_key_image(tmp_path, image)
_assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明")
def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
# 只有一种颜色的调色板图,Pillow 存成 1 位。
_assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码")
def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
_assert_key_image_skipped(tmp_path, "不是 PNG")
def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
"""build 漏了 C14 检查时,library 写图那一步报错停下。"""
source = tmp_path / "key.png"
Image.new("P", (4, 4)).save(source, format="PNG")
target = tmp_path / "out"
target.mkdir()
case = LibraryCase(
alias="abcd",
clinical_fields={},
ultrasound_report="",
key_image_source=source,
mr_report="",
)
with pytest.raises(ValueError, match="check_key_image"):
write_library(target, "b", "c", True, {}, False, (), None, [case])
def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
source = Image.frombytes("1", (8, 2), bytes([0b10110010, 0b01001101]))
_build_with_key_image(tmp_path, source)
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
with Image.open(copied) as image:
assert image.mode == "1"
assert image.tobytes() == source.tobytes()
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
def test_library_read_rejects_modified_file(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
alias = list_aliases(summary.library_dir)[0]
report = summary.library_dir / "cases" / alias / "mr" / "report.txt"
report.write_text(MR_TEXT + "改", encoding="utf-8")
with pytest.raises(ValueError, match="SHA-256"):
read_mr(summary.library_dir, alias)
def test_library_read_rejects_partial_directory(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
partial = summary.library_dir.with_name(summary.library_dir.name + ".partial")
summary.library_dir.rename(partial)
with pytest.raises(ValueError, match="partial"):
list_aliases(partial)
def test_answers_read_rejects_modified_file(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
path = summary.answers_dir / "diagnoses.csv"
path.write_text(path.read_text(encoding="utf-8-sig") + "x\n", encoding="utf-8-sig")
with pytest.raises(ValueError, match="SHA-256"):
read_diagnoses(summary.answers_dir)
def test_existing_build_directory_is_not_overwritten(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, None, NOW)
manifest = (summary.library_dir / "manifest.json").read_bytes()
with pytest.raises(FileExistsError):
build(tmp_path, make_delivery(3), 1, None, NOW) # 同一时刻、同一提交 → 同一构建号
assert (summary.library_dir / "manifest.json").read_bytes() == manifest
assert len(list_aliases(summary.library_dir)) == 2
def _csv_rows(path: Path) -> list[list[str]]:
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle))
def test_answers_contents(tmp_path: Path) -> None:
delivery = make_delivery(4)
delivery.case(3).mr["年龄"] = "99岁" # C10
delivery.case(1).ultrasound["超声描述"] = "胰体部见无回声区。考虑囊性病灶;边界清。"
summary = build(tmp_path, delivery, 1, None, NOW)
assert summary.total_cases == 4
assert summary.passed_cases == 3
assert summary.skipped_cases == 1
assert summary.skipped_by_check == {"C10": 1}
assert summary.review_rows == 1
skipped = _csv_rows(summary.answers_dir / "skipped.csv")
assert skipped[0] == ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"]
assert [row[:2] for row in skipped[1:]] == [["003", "C10"]]
assert json.loads(skipped[1][5])["年龄"]["MR 表"] == "99岁"
review = _csv_rows(summary.answers_dir / "review.csv")
alias_of_1 = {r.case_no: r.alias for r in read_aliases(summary.answers_dir)}["001"]
assert review[1:] == [[alias_of_1, "超声", "考虑", "考虑囊性病灶"]]
assert [r.case_no for r in read_aliases(summary.answers_dir)] == ["001", "002", "004"]
assert [r.video_id for r in read_aliases(summary.answers_dir)] == ["U001", "U002", "U004"]
codes = [
(d.clinical_code, d.ultrasound_code, d.mr_code) for d in read_diagnoses(summary.answers_dir)
]
assert codes == [(1, 1, 1), (2, 2, 2), (4, 4, 4)]
manifest = json.loads((summary.answers_dir / "manifest.json").read_text(encoding="utf-8"))
assert manifest["build_id"] == summary.build_id
assert "不代表没有问题" in manifest["skipped_note"]
assert sorted(manifest["files"]) == [
"aliases.csv",
"diagnoses.csv",
"review.csv",
"skipped.csv",
"snapshot/build.yaml",
"snapshot/snapshot.json",
]
def test_snapshot_records_config_and_git(tmp_path: Path) -> None:
summary = build(tmp_path, make_delivery(2), 1, SAMPLE_CODE_MAPS, NOW)
snapshot_dir = summary.answers_dir / "snapshot"
assert (snapshot_dir / "build.yaml").read_bytes() == (tmp_path / "build.yaml").read_bytes()
assert (snapshot_dir / "code_maps.yaml").read_bytes() == (
tmp_path / "code_maps.yaml"
).read_bytes()
snapshot = json.loads((snapshot_dir / "snapshot.json").read_text(encoding="utf-8"))
assert snapshot["build_id"] == summary.build_id
assert re.fullmatch(r"[0-9a-f]{40}", snapshot["git_commit"])
assert summary.build_id.startswith("20261016-093000-" + snapshot["git_commit"][:7])
assert summary.build_id.endswith("-dirty") == (snapshot["git_clean"] is False)
assert set(snapshot["config_sha256"]) == {"build.yaml", "code_maps.yaml"}
library_manifest = json.loads(
(summary.library_dir / "manifest.json").read_text(encoding="utf-8")
)
assert library_manifest["config_sha256"] == snapshot["config_sha256"]
assert library_manifest["ceus_source"] is None
assert library_manifest["aliases"] == sorted(library_manifest["aliases"])