Python分形艺术实践:从递归树到曼德博集算法绘图
2026/9/24 21:39:16 网站建设 项目流程

在技术社区看到一张曼德博集生成图的时候,我第一反应不是“这图真好看”,而是“这玩意到底是怎么算出来的”。后来真正动手用Python去画分形,才发现把一个迭代公式变成屏幕上的一张图,中间隔着的小坑比想象中多得多。这篇不是我翻译官方文档,而是按我自己的折腾路线,把“用Python生成艺术:分形与算法绘图”这件事拆成几块:从分形的核心概念、环境准备,到递归画树、逃逸时间算法画曼德博集,再到最后如何保存出一张高清成品图。

如果你是那种“Python基础会一点,但没做过图形类项目”的人,这篇应该能让你少走不少弯路。我不太喜欢那种只贴代码不解释原理的教程,所以每一段都会尽量说清楚“为什么这么做”。分形绘图最大的价值也在这里:它会逼你接触递归、复数、坐标变换、颜色映射,还得容忍程序慢、调试难,最后却能收获一张隔天还能拿出来显摆的作品。

1. 分形不是“神秘图案”:先把自相似性和维数说透

1.1 从海岸线悖论理解分形

很多人一听到“分形”两个字,脑子里出现的是一堆彩色漩涡或者精细的雪花图案,觉得那是某种高深数学的产物。其实分形这个概念一点都不玄,它最先被数学家注意到,是因为一个反直觉的现象:海岸线长度到底是多少?

想象你用一把一米长的尺子去量英国海岸线,会得到某个长度;如果你换成一厘米长的尺子去量,因为能拐进更小的海湾,结果会比原来长很多;尺子再短一点,长度还会继续涨。这听起来很离谱,但真实世界就是这样的,因为海岸线的弯曲程度在越来越小的尺度上不断重复出现。这种“局部和整体长得有点像”的性质,就是分形最核心的标签——自相似性。

我在教朋友画分形的时候,一般会先让他们看科赫雪花的动图:一个三角形,每条边中间三分之一处凸起一个小三角形,反复操作十几次,就出现一朵精致的雪花。每一层看到的锯齿形状,和上一层几乎一模一样,只是变小了。这种规律重复出现的结构,比任何理论定义都直观。

分形还有一个特点必须提一下——它“不规则中藏着规则”。分形图案看起来很复杂,但生成它的算法往往短得让人怀疑人生。科赫雪花的递归规则只有一行:把每条线段中间的三分之一替换成两个等长线段的凸起。这也决定了我们能用Python轻松画出来,因为它本质上就是简单的规则不断重复。

1.2 分形维数到底是什么

热搜里反复出现“分形和分形维数的区别”,我觉得有必要单独说清楚,因为这个混在一起绝对搞晕。分形是一种几何对象,是一种结构;分形维数是对这种结构复杂程度的定量描述,是一个数字。

我们熟悉的几何维数是整数:直线是1维,平面是2维,空间是3维。但像科赫曲线这种分形,它既不是一根直线(因为到处是弯折),又填不满一个平面(因为很细),所以它的维数应该介于1和2之间。科赫曲线的维数计算结果是 log(4)/log(3),大约是 1.26。这就是“分形维数”的典型例子。

想深入理解维数的话,可以试一下盒计数法:用边长为s的小方格盖住图形,统计盖住的非空格子数N(s)。如果s缩小一半,普通直线上的N只增加一倍(维数=1),平面上的区域会增加四倍(维数=2),而科赫曲线增加的倍数介于两者之间,测量多次后在log-log图上做线性拟合,斜率就是它的维数。这个思路其实也能用Python实现,直接对分形图像做统计就行。

当然,实际绘画过程中你不需要真的去算维数,心里有这个概念就够。真正影响我们画图的是自相似性、递归规则、迭代边界这些,分形维数更多是“画完之后回来给作品写注释”时用到的理论装饰。

1.3 绘制分形的两条路线

用Python画分形,我接触到的方案大致分成两条路线。

