
在画布上随机撒 200 个点,然后只做一件事:每个像素归离它最近的那个点。你不用画任何一条线,平面自己就裂成 200 块,块与块之间的边界全是直线段,远看像马赛克,近看像细胞,再放大一点就像蜂窝。上面这张图就是这个规则的输出,没有一块是我手画的。
这件事的名字叫 Voronoi 图(也叫泰森多边形)。它最有用的地方在于:一条规则同时给出「这块地归谁」和「谁和谁挨着」 。实测数字:1600×1080 的画布上撒 200 个种子点,200 块格子的面积之和是 1728000,正好等于画布面积(偏差 1.3e-14%);把「最近邻居归属」逐像素和暴力算法逐点比对,一致率 100.0% ;平均每个格子有 5.52 个邻居------而正六边形环环相扣时,每块的邻居数正好是 6。
公开仓库:atomgit.com/deli007/dem...;本案例目录:atomgit.com/deli007/dem...。成品是一个 index.html,双击就能跑,不联网、不引第三方库、没有构建步骤。
边界先说清楚:这是个几何演示,不是 GIS,也不是图像分割工具。它按欧氏距离划分平面,不认地形、不认权重、不认障碍;种子点是随机的,所以每次刷新看到的图案都不一样;画布外面的世界不存在,所有格子都被画布四条边裁过一刀,这一点直接影响「平均邻居数」这个指标,后面第四节会看到它掉到 5.07 的样子。
为什么值得看:一条一行就能写完的规则,长出了三种你见过的图案
蜂窝、肥皂泡、冷却的岩浆柱、干裂的泥地、细胞的切面------这些图案有一个共同的数学来源:在平面上撒点,让每块地归最近的点。撒点的方式不同、点的疏密不同,出来的形状就不同;但只要反复把点往自己那块地的中心挪,最后都会收敛到接近正六边形的蜂窝。
六边形为什么赢?因为它是在「把平面切成等面积小块、并且让边界总长最短」这件事上的最优解,而边界总长最短,正是肥皂泡和细胞膜在物理上要做的事。所以这不是「六边形好看」,是「六边形便宜」。
还有一层更实际的:如果你写过最近邻分类、KNN、或者「给每个用户分派最近的仓库」这类逻辑,Voronoi 图就是它的几何本体。很多人在纸上推不明白的东西,画出来一秒就懂了------这也是我决定把它做成一个能点的页面的原因。
一、先看结果:一条规则,200 块地
页面左边是画布,右边是控制与统计面板。默认 200 个种子点,11 条自检断言直接渲染在面板上,每条都带实测值:
| 指标 | 实测值 | 是怎么算出来的 |
|---|---|---|
| 格子面积之和 / 画布面积 | 1728000 / 1728000(偏差 1.3e-14%) | 鞋带公式逐块求面积后求和,对照 width×height |
| 与暴力最近邻的一致率 | 100.0% | 每 30 像素取一个网格点,把多边形归属和「遍历所有种子取最近」比对 |
| 平均邻居数 | 5.52 | 逐对格子判断公共边长度是否大于 0,再取平均 |
| 所有像素都有归属 | 是 | 每 20 像素取点,都要落进某一格里 |
| 边界点数量 | 94 个 | 落在画布四条边上的格子顶点数,用来确认格子确实被裁过 |
| 格子面积方差 | 37669091 | 逐块面积对其均值求方差,越小越均匀 |
| 种子数 | 200 | 输入框当前值 |

关于「悬停」还有个小细节值得说:格子的边数 和邻居数在这幅图里永远是同一个数。因为「有公共边」和「是邻居」在 Voronoi 图里是同一件事的两面------这也是为什么用「公共边长度」判相邻比用「两个点离得近」判相邻要靠谱,第四节的第 2 处改动就栽在这里。
二、为什么最后会长成六边形
先给一个能算的版本,而不是一句「自然选择」。把整个剖分看成一张平面图:每个格子是一个面,每条公共边是一条边。对面积完全均匀 的剖分,欧拉公式给出每个面的平均边数趋近于 6;而面积一不均匀,就会出现几十条边的「巨无霸」格子和一堆三角形。
页面上的「Lloyd 松弛」按钮做的正是让面积变均匀:点一次,就把每个种子点挪到自己所在格子的面积质心上,然后重新剖分。它是迭代的,点得越多越均匀。默认 200 个种子时:
| Lloyd 迭代次数 | 格子面积方差 | 平均邻居数 | 6 边形格子数 |
|---|---|---|---|
| 0(随机) | 37669091 | 5.52 | 40 个 |
| 5 | 6669090 | 5.47 | 85 个 |
| 0(60 个种子) | 175828389 | 5.13 | --- |
| 8(60 个种子) | 28481271 | 5.07 | 26 个 |
方差从 37669091 掉到 6669090,降了 5.6 倍;6 边形的数量从 40 涨到 85。这两条数字放在一起,就是「Lloyd 松弛会收敛到蜂窝」这句话的可验证版本。


