☰
Python分形艺术实战:Mandelbrot集合与递归树绘制指南
2026/9/28 23:27:26 网站建设 项目流程

1. 先搞懂分形到底在画什么:两条最值得上手的主线

第一次用Python渲染出Mandelbrot集合那张图的时候,我盯着屏幕上墨蓝与暗金交错的海岸线看了很久——代码生成的东西竟然能长出这么复杂的纹理。很多人以为"算法绘图"是专业图形学软件或生成艺术平台的专利,但只要你掌握了分形的核心逻辑,用NumPy加PIL就能画出同样惊艳甚至更自由的作品,而且每一步都看得见、摸得着,改一个参数就是一幅新画。

分形有一个特别迷人的特性:它的局部放大之后,和整体保持着某种相似性。这句话听起来很玄,落到代码层面其实非常具体——你只需要反复套用同一个数学规则,让程序自己"生长"出细节。我用Python做分形艺术这两年,绝大多数作品都从两条主线出发:迭代复数方程(逃逸时间算法)和递归/L系统重写规则。前者适合渲染那种深邃的、像宇宙尘埃一样的数字风景,后者适合画树、植物、雪花这类结构感很强的东西。

先解释一下什么叫"自相似"。想象你正在看一段不规则的锯齿形海岸线,把其中一公里放大到面前,你会看到更细碎的凹凸;再放大其中一米,凹凸还在。分形艺术就是把这个"无限放大仍有细节"的性质用算法逼近出来。Python里常用两种手段逼近:一种是反复迭代一个复数函数,看像素点在迭代中是逃向无穷远还是被困在有限区域内,这就是Mandelbrot集合和Julia集合的基本思路;另一种是递归调用,让一根树枝不断分裂出更小的树枝,或者让一组字符规则不断生长成复杂的植物形态。

这两种路线对新手都很友好,因为它们不需要复杂的图形引擎,核心不过是一个循环加一个判断。而且它们的视觉效果上限极高——迭代一次的规则极其简单,但叠加几十万次之后,画面复杂度远超手绘。

1.1 自相似性:大自然藏在数学里的"套娃"

你如果观察过蕨类植物的叶片,会发现每一片小叶子都像整片大叶子的缩小版;观察西兰花,一个花球切开之后,每一朵小花蕾又长得像完整的花球。这种"部分与整体结构相似"的性质就是自相似。

在Python里复现这件事,最直观的方式是递归。拿最简单的分形树举例:树干是一根线段,顶端分出两根倾斜的分支,每根分支再当作新的树干继续分……如果你只把"分支长度乘以0.7、旋转角度固定"这个规则重复执行,就能生成一棵有模有样的树。我通常用turtle库来做这种项目,因为turtle的笔尖方向和位置控制非常直观,缩到最短代码也只有十几行:

import turtle t = turtle.Turtle() t.speed(0) t.left(90) t.penup() t.goto(0, -300) 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(150, 8) turtle.done()

跑一下你就明白了:递归深度8层的时候已经能看出树的轮廓,深度加到12层,枝杈会细密许多。这里的核心思想是将同一操作施用于自身的结果,而不是每次重新设计规则。分形艺术中几乎所有的复杂图案都从这个"套娃"原则派生出来,区别只是套娃的规则长什么样、套多少层、颜色怎么分配。

1.2 迭代公式与逃逸时间:从一个复数方程开始的宇宙

如果说递归分形像"拓扑生长",那Mandelbrot集合就完全是另一套路子:它不画树枝,而是画复平面上所有满足特定迭代条件点的集合。

常用的公式只有一个:

z = z² + c

其中c是复平面上某个像素点对应的复数,z从0开始不断迭代。如果迭代很多次之后,z的模长始终没有超过某个阈值(通常取2),我们就认为这个点属于Mandelbrot集合,把它涂成黑色;如果中途跑出去了,就根据它"跑了多少次才逃逸"来分配颜色。后者就是我们常说的逃逸时间算法。

你可能想问:这么简单的式子凭什么能画出那么复杂的图形?恰恰因为它简单,却对初值极度敏感。c的微小变化会让迭代轨迹完全不同,于是复平面上每个点都像一个小小的"命运分支",大量点汇聚之后,就形成了那种布满螺旋、细丝和大陆块边缘的图案。复平面的坐标范围可以任选,但经典视图一般取实数轴-2.5到1.5、虚数轴-1.5到1.5。这个范围能完整呈现Mandelbrot集合的主心形区域和两侧的芽胞结构。

2. 第一幅作品:用Python渲染Mandelbrot集合的完整流程

