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
+85 -34
View File
@@ -10,6 +10,7 @@
"""
import csv
import hashlib
import importlib.util
import json
from collections.abc import Callable, Mapping, Sequence
@@ -26,17 +27,15 @@ import yaml
from cadx.answers import read_aliases
from cadx.build import run_build
from cadx.ceus import ANCHOR_SLOTS, LognormalParameters, evaluate_lognormal
from cadx.ceus import ANCHOR_SLOTS
from cadx.library import list_aliases, read_ultrasound
from conftest import CEUS_YAML
from fake_ceus_video import HEIGHT, LESION, SPLIT, WIDTH, write_video
from fake_delivery import REPO_ROOT, make_delivery, write_config
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_quant.py"
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
_FPS, _FRAMES = 10.0, 400
_LESION = (250, 60, 369, 159) # 整帧坐标 x1, y1, x2, y2(含端点)
_CYST = (290, 95, 320, 125)
_BOLUS = LognormalParameters(area=1300.0, mu=2.4, sigma=0.45, arrival_offset_s=4.0, baseline=40.0)
# review.csv 的列,按任务书 02「配准的目视核对」第 3 步独立写,不取脚本里的常量。
_REVIEW_HEADER = ("video_sha256", "dx", "dy", "passed", "verdict_a", "verdict_b")
def _load_script() -> ModuleType:
@@ -47,33 +46,14 @@ def _load_script() -> ModuleType:
return module
def _write_video(path: Path, green_box: bool) -> None:
rng = np.random.default_rng(3)
intensity = evaluate_lognormal(np.arange(_FRAMES) / _FPS, _BOLUS)
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), _FPS, (_WIDTH, _HEIGHT))
x1, y1, x2, y2 = _LESION
for index in range(_FRAMES):
frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8)
frame[:, :_SPLIT] = 120
frame[:, _SPLIT + 1 :] = 30
lesion = np.clip(
intensity[index] + rng.normal(0.0, 6.0, (y2 - y1 + 1, x2 - x1 + 1)), 0, 255
)
frame[y1 : y2 + 1, x1 : x2 + 1] = lesion.astype(np.uint8)[:, :, None]
cx1, cy1, cx2, cy2 = _CYST
frame[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10
if green_box:
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
def _config_dict(tmp_path: Path, delivery_dir: Path) -> dict[str, Any]:
document: dict[str, Any] = yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))
document["paths"] = {
"delivery_dir": str(delivery_dir),
"runs_root": str(tmp_path / "runs"),
"work_root": str(tmp_path / "work"),
"align_runs_root": str(tmp_path / "runs_align"),
"align_review": str(tmp_path / "review.csv"),
}
checkpoint = tmp_path / "fake_checkpoint.pt"
checkpoint.write_bytes(b"fake weights")
@@ -89,6 +69,17 @@ def _write_config(tmp_path: Path, document: dict[str, Any], name: str) -> Path:
return path
def _write_review(document: dict[str, Any], entries: dict[str, tuple[int, int, bool]]) -> None:
"""按视频号写配准目视核对表:(dx, dy, 是否通过),视频 SHA-256 现算。"""
videos = Path(document["paths"]["delivery_dir"]) / "CEUS_agent"
lines = [",".join(_REVIEW_HEADER)]
for video_id, (dx, dy, passed) in entries.items():
video = videos / f"{int(video_id[1:]):03d}" / f"{video_id}_roi2.mp4"
sha = hashlib.sha256(video.read_bytes()).hexdigest()
lines.append(f"{sha},{dx},{dy},{str(passed).lower()},对齐,对齐")
Path(document["paths"]["align_review"]).write_text("\n".join(lines) + "\n", encoding="utf-8")
class _CountingSegment:
"""假分割:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。"""
@@ -104,8 +95,8 @@ class _CountingSegment:
self.calls += 1
height, width = frames[0].shape[:2]
masks = np.zeros((len(frames), height, width), dtype=bool)
x1, y1, x2, y2 = _LESION
masks[:, y1 + 3 : y2 - 2, x1 - _SPLIT + 3 : x2 - _SPLIT - 2] = True
x1, y1, x2, y2 = LESION
masks[:, y1 + 3 : y2 - 2, x1 - SPLIT + 3 : x2 - SPLIT - 2] = True
return masks
@@ -115,8 +106,8 @@ def setup(
) -> tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]]:
"""写假交付包(两例真视频),载入脚本并换掉分割、MedSAM2 提交号和运行号。"""
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
_write_video(delivery_dir / "CEUS_agent" / "001" / "U001_roi2.mp4", green_box=True)
_write_video(delivery_dir / "CEUS_agent" / "002" / "U002_roi2.mp4", green_box=False)
write_video(delivery_dir / "CEUS_agent" / "001" / "U001_roi2.mp4", green_box=True)
write_video(delivery_dir / "CEUS_agent" / "002" / "U002_roi2.mp4", green_box=False)
module = _load_script()
segment = _CountingSegment()
monkeypatch.setattr(module, "segment_video", segment)
@@ -130,7 +121,10 @@ def setup(
monkeypatch.setattr(module, "medsam2_commit", fake_commit)
monkeypatch.setattr(module, "make_build_id", fake_run_id)
return module, segment, _config_dict(tmp_path, delivery_dir), run_ids.append
document = _config_dict(tmp_path, delivery_dir)
# 两段合成视频两半扇形对齐,配准估出 (0, 0)(test_ceus_pipeline 断言过),目视核对都通过。
_write_review(document, {"U001": (0, 0, True), "U002": (0, 0, True)})
return module, segment, document, run_ids.append
def test_run_writes_outputs_and_failed_case(
@@ -157,6 +151,8 @@ def test_run_writes_outputs_and_failed_case(
assert (case_dir / name).stat().st_size > 0, name
summary = json.loads((case_dir / "summary.json").read_text(encoding="utf-8"))
assert summary["video_id"] == "U001"
assert summary["registration"]["contrast_side"] == "right"
assert summary["registration"]["contrast_to_target_offset"] == [0, 0]
assert summary["injection_offset_s"] == 5.0
assert list(summary["anchor_frames"]) == list(ANCHOR_SLOTS)
assert summary["perfusion_metrics"]["roi_perf"] is not None
@@ -167,10 +163,11 @@ def test_run_writes_outputs_and_failed_case(
keyframes = {p.stem for p in (case_dir / "keyframes").iterdir()}
assert keyframes == located
image = cv2.imread(str(case_dir / "keyframes" / "F1.png"))
assert image is not None and image.shape == (_HEIGHT, _WIDTH, 3) # 整幅原始画面
assert image is not None and image.shape == (HEIGHT, WIDTH, 3) # 整幅原始画面
snapshot = run_dir / "snapshot"
assert (snapshot / "ceus.yaml").read_bytes() == (tmp_path / "ceus.yaml").read_bytes()
assert (snapshot / "align_review.csv").read_bytes() == (tmp_path / "review.csv").read_bytes()
segment_record = json.loads((snapshot / "segment.json").read_text(encoding="utf-8"))
assert segment_record["medsam2_commit"] == "c" * 40
assert segment_record["cuda_peak_memory_bytes"] is None # device 是 cpu
@@ -220,7 +217,8 @@ def test_unreadable_video_fails_at_step_1_and_next_case_runs(
# 第 1 例换成损坏的视频(cv2 打不开),第 2 例换成正常视频。
videos = Path(document["paths"]["delivery_dir"]) / "CEUS_agent"
(videos / "001" / "U001_roi2.mp4").write_bytes(b"not a real video")
_write_video(videos / "002" / "U002_roi2.mp4", green_box=True)
write_video(videos / "002" / "U002_roi2.mp4", green_box=True)
_write_review(document, {"U002": (0, 0, True)}) # 第 1 步失败的视频不进核对表
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
@@ -234,6 +232,56 @@ def test_unreadable_video_fails_at_step_1_and_next_case_runs(
assert segment.calls == 1
def test_review_rejected_case_fails_at_step_1(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
"""目视核对没通过的病例第 1 步失败、不调分割;另一例照常(这里是第 2 步失败)。"""
module, segment, document, next_run_id = setup
_write_review(document, {"U001": (0, 0, False), "U002": (0, 0, True)})
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
with (tmp_path / "runs" / "run-a" / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
assert rows[1] == ["U001", "1", "配准目视核对未通过"]
assert rows[2][:2] == ["U002", "2"]
assert segment.calls == 0
@pytest.mark.parametrize(
("entries", "message"),
[
({"U002": (0, 0, True)}, "没有这段视频"), # U001 不在表里
({"U001": (3, 0, True), "U002": (0, 0, True)}, "不同"), # 表里的平移与这次算出的不同
],
)
def test_review_table_mismatch_stops_run(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
entries: dict[str, tuple[int, int, bool]],
message: str,
) -> None:
module, segment, document, next_run_id = setup
_write_review(document, entries)
next_run_id("run-a")
with pytest.raises(RuntimeError, match=message):
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
assert not (tmp_path / "runs" / "run-a").exists()
assert segment.calls == 0
def test_missing_review_file_stops_run(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
module, _, document, next_run_id = setup
Path(document["paths"]["align_review"]).unlink()
next_run_id("run-a")
with pytest.raises(FileNotFoundError):
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
def test_segment_cache_reused_and_keyed_on_step_1_3_params(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
@@ -292,6 +340,9 @@ def _key(module: ModuleType, document: dict[str, Any]) -> str:
("ceus", ("views", "split_search_band"), [0.3, 0.7], True),
("ceus", ("prompt_source", "min_area"), 9000, True),
("ceus", ("keyframes", "iou_threshold"), 0.9, True),
("ceus", ("registration", "fan_std_threshold"), 5.0, True), # 第 1 步配准
("ceus", ("registration", "max_disagreement_px"), 9, True),
("ceus", ("pan_source", "hsv_lower"), [120, 60, 60], True), # 配准时修补紫圈颜色
("segment", ("sam2_config",), "configs/other.yaml", True),
("segment", ("checkpoint",), "models/other.pt", True),
("segment", ("device",), "cuda:1", False),