图 4 里「平均邻居数 5.07」这个数,正好把上面那句话补完整:6 是无边界世界里的极限,画布有边,边界格子天生少几个邻居。种子越少,边界格子的占比越大,平均值就越低。这也是我后来改掉一条自检的原因(第四节第 7 处)。
三、交付之后我改了七处:从 0.4% 一致率说起
码道交付的页面打开就有 11 条自检,其中 3 条当场是红的。我把页面在本机重新跑了一遍,逐条查下去,一共修了 7 处,全是真缺陷,不是美化:
| # | 现象 | 根因 | 改法 |
|---|---|---|---|
| 1 | 画布被一整块绿色多边形糊住,一致率 0.4%、平均邻居数 0.00、面积方差 5.35e238 | 线段求交的参数符号写反了,交点跑到直线另一侧,每次裁剪把坐标放大数倍 | 把 t 的分子从 p1−line1 改成 line1−p1 |
| 2 | 平均邻居数虚高:真值 5.53,页面报 5.65 | 判相邻用「两个多边形有顶点距离小于 2 像素」,把只在顶点相接的格子也算成邻居;200 个格子里有 31 个多算 1 个 | 改成算「公共边长度是否大于 0」 |
| 3 | 自检「Lloyd 后方差下降」永远是红的 | 断言写的是固定阈值「方差 < 500000」,而随机 200 点的初始方差是 37669091,这个阈值不可能达到 | 改成真跑一步 Lloyd、比较前后方差、再回滚状态 |
| 4 | 「边界点数量 > 0」这类断言没信息量 | --- | 新增「格子面积之和 = 画布面积」,偏差 1.3e-14% |
| 5 | 直方图会悄悄吃掉边数大于 9 的格子 | 分箱只有 3 到 9 | 分箱外单独给一行提示,截图里那 1 个 10 边形不再凭空消失 |
| 6 | 改了种子数、点了 Lloyd 之后,自检面板还是上一次的旧数字 | 自检只在页面初始化时跑过一次 | 重建和迭代两个动作结束后各刷新一次自检 |
| 7 | 种子数调小后「平均邻居数 5.5--6.5」误报为红 | 那个窗口只对足够多的种子成立(60 个种子实测 5.07) | 改成有数学依据的上界「平均邻居数 < 6」,并在断言名里写清依据 |
第 1 处是最值钱的。它的表现完全不像一个符号错误:页面能打开、种子点画得好好的、11 条自检里 8 条是绿的,只有 3 条红------而且红的那 3 条说得都很含糊。我把几何代码单独抽出来、固定一组随机种子在 Node 里复算,才看到真身:
| 同一组 200 个种子点,同一段几何代码 | 与暴力最近邻一致率 | 单个格子的最大顶点数 | 空格子数 |
|---|---|---|---|
| 修之前 | 0.19% | 13170 | 26 |
| 只把 t 的符号改回来 | 100.00% | 9 | 0 |
一个符号,从 0.19% 到 100%。这也是我为什么坚持「交付的页面必须自己重跑一遍」:这 7 处里有 6 处静态读代码读不出来,读出来的是把数字打出来对比------13170 个顶点的多边形,看一眼就知道不是多边形,是发散。

