"""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 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。"