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>
This commit is contained in:
2026-10-16 03:46:56 -04:00
parent de1d6b2d51
commit 9a916c65a5
29 changed files with 3634 additions and 45 deletions
+278
View File
@@ -0,0 +1,278 @@
"""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