一颗种子占一个位置:第 n 颗的角度是n × α,半径是R × √(n/N)。规则就这一行,喂进去的角度α不同,屏幕上长出来的东西完全不同。用真的黄金角137.50776405003785°、5000 颗种子,实测:重复方向 0 条(3000 以内找不到任何角度周期),最近邻最小间距9.97 px、平均10.56 px、最大10.97 px,角向聚集度1.08(1° 粒度下峰值/均值,1.00 就是绝对均匀),最近邻步长众数Δn = 144(55% 的种子把第 144 号邻居当成最近的),页面 13 条自检13/13 通过,全量重算20.8 ms。
然后很多人记的那个数——137.5°填进去,同一份代码立刻翻车:屏幕上冒出144 条清清楚楚的辐条(图 2)。原因是小学算术:137.5/360 约分完是55/144,转 144 次角度回到原点,于是种子只能落在 144 个方向上。再换成137.4°是 600 条,137.6°是 225 条——137.4/360 = 229/600,137.6/360 = 86/225。一句话:你能写出几位小数的角度,它的分母有多大,辐条就有多少条;只有写不出分母的角度,才真的没有辐条。
公开仓库:https://atomgit.com/deli007/demo_park,本案例目录:https://atomgit.com/deli007/demo_park/tree/main/codearts-phyllotaxis-golden-angle。成品是一个index.html,双击就能跑,不联网、不引第三方库、没有构建步骤。文中所有读数都是我把交付的页面落到本机、自己重新跑出来的,不是转述对话里的结论。
边界先说清楚:这是个教学/演示用的玩具实验室。它画的是理想模型——种子当成等大的点、半径严格按 √n 增长、不考虑生长过程中花盘变大;它也不是植物学结论,向日葵真实的叶序会受品种、光照和生长激素影响。另外 3000 这个周期搜索上限是我自己定的,理论上存在分母大于 3000 的角度(比如 137.3° 的真实分母是 3600),所以页面上「无(≤3000)」只表示"在 3000 以内找不到",不表示"无理数"。
一、先看结果:只改了一个角度,画面就换了一个物种
同一份代码、同样的 5000 颗种子和 440 px 花盘半径,只动α:
| α | 角度周期 q | 重复方向数(辐条) | 最小间距 | 平均间距 | 最大间距 | 角向聚集度 | 页面自检 |
|---|---|---|---|---|---|---|---|
| 137.50776405003785°(真黄金角) | 无(≤3000) | 0 | 9.97 px | 10.56 px | 10.97 px | 1.08 | 13/13 |
| 137.5° | 144 | 144 | 6.38 px | 8.74 px | 11.32 px | 2.52 | 13/13 |
| 137.6° | 225 | 225 | — | — | — | — | 13/13 |
| 137.4° | 600 | 600 | — | — | — | — | 13/13 |
| 360×13/34 = 137.6470588235° | 34 | 34 | 1.50 px | 2.74 px | 10.85 px | 10.66 | 12/13 |
(137.4° 和 137.6° 两行的间距空着,是因为它们的重复方向数和分母同一量级,要把种子数开到 2000 以上才铺得满,我把这一组单独放在 N=2000 的表里量;后文有数。)
最小间距那一列最直观:真黄金角 9.97 px,13/34 只有 1.50 px——差了 6.6 倍,而花盘半径是一样的,种子的总数量也是一样的。图上更直观,13/34 的版本就是 34 根粗射线,中间稀稀拉拉:
二、为什么值得看:黄金角不是玄学,是「最难逼近」的那个角度
向日葵、松果、菠萝的种子螺旋为什么都长成两族斜着的螺旋线,这个问题从开普勒那会儿就被问过。解释它的关键不是"黄金比例好看",而是一个纯数论性质:黄金角是最难被有理数逼近的角度。
把α/360写成分数,分母越小,转不了几次就回原点,种子就挤在几条射线上(图 2 的 144 条就是这么来的)。反过来,如果α/360是无理数,角度永远不重复,种子会被"摊"得越来越开——但"摊得开"也有高低。黄金比例 φ 的连分数是[1;1,1,1,…],全是 1,是所有数里收敛最慢的,所以它的分数近似最差,剩下的空隙最均匀。这就是 137.50776405003785° 能长出图 1 那种花盘的原因,也是这个页面唯一想讲清的一件事:
- 把
α写成有限位小数,你就给了它一个分母,屏幕上就一定会有辐条; - 分母越大辐条越细(144 条已经很难看出是"辐条")、间距越接近理想值;
- 而真黄金角在 3000 以内压根没有分母,所以没有辐条。
页面右侧的「Fibonacci 收敛表」就是按这条思路排的:5/13、8/21、13/34、21/55、34/89、55/144,横着一排看下去,最小间距从 2.87 px 一路涨到 21.55 px,同时辐条数从 13 涨到 144(也就是角度越来越"细")。另一边「十进制角度的隐藏分母」表更直接:
| α | 约分后的分数 | 周期 q(≤3000) | 实测辐条数 |
|---|---|---|---|
| 137.3° | 1373/3600 | 无(>3000) | 0 |
| 137.4° | 229/600 | 600 | 600 |
| 137.5° | 55/144 | 144 | 144 |
| 137.6° | 86/225 | 225 | 225 |
| 137.50776405003785° | 无(无理近似) | 无(≤3000) | 0 |
这张表在页面上是自动算的:把角度对 360 取模、量化到小数点后 6 位,统计每个方向出现了几次,出现 2 次以上的方向就算一条辐条;周期 q 是在 1~3000 里找最小的、能让q × α落到 360 整数倍上的那个数。
三、实验室长什么样:4 个参数、5 个指标、13 条自检
参数只有四个:发散角α(90°~180°,可输到小数点后 6 位)、种子数N(1~20000)、花盘半径R、尺寸系数(种子点半径 = 系数 ×R/√N,所以种子永远是"刚好挨着"不重叠),外加一个"螺旋连线"开关,能把第 n 颗和第 n+1 颗连起来看螺旋线怎么绕。
指标里有两个是这篇的核心:
- 重复方向数(辐条):把 5000 个角度量化到 1e-6 之后去重,出现 2 次以上的方向有几条。真黄金角是 0,137.5° 是 144。
- 角向聚集度:把 360° 切成 360 个 1° 的格子,最大格子里的种子数 ÷ 平均每格种子数。1.00 = 完全均匀;黄金角 1.08,137.5° 是 2.52,13/34 是 10.66。这个指标的好处是没有阈值、不需要调参,直接就是一个"看起来有多像辐条"的数。
另外三个是最近邻最小/平均/最大间距(网格分桶算出来的),以及最近邻步长众数Δn——就是"每个种子的最近邻居是第几号"。黄金角 5000 颗时 Δn 的众数是144(占 55%),次家族是89;N=20000 时是 233 和 377。这几个数都是斐波那契数列里的项,和"向日葵上看得见的斜列数"是同一回事,算是这个页面顺手给出的一点真实结构。
13 条自检全部渲染在页面上,每条都带实测值/期望值(图 4)。我特意把它们设计成任何参数下都能失败:默认参数下 13/13 全绿;把角度滑到 13/34,第 8 条「平均最近邻间距 ≥ 0.5×2R/√N」会当场变红(2.74 px < 6.22 px)。
四、给码道提的需求原文(可直接复制)
用单个 index.html(原生 JS + Canvas,不引第三方库、不做构建)做一个「叶序 phyllotaxis 实验室」, 把向日葵花盘里种子按黄金角螺旋排列的现象做成可交互的可视化工具,界面全中文。 核心模型:第 n 颗种子角度 θ = n × α,半径 r = R × √(n/N),α 默认 360/φ² 并在 90°~180° 可调。 实时指标:最近邻最小/平均距离、角向聚集度、检测到的径向辐条数、可见斜列(parastichy)条数; 实测值与理论值并排显示、给出相对误差。 自检面板:至少 12 条断言,每条渲染「名称/实测值/期望值/通过与否」,不许出现恒真的 pass: true 断言; 断言里要包含 360×(1-1/φ) 与 360/φ² 的差 < 1e-9、α=137.5° 且 N≥1000 时辐条数应为 144、 换成真黄金角后辐条数应 ≤ 8、绘制种子数等于输入 N、非法输入会回退。 Fibonacci 收敛表:α 依次取 360×5/13、8/21、13/34、21/55、34/89、55/144,逐行显示分母、实测辐条数。 健壮性:种子数 1~20000 的整数、发散角 90~180 的有限数,非法输入给中文提示并回退上一次有效值,不白屏。 性能:N=5000 时重算+重绘在 100ms 以内,页面显示重算耗时。 完成后打开预览,让我直接看到运行效果。需求里有三句话最值钱,后面都验证到了:「不许出现恒真的 pass: true 断言」(交付版里真有两处)、「实测值与理论值并排、给出相对误差」(让我一眼看出哪个指标不靠谱)、「非法输入给中文提示并回退」(失败路径截图就靠它)。
五、验证与踩坑:我改掉的四处
1)辐条检测用「阈值 + 连续区间」,数错了 18 倍。交付版的算法是:把 360° 分成 720 格统计密度,超过均值 1.5 倍的格子算"在辐条上",再要求连续 2 格以上才计一条。实测同一组参数:α=137.5° 报8 条(144 条才是对的),α=90° 报 3 条(应该是 4 条),α=120° 报 2 条(应该是 3 条)。两个毛病叠在一起——辐条只有 0.5° 宽、占不满 2 格;而 90° 那种粗辐条又会把相邻格算成一条。改成"量化到 1e-6 去重后统计重复方向"之后,13/34 → 34 条、55/144 → 144 条、90° → 4 条,全部和分母对得上。
2)「角向均匀度 CV」这个指标本身不成立。交付版用 720 个格子算变异系数,可 500 颗种子摊到 720 个格子里,大多数格子是 0、少数是 1,CV 直接被格子粒度支配——默认参数下它是 0.7373,而交付版自己的断言写着「CV < 0.5」,于是一打开页面就有一条断言是红的。这不是 bug 是度量设计错了:种子数少于格子数时,CV 量的根本不是"均匀不均匀",而是"格子有多粗"。换成峰值/均值之后,黄金角 1.08、137.5° 2.52、13/34 10.66,同一个尺度下可比,也不再需要拍一个阈值。
| 指标 | 交付版实测 | 重做后 | 说明 |
|---|---|---|---|
| α=137.5° 的辐条数 | 8 | 144 | 应等于 55/144 的分母 |
| α=90° 的辐条数 | 3 | 4 | 应等于 360/90 |
| α=120° 的辐条数 | 2 | 3 | 应等于 360/120 |
| 默认参数下「均匀度」 | CV 0.7373(断言红) | 聚集度 1.08(绿) | 去掉拍脑袋阈值 |
| 「可见斜列数」 | 9 / 16 / 28 / 33 乱跳 | 改为最近邻步长众数 Δn | 72 格方向直方图数峰,纯噪声 |
3)两处断言是恒真的。交付版的自检里,当N < 1000时,「α=137.5° 辐条数=144」和「真黄金角辐条数=0」这两条会走进pass: true的兜底分支——默认参数 N=500,也就是说默认状态下有一整块检查是"永远绿"的。这比没有检查更危险:它会让人以为验过了。我把它们做成不依赖当前参数、每次都用 N=1000 独立复算一遍的版本,并且补上了"周期 q 与实测辐条数必须一致"这条最强的断言——把角度改成 137.4°,它照样能算出 600 条并判对。
4)quantAngle把 -1e-13 归一成了 360.000000,凭空多出一条辐条。角度对 360 取模时,浮点误差会给出 -1e-13 这种值,加 360 之后变成 359.9999999999999,四舍五入到 6 位小数就是360.000000,和 0.000000 变成了两个方向。症状很隐蔽:13/34 应该 13 条辐条,页面上是14 条。修法是取模后再做一次% 360000000的整数归一,让 360.000000 回到 0.000000。修完 13 条。
性能:交付版的最近邻统计是每个种子跟其余所有种子比一遍,N²次距离计算。5000 颗种子我单独量了这一趟:60.4 ms(还没算角度分析和重绘)。改成网格分桶(格子边长取 2.5×R/√N,就近查一圈)之后,包含最近邻、角度分析、13 条自检和重绘的全量重算是 20.8 ms,而且两边算出的最小间距完全一致(9.968 vs 9.97 px)——换算法没换答案。N=20000 时全量重算是131.1 ms,这条我没做到需求里的"100 ms 以内",如实写在这里:需要的是把角度分析和自检也一起省掉,而不是再优化最近邻。
六、失败路径实测
四条输入校验的提示都是页面上点出来的原文:
- 发散角填 250:
⚠️ 发散角必须是 90°~180° 之间的数字,已回退到上一次的有效值 137.50776405003785°; - 种子数填 99999:
⚠️ 种子数必须是 1~20000 的整数,已回退到上一次的有效值 5000; - 花盘半径填 -5:
⚠️ 花盘半径必须是正数,已回退到上一次的有效值 440; - 尺寸系数填 9:
⚠️ 尺寸系数必须是 0~3 之间的数字,已回退到上一次的有效值 0.9;
提示挂在页面顶部,输入框自己改回上一次的有效值,画面继续正常渲染、没有白屏也没有停摆(图 5)。
另一条失败路径在断言里:把 α 设成 13/34 = 137.6470588235°,第 8 条断言立刻变红——平均最近邻间距 2.74 px,低于 0.5×2R/√N = 6.22 px 的下限。这条红是应该出现的:34 个方向上挤 5000 颗种子,相邻两颗最近只隔 1.50 px,肉眼也看得出来挤。能失败的检查才算检查,这条断言就是拿来做这件事的。
七、准备环境:进入码道 Web
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示:浏览器打开码道 Web 版 https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后把上面那段需求贴进对话窗口,剩下的交给它,生成完直接在页面里看效果。不需要装软件。
八、本地复现
单文件应用,不需要装任何东西:
gitclone https://atomgit.com/deli007/demo_parkcddemo_park/codearts-phyllotaxis-golden-angle# 方式一:直接双击 index.html# 方式二:起个本地静态服务python-mhttp.server8000# 然后打开 http://localhost:8000/index.html我自己复核用的操作序列(可以照着点,读数应该基本一致):打开页面 → 窗口拉大一点,让画布铺开到 1000 px 见方(花盘半径会自动取画布宽度的 0.44 倍,这里是 440)→ 默认就是黄金角、5000 颗种子 → 读右栏:重复方向 0 条、最小间距 9.97 px、平均 10.56 px、聚集度 1.08、Δn = 144(55%)、13/13 通过 → 把发散角改成137.5,辐条数变 144、角度周期变 q = 144 → 改成137.6470588235(360×13/34),辐条数 34、最小间距掉到 1.50 px、自检变 12/13 → 点「黄金角」按钮回到默认。这套读数在不同机器上会有零点几毫秒和百分位小数级别的差别,但辐条数、分母、Δn 这些整数是完全确定的。
九、使用码道体会
这次最有用的经验仍然是把「怎么验收」写进需求,而且要比上次写得更具体。上一轮我写的是"页面里要有自检面板",这轮我加了三条硬约束:断言不许恒真、实测值/理论值必须并排给出相对误差、每个参数都要有中文的非法输入提示。结果是交付版一打开就能看出问题——第 6 条断言红着(CV 0.7373 对 CV<0.5),我顺着这条线索才挖出"CV 这个指标在稀疏分箱下不成立"这个更本质的毛病。如果只写"帮我加点检查",这一条很可能被"改成 CV<1.0"糊过去。
第二条经验是AI 写这类数值模型,规则通常是对的,度量方式才是坑。这个页面的核心模型(θ = nα、r = R√(n/N)、黄金角 360/φ²)交付版一个符号都没错,黄金角恒等式差值 2.842e-14 也说明公式写得挺准;真正翻车的全是"怎么量"——辐条怎么数、均匀度怎么定义、NaN 和浮点边界怎么处理。这些恰恰是光读代码看不出来的,必须跑起来、拿可证伪的数字去对(教科书里的分母、360/90=4 这种小学生都算得出的期望值)。
第三条是**“看起来在检查"比"没有检查"更危险**。交付版 12 条自检里,两条是pass: true、一条阈值拍错、几条指标本身没意义,默认参数下它显示"11 绿 1 红”,很容易让人以为"大体没问题"。现在的 13 条我逐条问过自己同一个问题:“它在什么参数下会红?”——答不上来的就删掉重写。
十、总结
- 叶序的规则就一行:第 n 颗种子角度
n × α、半径R × √(n/N);角度不同,长出来的东西完全不同。 - 137.5° 和真黄金角 137.50776405003785° 看着只差 0.008°,实测差 144 条辐条:137.5/360 = 55/144,转 144 次就回原点。137.4° 是 600 条,137.6° 是 225 条,辐条数就是分母。
- 真黄金角 5000 颗种子:重复方向 0 条,最小/平均/最大最近邻间距 9.97 / 10.56 / 10.97 px,聚集度 1.08,最近邻步长众数 Δn = 144(斐波那契项),13 条自检全绿,全量重算 20.8 ms。
- 交付版有三个真问题:辐条检测数错(137.5° 报 8 条)、均匀度 CV 指标不成立(默认参数下断言就红)、两处恒真断言;另外还有一个 360.000000 的浮点边界,会让辐条数多算 1 条。
- 最近邻从
N²全对全改成网格分桶:N=5000 的最近邻那一趟 60.4 ms → 全量 20.8 ms,最小间距两边一致(9.968 px);N=20000 全量 131.1 ms,仍然是偏慢的,如实写出来。 - 判断一个自检面板值不值得信,就看它能不能红:把 α 滑到 13/34,第 8 条断言当场变红(平均间距 2.74 px < 6.22 px)。