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
+250
View File
@@ -0,0 +1,250 @@
"""配准的目视核对(任务书 02 第三阶段):`render --config <ceus.yaml>` 出图,`score --run-dir`
计分。
render 只做第 1 步、不用显卡:第 1 步成功的每例出棋盘拼图和边缘叠加图(只截扇形区),另随机挑
`align_check` 段定的例数把配准故意错开当阴性对照;图按打乱的编号放进 `images/`,对应关系写
`key.csv`(不给判读者)。score 对照 `verdicts_a.csv`、`verdicts_b.csv`:任一张对照被任一人判成
「对齐」或「看不清」就作废(不写 review.csv,退出码 1);否则写 `review.csv`,两人都判「对齐」才
通过。终端只打印计数。
"""
import argparse
import csv
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import numpy.typing as npt
from cadx import ceus
from cadx.ceus import CeusConfig, HalfRegistration, ViewRect, crop_frame
from cadx.delivery import read_delivery
from cadx.runs import make_build_id, read_git_state, write_snapshot
from ceus_quant import REPO_ROOT, REVIEW_HEADER, file_sha256, load_config, resolve_paths
KEY_HEADER = ("id", "video_id", "is_control", "dx", "dy", "video_sha256")
VERDICT_HEADER = ("编号", "判定", "依据")
ALIGNED, MISALIGNED, UNCLEAR = "对齐", "没对齐", "看不清"
_EDGE_FRACTION = 0.1 # 每半取扇形内梯度幅值最高的 10% 像素画成边缘
type _U8 = npt.NDArray[np.uint8]
type _Gray = npt.NDArray[np.float32]
type _Mask = npt.NDArray[np.bool_]
def _indices(count: int, wanted: int) -> set[int]:
"""与 `sample_frames_for_split` 相同的均匀抽帧下标。"""
if count <= wanted:
return set(range(count))
return {int(i) for i in np.linspace(0, count - 1, num=wanted, dtype=np.int32).tolist()}
@dataclass(frozen=True)
class Material:
"""一例出图要的东西:两个视图;整帧的时间平均灰度、抽样中间那帧的灰度、绿框紫圈像素;两半扇形。"""
left: ViewRect
right: ViewRect
mean: _Gray
middle: _Gray
marks: _Mask
fan_left: _Mask
fan_right: _Mask
def step1(video_path: Path, cfg: CeusConfig) -> tuple[Material, HalfRegistration]:
"""第 1 步:逐帧解码只留分屏和配准要的抽样帧,找分屏线,配准两半。"""
video = ceus.VideoFile(video_path)
_, frame_count, _, _ = video.probe()
split_at = _indices(frame_count, cfg.views.split_sample_frames)
register_at = _indices(frame_count, cfg.registration.sample_frames)
split_frames, frames = list[_U8](), list[_U8]()
decoded = 0
for index, frame in video.iter_frames():
decoded += 1
if index in split_at:
split_frames.append(frame)
if index in register_at:
frames.append(frame)
if decoded != frame_count:
raise ceus.VideoDecodeError(f"解码帧数 {decoded} 与容器记录的帧数 {frame_count} 不一致")
_, left, right = ceus.split_views(split_frames, cfg.views)
reg = ceus.register_halves(
frames, left, right, cfg.registration, cfg.prompt_source, cfg.pan_source
)
gray = np.stack([cv2.cvtColor(f, cv2.COLOR_BGR2GRAY) for f in frames]).astype(np.float32)
std = gray.std(axis=0)
fans = [ceus.fan_mask(crop_frame(std, view), cfg.registration) for view in (left, right)]
marks = ceus.color_marks(
frames, cfg.prompt_source, cfg.pan_source, cfg.registration.mark_dilate
)
material = Material(left, right, gray.mean(0), gray[len(frames) // 2], marks, *fans)
return material, reg
def _shift(image: npt.NDArray[Any], offset: tuple[int, int], size: tuple[int, int]) -> Any:
"""右半按平移挪进左半坐标:结果 (x, y) = 右半 (x + dx, y + dy),出界补 0。"""
matrix = np.array([[1, 0, -offset[0]], [0, 1, -offset[1]]], dtype=np.float32)
return cv2.warpAffine(image, matrix, size, flags=cv2.INTER_NEAREST)
def _edges(gray: _Gray, fan: _Mask, marks: _Mask, sigma: float) -> Any:
"""扇形内、绿框紫圈之外梯度幅值最高的 `_EDGE_FRACTION` 像素,膨胀 1 像素便于看出重合。"""
smooth = cv2.GaussianBlur(gray, (0, 0), sigma)
magnitude = np.hypot(cv2.Sobel(smooth, cv2.CV_32F, 1, 0), cv2.Sobel(smooth, cv2.CV_32F, 0, 1))
keep = fan & ~marks # 绿框紫圈只画在一半上,它们的边缘不参与比对
edge = keep & (magnitude >= np.quantile(magnitude[keep], 1 - _EDGE_FRACTION))
return cv2.dilate(edge.astype(np.uint8), np.ones((3, 3), np.uint8)) > 0
def render_pair(m: Material, offset: tuple[int, int], sigma: float) -> tuple[_U8, _U8]:
"""按左半 → 右半的平移出(棋盘拼图,边缘叠加图),截到两半扇形的外接框,扇形外涂黑。"""
left, right, size = m.left, m.right, (m.left.width, m.left.height)
fan_r = _shift(m.fan_right.astype(np.uint8), offset, size) > 0
support = m.fan_left | fan_r
ys, xs = np.nonzero(support)
x1, x2, y1, y2 = int(xs.min()), int(xs.max()) + 1, int(ys.min()), int(ys.max()) + 1
block = max(8, round((x2 - x1) / 12))
yy, xx = np.mgrid[0 : left.height, 0 : left.width]
first = ((yy - y1) // block + (xx - x1) // block) % 2 == 0
checker = np.where(
first, crop_frame(m.middle, left), _shift(crop_frame(m.middle, right), offset, size)
)
checker[~support] = 0
overlay = np.zeros((left.height, left.width, 3), dtype=np.uint8)
marks_l = crop_frame(m.marks, left)
marks_r = _shift(crop_frame(m.marks, right).astype(np.uint8), offset, size) > 0
edges_l = _edges(crop_frame(m.mean, left), m.fan_left, marks_l, sigma)
edges_r = _edges(_shift(crop_frame(m.mean, right), offset, size), fan_r, marks_r, sigma)
overlay[..., 2][edges_l] = 255 # 红:左半
overlay[..., 1][edges_r] = 255 # 绿:右半;重合处红 + 绿 = 黄
return np.clip(checker[y1:y2, x1:x2], 0, 255).astype(np.uint8), overlay[y1:y2, x1:x2]
def render(config_path: Path) -> None:
started_at = datetime.now().astimezone()
cfg = resolve_paths(load_config(config_path))
git = read_git_state(REPO_ROOT)
run_id = make_build_id(started_at, git)
run_dir = cfg.paths.align_runs_root / run_id
partial = cfg.paths.align_runs_root / f"{run_id}.partial"
for path in (run_dir, partial):
if path.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{path}")
(partial / "images").mkdir(parents=True)
rng = np.random.default_rng(cfg.align_check.seed)
cases: list[tuple[str, str, Material, tuple[int, int]]] = []
failures: list[tuple[str, int, str]] = []
for case in read_delivery(cfg.paths.delivery_dir, None).cases:
try:
material, registration = step1(case.video, cfg.ceus)
except ceus.CeusCaseError as error:
failures.append((case.video_id, error.step, error.reason))
continue
cases.append((case.video_id, file_sha256(case.video), material, registration.correlation))
# 对照例数多于成功例数时 rng.choice 报错(不放回抽样)。
controls = rng.choice(len(cases), size=cfg.align_check.control_count, replace=False)
items: list[tuple[int, bool, tuple[int, int]]] = [(i, False, c[3]) for i, c in enumerate(cases)]
for index in sorted(int(i) for i in controls):
length = rng.uniform(*cfg.align_check.control_shift_px)
angle = rng.uniform(0.0, 2 * np.pi)
dx, dy = cases[index][3]
shift = (round(length * np.cos(angle)), round(length * np.sin(angle)))
items.append((index, True, (dx + shift[0], dy + shift[1])))
numbers = rng.permutation(len(items)) + 1
rows: list[tuple[str, str, str, int, int, str]] = []
for number, (index, is_control, offset) in zip(numbers.tolist(), items, strict=True):
video_id, sha, material, _ = cases[index]
name = f"{number:03d}"
checker, edges = render_pair(material, offset, cfg.ceus.registration.blur_sigma)
for kind, image in (("checker", checker), ("edges", edges)):
if not cv2.imwrite(str(partial / "images" / f"{name}_{kind}.png"), image):
raise RuntimeError(f"写 PNG 失败:{name}_{kind}.png")
rows.append((name, video_id, str(is_control).lower(), offset[0], offset[1], sha))
with (partial / "key.csv").open("w", encoding="utf-8", newline="") as handle:
csv.writer(handle).writerows([KEY_HEADER, *sorted(rows)])
with (partial / "failed.csv").open("w", encoding="utf-8", newline="") as handle:
csv.writer(handle).writerows([("video_id", "step", "reason"), *failures])
finished_at, lock = datetime.now().astimezone(), REPO_ROOT / "uv.lock"
configs = {"ceus.yaml": config_path} # align_check 段的随机数种子随 ceus.yaml 进快照
write_snapshot(partial / "snapshot", run_id, git, started_at, finished_at, lock, configs)
if run_dir.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{run_dir}")
partial.rename(run_dir)
print(f"运行目录:{run_dir}")
print(f"第 1 步成功 {len(cases)} 例、失败 {len(failures)} 例;对照 {len(controls)} 组")
def _read_csv(path: Path, header: tuple[str, ...]) -> list[list[str]]:
with path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
if not rows or tuple(rows[0]) != header or any(len(r) != len(header) for r in rows):
raise ValueError(f"{path} 的表头应为 {header},且每行 {len(header)} 列")
return rows[1:]
def read_verdicts(path: Path, ids: set[str]) -> dict[str, str]:
"""一位判读者的判定,编号 → 判定。缺编号、多编号、重复编号、判定取值不合法都报错。"""
rows = _read_csv(path, VERDICT_HEADER)
verdicts = {row[0]: row[1] for row in rows}
if len(verdicts) != len(rows):
raise ValueError(f"{path} 有重复的编号")
if set(verdicts) != ids:
missing, extra = sorted(ids - set(verdicts)), sorted(set(verdicts) - ids)
raise ValueError(f"{path} 的编号与出图不符:缺 {missing},多 {extra}")
bad = sorted({v for v in verdicts.values() if v not in (ALIGNED, MISALIGNED, UNCLEAR)})
if bad:
raise ValueError(f"{path} 的判定只能是对齐、没对齐、看不清,收到 {bad}")
return verdicts
def score(run_dir: Path) -> int:
"""计分,返回退出码:0 写出 review.csv,1 本次核对作废。"""
review_path = run_dir / "review.csv"
if review_path.exists():
raise FileExistsError(f"review.csv 已存在,不覆盖:{review_path}")
key = _read_csv(run_dir / "key.csv", KEY_HEADER)
ids = {row[0] for row in key}
a = read_verdicts(run_dir / "verdicts_a.csv", ids)
b = read_verdicts(run_dir / "verdicts_b.csv", ids)
controls = [row[0] for row in key if row[2] == "true"]
real = [row for row in key if row[2] == "false"]
caught_a, caught_b = (sum(v[i] == MISALIGNED for i in controls) for v in (a, b))
print(f"阴性对照 {len(controls)} 张:判读者 A 判「没对齐」{caught_a} 张,B {caught_b} 张")
if caught_a < len(controls) or caught_b < len(controls):
print("本次核对作废:有阴性对照被判成「对齐」或「看不清」,不写 review.csv")
return 1
if len({row[5] for row in real}) != len(real):
raise ValueError("key.csv 里同一段视频作为真实病例出现了两次")
passed = [a[row[0]] == ALIGNED and b[row[0]] == ALIGNED for row in real]
lines = [
(r[5], r[3], r[4], str(ok).lower(), a[r[0]], b[r[0]])
for r, ok in zip(real, passed, strict=True)
]
with review_path.open("x", encoding="utf-8", newline="") as handle:
csv.writer(handle).writerows([REVIEW_HEADER, *lines])
unclear = sum(UNCLEAR in (a[row[0]], b[row[0]]) for row in real)
differ = sum(a[row[0]] != b[row[0]] for row in real)
print(f"真实病例 {len(real)} 例:通过 {sum(passed)},不通过 {len(real) - sum(passed)}")
print(f"其中有人判「看不清」{unclear} 例,两人判定不一致 {differ} 例")
return 0
def main(argv: list[str]) -> None:
parser = argparse.ArgumentParser(description="配准的目视核对:出图、计分")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("render", help="出图").add_argument("--config", type=Path, required=True)
commands.add_parser("score", help="计分").add_argument("--run-dir", type=Path, required=True)
args = parser.parse_args(argv)
if args.command == "render":
render(args.config)
elif score(args.run_dir) != 0:
raise SystemExit(1)
if __name__ == "__main__":
main(sys.argv[1:])
+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,