Files
CADx-4/tests/test_ceus_polygons.py
T
iomgaa 00ebbffb28 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>
2026-10-16 06:47:27 -04:00

509 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为配准得到的平移 `offset`,
去掉旧的有效区矩形和映射锚点参数。旧用例里两个视图左上角相同,平移取 (0, 0),断言不变)。
旧标题:B6 polygons.py 的单元测试:mask→平滑多边形 + 位置约束 + 绿框先验融合。
参考 ``references/ROI/roi_tracking_pipeline.py`` P:649-965。覆盖核心真实场景
(边缘场景靠实现内 assertion / 守卫,不在此堆假数据):
- ``masks_to_smoothed_polygons``:圆形 mask → (N, 64, 2) 平滑多边形;**空 mask 帧
保留在序列**(§5 帧对齐不变量,N 帧进 N 帧出),且其多边形由时间插值得到(非空)。
- ``apply_prompt_frame_position_constraints``(strength=1.0):把每帧质心拉到 keyframe
中心插值轨迹上(整体平移),**不改面积**;``enabled=False`` 原样返回。
- ``fuse_prompt_prior_with_medsam``:``source != "prompt_prior"`` 门关 → 直接返回纯
medsam;门开时 keyframe 帧用 ``keyframe_shape_strength`` 逐顶点混合,且面积被
收紧到 ``[min, max] × prior_area``(findings §3.7)。
"""
from dataclasses import dataclass
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
masks_to_smoothed_polygons,
polygon_area,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
@dataclass
class _RawShapeCfg:
"""绕过 RoiShapeConfig 校验器的鸭子接口(直接喂超界 strength,验证函数内部 clip)。"""
source: str = "prompt_prior"
keyframe_shape_strength: float = 1.0
propagation_shape_strength: float = 0.75
min_area_ratio_to_prompt: float = 0.70
max_area_ratio_to_prompt: float = 1.25
@dataclass
class _RawConstraintCfg:
"""绕过 KeyframePositionConstraint 校验器(Literal mode + strength clip)的鸭子接口。"""
enabled: bool = True
mode: str = "center_interpolation"
strength: float = 1.0
def _disk_mask(height: int, width: int, center_xy: tuple[int, int], radius: int) -> np.ndarray:
"""生成 ``(height, width)`` 的圆盘二值 mask(前景为 True),供轮廓提取测试用。"""
canvas = np.zeros((height, width), dtype=np.uint8)
cv2.circle(canvas, (int(center_xy[0]), int(center_xy[1])), int(radius), 255, -1)
return canvas > 0
def _circle_polygon(center_xy: tuple[float, float], radius: float, num_points: int) -> np.ndarray:
"""生成绕指定中心的规则圆多边形 ``(num_points, 2)``(用作 medsam / prior 测试输入)。"""
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
poly = np.zeros((num_points, 2), dtype=np.float32)
poly[:, 0] = center_xy[0] + radius * np.cos(angles)
poly[:, 1] = center_xy[1] + radius * np.sin(angles)
return poly
def test_masks_to_smoothed_polygons_shape_and_frame_alignment() -> None:
"""圆盘 mask → (N, 64, 2);中间空 mask 帧保留在序列(§5 帧对齐不变量)。"""
frame_count = 5
contour_points = _CONFIG.smoothing.contour_points # 64
masks_by_frame = {
0: _disk_mask(120, 120, (60, 60), 20),
1: _disk_mask(120, 120, (62, 60), 20),
# frame 2 空(退化 / 漏检)——不得剔除,靠时间插值填补
3: _disk_mask(120, 120, (64, 60), 20),
4: _disk_mask(120, 120, (66, 60), 20),
}
polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, _CONFIG.smoothing)
assert len(polygons) == frame_count, "N 帧进必须 N 帧出(§5 帧对齐,不剔除空帧)。"
for poly in polygons:
assert poly.shape == (contour_points, 2)
assert poly.dtype == np.float32
# 空帧(index 2)由相邻帧时间插值得到,应为有效非退化多边形
assert polygon_area(polygons[2]) > 1.0, "空 mask 帧应被时间插值填补为有效多边形。"
def test_masks_to_smoothed_polygons_all_empty_raises() -> None:
"""全部帧无有效轮廓时无从插值,直接 RuntimeError(不静默兜底)。"""
import pytest
with pytest.raises(RuntimeError):
masks_to_smoothed_polygons({}, 3, _CONFIG.smoothing)
def test_position_constraint_pulls_center_to_keyframe_track_without_area_change() -> None:
"""strength=1.0 把每帧质心拉到 keyframe 中心插值轨迹,且整体平移不改面积。"""
target_eff = ViewRect(0, 0, 200, 200)
offset = (0, 0)
# 两个 keyframe:source mask 中心分别在 (40, 100) 与 (160, 100)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 4, "mask": _disk_mask(200, 200, (160, 100), 15)},
]
# 当前多边形都偏在 (100, 30),期望被拉到插值中心轨迹(x 从 40 线性到 160)
polygons = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(5)]
areas_before = [polygon_area(poly) for poly in polygons]
corrected = apply_prompt_frame_position_constraints(
polygons,
keyframes,
offset,
target_eff,
_CONFIG.constraints.keyframe_position, # enabled、center_interpolation、strength=1.0
)
assert len(corrected) == len(polygons)
# frame 0 质心 ≈ (40, 100),frame 4 ≈ (160, 100),frame 2 ≈ (100, 100)(线性插值)
expected_x = [40.0, 70.0, 100.0, 130.0, 160.0]
for frame_idx, poly in enumerate(corrected):
center = poly.mean(axis=0)
assert abs(float(center[0]) - expected_x[frame_idx]) < 3.0
assert abs(float(center[1]) - 100.0) < 3.0
# 整体平移:面积不变
for before, poly in zip(areas_before, corrected, strict=True):
assert abs(polygon_area(poly) - before) < 2.0
def test_position_constraint_disabled_returns_input() -> None:
"""enabled=False 时原样返回输入(门关)。"""
target_eff = 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,
offset,
target_eff,
_CONFIG.constraints.keyframe_position.model_copy(update={"enabled": False}),
)
assert out is polygons
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)
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,
offset,
target_eff,
_CONFIG.roi_shape.model_copy(update={"source": "medsam2"}),
_CONFIG.smoothing,
)
assert out is medsam
def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
"""keyframe 帧 strength=1.0:融合结果几何上贴近绿框先验(逐顶点混合 s=1)。"""
target_eff = 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)}]
medsam = [_circle_polygon((130.0, 130.0), 30.0, 64)]
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
_CONFIG.roi_shape.model_copy(update={"keyframe_shape_strength": 1.0}),
_CONFIG.smoothing,
)
assert len(fused) == 1
center = fused[0].mean(axis=0)
# s=1 → 结果应贴近先验中心 (100, 100) 而非 medsam 中心 (130, 130)
assert abs(float(center[0]) - 100.0) < 8.0
assert abs(float(center[1]) - 100.0) < 8.0
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)
offset = (0, 0)
# 关键帧只在 frame 0;frame 1 为传播帧(非 keyframe),用 propagation_shape_strength=0
# 让 medsam 形状主导,从而触发面积收紧。先验半径 30,medsam 半径 80(面积远超 1.25×)。
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 30)}]
medsam = [
_circle_polygon((200.0, 200.0), 30.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 80.0, 64), # frame 1(propagation,测收紧)
]
shape_cfg = _CONFIG.roi_shape.model_copy(
update={
"keyframe_shape_strength": 1.0,
"propagation_shape_strength": 0.0, # frame 1 形状 = 纯 medsam(半径80,超界)
"min_area_ratio_to_prompt": 0.70,
"max_area_ratio_to_prompt": 1.25,
}
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# 真实先验面积取自 frame 0(keyframe,s=1 → 形状=离散化绿框先验),避免用解析圆面积失真
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# frame 1 s=0 → 形状=medsam(半径80,面积远超界),应被收紧到 ≈ max_ratio × prior_area
assert fused_area <= prior_area * 1.25 + 50.0, "超界面积应被收紧到 max_area_ratio。"
assert fused_area >= prior_area * 1.25 - 100.0, "应恰好收到上界附近,而非过度收缩。"
# Phase 4b: fuse_prompt_prior_with_medsam —— 关键证伪路径
def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
"""非 keyframe 帧 + 0<s<1:未触发面积收紧时逐顶点 ≈ medsam·(1−s) + prior·s。
构造 medsam 与 prior 半径相近(面积比落在 [min, max] 带内,不触发收紧),中心同位,
使逐顶点线性混合的几何结果可被精确预测(绕开起点对齐 / 收紧的扰动)。
"""
target_eff = ViewRect(0, 0, 400, 400)
offset = (0, 0)
# 单关键帧(frame 0),frame 1 为传播帧;先验半径 50,medsam 半径 60(面积比≈1.44,
# 但 max_ratio 放宽到 2.0 → 不触发收紧),中心同在 (200, 200)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 50)}]
medsam = [
_circle_polygon((200.0, 200.0), 50.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 1(传播帧,测线性混合)
]
strength = 0.5
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,确保 frame 1 不触发面积收紧
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# frame 1 半径 ≈ medsam·(1−s) + prior·s。prior 取自 frame 0(离散化绿框,s=1)。
# 用面积反推等效半径,校验落在 medsam(60) 与 prior(≈50) 之间且约为 0.5 混合。
prior_area = polygon_area(fused[0])
prior_radius = float(np.sqrt(prior_area / np.pi))
fused_radius = float(np.sqrt(polygon_area(fused[1]) / np.pi))
expected_radius = 60.0 * (1.0 - strength) + prior_radius * strength
assert abs(fused_radius - expected_radius) < 3.0, (
f"传播帧应为线性混合半径 ≈ {expected_radius:.1f},实得 {fused_radius:.1f}。"
)
# 逐顶点直接证伪(非聚合面积):每个融合顶点到圆心的半径都应 ≈ 混合半径,
# 而不仅是整体面积匹配——bbox 整体插值或部分混合会在个别顶点暴露偏差。
center = np.array([200.0, 200.0], dtype=np.float64)
radii = np.linalg.norm(fused[1].astype(np.float64) - center, axis=1)
assert radii.shape == (64,)
assert np.all(np.abs(radii - expected_radius) < 3.0), (
f"逐顶点半径应均 ≈ {expected_radius:.1f}(顶点级线性混合),"
f"实得范围 [{radii.min():.1f}, {radii.max():.1f}]。"
)
def test_fusion_lower_bound_tightening_expands_area() -> None:
"""下界收紧:medsam 面积 < min_ratio × prior 面积 → 被放大到 ≈ min_ratio × prior。"""
target_eff = 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)}]
medsam = [
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 20.0, 64), # frame 1(传播帧,面积过小)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.0, # frame 1 形状 = 纯 medsam(半径20,欠界)
min_area_ratio_to_prompt=0.70,
max_area_ratio_to_prompt=1.25,
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 欠界面积应被放大到 ≈ min_ratio × prior_area(绕先验质心 sqrt 缩放)
assert fused_area >= prior_area * 0.70 - 100.0, "欠界面积应被放大到 min_area_ratio。"
assert fused_area <= prior_area * 0.70 + 200.0, "应恰好放大到下界附近,而非过度膨胀。"
def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
"""medsam 与 prior 起点错位但形状相同:融合后不因起点错位畸变(输出仍贴近该形状)。
把 medsam 顶点循环移位(起点错位)但形状 / 中心与 prior 完全一致,s=0.5:
若不先对齐起点,逐顶点混合会把不同角度的点平均出来导致半径塌缩;align 后应保形。
"""
target_eff = ViewRect(0, 0, 400, 400)
offset = (0, 0)
radius = 50.0
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), int(radius))}]
base_circle = _circle_polygon((200.0, 200.0), radius, 64)
shifted_circle = np.roll(base_circle, 17, axis=0) # 起点错位 17 个顶点
medsam = [
_circle_polygon((200.0, 200.0), radius, 64), # frame 0(keyframe,对照)
shifted_circle, # frame 1(传播帧,起点错位)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.5,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,孤立起点对齐效应
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 起点对齐后形状不塌缩:融合面积应与先验面积相当(错位若未对齐会显著缩小)
assert fused_area == pytest.approx(prior_area, rel=0.10), (
f"起点错位经 align 后应保形(≈{prior_area:.0f}),实得 {fused_area:.0f}。"
)
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)
offset = (0, 0)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 40)}]
medsam = [
_circle_polygon((200.0, 200.0), 40.0, 64), # frame 0(keyframe)
_circle_polygon((260.0, 260.0), 40.0, 64), # frame 1(传播帧,偏移到右下)
]
def _fuse(propagation_strength: float) -> np.ndarray:
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=propagation_strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00,
)
return fuse_prompt_prior_with_medsam(
medsam,
keyframes,
offset,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)[1]
# s=1.5 被 clip 到 1.0 → 形状=纯 prior(中心贴近 (200,200));与显式 1.0 等价
over = _fuse(1.5).mean(axis=0)
one = _fuse(1.0).mean(axis=0)
assert np.allclose(over, one, atol=1.0), "s=1.5 应被 clip 到 1.0(行为等价)。"
# s=−0.5 被 clip 到 0.0 → 形状=纯 medsam(中心贴近 (260,260));与显式 0.0 等价
neg = _fuse(-0.5).mean(axis=0)
zero = _fuse(0.0).mean(axis=0)
assert np.allclose(neg, zero, atol=1.0), "s=−0.5 应被 clip 到 0.0(行为等价)。"
# Phase 5: 时间平滑 + 位置约束补测
def test_temporal_smoothing_damps_per_vertex_jitter_along_time() -> None:
"""逐帧抖动的顶点序列经时间轴三角平滑后抖动被削弱、形状趋稳。
构造一段「真值恒定 + 逐帧高频抖动」的多边形序列(顶点已逐点对应),
masks_to_smoothed_polygons 内的时间平滑应显著降低逐帧位移幅度。
"""
frame_count = 9
base_center = (200, 200)
radius = 30
rng = np.random.default_rng(7)
# 每帧在固定圆心附近抖动 ±4px(mask 级别抖动 → 平滑后中心轨迹应更平稳)
masks_by_frame: dict[int, np.ndarray] = {}
for frame_idx in range(frame_count):
jitter = rng.integers(-4, 5, size=2)
center = (int(base_center[0] + jitter[0]), int(base_center[1] + jitter[1]))
masks_by_frame[frame_idx] = _disk_mask(400, 400, center, radius)
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 5})
)
# 平滑后逐帧质心位移幅度应小于原始抖动尺度(抖动被削弱)
centers = np.array([poly.mean(axis=0) for poly in polygons])
frame_to_frame_shift = np.linalg.norm(np.diff(centers, axis=0), axis=1)
assert frame_to_frame_shift.max() < 4.0, "时间平滑应把逐帧质心抖动削弱到原始 ±4px 尺度以下。"
# 形状趋稳:各帧面积方差小(半径恒定 → 平滑不应制造面积剧烈起伏)
areas = np.array([polygon_area(poly) for poly in polygons])
assert areas.std() / areas.mean() < 0.10, "恒定半径下平滑后面积应稳定。"
def test_temporal_smoothing_no_vertex_crossing_distortion() -> None:
"""相邻帧轮廓起点对齐使逐点对应,时间平滑不产生顶点错位畸变(形状保真)。
两帧同形同位的圆(仅起点天然可能错位),平滑后每帧仍是完整圆而非塌缩折线。
"""
frame_count = 3
masks_by_frame = {idx: _disk_mask(400, 400, (200, 200), 40) for idx in range(frame_count)}
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 3})
)
# 起点对齐 + 平滑后:每帧面积应接近真实圆面积,未因顶点错位塌缩
analytic_area = np.pi * 40.0**2
for poly in polygons:
assert polygon_area(poly) == pytest.approx(analytic_area, rel=0.10), (
"起点对齐后时间平滑应保形,不出现顶点错位导致的面积塌缩。"
)
def test_position_constraint_invalid_mode_raises() -> None:
"""mode != 'center_interpolation' → RuntimeError(当前仅支持该模式,纵深防御)。"""
target_eff = 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)}]
with pytest.raises(RuntimeError):
apply_prompt_frame_position_constraints(
polygons,
keyframes,
offset,
target_eff,
_RawConstraintCfg(mode="affine"),
)
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)
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)},
]
base_polys = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(3)]
def _correct(strength: float) -> np.ndarray:
corrected = apply_prompt_frame_position_constraints(
[poly.copy() for poly in base_polys],
keyframes,
offset,
target_eff,
_RawConstraintCfg(strength=strength),
)
return np.array([poly.mean(axis=0) for poly in corrected])
# s=1.5 → clip 到 1.0(全量拉到 keyframe 轨迹);与显式 1.0 等价
assert np.allclose(_correct(1.5), _correct(1.0), atol=0.5)
# s=−0.5 → clip 到 0.0(不平移,质心保持原 (100, 30));与显式 0.0 等价
over_zero = _correct(-0.5)
explicit_zero = _correct(0.0)
assert np.allclose(over_zero, explicit_zero, atol=0.5)
assert np.allclose(over_zero[:, 1], 30.0, atol=2.0), "s=0 不平移,质心 y 应保持 30。"