第一条是“递归生成式”,典型代表是用turtle画科赫雪花、分形树、谢尔宾斯基三角形。这种方式模拟的是“小海龟拿着画笔在画布上爬”,每走一步就画一条线,递归函数控制它往哪个方向走、走多远、转弯多少度。这种路线最直观,非常适合理解分形的生成过程。

第二条是“迭代逃逸式”,典型代表是曼德博集和朱莉娅集。这种方式思路完全不一样:不是“画一条线”,而是对平面上的每一个像素点做复数迭代,根据迭代结果决定涂什么颜色。这是算法绘图中最迷人的部分,也是一个像素一个像素“算出来”的艺术品。

两条路线侧重点不同。递归生成式胜在视觉直观,写起来也简单,跑起来快;迭代逃逸式优点是可以生成几乎无限精细的细节,放大到任何地方都有新东西看,缺点是计算量大,优化不好会很慢。我的建议是先走第一条路线,花一两个小时体验“规则产生美”的乐趣,然后切入第二条路线,因为曼德博集才是很多人被分形震撼到的那个点。

2. 环境准备:跑通第一个分形程序之前别急着画

2.1 最省心的环境组合

如果电脑上还没装Python,别急着去官网下最新版然后一路Next。我推荐直接装一个干净的Python 3.10或者3.11版本,然后在项目目录里用venv建虚拟环境。虚拟环境的好处是被这些库和项目占用的依赖不会污染全局环境,以后换新项目也不容易产生版本冲突。

在命令行里依次执行下面这些操作就行:

python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install matplotlib numpy

这里我装了matplotlibnumpy,无论画曼德博集还是做颜色映射都会用到。turtle是Python标准库自带的,不需要单独安装,但要注意一个坑:如果你用的是某些精简版Linux或者Alpine之类的发行版,可能缺tkinter库,运行turtle时会直接报ModuleNotFoundError: No module named 'tkinter'。解决办法是补装系统包,比如Debian系的sudo apt install python3-tk。Windows和macOS用户基本不会遇到这个问题。

还有个热点问题,常见的“vscode里python环境配置”到底怎么配。我的经验是:如果你想弹出一个turtle画图窗口,不要直接在VS Code的集成终端里跑,因为集成终端对GUI窗口支持不好。打开系统终端,激活虚拟环境后再运行Python脚本,弹窗问题就少很多。这个我在第五节会细说。

2.2 海龟画图的坐标和状态模型

turtle之于Python,就像画板之于画家。你可以在脑海里把屏幕想象成一块坐标平面,正中心是(0, 0),x轴向右为正,y轴向上为正。有一只海龟,它的初始位置在原点,朝向右边。你调用forward(100)它就朝当前方向爬100像素,调用left(60)它就原地左转60度。

控制海龟的核心方法就那么几个,背下来就够:forward/backward前进后退,left/right转弯,penup/pendown提起笔和落下笔,pensize设置画笔粗细,color设置颜色,speed(0)设置为最快速度。还有一个技巧特别关键:turtle.tracer(0, 0)可以关闭自动刷新,等所有图形画完后再手动调用turtle.update()。这样能避免因为绘制线条太多导致画面一顿一顿的,属于分形绘图必备技能。

从我的实际经验来看,turtle稍微有点“面向过程”的味道,写起来很自由,但如果你把大量绘制逻辑堆在一起,代码会乱。建议把递归画图的函数单独抽出来,主流程只负责设置窗口、初始位置、调用函数。这样调整参数的时候不用在几百行代码里找。

2.3 第一屏分形:科赫雪花

先别直接上曼德博集,太复杂容易打击信心。科赫雪花是入门的绝佳素材,因为它只用递归和角度就能画出来,代码很短,效果却很惊艳。

科赫雪花的规则是这样的:初始是一个等边三角形。对每条边,把中间三分之一去掉,替换成两段线段,这两段线段向外凸起成一个60度的角。对替换后的每一条线段再次做同样的操作,迭代次数越多,雪花边缘越精细。

完整的turtle代码如下:

import turtle def koch_curve(t, length, depth): if depth == 0: t.forward(length) return length /= 3 koch_curve(t, length, depth - 1) t.left(60) koch_curve(t, length, depth - 1) t.right(120) koch_curve(t, length, depth - 1) t.left(60) koch_curve(t, length, depth - 1) t = turtle.Turtle() t.speed(0) turtle.tracer(0, 0) t.penup() t.goto(-150, 90) t.pendown() for _ in range(3): koch_curve(t, 300, 4) t.right(120) turtle.update() turtle.done()

每画一条边,需要调用递归函数四次。为什么是四次?因为规则把一条边分成三段,但中间一段被替换成两段,所以每次递归深入一层,线段数会乘以4。初始三角形有3条边,所以第depth层总线段数是3 * 4^depth。depth=4时有768条线段,depth=5是3072条,depth=6就超过一万二了。我用depth=4作为默认值,线条密度刚好,画面也不至于等太久。

这里有个小细节:turtle.done()在脚本里是让画布保持打开,但在某些VS Code外部终端里偶尔不生效。如果你发现运行后窗口一闪而过,可以改为turtle.exitonclick(),这个函数会在点击窗口后关闭,体验更稳定。

3. 递归的艺术:分形树的参数实验与视觉进化

3.1 递归如何映射为分形结构

递归是一种“函数调用自身”的编程方式,很多人学编程时对递归一脸懵,但用它画分形树就是天然的教学场景。想象一棵树:树干从地面出发,往上长到某处,分成两到三个侧枝;每个侧枝又继续往上长,再分成更小的侧枝;重复这个过程,直到枝条小到某个深度就不再分了。

用turtle表达这个逻辑,就是“往前走一段,左转一定角度,递归画子树,右转两倍角度,递归画另一棵子树,再左转回正,后退回节点”。每一层递归只做同样的事情,只不过树干越来越短、深度越来越小。

递归最反直觉的地方在于,你必须信任“函数会自己跑完”。很多初学者第一次看到递归分形树代码时会想,这不对吧,明明只写了两个子树调用,怎么长出来的树那么密密麻麻?因为每次调用会再次分裂,指数级增长。深度为12的一棵树,叶子节点就有4096个,整个结构包含的线段数更是数以千计。

3.2 分形树代码与参数调整

下面这棵分形树的代码是我反复调过参数的,默认分支角度30度,每层长度乘以0.7:

import turtle t = turtle.Turtle() t.speed(0) turtle.tracer(0, 0) t.left(90) t.penup() t.goto(0, -250) t.pendown() def tree(length, depth): if depth == 0: return t.forward(length) t.left(30) tree(length * 0.7, depth - 1) t.right(60) tree(length * 0.7, depth - 1) t.left(30) t.backward(length) tree(120, 12) turtle.update() turtle.done()

画完之后你就能理解我为什么说“参数实验”很重要。下面是我实测的一组对照数据,新手可以直接拿来参考:

深度总线段数(约)画面效果
6127骨架清晰,适合调试
91023轮廓完整,细节适中
128191树形饱满,推荐默认
1565535非常密集,不开tracer会明显卡顿

长度衰减系数0.7会让每层枝干迅速变短,整体看起来紧凑。如果想画一棵“张开式”的树,衰减系数调到0.75或0.8;想要一个瘦高的树,就调低到0.6。分支角度也是同理,15度出来的树更收敛,40度出来的树更散乱。

另外一个好玩的变体是给角度和长度加入随机。真实树木不可能每根枝条都对称,加一点随机扰动后会突然变得有生命力。我曾把left(30)改成left(random.uniform(15, 45)),再把长度衰减改为0.6~0.8之间的随机值,整棵树瞬间从“工科生作业”变成了“数字艺术品”。你甚至可以给末端枝条换成绿色,模拟树叶,这只需要判断当前深度是否小于等于2。

3.3 用颜色和画笔属性增强画面

分形树默认是黑色细线,即便形状出来了,观感也就是个“骨架”。但用turtle上色很简单,我一般按照深度来控制颜色:主树干用棕色,越往末端颜色越偏绿。

代码段可以这样改:

def tree(length, depth): if depth == 0: return if depth <= 2: t.color("forest green") t.pensize(1) else: t.color("saddle brown") t.pensize(max(1, depth // 2)) t.forward(length) t.left(random.uniform(15, 45)) tree(length * random.uniform(0.6, 0.8), depth - 1) t.right(random.uniform(30, 90)) tree(length * random.uniform(0.6, 0.8), depth - 1) t.left(random.uniform(15, 45)) t.backward(length)

这个技巧的本质是“用递归深度映射视觉属性”,其实后面画曼德博集时也差不多:用迭代次数映射颜色。这种映射思想在算法绘图里是通用的,学会一次,处处用得上。

pensize(max(1, depth // 2))这一行值得解释一下:深度大表示主干,应该粗一点;深度小表示末梢,细一点。如果不调整画笔粗细,整棵树所有线条都是一个宽度,层次感很差。加了这一步后树干和树枝的粗细差异立刻明显,视觉上更接近真实植物。

4. 逃逸时间算法:画出曼德博集

4.1 复杂的数学,简单的迭代

如果说分形树是用递归画出来的,那曼德博集就是“算”出来的。它的数学定义其实很短:对复平面上的每一个点 c,从 z=0 开始不断迭代公式 z = z^2 + c。如果迭代很多次之后z的模长仍然没有超过2,就认为这个点属于曼德博集;一旦超过2,就记录下此时迭代了多少次。

为什么判断条件是“模长超过2”?因为一旦 |z| 超过2,接下来的迭代中 z 的模长必然会越变越大,不可能再收敛回去了。这个定理保证了我们可以用一个固定阈值判断发散行为,不然算法根本无法终止。

这里需要一点复数基础,但其实只需要知道一个复数 z = x + iy,它的模长是开根号(sqrt(x^2 + y^2)),平方后模长会变大。复数的乘法在Python里用z * z就直接算好了,完全不用自己处理实部虚部,这也是为什么Python特别适合做这种可视化算法。

用迭代逃逸的思路生成图像,本质上是在对“整个复平面”采样。我们把屏幕上一个矩形区域划分成800×600的网格,每个网格点对应一个复数c,对这个c做最多max_iter次迭代。最终每个像素的颜色由“逃逸速度”决定——也就是迭代多少次才超过2。

4.2 完整的曼德博集绘制代码

这里给出一个能直接跑的版本,先不优化,因为它足够直白:

import numpy as np import matplotlib.pyplot as plt def mandelbrot(c, max_iter=100): z = 0 for i in range(max_iter): z = z * z + c if abs(z) > 2: return i return 0 xmin, xmax = -2.0, 1.0 ymin, ymax = -1.2, 1.2 width, height = 800, 600 xs = np.linspace(xmin, xmax, width) ys = np.linspace(ymin, ymax, height) img = np.zeros((height, width)) for j, x in enumerate(xs): for i, y in enumerate(ys): img[i, j] = mandelbrot(complex(x, y), max_iter=100) plt.figure(figsize=(10, 8)) plt.imshow(img, extent=(xmin, xmax, ymin, ymax), cmap='inferno') plt.show()

在双重循环里,img[i, j]的行索引i对应y,列索引j对应x。为什么要这样安排?因为matplotlib的imshow默认第一个维度是行(图像高度方向),第二个维度是列(宽度方向)。如果行列搞反了,图像会横过来。

曼德博集在复平面上的实际位置大约是x从-2到1、y从-1.2到1.2,这是因为它的范围落在以原点为圆心、半径约2的圆内。这个范围可以微调,但第一次跑建议用这个默认值,保证曼德博集的经典图形完整出现。

max_iter是个关键参数。设太小,比如10,M集的边界会模糊得像糊了一层雾;设太大,比如1000,边界非常细腻但计算量暴增。我一般先用100跑全图看个大概,等找到感兴趣的局部区域后再提高到300甚至500重新渲染。

4.3 让色彩有意义:映射逃逸速度

如果只是把迭代次数原样扔给imshow,图片颜色会偏暗而且层次感差。因为大部分像素要么迭代很少就逃逸(比如1~20),要么永不逃逸(集合内部是0),分布很不均匀。直接线性映射会把很多颜色细节挤在极小的数值范围里。

我自己比较喜欢的做法是先取对数压缩:

img_display = np.log1p(img)

np.log1p(img)计算log(1 + img),能把几百万次迭代的大动态范围压缩到适合人眼观感的范围。这个技巧在可视化领域非常常见,不只是分形绘制会用,画火焰热力图、地震分布图都会用到。

另一个问题是曼德博集内部(逃逸次数为0)按默认色条会显示为最深色,但不同colormap里的“最深色”不一定是黑色。想要内部纯黑,可以先把0值像素替换为-1,然后设置vmin=-1, vmax=np.log1p(max_iter)

img_show = np.log1p(img) img_show[img == 0] = -1 plt.imshow(img_show, cmap='inferno', vmin=-1, vmax=np.log1p(100))

关于配色,我实测下来最好用的是infernomagmahot这三个色条。inferno的优势是内部接近黑色,外部从暗红到亮黄过渡,层次丰富又有艺术感;hot则是蓝-青-黄-红-白,颜色更鲜艳,适合做偏广告风格的图。你可以每个都试一遍,反正只是改一个参数的事。

4.4 从曼德博集到朱莉娅集

画完曼德博集之后,我发现大家几乎都会顺藤摸瓜去画朱莉娅集(Julia set)。朱莉娅集和曼德博集的迭代公式一模一样,差别只在一点:曼德博集是固定起点 z=0、遍历参数c;朱莉娅集是固定参数c、遍历起点z。

朱莉娅集的代码只需要把函数头改一下:

def julia(z0, c=-0.8 + 0.156j, max_iter=100): z = z0 for i in range(max_iter): z = z * z + c if abs(z) > 2: return i return 0

然后遍历复平面上的每个z0点,其他逻辑和曼德博集完全一样。同样一个公式,只是交换了“固定参数”和“遍历参数”,生成的图案千差万别。c取不同值,朱莉娅集可以是饱满的连通的云朵状,也可以是破碎的尘埃状。我经常花很长时间去试不同的c值,就像在玩一个参数寻宝游戏。

一个很有价值的关联知识是:c就是你在曼德博集上的一个坐标点。c在M集内部,对应的朱莉娅集是连通的;c在M集外部,对应的朱莉娅集就是碎成粉末的“法图尘埃”。所以你可以把曼德博集理解成一张“朱莉娅集的地图”,这是分形理论里很优雅的一点。

5. 实测过的坑:从画不出来到高清出图

5.1 turtle窗口不出现:环境与调用链问题

我在制作分形教程踩过的最多的坑,就是turtle窗口弹不出来。具体场景一般是这样:在编辑器里写好了代码,按F5运行,终端没有任何报错,但屏幕上就是没有画布。这种情况在VS Code集成终端里特别常见,因为某些集成环境对GUI弹窗进程的支持有限。

解决办法有几个,按推荐程度排序:

  • 在系统终端(Windows的cmd、macOS和Linux的shell)里先激活虚拟环境,再运行脚本,这样最稳。
  • 脚本末尾不要用turtle.done(),改用turtle.exitonclick(),避免窗口一闪而过。
  • 如果还是一闪而过,可以用input()turtle.done()之前阻塞一下,但这只是治标不治本。

turtle还有一个隐藏坑:某些Linux发行版的Python包没有编译tkinter支持,无论你怎么写代码都弹不出窗口。这就是我在2.1节提到的安装python3-tk才能解决的情况。

5.2 曼德博集渲染太慢的优化路线

第一次跑曼德博集的时候,如果直接上800×600且max_iter=500,你会体验到什么叫“卡到怀疑人生”。纯Python双重循环要执行80万次复数运算,每次数乘、加法、模长计算都是Python解释器在跑,几分钟都不一定能出图。

优化的第一步是降低max_iter。先设成50或100,保证能看到形状,再逐步提高。第二步是缩小渲染区域,比如只渲染M集边界附近的一个小方块。边界附近细节最丰富,计算量却小得多。

第三步就是向量化。用numpy把整个平面的复数网格一次性算出来,利用数组的批量运算能力替代Python循环,速度通常能提升几十倍:

import numpy as np import matplotlib.pyplot as plt def mandelbrot_numpy(xmin, xmax, ymin, ymax, width, height, max_iter=100): x = np.linspace(xmin, xmax, width).reshape(1, -1) y = np.linspace(ymin, ymax, height).reshape(-1, 1) c = x + 1j * y z = np.zeros_like(c) img = np.zeros(c.shape) for i in range(max_iter): yet_to_escape = np.abs(z) < 2 img[yet_to_escape] = i z[yet_to_escape] = z[yet_to_escape] ** 2 + c[yet_to_escape] return img

这段逻辑维持一个布尔掩码yet_to_escape,只对那些仍在收敛区域内的点继续迭代和计数。它的思路和纯循环版一模一样,但每次迭代都作用在整个数组上,只有还没逃逸的点才会被更新。实测600×600、max_iter=200只需一两秒,和纯Python循环的几分钟形成鲜明对比。

如果你连向量化都嫌不够,还可以用numba给纯Python的mandelbrot函数加@jit(nopython=True)装饰器,编译成机器码运行,速度也会非常可观。我个人的建议是:项目阶段用向量化版足够,而且写起来逻辑清晰好调试;等你想做那种超大分辨率壁纸时再去折腾numba或者并行。

5.3 图像倒置与颜色失真的常见处理

画出来的曼德博集倒过来,这个问题我第一次遇到时还认真怀疑了十分钟人生。原因在于,imshow默认把数组第一行画在图像顶部,而我们生成img时,第一行对应的是y的最小值(复平面下方)。所以在数学上正确的ys顺序到了imshow里就变成上下颠倒。

解决办法最直接的是加参数origin='lower'

plt.imshow(img, extent=(xmin, xmax, ymin, ymax), origin='lower', cmap='inferno')

或者你可以在生成数组时,把y从大往小排列:

ys = np.linspace(ymax, ymin, height)

两种方法等效,任选其一即可。这个坑在画Julia集时一样会遇到,最好养成习惯,看到图像不对劲先检查orientation

颜色失真主要是两个原因。一个是没有做log压缩,导致边界细节灰蒙蒙;另一个是内部纯黑区域和外部暗色区域混在一起。我在4.3节给出的img_show[img == 0] = -1方案,基本上能解决这两个问题。如果你想更精确地控制颜色,还可以用matplotlib.colors里的LogNorm,不过对大多数人来说,np.log1p就已经够用了。

5.4 如何输出高清大图

画完图之后,大多数人第一个动作是截图存个PNG。截图当然没问题,但分辨率受限于屏幕,而且放大一点全是锯齿。更专业的做法是用matplotlib直接保存高清图:

fig, ax = plt.subplots(figsize=(12, 8)) ax.imshow(img_show, extent=(xmin, xmax, ymin, ymax), cmap='inferno') ax.axis('off') plt.savefig('mandelbrot_hd.png', dpi=300, bbox_inches='tight')

这里的figsize决定画布尺寸,dpi决定保存时的像素密度。两者相乘就是输出图片的最终分辨率。举个简单例子,figsize=(12, 8)dpi=300,保存出来就是3600×2400像素,完全够当壁纸用。axis('off')能去掉坐标轴,让画面更纯净。

turtle那边保存输出相对麻烦一些,因为它本身不是面向高质量渲染的工具。简单粗暴的做法是把窗口调大,pensize(2)再截图;如果想要矢量图,可以用:

canvas = turtle.getcanvas() canvas.postscript(file='tree.eps')

生成的是EPS矢量文件,可以用Ghostscript或者ImageMagick转成PNG。不过说实话,如果目标是高清作品,我更推荐把turtle当作原型探索工具,等确定了构图和参数后,再用matplotlib重写绘制逻辑,这样出图质量高得多。

我自己从第一张灰蒙蒙的科赫雪花到现在能稳定输出高清分形作品,中间踩过的坑基本就是上面这些。分形绘图这个题材有一个特点:入门门槛不高,但天花板极高。同一套公式,换一个配色、换一个参数、换一个缩放区域,出来的作品完全不一样。我特别喜欢在M集边界附近到处放大,有时候无意中撞见一个像海马尾巴又像星系旋臂的区域,那种感觉比看任何教程都震撼。希望这篇内容能让你少走点弯路,早点画出第一张真正属于自己的算法作品。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询