看完了原理,接着就是动手。我觉得最合理的节奏是:先写一个最简版本,让图像能"出来",然后在此基础上加配色、加平滑、提高清晰度。很多初学者一上来就想做4K高清大图,结果被性能问题劝退,其实完全没必要。第一版分辨率控制在800×600、迭代次数128就够观察了。

2.1 从公式到像素:坐标映射与迭代判断

图像是一个二维网格,每一格对应复平面上的一个坐标。要做的就是把像素坐标映射到复平面:

  • 横向:像素列号j映射到复平面实部x
  • 纵向:像素行号i映射到复平面虚部y

我习惯用NumPy的向量化写法,避免在Python层面套无数个for循环。原因是NumPy底层把循环推给了C数组运算,速度能快上百倍。下面是最简版本:

import numpy as np from PIL import Image width, height = 800, 600 max_iter = 128 xmin, xmax = -2.5, 1.5 ymin, ymax = -1.5, 1.5 xs = np.linspace(xmin, xmax, width).reshape(1, -1) ys = np.linspace(ymin, ymax, height).reshape(-1, 1) c = xs + 1j * ys z = np.zeros_like(c) escaped_count = np.zeros(c.shape, dtype=np.int32) for n in range(max_iter): mask = escaped_count == 0 z[mask] = z[mask] ** 2 + c[mask] escaped = mask & (np.abs(z) > 2.0) escaped_count[escaped] = n + 1 image_array = (255 - escaped_count * (255 // max_iter)).astype(np.uint8) img = Image.fromarray(image_array, mode="L") img.save("mandelbrot_basic.png")

这段代码里,escaped_count记录的是每个像素在第几次迭代时逃逸,0表示一直没逃逸(集合内部)。黑白模式下,逃逸早的点偏暗,逃逸晚的点偏亮,于是集合外部会形成一圈一圈的同心纹理。第一次跑通的时候,你可能觉得黑白也很耐看,但彩色会让作品跃升一个台阶。

2.2 平滑着色与调色板:把"迭代次数"变成"色彩层次"

单纯用整数迭代次数上色,色带会出现明显的断层,因为相邻像素的迭代次数跳变很大。专业的分形渲染通常会用平滑迭代值来消除断层。常用公式是:

mu = n + 1 - log2(log(|z|))

这个公式的核心思想是:不仅看"第几次逃逸",还要看逃逸瞬间z的模长大到什么程度,用对数把连续的模长差异折算成小数部分。这样一来,原本相邻像素即使迭代次数相差1,颜色的过渡也会柔和许多。

拿到0到1之间连续变化的mu之后,要做的就是把它映射成RGB。我个人的偏好是用HSV颜色空间再做转换,因为Hue(色相)可以从0到360平滑变化,做出来的渐变特别自然。举个简单的例子:

def color_map(mu, max_iter): hue = (mu / max_iter * 240 + 200) % 360 sat = 0.8 val = 0.9 if mu < max_iter else 0.0 # 用colorsys转RGB from colorsys import hsv_to_rgb r, g, b = hsv_to_rgb(hue / 360, sat, val) return int(r * 255), int(g * 255), int(b * 255)

我试过很多配色,发现深蓝到青、紫红到金这两种组合在Mandelbrot上效果最稳定。前者很像深空星云,后者很像熔岩海岸。配色方案其实没有标准答案,建议把调色函数独立出来,方便随时换,我后面专门讲这个。

2.3 当你放大某个细节:局部坐标范围与高迭代次数

Mandelbrot集合最迷人的地方在于放大之后依然复杂。你只需要把坐标范围缩小到集合边界附近,同时提高迭代次数,就能看到完全不同的结构。比如我把视图拉近到实轴-0.748、虚轴0.1附近,那里的海马状螺旋和细丝纹理特别丰富。

放大时有一个关键经验:迭代次数必须跟着分辨率一起提高。如果坐标范围缩得很小,但迭代上限还是128,那么大量像素会糊成一片,很多细节被误判为"集合内部"变成黑色。我一般根据观察尺度调整max_iter——整体视图用200左右,放大到0.01尺度用800到1200,再往里就可能需要2000以上了。放大坐标时要特别小心浮点精度,复平面很小的偏移都会让整幅图构图全变。

3. 换一种节奏:递归树、L-system与植物生长模拟

Mandelbrot满足那种想反复放大探索的欲求,但没有一棵树的结构感。做植物形态的分形,我用得最多的工具是turtle加L-system。L-system(Lindenmayer系统)核心是用字符串重写规则模拟生长过程:先定义初始字符串(公理),再定义替换规则,反复迭代后把最终字符串解释成绘图指令。

3.1 递归分形树与海龟绘图:最简单也最能练手感

turtle画树的优点是能实时看到笔尖行动,递归树的分支角度、长度衰减率、深度全部可视化,调试起来非常顺手。前面代码里的递归函数tree,本质就是"画一段,然后左右分支递归"。三个核心参数的意义你要弄清楚:

  • 分支长度衰减率:0.6以下树形偏稀疏,0.75以上树冠密实
  • 分支角度:20到45度比较像自然树冠,角度太大会显得机械
  • 递归深度:8到12层适合演示,超过14层turtle会非常慢

我在初始代码里用了固定的30度和0.7衰减率。这里有个小技巧:给每层增加随机扰动,树会从"几何树"变成"有生命感的树"。比如把角度写成30 + random.uniform(-8, 8),长度乘0.7改成length * random.uniform(0.65, 0.78),跑几次你会发现每一棵都长得不一样。随机扰动是分形艺术从"数学标本"走向"艺术作品"的重要一步。

3.2 L-system的字符串重写逻辑与绘图解释

L-system比硬编码递归更灵活,因为规则本身是可定义的数据。一个经典的植物生长规则例子:

  • 公理:F
  • 规则:F -> F[+F]F[-F]F

这里F代表向前画一条线段,+代表左转,-代表右转,[和]分别代表压栈和出栈(即保存当前状态再恢复)。反复应用规则之后,字符串会指数级增长。比如迭代6次之后字符串长度已经非常可观,画出来就是浓密的灌木丛。

代码大致是这样:

import turtle axiom = "F" rules = {"F": "F[+F]F[-F]F"} angle = 25 stack = [] def expand(axiom, rules, iterations): result = axiom for _ in range(iterations): result = "".join(rules.get(ch, ch) for ch in result) return result def draw(commands): for ch in commands: if ch == "F": t.forward(10) elif ch == "+": t.left(angle) elif ch == "-": t.right(angle) elif ch == "[": stack.append((t.pos(), t.heading())) elif ch == "]": pos, heading = stack.pop() t.penup() t.goto(pos) t.setheading(heading) t.pendown()

用L-system的好处是:你不需要改代码逻辑,只要改规则字符串,就能画出不同风格的植物、雪花、迷宫,甚至类龙形曲线。典型的Koch雪花规则是F -> F+F--F+F,配上60度转角,迭代几次后就是完整的雪花轮廓。建议把规则和角度做成配置参数,把L-system当"绘画编译器",你会发现它能覆盖的分形类型远超预期。

4. 真正拉开差距的细节:颜色映射、分辨率与迭代策略

实话说,分形图案的结构由数学规则决定,只要写对公式,任何人都能画出"对"的图。但想画出"好看"的图,拼的是颜色映射和参数搭配。这一节我把我常用的参数组合和配色策略完整列出来。

4.1 给"逃逸次数"设计一套顺眼的配色方案

颜色映射的本质是:平滑迭代值mu归一化到0到1之间,然后查颜色表。三种常用方案:

  • 线性渐变:从color1到color2直接插值,适合极简风格
  • 三角渐变:双色交替,营造星环或波纹的效果
  • 多段渐变:按mu值分段映射到2到4个颜色锚点,层次最丰富

我最常用多段渐变。锚点列表大概是[(0, 0.1, 0.3), (0.5, 0.7, 1.0), (1.0, 0.8, 0.2)],也就是深蓝到亮蓝再跳到金色。用NumPy的np.interp可以方便做分段插值。还有一个重要技巧:颜色循环周期。mu值超过1之后取小数部分循环,这样同样的渐变色就能反复出现,形成一圈一圈的环状色带。

代码示意:

def palette(t): # t: 0~1 的色位 if t < 0.5: r = 0 g = int(0.4 + t * 2 * 0.4) b = 100 + t * 2 * 120 else: r = int((t - 0.5) * 2 * 255) g = int(0.3 * 255) b = 255 - r return (r, g, b)

真正渲染大幅图像时,逐像素调用Python函数还是有点慢。我一般预计算一个256长度的查询表(LUT),每个mu值直接查表映射,渲染8K图的提速非常明显。

4.2 性能瓶颈与分辨率策略:多人画不出高清图的关键原因

分形渲染的复杂度大头不在图像尺寸,而在迭代计算。800×600×200次迭代就是9600万次复数运算,听起来很多,但NumPy向量化后其实只要几秒。真正的坑是大量像素落在集合内部——这些像素每轮迭代都要继续算,直到达到max_iter才停。

优化思路有三个层次:

  1. 先用低分辨率粗跑,确定构图和配色,满意后再渲染大图,避免反复试错消耗时间
  2. 用NumPy的数组掩码跳过已逃逸像素,就像我在最简版代码里写的mask = escaped_count == 0。这样每轮参与计算的像素越来越少,后段迭代速度显著提升
  3. 把核心迭代函数用Numba或Cython加速,这个优化对中等尺寸图收益不太明显,但高清图很吃这套

如果你做动画或批量生成系列图,还有一个容易被忽略的技巧:分辨率缩小4倍,颜色参考大图映射。也就是先渲染一张低清图做色彩参考,再正式渲染高清图,能省掉很多调参时间。

4.3 让作品有"呼吸感"的参数组合表

我把经常用的参数组合整理成了一张表,方便你直接抄作业。每套方案都是我在实际项目里验证过出图效果的:

方案名称公式/规则坐标范围迭代次数配色倾向备注
海马港湾z²+c实部-0.748到-0.743,虚部0.095到0.11200蓝紫渐变放大细节极多
熔岩之心z³+c整体视图附近300金红渐变幂次提高,结构更碎
星云尘带z⁴+c实部-1.35到-0.6,虚部-0.4到0.2400紫青多段适合做桌面壁纸
蕨叶生长L-system规则F[+F]F[-F]F画布区域深度8绿色系适度随机扰动效果最好
雪原晶体Koch规则F+F--F+F画布区域深度5白蓝渐变角度60度是核心

这张表的重点是"换参数就换风格"。实际调参过程中,我习惯每次只改一个变量,比如只改幂次从2到3,你会发现画面从经典款变成锯齿状礁石,视觉差异巨大。这种微调是算法绘图最有意思的部分。

5. 从"能出图"到"出好图":踩坑记录与参数探索路径

最后写写实际操作中容易栽跟头的地方。很多问题看起来是代码错误,本质上是对分形原理理解不够深。

5.1 边缘锯齿、色带断层与"半途而废"的迭代

第一个常见问题是集合边界出现明显的锯齿状噪声,尤其当max_iter偏低时。解决方法和前面说的一致:提高迭代上限,同时用平滑迭代值替代整数迭代次数。如果用了平滑公式还有隐约条纹,检查你在计算mu时用的对数是log2还是自然对数,公式必须对应。

第二个问题是图像整体偏黑,仿佛什么都看不见。这通常发生在坐标范围选到了集合内部大片区域,比如实轴-0.1到0.1、虚轴-0.1到0.1,这一带大量的点属于集合内部,逃逸像素太少,自然没有层次。解决办法是回到经典视图重新选区域,或者把最大迭代次数降低,让更多点显示为外部。

第三个问题很有代表性:迭代过程中大量像素始终没有逃逸,导致程序跑得极慢。我遇到过一次,坐标缩放太深直接掉进了Mandelbrot集合内部深处,几乎一半像素都是"内部点",每轮迭代都在空转。这时候不是优化代码能解决的,换一块离集合边界更近的区域,性能问题会立刻缓解。

5.2 用"参数脚本"管理系列作品:从一个画到一组画

接触分形艺术三个月后,我发现单张图再好看也有审美疲劳的临界点,真正有玩味的是系列作品——同一个公式坐标范围微微偏移,或者旋转角度逐张递进,拼在一起就像在讲述空间探索的过程。

我自己养成了一个习惯:所有作品都有一个独立的"绘制参数文件",记录坐标范围、迭代次数、调色板锚点、随机种子。想创作系列时,就把这些参数批量修改,写一个循环逐帧渲染。这种方式特别适合做分形动画,虽然需要不少渲染时间,但效果是YouTube上那种"无限缩放进分形"视频的感觉。

顺带分享一个能立刻提升观感的细节:保存成PNG而不是JPG。分形图边缘极度锐利,JPG的压缩伪影会在细丝部分产生明显的色斑。我早期很多作品发到社交平台上总觉得有点糊,后来全部改存PNG,边界清晰度立刻不一样。

如果你想要更进一步,可以尝试把多个公式混合——比如在Mandelbrot迭代中加入三角函数项z = z**2 + c + 0.1 * np.sin(z),图案会变得非常戏剧性。但这是进阶话题了,不建议新手一上来就玩混合公式,等基础的那几条路线都画出过满意的作品再尝试会更好。

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

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

立即咨询