feat(ceus): 两半坐标换算改为每段视频配准,加目视核对脚本

- 第 1 步分屏后在两半扇形区内用边缘图归一化互相关估 (dx, dy),再用逐帧多块模板匹配的中位数独立核对;
  两者各分量之差超过门槛(15 像素)这一例第 1 步失败。删掉「两个视图左上角相减」的实现与测试。
- 门槛依据:34 例中走完第 1 步的 29 例,差值中位 2、95 分位 6.2、最大 13;参数与门槛在同一批视频上调出。
- scripts/ceus_align_review.py:只做第 1 步,出扇形区棋盘图与边缘叠加图,混入错开 15–30 像素的
  阴性对照、随机编号;计分时任一对照未被判「没对齐」即作废。
- ceus_quant 读 review.csv:不通过的病例第 1 步失败;表里没有这段视频或平移对不上即停。
- 配准与紫圈颜色参数进分割缓存键。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 06:47:27 -04:00
parent c582fe426f
commit 00ebbffb28
20 changed files with 1500 additions and 277 deletions
+114 -14
View File
@@ -1,6 +1,9 @@
"""CEUS 定量:对交付包里检查全部通过的病例逐例跑第 1–9 步,写进 `runs/ceus/<运行号>/`。
用法:uv run python scripts/ceus_quant.py --config config/ceus.yaml
第 1 步算出两半配准后,按视频 SHA-256 查配准目视核对的结果表(`paths.align_review`,由
`scripts/ceus_align_review.py score` 写出):核对没通过的病例第 1 步失败;表里没有这段视频,或配准
结果与表里记的不同,说明核对的不是这次跑的东西,整次运行停下。
第 3 步(MedSAM2 分割)的结果按内容键缓存在 `<work_root>/segment_cache/<键>/masks.npz`,键相同就
直接读缓存、不调显卡。某一例在第 1–6 步失败(`CeusCaseError`)记进 `failed.csv`,接着跑下一例;
其余异常整次运行停下,留下 `.partial` 目录。终端只打印计数,不打印视频号。
@@ -13,7 +16,8 @@ import json
import sys
import time
from collections import Counter
from collections.abc import Callable, Hashable, Sequence
from collections.abc import Callable, Hashable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -22,7 +26,7 @@ import cv2
import numpy as np
import numpy.typing as npt
import yaml
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator
from cadx.ceus import (
ANCHOR_SLOTS,
@@ -45,14 +49,20 @@ from cadx.segment import SegmentConfig, cuda_peak_memory_bytes, medsam2_commit,
REPO_ROOT = Path(__file__).resolve().parents[1]
# 第 1–2 步(`prepare_segmentation`)读的 ceus 段子段:views(分屏)、prompt_source(绿框颜色)、
# keyframes(提示帧选择)。它们决定交给分割的帧和提示掩码,进分割缓存键。
_STEP_1_2_CEUS_FIELDS = frozenset({"views", "prompt_source", "keyframes"})
# 第 1–2 步(`prepare_segmentation`)读的 ceus 段子段:views(分屏)、registration(两半配准)、
# prompt_source(绿框颜色,配准和选提示帧都用)、pan_source(紫圈颜色,配准时修补掉)、keyframes
# (提示帧选择)。它们决定交给分割的帧和提示掩码,进分割缓存键。
_STEP_1_2_CEUS_FIELDS = frozenset(
{"views", "registration", "prompt_source", "pan_source", "keyframes"}
)
# segment 段里不改变分割结果的开关,不进缓存键;其余字段都进。
_RESULT_NEUTRAL_SEGMENT_FIELDS = frozenset(
{"device", "offload_video_to_cpu", "offload_state_to_cpu"}
)
_FAILED_HEADER = ("video_id", "step", "reason")
# 配准目视核对结果表 review.csv 的列:(dx, dy) 是左半 → 右半的平移
# (`HalfRegistration.correlation`),passed 取 true / false,后两列是两位判读者的判定。
REVIEW_HEADER = ("video_sha256", "dx", "dy", "passed", "verdict_a", "verdict_b")
def _reject_empty_path(value: object) -> object:
@@ -67,13 +77,37 @@ type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_e
class PathsConfig(BaseModel):
"""`paths` 段:交付包、运行目录的上级、中间文件目录。相对路径相对仓库根目录。"""
"""`paths` 段:交付包、运行目录的上级、中间文件目录、配准目视核对的运行目录上级和结果表。
相对路径相对仓库根目录。`align_review` 在 ceus_quant 运行时必须存在,出图时用不到。
"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
delivery_dir: _PathField
runs_root: _PathField
work_root: _PathField
align_runs_root: _PathField
align_review: _PathField
class AlignCheckConfig(BaseModel):
"""`align_check` 段:配准目视核对出图用的随机数种子、阴性对照例数、对照错开距离区间(像素)。"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
seed: int
control_count: int = Field(ge=1)
control_shift_px: list[int]
@field_validator("control_shift_px")
@classmethod
def _shift_range(cls, value: list[int]) -> list[int]:
if len(value) != 2 or not 0 < value[0] <= value[1]:
raise ValueError(
f"align_check.control_shift_px 必须是 [lo, hi] 且 0 < lo ≤ hi,收到 {value}"
)
return value
class QuantConfig(BaseModel):
@@ -82,6 +116,7 @@ class QuantConfig(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
paths: PathsConfig
align_check: AlignCheckConfig
segment: SegmentConfig
ceus: CeusConfig
@@ -106,7 +141,7 @@ def load_config(path: Path) -> QuantConfig:
return QuantConfig.model_validate(yaml.load(text, Loader=_UniqueKeyLoader)) # noqa: S506 继承 SafeLoader
def _resolved(config: QuantConfig) -> QuantConfig:
def resolve_paths(config: QuantConfig) -> QuantConfig:
"""相对路径换成仓库根目录下的绝对路径(绝对路径原样保留)。"""
paths, segment = config.paths, config.segment
return config.model_copy(
@@ -116,6 +151,8 @@ def _resolved(config: QuantConfig) -> QuantConfig:
"delivery_dir": REPO_ROOT / paths.delivery_dir,
"runs_root": REPO_ROOT / paths.runs_root,
"work_root": REPO_ROOT / paths.work_root,
"align_runs_root": REPO_ROOT / paths.align_runs_root,
"align_review": REPO_ROOT / paths.align_review,
}
),
"segment": segment.model_copy(
@@ -136,6 +173,49 @@ def file_sha256(path: Path) -> str:
return digest.hexdigest()
@dataclass(frozen=True)
class ReviewEntry:
"""review.csv 的一行:核对时的配准结果(左半 → 右半)和是否通过。"""
offset: tuple[int, int]
passed: bool
def read_review(path: Path) -> dict[str, ReviewEntry]:
"""读配准目视核对结果表,视频 SHA-256 → 这一行。表头、取值不合规或同一视频出现两次就报错。"""
with path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
if not rows or tuple(rows[0]) != REVIEW_HEADER:
raise ValueError(f"{path} 的表头应为 {REVIEW_HEADER}")
table: dict[str, ReviewEntry] = {}
for number, row in enumerate(rows[1:], start=2):
if len(row) != len(REVIEW_HEADER) or row[3] not in ("true", "false"):
raise ValueError(f"{path} 第 {number} 行不合规:{row}")
if row[0] in table:
raise ValueError(f"{path} 第 {number} 行:同一段视频出现两次")
table[row[0]] = ReviewEntry(offset=(int(row[1]), int(row[2])), passed=row[3] == "true")
return table
def check_review(
prepared: SegmentationInput, video_sha256: str, review: Mapping[str, ReviewEntry]
) -> None:
"""第 1 步配准结果对照目视核对表:没通过抛 `CeusCaseError`(第 1 步);对不上表就报错停下。"""
if video_sha256 not in review:
raise RuntimeError(
f"配准目视核对表里没有这段视频(SHA-256 {video_sha256}),核对的不是这批视频"
)
entry = review[video_sha256]
offset = prepared.registration.correlation
if offset != entry.offset:
raise RuntimeError(
f"这段视频(SHA-256 {video_sha256})的配准结果 {offset} 与目视核对表里的 "
f"{entry.offset} 不同,核对的不是这次跑的东西"
)
if not entry.passed:
raise CeusCaseError(1, "配准目视核对未通过")
def segment_cache_key(
video_sha256: str, config: QuantConfig, checkpoint_sha256: str, commit: str
) -> str:
@@ -178,14 +258,21 @@ def _write_png(path: Path, image: npt.NDArray[np.uint8]) -> None:
def _run_case(
case: CaseRecord, cfg: QuantConfig, key_of: Callable[[str], str], run_dir: Path
case: CaseRecord,
cfg: QuantConfig,
key_of: Callable[[str], str],
review: Mapping[str, ReviewEntry],
run_dir: Path,
) -> tuple[tuple[str, ...], bool]:
"""跑一例并写出产物,返回(定位到的关键帧槽位,分割是否命中缓存)。
`key_of` 把视频的 SHA-256 换成分割缓存键(其余几样跨病例不变)。
`key_of` 把视频的 SHA-256 换成分割缓存键(其余几样跨病例不变);`review` 是配准目视核对表。
第 1–2 步都在 `prepare_segmentation` 里,所以第 2 步失败的病例不查核对表。
"""
prepared = prepare_segmentation(case.video, cfg.ceus)
key = key_of(file_sha256(case.video))
video_sha256 = file_sha256(case.video)
check_review(prepared, video_sha256, review)
key = key_of(video_sha256)
masks, hit = _masks(prepared, key, cfg.paths.work_root / "segment_cache", cfg.segment)
result = quantify_case(prepared, masks, float(case.bolus_start_s), cfg.ceus)
@@ -193,7 +280,19 @@ def _run_case(
(case_dir / "keyframes").mkdir(parents=True, exist_ok=False)
(case_dir / "trajectory.npz").write_bytes(pack_trajectory(result.trajectory))
(case_dir / "tic_curves.npz").write_bytes(pack_tic_curves(result.curves))
summary = {"video_id": case.video_id, "segment_cache_key": key, **quant_summary(result)}
registration = prepared.registration
summary = {
"video_id": case.video_id,
"segment_cache_key": key,
"registration": {
"contrast_side": prepared.contrast.side,
"contrast_to_target_offset": list(prepared.offset),
"left_to_right_correlation": list(registration.correlation),
"left_to_right_template": list(registration.template),
"disagreement_px": registration.disagreement_px,
},
**quant_summary(result),
}
(case_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8"
)
@@ -214,7 +313,7 @@ def main(argv: Sequence[str]) -> None:
clock = time.monotonic()
written = load_config(config_path)
cfg = _resolved(written)
cfg = resolve_paths(written)
git = read_git_state(REPO_ROOT)
run_id = make_build_id(started_at, git)
run_dir = cfg.paths.runs_root / run_id
@@ -224,6 +323,7 @@ def main(argv: Sequence[str]) -> None:
raise FileExistsError(f"运行目录已存在,不覆盖:{path}")
partial.mkdir(parents=True, exist_ok=False)
review = read_review(cfg.paths.align_review)
delivery = read_delivery(cfg.paths.delivery_dir, None)
checkpoint_sha256 = file_sha256(cfg.segment.checkpoint)
commit = medsam2_commit(cfg.segment.medsam2_dir)
@@ -238,7 +338,7 @@ def main(argv: Sequence[str]) -> None:
total = len(delivery.cases)
for number, case in enumerate(delivery.cases, start=1):
try:
slots, hit = _run_case(case, cfg, key_of, partial)
slots, hit = _run_case(case, cfg, key_of, review, partial)
except CeusCaseError as error:
failures.append((case.video_id, error.step, error.reason))
print(f"[{number}/{total}] 失败在第 {error.step} 步", flush=True)
@@ -259,7 +359,7 @@ def main(argv: Sequence[str]) -> None:
started_at=started_at,
finished_at=datetime.now().astimezone(),
uv_lock=REPO_ROOT / "uv.lock",
configs={"ceus.yaml": config_path},
configs={"ceus.yaml": config_path, "align_review.csv": cfg.paths.align_review},
)
segment_record = {
"checkpoint_sha256": checkpoint_sha256,