c582fe426f
- build.yaml 加必填字段 ceus_run(CEUS 运行目录或 null)。 - 不为 null 时按视频号找结果;在 failed.csv 里或缺结果目录的整例跳过,记 C15;关键帧图沿用 C14 检查。 - 资料库超声一路加 keyframes/<槽位>.png(去附加数据重编码)和 tic.json(白名单逐字段构造: 四条曲线的拟合成败与 R²、灌注指标、紫圈曲线是否可用、7 个槽位的定位与视频时间或原因)。 - manifest 的 CEUS 来源写运行号;泄露扫描已覆盖 tic.json,补命中测试。 - 真实构建等配准改完、全量 CEUS 运行之后再做。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
9.0 KiB
Python
273 lines
9.0 KiB
Python
"""测试用假交付包:形状照 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 = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
|
||
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(b"not a real video")
|
||
(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)
|