简介:这是一份用Python编写的凸包算法项目,适合正在学习计算几何、图形学或需要快速集成几何计算能力的开发者。资源围绕凸包这个经典问题,演示了葛立恒扫描、贾维斯步进等常见算法的实现思路,并配有二维点集可视化模块与测试用例,帮助读者直观理解算法执行过程、对比不同实现的正确性与性能表现。压缩包共五个文件,包含四个Python脚本和一个Markdown说明文档,分别承担核心算法实现、绘图展示、单元测试、性能基准测试以及使用环境说明,整体仅7KB,结构清晰、注释简洁,便于直接阅读、修改和二次开发。在实际应用中,凸包可用于碰撞检测、数据聚类、区域边界提取、路径规划等众多场景。项目目前已有两百六十一人学习,对于算法初学者和工程开发者都是一份轻量实用的参考资料。
1. 凸包算法重实现:给点云拉一根“最小橡皮筋”
在 GIS 数据清理、CAD 描边、图像轮廓标注这些场景里,最常做的一件事就是把屏幕上密密麻麻的散点圈出一个边界。凭肉眼描边,出图没有问题;一旦交给下游算面积、做碰撞检测,多一个点少一个点结果都对不上。凸包算法做的正是这件事——在所有点外侧拉出一条最小凸多边形,只保留真正的外围角点。这个用 Python 写的 convex_hull 项目,重新实现了多款软件里最常用的凸包算法,并自带可视化和命令行输出。你不需要把整个几何库搬进项目,直接拿这份源码,该跑的跑通,想换算法的也只用动一个文件。适合做图形处理、地理数据整理,以及准备算法相关面试的开发者。
2. 项目结构与技术选型:四个模块如何把一个算法拆成工程
2.1 目录结构与模块职责
这个仓库并没有把凸包算法堆在一个脚本里,而是按“数据生成、算法求解、结果渲染、命令行入口”拆成了四个关注点,目录结构大概长这样:
convex_hull/ ├── convex_hull/ │ ├── __init__.py # 包初始化,导出算法入口 │ ├── core.py # 叉积、方向判断等基础几何工具 │ ├── algorithms.py # Graham / Andrew / Jarvis 三种算法 │ ├── render.py # matplotlib 可视化 │ └── cli.py # 命令行入口,生成随机点并导出结果 ├── examples/ │ └── demo_random_points.py # 一键可视化的演示脚本 ├── tests/ │ └── test_convex_hull.py # 最小回归测试 └── README.md核心文件职责对照如下:
| 文件 | 职责 |
|---|---|
| core.py | 提供cross()叉积计算,这是整个凸包判定的地基 |
| algorithms.py | 三种算法实现,输入统一为点列表,输出统一为逆时针凸包点列表 |
| render.py | 把原始点集和凸包画在同一坐标系,便于肉眼核验 |
| cli.py | 读取参数、生成数据、调算法、落盘结果 |
算法层和渲染层隔离带来的好处很明显:以后新增 QuickHull,完全不用碰画图代码;调试时可以直接在 Python REPL 里调algorithms.graham_scan(points),拿到顶点列表再做面积或周长校验,不需要经过命令行。这种分层方式,比把算法和可视化写在一个文件里更适合拿来二次开发。
2.2 技术选型:为什么只依赖 matplotlib
这个项目本质上是个纯计算几何工程,不涉及矩阵运算、线性代数,numpy用在这里纯粹是拉高安装门槛。我一般不会在这种规模的算法库里引入它,点坐标直接用标准库的tuple表示,算法内部做的只是加减法和比较,set去重、sort排序都是 Python 内置能力。所以core.py和algorithms.py这两个模块在任何 Python 3.8+ 环境里都能直接跑,连 pip 都不用装。
可视化选择 matplotlib 是最省事的方案。它用来散点、画线、填充多边形都足够直观,而且几乎每个 Python 开发环境里都有。唯一要注意的是plt.axis("equal")必须加,否则 X 轴和 Y 轴比例不一致,圆形点云会被拉成椭圆形,凸包看起来像画歪了,实际并没有。这也是我最早写可视化时常踩的坑。
2.3 从命令行拉起一个最小 Demo
先把环境跑起来,最小操作是这样:
python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install matplotlib python examples/demo_random_points.py --seed 2024--seed参数用来固定随机种子。没有它,每次生成的随机点都不一样,调试时很难对比结果;固定种子以后,同一台机器上每次跑出来的点集和凸包完全一致,方便你对照源码检查顶点顺序。
如果你不需要弹窗看图形,只想拿计算结果,走命令行入口更合适:
python -m convex_hull.cli --count 200 --seed 42 --output hull.txt这条命令会生成 200 个横纵坐标在 0~100 之间的随机点,计算完凸包后把顶点按逆时针顺序写入hull.txt,每行一个x,y。拿到这个文件后,你可以用鞋带公式算一下多边形面积,面积为正说明顶点顺序确实是逆时针,这也是最快的结果校验方式。
3. 核心实现逐段拆解:Graham 扫描如何一步步拉出外圈
3.1 地基:叉积与三点转向判断
整个 convex_hull 项目所有算法都建立在同一个函数上——叉积。先看core.py里的实现:
def cross(o, a, b): """计算向量 OA 与 OB 的叉积,判断三点之间的转向关系""" return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])叉积返回值有明确的几何含义:大于 0 表示向量 OA 逆时针旋转到 OB,等于 0 表示三点共线,小于 0 表示顺时针。这个函数在凸包代码里出现频率极高,Graham 扫描弹栈用它,Andrew 单调链判断上下包用它,Jarvis 步进选下一个点也用它。
参数o是基准点,a和b是被测点。命名上刻意把基准点放第一位,读代码时cross(stack[-2], stack[-1], p)一眼就能看出谁是当前边、谁是新来的点。这个约定在三个算法里全程保持一致,避免方位搞混。
3.2 Graham 扫描:找最低点、极角排序、单调栈
Graham 扫描的思路是先找一个必然在凸包上的点(通常是 y 坐标最小的点),再把其余点按极角排序,最后用单调栈维护凸包的边界。看实现:
import math def graham_scan(points): # 先去重再排序,保证点不重复 points = sorted(set(points)) if len(points) < 3: return points # 如果所有点共线,凸包退化成一条线段,直接返回两端点 if all(cross(points[0], points[1], p) == 0 for p in points[2:]): return [points[0], points[-1]] # 找到最低点:y 最小,y 相同时取 x 最小 p0 = min(points, key=lambda p: (p[1], p[0])) rest = [p for p in points if p != p0] # 以 p0 为原点,按极角排序 rest.sort(key=lambda p: math.atan2(p[1] - p0[1], p[0] - p0[0])) # 单调栈扫描 stack = [p0, rest[0], rest[1]] for p in rest[2:]: while len(stack) >= 2 and cross(stack[-2], stack[-1], p) <= 0: stack.pop() stack.append(p) return stack第一眼看上去最容易被忽略的是共线预检查。当所有输入点在同一条直线上时,stack初始化的三个点就已经共线,后续循环不会弹出中间点,最后返回三个点而不是一条线段。所以算法开头必须判断“全共线”这个退化场景,直接返回两端点。这个边界条件很多教科书实现都不处理,实际接入数据时却很容易碰到。
极角排序用math.atan2是最直观的写法:它返回范围是(-π, π],能正确处理四个象限。排序后,从最低点 p0 出发按逆时针方向扫描,一旦发现新点让栈顶出现顺时针转向(叉积小于等于零),说明栈顶点被包在内部,弹掉。<= 0的写法同时把共线点也弹掉,保证凸包是严格最小多边形,不包含边上的冗余点。
3.3 Andrew 单调链:不做三角函数排序的工程替代
Graham 的极角排序涉及浮点三角函数,而 Andrew 单调链完全避开了这个环节。它对点集按(x, y)排序后分别构建下链和上链,最后拼接成完整凸包:
def andrew_monotone_chain(points): points = sorted(set(points)) if len(points) < 3: return points # 构建下凸包:从左到右扫描 lower = [] for p in points: while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop() lower.append(p) # 构建上凸包:从右到左扫描 upper = [] for p in reversed(points): while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop() upper.append(p) # 两端点会重复,拼接时各去掉最后一个 return lower[:-1] + upper[:-1]这个实现里排序是关键一步,排序规则是先 x 后 y,完全基于整数比较,不涉及任何浮点角度。所以 Andrew 在某些软件里的出镜率比 Graham 还高——它稳定,输出顺序可控,而且在输入点分布极端时不容易翻车。实际写代码时,我用它就是当作 Graham 的对照组,专门验证随机点集下两个算法画出来的多边形是否重合。
两次扫描的复杂度是 O(n log n),瓶颈在排序,扫描本身是线性的。lower[:-1] + upper[:-1]这么拼接是有原因的:下链的最后一个点是全图 x 最大的点,上链的第一个点也是它,直接拼接会重复;同理最左侧点也会重合,所以两边各去掉尾部一个顶点。
3.4 可视化渲染:把点云和凸包叠在同一张图里
render.py里的函数很短,但它承担了“肉眼验收”的职责。算法算得对不对,跑一遍看图形立刻见分晓:
import matplotlib.pyplot as plt def draw(points, hull): xs, ys = zip(*points) plt.scatter(xs, ys, s=12, color="gray", label="点集") hx = [p[0] for p in hull] + [hull[0][0]] hy = [p[1] for p in hull] + [hull[0][1]] plt.plot(hx, hy, color="red", linewidth=2, label="凸包") plt.fill(hx, hy, color="red", alpha=0.2) plt.legend() plt.axis("equal") plt.show()zip(*points)把点列表拆成 x 序列和 y 序列,scatter画原始散点,plot负责把凸包顶点依次连起来。注意hx和hy在顶点列表后面又追加了第一个点,这是为了闭合多边形——否则画出来是一条折线,不是封闭图形。fill用半透明红色填充内部,方便确认凸包是否包含所有点、有没有点露在外面。
axis("equal")是最容易被忽略的参数。坐标范围不一致时,matplotlib 会默认拉伸画面,圆点云可能看起来是椭圆,凸包的边视觉上就不贴合点云,容易误判算法有问题。加上这个参数后,X、Y 轴的单位长度一致,图形所见即所得。
4. 常见问题与避坑:方向、精度、共线是最容易翻车的三件事
4.1 极角排序的浮点陷阱
现象:在点几乎共线但稍有随机扰动时跑 Graham 扫描,输出的凸包顶点顺序偶尔会变,换一个 Python 版本结果也可能不同。
原因:math.atan2返回浮点角度,当两个点和基准点几乎在同一条射线上时,角度差值小到超过浮点精度,排序器认为两个键相等,先后顺序就交给sort的稳定性决定。而稳定排序保留原顺序,原顺序一旦在输入里变化,输出顶点顺序跟着变。
解决:生产环境优先用 Andrew 单调链,它只做整数叉积比较;如果必须用 Graham,自定义比较器代替角度键——先用叉积判断方向,叉积为 0 时再按距离排序,全程不计算角度。我写第 3 章代码时保留atan2是为了教学直观,实际接数据我一般切 Andrew。
4.2 共线点到底该不该保留
现象:从 CAD 软件导出描边点计算凸包,做等距采样时发现原本落在直线边上的点全不见了,下游工序拿不到原始采样点。
原因:while条件写成cross(...) <= 0,凡是叉积等于零的共线点一律弹栈。这在几何上没问题,凸包确实不需要中间点,但业务上这些点可能承担着等距标尺、纹理坐标等额外属性。
解决:需要保留边界点时,把弹出条件改成< 0,共线点会留在栈里,面积不变但顶点数变多。我建议在算法入口加一个keep_boundary参数,让调用方显式选择哪种子行为,而不是悄悄改全局逻辑。
4.3 输出方向不统一导致面积符号飘
现象:同一个点集,用 Graham 算出正面积,用 Jarvis 算出负面积,某 GIS 库接手后直接报参数异常。
原因:鞋带公式算面积的正负由顶点绕行方向决定,逆时针为正,顺时针为负。三种算法实现约定不一致时,下游只要直接用返回值就会踩雷。
解决:所有算法统一约定逆时针输出,并加一个入口校验函数:
def ensure_ccw(hull): area = 0 for i in range(len(hull)): x1, y1 = hull[i] x2, y2 = hull[(i + 1) % len(hull)] area += x1 * y2 - y1 * x2 if area < 0: return hull[::-1] return hull这段代码用鞋带公式算有向面积,面积为负就把整个列表反转。放进所有算法返回之前,输出方向就永远一致了。
4.4 经纬度坐标直接计算的结果只是“看起来对”
现象:把地图上的经纬度点喂进算法,画出来是凸包,但拿去算面积,和地理软件给出的结果差一大截。
原因:经纬度是角度坐标,不是平面投影坐标,叉积公式在经纬度上不成立。纬度越高,同样经度差对应的实际距离越短,直接用角度计算会扭曲凸包形状和面积。
解决:先把经纬度投影到平面坐标再算凸包。常见做法是使用墨卡托或高斯投影近似,把点转换成以米为单位的平面坐标,算完凸包后再把顶点投影回经纬度。这个项目本身没有内置投影能力,但算法层接收的是纯数字点,你可以在调用前先完成坐标转换,cross()完全感知不到差异。
4.5 无桌面环境跑 demo 卡死不动
现象:在服务器或 Docker 容器里跑demo_random_points.py,程序既不退出也不报错,像死了一样。
原因:matplotlib 的plt.show()在无桌面环境里没有图形后端可用,会进入阻塞状态等一个永远不会出现的窗口。
解决:给渲染函数加一个save_path参数,有值时用plt.savefig(save_path)代替plt.show()。跑在服务器上就传一个 PNG 路径,图形会被写入文件,不会卡住。这也是我把render.py单独拆出来的原因:算法和渲染解耦后,在不同环境下替换渲染策略只需改一个函数。
5. 二次开发:把这个凸包模块接进你的项目
5.1 读取自己的点集
examples里生成的是随机点,实际项目中点集通常来自 CSV、数据库或接口。把数据源替换成文件是最常见的改造:
def load_points(path): with open(path, "r", encoding="utf-8") as f: return [ (float(x), float(y)) for line in f if line.strip() for x, y in [line.strip().split(",")] ] from convex_hull import algorithms points = load_points("road_nodes.csv") hull = algorithms.andrew_monotone_chain(points)注意先转浮点再去重,因为文本读进来的字符串比较不等于浮点数比较。我在测试里遇到过顶点数异常偏多的问题,最后定位就是没做类型转换导致去重失效。
5.2 用策略模式切换算法
现在algorithms.py里的三个函数签名完全一致,接新算法可以直接做成注册表:
ALGORITHMS = { "graham": algorithms.graham_scan, "andrew": algorithms.andrew_monotone_chain, "jarvis": algorithms.jarvis_march, } def solve(points, method="andrew"): return ALGORITHMS[method](points)替换算法时,调用方只需要换一个字符串参数,不需要改任何业务代码。这也意味着你可以放心加第四种算法(比如 QuickHull),只要保持“输入点列表、输出逆时针顶点列表”的约定就行。
5.3 图像坐标系的 Y 轴翻转
做图像轮廓标注时,图像坐标的 y 轴是向下的,直接用图像坐标算凸包,视觉上会上下颠倒。常见做法是把 y 取负转成笛卡尔坐标,算完再转回来:
points_cartesian = [(x, -y) for x, y in points_image] hull_cartesian = algorithms.andrew_monotone_chain(points_cartesian) hull_image = [(x, -y) for x, y in hull_cartesian]转换之后再算面积、周长、顶点顺序都会符合图像坐标下的直觉。从那以后,我每次给项目换算法或换数据源,都会先用固定种子跑一遍回归用例,把顶点数、面积、周长三项和上一轮结果做对比,确认三项都对得上再接入正式流程,省掉了很多以为自己改错代码的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取