图 1:200 个随机种子点撒在 1600×1080 的画布上。规则只有一条——每个像素归离它最近的那个种子点——平面就自己切成了 200 块彩色格子;右栏 11 条自检全绿:与暴力最近邻逐像素对比一致率 100.0%,平均邻居数 5.52,格子面积之和 1728000 正好等于画布面积

在画布上随机撒 200 个点,然后只做一件事:每个像素归离它最近的那个点。你不用画任何一条线,平面自己就裂成 200 块,块与块之间的边界全是直线段,远看像马赛克,近看像细胞,再放大一点就像蜂窝。上面这张图就是这个规则的输出,没有一块是我手画的。

这件事的名字叫 Voronoi 图(也叫泰森多边形)。它最有用的地方在于:一条规则同时给出「这块地归谁」和「谁和谁挨着」。实测数字:1600×1080 的画布上撒 200 个种子点,200 块格子的面积之和是 1728000,正好等于画布面积(偏差 1.3e-14%);把「最近邻居归属」逐像素和暴力算法逐点比对,一致率 100.0%;平均每个格子有 5.52 个邻居——而正六边形环环相扣时,每块的邻居数正好是 6。

公开仓库:https://atomgit.com/deli007/demo_park;本案例目录:https://atomgit.com/deli007/demo_park/tree/main/codearts-voronoi-honeycomb。成品是一个 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输入框当前值

图 2:鼠标悬停任意格子,右边浮出这一块的信息。图上这块是 4 边形:面积 4638.2 px²、周长 310.5 px、4 个邻居;被悬停的格子描边变白变粗,种子点也一起放大

关于「悬停」还有个小细节值得说:格子的边数和邻居数在这幅图里永远是同一个数。因为「有公共边」和「是邻居」在 Voronoi 图里是同一件事的两面——这也是为什么用「公共边长度」判相邻比用「两个点离得近」判相邻要靠谱,第四节的第 2 处改动就栽在这里。

二、为什么最后会长成六边形

先给一个能算的版本,而不是一句「自然选择」。把整个剖分看成一张平面图:每个格子是一个面,每条公共边是一条边。对面积完全均匀的剖分,欧拉公式给出每个面的平均边数趋近于 6;而面积一不均匀,就会出现几十条边的「巨无霸」格子和一堆三角形。

页面上的「Lloyd 松弛」按钮做的正是让面积变均匀:点一次,就把每个种子点挪到自己所在格子的面积质心上,然后重新剖分。它是迭代的,点得越多越均匀。默认 200 个种子时:

Lloyd 迭代次数格子面积方差平均邻居数6 边形格子数
0(随机)376690915.5240 个
566690905.4785 个
0(60 个种子)1758283895.13—
8(60 个种子)284812715.0726 个

方差从 37669091 掉到 6669090,降了 5.6 倍;6 边形的数量从 40 涨到 85。这两条数字放在一起,就是「Lloyd 松弛会收敛到蜂窝」这句话的可验证版本。

图 3:默认 200 个种子点按了 5 次 Lloyd 之后。和第一张图比,格子明显更均匀、更接近蜂窝;右栏统计里面积方差从 37669091 降到 6669090,直方图的柱子从「散在 3 到 9 边」收拢到 5 边和 6 边

图 4:把种子数调到 60,再点 8 次 Lloyd。格子变大之后蜂窝看得更清楚——60 块里 26 块是正六边形、26 块是五边形;平均邻居数掉到 5.07,因为格子越少,被画布四条边裁掉邻居的边界格子占比越高

图 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%1317026
只把 t 的符号改回来100.00%90

一个符号,从 0.19% 到 100%。这也是我为什么坚持「交付的页面必须自己重跑一遍」:这 7 处里有 6 处静态读代码读不出来,读出来的是把数字打出来对比——13170 个顶点的多边形,看一眼就知道不是多边形,是发散。

图 5:失败路径。把种子数填成 1 再点「重新生成」,页面不吐出假结果,而是把输入框描红、给出提示原文「种子数必须 ≥ 3,已保留上次有效值 (300)」,画布保持上一次有效的 300 块剖分不动——没有白屏,也没有 NaN

四、本地怎么复现

仓库里就一个文件,浏览器直接打开即可;也可以起个本地静态服务:

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% 左右。

五、亲手点三下

  1. 看尺子:默认 200 个种子点下,确认「平均邻居数」是 5.52 左右、「格子面积之和 / 画布面积」两边的数字完全一样,自检第 2 条一致率是 100.0%。
  2. 换个输入:把种子数改成 60,点「重新生成」,再连点 8 次「Lloyd 松弛」——格子会明显长成蜂窝,直方图从 3 到 9 边散开收拢到 5 边、6 边;此时平均邻居数会掉到 5.07,这是边界格子的必然结果,不是坏了。
  3. 看失败路径:把种子数填成 1 或者 abc,点「重新生成」——页面会把输入框描红、拒绝重建、保留上一次有效结果,并给出中文提示原文。页面上不会出现空画布,也不会出现 NaN。

六、准备环境:进入码道 Web 版

这个页面是用码道做的。码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。

浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz ,登录后就能在对话窗口输入需求,不需要装软件。

我的需求是一条消息把要做的事说全的:默认随机撒 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),所以统计面板里这两个数字永远一致;如果你想要的是「角上相接但不共边」的关系,这个页面不提供。
  • 随机种子每次刷新都变,截图里的具体数字只对应截图那一刻的那一组种子;复现到的是同一量级和同一结论,不是逐位相同的小数。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