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