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
+57
View File
@@ -0,0 +1,57 @@
"""测试用合成 CEUS 视频:400×200、10 fps、40 秒的分屏造影视频(test_ceus_pipeline、test_ceus_quant、
test_ceus_align_review 共用)。
两半各有一块位置相同、带纹理、随时间轻微起伏的扇形区(`fake_split_scene`,配准平移 (0, 0))。
造影画面在右半:病灶是整帧 `LESION` 方块,亮度按 Lognormal 团注曲线 `BOLUS` 变化,内部有一块一直
发黑的囊 `CYST`;`green_box` 为真时画医生的绿框(病灶的空心描边)。
"""
from pathlib import Path
import cv2
import numpy as np
from cadx.ceus import LognormalParameters, evaluate_lognormal
from fake_split_scene import make_scene, scene_gray, to_bgr
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)
def write_video(path: Path, *, green_box: bool) -> None:
rng = np.random.default_rng(3)
intensity = evaluate_lognormal(np.arange(FRAMES) / FPS, BOLUS)
# 两半是同一幅带纹理的扇形,平移 (0, 0):配准应估出 (0, 0),绿框原位挪到左半。
scene = make_scene(
height=HEIGHT,
half_width=SPLIT,
fan_polygon=[(30, 22), (170, 22), (197, 192), (3, 192)],
offset=(0, 0),
interior_shift=None,
texture_std=20.0,
# 造影那一半调暗(扇形内平均约 32,与改动前的 30 相当):mp4 压缩把绿框描边的颜色渗到
# 相邻像素,背景亮时渗出的像素更容易落进绿框的 HSV 范围,绿框会被认大。
right_gain=0.15,
right_bias=0.0,
seed=7,
)
x1, y1, x2, y2 = LESION
# 病灶里保留组织纹理(减去它在病灶方块内的均值),病灶平均亮度仍等于团注曲线。
detail = scene.right[y1 : y2 + 1, x1 - SPLIT : x2 - SPLIT + 1]
detail = detail - detail.mean()
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), FPS, (WIDTH, HEIGHT))
for index in range(FRAMES):
gray = scene_gray(scene, index, rng)
gray[:, SPLIT] = 0 # 分屏缝
lesion = intensity[index] + detail + rng.normal(0.0, 6.0, detail.shape)
gray[y1 : y2 + 1, x1 : x2 + 1] = lesion
cx1, cy1, cx2, cy2 = CYST
gray[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10
frame = np.ascontiguousarray(to_bgr(gray))
if green_box:
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
+92
View File
@@ -0,0 +1,92 @@
"""测试用合成分屏画面:左右两半各有一块带纹理的扇形区,右半内容是左半平移 `(dx, dy)` 后的样子。
模仿真实录像里配准要用到的几样东西:扇形区里的组织纹理随时间轻微起伏(扇形外的界面是静止的),
两半的成像亮度不同(右半扇形内做一次线性变换 `gain × 灰度 + bias`),各帧噪声独立。
- `right_local(x, y) = left_local(x − dx, y − dy)`:右半局部坐标 = 左半局部坐标 + (dx, dy);
- `interior_shift` 不为 None 时,右半扇形的轮廓仍按 `offset` 平移,里面的纹理却按 `interior_shift`
平移,用来造「扇形轮廓和组织纹理给出不同平移」的画面。
"""
from dataclasses import dataclass
import cv2
import numpy as np
import numpy.typing as npt
type Gray = npt.NDArray[np.float32]
OUTSIDE = 20.0 # 扇形外静止界面的灰度
BASE = 100.0 # 扇形内的平均灰度
@dataclass(frozen=True)
class SplitScene:
"""一幅分屏画面的静止部分:两半的灰度内容和扇形掩膜(各自局部坐标)。"""
left: Gray
right: Gray
left_fan: npt.NDArray[np.bool_]
right_fan: npt.NDArray[np.bool_]
def _fan(shape: tuple[int, int], polygon: list[tuple[int, int]], shift: tuple[int, int]) -> Gray:
points = np.asarray(polygon, dtype=np.int32) + np.asarray(shift, dtype=np.int32)[None, :]
mask = np.zeros(shape, dtype=np.uint8)
cv2.fillConvexPoly(mask, points, 1)
return mask.astype(np.float32)
def make_scene(
*,
height: int,
half_width: int,
fan_polygon: list[tuple[int, int]],
offset: tuple[int, int],
interior_shift: tuple[int, int] | None,
texture_std: float,
right_gain: float,
right_bias: float,
seed: int,
) -> SplitScene:
"""左半扇形取 `fan_polygon`(左半局部坐标),右半按 `offset` 平移;纹理是平滑过的随机场。"""
rng = np.random.default_rng(seed)
margin = 64
canvas = cv2.GaussianBlur(
rng.normal(0.0, 1.0, (height + 2 * margin, half_width + 2 * margin)).astype(np.float32),
(0, 0),
4.0,
)
canvas *= texture_std / float(canvas.std())
shape = (height, half_width)
left_texture = canvas[margin : margin + height, margin : margin + half_width]
tdx, tdy = offset if interior_shift is None else interior_shift
right_texture = canvas[
margin - tdy : margin - tdy + height, margin - tdx : margin - tdx + half_width
]
left_fan = _fan(shape, fan_polygon, (0, 0))
right_fan = _fan(shape, fan_polygon, offset)
left = OUTSIDE + left_fan * (BASE - OUTSIDE + left_texture)
# 右半是另一种成像:同一组织,亮度做一次线性变换。
right = OUTSIDE + right_fan * (right_gain * (BASE - OUTSIDE + right_texture) + right_bias)
return SplitScene(
left=left.astype(np.float32),
right=right.astype(np.float32),
left_fan=left_fan > 0,
right_fan=right_fan > 0,
)
def scene_gray(scene: SplitScene, index: int, rng: np.random.Generator) -> Gray:
"""第 `index` 帧的整帧灰度(左右拼接):扇形内加缓慢起伏和独立噪声,扇形外不动。"""
wave = 10.0 * np.sin(2 * np.pi * index / 7.0)
halves: list[Gray] = []
for content, fan in ((scene.left, scene.left_fan), (scene.right, scene.right_fan)):
noise = rng.normal(0.0, 3.0, content.shape).astype(np.float32)
halves.append(content + fan * (wave + noise))
return np.hstack(halves)
def to_bgr(gray: Gray) -> npt.NDArray[np.uint8]:
"""灰度裁到 0–255 转成三通道 uint8。"""
return np.repeat(np.clip(gray, 0, 255).astype(np.uint8)[:, :, None], 3, axis=2)
+196
View File
@@ -0,0 +1,196 @@
"""`scripts/ceus_align_review.py`:配准目视核对的出图和计分(任务书 02「配准的目视核对」)。
出图:假交付包两例合成分屏视频(`fake_ceus_video`,两半扇形对齐,配准 (0, 0)),对照取 1 例。
计分:key.csv 和两份判读手写,预期按任务书的规则手算。表头按任务书和协调者给的列名独立写。
"""
import csv
import hashlib
import importlib.util
import math
import re
from pathlib import Path
from types import ModuleType
from typing import Any
import cv2
import pytest
import yaml
from conftest import CEUS_YAML
from fake_ceus_video import write_video
from fake_delivery import make_delivery
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_align_review.py"
_KEY_HEADER = ["id", "video_id", "is_control", "dx", "dy", "video_sha256"]
_VERDICT_HEADER = ["编号", "判定", "依据"]
_REVIEW_HEADER = ["video_sha256", "dx", "dy", "passed", "verdict_a", "verdict_b"]
def _load_script() -> ModuleType:
spec = importlib.util.spec_from_file_location("ceus_align_review", _SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _read(path: Path) -> list[list[str]]:
with path.open(encoding="utf-8", newline="") as handle:
return list(csv.reader(handle))
def _write(path: Path, rows: list[list[str]]) -> None:
with path.open("w", encoding="utf-8", newline="") as handle:
csv.writer(handle).writerows(rows)
def test_render_writes_shuffled_images_controls_and_key(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
videos = {
"U001": delivery_dir / "CEUS_agent" / "001" / "U001_roi2.mp4",
"U002": delivery_dir / "CEUS_agent" / "002" / "U002_roi2.mp4",
}
write_video(videos["U001"], green_box=True)
write_video(videos["U002"], green_box=False) # 第 2 步才会失败,第 1 步照常出图
document: dict[str, Any] = yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))
document["paths"].update(
delivery_dir=str(delivery_dir),
align_runs_root=str(tmp_path / "align"),
align_review=str(tmp_path / "unused_review.csv"), # 出图用不到,文件不存在也行
)
document["align_check"]["control_count"] = 1
config = tmp_path / "ceus.yaml"
config.write_text(yaml.safe_dump(document, allow_unicode=True), encoding="utf-8")
module = _load_script()
def fake_run_id(now: object, git: object) -> str:
return "run-a"
monkeypatch.setattr(module, "make_build_id", fake_run_id)
module.main(["render", "--config", str(config)])
run_dir = tmp_path / "align" / "run-a"
assert not (tmp_path / "align" / "run-a.partial").exists()
key = _read(run_dir / "key.csv")
assert key[0] == _KEY_HEADER
rows = key[1:]
# 2 例真实 + 1 例对照,编号是 001–003 的一个排列,每个编号两张图。
assert sorted(row[0] for row in rows) == ["001", "002", "003"]
names = sorted(p.name for p in (run_dir / "images").iterdir())
assert names == sorted(f"{row[0]}_{kind}.png" for row in rows for kind in ("checker", "edges"))
assert all(re.fullmatch(r"\d{3}_(checker|edges)\.png", name) for name in names)
real = [row for row in rows if row[2] == "false"]
controls = [row for row in rows if row[2] == "true"]
assert sorted(row[1] for row in real) == ["U001", "U002"]
assert [(row[3], row[4]) for row in real] == [("0", "0"), ("0", "0")]
(control,) = controls
# 对照的平移 = 真实配准 (0, 0) + 长度 15–30 像素的随机错开(取整后容差 1 像素)。
assert 14 <= math.hypot(int(control[3]), int(control[4])) <= 31
for row in rows:
assert row[5] == hashlib.sha256(videos[row[1]].read_bytes()).hexdigest()
checker = cv2.imread(str(run_dir / "images" / f"{control[0]}_checker.png"))
edges = cv2.imread(str(run_dir / "images" / f"{control[0]}_edges.png"))
assert checker is not None and edges is not None and checker.shape == edges.shape
assert _read(run_dir / "failed.csv") == [["video_id", "step", "reason"]]
assert (run_dir / "snapshot" / "ceus.yaml").read_bytes() == config.read_bytes()
out = capsys.readouterr().out
assert "U001" not in out and "U002" not in out
# 手写的 key.csv:两例真实(001、003)、一例对照(002)。
_KEY = [
_KEY_HEADER,
["001", "V1", "false", "-24", "0", "a" * 64],
["002", "V1", "true", "-4", "17", "a" * 64],
["003", "V2", "false", "183", "-2", "b" * 64],
]
def _score(module: ModuleType, run_dir: Path, a: list[list[str]], b: list[list[str]]) -> None:
_write(run_dir / "key.csv", _KEY)
_write(run_dir / "verdicts_a.csv", [_VERDICT_HEADER, *a])
_write(run_dir / "verdicts_b.csv", [_VERDICT_HEADER, *b])
module.main(["score", "--run-dir", str(run_dir)])
def test_score_writes_review_when_controls_caught(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""对照两人都判「没对齐」;001 两人都判对齐 → 通过,003 有一人看不清 → 不通过。"""
module = _load_script()
a = [["001", "对齐", "血管连续"], ["002", "没对齐", "边缘错开"], ["003", "对齐", "扇形重合"]]
b = [["001", "对齐", "血管连续"], ["002", "没对齐", "边缘错开"], ["003", "看不清", "太暗"]]
_score(module, tmp_path, a, b)
assert _read(tmp_path / "review.csv") == [
_REVIEW_HEADER,
["a" * 64, "-24", "0", "true", "对齐", "对齐"],
["b" * 64, "183", "-2", "false", "对齐", "看不清"],
]
out = capsys.readouterr().out
assert "通过 1,不通过 1" in out
assert "「看不清」1 例" in out and "不一致 1 例" in out
# ceus_quant 读得回这份表。
review = _load_quant().read_review(tmp_path / "review.csv")
assert review["a" * 64].offset == (-24, 0) and review["a" * 64].passed is True
assert review["b" * 64].passed is False
def _load_quant() -> ModuleType:
spec = importlib.util.spec_from_file_location("ceus_quant", _SCRIPT.parent / "ceus_quant.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize("control_verdict", ["对齐", "看不清"])
def test_score_is_void_when_a_control_is_missed(
tmp_path: Path, capsys: pytest.CaptureFixture[str], control_verdict: str
) -> None:
module = _load_script()
a = [["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "对齐", "x"]]
b = [["001", "对齐", "x"], ["002", control_verdict, "x"], ["003", "对齐", "x"]]
with pytest.raises(SystemExit) as raised:
_score(module, tmp_path, a, b)
assert raised.value.code == 1
assert not (tmp_path / "review.csv").exists()
assert "作废" in capsys.readouterr().out
@pytest.mark.parametrize(
("rows", "message"),
[
([["001", "对齐", "x"], ["002", "没对齐", "x"]], "缺"), # 缺 003
(
[
["001", "对齐", "x"],
["002", "没对齐", "x"],
["003", "对齐", "x"],
["004", "对齐", "x"],
],
"多",
),
([["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "差不多", "x"]], "判定只能是"),
(
[
["001", "对齐", "x"],
["001", "对齐", "x"],
["002", "没对齐", "x"],
["003", "对齐", "x"],
],
"重复",
),
],
)
def test_score_rejects_malformed_verdicts(
tmp_path: Path, rows: list[list[str]], message: str
) -> None:
module = _load_script()
good = [["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "对齐", "x"]]
with pytest.raises(ValueError, match=message):
_score(module, tmp_path, rows, good)
assert not (tmp_path / "review.csv").exists()
+2 -2
View File
@@ -65,8 +65,8 @@ def test_green_box_in_right_half(ceus_config: CeusConfig) -> None:
# 整帧 x=250 在右半局部是 250 − 200 = 50。
prompt = side.prompt_frames[0]
assert abs(prompt.bbox_xyxy[0] - 50) <= 3
# 挪到左半(灰阶那一半):整帧 x = 250 − 200 = 50,局部也是 50。
mapped = map_source_mask_to_target(prompt.mask, side.contrast_view, side.target_view)
# 挪到左半(灰阶那一半),两半扇形对齐、配准平移为 (0, 0):局部 x 仍是 50。
mapped = map_source_mask_to_target(prompt.mask, (0, 0), side.target_view)
xs = np.where(mapped)[1]
assert abs(int(xs.min()) - 50) <= 3
assert abs(int(xs.max()) - 169) <= 3
+65 -72
View File
@@ -1,7 +1,8 @@
"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 02 第一阶段第 1 条)。
"""两半画面之间的坐标换算:造影那一半局部坐标 + 配准平移 (dx, dy) = 另一半局部坐标。
后两条照搬旧 test_ceus_mapping(去掉锚点配置参数,断言不变);其余是新写的手算测试。
旧文件里依赖锚点、有效区或缩放的用例不再成立,已删(见任务书 02 执行报告)。
前两条照搬旧 test_ceus_mapping(去掉锚点配置参数,平移取 (0, 0),断言不变);其余是新写的手算
测试,平移量直接给定(怎么估平移见 test_ceus_registration)。旧文件里依赖锚点、有效区或缩放的
用例不再成立,已删(见任务书 02 执行报告)。
"""
import cv2
@@ -9,15 +10,11 @@ import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
map_source_mask_to_target,
polygon_to_mask,
split_views,
target_polygons_to_source,
view_offset,
)
from conftest import load_ceus_section
def _largest_external_contour(mask: np.ndarray) -> np.ndarray:
@@ -49,14 +46,13 @@ def _bbox(points: np.ndarray) -> tuple[float, float, float, float]:
def test_map_source_mask_to_target_translates_into_target_effective() -> None:
"""source 视图局部 mask 经映射落入 target 有效区局部坐标,形状为 target_eff 尺寸。"""
# source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边)。
source_view = ViewRect(0, 0, 100, 100)
# source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边),平移 (0, 0)。
target_eff = ViewRect(100, 0, 200, 100)
source_mask = np.zeros((100, 100), dtype=bool)
source_mask[30:50, 20:40] = True # source 局部一个方块
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
target_mask = map_source_mask_to_target(source_mask, (0, 0), target_eff)
assert target_mask.dtype == bool
assert target_mask.shape == (target_eff.height, target_eff.width)
@@ -83,97 +79,94 @@ def test_real_roundtrip_same_size_no_offset_restores_polygon() -> None:
# 正向:source 局部多边形 → 栅格化 mask → 映射到 target 有效区局部 mask。
source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width)
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
target_mask = map_source_mask_to_target(source_mask, (0, 0), target_eff)
assert target_mask.sum() > 0
# 从真实 target mask 提最大外轮廓得 target 多边形,再逆向映回 source 视图局部。
target_polygon = _largest_external_contour(target_mask)
[back_to_source] = target_polygons_to_source([target_polygon], source_view, target_eff)
[back_to_source] = target_polygons_to_source([target_polygon], (0, 0), source_view)
# 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤2px)。
np.testing.assert_allclose(_bbox(back_to_source), _bbox(source_polygon), atol=2.0)
def _seam_frames(width: int, height: int, split_x: int) -> list[np.ndarray]:
"""合成几帧左右分屏画面:两半是不同的常量灰度,`split_x` 处一列纯黑缝。"""
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[:, :split_x] = 180
frame[:, split_x + 1 :] = 120
return [frame.copy() for _ in range(5)]
# 三种宽度(40 例里有 5 种),分屏线在正中:右半左上角 x = W/2,平移量手算为 (W/2, 0)。
@pytest.mark.parametrize(
("width", "expected_dx"),
[(1856, 928), (1280, 640), (1024, 512)],
)
def test_view_offset_is_difference_of_view_top_left_corners(width: int, expected_dx: int) -> None:
config = CeusConfig.model_validate(load_ceus_section())
_, left, right = split_views(_seam_frames(width, 200, expected_dx), config.views)
assert view_offset(left, right) == (expected_dx, 0)
assert view_offset(right, left) == (-expected_dx, 0)
@pytest.mark.parametrize(
("width", "split_x", "box_x1", "expected_target_x1"),
("offset", "expected_x", "expected_y"),
[
# 造影在左半:整帧 x=300 的框挪到右半,整帧 x = 300 + 928 = 1228。
(1856, 928, 300, 1228),
# 造影在左半:整帧 x=300 → 300 + 640 = 940。
(1280, 640, 300, 940),
# 造影在左半:整帧 x=100 → 100 + 512 = 612。
(1024, 512, 100, 612),
# 局部方块 x 300..339、y 50..79。平移 (−37, 4):x 263..302、y 54..83。
((-37, 4), (263, 302), (54, 83)),
# 平移 (25, −6):x 325..364、y 44..73。
((25, -6), (325, 364), (44, 73)),
# 平移 (0, 0):原位不动。
((0, 0), (300, 339), (50, 79)),
],
)
def test_mask_moves_by_view_offset_in_frame_coordinates(
width: int, split_x: int, box_x1: int, expected_target_x1: int
def test_mask_moves_by_offset(
offset: tuple[int, int], expected_x: tuple[int, int], expected_y: tuple[int, int]
) -> None:
left = ViewRect(0, 0, split_x, 200)
right = ViewRect(split_x, 0, width, 200)
source_mask = np.zeros((left.height, left.width), dtype=bool)
source_mask[50:80, box_x1 - left.x1 : box_x1 - left.x1 + 40] = True
target = ViewRect(640, 0, 1280, 200)
source_mask = np.zeros((200, 640), dtype=bool)
source_mask[50:80, 300:340] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
target_mask = map_source_mask_to_target(source_mask, offset, target)
assert target_mask.shape == (200, 640)
ys, xs = np.where(target_mask)
assert (int(xs.min()), int(xs.max())) == expected_x
assert (int(ys.min()), int(ys.max())) == expected_y
def test_mask_crops_what_leaves_target_view() -> None:
"""平移 (−310, 0):局部 x 300..339 落到 −10..29,出界的 10 列丢掉,不缩放。"""
target = ViewRect(0, 0, 640, 200)
source_mask = np.zeros((200, 640), dtype=bool)
source_mask[10:20, 300:340] = True
target_mask = map_source_mask_to_target(source_mask, (-310, 0), target)
ys, xs = np.where(target_mask)
assert int(xs.min()) + right.x1 == expected_target_x1
assert int(xs.max()) + right.x1 == expected_target_x1 + 39
assert (int(ys.min()), int(ys.max())) == (50, 79)
def test_mask_moves_from_right_half_to_left_half() -> None:
"""造影在右半:1856 宽、分屏线 928,整帧 x=1000 的框挪到左半,整帧 x = 1000 − 928 = 72。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
source_mask = np.zeros((right.height, right.width), dtype=bool)
source_mask[10:20, 1000 - 928 : 1040 - 928] = True
target_mask = map_source_mask_to_target(source_mask, right, left)
ys, xs = np.where(target_mask)
assert (int(xs.min()), int(xs.max())) == (72, 111)
assert (int(xs.min()), int(xs.max())) == (0, 29)
assert (int(ys.min()), int(ys.max())) == (10, 19)
def test_polygons_move_back_by_negative_view_offset() -> None:
"""右半局部 (40, 30) 的点挪回左半:整帧 (968, 30) − 928 = (40, 30);超出左半的点裁到边界。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
polygon = np.array([[40.0, 30.0], [60.0, 30.0], [927.0, 250.0]], dtype=np.float32)
def test_polygons_move_back_by_negative_offset() -> None:
"""平移 (25, 4):目标局部 (40, 30) 挪回 (15, 26);超出造影视图的点裁到边界。"""
source = ViewRect(0, 0, 928, 200)
polygon = np.array([[40.0, 30.0], [60.0, 30.0], [10.0, 250.0]], dtype=np.float32)
[back] = target_polygons_to_source([polygon], left, right)
[back] = target_polygons_to_source([polygon], (25, 4), source)
np.testing.assert_allclose(back, [[40.0, 30.0], [60.0, 30.0], [927.0, 199.0]])
np.testing.assert_allclose(back, [[15.0, 26.0], [35.0, 26.0], [0.0, 199.0]])
def test_mask_crops_when_target_half_is_narrower() -> None:
"""宽度为奇数时右半比左半窄 1 像素:不缩放,超出右半的那一列裁掉。"""
left = ViewRect(0, 0, 101, 50)
"""宽度为奇数时右半比左半窄 1 像素:平移 (0, 0),不缩放,超出右半的那一列裁掉。"""
right = ViewRect(101, 0, 201, 50)
source_mask = np.zeros((50, 101), dtype=bool)
source_mask[:, 99:101] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
target_mask = map_source_mask_to_target(source_mask, (0, 0), right)
assert target_mask.shape == (50, 100)
assert np.where(target_mask)[1].tolist() == [99] * 50
def test_real_roundtrip_with_offset_restores_polygon() -> None:
"""平移 (−37, 4) 下真往返:多边形 → 栅格化 → 正向 → 提轮廓 → 逆向,近似还原(误差 ≤2px)。"""
source_view = ViewRect(0, 0, 200, 150)
target_view = ViewRect(200, 0, 400, 150)
source_polygon = np.array(
[[60.0, 30.0], [120.0, 25.0], [130.0, 90.0], [70.0, 85.0]], dtype=np.float32
)
source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width)
target_mask = map_source_mask_to_target(source_mask, (-37, 4), target_view)
target_polygon = _largest_external_contour(target_mask)
np.testing.assert_allclose(
_bbox(target_polygon),
np.array(_bbox(source_polygon)) + np.array([-37, -37, 4, 4]),
atol=2.0,
)
[back] = target_polygons_to_source([target_polygon], (-37, 4), source_view)
np.testing.assert_allclose(_bbox(back), _bbox(source_polygon), atol=2.0)
+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(
+24 -24
View File
@@ -1,5 +1,5 @@
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为两个视图矩形,
去掉旧的有效区矩形和映射锚点参数)。
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为配准得到的平移 `offset`,
去掉旧的有效区矩形和映射锚点参数。旧用例里两个视图左上角相同,平移取 (0, 0),断言不变)。
旧标题:B6 polygons.py 的单元测试:mask→平滑多边形 + 位置约束 + 绿框先验融合。
@@ -104,7 +104,7 @@ def test_masks_to_smoothed_polygons_all_empty_raises() -> None:
def test_position_constraint_pulls_center_to_keyframe_track_without_area_change() -> None:
"""strength=1.0 把每帧质心拉到 keyframe 中心插值轨迹,且整体平移不改面积。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
offset = (0, 0)
# 两个 keyframe:source mask 中心分别在 (40, 100) 与 (160, 100)
keyframes = [
@@ -118,7 +118,7 @@ def test_position_constraint_pulls_center_to_keyframe_track_without_area_change(
corrected = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
offset,
target_eff,
_CONFIG.constraints.keyframe_position, # enabled、center_interpolation、strength=1.0
)
@@ -138,14 +138,14 @@ def test_position_constraint_pulls_center_to_keyframe_track_without_area_change(
def test_position_constraint_disabled_returns_input() -> None:
"""enabled=False 时原样返回输入(门关)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
offset = (0, 0)
polygons = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
offset,
target_eff,
_CONFIG.constraints.keyframe_position.model_copy(update={"enabled": False}),
)
@@ -156,14 +156,14 @@ def test_position_constraint_disabled_returns_input() -> None:
def test_fusion_gate_closed_returns_pure_medsam() -> None:
"""roi_shape.source != 'prompt_prior' → 直接返回纯 medsam(门关,findings §3.7)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
offset = (0, 0)
medsam = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
_CONFIG.roi_shape.model_copy(update={"source": "medsam2"}),
_CONFIG.smoothing,
@@ -175,7 +175,7 @@ def test_fusion_gate_closed_returns_pure_medsam() -> None:
def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
"""keyframe 帧 strength=1.0:融合结果几何上贴近绿框先验(逐顶点混合 s=1)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
offset = (0, 0)
# 绿框先验中心 (100, 100) 半径 30;medsam 偏移到 (130, 130) 半径 30
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 30)}]
@@ -184,7 +184,7 @@ def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
_CONFIG.roi_shape.model_copy(update={"keyframe_shape_strength": 1.0}),
_CONFIG.smoothing,
@@ -200,7 +200,7 @@ def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
def test_fusion_area_clamped_to_prior_band() -> None:
"""融合面积超过 max_area_ratio_to_prompt 时按 sqrt 绕先验质心缩放回界(findings §3.7)。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
offset = (0, 0)
# 关键帧只在 frame 0;frame 1 为传播帧(非 keyframe),用 propagation_shape_strength=0
# 让 medsam 形状主导,从而触发面积收紧。先验半径 30,medsam 半径 80(面积远超 1.25×)。
@@ -222,7 +222,7 @@ def test_fusion_area_clamped_to_prior_band() -> None:
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
@@ -246,7 +246,7 @@ def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
使逐顶点线性混合的几何结果可被精确预测(绕开起点对齐 / 收紧的扰动)。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
offset = (0, 0)
# 单关键帧(frame 0),frame 1 为传播帧;先验半径 50,medsam 半径 60(面积比≈1.44,
# 但 max_ratio 放宽到 2.0 → 不触发收紧),中心同在 (200, 200)
@@ -267,7 +267,7 @@ def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
@@ -297,7 +297,7 @@ def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
def test_fusion_lower_bound_tightening_expands_area() -> None:
"""下界收紧:medsam 面积 < min_ratio × prior 面积 → 被放大到 ≈ min_ratio × prior。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
offset = (0, 0)
# 先验半径 60;medsam 传播帧半径 20(面积比≈0.11 < min_ratio 0.70 → 触发下界放大)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 60)}]
@@ -316,7 +316,7 @@ def test_fusion_lower_bound_tightening_expands_area() -> None:
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
@@ -336,7 +336,7 @@ def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
若不先对齐起点,逐顶点混合会把不同角度的点平均出来导致半径塌缩;align 后应保形。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
offset = (0, 0)
radius = 50.0
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), int(radius))}]
@@ -357,7 +357,7 @@ def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
@@ -374,7 +374,7 @@ def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
def test_fusion_propagation_strength_clipped_to_unit_interval() -> None:
"""传播权重越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
offset = (0, 0)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 40)}]
medsam = [
@@ -392,7 +392,7 @@ def test_fusion_propagation_strength_clipped_to_unit_interval() -> None:
return fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
@@ -465,7 +465,7 @@ def test_temporal_smoothing_no_vertex_crossing_distortion() -> None:
def test_position_constraint_invalid_mode_raises() -> None:
"""mode != 'center_interpolation' → RuntimeError(当前仅支持该模式,纵深防御)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
offset = (0, 0)
polygons = [_circle_polygon((100.0, 100.0), 20.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 20)}]
@@ -473,7 +473,7 @@ def test_position_constraint_invalid_mode_raises() -> None:
apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
offset,
target_eff,
_RawConstraintCfg(mode="affine"),
)
@@ -482,7 +482,7 @@ def test_position_constraint_invalid_mode_raises() -> None:
def test_position_constraint_strength_clipped_to_unit_interval() -> None:
"""strength 越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
offset = (0, 0)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 2, "mask": _disk_mask(200, 200, (160, 100), 15)},
@@ -493,7 +493,7 @@ def test_position_constraint_strength_clipped_to_unit_interval() -> None:
corrected = apply_prompt_frame_position_constraints(
[poly.copy() for poly in base_polys],
keyframes,
source_view,
offset,
target_eff,
_RawConstraintCfg(strength=strength),
)
+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),
+144
View File
@@ -0,0 +1,144 @@
"""两半配准:每段视频单独估左半 → 右半的平移(任务书 02 第一阶段第 1 条)。
合成分屏画面(`fake_split_scene`):每半 240×200,左半扇形是一个梯形,右半的扇形和纹理是左半平移
(dx, dy) 后的样子,再换一种亮度、加独立噪声。预期值就是造图时设定的平移,不从实现里推。
"""
import numpy as np
import pytest
from cadx.ceus import (
CeusCaseError,
CeusConfig,
RegistrationError,
ViewRect,
register_halves,
)
from fake_split_scene import SplitScene, make_scene, scene_gray, to_bgr
_HEIGHT, _HALF = 200, 240
_LEFT, _RIGHT = ViewRect(0, 0, _HALF, _HEIGHT), ViewRect(_HALF, 0, 2 * _HALF, _HEIGHT)
# 左半局部坐标的梯形扇形:上窄下宽,顶边在顶部信息栏(200 × 0.0834 ≈ 17 行)之下。
_FAN = [(80, 30), (170, 30), (215, 185), (35, 185)]
def _frames(scene: SplitScene, *, green_box: bool) -> list[np.ndarray]:
rng = np.random.default_rng(11)
frames: list[np.ndarray] = []
for index in range(12):
frame = to_bgr(scene_gray(scene, index, rng))
if green_box:
# 绿框只画在左半:配准前要修补掉,否则它的边缘只在一半上有。
frame[90:140, 100] = frame[90:140, 160] = (0, 255, 0)
frame[90, 100:161] = frame[140, 100:161] = (0, 255, 0)
frames.append(frame)
return frames
@pytest.mark.parametrize(
("offset", "green_box"),
[
((-37, 0), False), # 负的水平偏移(真实视频里最常见:造影那一半扇形左边有黑边)
((25, 4), False), # 正的水平偏移、非零竖直偏移
((-12, -6), True), # 两个分量都为负,左半画着绿框
((0, 0), False),
],
)
def test_known_offset_is_recovered_by_both_methods(
ceus_config: CeusConfig, offset: tuple[int, int], green_box: bool
) -> None:
scene = make_scene(
height=_HEIGHT,
half_width=_HALF,
fan_polygon=_FAN,
offset=offset,
interior_shift=None,
texture_std=25.0,
right_gain=0.8,
right_bias=10.0,
seed=5,
)
registration = register_halves(
_frames(scene, green_box=green_box),
_LEFT,
_RIGHT,
ceus_config.registration,
ceus_config.prompt_source,
ceus_config.pan_source,
)
assert registration.correlation == offset
assert registration.template == offset
assert registration.disagreement_px == 0
# 造影在左半时平移照用;造影在右半时(把右半的东西挪到左半)取反。
assert registration.contrast_to_target("left") == offset
assert registration.contrast_to_target("right") == (-offset[0], -offset[1])
# 冲突画面用更宽的扇形:模板块落在扇形中部,纹理平移后真正的匹配位置仍在右半扇形内。
_WIDE_FAN = [(40, 25), (200, 25), (235, 195), (5, 195)]
def _conflicting_scene() -> SplitScene:
"""扇形轮廓按 (15, 0) 平移,里面的纹理按 (−15, 0) 平移:两种方法各信一样。
纹理起伏(标准差 5)远小于扇形内外的亮度差(80),边缘图上占主导的是扇形轮廓,互相关跟着
轮廓走;模板块整块落在扇形里,只看得到纹理,跟着纹理走。
"""
return make_scene(
height=_HEIGHT,
half_width=_HALF,
fan_polygon=_WIDE_FAN,
offset=(15, 0),
interior_shift=(-15, 0),
texture_std=5.0,
right_gain=0.8,
right_bias=10.0,
seed=5,
)
def test_methods_disagree_on_conflicting_scene(ceus_config: CeusConfig) -> None:
"""门槛放到极大时两种方法各自的估计:互相关跟轮廓 (15, 0),模板匹配跟纹理 (−15, 0)。"""
loose = ceus_config.registration.model_copy(update={"max_disagreement_px": 10_000})
registration = register_halves(
_frames(_conflicting_scene(), green_box=False),
_LEFT,
_RIGHT,
loose,
ceus_config.prompt_source,
ceus_config.pan_source,
)
assert registration.correlation == (15, 0)
assert registration.template == (-15, 0)
assert registration.disagreement_px == 30
def test_disagreement_beyond_threshold_fails_case_at_step_1(ceus_config: CeusConfig) -> None:
with pytest.raises(RegistrationError, match="两半配准不一致") as raised:
register_halves(
_frames(_conflicting_scene(), green_box=False),
_LEFT,
_RIGHT,
ceus_config.registration,
ceus_config.prompt_source,
ceus_config.pan_source,
)
assert isinstance(raised.value, CeusCaseError)
assert raised.value.step == 1
# 原因里附两个估计值。
assert "(15, 0)" in raised.value.reason and "(-15, 0)" in raised.value.reason
def test_static_half_has_no_fan_and_fails(ceus_config: CeusConfig) -> None:
"""一半画面完全不动(没有扇形区):第 1 步失败,不猜一个平移。"""
frames = [np.full((_HEIGHT, 2 * _HALF, 3), 60, dtype=np.uint8) for _ in range(12)]
with pytest.raises(RegistrationError, match="找不到扇形区") as raised:
register_halves(
frames,
_LEFT,
_RIGHT,
ceus_config.registration,
ceus_config.prompt_source,
ceus_config.pan_source,
)
assert raised.value.step == 1