Files
CADx-4/tests/fake_delivery.py
iomgaa 1de88a5d0a fix(ceus): 降低长视频内存峰值,快照开跑即写,build 核对 CEUS 运行与交付包
- 内存:prepare_segmentation 改为流式解码,只留分屏和配准的抽样帧,绿框逐帧在两半上找;
  分割那一半由 SegmentationInput.target_frames() 到用时再解码,segment_video 边读边写 jpg;
  MedSAM2 init_state 的读帧换成直接在存放设备上建张量(不改 MedSAM2 源码,结果逐位相同);
  传播结果写进预分配数组。帧数最多的一段视频常驻内存峰值 11.2 GiB → 2.3 GiB,分辨率最大的
  一段原代码被 OOM 杀掉、现为 4.2 GiB;两段的掩码和全部产物与改前逐字节相同,33 段视频的
  第 1–2 步结果与改前一致,29 例配准与 review.csv 一致。
- 快照:ceus_quant 开跑时写 snapshot/(git、ceus.yaml、review.csv、uv.lock、segment.json),
  配置和核对表从快照副本读;收尾由新函数 runs.finish_snapshot 只新建 finished.json。
  build 与 ceus_align_review 同样改用 finish_snapshot 记结束时间。
- build:summary.json 加 video_sha256;装 CEUS 结果时核对快照里的 delivery_dir、逐例核对
  视频 SHA-256 和推注时间,不同就整次停下;新字段做类型检查,不进 tic.json。

来源:主会话任务(问题 1–3 及补充的 delivery_dir 比对)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:01:44 -04:00

