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
+18 -42
View File
@@ -3,7 +3,8 @@
视频 400×200、10 fps、40 秒(400 帧),分屏线在 x=200(这一列全黑)。造影画面在右半:病灶是整帧
[250, 369] × [60, 159] 的方块,医生的绿框是它的空心描边;病灶内部亮度按 Lognormal 团注曲线从
基线 40 升到约 170 再回落,内部另有一块一直发黑的囊(暗区);每帧加一点随机噪声,免得相邻帧
被当成重复帧。左半是不变的灰阶画面。
被当成重复帧。两半各有一块位置相同、带纹理、随时间轻微起伏的扇形区(`fake_split_scene`,
配准要靠它),左半是灰阶画面,右半(造影)调暗。
手算的预期:
- 绿框在右半 → 造影画面是右半,分割那一半是左半 [0, 200);
@@ -14,19 +15,15 @@
import json
import math
from pathlib import Path
import cv2
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
CeusConfig,
LognormalParameters,
NoValidContourError,
SegmentationInput,
evaluate_lognormal,
keyframe_images,
pack_tic_curves,
pack_trajectory,
@@ -39,36 +36,11 @@ from cadx.ceus import (
unpack_tic_curves,
unpack_trajectory,
)
from fake_ceus_video import BOLUS, FPS, FRAMES, HEIGHT, LESION, SPLIT, WIDTH, write_video
_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)
_INJECTION_OFFSET_S = 5.0
def _write_video(path: Path) -> None:
rng = np.random.default_rng(3)
times = np.arange(_FRAMES) / _FPS
intensity = evaluate_lognormal(times, _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
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
@pytest.fixture(scope="module")
def config() -> CeusConfig:
from conftest import load_ceus_section
@@ -79,7 +51,7 @@ def config() -> CeusConfig:
@pytest.fixture(scope="module")
def prepared(tmp_path_factory: pytest.TempPathFactory, config: CeusConfig) -> SegmentationInput:
path = tmp_path_factory.mktemp("ceus") / "synthetic.mp4"
_write_video(path)
write_video(path, green_box=True)
return prepare_segmentation(path, config)
@@ -87,21 +59,25 @@ def _lesion_masks(prepared: SegmentationInput) -> np.ndarray:
"""合成「分割结果」:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。"""
view = prepared.contrast.target_view
masks = np.zeros((prepared.frame_count, view.height, view.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
def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
prepared: SegmentationInput,
) -> None:
assert prepared.frame_count == _FRAMES
assert prepared.frame_size == (_WIDTH, _HEIGHT)
assert prepared.frame_count == FRAMES
assert prepared.frame_size == (WIDTH, HEIGHT)
assert prepared.contrast.side == "right"
# 两半扇形对齐:两种配准都估出 (0, 0);造影在右半,平移取反后仍是 (0, 0)。
assert prepared.registration.correlation == (0, 0)
assert prepared.registration.disagreement_px <= 1
assert prepared.offset == (0, 0)
target = prepared.contrast.target_view
assert (target.x1, target.x2) == (0, prepared.split_x)
assert abs(prepared.split_x - _SPLIT) <= 1
assert len(prepared.target_frames) == _FRAMES
assert abs(prepared.split_x - SPLIT) <= 1
assert len(prepared.target_frames) == FRAMES
assert all(frame.shape == (target.height, target.width, 3) for frame in prepared.target_frames)
assert sorted(prepared.prompt_masks) == [p.frame_index for p in prepared.contrast.prompt_frames]
assert 0 in prepared.prompt_masks
@@ -114,7 +90,7 @@ def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfig) -> None:
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
assert result.trajectory.source_polygons.shape == (_FRAMES, 64, 2)
assert result.trajectory.source_polygons.shape == (FRAMES, 64, 2)
assert result.lesion_fit_ready is True
assert list(result.anchors) == list(ANCHOR_SLOTS)
for slot in ("D1", "D2", "D3"):
@@ -124,13 +100,13 @@ def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfi
assert result.anchors["F2"].status == "not_covered"
assert result.anchors["F2"].reason is not None
assert result.perfusion["roi_perf"] is not None
peak_time = _BOLUS.arrival_offset_s + math.exp(_BOLUS.mu - _BOLUS.sigma**2)
peak_time = BOLUS.arrival_offset_s + math.exp(BOLUS.mu - BOLUS.sigma**2)
assert result.perfusion["roi_perf"].ttp_s == pytest.approx(peak_time, abs=0.5)
# 画面上没有紫圈。
assert result.pan_detected_fraction == 0.0
assert result.tic.reference_valid is False
# 暗区(囊)被找出来,去暗区曲线比病灶整体亮。
peak_row = result.tic.rows[round(peak_time * _FPS)]
peak_row = result.tic.rows[round(peak_time * FPS)]
assert peak_row["dark_area_px"] > 0
assert peak_row["roi_perf_raw"] > peak_row["roi_all_raw"]
@@ -150,7 +126,7 @@ def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfi
assert len(render_case_overlay(prepared, result)) > 0
images = keyframe_images(prepared, result.anchors)
assert "F1" in images and "F2" not in images
assert all(image.shape == (_HEIGHT, _WIDTH, 3) for image in images.values())
assert all(image.shape == (HEIGHT, WIDTH, 3) for image in images.values())
def test_empty_segmentation_fails_case_at_step_4(