撒下 200 个种子点,平面为什么自己长成蜂窝?Voronoi 图实验室

在画布上随机撒 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% 左右。

五、亲手点三下

  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 版: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),所以统计面板里这两个数字永远一致;如果你想要的是「角上相接但不共边」的关系,这个页面不提供。
  • 随机种子每次刷新都变,截图里的具体数字只对应截图那一刻的那一组种子;复现到的是同一量级和同一结论,不是逐位相同的小数。
相关推荐
大文说跨境2 小时前
多账号环境隔离方案技术选型:指纹浏览器、VPS 与云手机的三种架构对比
java·开发语言·前端
咕白m6252 小时前
使用 C# 将 TIFF 转换为 PDF
前端·c#
全栈项目管理程序猿2 小时前
ArcGIS JS 基础教程(28):图层渲染顺序管理
前端·javascript
huakoh2 小时前
MCP 工具报错走哪条通道:三条探针的最小复现检查
前端
用户033307413952 小时前
ParadeDB 的 pg_search 0.26 把十词 BM25 搜索从 129ms 降到 29ms
前端
不爱说话郭德纲2 小时前
从“点点点”到一键出包:我把 uni-app x Android 离线打包做成了脚本
android·前端·uni-app
Lstone73642 小时前
从 Jetpack Compose 到 CMP:跨平台开发学习笔记
前端
deli0072 小时前
多加一粒沙,整堆为什么就塌了?sandpile 模型 20 万粒实测
前端
涛涛ing2 小时前
乱序HTML流正式进入浏览器:前端流式渲染的“框架特权”被终结了
前端