feat(build): 任务书 02 第四阶段代码——build 装入 CEUS 结果

- 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>
This commit is contained in:
2026-10-16 05:55:57 -04:00
parent e78d1b50b8
commit c582fe426f
15 changed files with 683 additions and 35 deletions
+44 -3
View File
@@ -15,6 +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._config import load_build_config, load_code_maps
from cadx.delivery import (
CODED_COLUMNS,
@@ -25,7 +26,7 @@ from cadx.delivery import (
read_delivery,
review_sentences,
)
from cadx.library import LibraryCase, check_key_image, write_library
from cadx.library import LibraryCase, LibraryCeus, check_key_image, write_library
from cadx.runs import make_build_id, read_git_state, write_snapshot
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
@@ -104,6 +105,35 @@ def _check_key_images(
return kept, skipped
def _attach_ceus(
cases: Sequence[CaseRecord], run: CeusRun
) -> tuple[list[tuple[CaseRecord, LibraryCeus]], list[SkipRecord]]:
"""C15:CEUS 失败或没有结果的病例整例跳过;关键帧图同 key.png 一样过 C14。"""
kept: list[tuple[CaseRecord, LibraryCeus]] = []
skipped: list[SkipRecord] = []
for case in cases:
ceus = case_ceus(run, case)
if isinstance(ceus, SkipRecord):
skipped.append(ceus)
continue
reasons = {slot: check_key_image(path) for slot, path in ceus.keyframe_sources.items()}
bad = next(((slot, r) for slot, r in reasons.items() if r is not None), None)
if bad is None:
kept.append((case, ceus))
continue
skipped.append(
SkipRecord(
case=case.case_no,
check=C14,
description=C14_DESCRIPTION,
files=(f"{run.run_id}/{case.video_id}/keyframes/{bad[0]}.png",),
columns=(),
value_json=json.dumps(bad[1], ensure_ascii=False),
)
)
return kept, skipped
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -116,12 +146,22 @@ def _rename(partial: Path, final: Path) -> None:
def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary:
"""读配置和交付包,装配一对同号的资料库与答案库。`now` 是本地时间,定构建号。"""
"""读配置和交付包(`ceus_run` 不为 null 时还读那次 CEUS 运行),装配一对同号的资料库与答案库。
`now` 是本地时间,定构建号。
"""
config = load_build_config(config_path, repo_root)
code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None
delivery = read_delivery(config.delivery_dir, code_maps)
passed, key_image_skips = _check_key_images(delivery.cases, config.delivery_dir)
skipped = [*delivery.skipped, *key_image_skips]
ceus_run = None if config.ceus_run is None else open_ceus_run(config.ceus_run)
ceus_of: dict[str, LibraryCeus | None] = dict.fromkeys((c.case_no for c in passed), None)
if ceus_run is not None:
with_ceus, ceus_skips = _attach_ceus(passed, ceus_run)
passed = [case for case, _ in with_ceus]
ceus_of = {case.case_no: ceus for case, ceus in with_ceus}
skipped.extend(ceus_skips)
git = read_git_state(repo_root)
build_id = make_build_id(now, git)
@@ -149,7 +189,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
config_sha256={name: _sha256(path) for name, path in configs.items()},
codes_decoded=code_maps is not None,
uncompared=delivery.uncompared,
ceus_source=None,
ceus_source=None if ceus_run is None else ceus_run.run_id,
cases=[
LibraryCase(
alias=aliases[case.case_no],
@@ -157,6 +197,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
ultrasound_report=case.ultrasound_report,
key_image_source=case.key_image,
mr_report=case.mr_report,
ceus=ceus_of[case.case_no],
)
for case in by_alias
],
+151
View File
@@ -0,0 +1,151 @@
"""读一次 CEUS 定量运行(`runs/ceus/<运行号>/`),按白名单挑出超声一路的 tic.json 和关键帧(C15)。
运行目录的布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`。tic.json 的字段只在这里列,
视频号、路径、配置、帧号和拟合参数都不进去。
"""
import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from cadx.delivery import CaseRecord, SkipRecord
from cadx.library import LibraryCeus
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")
_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
_PERFUSION_LABELS = ("signal_domain", "time_origin", "area_curve")
# 11 个灌注指标(同旧 replay summary 的 perfusion_metrics.values)。
_PERFUSION_VALUES = (
"PE_au",
"WiAUC_au_s",
"RT_s",
"mTTl_s",
"TTP_s",
"WiR_au_per_s",
"WiPI_au",
"WoAUC_au_s",
"WiWoAUC_au_s",
"FT_s",
"WoR_au_per_s",
)
_SUPPORTING_TIMES = ("TI", "mTT", "TO")
# 未定位原因由 ceus 按固定模板加数值生成,不含路径;出现这些字符说明模板变了,停下检查。
_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) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有 failed.csv 和快照),读 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}")
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:]})
def _number(value: object) -> float | int | None:
if value is not None and (isinstance(value, bool) or not isinstance(value, int | float)):
raise ValueError(f"summary.json 里应为数值或 null 的字段是 {value!r}")
return value
def _typed[T](value: object, kind: type[T]) -> T:
if not isinstance(value, kind):
raise ValueError(f"summary.json 里应为 {kind.__name__} 的字段是 {value!r}")
return value
def _perfusion(section: dict[str, Any] | None) -> dict[str, Any] | None:
if section is None:
return None
return {
**{key: _typed(section[key], str) for key in _PERFUSION_LABELS},
"values": {key: _number(section["values"][key]) for key in _PERFUSION_VALUES},
"supporting_times_s": {
key: _number(section["supporting_times_s"][key]) for key in _SUPPORTING_TIMES
},
}
def _keyframe(anchor: dict[str, Any], video_id: str) -> dict[str, Any]:
located = anchor["status"] == "ok"
time_s, reason = _number(anchor["frame_time_video_s"]), anchor["reason"]
if located != (time_s is not None) or located != (reason is None):
raise ValueError(f"summary.json 的关键帧状态、时间、原因互相矛盾:{anchor}")
if reason is not None and (
not isinstance(reason, str) or video_id in reason or any(m in reason for m in _PATH_MARKS)
):
raise ValueError(f"关键帧未定位原因含视频号或路径样的字符,不能给专家:{reason!r}")
return {"located": located, "video_time_s": time_s, "reason": reason}
def tic_view(summary: dict[str, Any], video_id: str) -> dict[str, Any]:
"""从一例的 summary.json 按白名单逐字段构造 tic.json 的内容。"""
return {
"fits": {
name: {
"success": _typed(summary["fits"][name]["success"], bool),
"r2": _number(summary["fits"][name]["quality"]["r2"]),
}
for name in _CURVES
},
"perfusion_metrics": {
name: _perfusion(summary["perfusion_metrics"][name]) for name in _LESION_CURVES
},
"pan_curve_usable": _typed(summary["reference_valid"], bool),
"keyframes": {slot: _keyframe(summary["anchor_frames"][slot], video_id) for slot in _SLOTS},
}
def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord:
"""一例的 CEUS 结果;在 failed.csv 里或没有结果目录的,交出 C15 跳过记录。"""
def c15(files: str, value: str) -> SkipRecord:
return SkipRecord(
case=case.case_no,
check=C15,
description=C15_DESCRIPTION,
files=(f"{run.run_id}/{files}",),
columns=(),
value_json=json.dumps(value, ensure_ascii=False),
)
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():
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}")
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)
+3
View File
@@ -54,6 +54,8 @@ class BuildConfig(BaseModel):
alias_seed: int
decode_codes: bool
code_maps: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
# 某次 CEUS 定量的运行目录(`runs/ceus/<运行号>/`);null 表示这次构建不装 CEUS 结果。
ceus_run: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
@model_validator(mode="after")
def _code_maps_match_switch(self) -> Self:
@@ -73,6 +75,7 @@ def load_build_config(path: Path, repo_root: Path) -> BuildConfig:
"library_root": repo_root / config.library_root,
"answers_root": repo_root / config.answers_root,
"code_maps": None if config.code_maps is None else repo_root / config.code_maps,
"ceus_run": None if config.ceus_run is None else repo_root / config.ceus_run,
}
)
+2 -1
View File
@@ -13,12 +13,13 @@ from cadx.library._read import (
read_ultrasound,
)
from cadx.library._scan import LeakHit, scan_for_leaks
from cadx.library._write import LibraryCase, check_key_image, write_library
from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library
__all__ = [
"ClinicalRoute",
"LeakHit",
"LibraryCase",
"LibraryCeus",
"MrRoute",
"UltrasoundRoute",
"check_key_image",
+18 -2
View File
@@ -8,8 +8,10 @@ from typing import Any, cast
from cadx.library._write import (
CLINICAL_JSON,
KEY_IMAGE,
KEYFRAMES_DIR,
MANIFEST,
MR_REPORT,
TIC_JSON,
ULTRASOUND_REPORT,
case_file,
sha256_file,
@@ -26,10 +28,13 @@ class ClinicalRoute:
@dataclass(frozen=True)
class UltrasoundRoute:
"""超声一路:报告「所见」原文和医生截的关键图(给路径)。"""
"""超声一路:报告「所见」原文、医生截的关键图(给路径);装了 CEUS 结果时还有 tic.json 的内容和
定位到的关键帧(槽位 → 路径),没装时 `tic` 为 None、`keyframes` 为空。"""
report: str
key_image: Path
tic: dict[str, Any] | None
keyframes: dict[str, Path]
@dataclass(frozen=True)
@@ -75,7 +80,18 @@ def read_clinical(build_dir: Path, alias: str) -> ClinicalRoute:
def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute:
"""读一例的超声一路。"""
report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT))
return UltrasoundRoute(report=report, key_image=_verified(build_dir, alias, KEY_IMAGE))
key_image = _verified(build_dir, alias, KEY_IMAGE)
manifest = _manifest(build_dir)
if manifest["ceus_source"] is None:
return UltrasoundRoute(report=report, key_image=key_image, tic=None, keyframes={})
prefix = case_file(alias, f"{KEYFRAMES_DIR}/")
keyframes = {
Path(key).stem: _verified(build_dir, alias, key.removeprefix(f"cases/{alias}/"))
for key in manifest["files"]
if key.startswith(prefix)
}
tic = json.loads(_text(_verified(build_dir, alias, TIC_JSON)))
return UltrasoundRoute(report=report, key_image=key_image, tic=tic, keyframes=keyframes)
def read_mr(build_dir: Path, alias: str) -> MrRoute:
+29 -3
View File
@@ -6,6 +6,8 @@
cases/<化名>/clinical/clinical.json
cases/<化名>/ultrasound/report.txt
cases/<化名>/ultrasound/key.png
cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位)
cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时)
cases/<化名>/mr/report.txt
```
"""
@@ -22,6 +24,8 @@ MANIFEST = "manifest.json"
CLINICAL_JSON = "clinical/clinical.json"
ULTRASOUND_REPORT = "ultrasound/report.txt"
KEY_IMAGE = "ultrasound/key.png"
KEYFRAMES_DIR = "ultrasound/keyframes"
TIC_JSON = "ultrasound/tic.json"
MR_REPORT = "mr/report.txt"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -30,15 +34,24 @@ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_FAITHFUL = frozenset({(8, 0), (8, 2), (8, 4), (8, 6), (1, 0)})
@dataclass(frozen=True)
class LibraryCeus:
"""超声一路的 CEUS 部分:`tic` 由 build 按白名单挑好 写成 tic.json;槽位 → 关键帧源图。"""
tic: Mapping[str, object]
keyframe_sources: Mapping[str, Path]
@dataclass(frozen=True)
class LibraryCase:
"""要写进资料库的一例:三路各自的内容。"""
"""要写进资料库的一例:三路各自的内容。`ceus` 为 None 表示这次构建不装 CEUS 结果。"""
alias: str
clinical_fields: Mapping[str, str | int | float | None]
ultrasound_report: str
key_image_source: Path
mr_report: str
ceus: LibraryCeus | None
def case_file(alias: str, relative: str) -> str:
@@ -98,9 +111,14 @@ def write_library(
ceus_source: str | None,
cases: Sequence[LibraryCase],
) -> None:
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。"""
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。
`ceus_source` 是所用 CEUS 运行号,为 None 时每例的 `ceus` 都必须是 None,反之都不能是 None。
"""
files: dict[str, str] = {}
for case in cases:
if (case.ceus is None) != (ceus_source is None):
raise ValueError(f"化名 {case.alias} 的 CEUS 结果与 ceus_source={ceus_source!r} 不配套")
case_dir = build_dir / "cases" / case.alias
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT):
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
@@ -111,7 +129,15 @@ def write_library(
(case_dir / ULTRASOUND_REPORT).write_text(case.ultrasound_report, encoding="utf-8")
_write_clean_png(case.key_image_source, case_dir / KEY_IMAGE)
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT):
written = [CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT]
if case.ceus is not None:
(case_dir / KEYFRAMES_DIR).mkdir()
for slot, source in case.ceus.keyframe_sources.items():
_write_clean_png(source, case_dir / KEYFRAMES_DIR / f"{slot}.png")
written.append(f"{KEYFRAMES_DIR}/{slot}.png")
_write_json(case_dir / TIC_JSON, dict(case.ceus.tic))
written.append(TIC_JSON)
for relative in written:
files[case_file(case.alias, relative)] = sha256_file(case_dir / relative)
_write_json(
build_dir / MANIFEST,