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