简介:scikit-geometry 是一套面向科学计算与几何算法开发的 Python 封装库,适合具备一定 Python 基础、希望借助成熟计算几何能力解决实际问题的开发者与研究者。它主要封装了 C++ 计算几何算法库 CGAL 的核心功能,提供 Point2、Point3、Segment2、Ray2、Line2、Circle2、Plane3、Triangle3、Polyhedron3 等基本几何类型,并支持线段构造、相交测试、凸包、Voronoi 图、布尔运算、骨架提取、主成分分析等操作,可用于机器人路径规划、CAD 建模、地理信息处理等场景。资源包共 61 个文件,约 273KB,包含 18 个 cpp 源文件、12 个 py 脚本、10 个 ipynb 示例笔记,以及 yml 配置、hpp 头文件、rst 文档等,覆盖源码、测试与文档多个层面。目前已有 2123 人学习下载。通过其中的 Jupyter Notebook 示例与测试脚本,读者可快速理解几何算法的调用方式,并借助源码与文档深入掌握封装细节,为二次开发与算法验证提供参考。
1. scikit-geometry:当几何计算开始像 NumPy 一样顺手
做几何算法的人大概都有过这种体验:算个多边形布尔运算,先翻 CGAL 文档,再配 Boost 依赖,最后被模板报错糊一脸。scikit-geometry 想干的事,就是把这类计算收进 Python 的调用习惯里——import skgeom as sg,然后像操作数组一样操作点、线段、多边形。它底层绑的是 CGAL,但暴露出来的接口是 Python 原生的,不需要你写 C++ 模板,也不需要手动管理内存。适合谁?做 CAD 原型、机器人路径规划、GIS 空间分析、游戏碰撞检测的开发者,尤其是那些想快速验证几何思路、不想在编译环境上耗三天的人。这一章先把「它是什么、边界在哪」讲清楚,后面几章再动手。
2. 环境搭建与第一个多边形:从安装到布尔运算跑通
2.1 为什么选 scikit-geometry 而不是自己包 CGAL
自己用 pybind11 或 Cython 包 CGAL,最直接的代价是编译时间和模板实例化爆炸。CGAL 的精确谓词和惰性求值机制在 C++ 里是优势,但暴露到 Python 时,如果接口设计不好,每次调用都可能在 Python 和 C++ 之间来回拷贝数据。scikit-geometry 的做法是把常用几何对象做成 Python 类,内部持有 CGAL 的句柄,运算在 C++ 侧完成,只把结果传回来。常见做法是:先用它做算法验证,确认几何逻辑没问题后,再决定是否下沉到 C++ 做性能优化。我一般会建议团队里做算法原型的同学先用它,因为改一行 Python 比改一行模板快太多。
另一个选型理由是它的 API 命名跟 shapely 有相似之处,但底层精度策略不同。shapely 基于 GEOS,浮点运算为主;scikit-geometry 走的是 CGAL 的精确谓词路线,在退化情况(比如三点共线、自相交多边形)下更稳。如果你的场景里经常出现「理论上应该相交但浮点算出来不相交」,那这个差异就值得关注。
2.2 安装:conda 与 pip 的取舍
scikit-geometry 的依赖里有 CGAL 和 Boost,直接 pip install 在部分平台上会触发源码编译,耗时且容易缺头文件。我一般优先走 conda:
# 创建独立环境,避免和系统里的 CGAL 版本打架 conda create -n skgeom python=3.10 conda activate skgeom # conda-forge 渠道有预编译包,省去编译 CGAL 的麻烦 conda install -c conda-forge scikit-geometry如果只能用 pip,先确认系统里有 CGAL 头文件和 Boost:
# Ubuntu 系示例,安装编译期依赖 sudo apt-get install libcgal-dev libboost-all-dev # 再装 Python 包,此时会走源码编译 pip install scikit-geometry装完后验证:
import skgeom as sg print(sg.__version__) # 能打印版本号就说明底层绑定加载成功参数说明:python=3.10不是硬性要求,3.8 到 3.11 一般都可以,但建议避开刚发布的 Python 大版本,因为预编译包跟进有延迟。conda-forge 的包名就是scikit-geometry,不要写成skgeom。
2.3 构造点、线段、多边形并做布尔运算
先跑一个最小闭环:两个矩形求交。
import skgeom as sg from skgeom import Point2, Segment2, Polygon # 用逆时针顺序给顶点,CGAL 对多边形方向敏感 rect_a = Polygon([ Point2(0, 0), Point2(4, 0), Point2(4, 3), Point2(0, 3), ]) rect_b = Polygon([ Point2(2, 1), Point2(6, 1), Point2(6, 4), Point2(2, 4), ]) # 布尔交集,返回一个新的 Polygon 对象 inter = sg.boolean_set.intersection(rect_a, rect_b) # 遍历结果的顶点 for poly in inter: print("交点数量:", len(poly.vertices)) for v in poly.vertices: print(v.x(), v.y())逻辑说明:boolean_set.intersection返回的是一个可迭代对象,因为两个多边形的交集可能不止一个连通分量。每个分量是一个 Polygon,vertices是顶点迭代器。参数上,顶点顺序必须是逆时针,顺时针会导致有向面积符号相反,布尔运算结果可能为空或异常。如果拿到的数据源方向不确定,可以先做一次方向校正:
# 检查有向面积,为负则反转顶点顺序 def ensure_ccw(poly): area = sum( poly.vertices[i].x() * poly.vertices[(i+1) % len(poly.vertices)].y() - poly.vertices[(i+1) % len(poly.vertices)].x() * poly.vertices[i].y() for i in range(len(poly.vertices)) ) / 2.0 if area < 0: # 反转顶点列表后重新构造 verts = list(poly.vertices)[::-1] return Polygon(verts) return poly这段代码里的area是鞋带公式,判断方向用。注意 scikit-geometry 的 Polygon 构造后顶点顺序就固定了,没有原地反转的方法,得重新构造。
3. 精确谓词与退化情况:为什么浮点几何总在关键时刻翻车
3.1 浮点误差在几何里的典型表现
用浮点算几何,最经典的问题是「明明共线的三点,算出来不共线」。比如判断点是否在线段上,浮点做法是算叉积是否接近零,但阈值设多少都有人不服。CGAL 的精确谓词用自适应精度算术,先按浮点算,如果结果落在不确定区间,再提升精度重算,直到符号确定。scikit-geometry 把这个能力带到了 Python 层。
看一个退化例子:
from skgeom import Point2, Segment2 # 三个点理论上共线 p = Point2(0, 0) q = Point2(1, 1) r = Point2(2, 2) seg = Segment2(p, q) # orientation 判断点 r 相对线段 pq 的方向 # 返回 0 表示共线,正负表示左右 result = sg.orientation(p, q, r) print("orientation:", result) # 精确谓词下会稳定返回 0,而不是 1e-16 之类的浮点噪声逻辑说明:sg.orientation是 CGAL 精确谓词的直接暴露,返回值是整数符号,不是浮点。参数顺序是「起点、终点、待判断点」。这个函数在实现凸包、线段相交、点在多边形内判断时是基础构件。如果你自己用叉积写,在接近共线时就会遇到符号跳变,导致凸包多一个点或少一个点。
3.2 用精确谓词重写「点是否在多边形内」
浮点版本的射线法在边界附近容易误判。用 scikit-geometry 的Polygon自带方法:
from skgeom import Point2, Polygon poly = Polygon([ Point2(0, 0), Point2(5, 0), Point2(5, 5), Point2(0, 5), ]) test_point = Point2(2.5, 2.5) # 判断点是否在多边形内部 inside = poly.contains(test_point) print("inside:", inside) # 边界上的点 boundary_point = Point2(0, 2.5) print("on boundary:", poly.contains(boundary_point))逻辑说明:contains内部走的是 CGAL 的精确判断,边界情况有明确语义。参数上,test_point必须是 Point2 对象,不能直接传元组。如果数据量大,逐个构造 Point2 会有开销,可以先把坐标列表转成 Point2 列表再批量判断。注意contains对边界点的返回值取决于具体实现版本,稳妥做法是同时用on_boundary类方法确认,或者自己用orientation组合判断。
3.3 退化情况下的布尔运算稳定性
两个多边形共享一条边时,布尔运算最容易出问题。浮点实现可能返回一个细长的碎片,精确谓词下会正确合并或分割。
from skgeom import Point2, Polygon import skgeom # 两个矩形共享一条边 x=2 left = Polygon([Point2(0,0), Point2(2,0), Point2(2,2), Point2(0,2)]) right = Polygon([Point2(2,0), Point2(4,0), Point2(4,2), Point2(2,2)]) # 求并集 union_result = skgeom.boolean_set.join(left, right) for poly in union_result: print("并集顶点数:", len(poly.vertices)) # 精确谓词下应该得到一个 4 个顶点的矩形,而不是两个分离的碎片逻辑说明:boolean_set.join是并集操作。共享边在精确算术下会被正确识别为内部边,结果合并成一个多边形。如果换成浮点库,共享边上的点可能因为微小误差被判定为不相交,结果返回两个独立多边形。参数上,两个多边形的顶点顺序都要是逆时针,否则并集结果可能不符合预期。
4. 避坑与排查:scikit-geometry 落地时的五个血泪经验
4.1 坑一:顶点顺序反了,布尔运算返回空
现象:两个明显相交的多边形,intersection返回空列表。 原因:其中一个多边形顶点是顺时针,有向面积为负,CGAL 把它当成「洞」或无效区域处理。 解决:构造前统一做方向校正,用 2.3 里的ensure_ccw函数过一遍。如果数据来自 GeoJSON,注意 GeoJSON 规范里外环是逆时针、内环是顺时针,转换时要对齐。
4.2 坑二:Point2 不能直接当元组用
现象:poly.contains((2.5, 2.5))报类型错误。 原因:scikit-geometry 的接口要求显式 Point2 对象,不做隐式转换。 解决:统一在数据入口处把坐标转成 Point2。如果从 NumPy 数组来,写一个转换函数:
import numpy as np from skgeom import Point2 def array_to_points(arr): # arr 形状为 (N, 2) return [Point2(float(x), float(y)) for x, y in arr]参数说明:float()强制转换是为了避免 NumPy 的 float32 传进去后精度不够,CGAL 内部按 double 算,但入口处统一成 Python float 更稳。
4.3 坑三:大量小对象导致内存增长
现象:循环里反复构造 Polygon 和 Point2,内存持续上涨。 原因:CGAL 对象在 Python 侧有句柄,垃圾回收不及时会堆积。 解决:把循环拆成批次,每批处理完显式del并触发gc.collect()。更根本的做法是尽量用批量接口,比如把多个多边形放进一个列表一次性做布尔运算,而不是逐个循环。
4.4 坑四:conda 和 pip 混装导致绑定加载失败
现象:import skgeom报ImportError: undefined symbol。 原因:conda 装的 CGAL 版本和 pip 编译时链接的版本不一致。 解决:一个环境里只用一种安装方式。如果已经混了,先conda remove scikit-geometry,再pip uninstall scikit-geometry,清干净后重装。检查conda list | grep cgal和pip list | grep geometry确认没有残留。
4.5 坑五:布尔运算结果顶点数暴增
现象:两个简单多边形求交,结果有几百个顶点。 原因:输入多边形有自相交或重复顶点,CGAL 在精确计算时把退化边拆成了多个段。 解决:运算前先做清理,去重顶点、检查自相交。scikit-geometry 没有内置的simplify,但可以用Polygon的is_simple类方法检查,不简单就先用其他工具修。常见做法是先用 shapely 的buffer(0)修自相交,再转成 scikit-geometry 的 Polygon。
5. 进阶技巧:用 scikit-geometry 做凸包与骨架提取
5.1 凸包:从点集到最小外接多边形
凸包是很多几何流水线的第一步。scikit-geometry 提供了convex_hull:
from skgeom import Point2 import skgeom import random # 生成随机点集 random.seed(42) points = [Point2(random.uniform(0, 10), random.uniform(0, 10)) for _ in range(50)] # 计算凸包,返回一个 Polygon hull = skgeom.convex_hull(points) print("凸包顶点数:", len(hull.vertices)) for v in hull.vertices: print(f"({v.x():.2f}, {v.y():.2f})")逻辑说明:convex_hull接受 Point2 的可迭代对象,返回逆时针方向的凸包 Polygon。参数上,点集里如果有重复点,精确谓词会正确处理,不会影响结果。如果点集共线,返回的是一条退化多边形,顶点数为 2,后续使用时要判断len(hull.vertices) >= 3再做面积计算。
5.2 骨架提取:用中轴变换做路径规划
scikit-geometry 里有skeleton模块,可以对多边形做中轴变换,得到骨架图。这在机器人路径规划里很有用——骨架上的点天然远离边界,适合做安全路径。
from skgeom import Point2, Polygon from skgeom.skeleton import StraightSkeleton # 构造一个 L 形多边形 poly = Polygon([ Point2(0, 0), Point2(4, 0), Point2(4, 2), Point2(2, 2), Point2(2, 4), Point2(0, 4), ]) # 计算直线骨架 skeleton = StraightSkeleton(poly) # 遍历骨架边 for edge in skeleton.edges: src = edge.source tgt = edge.target print(f"骨架边: ({src.x():.1f},{src.y():.1f}) -> ({tgt.x():.1f},{tgt.y():.1f})")逻辑说明:StraightSkeleton对简单多边形有效,输入必须是非自相交的。edges返回的是骨架边集合,每条边有source和target两个端点。参数上,多边形顶点顺序影响骨架方向,但不影响拓扑结构。如果多边形有洞,需要额外处理,当前接口对带洞多边形的支持有限,常见做法是把洞单独提出来做约束。
5.3 验证方法:用面积守恒检查布尔运算
布尔运算做完后,怎么确认结果对?一个简单办法是面积守恒:两个多边形并集的面积等于各自面积之和减去交集面积。
import skgeom from skgeom import Point2, Polygon a = Polygon([Point2(0,0), Point2(3,0), Point2(3,3), Point2(0,3)]) b = Polygon([Point2(1,1), Point2(4,1), Point2(4,4), Point2(1,4)]) area_a = abs(skgeom.area(a)) area_b = abs(skgeom.area(b)) inter = list(skgeom.boolean_set.intersection(a, b)) area_inter = sum(abs(skgeom.area(p)) for p in inter) union = list(skgeom.boolean_set.join(a, b)) area_union = sum(abs(skgeom.area(p)) for p in union) # 面积守恒检查 print("a + b - inter =", area_a + area_b - area_inter) print("union area =", area_union) # 两者应该相等,误差在精确算术下为 0逻辑说明:skgeom.area返回有向面积,取绝对值得到实际面积。intersection和join返回可迭代对象,可能有多个分量,所以用列表推导求和。这个检查在调试布尔运算时很实用,如果两边对不上,说明输入多边形有问题或者运算结果有退化分量。
我自己的习惯是:每次换数据集或改几何逻辑后,先跑一遍面积守恒检查,再跑业务逻辑。这个习惯帮我省过好几次「结果看起来对但实际有碎片」的后悔药。几何计算没有黑匣子,精确谓词给了你确定性,但前提是输入得干净、方向得对、退化情况得提前想到。希望帮到你。
本文还有配套的精品资源,点击获取