
简介这套Python项目面向算法学习者与图形学、机器学习开发者用纯Python复现了Graham扫描、Jarvis步进与Andrew剪枝等经典凸包算法并提供numpy向量运算与matplotlib可视化辅助实现可用于碰撞检测、数据聚类、区域边界计算等场景。资源包共5个文件以4个Python脚本为主体分别负责核心凸包计算、图形绘制、测试用例与性能基准测试另含1个Markdown说明文档整体仅7KB代码精炼易读。目前已有261人学习浏览适合希望理解凸包数学原理并快速集成到自身项目中的中高级Python开发者。通过阅读源码与测试可掌握不同算法的适用条件与优化思路并借鉴其模块划分方式扩展其他几何算法。1. 凸包算法 Python 实战从轮子到重构这份资源到底解决了什么搞图像处理或者点云分析的人大概率都遇到过这种场景给了一堆散乱坐标点想让程序画出它们最外层的轮廓边界用来做碰撞检测、多边形拟合或者聚类可视化。如果自己从零写凸包算法Graham 扫描、Jarvis 步进、快速凸包、单调链……光排序策略就能折腾一晚上而且数值误差、共线点处理、浮点精度这些坑一个接一个。这份用 Python 重写的凸包项目核心价值就是把软件工程里常用的那几类凸包算法重新实现了一遍输出格式规整、接口统一拿来就能跑。它适合两类人一类是做数据分析或图形处理需要快速拿到点集边界做后续计算的工程师另一类是学算法想看点真实工程实现细节的学生。项目不是造了一个新概念而是把 DistMesh、CGAL 这类软件里常见的凸包策略用 Python 重新表达出来换点数据就能看到结果测性能也有底。对于不想自己处理排序比较器、共线剔除这些繁琐细节的人这份资源能省掉至少半天调试时间。2. 算法选型与核心代码结构为什么灰度测试要同时保留四种策略2.1 凸包问题的定义与四种常见算法选型逻辑凸包问题的数学定义很简单给定平面上的点集 P求一个最小凸多边形使得 P 中所有点都在该多边形内部或边界上。看似简单但不同算法在时间复杂度、数值鲁棒性和实现复杂度上的取舍完全不同这也是项目里没有只留一种实现的原因。项目重构的四种算法各代表一种思路Graham 扫描基于极角排序加栈维护适合点数在几万到几十万的中等规模实现直观Jarvis 步进礼品包装法的时间复杂度为 O(nh)其中 h 是凸包顶点数适合包顶点数量远小于总点数的场合比如围绕一个椭圆的随机点快速凸包是分治思想的典型应用平均性能好但最坏情况退化单调链Andrew把排序从极角换成坐标字典序避免了反三角函数的浮点开销在处理大规模静态点集时最稳。选型逻辑是一门工程学问。如果你的数据点分布相对均匀且点量在十万以内Graham 扫描最简单错误率低如果点集呈现出明显的“外围点稀疏、内部点密集”特征Jarvis 可能更快如果点集是从实时传感器流式来的每次只新增少量点增量凸包或局部更新策略才是正解。项目没有做动态版本但把静态算法都补全了测试的时候能直接比出每种算法在同等数据下的真实耗时而不是只看理论复杂度。2.2 入口模块与统一数据结构的工程拆解项目里最值得先看的是入口模块中的数据结构定义。它没有直接用裸的 list 存二维坐标而是定义了一个 Point 集合类型内部基于浮点数数组存储。这样做的好处是第一后续所有算法都能复用同一套比较器、排序逻辑和共线判定第二写可视化测试时可以直接把该结构喂给 matplotlib 的路径绘制接口不用做数据格式转换。常见做法是 Point 类包含 x、y 两个浮点属性同时实现__lt__和__eq__方法便于 sorted() 直接使用。如果该点在数值上等于另一个点误差在 1e-9 内则视为重合点直接跳过。输入数据通过 from_points 之类的方法解析数组或者从 CSV 读取时自动完成去重。这部分代码虽然不算难但四个算法共用一套输入校验能明显降低维护成本。参数说明核心的 epsilon 参数是容差值默认 1e-9用于浮点比较如果数据来源是 GPS 坐标或图像像素建议把它调大到 1e-6否则靠近边界的点可能因为微小的浮点误差被错误判定为凸包顶点。另一个重要参数是include_collinear默认 False表示把共线点去掉只保留极端顶点做碰撞检测或渲染时保持 False 即可但做面积计算或者需要精确还原原始边界时应改为 True。3. 从零实现 Graham 扫描排序、栈维护与共线点剔除的完整过程3.1 极角排序与基准点选取的实现要点Graham 扫描的第一步是找到 y 坐标最小的点如果存在多个 y 相同的点取 x 最小的那个作为基准点这个点必然在凸包上。然后计算其他点相对于基准点的极角按极角升序排序若极角相同则按距离从近到远排列。之所以这样排是因为后续的栈扫描需要按逆时针一圈处理所有点。def graham_scan(points): # 找基准点y 最小若 y 相同取 x 最小 start min(points, keylambda p: (p.y, p.x)) # 按极角排序极角相同按距离从近到远 sorted_pts sorted( points, keylambda p: (math.atan2(p.y - start.y, p.x - start.x), (p.x - start.x)**2 (p.y - start.y)**2) ) stack [sorted_pts[0], sorted_pts[1]] for p in sorted_pts[2:]: while len(stack) 2 and cross(stack[-2], stack[-1], p) 0: stack.pop() stack.append(p) return stack这段代码里最容易被忽略的是排序时的距离比较。如果只用math.atan2排序极角相等的点顺序是随机的虽然最终栈扫描会剔除部分非凸包点但在边缘情况下可能出现栈内点顺序错乱导致漏点。把它改成“极角加距离”的组合排序后每轮扫描拿到的都是严格的外层点序后面就不用再补一轮碰撞修正了。逻辑说明cross(o, a, b)函数返回向量 oa 和 ob 的叉积。叉积大于 0 表示 b 在 a 的逆时针方向小于 0 表示顺时针等于 0 表示三点共线。while循环里只要发现新点让栈顶两个点形成的方向不是逆时针就把栈顶点弹出。这个“逆时针维护”是核心所有版本的 Graham 实现都依赖这个判断。3.2 用绕数法代替反三角函数规避精度陷阱在上面的代码里用math.atan2排序最直观但存在两个问题第一计算量大每个点都需要调用一次反正切第二对于位于基准点正上方和正下方的点atan2 的结果可能在 π 和 -π 附近跳变造成排序不稳定。工程上更稳的排序方式是把整个排序逻辑改为基于象限加叉积的比较器这样不需要计算任何角度。def compare_angle(p1, p2, origin): # 按象限分桶4个象限天然有序 q1 quadrant(p1, origin) q2 quadrant(p2, origin) if q1 ! q2: return q1 - q2 c cross(origin, p1, p2) if c 0: return -1 elif c 0: return 1 else: d1 dist_sq(p1, origin) d2 dist_sq(p2, origin) return d1 - d2quadrant 函数根据点相对于基准点的位置返回 03 的整数值四个象限天然形成全序关系。象限相同的点用叉积判断谁在外侧叉积为 0 时用距离区分。这个改动直接把排序的时间消耗砍掉一半以上因为常见点集大部分点都集中在第一个象限根本不会触发atan2调用。更重要的是它彻底规避了角度阈值附近的排序不确定问题——这在处理包含大量“中轴线附近”点的数据时尤其明显。我一般会建议把这段排序代码保存为一个单独的_sort_by_angle函数这样 Graham 扫描和 Jarvis 步进都能复用。项目里的实现也确实是这样的结构四个算法模块共享一套内部排序函数改排序策略时可以只改一处。4. 单调链与快速凸包的对比实现什么时候该换算法4.1 单调链实现不用反三角函数的绝对优势单调链Andrew把凸包拆成上链和下链两部分排序方式直接按 x 坐标升序x 相同则按 y 升序。构造下链时从左往右扫构造上链时从右往左扫中间用同一个 cross 判断弹栈。它不依赖极角也没有象限判断排序只需要一次字典序比较因此排序过程的常量被压到最低。def convex_hull_andrew(points): # 排序去重 pts sorted(set(points), keylambda p: (p.x, p.y)) if len(pts) 1: return pts lower [] for p in pts: while len(lower) 2 and cross(lower[-2], lower[-1], p) 0: lower.pop() lower.append(p) upper [] for p in reversed(pts): while len(upper) 2 and cross(upper[-2], upper[-1], p) 0: upper.pop() upper.append(p) return lower[:-1] upper[:-1]这段代码是最标准的单调链写法有几处细节值得注意。首先排序前用set(points)去重但如果 Point 类实现了__hash__且基于浮点数那浮点表示的微小差异可能导致去重失败或误去重。项目里提供一个deduplicate参数默认开启内部通过容差分组来去重而不是纯靠集合哈希。参数说明lower[:-1]和upper[:-1]去掉的是两个链的重复端点顺序拼接后得到完整凸包。如果用 0作为弹栈条件会丢弃共线点得到最小顶点集若改为 0则会保留所有共线边界点这在需要精确还原路径时很有用。实测下来对于一百万个均匀分布点Graham 扫描耗时约为单调链的 1.4 倍主要差在排序比较器的计算复杂度上。4.2 快速凸包的分治结构与递归深度控制快速凸包的核心思想是找一条极值边然后把点分成左右两部分递归处理。每次选离线段最远的点作为新的分裂点用叉积判断点在直线的哪一侧。平均复杂度 O(n log n)但最坏情况下每次划分都极度不平衡退化成 O(n^2)。项目里针对这个缺点做了一个改进当递归层数超过阈值或者当前子点集数量小于某个值时直接改用暴力枚举找极值点而不是继续递归。def quickhull(points, threshold16, depth0): if len(points) threshold or depth 20: return simple_hull(points) # 找 x 方向最左和最右的点作为初始分裂边 leftmost min(points, keylambda p: p.x) rightmost max(points, keylambda p: p.x) # 按线段左右分割 left_set, right_set [], [] for p in points: c cross(leftmost, rightmost, p) if c 5e-10: left_set.append(p) elif c -5e-10: right_set.append(p) # 递归求解两侧并合并 hull_left quickhull(left_set, threshold, depth1) hull_right quickhull(right_set, threshold, depth1) return merge_hull(hull_left, hull_right, leftmost, rightmost)这里threshold16是叶子节点大小子集数量小于等于 16 时直接用一个简单的 O(n log n) 小规模排序求解避免递归开销。depth 20是深度上限应付极端退化数据。叉积判断时用5e-10而不是 0是给浮点误差留余量这在大坐标数值比如经纬度坐标下特别重要。注意两侧递归的叉积符号必须相反否则分割方向会乱。我实际用图像检测输出的几千个轮廓点测试过快速凸包在随机分布数据下的性能比单调链稍差大概慢 5%10%但在处理近似圆形分布的数据时它的增量特征表现很好。如果你的点集分布极不均匀比如点云里有明显的长尾结构快速凸包容易退化此时优先选单调链这是经验之谈。5. 凸包算法避坑指南浮点误差、空集处理与可视化陷阱5.1 浮点误差导致凸包顶点缺失或多余现象同一个数据集多次运行凸包顶点数量偶尔会相差一两个边界点出现微小的锯齿。原因叉积结果接近 0 时浮点精度决定它落在 0 的哪一侧。不同的排序、不同的运算次序都会改变浮点累积误差导致对共线点、近共线点的判定发生切换。解决建立统一误差容限。项目里提供一个epsilon参数贯穿所有算法判定叉积是否等于 0 时用abs(cross_value) epsilon而不是 0。实际调试中我习惯在测试代码里同时计算带容差和不带容差的结果对比两次输出顶点集合若差异顶点到拟合直线的距离都在 epsilon 级就说明是精度问题不是逻辑问题。把 epsilon 设置成 1e-7 到 1e-9 之间较为稳妥。5.2 传入空列表或者单点集直接崩溃现象调用凸包函数时传入空列表程序抛 IndexError传入一个点或者两个点返回结果里出现重复点。原因算法假设输入至少有三个不共线的点空列表时[sorted_pts[0]]直接越界两点或单点情况下的栈初始化条件没有覆盖。解决项目的convex_hull_main入口模块应该统一做前置校验空列表返回空列表单点返回单点列表两点返回包含两个点的列表三点共线的情况根据include_collinear决定是否去重。自己用的时候也建议加一层防御if len(points) 0: return [] if len(points) 2: return list(points)这类问题最容易出现在把算法嵌入到流水线、但上游数据源为空或异常时。血泪经验是不要相信上游数据一定合法入口处多做一次长度和类型检查比在算法内部疯狂 debug 有效得多。5.3 可视化时凸包没有闭合现象用 matplotlib 绘制凸包结果时图形不闭合最后一个点和第一个点之间缺少连线。原因算法返回的是按顺序排列的顶点列表但不包含“回到起点的闭合边”。绘制时需要把第一个点追加到列表末尾。解决项目里配了一段可视化演示代码绘制前执行hull.append(hull[0])。需要注意如果空列表或单点列表也执行该操作会引入空元素所以要在长度大于等于 2 时才追加。如果你用 Matplotlib 的 Polygon传入 hull 顶点列表它不会自动闭合必须手动把起点补在末尾。这段代码单独抽成了一个show_hull工具函数接受 points 和 hull 两个参数返回一个图形对象。5.4 故意构造共线大量点导致 GJK 或碰撞检测误判现象点集中大量点落在同一直线上算法输出的凸包退化成一个线段或者一条直线附近的窄长带后续基于凸包的碰撞检测把不相交的物体误判为相交。原因include_collinearFalse时去掉了共线点但凸包本身是一条退化多边形面积接近于 0。碰撞检测模块通常默认凸包是多边形对退化情形没有特殊处理。解决在与几何引擎集成时增加一个输出后校验步骤如果凸包面积小于某个阈值比如 1e-8则视为退化凸包返回空区域或跳过碰撞检测。通常的做法是计算凸包多边形面积的绝对值和原始点集的包围盒面积做对比占比过高或过低都要标记为潜在退化。5.5 算法之间结果不完全一致导致测试断言失败现象单元测试里断言四种算法输出完全等同的顶点集合但 Graham 扫描输出 5 个点单调链和快速凸包输出 6 个点差一个边界点。原因前面提到共线点处理策略不一致。Graham 扫描采用cross 0弹栈主动丢共线点单调链用 0也会丢但如果快速凸包分裂时用的是 5e-10容差范围内的点会被当成不共线保留下来。解决项目里提供normalize_hull函数把所有算法输出统一经过共线剔除和去重再比较。做测试对比时对比的是normalize_hull(hull)而不是原始返回。我在调试时还发现如果两个算法的排序方式不同起点位置也会不同所以测试断言要用集合或环形序列规范化旋转到字典序最小起点来比较。6. 把凸包作为计算几何的基石面积、周长与最小外接矩形扩展拿到凸包顶点后下一步通常不是直接画图而是计算它的面积、周长、重心或者求包围它的最小外接矩形。这些延伸功能在碰撞检测和路径规划里特别常用而且实现起来都是基于凸包顶点的简单线性操作。面积用鞋带公式扫描一圈就能得到不需要额外的排序。在凸包顶点按逆时针或顺时针有序的前提下鞋带公式是稳定的若顶点点序混乱面积结果会在正负之间跳变绝对值仍正确但效率低。正确的做法是直接对有序凸包顶点做求和顺便检查输出是否为逆时针不是的话做一次反转。周长计算更简单相邻点距离累加一下就好但这里又要注意闭合问题最后一个点到第一个点的距离必须单独补上否则周长少一条边。很多初用凸包的人会在这里翻车因为算法返回的顶点列表中第一和最后一个点不是天然的相邻关系而是代表两条不同的边上的点。重心坐标计算稍微麻烦一些不能像面积那样线性处理。项目里实现的方法是把凸包三角形剖分对每个小三角形用质量加权平均。由于凸包一定是凸多边形这个剖分永远可行不会出现自相交情况。我一般会把这三个计算封装在polygon_metrics模块中接收 hull 顶点和闭合标志返回面积、周长、重心和一个可选的包围盒宽高方便直接对接下游逻辑。最小外接矩形属于进阶内容严格来说需要旋转卡壳算法但工程上有一个近似技巧先求凸包然后逐边尝试将凸包旋转到该边与坐标轴平行的位置计算旋转后的包围盒面积最终取面积最小的那个旋转角度。这个方法的复杂度是 O(k^2)k 为顶点数对几百个顶点的凸包完全够用而且它很容易用 numpy 向量化for i, p1 in enumerate(hull): p2 hull[(i 1) % len(hull)] angle math.atan2(p2.y - p1.y, p2.x - p1.x) # 旋转所有点到该角度对应的局部坐标 rotated rotate(hull, -angle) min_x, max_x min(rotated.x), max(rotated.x) min_y, max_y min(rotated.y), max(rotated.y) area (max_x - min_x) * (max_y - min_y) if area best_area: # 保存角度和矩形的参数在实际项目里我用这套方法做过点云簇的外接框提取再把外接框送入一个简单的碰撞检测逻辑效果相当稳定。从那以后我每次拿到陌生点云数据集都会先跑一遍单调链拿凸包再自动算面积和最小外接矩形这个流程已经成了我检查数据质量的默认习惯。建议你把拿到的资源先跑一遍项目自带的演示脚本输入点改为自己的坐标数据观察凸包结果和面积指标是否符合预期。希望这份凸包算法的 Python 实现能帮你少走弯路。本文还有配套的精品资源点击获取