分形艺术与算法绘图实战:从曼德勃罗集合到递归分形

发布时间:2026/10/11 4:20:54
分形艺术与算法绘图实战:从曼德勃罗集合到递归分形
我第一次把曼德勃罗集合完整渲染出来的那个晚上对着屏幕发了很久的呆。那种藏在公式背后的无穷细节是任何预设矢量图都给不了的高阶审美。分形与算法绘图这事本质上就是让数学公式直接开口说话用像素说话让每一行代码都变成笔触。今天这篇我就当经验笔记写把做分形艺术和算法绘图时涉及的原理、实操、坑点和提速方案一次性讲透方便你直接上手复现也可以顺着这个思路玩出自己的风格。1. 先弄明白分形与算法绘图到底在玩什么1.1 分形艺术的核心让公式代替画笔分形Fractal这个词多数人第一次听到都会觉得挺玄但用一句话解释就是一个整体图案的每一个局部放大之后都跟整体长得相似。典型的例子是蕨类植物的叶子边缘的每一个小羽片都像整片叶子的缩小版还有海岸线不管你用什么比例尺去测量曲折的特征都在重复出现。放到计算机绘图里我们通常不是像传统画画那样去“画线条、填色块”而是先定义一个数学规则让计算机在这个规则下反复迭代计算然后把迭代的结果转换成屏幕上的颜色或坐标。你不需要手绘任何具体形状形状是计算出来的。这也是分形和普通算法绘图最大的区别普通算法绘图可能只是在代码里随机撒点或画圆而分形绘图强调的是自相似性和迭代递归。作为项目方向这类内容的核心价值在于用很短的代码产生复杂度极高的视觉结果。入门阶段你只需要理解一个迭代公式就能输出一张细节惊人、放到网上会被人追问“用什么软件做的”的高质量图像。同时这个方向对数学、编程、色彩审美三方面都有锻炼特别适合想提升代码能力但又不想做纯业务逻辑的新手。1.2 为什么选Python而不是直接上专用的分形软件市面上确实有人直接用现成分形软件捣鼓比如自带的火焰效果、三维分形渲染器但那类软件往往把参数给你封装好你只管拖拽滑杆做完了也不知道原理。用Python的好处恰恰在于你把“定义规则”和“渲染输出”这两件事都握在自己手里中间每一层像素、每一个迭代都能由你控制。从工程角度Python生态里有几个库让我觉得很顺手NumPy分形计算本质是大量重复数值运算NumPy的数组批量运算能把Python原本很慢的循环压缩到几十分之一。Pillow用于生成图片、保存PNG/JPEG文件也能方便地做像素级图像操作。Matplotlib如果只是想快速出图并看颜色映射效果可以直接用它。Turtle / Tkinter适合做递归分形树的动态演示自带绘图窗口能直观看到树的生长过程。选择Python做分形艺术不是因为它图形性能多强而是因为它把“设计算法”的门槛降得非常低。你可以先用三层循环把图算出来保证逻辑正确然后再用向量化代码优化这个过程本身就是很好的性能优化训练。2. 核心原理拆解逃逸时间算法和自相似结构2.1 曼德勃罗集合背后的数学闭环曼德勃罗集合可以说是分形绘图里必做的第一个项目。它背后只有两个东西一个复数平面一个迭代公式。公式长这样z_(n1) z_n^2 c其中z和c都是复数。我们从z0开始把某个复数c代入公式反复迭代。如果经过足够多迭代之后z的模长仍然没有超过某个阈值比如2就认为c属于曼德勃罗集合内部如果z迅速飞向无穷大就说c逃逸了。这里有个关键操作叫逃逸时间算法我们并不需要把所有集合内的点都画出来而是关注“逃逸速度”。对每个像素点c计算它的迭代次数——也就是经过多少次迭代z才终于超过了那个阈值。这个迭代次数直接转换成一个颜色的渐变于是我们就能得到那张著名的“黑色内部彩色向外晕染”的图。为什么要判断阈值2因为复数迭代的收敛域和发散域之间有个性质当|z|超过2时它基本不可能再收敛回集合内部了。所以阈值2是数学上比较安全的选择。你也可以改成更大的值但实际效果差异不大反而会浪费计算时间。2.2 平滑着色让分形的层次感出来很多刚入门的朋友第一次跑通曼德勃罗集合都会发现出一张图图是出来了但颜色一档一档的跟色带卡住一样难看。原因在于直接用迭代次数做色彩索引离散感很强。比如迭代30次和31次之间有一次跳跃表现为一条生硬的色环。解决办法是给迭代次数做一个连续化处理常用的是归一化迭代计数Normalized Iteration Count取当前逃逸时的模长|z|计算一个小数偏移量用 log(log(|z|)) / log(2) 这类公式把整数迭代次数变成浮点数值浮点值映射到颜色分量时再用正弦函数做平滑插值我后来一直用这种着色方式色彩区域的过渡非常自然深色和亮色之间有细腻的渐变观感跟专业分形软件没什么差别。别小看这一步分形艺术真正拉开审美差距的很多时候不是算法而是颜色映射。2.3 理解复平面到像素平面的坐标变换计算的时候每个像素都需要对应到复平面上的具体坐标。比如你要绘制一张宽w像素、高h像素的图对应的复平面范围是x_min到x_max、y_min到y_max。那么对像素坐标(i, j)实部 x_min (i / w) * (x_max - x_min)虚部 y_min (j / h) * (y_max - y_min)这个映射听起来简单却是最容易出bug的地方。坐标写反会导致图像发生镜像翻转纵横比没做调整则会让圆形图案被压成椭圆。我建议在花括号固定好画布宽高以后把像素宽高比手动换算到复平面坐标范围里比如图像的x方向跨度设为3.0y方向跨度就设为3.0 * (h / w)。这样渲染出来的圆形才是正圆。3. 实际操作从零渲染一张高清分形图3.1 环境准备与整体工作流在动手之前先把环境准备好。我日常的分形绘图环境大概是这样Python 3.8以上太老的版本某些语法和库兼容会有小问题安装NumPy和Pillowpip install numpy pillow如果要做调试可以再装一个Matplotlib整个工作流非常清晰定义复平面范围生成像素网格对应的复数矩阵用逃逸时间算法迭代计算每个点的迭代次数将迭代次数映射到RGB颜色用Pillow生成图片并保存这种流程的好处是每步结果都能单独输出验证。比如第三步算完可以打印迭代次数的最大最小值第四步调颜色函数时可以单独输出一小块试色。3.2 完整代码实现曼德勃罗集合下面这段是我实际使用中做得比较顺手的版本保留了对新手友好的注释同时也做了基础向量化优化import numpy as np from PIL import Image def mandelbrot(width1200, height900, max_iter300): # 设置复平面范围保持纵横比 x_min, x_max -2.5, 1.5 y_min, y_max -1.5, 1.5 x_range x_max - x_min y_range y_max - y_min if width / height x_range / y_range: # 以x方向为基准调整y方向范围让图像比例正确 new_y_range x_range * height / width y_mid (y_min y_max) / 2 y_min y_mid - new_y_range / 2 y_max y_mid new_y_range / 2 else: new_x_range y_range * width / height x_mid (x_min x_max) / 2 x_min x_mid - new_x_range / 2 x_max x_mid new_x_range / 2 # 生成复数矩阵 xs np.linspace(x_min, x_max, width) ys np.linspace(y_min, y_max, height) X, Y np.meshgrid(xs, ys) # 不要用np.mgridmeshgrid更直观 C X 1j * Y Z np.zeros_like(C) divergence np.zeros(C.shape, dtypeint) for i in range(max_iter): # 只在绝对值小于等于2的点继续迭代 mask np.abs(Z) 2 Z[mask] Z[mask] * Z[mask] C[mask] divergence[mask] i # 归一化迭代次数增强颜色平滑度 norm_escape divergence 1 - np.log2(np.log2(np.abs(Z) 1e-10)) norm_escape np.clip(norm_escape, 0, max_iter) return (norm_escape / max_iter * 255).astype(np.uint8) def color_map(gray): # 把灰度转换成为RGB用正弦函数生成平滑渐变 r (np.sin(gray * 0.05 0) * 127 128).astype(np.uint8) g (np.sin(gray * 0.05 2) * 127 128).astype(np.uint8) b (np.sin(gray * 0.05 4) * 127 128).astype(np.uint8) return np.stack([r, g, b], axis-1) data mandelbrot() img Image.fromarray(color_map(data), modeRGB) img.save(mandelbrot.png)代码里要说三个实际体会Z[mask] Z[mask] * Z[mask] C[mask]这行是我调整过很多次之后的版本。很多人第一次会习惯写成Z Z * Z C但因为matplotlib和Pillow的坐标原点位置不同导致显示方向奇怪。我这里的mask方式只对未逃逸点继续迭代能节约不少计算量。np.clip很关键。归一化公式计算出来的值有时会越界clip可以保证灰度范围稳定在0到max_iter之间。modeRGB必须显式指明否则存图时可能因为数组形状被解释成灰度图出现货不对板的颜色。3.3 参数调节的“手感”积累做分形绘图最需要积累的就是调参手感。我根据自己的实践整理了一张速查表方便你对号入座现象原因调整方式画面几乎全黑迭代次数太少提高max_iter比如从50调到300图形边缘糊成一片分辨率低或着色离散提高输出尺寸使用平滑着色图形偏移/变形复平面坐标范围设置不当检查纵横比映射重新调整y/x范围颜色太刺眼正弦函数频率高调低正弦函数的频率系数比如0.05改成0.02中部的黑色区域太小观察窗口选的不是经典区域调整复平面中心点或缩小缩放范围说到缩放这里额外讲一个计算技巧曼德勃罗集合任意边缘区域放大之后都有无穷细节。想要做“深度缩放”动画就是不断把复平面范围缩小再重新渲染。这一步完全可以用代码自动化每次渲染完保存图片再把当前范围和下一帧的范围做线性插值合成视频就是一个非常酷的动态分形项目。4. 扩展玩法递归分形树与随机算法绘图曼德勃罗集合只是分形世界的冰山一角。实际玩起来你会发现分形和算法绘图是一个巨大的创作盒里面装着大量互补的技法。这一节我讲讲另外两种我特别常用的玩法递归分形树、随机扰动算法。4.1 递归分形树30行代码画出自然感的树递归分形树是理解“自相似”最直观的例子。一棵树的树干分出两个树枝每个树枝又各自分出更小的两个树枝如此反复。代码上就是同一个函数调用自身若干次唯一变化是每次缩短长度并旋转一定角度。我用Pillow画这种图时自己封装了一个简单的线段绘制函数不依赖Turtle逻辑更直白并且能直接输出PNG或者批量做多帧动画。核心伪代码逻辑是这样的def draw_branch(x, y, angle, length, width, depth, img, factor): if depth 0: return # 计算末端坐标 end_x x length * math.cos(math.radians(angle)) end_y y length * math.sin(math.radians(angle)) draw_line(img, (x, y), (end_x, end_y), width) new_depth depth - 1 new_length length * factor # 通常取0.65到0.8之间 # 左右两个子枝 draw_branch(end_x, end_y, angle - 30, new_length, max(1, width-1), new_depth, img, factor) draw_branch(end_x, end_y, angle 30, new_length, max(1, width-1), new_depth, img, factor)做这个项目时我一开始犯过一个典型错误画完左边分支之后坐标状态被带偏导致右边分支起点不对。解决方式很简单就是让每个递归函数内自己算出终点坐标而不是依赖外部变量。另外factor这个参数很值得调0.7左右是自然的树形0.8以上树枝看起来太密0.5以下像一把干枯的扫帚。如果想让图像更好看可以在深度比较小的时候给树枝填充灰褐色在末端画几个绿点模拟树叶。这一步可以自己写一个随机函数来决定叶子位置让画面不死板。4.2 用随机扰动与噪声制造类自然纹理除了严谨的数学分形算法绘图另一大分支是随机性生成。比如画抽象艺术图你可以让无数个粒子在画布上随机游走每次游走留下一条颜色淡化的轨迹。这种图片看着随意但因为有算法控制边界条件整体风格非常统一。我经常做的一种风格是“流动线条”把画布分为有限网格从每个网格中心发射若干条贝塞尔曲线每条曲线的控制点由带种子随机数生成颜色从预设的调色板里取。这样做的效果是看上去像是某种水管系统或者地形等高线但纯靠代码处理不需要任何外部素材。这里给一段简单的随机游走示意import random from PIL import Image, ImageDraw def random_walk(img, start_x, start_y, steps, color): draw ImageDraw.Draw(img) x, y start_x, start_y for _ in range(steps): direction random.choice([up, down, left, right]) if direction up: y - 1 elif direction down: y 1 elif direction left: x - 1 else: x 1 draw.point((x, y), fillcolor)随机游走本身很简单但当你叠加数千条游走路径并使用半透明颜色叠加时视觉效果就会立刻丰富起来。随机算法绘图的魅力就在这里核心规则极其简单但大量简单事件相互作用就产生了不可预知的复杂美学。5. 性能优化和我踩过的一些坑5.1 从纯循环到向量化性能差距是数量级的很多人第一次实现曼德勃罗集合都是三层循环遍历宽度、遍历高度、迭代计算。这个版本很好理解但跑1200x900、迭代300次的图可能要几秒到几十秒。用上NumPy的向量化之后同一张图的时间可以被压缩到几百毫秒级别。原理在于NumPy把Python的循环下沉到了C语言层面。我们不再逐点计算而是对整个复数矩阵同时做运算。我实测过一个1200x900像素、最大迭代500次的图纯Python版本约需25秒NumPy版本只需要0.8到1.5秒。差异接近二三十倍。除了向量化还有几个加速技巧使用numba进行JIT即时编译把循环版本直接变成机器码对复杂分形效果时收益明显。使用多进程并行渲染把图片分成上下多块分别让不同核心来计算最后再拼接。这个方法对大尺寸图像特别有效。使用更少的最大迭代次数不是所有图案都需要500次迭代有些局部区域迭代到100次细节就够丰富了。5.2 常见问题速查和调试建议在做分形与算法绘图过程中我把踩过比较深的坑整理成了一个速查表基本都是常规文档里不太会写的问题原因解决方案生成图片后左右颠倒复平面坐标与像素坐标映射方向反了x方向统一用linspace(x_min, x_max)别把起点终点写反颜色出现奇怪的条纹伪影灰度到RGB映射时没有归一化先对灰度数组求最大值并归一化再做正弦映射内存占用过高大尺寸图像叠加Z矩阵和C矩阵都存储为复数类型用np.complex64代替默认的np.complex128内存直接减半迭代次数过高但画面仍黑你观察的区域不是集合内部而是外部细节放大到集合边缘区域观察避免直接看中部大块黑色保存图片时数组越界灰度值大于255使用np.clip做边界处理递归树绘制时程序卡死递归深度过大控制深度在10层以内若确实要更深考虑用循环加栈还有一个我特别想说的调试技巧不要在第一次就追求全尺寸高清输出。先输出一张200x200的小图确认颜色映射和图形形状正确再放大分辨率做正式渲染。这张小图每次生成只要几十毫秒可以让你的试错成本降低很多。6. 除了表面效果复盘分形绘图给我的三点收获做分形与算法绘图表面上是产出了一堆好看的图但实际上有很大一部分收获发生在代码之外。第一是数学直觉。以前学复数、学迭代时总感觉这些概念停留在纸上。当你在屏幕上亲眼看到复平面的迭代如何形成那个精妙图案时你会实实在在理解“收敛”“发散”“临界点”是什么意思。这个直觉对后来做算法设计、信号处理类的东西都有帮助。第二是工程思维。把一个视觉想法变成可复现的代码中间涉及坐标、数据类型、性能、文件管理等一系列问题。你会发现最终图片能不能看别人能不能复现取决于你对细节的把握。比如颜色映射是不是平滑纵横比是否正确这些全都要靠工程手段来解决。第三是审美表达。同一套曼德勃罗算法更换调色板、迭代次数、观察坐标出来的图风格完全不同。做分形艺术本质上是在数学规律约束下寻找自己的审美偏好这个过程比结果还迷人。我自己现在的习惯是每次调出满意的参数组合后除了保存成品图片还会写一个简短的README记录下算法类型、关键参数、复平面范围和配色方案。以后想生成同系列作品直接按README跑完全能复现当时的风格。这挺像一个绘画师保存自己的草稿和色卡。最后分享一个小技巧你在渲染分形时如果觉得图太“干净”、缺少手绘感可以在最后一步给像素加一层轻度的颗粒噪声。给每个像素的RGB值随机加上一点点偏移图片马上就有了一种类似纸质印刷的质感比超光滑的数字图像更耐看。加上这个步骤之后很多朋友问我是不是用了什么特殊的滤镜其实只是几行随机数的事。