feat(review): 医生核对包入口;CEUS 运行目录的读取收进 runs

- 新模块 review(组织层,只依赖 library、answers、runs):把一次构建和它装入的
  CEUS 运行整理成医生可读的文件夹(CSV 带 BOM、说明与报告转 PDF、图片视频逐字节拷贝),
  入口 scripts/review_pack.py + config/review_pack.yaml。任务书 03。
- 读 CEUS 运行目录(failed.csv、结果目录、关键帧文件)从 build 挪进 runs,build 与
  review 都经它读;failed.csv 读取在边界上收严(步号须为整数、视频号不重复、列数固定)。
- library、answers 补只读函数;import-linter 增 review 契约。
- 文档:guides/医生核对包.md(过一轮硕士生阅读),结构.md 第三至八部分同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 06:04:19 -04:00
parent 14d6f8e153
commit c7373efde2
23 changed files with 1718 additions and 83 deletions
+4
View File
@@ -10,6 +10,8 @@ from cadx.answers._files import (
SkippedRow,
read_aliases,
read_diagnoses,
read_skipped,
verified_file,
write_answers,
)
@@ -20,5 +22,7 @@ __all__ = [
"SkippedRow",
"read_aliases",
"read_diagnoses",
"read_skipped",
"verified_file",
"write_answers",
]
+23 -1
View File
@@ -139,7 +139,8 @@ def write_answers(
)
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
def verified_file(answers_dir: Path, name: str) -> Path:
"""答案库里文件 `name`(如 `snapshot/build.yaml`)的路径;先核对清单里的 SHA-256。"""
if answers_dir.name.endswith(".partial"):
raise ValueError(f"{answers_dir} 是没写完的构建(.partial),不能读")
manifest = cast(
@@ -148,6 +149,11 @@ def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[lis
path = answers_dir / name
if _sha256(path) != manifest["files"][name]:
raise ValueError(f"{name} 的 SHA-256 与清单不一致,答案库被改动过")
return path
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
path = verified_file(answers_dir, name)
with path.open(encoding=_ENCODING, newline="") as handle:
rows = list(csv.reader(handle))
if tuple(rows[0]) != header:
@@ -175,3 +181,19 @@ def read_diagnoses(answers_dir: Path) -> tuple[DiagnosisRow, ...]:
)
for alias, case_no, clinical, ultrasound, mr, name in rows
)
def read_skipped(answers_dir: Path) -> tuple[SkippedRow, ...]:
"""读跳过清单。文件和列两栏按写入时的分隔符拆回元组,空栏为空元组。"""
rows = _read_csv(answers_dir, "skipped.csv", SKIPPED_HEADER)
return tuple(
SkippedRow(
case=case,
check=check,
description=description,
files=tuple(files.split(_JOIN)) if files else (),
columns=tuple(columns.split(_JOIN)) if columns else (),
value_json=value_json,
)
for case, check, description, files, columns, value_json in rows
)
+3 -3
View File
@@ -15,7 +15,7 @@ from pathlib import Path
from typing import cast
from cadx.answers import AliasRow, DiagnosisRow, ReviewRow, SkippedRow, write_answers
from cadx.build._ceus import CeusRun, case_ceus, open_ceus_run
from cadx.build._ceus import case_ceus, open_matching_run
from cadx.build._config import load_build_config, load_code_maps
from cadx.delivery import (
CODED_COLUMNS,
@@ -27,7 +27,7 @@ from cadx.delivery import (
review_sentences,
)
from cadx.library import LibraryCase, LibraryCeus, check_key_image, write_library
from cadx.runs import finish_snapshot, make_build_id, read_git_state, write_snapshot
from cadx.runs import CeusRun, finish_snapshot, make_build_id, read_git_state, write_snapshot
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
ALIAS_LENGTH = 4
@@ -158,7 +158,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
ceus_run = (
None
if config.ceus_run is None
else open_ceus_run(config.ceus_run, config.delivery_dir, repo_root)
else open_matching_run(config.ceus_run, config.delivery_dir, repo_root)
)
ceus_of: dict[str, LibraryCeus | None] = dict.fromkeys((c.case_no for c in passed), None)
if ceus_run is not None:
+25 -52
View File
@@ -1,27 +1,22 @@
"""读一次 CEUS 定量运行(`runs/ceus/<运行号>/`),按白名单挑出超声一路的 tic.json 和关键帧(C15)。
"""从一次 CEUS 定量运行按白名单挑出超声一路的 tic.json 和关键帧(C15)。
运行目录的布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、
`snapshot/ceus.yaml`、`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`。tic.json 的字段
只在这里列,视频号、路径、配置、帧号和拟合参数都不进去。快照里的交付包路径、summary.json 里记的
视频 SHA-256 和推注时间要与这份交付包一致,否则运行不是在这份交付包上跑的,整次停下。
运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列,视频号、路径、配置、帧号和
拟合参数都不进去。快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包
一致,否则运行不是在这份交付包上跑的,整次停下。
"""
import csv
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from cadx.delivery import CaseRecord, SkipRecord
from cadx.library import LibraryCeus
from cadx.runs import CeusRun, open_ceus_run
C15 = "C15"
C15_DESCRIPTION = "CEUS 定量结果齐全"
_FAILED_HEADER = ["video_id", "step", "reason"]
# 四条曲线:病灶整体、病灶去暗区、紫圈(医生圈的正常胰腺对照)、背景圆。
_CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
_LESION_CURVES = ("roi_all", "roi_perf")
@@ -46,41 +41,20 @@ _SUPPORTING_TIMES = ("TI", "mTT", "TO")
_PATH_MARKS = ("/", "\\", ".mp4", ".png")
@dataclass(frozen=True)
class CeusRun:
"""一次已完成的 CEUS 运行:运行号、目录、failed.csv 里的失败(视频号 → 步号、原因)。"""
run_id: str
run_dir: Path
failed: dict[str, tuple[str, str]]
def open_ceus_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有 failed.csv 和快照),读 failed.csv。
def open_matching_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun:
"""经 runs 打开运行目录,再核对它是在这份交付包上跑的。
快照里 `ceus.yaml` 的 `paths.delivery_dir`(相对路径相对 `repo_root`,同 ceus_quant)与这次
build 的 `delivery_dir` 规范化后不是同一路径,说明运行不是在这份交付包上跑的,报错停下。
"""
if run_dir.name.endswith(".partial"):
raise ValueError(f"CEUS 运行目录是没跑完的 .partial:{run_dir}")
if not run_dir.is_dir():
raise FileNotFoundError(f"CEUS 运行目录不存在:{run_dir}")
if not (run_dir / "snapshot" / "snapshot.json").is_file():
raise FileNotFoundError(f"CEUS 运行目录缺快照 snapshot/snapshot.json:{run_dir}")
ceus_yaml = run_dir / "snapshot" / "ceus.yaml"
ran_on = _typed(
yaml.safe_load(ceus_yaml.read_text(encoding="utf-8"))["paths"]["delivery_dir"], str
)
run = open_ceus_run(run_dir)
ran_on = _typed(run.config["paths"]["delivery_dir"], str)
if (repo_root / ran_on).resolve() != delivery_dir.resolve():
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:{ceus_yaml} 的 paths.delivery_dir 是 {ran_on},"
f"build 的 delivery_dir 是 {delivery_dir}"
f"CEUS 运行不是在这份交付包上跑的:{run_dir / 'snapshot' / 'ceus.yaml'} 的 "
f"paths.delivery_dir 是 {ran_on},build 的 delivery_dir 是 {delivery_dir}"
)
with (run_dir / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
if not rows or rows[0] != _FAILED_HEADER:
raise ValueError(f"failed.csv 的表头应为 {_FAILED_HEADER}:{run_dir}")
return CeusRun(run_dir.name, run_dir, {row[0]: (row[1], row[2]) for row in rows[1:]})
return run
def _number(value: object) -> float | int | None:
@@ -151,28 +125,27 @@ def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord:
)
if case.video_id in run.failed:
step, reason = run.failed[case.video_id]
return c15("failed.csv", f"第 {step} 步失败:{reason}")
case_dir = run.run_dir / case.video_id
if not case_dir.is_dir():
failure = run.failed[case.video_id]
return c15("failed.csv", f"第 {failure.step} 步失败:{failure.reason}")
if not run.has_result(case.video_id):
return c15(case.video_id, "CEUS 运行目录里没有这一例的结果目录")
summary = json.loads((case_dir / "summary.json").read_text(encoding="utf-8"))
if summary["video_id"] != case.video_id:
raise ValueError(f"summary.json 的视频号与所在目录不一致:{case_dir}")
summary = run.summary(case.video_id)
summary_path = f"{run.run_id}/{case.video_id}/summary.json"
if _typed(summary["video_sha256"], str) != hashlib.sha256(case.video.read_bytes()).hexdigest():
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:{case.video} 的 SHA-256 与 "
f"{case_dir / 'summary.json'} 的 video_sha256 不同"
f"{summary_path} 的 video_sha256 不同"
)
if _typed(summary["injection_offset_s"], float) != case.bolus_start_s:
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:交付包的推注时间 {case.bolus_start_s} 秒与 "
f"{case_dir / 'summary.json'} 的 injection_offset_s 不同"
f"{summary_path} 的 injection_offset_s 不同"
)
tic = tic_view(summary, case.video_id)
located = [slot for slot in _SLOTS if tic["keyframes"][slot]["located"]]
images = sorted(p.stem for p in (case_dir / "keyframes").iterdir())
if images != sorted(located):
raise ValueError(f"keyframes/ 里的图与 summary.json 定位到的槽位不一致:{case_dir}")
sources = {slot: case_dir / "keyframes" / f"{slot}.png" for slot in located}
return LibraryCeus(tic=tic, keyframe_sources=sources)
files = run.keyframe_files(case.video_id)
# 文件必须恰好是定位到的各槽位的 `<槽位>.png`:多一个(含同名不同扩展名)、少一个都报错。
if sorted(p.name for p in files) != sorted(f"{slot}.png" for slot in located):
raise ValueError(f"keyframes/ 里的图与 summary.json 定位到的槽位不一致:{summary_path}")
by_name = {p.name: p for p in files}
return LibraryCeus(tic=tic, keyframe_sources={s: by_name[f"{s}.png"] for s in located})
+4
View File
@@ -4,10 +4,12 @@
"""
from cadx.library._read import (
BuildInfo,
ClinicalRoute,
MrRoute,
UltrasoundRoute,
list_aliases,
read_build_info,
read_clinical,
read_mr,
read_ultrasound,
@@ -16,6 +18,7 @@ from cadx.library._scan import LeakHit, scan_for_leaks
from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library
__all__ = [
"BuildInfo",
"ClinicalRoute",
"LeakHit",
"LibraryCase",
@@ -24,6 +27,7 @@ __all__ = [
"UltrasoundRoute",
"check_key_image",
"list_aliases",
"read_build_info",
"read_clinical",
"read_mr",
"read_ultrasound",
+19
View File
@@ -66,6 +66,25 @@ def _text(path: Path) -> str:
return path.read_bytes().decode("utf-8")
@dataclass(frozen=True)
class BuildInfo:
"""一次构建清单里的概况:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。"""
build_id: str
codes_decoded: bool
ceus_source: str | None
def read_build_info(build_dir: Path) -> BuildInfo:
"""读构建清单里的构建号、是否解码、CEUS 运行号。"""
manifest = _manifest(build_dir)
return BuildInfo(
build_id=manifest["build_id"],
codes_decoded=manifest["codes_decoded"],
ceus_source=manifest["ceus_source"],
)
def list_aliases(build_dir: Path) -> tuple[str, ...]:
"""一次构建的全部化名(按化名排序)。"""
return tuple(_manifest(build_dir)["aliases"])
+10
View File
@@ -0,0 +1,10 @@
"""review:医生核对包。目录布局、每个文件的格式、问题清单的来源与写法、说明模板只在这里决定。
只读已经构建好的资料库(library)、答案库(answers)和资料库记的那次 CEUS 运行(runs),不写它们;
不碰交付包。说明模板是同目录下的 `readme_template.md`,随代码走。
"""
from cadx.review._config import ReviewPackConfig, load_review_config
from cadx.review._pack import PackSummary, run_review_pack
__all__ = ["PackSummary", "ReviewPackConfig", "load_review_config", "run_review_pack"]
+64
View File
@@ -0,0 +1,64 @@
"""`config/review_pack.yaml` 的字段定义和读取。"""
from collections.abc import Hashable
from pathlib import Path
from typing import Annotated, Any
import yaml
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
def _reject_empty_path(value: object) -> object:
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
if value == "":
raise ValueError("路径不能是空串")
return value
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
class _UniqueKeyLoader(yaml.SafeLoader):
"""同一层出现重复键就报错:PyYAML 默认让后一个悄悄覆盖前一个。"""
# 签名(含 deep 的默认值)照抄 PyYAML 的基类方法,覆盖时必须一致。
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]:
seen: set[Hashable] = set()
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep) # pyright: ignore[reportUnknownMemberType]
if key in seen:
raise ValueError(f"YAML 第 {key_node.start_mark.line + 1} 行重复的键:{key!r}")
seen.add(key)
return super().construct_mapping(node, deep=deep)
class ReviewPackConfig(BaseModel):
"""出核对包的配置。每个字段必填;相对路径相对仓库根目录解析。
`library_dir`、`answers_dir` 是同一次构建的资料库和答案库(目录名即构建号,必须相同);
`output_root` 下每次出包新建一个运行目录。
"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
library_dir: _PathField
answers_dir: _PathField
output_root: _PathField
def load_review_config(path: Path, repo_root: Path) -> ReviewPackConfig:
"""读并校验 `review_pack.yaml`,相对路径换成 `repo_root` 下的路径;两个库的构建号不同就报错。"""
text = path.read_text(encoding="utf-8")
config = ReviewPackConfig.model_validate(yaml.load(text, Loader=_UniqueKeyLoader)) # noqa: S506 _UniqueKeyLoader 继承 SafeLoader
if config.library_dir.name != config.answers_dir.name:
raise ValueError(
f"资料库与答案库不是同一次构建:{config.library_dir.name} ≠ {config.answers_dir.name}"
)
return config.model_copy(
update={
"library_dir": repo_root / config.library_dir,
"answers_dir": repo_root / config.answers_dir,
"output_root": repo_root / config.output_root,
}
)
+407
View File
@@ -0,0 +1,407 @@
"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源与写法。
```
<output_root>/<运行号>/
snapshot/ 快照(不给医生)
医生核对包_<构建号>/
说明.pdf 问题清单.csv 病例总表.csv
标准答案/化名对照.csv 诊断.csv 跳过病例.csv 报告复核句.csv
病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf 关键图.png
关键帧/<槽位>_第<秒>秒.png TIC曲线.png TIC结果.csv 回放视频.mp4
```
只读已经构建好的资料库、答案库和那次 CEUS 运行,不写它们;图片和视频按字节拷贝,不打开。
问题清单每一行都抄自某个已有文件里的某个值,不另做判断。
"""
import csv
import json
import re
import shutil
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from importlib import resources
from pathlib import Path
from string import Template
from typing import Any
import yaml
from cadx.answers import read_aliases, read_skipped, verified_file
from cadx.library import read_build_info, read_clinical, read_mr, read_ultrasound
from cadx.review._config import load_review_config
from cadx.review._pdf import markdown_to_pdf, report_markdown, tool_versions
from cadx.runs import (
CeusRun,
finish_snapshot,
make_build_id,
open_ceus_run,
read_git_state,
write_snapshot,
)
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
_CASE_COLUMNS = ("病例号", "视频号", "化名", "是否进入资料库", "没进的原因")
SUMMARY_HEADER = (*_CASE_COLUMNS, "CEUS 定量是否成功", "CEUS 失败在第几步", *SLOTS)
PROBLEM_HEADER = ("病例号", "视频号", "环节", "问题")
TIC_HEADER = ("项目", "值")
# 答案库的表 → 包里的文件名;内容逐字节照抄(答案库写的就是带 BOM 的 UTF-8、中文列名、缺失为空)。
ANSWER_TABLES = {
"aliases.csv": "化名对照.csv",
"diagnoses.csv": "诊断.csv",
"skipped.csv": "跳过病例.csv",
"review.csv": "报告复核句.csv",
}
STAGE_ASSEMBLY = "装配资料库"
STAGE_FIT = "TIC 拟合"
STAGE_KEYFRAME = "关键帧"
STAGE_PAN = "紫圈"
_LESION_CURVES = {"roi_all": "病灶整体曲线", "roi_perf": "病灶去暗区曲线"}
_CURVES = {**_LESION_CURVES, "roi_pan": "紫圈曲线", "background_circle": "背景圆曲线"}
_PERFUSION = {
"signal_domain": "灌注指标 信号域",
"time_origin": "灌注指标 时间零点",
"area_curve": "灌注指标 面积所用曲线",
"values.PE_au": "峰值增强 PE",
"values.WiAUC_au_s": "洗入段曲线下面积 WiAUC",
"values.RT_s": "上升时间 RT(秒)",
"values.mTTl_s": "平均渡越时间 mTTl(秒)",
"values.TTP_s": "达峰时间 TTP(秒)",
"values.WiR_au_per_s": "最大洗入斜率 WiR",
"values.WiPI_au": "洗入灌注指数 WiPI",
"values.WoAUC_au_s": "洗出段曲线下面积 WoAUC",
"values.WiWoAUC_au_s": "洗入洗出总面积 WiWoAUC",
"values.FT_s": "下降时间 FT(秒)",
"values.WoR_au_per_s": "最大洗出斜率 WoR",
"supporting_times_s.TI": "辅助时刻 TI(秒)",
"supporting_times_s.mTT": "辅助时刻 mTT(秒)",
"supporting_times_s.TO": "辅助时刻 TO(秒)",
}
# TIC结果.csv 的行:tic.json 里的字段路径 → 中文名。
TIC_ITEMS = {
**{f"fits.{c}.success": f"{n} 拟合成功" for c, n in _CURVES.items()},
**{f"fits.{c}.r2": f"{n} 拟合优度 R²" for c, n in _CURVES.items()},
**{
f"perfusion_metrics.{c}.{k}": f"{n} {label}"
for c, n in _LESION_CURVES.items()
for k, label in _PERFUSION.items()
},
"pan_curve_usable": "紫圈曲线可用",
**{f"keyframes.{s}.located": f"关键帧 {s} 取到" for s in SLOTS},
**{f"keyframes.{s}.video_time_s": f"关键帧 {s} 视频时间(秒)" for s in SLOTS},
**{f"keyframes.{s}.reason": f"关键帧 {s} 未取到的原因" for s in SLOTS},
}
# 病例号是交付包临床表 Case_ID 的写法「P + 三位数字」,库里存的是三位数字。
# 视频号是「U + 三位病例号」(交付包检查 C05 核对过);跳过的病例也按这条规则写,在 C05 之前
# 就被跳过的,交付里实际的 CEUS视频ID 没有核对过。认不出编号的跳过记录(「文件 第 N 行」等)
# 病例号原样写,视频号空着。
_CASE_NO = re.compile(r"[0-9]{3}")
_VIDEO_ID = re.compile(r"U([0-9]{3})")
@dataclass(frozen=True)
class PackSummary:
"""一次出包的计数,供终端打印;不含病例号。"""
run_dir: Path
pack_dir: Path
cases: int
passed: int
skipped: int
problems_by_stage: dict[str, int]
pdfs: int
video_bytes: int
def _doctor_no(case_no: str) -> str:
return f"P{case_no}" if _CASE_NO.fullmatch(case_no) else case_no
def _cell(value: object) -> str:
"""CSV 的格:None 写空串,布尔写 true/false,数值写 JSON 的写法,字符串原样。"""
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value)
def _write_csv(path: Path, header: Sequence[str], rows: Sequence[Sequence[str]]) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(header)
writer.writerows(rows)
def _leaves(node: object, prefix: str) -> list[str]:
if isinstance(node, dict):
items: dict[str, object] = node # pyright: ignore[reportUnknownVariableType]
return [leaf for key, value in items.items() for leaf in _leaves(value, f"{prefix}{key}.")]
return [prefix.removesuffix(".")]
def _lookup(tic: dict[str, Any], path: str) -> object:
"""按路径取值;路径上某一段是 null(如拟合没过时的灌注指标)则这一项为 None。"""
node: Any = tic
for key in path.split("."):
if node is None:
return None
node = node[key]
return node
def tic_rows(tic: dict[str, Any]) -> list[tuple[str, str]]:
"""TIC结果.csv 的行;tic.json 里有 `TIC_ITEMS` 没列的字段就报错(字段变了要同步中文名)。"""
unknown = [
p
for p in _leaves(tic, "")
if p not in TIC_ITEMS and not any(q.startswith(f"{p}.") for q in TIC_ITEMS)
]
if unknown:
raise ValueError(f"tic.json 里有 TIC结果.csv 没有中文名的字段:{unknown}")
return [(f"{name}({path})", _cell(_lookup(tic, path))) for path, name in TIC_ITEMS.items()]
def _ceus_run(answers_dir: Path, run_id: str, repo_root: Path) -> CeusRun:
"""这次构建用的 CEUS 运行:路径取自答案库快照里的 build.yaml,目录名必须是资料库记的运行号。"""
build_yaml = verified_file(answers_dir, "snapshot/build.yaml")
written = yaml.safe_load(build_yaml.read_text(encoding="utf-8"))["ceus_run"]
if not isinstance(written, str) or Path(written).name != run_id:
raise ValueError(
f"{build_yaml} 的 ceus_run 是 {written!r},与资料库记的 CEUS 运行号 {run_id} 不符"
)
return open_ceus_run(repo_root / written)
def _template_text(
build_id: str,
run: CeusRun | None,
codes_decoded: bool,
now: datetime,
counts: tuple[int, int, int],
) -> str:
template = Template(
resources.files("cadx.review").joinpath("readme_template.md").read_text(encoding="utf-8")
)
if run is None:
ceus_note = (
"**这次构建没有装入 CEUS 定量结果**:病例目录里没有关键帧、TIC曲线.png、"
"TIC结果.csv、回放视频.mp4,病例总表的 CEUS 各列空着,"
"问题清单里没有 CEUS、TIC 拟合、关键帧、紫圈几类。"
)
f1 = f2 = tolerance = "(这次没有 CEUS 结果,不适用)"
else:
ceus_note = f"这次构建装入了 CEUS 定量运行 {run.run_id} 的结果。"
anchors = run.config["ceus"]["tic_analysis"]["anchor_frames"]
f1, f2 = f"{anchors['f1_anchor_s']:g}", f"{anchors['late_phase']['preferred_time_s']:g}"
tolerance = f"{anchors['late_phase']['tolerance_s']:g}"
codes_note = (
"临床信息.csv 里的编码列(性别、症状、既往史、化验是否异常)"
"已按贵方提供的编码对照表解码成文字。"
if codes_decoded
else "临床信息.csv 里用数字表示的列(性别、症状、既往史、化验是否异常)"
"这次**未解码**,照交付的原始数字写;数字的含义要等贵方提供编码对照表后再解码"
"(构建配置 build.yaml 的 decode_codes 开关)。"
)
return template.substitute(
build_id=build_id,
ceus_run_id="无" if run is None else run.run_id,
generated_date=f"{now:%Y-%m-%d}",
total_cases=counts[0],
passed_cases=counts[1],
skipped_cases=counts[2],
ceus_note=ceus_note,
f1_s=f1,
f2_s=f2,
f2_tol_s=tolerance,
codes_note=codes_note,
)
def _copy(source: Path, target: Path) -> int:
shutil.copyfile(source, target)
return target.stat().st_size
type Problem = tuple[str, str, str, str] # 病例号、视频号、环节、问题
def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]:
"""病例总表的「CEUS 定量是否成功」「CEUS 失败在第几步」:在 failed.csv 里为否和步号,
运行里有这段视频的结果目录为是;都没有、没有视频号或没装 CEUS 运行时都空着。"""
if run is None or not video_id:
return "", ""
if video_id in run.failed:
return "否", str(run.failed[video_id].step)
return ("是", "") if run.has_result(video_id) else ("", "")
def _write_ceus(
case_dir: Path,
no: str,
video_id: str,
tic: dict[str, Any],
keyframes: dict[str, Path],
run: CeusRun,
) -> tuple[list[str], list[Problem], int]:
"""一例的 CEUS 文件和问题;交出病例总表的 7 个槽位格、问题清单的行、回放视频的字节数。"""
(case_dir / "关键帧").mkdir()
for slot, source in keyframes.items():
seconds = tic["keyframes"][slot]["video_time_s"]
_copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png")
_copy(run.tic_png(video_id), case_dir / "TIC曲线.png")
size = _copy(run.overlay(video_id), case_dir / "回放视频.mp4")
_write_csv(case_dir / "TIC结果.csv", TIC_HEADER, tic_rows(tic))
problems: list[Problem] = []
fits = run.summary(video_id)["fits"]
for curve, name in _LESION_CURVES.items():
if not tic["fits"][curve]["success"]:
code = fits[curve]["failure_reason"]
if not isinstance(code, str):
raise ValueError(
f"{run.run_id}/{video_id}/summary.json 里 {curve} 拟合没成功却没有原因码"
)
problems.append((no, video_id, STAGE_FIT, f"{name}未完成拟合({code})"))
slots = tic["keyframes"]
for slot in SLOTS:
if not slots[slot]["located"]:
problems.append(
(no, video_id, STAGE_KEYFRAME, f"{slot} 未取到:{slots[slot]['reason']}")
)
if not tic["pan_curve_usable"]:
problems.append((no, video_id, STAGE_PAN, "紫圈曲线不可用"))
cells = [_cell(slots[s]["video_time_s"]) if slots[s]["located"] else "未取到" for s in SLOTS]
return cells, problems, size
def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSummary:
"""读配置、两个库和资料库记的那次 CEUS 运行,写出一个新的核对包运行目录。
`now` 是本地时间,定运行号。
"""
config = load_review_config(config_path, repo_root)
library, answers = config.library_dir, config.answers_dir
info = read_build_info(library)
if info.build_id != library.name:
raise ValueError(f"资料库清单里的构建号 {info.build_id} 与目录名 {library.name} 不同")
run = None if info.ceus_source is None else _ceus_run(answers, info.ceus_source, repo_root)
versions = tool_versions()
aliases, skipped = read_aliases(answers), read_skipped(answers)
known = {a.case_no for a in aliases} | {s.case for s in skipped}
if len(known) != len(aliases) + len(skipped):
raise ValueError("答案库里同一个病例出现了两次(化名对照与跳过清单之间或之内)")
git = read_git_state(repo_root)
run_id = make_build_id(now, git)
final, partial = config.output_root / run_id, config.output_root / f"{run_id}.partial"
for path in (final, partial):
if path.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{path}")
partial.mkdir(parents=True)
snapshot = partial / "snapshot"
write_snapshot(
snapshot, run_id, git, now, repo_root / "uv.lock", {"review_pack.yaml": config_path}
)
record = {
"build_id": info.build_id,
"ceus_run_id": None if run is None else run.run_id,
**versions,
}
(snapshot / "review.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
pack = partial / f"医生核对包_{info.build_id}"
(pack / "标准答案").mkdir(parents=True)
for name, target in ANSWER_TABLES.items():
_copy(verified_file(answers, name), pack / "标准答案" / target)
# failed.csv 的视频号 → 病例号(三位数字)。
failures: dict[str, tuple[str, int, str]] = {}
for video_id, failure in ({} if run is None else run.failed).items():
match = _VIDEO_ID.fullmatch(video_id)
if match is None or match.group(1) not in known:
raise ValueError(f"CEUS 运行 failed.csv 里的视频号 {video_id} 对不上这次构建的任何病例")
failures[match.group(1)] = (video_id, failure.step, failure.reason)
summary: list[list[str]] = []
problems: list[Problem] = []
pdf_jobs: dict[Path, str] = {}
video_bytes = 0
for row in aliases:
no = _doctor_no(row.case_no)
case_dir = pack / "病例" / no
case_dir.mkdir(parents=True)
clinical = read_clinical(library, row.alias).fields
_write_csv(
case_dir / "临床信息.csv", list(clinical), [[_cell(v) for v in clinical.values()]]
)
ultrasound = read_ultrasound(library, row.alias)
for name, route, text in (
("超声报告.pdf", "超声描述", ultrasound.report),
("MR报告.pdf", "MR描述", read_mr(library, row.alias).report),
):
title = f"病例号 {no} {route}(AI 专家看到的原文,不含结论段)"
pdf_jobs[case_dir / name] = report_markdown(title, text)
_copy(ultrasound.key_image, case_dir / "关键图.png")
cells = ["" for _ in SLOTS]
if run is not None and ultrasound.tic is not None:
cells, case_problems, size = _write_ceus(
case_dir, no, row.video_id, ultrasound.tic, ultrasound.keyframes, run
)
problems.extend(case_problems)
video_bytes += size
summary.append(
[no, row.video_id, row.alias, "是", "", *_ceus_status(run, row.video_id), *cells]
)
for record_ in skipped:
no = _doctor_no(record_.case)
video_id = f"U{record_.case}" if _CASE_NO.fullmatch(record_.case) else ""
reason = f"{record_.check} {record_.description}"
status = _ceus_status(run, video_id)
summary.append([no, video_id, "", "否", reason, *status, *["" for _ in SLOTS]])
parts = [reason]
parts += [
f"{k}:{'; '.join(v)}"
for k, v in (("文件", record_.files), ("列", record_.columns))
if v
]
parts.append(f"涉及值:{record_.value_json}")
problems.append((no, video_id, STAGE_ASSEMBLY, ";".join(parts)))
for case_no, (video_id, step, reason) in failures.items():
problems.append(
(_doctor_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}")
)
summary.sort(key=lambda r: r[0])
problems.sort(key=lambda r: r[0])
_write_csv(pack / "病例总表.csv", SUMMARY_HEADER, summary)
_write_csv(pack / "问题清单.csv", PROBLEM_HEADER, problems)
counts = (len(summary), len(aliases), len(skipped))
pdf_jobs[pack / "说明.pdf"] = _template_text(
info.build_id, run, info.codes_decoded, now, counts
)
markdown_to_pdf(pdf_jobs)
finish_snapshot(
snapshot, datetime.now().astimezone(), {"cases": len(summary), "problems": len(problems)}
)
# POSIX 的 rename 会悄悄覆盖空目录,所以改名前再查一次。
if final.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{final}")
partial.rename(final)
by_stage = Counter(stage for _, _, stage, _ in problems)
return PackSummary(
run_dir=final,
pack_dir=final / pack.name,
cases=len(summary),
passed=len(aliases),
skipped=len(skipped),
problems_by_stage=dict(sorted(by_stage.items())),
pdfs=len(pdf_jobs),
video_bytes=video_bytes,
)
+129
View File
@@ -0,0 +1,129 @@
"""Markdown → PDF:pandoc 转 docx,再用 LibreOffice(soffice)一次批量转 PDF。
中文字体:pandoc 默认的 docx 样式没指定东亚字体,LibreOffice 会挑到 Noto CJK 的日文字形
(「门」「直」等字写法不同),所以从 pandoc 自带的样式文件改出一份参考文档,东亚字体指定为
Noto Sans/Serif CJK SC、东亚语言设为 zh-CN。pdffonts 列出的字体名仍是 `NotoSerifCJKjp-…`:
同一个 .ttc 文件里各语言的字形共用一个名字,实际嵌入的是简体字形(转成图片目视核对过)。
"""
import re
import shutil
import string
import subprocess
import tempfile
import zipfile
from collections.abc import Mapping
from pathlib import Path
_STYLE_FONTS = (
('w:eastAsiaTheme="majorEastAsia"', 'w:eastAsia="Noto Sans CJK SC"'), # 标题
('w:eastAsiaTheme="minorHAnsi"', 'w:eastAsia="Noto Serif CJK SC"'), # 正文
('w:eastAsia="en-US"', 'w:eastAsia="zh-CN"'), # 东亚语言
)
# smart 扩展会把引号、连字符、省略号改成排版符号,正文要照抄,关掉。
_MARKDOWN = "markdown-smart"
# 紧跟在一个空格后面的空格。
_REPEATED_SPACE = re.compile(r"(?<= ) ")
def _tool(name: str) -> str:
path = shutil.which(name)
if path is None:
raise FileNotFoundError(f"找不到 {name},出核对包要用它把文字转成 PDF")
return path
def _run(*args: str) -> str:
return subprocess.run(args, check=True, capture_output=True, text=True).stdout # noqa: S603 参数是写死的工具和本程序生成的路径
def tool_versions() -> dict[str, str]:
"""pandoc 和 soffice 的版本行,写进快照。"""
return {
"pandoc": _run(_tool("pandoc"), "--version").splitlines()[0],
"soffice": _run(_tool("soffice"), "--version").strip(),
}
def report_markdown(title: str, text: str) -> str:
"""报告原文的 Markdown:一级标题,正文逐行写成行块(保留换行),ASCII 标点全部转义,
这样 pandoc 不会把正文里的 `*`、`#`、`1.` 之类当成格式。
pandoc 会把连续空格并成一个,所以一行里连续的 ASCII 空格从第二个起换成不换行空格(U+00A0)。
Tab 在 PDF 里没法照原样排,真实报告里也没有,出现就报错停下(`title` 写明是哪例哪份报告)。
"""
if "\t" in text:
raise ValueError(f"{title}:报告原文里有 Tab,不能照原样转成 PDF")
escaped = (
_REPEATED_SPACE.sub(
"\u00a0", "".join(f"\\{c}" if c in string.punctuation else c for c in line)
)
for line in text.splitlines()
)
return f"# {title}\n\n" + "\n".join(f"| {line}" for line in escaped) + "\n"
def _reference_docx(work: Path) -> Path:
default = work / "pandoc_default.docx"
default.write_bytes(
subprocess.run( # noqa: S603 参数是写死的
[_tool("pandoc"), "--print-default-data-file", "reference.docx"],
check=True,
capture_output=True,
).stdout
)
reference = work / "reference.docx"
with (
zipfile.ZipFile(default) as source,
zipfile.ZipFile(reference, "w", zipfile.ZIP_DEFLATED) as out,
):
for item in source.infolist():
data = source.read(item.filename)
if item.filename == "word/styles.xml":
styles = data.decode("utf-8")
for old, new in _STYLE_FONTS:
if old not in styles:
raise ValueError(
f"pandoc 的参考样式里没有 {old},pandoc 版本变了,改 _STYLE_FONTS"
)
styles = styles.replace(old, new)
data = styles.encode("utf-8")
out.writestr(item, data)
return reference
def markdown_to_pdf(jobs: Mapping[Path, str]) -> None:
"""把每份 Markdown(目标 PDF 路径 → 内容)转成 PDF 写到目标路径。soffice 只启动一次。"""
pandoc, soffice = _tool("pandoc"), _tool("soffice")
with tempfile.TemporaryDirectory(prefix="cadx_pdf_") as temp:
work = Path(temp)
reference = _reference_docx(work)
docx = [work / f"{i}.docx" for i in range(len(jobs))]
for path, content in zip(docx, jobs.values(), strict=True):
path.with_suffix(".md").write_text(content, encoding="utf-8")
_run(
pandoc,
"-f",
_MARKDOWN,
"--reference-doc",
str(reference),
"-o",
str(path),
str(path.with_suffix(".md")),
)
# 独立的用户配置目录:本机开着 LibreOffice 时,headless 转换不会交给那个进程去做。
_run(
soffice,
f"-env:UserInstallation={(work / 'profile').as_uri()}",
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(work / "pdf"),
*map(str, docx),
)
for path, target in zip(docx, jobs, strict=True):
pdf = work / "pdf" / f"{path.stem}.pdf"
if not pdf.is_file() or pdf.stat().st_size == 0:
raise RuntimeError(f"soffice 没有转出 {target.name}({pdf.name})")
shutil.move(pdf, target)
+202
View File
@@ -0,0 +1,202 @@
# 医生核对包说明
## 一、这个包是什么
这是我们用程序处理贵方交付资料之后的结果,整理出来请您核对:程序读到的内容对不对、CEUS(超声造影)视频的自动定量做得对不对、哪些病例没能进入资料库以及原因。包里只有我们的处理结果,不需要填写或回复任何表格。
- 构建号:$build_id(资料库和标准答案库是同一次构建产出的,共用这个编号)
- CEUS 定量运行号:$ceus_run_id
- 生成日期:$generated_date
- 病例数:病例总表共 $total_cases 行,其中进入资料库 $passed_cases 例,没进入 $skipped_cases 例。
$ceus_note
**包里含患者资料(报告原文、临床信息、超声图像与视频、病理诊断),只在临床方与我们之间传递,请勿转发给其他人。**
## 二、目录结构
- **医生核对包_\<构建号\>/**
- **说明.pdf**:本文件。
- **问题清单.csv**:程序在各环节观察到的现象,一行一条。
- **病例总表.csv**:这次涉及的全部病例,一例一行。
- **标准答案/**
- **化名对照.csv**:进入资料库的病例的化名、病例号、视频号。
- **诊断.csv**:进入资料库的病例在三张表里各自的病理诊断编码。
- **跳过病例.csv**:没进入资料库的病例,以及第一项没通过的检查。
- **报告复核句.csv**:报告里含「考虑」「可能」「倾向」「提示」的句子。
- **病例/\<病例号\>/**:只有进入资料库的病例才有目录,目录名是病例号(如 P001)。
- **临床信息.csv**:临床一路的字段,一行。
- **超声报告.pdf**:超声报告的描述段,就是 AI 专家看到的原文。
- **MR报告.pdf**:MR 报告的描述段,就是 AI 专家看到的原文。
- **关键图.png**:贵方截的超声关键图。
- **关键帧/**:程序从 CEUS 视频里取的关键帧,文件名是「槽位_第几秒.png」(如 B0_第1.6秒.png),秒数是视频时间、保留一位小数;没取到的槽位没有文件。
- **TIC曲线.png**:时间-强度曲线图。
- **TIC结果.csv**:曲线拟合、灌注指标、关键帧的逐项结果。
- **回放视频.mp4**:原视频逐帧叠加程序画出的病灶轮廓等。
所有图片和视频都没有压缩、缩放或重新编码,与我们处理时用的文件逐字节相同。关键图和关键帧是写进资料库时那一份:程序重新保存时去掉了文件里附带的文字信息(如拍摄参数),像素没有改变。
## 三、表格每一列的意思
表格都是 CSV 文件,可以直接用 Excel 打开。空着的格子表示没有这项值。
**病例总表.csv**(这次交付涉及的全部病例,按病例号排序)
| 列 | 意思 |
| --- | --- |
| 病例号 | 贵方的病例编号,如 P001。表里认不出编号的行写成「文件 第 N 行」或「病例目录 名字」 |
| 视频号 | CEUS 视频编号,按「U + 三位病例号」写,如 U001。没进入资料库的病例也这样写;其中在核对视频编号那项检查(C05)之前就被跳过的,交付里实际的视频编号没有核对过。认不出编号的行空着 |
| 化名 | 资料库里代替病例号的 4 位随机字母数字(见第七节);没进入资料库的空着 |
| 是否进入资料库 | 是 / 否 |
| 没进的原因 | 第一项没通过的检查编号和说明(见第九节);进入的空着 |
| CEUS 定量是否成功 | 这段视频在 CEUS 定量的失败记录里写「否」;定量运行里有这段视频的结果写「是」(没进入资料库的病例也可能是「是」,它是因为别的检查被跳过的);两者都没有(这段视频没做定量)或这次没装 CEUS 结果时空着 |
| CEUS 失败在第几步 | CEUS 定量失败在第 1–9 步中的哪一步(见第四节);没失败的空着 |
| B0、D1、D2、D3、D4、F1、F2 | 7 个关键帧(见第五节):取到的写它在视频里的时间(秒);没取到写「未取到」;CEUS 失败或没装 CEUS 结果时空着 |
**问题清单.csv**(按病例号排序,一个病例可以有多行)
| 列 | 意思 |
| --- | --- |
| 病例号 | 同上 |
| 视频号 | 同上 |
| 环节 | 现象出在处理流程的哪一环,取值见第四节末尾的表 |
| 问题 | 程序观察到的现象,照程序记录的原文写,不含推测 |
**标准答案/化名对照.csv**:化名、病例号、视频号。这里的病例号是三位数字(如 001),对应病例目录名 P001。
**标准答案/诊断.csv**:化名、病例号、临床表诊断编码、超声表诊断编码、MR表诊断编码、诊断名。三张表的诊断编码分别取自临床工作簿、超声报告工作簿、MR 报告工作簿,进入资料库的病例三者一致。编码含义见第八节。诊断名一列在编码没有解码时空着。
**标准答案/跳过病例.csv**:病例、检查编号、检查说明、文件、列、涉及值。病例是三位数字病例号,或认不出编号时的「文件 第 N 行」。文件和列是这项检查涉及的交付文件和列名,多个时用分号隔开。涉及值是对不上的原始取值,以程序记录的格式写出(带引号和括号)。
**标准答案/报告复核句.csv**:化名、路(超声或 MR)、命中的词、所在句子。进入资料库的病例中,报告描述段里含「考虑」「可能」「倾向」「提示」的句子,一句一行。这些词可能带出诊断倾向,所以单独列出;这些句子仍留在资料库里。
**病例/\<病例号\>/临床信息.csv**:一行,列名是交付的临床工作簿原列名(性别、年龄、5 个症状、5 项既往史、5 项化验的数值与是否异常)。病例号和诊断两列不在这里。
**病例/\<病例号\>/TIC结果.csv**:两列「项目」「值」。项目名是中文加程序里的字段名(括号里),值里的 true 表示是、false 表示否。各项的意思见第六节。
## 四、我们的处理流程
处理分三段:先读交付资料并逐例检查,再对 CEUS 视频做定量,最后把通过的病例装进资料库。
**第一段:读交付资料、逐例检查。** 程序读临床工作簿、超声报告工作簿、MR 报告工作簿和每例的病例目录,逐例做一串检查(编号能认出、三张表都有这一例、病例目录文件齐全、取值合规、三张表的诊断年龄性别一致、报告描述里没有结论段标题、没有亚型名、没有性别年龄等)。一例遇到第一项没通过的检查就整例跳过(见第九节)。关键图要能原样重新保存(检查 C14)。
**第二段:CEUS 定量。** 每段 CEUS 视频依次做下面 9 步。第 1、2、4 步遇到视频不满足前提时,这一例整例失败、记下失败在哪一步和原因,接着处理下一例;第 7、8 步做不出来不算整例失败,只是相应结果空缺。
1. **解码与分屏、两半配准。** 读视频;超声机录造影时屏幕分左右两半,一半是造影画面,一半是普通灰阶画面,程序找出分界线,再估出两半画面之间的位置偏移。每段视频的偏移都经过我们人工目视核对,没通过核对的病例在这一步失败。视频读不出、找不到分界线、两半对不齐,也在这一步失败。
2. **找绿框、选提示帧。** 在两半上找贵方画的绿框:恰好一半有绿框,那一半就是造影画面;两半都有或都没有,这一例在这一步失败。然后挑出绿框位置或形状有明显变化的几帧作为「提示帧」,把绿框换算到灰阶那一半上。
3. **分割。** 用 MedSAM2 分割模型在灰阶那一半上,从提示帧的绿框出发,逐帧划出病灶范围。
4. **病灶轮廓。** 把逐帧的分割结果变成平滑的轮廓,与绿框的位置和形状融合,再换算回造影那一半。分割结果一帧都没有可用轮廓时,这一例在这一步失败。
5. **暗区。** 在病灶轮廓内找出一直发黑、不进造影剂的部分(如囊液)。
6. **TIC 采样。** TIC(时间-强度曲线)是某个区域的平均亮度随时间的变化。程序在造影那一半上逐帧取四条曲线:病灶整体(轮廓内,去掉绿框和紫圈的颜色)、病灶去暗区(再去掉暗区)、紫圈(贵方画的紫色圈内部,一块正常胰腺作对照)、背景圆(程序在病灶附近自动挑的一小块背景,只用来看整机亮度有没有漂移)。紫圈只有在足够多的帧里都认出来,紫圈曲线才算可用。
7. **曲线拟合。** 用 Lognormal(对数正态)曲线拟合每条 TIC,拟合要通过一组质量检查(下称质量门)才算成功。没通过时记下原因码,含义见第六节。
8. **关键帧。** 定位 7 个关键帧,见第五节。
9. **灌注指标。** 在病灶两条曲线拟合成功时,从拟合曲线算 11 项灌注指标(口径仿照商用造影定量软件 VueBOX),见第六节。
**第三段:装进资料库。** CEUS 定量失败或没有结果的病例整例跳过(检查 C15);关键帧也要能原样重新保存(检查 C14)。通过的病例取化名,把临床、超声、MR 三路资料分开存放,每一路的 AI 专家只看得到自己那一路。
问题清单里「环节」一列的取值:
| 环节 | 对应哪一步 | 问题的写法 |
| --- | --- | --- |
| 装配资料库 | 第一段的检查和第三段的 C14、C15 | 检查编号、检查说明、涉及的文件、列和值,照跳过病例表原样写 |
| CEUS 第 N 步 | 第二段第 N 步整例失败 | 「第 N 步失败:」加程序记录的原因 |
| TIC 拟合 | 第二段第 7 步,病灶整体或病灶去暗区曲线没通过质量门 | 「病灶整体曲线未完成拟合」或「病灶去暗区曲线未完成拟合」,括号里是原因码 |
| 关键帧 | 第二段第 8 步,某个关键帧没取到 | 「槽位 未取到:」加程序记录的原因 |
| 紫圈 | 第二段第 6 步,紫圈曲线不可用 | 「紫圈曲线不可用」 |
一个 CEUS 失败的病例会有一行「CEUS 第 N 步」,一般还同时有一行「装配资料库」(C15),两行来自不同的记录文件;但如果它在 C15 之前就因别的检查被跳过,「装配资料库」那一行写的是那项检查。
## 五、7 个关键帧
| 槽位 | 代表的时刻 | 怎么取 |
| --- | --- | --- |
| B0 | 病灶刚开始变亮之前 | 病灶去暗区曲线的拟合曲线上的到达时刻,再往前退一点,在到达之前的基线段里取帧 |
| D1 | 病灶开始变亮(造影剂到达) | 拟合曲线上的到达时刻 |
| D2 | 变亮最快的时刻(洗入拐点) | 拟合曲线 |
| D3 | 最亮的时刻(峰值) | 拟合曲线 |
| D4 | 变暗最快的时刻(洗出拐点) | 拟合曲线 |
| F1 | 推注后第 $f1_s 秒 | 视频时间 = 推注后时刻 − X |
| F2 | 推注后第 $f2_s 秒 | 视频时间 = 推注后时刻 − X |
B0、D1–D4 都来自病灶去暗区曲线的拟合曲线,在各自时刻附近的允许范围内找最近的可用帧,并保持先后顺序(D2 的帧不会早于 D1 的帧)。F1、F2 按推注时间取:X 是超声报告工作簿的「推注开始时间s」,意思是视频第 0 秒时已经推注了 X 秒,所以推注后第 T 秒在视频第 T − X 秒。F1 取离这个时刻最近的帧;F2 只在这个时刻前后 $f2_tol_s 秒内取。
「未取到」时,问题清单里写的是程序记录的原因原文,有以下几种:
- 「病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门」:病灶整体、病灶去暗区两条曲线没有都拟合成功,B0、D1–D4 都不取。
- 「roi_perf 曲线的 Lognormal 拟合没过质量门(原因码)」:病灶去暗区曲线拟合没成功,B0、D1–D4 都不取。
- 「拟合曲线升不过到达门槛,没有到达时刻」(B0)、「拟合曲线上没有 D1 时刻(升不过到达门槛)」之类:拟合曲线上升的幅度不够,定不出到达时刻。
- 「视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义」:视频一开始病灶就已经在变亮,没有变亮之前的那一段,所以没有 B0。
- 「峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4」:峰值之后录得不够长,拟合曲线上找不到洗出拐点。
- 「某时刻 … 秒落在视频有效帧范围 … 之外」:算出的时刻早于视频第一帧或晚于最后一帧。
- 「推注后第 T 秒在视频第 … 秒(推注时间 X=… 秒),落在 … 之外」(F1、F2):同上,按推注时间换算出的时刻不在视频里。
- 「… 容差窗口 … 内没有可用帧」:时刻附近的允许范围里没有可用的帧(重复帧不算可用)。
- 「… 容差窗口 … 内的候选帧都被时间更早的槽位占用,无法保持先后顺序」:附近的帧已经被前一个关键帧用掉了。
- 「视频第 … 秒 ±… 秒内没有不重复的帧」(F1、F2):同上,附近只有重复帧。
- 「没有推注时间,推注后时刻换算不到视频时间」(F1、F2):没有推注时间。
## 六、TIC 曲线图和 TIC 结果怎么看
**TIC曲线.png**:横轴是视频时间(秒),纵轴是灰度亮度。图上的英文标注:
| 图上的英文 | 中文意思 |
| --- | --- |
| TIC: Lognormal fit | 图标题:时间-强度曲线及其对数正态拟合 |
| Time (s) | 横轴:视频时间(秒) |
| Grayscale intensity | 纵轴:灰度亮度 |
| roi_all raw | 病灶整体曲线的逐帧原始值(蓝色小点) |
| roi_perf (dark removed) raw | 病灶去暗区曲线的逐帧原始值(橙色小点) |
| roi_pan reference raw | 紫圈曲线(正常胰腺对照)的逐帧原始值(紫色小点) |
| … Lognormal fit | 对应曲线的拟合曲线(粗实线),只画拟合成功的 |
| roi_pan reference Lognormal fit [QC rejected] | 紫圈曲线没通过质量门的拟合(紫色虚线),仍画出来供参考;病灶曲线拟合没通过时不画 |
| D1 model、D2 model、D3 model、D4 model | 竖虚线:拟合曲线上 D1–D4 的时刻(见第五节) |
| [no_frame_in_window] | 跟在竖线标签后面:这个时刻附近的允许范围里没有可用帧,关键帧没取到 |
| [no_ordered_frame] | 附近的帧都被时间更早的关键帧占用,关键帧没取到 |
| [not_covered] | 这个时刻落在视频有效帧范围之外,关键帧没取到 |
| [anchor_undefined] | 病灶两条曲线没有都拟合成功,这个时刻不用来取关键帧 |
| No plottable TIC data | 没有能画的曲线数据 |
背景圆曲线不画在图上。竖线没有标状态的,表示这个关键帧取到了。
**TIC结果.csv 的各项**:
- 四条曲线(病灶整体 roi_all、病灶去暗区 roi_perf、紫圈 roi_pan、背景圆 background_circle)各自「拟合成功」(success)和「拟合优度 R²」(r2,越接近 1 拟合越贴合;算不出时空着)。
- 病灶两条曲线的灌注指标(perfusion_metrics;拟合没成功时整组空着)。观测时间不够算不出的单项空着,不外推。
- 信号域 signal_domain = db_display:在视频显示的灰度上算,不是换算后的回声功率;时间零点 time_origin = video_start:时间从视频开头算起;面积所用曲线 area_curve = fitted_minus_baseline:面积用拟合曲线减去基线。
- PE:峰值增强(峰值减基线);TTP:达峰时间(秒);WiR:最大洗入斜率;WiAUC:洗入段曲线下面积(TI 到 TTP);RT:上升时间(TTP − TI);WiPI:洗入灌注指数(WiAUC / RT);WoR:最大洗出斜率;WoAUC:洗出段曲线下面积(TTP 到 TO);FT:下降时间(TO − TTP);WiWoAUC:洗入洗出总面积;mTTl:平均渡越时间(mTT − TI)。
- 辅助时刻:TI 是洗入切线与基线的交点,TO 是洗出切线与基线的交点,mTT 是 TI 到 TO 之间的平均渡越时刻(都是视频时间,秒)。
- 紫圈曲线可用(pan_curve_usable):紫圈在足够多的帧里认出来时为 true。
- 7 个关键帧各自「取到」(located)、「视频时间」(video_time_s,秒)、「未取到的原因」(reason)。
**拟合没通过质量门的原因码**(问题清单「TIC 拟合」一行括号里的英文):
| 原因码 | 意思 |
| --- | --- |
| insufficient_valid_samples | 可用的帧(亮度能取到、不是重复帧)太少 |
| candidate_grid_unavailable | 可用帧的时间点无法排成拟合用的等间隔时间轴 |
| credible_peak_not_found | 平滑后的曲线上找不到够高、够突出的峰 |
| candidate_arrival_not_found | 峰之前找不到亮度持续越过到达门槛的时刻 |
| insufficient_bolus_samples | 第一次团注(造影剂第一次通过)那一段里可用帧太少 |
| long_gap_in_wash_in | 峰之前的上升段里有一段太长的时间缺口(中间缺帧) |
| arrival_bounds_invalid | 按观测范围给到达时刻、峰时刻划的允许区间不成立(区间为空) |
| optimizer_failed | 拟合计算从各组起始值出发都没有算出结果 |
| parameter_on_boundary | 拟合出的曲线参数贴在允许范围的边上 |
| model_peak_on_boundary | 拟合曲线的峰落在拟合时间段的两端附近 |
| fit_quality_failed | 拟合曲线与原始数据贴合得不够(误差太大,且拟合优度和残差都没达标) |
**回放视频.mp4**:原视频逐帧叠加程序画的线:造影那一半的病灶轮廓是黄色,灰阶那一半的病灶轮廓是绿色,造影那一半的暗区轮廓是蓝色(灰阶那一半不画暗区),背景圆是青色圆圈。贵方画的绿框和紫圈是视频原有的。
## 七、化名与报告
**化名**:资料库里每例用一个 4 位随机字母数字代替病例号。交付文件名里带着病例号,而表格的行序可能与诊断分组有关;用随机化名并按化名排序,AI 专家就无法从编号或顺序猜出诊断。化名与病例号的对照只在标准答案里。
**报告只放描述段**:资料库里只放超声和 MR 报告的「所见」描述,不放「印象 / 结论」段,因为结论段写着医生的诊断倾向,AI 专家看到它就等于看到了答案。程序检查每份报告的描述文字里有没有结论段标题(如「超声提示:」「印象:」「诊断:」,检查 C11),有就整例跳过,不进资料库。超声报告.pdf 和 MR报告.pdf 就是资料库里 AI 专家看到的那段文字,一字未改;PDF 第一行的标题是我们加的。
## 八、编码列
诊断编码:1 IPMN(导管内乳头状黏液性肿瘤),2 SPN(实性假乳头状肿瘤),3 MCN(黏液性囊性肿瘤),4 SCN(浆液性囊性肿瘤)。出自交付的超声、MR 报告工作簿的诊断列表头。
$codes_note
## 九、跳过病例
一例只要有一项检查没通过,就整例跳过,不进资料库:缺了某一路资料或资料前后对不上,这一例就无法公平地交给三位 AI 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。
+12 -2
View File
@@ -1,5 +1,6 @@
"""runs:构建号怎么取、运行快照记什么。会诊运行目录等会诊那一步再加。"""
"""runs:构建号怎么取、运行快照记什么、怎么读一次 CEUS 定量运行。会诊运行目录等会诊那一步再加。"""
from cadx.runs._ceus_run import CeusFailure, CeusRun, open_ceus_run
from cadx.runs._snapshot import (
GitState,
finish_snapshot,
@@ -8,4 +9,13 @@ from cadx.runs._snapshot import (
write_snapshot,
)
__all__ = ["GitState", "finish_snapshot", "make_build_id", "read_git_state", "write_snapshot"]
__all__ = [
"CeusFailure",
"CeusRun",
"GitState",
"finish_snapshot",
"make_build_id",
"open_ceus_run",
"read_git_state",
"write_snapshot",
]
+82
View File
@@ -0,0 +1,82 @@
"""读一次已完成的 CEUS 定量运行(`runs/ceus/<运行号>/`)的目录布局。
布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、`snapshot/ceus.yaml`、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`、`<视频号>/tic.png`、`<视频号>/overlay.mp4`。
这里只管「文件在哪、怎么读出来」;从 summary.json 挑哪些字段给专家由 build 决定。
"""
import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
import yaml
_FAILED_HEADER = ["video_id", "step", "reason"]
@dataclass(frozen=True)
class CeusFailure:
"""failed.csv 的一行:失败在第几步、原因原文。"""
step: int
reason: str
@dataclass(frozen=True)
class CeusRun:
"""一次已完成的 CEUS 运行:运行号、目录、快照里的整份 ceus.yaml、failed.csv。
`failed` 是视频号 → 失败。由 `open_ceus_run` 核对目录后交出。
"""
run_id: str
run_dir: Path
config: dict[str, Any]
failed: dict[str, CeusFailure]
def has_result(self, video_id: str) -> bool:
"""运行目录里有没有这段视频的结果目录。"""
return (self.run_dir / video_id).is_dir()
def summary(self, video_id: str) -> dict[str, Any]:
"""这段视频的 summary.json;里面记的视频号与所在目录不一致就报错。"""
path = self.run_dir / video_id / "summary.json"
content = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
if content["video_id"] != video_id:
raise ValueError(f"summary.json 的视频号与所在目录不一致:{path}")
return content
def keyframe_files(self, video_id: str) -> tuple[Path, ...]:
"""`keyframes/` 里的全部文件(按文件名排序),不按扩展名筛选,由调用方核对。"""
return tuple(sorted((self.run_dir / video_id / "keyframes").iterdir()))
def tic_png(self, video_id: str) -> Path:
"""这段视频的 TIC 曲线图 tic.png。"""
return self.run_dir / video_id / "tic.png"
def overlay(self, video_id: str) -> Path:
"""这段视频的回放视频 overlay.mp4。"""
return self.run_dir / video_id / "overlay.mp4"
def open_ceus_run(run_dir: Path) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有快照),读快照里的 ceus.yaml 和 failed.csv。"""
if run_dir.name.endswith(".partial"):
raise ValueError(f"CEUS 运行目录是没跑完的 .partial:{run_dir}")
if not run_dir.is_dir():
raise FileNotFoundError(f"CEUS 运行目录不存在:{run_dir}")
if not (run_dir / "snapshot" / "snapshot.json").is_file():
raise FileNotFoundError(f"CEUS 运行目录缺快照 snapshot/snapshot.json:{run_dir}")
config = yaml.safe_load((run_dir / "snapshot" / "ceus.yaml").read_text(encoding="utf-8"))
with (run_dir / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
if not rows or rows[0] != _FAILED_HEADER:
raise ValueError(f"failed.csv 的表头应为 {_FAILED_HEADER}:{run_dir}")
failed: dict[str, CeusFailure] = {}
for video_id, step, reason in rows[1:]:
if not step.isdigit() or video_id in failed:
raise ValueError(f"failed.csv 的行不合规(步号不是整数或视频号重复):{run_dir}")
failed[video_id] = CeusFailure(step=int(step), reason=reason)
return CeusRun(run_dir.name, run_dir, cast(dict[str, Any], config), failed)
+1 -1
View File
@@ -63,7 +63,7 @@ def write_snapshot(
uv_lock: Path,
configs: Mapping[str, Path],
) -> None:
"""建 `snapshot_dir`,写 snapshot.json,并把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
"""建 `snapshot_dir`,写 snapshot.json,把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
snapshot_dir.mkdir(exist_ok=False)
copied: dict[str, str] = {}
for name, source in configs.items():