Files
CADx-4/tests/test_ceus_geometry.py
T
iomgaa 9a916c65a5 feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14
任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:46:56 -04:00

279 lines
9.8 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.
"""B1 geometry 纯函数库单元测试。
钉死几何/重采样/平滑工具的关键性质:IoU 边界、等弧长重采样闭合性、
三角平滑的恒等与偶窗拒绝、奇窗化与可选正整数解析、多边形栅格化 dtype,
以及 ViewRect 的不变量校验。测试用真实数据的二次构造(合成 mask / 正方形轮廓 /
常量轨迹),边缘场景靠实现里的 assertion/raise 保证。
"""
import numpy as np
import pytest
from cadx.ceus import (
ViewRect,
crop_frame,
local_rect_within,
mask_iou,
odd_window,
optional_positive_int,
polygon_area,
polygon_center,
polygon_to_mask,
polygons_to_mask,
rect_from_values,
resample_closed_contour,
triangular_smooth,
)
# Phase 1: mask_iou —— 交并比边界
def test_mask_iou_identical_is_one() -> None:
mask = np.zeros((10, 10), dtype=bool)
mask[2:6, 3:7] = True
assert mask_iou(mask, mask) == 1.0
def test_mask_iou_disjoint_is_zero() -> None:
a = np.zeros((10, 10), dtype=bool)
a[0:3, 0:3] = True
b = np.zeros((10, 10), dtype=bool)
b[6:9, 6:9] = True
assert mask_iou(a, b) == 0.0
def test_mask_iou_empty_union_is_zero() -> None:
empty = np.zeros((5, 5), dtype=bool)
assert mask_iou(empty, empty) == 0.0
def test_mask_iou_partial_overlap() -> None:
a = np.zeros((10, 10), dtype=bool)
a[0:4, 0:4] = True # 16 像素
b = np.zeros((10, 10), dtype=bool)
b[2:6, 2:6] = True # 16 像素,交集 2x2=4,并集 28
assert mask_iou(a, b) == pytest.approx(4 / 28)
# Phase 2: resample_closed_contour —— 等弧长重采样
def _unit_square() -> np.ndarray:
return np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
def test_resample_shape_is_num_points_by_two() -> None:
out = resample_closed_contour(_unit_square(), 64)
assert out is not None
assert out.shape == (64, 2)
def test_resample_equal_arc_length_spacing() -> None:
out = resample_closed_contour(_unit_square(), 64)
assert out is not None
closed = np.vstack([out, out[0]])
seg = np.sqrt((np.diff(closed, axis=0) ** 2).sum(axis=1))
# 等弧长:所有相邻采样段长应近似一致
assert seg.std() < 1e-3
def test_resample_too_few_points_returns_none() -> None:
assert resample_closed_contour(np.array([[0.0, 0.0], [1.0, 1.0]]), 64) is None
assert resample_closed_contour(None, 64) is None
# Phase 3: triangular_smooth —— 时间轴三角加权
def test_triangular_smooth_even_window_raises() -> None:
values = np.zeros((5, 4, 2), dtype=np.float32)
with pytest.raises(ValueError):
triangular_smooth(values, 4)
def test_triangular_smooth_window_one_is_identity() -> None:
rng = np.random.default_rng(0)
values = rng.standard_normal((6, 8, 2)).astype(np.float32)
out = triangular_smooth(values, 1)
assert np.array_equal(out, values)
def test_triangular_smooth_constant_track_unchanged() -> None:
const = np.full((7, 5, 2), 3.5, dtype=np.float32)
out = triangular_smooth(const, 3)
assert np.allclose(out, 3.5)
# Phase 4: odd_window —— 奇窗化
def test_odd_window_even_increments() -> None:
assert odd_window(10) == 11
def test_odd_window_odd_unchanged() -> None:
assert odd_window(7) == 7
def test_odd_window_floor_at_one() -> None:
assert odd_window(0) == 1
assert odd_window(-2) == 1
# Phase 5: optional_positive_int —— 可选正整数解析
def test_optional_positive_int_auto_is_none() -> None:
assert optional_positive_int("auto") is None
def test_optional_positive_int_none_is_none() -> None:
assert optional_positive_int(None) is None
def test_optional_positive_int_negative_is_none() -> None:
assert optional_positive_int(-3) is None
assert optional_positive_int(0) is None
def test_optional_positive_int_positive_parsed() -> None:
assert optional_positive_int(5) == 5
assert optional_positive_int("8") == 8
# Phase 6: polygon_to_mask —— 栅格化
def test_polygon_to_mask_dtype_is_bool() -> None:
poly = _unit_square()
mask = polygon_to_mask(poly, 12, 12)
assert mask.dtype == bool
assert mask.shape == (12, 12)
assert mask.any()
# Phase 6a: polygons_to_mask —— even-odd 一次填充挖洞
def test_polygons_to_mask_carves_hole_via_even_odd() -> None:
"""外框 + 内洞一次 fillPoly:洞中心为 False、外框环上为 True(even-odd 挖洞)。"""
outer = np.array([[5.0, 5.0], [45.0, 5.0], [45.0, 45.0], [5.0, 45.0]])
hole = np.array([[20.0, 20.0], [30.0, 20.0], [30.0, 30.0], [20.0, 30.0]])
mask = polygons_to_mask([outer, hole], 50, 50)
assert mask.dtype == bool
assert not mask[25, 25] # 洞中心:被挖空
assert mask[10, 10] # 外框环上:填充
# 甜甜圈面积 = 外框 40x40 - 洞 10x10(栅格边界差几像素,放宽)
assert mask.sum() == pytest.approx(40 * 40 - 10 * 10, abs=200)
def test_polygons_to_mask_empty_is_all_false() -> None:
"""空列表 / 全退化多边形:返回全 False 掩膜(不报错)。"""
assert not polygons_to_mask([], 20, 20).any()
degenerate = np.array([[1.0, 1.0], [2.0, 2.0]]) # < 3 顶点,跳过
assert not polygons_to_mask([degenerate], 20, 20).any()
# Phase 6b: polygon_area —— 鞋带公式精确面积
def test_polygon_area_square_exact() -> None:
"""边长 10 的正方形面积精确为 100.0(鞋带浮点精确,不取整)。"""
square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
assert polygon_area(square) == pytest.approx(100.0, abs=1e-6)
def test_polygon_area_triangle_exact() -> None:
"""直角三角形(底 8 高 6)面积精确为 24.0。"""
triangle = np.array([[0.0, 0.0], [8.0, 0.0], [0.0, 6.0]], dtype=np.float32)
assert polygon_area(triangle) == pytest.approx(24.0, abs=1e-6)
def test_polygon_area_orientation_independent() -> None:
"""顺时针 / 逆时针同一多边形面积一致(取绝对值消朝向符号)。"""
ccw = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
cw = ccw[::-1].copy()
assert polygon_area(ccw) == pytest.approx(polygon_area(cw), abs=1e-6)
assert polygon_area(cw) == pytest.approx(100.0, abs=1e-6)
def test_polygon_area_non_integer_vertices_not_quantized() -> None:
"""半整数顶点不被取整:边长 10.5 正方形面积 = 110.25(不会被 round 量化)。"""
square = np.array([[0.0, 0.0], [10.5, 0.0], [10.5, 10.5], [0.0, 10.5]], dtype=np.float32)
assert polygon_area(square) == pytest.approx(110.25, abs=1e-4)
# Phase 6c: polygon_center —— 质心(退化时回退顶点均值)
def test_polygon_center_square_is_geometric_center() -> None:
"""正方形质心在几何中心 (5, 5)。"""
square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
center = polygon_center(square)
assert center == pytest.approx(np.array([5.0, 5.0]), abs=1e-3)
def test_polygon_center_rectangle_is_geometric_center() -> None:
"""矩形质心在几何中心 (10, 3)。"""
rect = np.array([[0.0, 0.0], [20.0, 0.0], [20.0, 6.0], [0.0, 6.0]], dtype=np.float32)
center = polygon_center(rect)
assert center == pytest.approx(np.array([10.0, 3.0]), abs=1e-3)
def test_polygon_center_degenerate_collinear_falls_back_to_vertex_mean() -> None:
"""共线顶点 m00≈0:退化路径回退到顶点算术均值,不崩、不除零。"""
collinear = np.array([[0.0, 0.0], [2.0, 2.0], [4.0, 4.0], [6.0, 6.0]], dtype=np.float32)
center = polygon_center(collinear)
assert center == pytest.approx(collinear.mean(axis=0), abs=1e-3)
def test_polygon_center_degenerate_coincident_falls_back_to_vertex_mean() -> None:
"""所有点重合 m00≈0:退化路径回退到该点本身(顶点均值),不除零。"""
coincident = np.full((5, 2), 7.0, dtype=np.float32)
center = polygon_center(coincident)
assert center == pytest.approx(np.array([7.0, 7.0]), abs=1e-3)
# Phase 7: ViewRect / crop_frame / local_rect_within / rect_from_values
def test_view_rect_valid() -> None:
rect = ViewRect(0, 0, 4, 6)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 4, 6)
def test_view_rect_rejects_inverted() -> None:
with pytest.raises(ValueError):
ViewRect(5, 0, 3, 6)
with pytest.raises(ValueError):
ViewRect(0, 6, 4, 2)
with pytest.raises(ValueError):
ViewRect(-1, 0, 4, 6)
def test_crop_frame_slices_subimage() -> None:
frame = np.arange(100).reshape(10, 10)
rect = ViewRect(2, 1, 5, 4)
sub = crop_frame(frame, rect)
assert sub.shape == (3, 3)
assert np.array_equal(sub, frame[1:4, 2:5])
def test_local_rect_within_translates_to_parent_origin() -> None:
parent = ViewRect(10, 20, 110, 220)
child = ViewRect(30, 50, 60, 90)
local = local_rect_within(parent, child)
assert (local.x1, local.y1, local.x2, local.y2) == (20, 30, 50, 70)
def test_rect_from_values_pixel() -> None:
rect = rect_from_values([3, 4, 8, 9], width=20, height=20)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (3, 4, 8, 9)
def test_rect_from_values_normalized() -> None:
rect = rect_from_values([0.0, 0.0, 0.5, 0.5], width=100, height=80)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 50, 40)
def test_rect_from_values_clamps_and_keeps_min_extent() -> None:
# x2==x1 时应被推到 x1+1,避免退化
rect = rect_from_values([5, 5, 5, 5], width=20, height=20)
assert rect.x2 >= rect.x1 + 1
assert rect.y2 >= rect.y1 + 1
def test_rect_from_values_wrong_length_raises() -> None:
with pytest.raises(ValueError):
rect_from_values([1, 2, 3], width=10, height=10)
def test_viewrect_width_height():
"""ViewRect.width/height 与参考接口对齐,供下游消费。"""
from cadx.ceus import ViewRect
r = ViewRect(10, 20, 110, 220)
assert r.width == 100
assert r.height == 200