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:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user