四、本地怎么复现
仓库里就一个文件,浏览器直接打开即可;也可以起个本地静态服务:
bash
git clone https://atomgit.com/deli007/demo_park.git
cd demo_park/codearts-voronoi-honeycomb
python -m http.server 8080
# 浏览器打开 http://localhost:8080/index.html
页面加载后会立刻跑一次自检,右栏「自检结果」打出 11/11 全绿 ;点一次「Lloyd 松弛」再跑,仍然是 11/11,此时「Lloyd 一步后方差下降」那一行会变成实测的 37669091 → 20641392。这些数字都是我把交付页面在本机重新跑出来的,不是抄交付说明。
想复现第 1 处那个 bug 的话,把 intersect() 里 t 的分子从 (x1 - p1.x) * segDy - (y1 - p1.y) * segDx 改回 (p1.x - x1) * segDy - (p1.y - y1) * segDx,刷新页面,一致率那一行会立刻掉到 0.4% 左右。
五、亲手点三下
- 看尺子:默认 200 个种子点下,确认「平均邻居数」是 5.52 左右、「格子面积之和 / 画布面积」两边的数字完全一样,自检第 2 条一致率是 100.0%。
- 换个输入:把种子数改成 60,点「重新生成」,再连点 8 次「Lloyd 松弛」------格子会明显长成蜂窝,直方图从 3 到 9 边散开收拢到 5 边、6 边;此时平均邻居数会掉到 5.07,这是边界格子的必然结果,不是坏了。
- 看失败路径 :把种子数填成
1或者abc,点「重新生成」------页面会把输入框描红、拒绝重建、保留上一次有效结果,并给出中文提示原文。页面上不会出现空画布,也不会出现 NaN。
六、准备环境:进入码道 Web 版
这个页面是用码道做的。码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source... ,登录后就能在对话窗口输入需求,不需要装软件。
我的需求是一条消息把要做的事说全的:默认随机撒 200 个种子点并实时剖分、归属判定必须精确、悬停显示面积周长边数、面板给平均邻居数与 3 到 9 边的直方图、一个 Lloyd 松弛按钮、一个把至少 8 条断言渲染到页面上的自检按钮、种子数小于 3 要有中文提示且不许白屏不许 NaN、单个 index.html 不引第三方库不联网。末尾我固定加了一句「完成后打开预览,让我直接看到运行效果」------这一句能省掉一轮来回。
七、使用码道 Web 的体会
- 自检要一次性写进需求,而且要求它渲染到页面上。这次 11 条断言里,正是那 3 条飘红的把第 1、2、7 处问题拽了出来。如果只是「给我做个 Voronoi 演示」,我大概率会收到一个看起来正常、其实一致率 0.4% 的页面。
- 「不许 NaN、不许白屏」要当成需求写。合法性校验这种东西,不明确要求,交付物里往往就没有。
- 交付必须自己重跑一遍,而且要打印数字。第 1 处那个符号错误,肉眼盯着发散成 1e60 的坐标是看不出来的,只有把「一致率 / 最大顶点数 / 空格子数」打出来才现形。
- 独立复算是唯一可信的验收方式。我没有检查它的几何代码写得对不对,而是拿暴力最近邻当尺子去量它------尺子必须和被测对象实现不同,否则只能证明它和自己一致。
- 指标要选有数学依据的。第 3、7 两处改动都是同一个教训:随手定的阈值(< 500000、5.5 到 6.5)会长期误报,而有依据的边界(面积之和等于画布面积、平均邻居数小于 6)在任何输入下都站得住。
- 别把生成结果当结论。它说完成不算完成,页面上那条红着的断言才算。
说完不足
- 画布外的世界不存在。所有格子都被画布四条边裁过一刀,直接后果是平均邻居数会低于 6:200 个种子时 5.52,60 个种子时 5.07。这不是 bug,是有界 Voronoi 图的固有性质,但它意味着这一列的绝对数字不能拿去和「平均 6 个邻居」直接比。
- 剖分是逐对种子做半平面裁剪,复杂度是 O(n²)。200 个种子流畅,把种子数拉到几百个以上,重建和 Lloyd 都会开始卡。
- 颜色是按黄金角在色相环上取的,纯属好看,不承载任何信息;颜色相近的两块格子并不代表它们的面积或邻居数相近。
- 边数和邻居数是同一件事(公共边长度大于 0),所以统计面板里这两个数字永远一致;如果你想要的是「角上相接但不共边」的关系,这个页面不提供。
- 随机种子每次刷新都变,截图里的具体数字只对应截图那一刻的那一组种子;复现到的是同一量级和同一结论,不是逐位相同的小数。