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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user