feat(ceus): 任务书 02 第三阶段——scripts/ceus_quant.py

- delivery 的 CaseRecord 加视频路径和推注时间(C04、C08 已检查过)。
- scripts/ceus_quant.py:读 ceus.yaml,跑检查全部通过的病例;runs/ceus/<运行号>/ 先写 .partial
  再改名;第 1–6 步的单例失败记 failed.csv 后接着跑;终端只打印计数。
- 分割缓存 work/segment_cache/<键>/:键含视频 SHA-256、第 1–3 步参数、权重 SHA-256、MedSAM2 提交号。
- 视频解码错误改为第 1 步的单例失败(VideoDecodeError),不再停掉整次运行(审阅意见)。
- 暗区 kmeans 用 cv2 进程级随机数,结果受病例顺序影响;ceus 配置加必填 random_seed,每例第 4 步前设种子。
- segment 导出 cuda_peak_memory_bytes,供运行快照记录显存峰值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:30:29 -04:00
parent 150eb5f6ae
commit 3524b31e3e
12 changed files with 667 additions and 26 deletions
+2 -1
View File
@@ -135,7 +135,7 @@ from cadx.ceus._trajectory import (
pack_trajectory,
unpack_trajectory,
)
from cadx.ceus._video import VideoFile
from cadx.ceus._video import VideoDecodeError, VideoFile
__all__ = [
"ANCHOR_SLOTS",
@@ -186,6 +186,7 @@ __all__ = [
"TicPlotMarker",
"TicResult",
"Trajectory",
"VideoDecodeError",
"VideoFile",
"ViewRect",
"ViewsConfig",
+3 -1
View File
@@ -539,12 +539,14 @@ class CeusConfig(BaseModel):
"""`config/ceus.yaml` 的 `ceus` 段:CEUS 定量的全部算法参数。
`config_version` 标记这一批产物用的是哪一套参数口径,由人改,不从内容哈希派生;
不许是空串。
不许是空串。`random_seed` 是 cv2 进程级随机数的种子,`quantify_case` 每例开头设一次(第 5 步暗区
的 kmeans 候选用到随机数),让一例的结果与同一进程里先跑过哪些病例无关。
"""
model_config = _STRICT
config_version: str = Field(min_length=1)
random_seed: int
views: ViewsConfig
prompt_source: PromptSourceConfig
pan_source: PanSourceConfig
+8 -3
View File
@@ -20,6 +20,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from cadx.ceus._anchor_frames import (
@@ -50,7 +51,7 @@ from cadx.ceus._tic_curves import TicCurves, from_tic_result
from cadx.ceus._tic_fitting import LognormalFitResult, evaluate_lognormal
from cadx.ceus._tic_plot import TicPlotMarker
from cadx.ceus._trajectory import Trajectory
from cadx.ceus._video import VideoFile
from cadx.ceus._video import VideoDecodeError, VideoFile
# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。
LESION_CURVES = ("roi_all", "roi_perf")
@@ -125,7 +126,8 @@ class QuantResult:
def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput:
"""第 1–2 步:解码、分屏、找绿框定造影那一半、选提示帧,交出分割的输入。
找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError`
视频读不了或读出的不合规抛 `VideoDecodeError`、找不到可信分屏线抛 `SplitDetectionError`(都是
第 1 步),两半都有或都没有绿框抛 `ContrastSideError`
(第 2 步),两者都是 `CeusCaseError`。
"""
video = VideoFile(video_path)
@@ -133,7 +135,7 @@ def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput
frame_times_s = video.frame_times_s()
full_frames = [frame for _, frame in video.iter_frames()]
if len(full_frames) != frame_count:
raise RuntimeError(
raise VideoDecodeError(
f"解码帧数 {len(full_frames)} 与容器记录的帧数 {frame_count} 不一致:{video_path}"
)
@@ -194,6 +196,9 @@ def quantify_case(
灌注指标为 None。
"""
_validate_masks(prepared, target_masks)
# 第 5 步暗区的 kmeans 候选(k-means++ 选初始中心)用 cv2 的进程级随机数;每例开头重设,
# 这一例的结果就与同一进程里先跑过什么无关。
cv2.setRNGSeed(cfg.random_seed)
contrast = prepared.contrast
frame_count = prepared.frame_count
prompt_frame_dicts: list[dict[str, Any]] = [
+20 -6
View File
@@ -2,6 +2,7 @@
反复遍历靠重新解码,不缓存帧:一段 1260 帧的素材解一遍不到两秒,缓存下来要好几 GB(旧项目实测)。
每次遍历各开一个 `cv2.VideoCapture`,互不干扰。探测不到有效 fps、帧数或尺寸直接报错,不回退默认值。
打不开、元数据或时间戳不合规是这一段视频的资料问题,抛 `VideoDecodeError`(第 1 步的单例失败)。
"""
import math
@@ -13,8 +14,21 @@ import cv2
import numpy as np
import numpy.typing as npt
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import Frame
DECODE_STEP = 1
class VideoDecodeError(CeusCaseError, RuntimeError):
"""这段视频读不了或读出的不合规(打不开、fps 或尺寸非法、时间戳不严格递增、帧数对不上)。
同时是 `RuntimeError`:旧测试按 `RuntimeError` 断言,语义不变。
"""
def __init__(self, reason: str) -> None:
super().__init__(DECODE_STEP, reason)
@dataclass(frozen=True)
class VideoFile:
@@ -26,7 +40,7 @@ class VideoFile:
cap = cv2.VideoCapture(str(self.path))
if not cap.isOpened():
cap.release()
raise RuntimeError(f"cv2 打不开视频:{self.path}")
raise VideoDecodeError(f"cv2 打不开视频:{self.path}")
return cap
def probe(self) -> tuple[float, int, int, int]:
@@ -40,9 +54,9 @@ class VideoFile:
finally:
cap.release()
if not math.isfinite(fps) or fps <= 0:
raise RuntimeError(f"探测到非法 fps={fps}:{self.path}")
raise VideoDecodeError(f"探测到非法 fps={fps}:{self.path}")
if frame_count <= 0 or width <= 0 or height <= 0:
raise RuntimeError(
raise VideoDecodeError(
f"探测到非法元数据 frame_count={frame_count} "
f"width={width} height={height}:{self.path}"
)
@@ -74,9 +88,9 @@ class VideoFile:
break
timestamp_s = float(cap.get(cv2.CAP_PROP_POS_MSEC)) / 1000.0
if not math.isfinite(timestamp_s) or timestamp_s < 0.0:
raise RuntimeError(f"视频包含非法显示时间戳 {timestamp_s}:{self.path}")
raise VideoDecodeError(f"视频包含非法显示时间戳 {timestamp_s}:{self.path}")
if times and timestamp_s <= times[-1]:
raise RuntimeError(
raise VideoDecodeError(
"视频显示时间戳不严格递增:"
f"第 {len(times)} 帧 {timestamp_s},前一帧 {times[-1]}:{self.path}"
)
@@ -84,7 +98,7 @@ class VideoFile:
finally:
cap.release()
if len(times) != expected_count:
raise RuntimeError(
raise VideoDecodeError(
f"时间戳帧数与容器记录的帧数不一致:{len(times)} / {expected_count}:{self.path}"
)
return np.asarray(times, dtype=np.float64)
+13 -7
View File
@@ -77,6 +77,8 @@ class CaseRecord:
ultrasound_report: str
mr_report: str
key_image: Path
video: Path # CEUS 视频(C04 已保证恰好一段,文件名带不带 roi2 标记都可能)
bolus_start_s: int # 超声表「推注开始时间s」:视频第 0 秒时已推注的秒数(C08 已保证非负整数)
diagnosis: DiagnosisCodes
@@ -181,13 +183,13 @@ def _index_rows(
_VIDEO_REPORT_PAIRS = (("_roi2.mp4", "_roi2_report.xlsx"), (".mp4", "_report.xlsx"))
def _check_case_dir(case_no: str, folder: Path) -> str | SkipRecord:
"""C04:通过时返回这一例单例报告表的文件名。"""
def _check_case_dir(case_no: str, folder: Path) -> tuple[str, str] | SkipRecord:
"""C04:通过时返回这一例(视频文件名,单例报告表文件名)。"""
u = video_id(case_no)
names = sorted(p.name for p in folder.iterdir())
for video, report in _VIDEO_REPORT_PAIRS:
if names == sorted((f"{u}_key.png", f"{u}{video}", f"{u}{report}")):
return f"{u}{report}"
return f"{u}{video}", f"{u}{report}"
return _skip(case_no, "C04", (case_dir_path(case_no),), (), {"文件": names})
@@ -336,9 +338,10 @@ def _check_case(
folder = delivery_dir / case_dir_path(case_no)
u = video_id(case_no)
report_name = _check_case_dir(case_no, folder)
if isinstance(report_name, SkipRecord):
return report_name
dir_files = _check_case_dir(case_no, folder)
if isinstance(dir_files, SkipRecord):
return dir_files
video_name, report_name = dir_files
if ultrasound["CEUS视频ID"] != u:
value = {"CEUS视频ID": ultrasound["CEUS视频ID"], "病例号": case_no}
@@ -370,7 +373,8 @@ def _check_case(
if (skip := _text_hits(case_no, check, reports, find)) is not None:
return skip
# C07 已保证临床各列是整数、浮点数或空;C07–C10 已保证三个诊断是相等的整数。
# C07 已保证临床各列是整数、浮点数或空;C07–C10 已保证三个诊断是相等的整数;
# C08 已保证推注时间是非负整数。
return CaseRecord(
case_no=case_no,
video_id=u,
@@ -378,6 +382,8 @@ def _check_case(
ultrasound_report=reports[ULTRASOUND],
mr_report=reports[MR],
key_image=folder / f"{u}_key.png",
video=folder / video_name,
bolus_start_s=cast(int, bolus),
diagnosis=DiagnosisCodes(
clinical=cast(int, clinical["Diagnosis"]),
ultrasound=cast(int, ultrasound[DIAGNOSIS_HEADER]),
+2 -2
View File
@@ -5,6 +5,6 @@
"""
from cadx.segment._config import SegmentConfig
from cadx.segment._medsam2 import medsam2_commit, segment_video
from cadx.segment._medsam2 import cuda_peak_memory_bytes, medsam2_commit, segment_video
__all__ = ["SegmentConfig", "medsam2_commit", "segment_video"]
__all__ = ["SegmentConfig", "cuda_peak_memory_bytes", "medsam2_commit", "segment_video"]
+13
View File
@@ -45,6 +45,19 @@ def medsam2_commit(medsam2_dir: Path) -> str:
return completed.stdout.decode().strip()
def cuda_peak_memory_bytes(device: str) -> int | None:
"""本进程从启动起在 `device` 上分配过的显存峰值(字节,`torch.cuda.max_memory_allocated`)。
`device` 不是 CUDA 设备时返回 None;本进程还没初始化过 CUDA(比如分割全部读了缓存)时返回 0,
不为了查询去初始化显卡。
"""
if not device.startswith("cuda"):
return None
if not torch.cuda.is_initialized():
return 0
return int(torch.cuda.max_memory_allocated(device))
def _check_inputs(
frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig
) -> tuple[int, int]: