1. 从"帝国边界"到算法落地:沃罗诺伊图到底在解决什么问题
第一次看到"帝国边界划分"这个说法,很多人会以为这是历史或地理话题。其实它描述的是一个非常经典的几何问题:假设地图上有若干个权力中心(城市、据点、基站、门店),每个中心都想控制离自己最近的那片区域,那么最终形成的势力范围图,就是沃罗诺伊图(Voronoi Diagram)。每个中心对应一个"细胞"(Cell),细胞内的任意一点到该中心的距离,都小于到其他任何中心的距离。
这个问题的核心价值在于:它把"最近邻归属"这件事,从模糊的直觉变成了可计算、可绘制、可验证的几何结构。你只要给出若干种子点,算法就能自动帮你把整个平面切分干净,不留缝隙、不重叠。听起来简单,但真正动手实现时会发现,边界到底怎么画、无穷远的区域怎么处理、退化情况(多个点共线、重合)怎么兜底,全是坑。
这篇是"2/2",也就是下半部分。上半部分我们聊了沃罗诺伊图的数学定义、对偶关系(和德劳内三角剖分互为对偶)以及它在自然界和城市格局里的直观体现。这一篇要解决的是工程落地:怎么把一张沃罗诺伊图真正算出来、画出来、用起来。我会从最朴素的暴力法讲起,一路讲到分治法、扫描线法,再给出可直接运行的代码,最后聊聊它在选址、路径规划、生成艺术里的真实用法。
适合谁看?如果你已经理解了沃罗诺伊图"是什么",但卡在"怎么算、怎么画、怎么用"这一步,这篇就是为你写的。哪怕你只是想在项目里画一张好看的分区图,或者想搞明白游戏里那种自然裂纹、细胞纹理是怎么生成的,也能直接抄作业。
提示:本文所有代码基于 Python,依赖 numpy 和 matplotlib,不需要任何额外几何库就能跑通基础版本。进阶部分会提到 scipy.spatial 的 Voronoi 类,但核心逻辑我会手写一遍,方便你理解原理。
2. 暴力法先跑通:为什么"逐点比较"是最该先写的版本
2.1 暴力法的核心逻辑与像素化实现
很多人一上来就想找现成的库,结果调出来的图边界是断的、颜色是乱的,反而更懵。我的建议永远是:先用最笨的办法跑通一遍。暴力法的思路简单到不能再简单——把整个画布切成一个个像素点,对每个像素,计算它到所有种子点的距离,谁最近就归谁。
用生活化的类比:想象你在一张方格纸上画了很多个点,然后拿一支笔,一格一格地涂色,每格涂成离它最近的那个点的颜色。涂完,沃罗诺伊图就出来了。这个方法的时间复杂度是 O(像素数 × 种子点数),听起来很慢,但对于几百个种子点、一张 800×800 的图,现代计算机几秒钟就能跑完,完全够用。
import numpy as np import matplotlib.pyplot as plt def voronoi_bruteforce(seeds, width=800, height=800): # seeds: (N, 2) 的数组,每行是一个种子点的 (x, y) xs = np.arange(width) ys = np.arange(height) xx, yy = np.meshgrid(xs, ys) # 生成所有像素坐标 # 展平成 (H*W, 2) points = np.stack([xx.ravel(), yy.ravel()], axis=1).astype(float) # 计算每个像素到每个种子的距离,形状 (H*W, N) dists = np.linalg.norm(points[:, None, :] - seeds[None, :, :], axis=2) # 取最近种子的索引 labels = np.argmin(dists, axis=1).reshape(height, width) return labels # 随机生成 20 个种子点 np.random.seed(42) seeds = np.random.rand(20, 2) * 800 labels = voronoi_bruteforce(seeds) plt.figure(figsize=(8, 8)) plt.imshow(labels, cmap='tab20', origin='lower') plt.scatter(seeds[:, 0], seeds[:, 1], c='black', s=30, marker='x') plt.title('Voronoi by brute force') plt.axis('off') plt.show()这段代码跑出来的图,颜色块就是各个"帝国"的领地,黑叉是权力中心。你会发现边界是锯齿状的,因为像素是离散的。想要边界更平滑,把分辨率调高就行,代价是计算量按平方增长。
2.2 暴力法的三个隐藏坑
第一个坑是坐标顺序。numpy 的 meshgrid 默认返回的是 (行, 列),对应到图像是 (y, x),而种子点通常按 (x, y) 给。如果你不统一,画出来的点会整体转置,看起来"对但就是怪"。我习惯在生成 points 时显式写成[xx.ravel(), yy.ravel()],保证和 seeds 的 (x, y) 一致。
第二个坑是距离度量。默认用欧氏距离没问题,但如果你做的是城市街区划分,曼哈顿距离(L1)可能更符合实际,因为道路是横平竖直的。改法很简单,把np.linalg.norm换成np.abs(...).sum(axis=2)即可。不同距离度量会得到完全不同的图,这一点在选址场景里很关键。
第三个坑是内存爆炸。points[:, None, :] - seeds[None, :, :]这一步会生成一个 (H*W, N, 2) 的中间数组。800×800 的图配 100 个种子,就是 800×800×100×2 ≈ 1.28 亿个浮点数,约 1GB 内存。种子一多就崩。解决办法是分块计算,或者直接用 scipy 的 KDTree 查询最近邻,把复杂度从 O(N) 降到 O(log N)。
注意:暴力法适合验证理解和做小规模 Demo,生产环境请务必换用 KDTree 或直接调用成熟的 Voronoi 库。我见过有人拿暴力法去处理 4K 分辨率的图,结果内存直接打满,进程被系统杀掉。
3. 从 O(N) 到 O(log N):KDTree 加速与 scipy 实战
3.1 KDTree 为什么能加速最近邻查询
暴力法慢在"每个像素都要和所有种子比一遍"。KDTree 的思路是:先把种子点组织成一棵二叉树,按坐标轴交替切分空间。查询某个像素的最近种子时,从根节点往下走,边走边排除掉那些"肯定不可能更近"的子树。这样平均只需要比较 log(N) 个种子,而不是 N 个。
用生活类比:你要在一个城市里找最近的便利店。暴力法是挨家挨户问距离;KDTree 是先看你在哪个区,再在区里找,再在街道里找,层层缩小范围。种子点分布越均匀,KDTree 的效果越好;如果所有点挤在一条线上,树会退化成链表,加速效果打折。
from scipy.spatial import cKDTree def voronoi_kdtree(seeds, width=800, height=800): tree = cKDTree(seeds) xs = np.arange(width) ys = np.arange(height) xx, yy = np.meshgrid(xs, ys) points = np.stack([xx.ravel(), yy.ravel()], axis=1).astype(float) # query 返回 (距离, 索引) dists, labels = tree.query(points, k=1) return labels.reshape(height, width)实测下来,同样 800×800、100 个种子,暴力法要 3 到 5 秒,KDTree 只要 0.2 秒左右,快了十几倍。种子越多,差距越明显。
3.2 scipy.spatial.Voronoi 的正确打开方式
如果你不需要像素级的归属图,而是想要矢量边界(一堆线段和多边形),那就该用scipy.spatial.Voronoi。它直接返回顶点、边、区域等信息,画出来的边界是光滑的直线段,不是锯齿。
from scipy.spatial import Voronoi, voronoi_plot_2d points = np.random.rand(30, 2) vor = Voronoi(points) fig, ax = plt.subplots(figsize=(8, 8)) voronoi_plot_2d(vor, ax=ax, show_points=True, show_vertices=False, line_colors='orange') ax.set_xlim(0, 1) ax.set_ylim(0, 1) plt.show()但这里有个大坑:scipy 的 Voronoi 默认不处理无穷远的区域。位于点集凸包上的种子,它的细胞会延伸到无穷远,scipy 用 -1 表示这些区域的顶点索引。你直接画,会发现边缘的细胞是开口的,图不封闭。解决办法是手动在点集外围加一圈"虚拟点",把无穷远区域"框"回来,或者用vor.ridge_points和vor.ridge_vertices自己重建边界。
我常用的技巧是:在原始点集的包围盒外扩一圈,均匀撒 8 到 16 个虚拟点,算完 Voronoi 后只保留原始点对应的细胞。这样边缘就封闭了,而且虚拟点离得够远,不会影响内部结构。
3.3 两种方案的选型对照
| 方案 | 输出形式 | 适用场景 | 速度 | 边界质量 |
|---|---|---|---|---|
| 暴力法 | 像素标签图 | 教学、小图、自定义距离 | 慢 | 锯齿 |
| KDTree | 像素标签图 | 大图、自定义距离、批量查询 | 快 | 锯齿 |
| scipy.Voronoi | 矢量顶点与边 | 精确几何、路径规划、面积计算 | 快 | 光滑 |
选型逻辑很直接:要"图"就用前两个,要"几何结构"就用第三个。如果你既要图又要精确面积,可以先用 scipy 算出多边形,再用 shapely 求面积,最后用 matplotlib 填充颜色。
4. 分治与扫描线:理解工业级算法的底层思路
4.1 分治法:把大问题切成两半
工业级的沃罗诺伊图算法,主流是分治法和扫描线法。分治法的思路是:把所有种子点按 x 坐标排序,从中间切成左右两半,分别递归求各自的沃罗诺伊图,最后把两张图"缝合"起来。缝合的关键是找一条分割链(merge curve),它由左右两侧种子点之间的垂直平分线片段组成。
为什么分治能到 O(N log N)?因为每次递归把问题规模减半,递归深度是 log N,每层的缝合操作是线性的。这个复杂度已经是最优的了,因为任何算法都至少要读一遍所有点。
分治法的难点全在"缝合"这一步。你要从无穷远开始,沿着分割链往下走,每走一步都要判断当前是哪个左侧点和哪个右侧点在"争夺"边界。实现起来代码量不小,但理解了它对偶的德劳内三角剖分,就会清晰很多——德劳内三角剖分有成熟的分治实现,而沃罗诺伊图就是它的对偶,两者可以互相转换。
4.2 扫描线法:一条线扫过去,事件驱动
扫描线法(Fortune 算法)更巧妙。想象一条水平线从上往下扫,扫过的区域里,每个种子点都"生长"出一个抛物线形状的波前。这些抛物线的下包络(beach line)就是当前已确定边界的轮廓。当扫描线遇到新种子点,或者两条抛物线相交(形成一个沃罗诺伊顶点),就触发一个"事件",更新数据结构。
Fortune 算法用平衡二叉树维护 beach line,用优先队列维护事件,整体也是 O(N log N)。它的优势是在线处理:点可以一个一个来,不需要一次性拿到全部。这在流式数据场景里很有用,比如实时更新的基站选址。
不过说实话,除非你要自己写几何库,否则没必要手撸 Fortune 算法。它的边界情况(三个点共线、四个点共圆)处理起来极其繁琐,一个符号错误就全盘皆输。我的建议是:理解思想,用现成库。scipy、CGAL、shapely 都提供了可靠的实现。
4.3 退化情况:算法工程师的噩梦
不管用哪种算法,退化情况都是必须面对的。常见的退化有三类:
- 点重合:两个种子点坐标完全相同。这时它们的细胞面积都是零,算法可能除零或死循环。处理办法是去重,或者给重合点加一个极小的随机偏移。
- 多点共线:三个以上种子点在同一直线上。这时会出现"退化边",垂直平分线互相平行,没有交点。需要特殊分支处理。
- 四点共圆:四个种子点恰好在同一个圆上。这时沃罗诺伊图会出现一个"四岔路口",四条边交于一点。浮点误差会让这个点分裂成两个很近的点,导致图出现微小裂缝。
提示:在实际项目里,我习惯在输入种子点后先做一次"抖动"(jitter),给每个坐标加上 1e-6 量级的随机扰动。这能规避 90% 以上的退化问题,代价是边界位置有极微小的偏移,肉眼完全看不出来。
5. 沃罗诺伊图的真实应用:选址、路径与生成艺术
5.1 选址与势力范围划分
回到"帝国边界"这个比喻。现实中,连锁门店的配送范围、外卖平台的骑手分区、通信基站的信号覆盖,本质上都是沃罗诺伊问题。给定若干服务点,每个点负责离它最近的区域,这样总配送距离最短。
但纯沃罗诺伊有个问题:它假设所有中心"权重相同"。现实中,大城市的门店覆盖范围应该比小城市大。解决办法是加权沃罗诺伊图(Weighted Voronoi),也叫乘权沃罗诺伊或加权沃罗诺伊。加权版本里,距离变成d(p, s) - w_s,权重大的点能"抢"到更多区域。实现时只需在距离计算里减去权重,其余逻辑不变。
def weighted_voronoi(seeds, weights, width=800, height=800): xs = np.arange(width) ys = np.arange(height) xx, yy = np.meshgrid(xs, ys) points = np.stack([xx.ravel(), yy.ravel()], axis=1).astype(float) dists = np.linalg.norm(points[:, None, :] - seeds[None, :, :], axis=2) dists = dists - weights[None, :] # 加权重 labels = np.argmin(dists, axis=1).reshape(height, width) return labels这个改动虽小,但效果立竿见影。权重可以按人口、销售额、订单量来设,让分区更符合业务实际。
5.2 路径规划与避障
在机器人路径规划里,沃罗诺伊图有一个经典用法:广义沃罗诺伊图(Generalized Voronoi Diagram)。把障碍物的顶点和边当作种子,生成的沃罗诺伊边就是"离所有障碍物都尽量远"的路径。机器人沿着这些边走,天然就能避开障碍,因为每条边到最近障碍的距离是局部最大的。
这个思路在无人机航线、自动驾驶换道决策里都有应用。它的优点是路径安全裕度高,缺点是路径往往比较绕,不是最短路径。实际工程里通常先用沃罗诺伊图生成一条安全走廊,再用优化算法在走廊内拉直。
5.3 生成艺术与程序化纹理
游戏和设计领域,沃罗诺伊图是生成自然纹理的利器。细胞裂纹、干裂土地、长颈鹿斑纹、玻璃碎片,都能用它做出来。做法是:随机撒种子点,算沃罗诺伊图,然后给每个细胞填充略有差异的颜色,再叠加噪声和边缘高光。
更进一步,可以用劳埃德松弛(Lloyd's Relaxation)让细胞变得均匀。做法是反复把每个种子点移到它当前细胞的质心,迭代几十次后,细胞会变得像蜂巢一样规整。这个技巧在生成"有机但均匀"的纹理时特别好用。
def lloyd_relaxation(seeds, iterations=10, width=800, height=800): for _ in range(iterations): labels = voronoi_kdtree(seeds, width, height) new_seeds = [] for i in range(len(seeds)): mask = (labels == i) if mask.sum() == 0: new_seeds.append(seeds[i]) continue ys, xs = np.where(mask) new_seeds.append([xs.mean(), ys.mean()]) seeds = np.array(new_seeds) return seeds跑完松弛,再画出来的图就非常"舒服",没有特别大或特别小的细胞,视觉上很平衡。
6. 手写一个完整的沃罗诺伊可视化工具
6.1 需求拆解与模块划分
把前面所有东西串起来,我们做一个完整的小工具,功能包括:随机或手动指定种子点、支持加权、支持劳埃德松弛、输出彩色分区图并标注种子点。模块分三块:种子管理、计算核心、可视化。
种子管理负责生成、去重、抖动。计算核心用 KDTree 做最近邻查询,支持权重。可视化用 matplotlib 的 imshow 加散点,颜色用 tab20 或 hsv 色图循环。
6.2 完整代码与参数说明
import numpy as np import matplotlib.pyplot as plt from scipy.spatial import cKDTree class VoronoiTool: def __init__(self, width=800, height=800, seed=42): self.width = width self.height = height self.rng = np.random.default_rng(seed) self.seeds = None self.weights = None def random_seeds(self, n, jitter=1e-6): pts = self.rng.random((n, 2)) * [self.width, self.height] pts += self.rng.normal(0, jitter, pts.shape) # 抖动防退化 self.seeds = pts self.weights = np.zeros(n) return self def set_weights(self, weights): self.weights = np.asarray(weights, dtype=float) return self def relax(self, iterations=10): for _ in range(iterations): labels = self._compute() new_seeds = [] for i in range(len(self.seeds)): mask = (labels == i) if mask.sum() == 0: new_seeds.append(self.seeds[i]) continue ys, xs = np.where(mask) new_seeds.append([xs.mean(), ys.mean()]) self.seeds = np.array(new_seeds) return self def _compute(self): tree = cKDTree(self.seeds) xs = np.arange(self.width) ys = np.arange(self.height) xx, yy = np.meshgrid(xs, ys) points = np.stack([xx.ravel(), yy.ravel()], axis=1).astype(float) # 加权:查询多个候选再按权重修正 dists, idx = tree.query(points, k=min(len(self.seeds), 8)) if dists.ndim == 1: dists = dists[:, None] idx = idx[:, None] adjusted = dists - self.weights[idx] best = np.argmin(adjusted, axis=1) labels = idx[np.arange(len(best)), best] return labels.reshape(self.height, self.width) def plot(self, cmap='tab20'): labels = self._compute() plt.figure(figsize=(8, 8)) plt.imshow(labels, cmap=cmap, origin='lower', interpolation='nearest') plt.scatter(self.seeds[:, 0], self.seeds[:, 1], c='black', s=25, marker='x', linewidths=1.5) plt.axis('off') plt.tight_layout() plt.show() # 使用示例 tool = VoronoiTool(width=600, height=600) tool.random_seeds(25).relax(iterations=8) tool.plot()参数说明:width/height决定分辨率,越大越精细但越慢;jitter是防退化抖动幅度,1e-6 足够;relax的迭代次数一般 5 到 15 次,太多会过度规整失去自然感;k=8是加权查询的候选数,权重差异大时可以调大。
6.3 实测效果与调参心得
实测下来,600×600 配 25 个种子,松弛 8 次,整个流程不到 2 秒。松弛后的细胞大小非常均匀,适合做"规划感"强的图;不松弛则更自然,适合做有机纹理。
调参上我踩过的坑:权重不能设得太大。如果某个点的权重远超其他点,它会吞掉大半个图,其他点被挤到边缘,看起来像 bug。经验是权重差异控制在距离尺度的 20% 以内,比如图宽 600,权重差不超过 120。
另一个心得是颜色映射的选择。tab20 适合 20 个以内的细胞,超过 20 个颜色会重复,相邻细胞可能同色,边界就糊了。细胞多的时候改用hsv或nipy_spectral,或者干脆用随机颜色字典,保证相邻不同色。
7. 那些文档不会告诉你的踩坑记录
7.1 边界不封闭的排查链路
第一次用 scipy.Voronoi 画图,我发现边缘的细胞是开口的,图看起来像被啃了一口。排查过程是这样的:先打印vor.regions,发现有些区域的顶点列表里出现了 -1;再查文档,确认 -1 代表无穷远顶点;然后尝试手动加虚拟点,发现虚拟点太近会挤压内部结构,太远又不起作用。
最终的解决方案是:计算原始点集的包围盒,在盒外按 1.5 倍盒宽的距离,均匀撒 12 个虚拟点。这样既封闭了边缘,又不影响内部。这个"1.5 倍"是我试出来的经验值,太小会干扰,太大则虚拟点自身的细胞会异常大,虽然不影响原始点,但画图时如果不过滤会很难看。
7.2 浮点误差导致的裂缝
有一次做面积统计,发现所有细胞面积加起来比总面积少了 0.3%。查了半天,发现是四点共圆导致的微小裂缝。浮点计算里,本该交于一点的四条边,实际交成了两个相距 1e-10 的点,中间留了一条极细的缝。
解决办法有两个:一是加抖动,从源头避免共圆;二是后处理时做"焊接",把距离小于阈值的顶点合并。我一般两个都用,抖动防患于未然,焊接兜底。
7.3 性能优化的三个层次
性能优化我总结了三层:第一层是换算法,暴力换 KDTree;第二层是降分辨率,先低分辨率算归属,再对边界像素做精细判定;第三层是并行化,把画布切成若干块,多进程同时算。
第二层特别实用。比如 4K 图,先用 400×400 算一遍,找到所有边界附近的像素,只对这些像素做高精度距离计算。这样能省 90% 以上的计算量,视觉上几乎看不出差别。
注意:并行化时要注意随机种子的管理,每个进程用独立的种子,否则抖动会重复,退化问题反而更严重。
8. 从沃罗诺伊图延伸出去的两个方向
沃罗诺伊图本身已经够用了,但如果你想把这件事做得更深,有两个方向值得探索。
第一个是德劳内三角剖分。它和沃罗诺伊图互为对偶:沃罗诺伊图的每个顶点对应德劳内三角形的一个外接圆圆心,每条边对应一条垂直平分线。理解了这层关系,你就能在两者之间自由转换。德劳内三角剖分在网格生成、地形建模里用得极多,而且它的算法(比如 Bowyer-Watson)比沃罗诺伊更容易实现。
第二个是约束沃罗诺伊图。普通沃罗诺伊图的边界是直线,但现实中边界可能是河流、山脉、行政线。约束沃罗诺伊图允许你指定某些边必须存在,算法会在满足约束的前提下最小化"偏离"。这在真实地图分区里非常有用,实现上通常要借助计算几何库。
我在实际项目里的体会是:先把无约束版本吃透,再上约束。无约束版本的所有坑(退化、边界、性能)你都会在约束版本里再遇到一遍,而且更难排查。基础打牢了,后面都是水到渠成。
最后分享一个小技巧:如果你只是想快速看效果,不想写代码,很多在线工具和绘图软件都内置了沃罗诺伊功能。但如果你想真正掌控参数、做批量处理、集成到自己的系统里,手写一遍绝对值得。我前后写了三版,每一版都对"最近邻归属"这件事理解更深一层。第一版只会暴力,第二版学会用树,第三版才搞明白加权和松弛的配合。这个过程没有捷径,但每一步的收获都很实在。