275 lines
9.1 KiB
Python
Raw Permalink 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.
"""测试用假交付包:形状照 40 例交付包写(表头、工作表名、目录结构),内容全部编造。
这里的表头和文件名是按任务书 01 第二部分的表格独立写的,不取自 delivery 的常量,
这样 delivery 的常量写错时测试能发现。各条测试在 `FakeDelivery` 上改出要测的情况,再 `write`。
"""
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import openpyxl
import yaml
from PIL import Image, PngImagePlugin
from cadx.build import BuildSummary, run_build
REPO_ROOT = Path(__file__).resolve().parents[1]
DIAGNOSIS_COLUMN = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
CLINICAL_HEADER = [
"Case_ID",
"Sex",
"Age",
"Diagnosis",
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
"CA199",
"CA199_Level",
"CEA",
"CEA_Level",
"Amylase",
"Amylase_Level",
"Lipase",
"Lipase_Level",
"TBil",
"TBil_Level",
]
ULTRASOUND_HEADER = [
"患者ID",
"CEUS视频ID",
"性别",
"年龄",
DIAGNOSIS_COLUMN,
"推注开始时间s",
"超声描述",
]
MR_HEADER = ["患者ID", "性别", "年龄", DIAGNOSIS_COLUMN, "MR描述"]
CLINICAL_PATH = "Clinical_agent/临床工作簿_40例.xlsx"
ULTRASOUND_PATH = "CEUS_agent/超声报告文本_40例.xlsx"
MR_PATH = "MR_agent/MR报告文本_40例.xlsx"
# 编造的报告文字:不含亚型名、结论段标题、性别字、「数字岁」和复核措辞。
ULTRASOUND_TEXT = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
# 假交付包里每段 CEUS 视频文件的内容(不是真视频)。
VIDEO_BYTES = b"not a real video"
MR_TEXT = "胰体部见一类圆形长T2信号灶,边界清楚,内见细分隔;增强后分隔轻度强化。"
# 测试用的样例映射表:含义是编的,只用来验证解码路径。
SAMPLE_CODE_MAPS: dict[str, dict[int, str]] = {
"Sex": {1: "男", 2: "女"},
"Diagnosis": {1: "IPMN", 2: "SPN", 3: "MCN", 4: "SCN"},
**{
column: {0: "否", 1: "是"}
for column in (
"Symptom_Pain",
"Symptom_Jaundice",
"Symptom_WeightLoss",
"Symptom_Fever",
"Symptom_Asymptomatic",
"Hx_Pancreatitis",
"Hx_Alcohol",
"Hx_Gallstones",
"Hx_Malignancy",
"Hx_Family_PC",
)
},
**{
column: {0: "正常", 1: "异常"}
for column in ("CA199_Level", "CEA_Level", "Amylase_Level", "Lipase_Level", "TBil_Level")
},
}
@dataclass
class FakeCase:
"""一例:三张表各一行,病例目录里的文件名和单例报告表的行。"""
number: int
clinical: dict[str, object]
ultrasound: dict[str, object]
mr: dict[str, object]
dir_files: list[str]
# None 表示单例报告表照抄超声汇总表那一行。
case_report_rows: list[list[object]] | None = None
case_report_sheet: str = "Sheet1"
@property
def no(self) -> str:
return f"{self.number:03d}"
def make_case(number: int) -> FakeCase:
"""一例全部检查都能通过的病例。诊断按病例号轮转 1–4。"""
no = f"{number:03d}"
sex = 1 + number % 2
age = 30 + number
diagnosis = 1 + (number - 1) % 4
clinical: dict[str, object] = dict.fromkeys(CLINICAL_HEADER, 0)
clinical.update(
{
"Case_ID": f"P{no}",
"Sex": sex,
"Age": age,
"Diagnosis": diagnosis,
"Symptom_Pain": 1,
"CA199": 12.5,
"CA199_Level": 0,
"CEA": 3,
"CEA_Level": 1,
"Amylase": None,
"Amylase_Level": None,
"Lipase": None,
"Lipase_Level": None,
"TBil": 10.2,
"TBil_Level": 0,
}
)
sex_text = {1: "男", 2: "女"}[sex]
ultrasound: dict[str, object] = {
"患者ID": str(number),
"CEUS视频ID": f"U{no}",
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"推注开始时间s": 5,
"超声描述": ULTRASOUND_TEXT,
}
mr: dict[str, object] = {
"患者ID": str(number),
"性别": sex_text,
"年龄": f"{age}岁",
DIAGNOSIS_COLUMN: diagnosis,
"MR描述": MR_TEXT,
}
files = [f"U{no}_key.png", f"U{no}_roi2.mp4", f"U{no}_roi2_report.xlsx"]
return FakeCase(number, clinical, ultrasound, mr, files)
@dataclass
class FakeDelivery:
"""一个假交付包。`extra_rows` 是附加在汇总表末尾的整行(测试编号认不出的行)。"""
cases: list[FakeCase]
sheet_names: dict[str, str] = field(
default_factory=lambda: {
CLINICAL_PATH: "Sheet",
ULTRASOUND_PATH: "超声报告",
MR_PATH: "MR报告",
}
)
headers: dict[str, list[str]] = field(
default_factory=lambda: {
CLINICAL_PATH: list(CLINICAL_HEADER),
ULTRASOUND_PATH: list(ULTRASOUND_HEADER),
MR_PATH: list(MR_HEADER),
}
)
extra_rows: dict[str, list[list[object]]] = field(
default_factory=lambda: {CLINICAL_PATH: [], ULTRASOUND_PATH: [], MR_PATH: []}
)
def case(self, number: int) -> FakeCase:
return next(c for c in self.cases if c.number == number)
def write(self, root: Path) -> Path:
tables = {
CLINICAL_PATH: [c.clinical for c in self.cases],
ULTRASOUND_PATH: [c.ultrasound for c in self.cases],
MR_PATH: [c.mr for c in self.cases],
}
for path, rows in tables.items():
header = self.headers[path]
values = [[row.get(h) for h in header] for row in rows]
_write_workbook(
root / path, self.sheet_names[path], [header, *values, *self.extra_rows[path]]
)
for case in self.cases:
folder = root / "CEUS_agent" / case.no
folder.mkdir(parents=True, exist_ok=True)
for name in case.dir_files:
if name.endswith(".png"):
_write_png(folder / name, case.number)
elif name.endswith(".xlsx"):
row = [case.ultrasound.get(h) for h in ULTRASOUND_HEADER]
rows = case.case_report_rows or [list(ULTRASOUND_HEADER), row]
_write_workbook(folder / name, case.case_report_sheet, rows)
else:
(folder / name).write_bytes(VIDEO_BYTES)
(root / "交付说明_40例_20260908.md").write_text("说明不读", encoding="utf-8")
return root
def make_delivery(count: int) -> FakeDelivery:
"""病例号 1..count、全部能通过检查的假交付包。"""
return FakeDelivery(cases=[make_case(n) for n in range(1, count + 1)])
def _write_workbook(path: Path, sheet: str, rows: Sequence[Sequence[object]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
workbook = openpyxl.Workbook()
worksheet = workbook.active
assert worksheet is not None
worksheet.title = sheet
for row in rows:
# 假数据里的值都是 openpyxl 认得的单元格类型(字符串、数字、None)。
worksheet.append(cast("list[Any]", list(row)))
workbook.save(path)
def _write_png(path: Path, number: int) -> None:
pixels = bytes(v for i in range(24) for v in (number * 10 % 256, i * 10, 255 - i * 10))
image = Image.frombytes("RGB", (6, 4), pixels)
info = PngImagePlugin.PngInfo()
info.add_text("PatientName", "should-not-survive")
image.save(path, format="PNG", pnginfo=info)
def write_config(
tmp_path: Path,
delivery_dir: Path,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
ceus_run: Path | None,
) -> Path:
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
maps_path = None
if code_maps is not None:
maps_path = tmp_path / "code_maps.yaml"
maps_path.write_text(yaml.safe_dump(code_maps, allow_unicode=True), encoding="utf-8")
config = {
"delivery_dir": str(delivery_dir),
"library_root": str(tmp_path / "library"),
"answers_root": str(tmp_path / "answers"),
"alias_seed": seed,
"decode_codes": code_maps is not None,
"code_maps": None if maps_path is None else str(maps_path),
"ceus_run": None if ceus_run is None else str(ceus_run),
}
path = tmp_path / "build.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return path
def build(
tmp_path: Path,
delivery: FakeDelivery,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
now: datetime,
) -> BuildSummary:
"""写出假交付包和配置,跑一次不装 CEUS 结果的构建。"""
delivery_dir = delivery.write(tmp_path / "delivery")
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps, None), REPO_ROOT, now)