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