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