feat(ceus,segment): 任务书 02 第二阶段——分割和后半段算法
- ceus 迁入 polygons、dark_region、reference_region、tic_fitting、tic、anchor_frames、 perfusion_metrics、trajectory、tic_curves、tic_plot、overlay,旧测试照搬(改 import 与配置读取)。 - 对外两段接口:prepare_segmentation(第 1–2 步)与 quantify_case(第 4–9 步)。 - 第 3 条:删掉 D1 取医生标注时刻的模式,D1 只从拟合曲线算。 - 第 4 条:F1/F2 取视频第 20−X、60−X 秒(X 为视频第 0 秒时已推注的秒数),落在视频外记未定位。 - 绿框融合权重、紫圈颜色阈值照搬;关键帧容差窗口的出处写进 ceus.yaml 注释。 - segment:同一进程调用 MedSAM2,设置写进 ceus.yaml 的 segment 段,全部必填;gpu 测试真跑通过。 - 依赖加 scipy、matplotlib、medsam2(可编辑安装,不编译 CUDA 扩展);uv 只解析 linux x86_64。 - 导入契约:只有 segment 引用 torch、sam2;segment 不引用其他 cadx 模块。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为两个视图矩形,
|
||||
去掉旧的有效区矩形和映射锚点参数)。
|
||||
|
||||
旧标题: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)
|
||||
source_view = ViewRect(0, 0, 200, 200)
|
||||
|
||||
# 两个 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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 100, 100)
|
||||
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,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 100, 100)
|
||||
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,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 200, 200)
|
||||
|
||||
# 绿框先验中心 (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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 400, 400)
|
||||
|
||||
# 关键帧只在 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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 400, 400)
|
||||
|
||||
# 单关键帧(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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 400, 400)
|
||||
|
||||
# 先验半径 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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 400, 400)
|
||||
|
||||
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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 400, 400)
|
||||
|
||||
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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 200, 200)
|
||||
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,
|
||||
source_view,
|
||||
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)
|
||||
source_view = ViewRect(0, 0, 200, 200)
|
||||
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,
|
||||
source_view,
|
||||
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。"
|
||||
Reference in New Issue
Block a user