在技术社区看到一张曼德博集生成图的时候我第一反应不是“这图真好看”而是“这玩意到底是怎么算出来的”。后来真正动手用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这里我装了matplotlib和numpy无论画曼德博集还是做颜色映射都会用到。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。depth4时有768条线段depth5是3072条depth6就超过一万二了。我用depth4作为默认值线条密度刚好画面也不至于等太久。这里有个小细节turtle.done()在脚本里是让画布保持打开但在某些VS Code外部终端里偶尔不生效。如果你发现运行后窗口一闪而过可以改为turtle.exitonclick()这个函数会在点击窗口后关闭体验更稳定。3. 递归的艺术分形树的参数实验与视觉进化3.1 递归如何映射为分形结构递归是一种“函数调用自身”的编程方式很多人学编程时对递归一脸懵但用它画分形树就是天然的教学场景。想象一棵树树干从地面出发往上长到某处分成两到三个侧枝每个侧枝又继续往上长再分成更小的侧枝重复这个过程直到枝条小到某个深度就不再分了。用turtle表达这个逻辑就是“往前走一段左转一定角度递归画子树右转两倍角度递归画另一棵子树再左转回正后退回节点”。每一层递归只做同样的事情只不过树干越来越短、深度越来越小。递归最反直觉的地方在于你必须信任“函数会自己跑完”。很多初学者第一次看到递归分形树代码时会想这不对吧明明只写了两个子树调用怎么长出来的树那么密密麻麻因为每次调用会再次分裂指数级增长。深度为12的一棵树叶子节点就有4096个整个结构包含的线段数更是数以千计。3.2 分形树代码与参数调整下面这棵分形树的代码是我反复调过参数的默认分支角度30度每层长度乘以0.7import 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从 z0 开始不断迭代公式 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_iter100): 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_iter100) plt.figure(figsize(10, 8)) plt.imshow(img, extent(xmin, xmax, ymin, ymax), cmapinferno) plt.show()在双重循环里img[i, j]的行索引i对应y列索引j对应x。为什么要这样安排因为matplotlib的imshow默认第一个维度是行图像高度方向第二个维度是列宽度方向。如果行列搞反了图像会横过来。曼德博集在复平面上的实际位置大约是x从-2到1、y从-1.2到1.2这是因为它的范围落在以原点为圆心、半径约2的圆内。这个范围可以微调但第一次跑建议用这个默认值保证曼德博集的经典图形完整出现。max_iter是个关键参数。设太小比如10M集的边界会模糊得像糊了一层雾设太大比如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, vmaxnp.log1p(max_iter)img_show np.log1p(img) img_show[img 0] -1 plt.imshow(img_show, cmapinferno, vmin-1, vmaxnp.log1p(100))关于配色我实测下来最好用的是inferno、magma、hot这三个色条。inferno的优势是内部接近黑色外部从暗红到亮黄过渡层次丰富又有艺术感hot则是蓝-青-黄-红-白颜色更鲜艳适合做偏广告风格的图。你可以每个都试一遍反正只是改一个参数的事。4.4 从曼德博集到朱莉娅集画完曼德博集之后我发现大家几乎都会顺藤摸瓜去画朱莉娅集Julia set。朱莉娅集和曼德博集的迭代公式一模一样差别只在一点曼德博集是固定起点 z0、遍历参数c朱莉娅集是固定参数c、遍历起点z。朱莉娅集的代码只需要把函数头改一下def julia(z0, c-0.8 0.156j, max_iter100): 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_iter500你会体验到什么叫“卡到怀疑人生”。纯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_iter100): 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_iter200只需一两秒和纯Python循环的几分钟形成鲜明对比。如果你连向量化都嫌不够还可以用numba给纯Python的mandelbrot函数加jit(nopythonTrue)装饰器编译成机器码运行速度也会非常可观。我个人的建议是项目阶段用向量化版足够而且写起来逻辑清晰好调试等你想做那种超大分辨率壁纸时再去折腾numba或者并行。5.3 图像倒置与颜色失真的常见处理画出来的曼德博集倒过来这个问题我第一次遇到时还认真怀疑了十分钟人生。原因在于imshow默认把数组第一行画在图像顶部而我们生成img时第一行对应的是y的最小值复平面下方。所以在数学上正确的ys顺序到了imshow里就变成上下颠倒。解决办法最直接的是加参数originlowerplt.imshow(img, extent(xmin, xmax, ymin, ymax), originlower, cmapinferno)或者你可以在生成数组时把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), cmapinferno) ax.axis(off) plt.savefig(mandelbrot_hd.png, dpi300, bbox_inchestight)这里的figsize决定画布尺寸dpi决定保存时的像素密度。两者相乘就是输出图片的最终分辨率。举个简单例子figsize(12, 8)加dpi300保存出来就是3600×2400像素完全够当壁纸用。axis(off)能去掉坐标轴让画面更纯净。turtle那边保存输出相对麻烦一些因为它本身不是面向高质量渲染的工具。简单粗暴的做法是把窗口调大pensize(2)再截图如果想要矢量图可以用canvas turtle.getcanvas() canvas.postscript(filetree.eps)生成的是EPS矢量文件可以用Ghostscript或者ImageMagick转成PNG。不过说实话如果目标是高清作品我更推荐把turtle当作原型探索工具等确定了构图和参数后再用matplotlib重写绘制逻辑这样出图质量高得多。我自己从第一张灰蒙蒙的科赫雪花到现在能稳定输出高清分形作品中间踩过的坑基本就是上面这些。分形绘图这个题材有一个特点入门门槛不高但天花板极高。同一套公式换一个配色、换一个参数、换一个缩放区域出来的作品完全不一样。我特别喜欢在M集边界附近到处放大有时候无意中撞见一个像海马尾巴又像星系旋臂的区域那种感觉比看任何教程都震撼。希望这篇内容能让你少走点弯路早点画出第一张真正属于自己的算法作品。