ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python turtle绘图实战:循环、递归分形与星空动画全解析

Python turtle绘图实战:循环、递归分形与星空动画全解析

做教学设计、写算法演示、或者只是想给初学者一个能看得见摸得着的图形小项目,Python自带的turtle模块其实是个被低估的工具。很多人一听“turtle绘图”就觉得是给小学生玩的,实际用起来完全不是这样:它的状态式画图方式非常适合讲递归、循环、坐标变换、随机过程这些编程概念,而且“看到程序在屏幕上动起来”这件事,本身就是最好的反馈闭环。这篇我准备用多个实例,把turtle绘图里几种典型玩法拆开讲清楚:几何图形的循环组合、递归分形树、科赫雪花、随机星空动画,再附上我在实际运行中踩过的一堆坑。无论你是准备拿它做课程作业、给小朋友做图形启蒙,还是单纯想找个轻量工具漂亮地展示一段算法结果,这篇文章应该都能直接“抄作业”。

1. 动手前的准备与基本认知

1.1 用海龟绘图前,先把环境调顺

先说环境。turtle不是第三方库,它属于Python标准库的一部分,正常安装的Python环境直接import turtle就能用。不需要pip install,不需要额外下载字体,这一点对新手特别友好,也是我经常在课堂演示首选它的原因之一。

第一次跑turtle程序的人,最容易遇到两个问题:一个是窗口一闪而过,另一个是报类似No module named 'turtle'的错误。第二个问题通常不是真没装,而是你用的Python解释器选错了——比如某些只装了精简版的环境,或者把系统自带的另一个Python路径混进来了。多解释器混用的情况,解决办法很简单,在命令行里先确认python --version,再用同一个解释器去运行脚本就行。

第一个窗口闪退,多半是没写两行收尾代码:

import turtle t = turtle.Turtle() t.forward(100) turtle.done()

turtle.done()的作用是让窗口保持打开,等你手动关闭。如果你是在交互式环境里跑代码,快捷键执行也可能造成窗口来不及刷新,这时候建议直接运行整个.py文件,而不是一行行敲。还有一个常见习惯是把窗口关闭操作写成turtle.exitonclick(),它能在窗口被点击时退出,这个更适合做成品演示,比done()更贴心一点,两者都可以。

1.2 海龟坐标、方向与绘制原理

turtle绘图的核心其实特别朴素:屏幕上有一只“海龟”,它头上有方向,手里有画笔。你命令它往前走多少步,它就走多少步;你让它左转多少度,它就在原地转头。只要会这俩操作,所有图形都能画出来。

理解它的坐标和方向,是后续所有实例的底层基础。默认画布是一个以原点为中心的坐标系,水平向右是x轴正方向,垂直向上是y轴正方向。海龟的初始位置在画布正中间(0, 0),初始朝向是右,也就是x轴正方向,角度为0度;往上为90度,往左为180度,往下为270度。用t.right(90)是顺时针转,用t.left(90)是逆时针转,别记反。

这个坐标和角度系统,刚接触时容易绕,尤其是画几何图形时。我自己的经验是:别背角度,画之前先用一张草稿纸画一条基准线,标出海龟的朝向,然后再推转角。比如要画一个正方形,先走100,然后左转90度,再走100……每一步的执行结果都直观显示在屏幕上,所以出错了也很好排查,这一点比纯文本打印输出友好太多了。

画笔本身还有两个极重要的开关:penup()和pendown()。penup()相当于把笔从纸上抬起来,移动的时候不会留下痕迹;pendown()是放下笔,开始画。任何在坐标纸上的“空移”都要记得配合使用。还有一个好用的小技巧是t.goto(x, y),它可以不改变海龟朝向,直接走到某个坐标点;如果不想让路径被记录下来,就先penup()。想做精确排版时,t.setpos()和goto是同一类操作,可以按习惯混着用。

1.3 用循环组合复杂图形

绝大多数好看的turtle图案,都不是靠一条直线一条直线“攒”出来的,而是靠循环和角度控制画出来的。最简单的例子是正多边形:正n边形每次转360/n度,画n次,就能闭合。代码写出来很清爽:

import turtle t = turtle.Turtle() n = 6 side_length = 100 for _ in range(n): t.forward(side_length) t.right(360 / n) turtle.done()

你把n改成3、5、8、12,得到的图形分别是正三角形、正五边形、正八边形、正十二边形。这个公式背后是“外角和等于360度”这个几何规律,初学阶段不妨自己验算一下,比如三角形为什么每次转120度而不是60度——因为每次转的是外角,外角=180度减去内角。弄明白这一条,后面画五角星、齿轮、多瓣花朵时,角度就不会乱推。

循环里套循环,图形复杂度会瞬间上一个台阶。比如每画完一个正多边形就让海龟转一个小角度,继续画下一个,转一圈下来就成了旋转花瓣图、星芒图一类的东西。这种嵌套循环在后面的实例里会是主力,先把这个结构记住:外层循环控制“整体旋转几次”,内层循环负责“画出一个小单元”,内层结束回到起点,外层再转动角度。这个套路能演化出的图案上限,远比想象中高。

2. 实例一:多层彩色雷达风车

2.1 效果与设计思路

第一个完整实例,我用它来讲“嵌套循环+颜色循环+角度分割”三个知识点。效果是一朵多层的彩色风车,每一层由若干段圆弧围绕中心组成,像雷达图,也像花瓣。它不难,但视觉反馈很好,非常适合作为第一个“有点作品感”的turtle程序。

设计思路是这样的:把圆周分成8等份,每一份就是一个叶片。为了不让每个叶片变成孤零零的直线,我让每个叶片由两段90度圆弧拼成一个“水滴状”的封闭感图案,然后整体旋转45度进入下一个叶片。为什么是45度?因为一圈360度,8个叶片,360/8=45,正好均匀分布露出缝隙,不会重叠到看不出层次。

这个例子还能顺带演示“取模取颜色”:用颜色列表的下标对数量做取模运算,确保颜色循环切换。核心代码特别短,但肉眼可见地漂亮。

2.2 实现代码与参数计算

import turtle screen = turtle.Screen() screen.bgcolor("black") t = turtle.Turtle() t.speed(0) t.pensize(2) colors = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "magenta"] for i in range(8): t.color(colors[i % len(colors)]) # 画一个由两段90度圆弧组成的小单元 t.circle(80, 90) t.left(90) t.circle(80, 90) t.left(90) # 旋转到下一个叶片位置 t.left(45) t.hideturtle() turtle.done()

要理解为什么每次都转90度,得回到海龟的朝向上看。t.circle(80, 90)是让海龟沿着半径为80的圆走90度弧线,停下来时它的朝向也跟随着改变。第一个90度弧线结束时,海龟朝向了原本的顺时针90度方向,此时t.left(90)把它掰回和初始方向一致的角度,再画第二个90度弧线。两段弧线拼接,正好形成一个从中心“鼓出去”的花瓣轮廓。最后t.left(45),让海龟整体旋转45度,为下一个叶片留出位置。

如果只画两段圆弧就直接转45度,会出现叶片不闭合、线条乱穿的情况。所以中间夹着的两个left(90)是关键,它们负责在一个叶片内部“折返”,让两段弧线共享同一个起点和方向。画完第二段弧线后,海龟实际上已经自然转回了初始朝向,这时候再转45度,正好衔接下一个叶片。

2.3 让画面更自然的小细节

这个实例的参数并不复杂,但有几个细节会直接决定成品是“好看”还是“杂乱”。

第一个是speed(0)。turtle默认的绘制速度比较慢,尤其当程序要画几十上百个图形元素时,逐帧动画会等得人心烦。speed(0)表示“最快”,它会把动画关掉,直接渲染结果;如果希望看到绘制过程,可以在调试阶段改成speed(5)左右,但最终出图一定要用0。

第二个是背景颜色。黑底能极大提升彩色线条的对比度,同样的色值在黑底上会比在白底上看起来浓烈很多。这个技巧在做颜色展示类图案时屡试不爽。

第三个是hideturtle()。画完所有叶片后,把海龟箭头藏起来,画面上只剩下图形本体,观感会专业很多。不要小看这个动作,很多新手画完图都会留下一个悬浮在终点的小箭头,第一眼就被它带偏了注意力。

我还试过在这个基础上继续嵌套一个外层循环,让每个叶片的半径从60、80、100递增,原有的叶片就会叠出来“多圈旋风”的层次。代价是循环次数翻了几倍,但视觉效果完全值得。

3. 实例二:递归分形树

3.1 为什么递归特别适合画树

如果说循环是turtle绘图的第一板斧,那递归就是第二板斧。递归画树,是我见过最容易让初学者理解“递归到底在做什么”的可视化案例。

树的结构天然就是递归的:一整棵树,可以看作一根主树干分成左右两根小树干;每一根小树干,又各自分成两根更小的树干;直到细到某个程度,就不再分下去了。这个过程如果用普通循环做,会写得非常别扭,因为分支数量是指数增长的,而且每根枝桠的位置都无法用简单的线性循环枚举。但用递归写,代码结构几乎和“树的定义”长得一模一样:函数画一个枝干,然后调用自己画左边的分支,再调用自己画右边的分支。

递归画分形树还有一个额外的好处:它的绘制过程本身就是一棵树的生长过程。程序开始运行时,你能看到树干先长出来,然后左右两枝伸出去,每根枝头再伸新枝,视觉效果非常解压,也很适合拿来讲递推深度、调用栈这些偏理论的话题。

3.2 代码实现与分支角度参数

import turtle t = turtle.Turtle() t.speed(0) t.left(90) # 让海龟朝上 def draw_tree(length, n): if n == 0: return t.forward(length) # 左分支 t.left(30) draw_tree(length * 0.7, n - 1) # 右分支 t.right(60) draw_tree(length * 0.7, n - 1) # 回到分叉点 t.left(30) t.backward(length) draw_tree(100, 6) t.hideturtle() turtle.done()

这段代码的关键,在于最后那句t.backward(length)。海龟先沿着当前方向画了一段树干,然后递归往左画、往右画,等左右两棵子树都画完了,它必须原路退回到分叉点。如果不退回去,下一次画右分支的起始位置就会被抬高到左分支的末端,整棵树会歪掉。

n是递归深度。每递归一层,长度乘0.7,意思是枝干越来越短;当深度减到0,递归终止。深度6级时,最终生成的节点数量是2^6个叶子级别,实际运行起来有点小慢但完全能接受。如果想要更茂密的效果,可以加大n并把长度衰减因子调大到0.75;想要更稀疏利落的树,就用0.6。这个因子直接决定树的紧凑程度,低于0.5时树会显得“营养不良”,高于0.85时会严重重叠,看起来像一团乱麻。

3.3 现场调整参数与常见陷阱

这个实例在实际运行中最容易翻车的点有三个,我一个个说。

第一个是角度不对称。如果你把左转30度、右转60度、再左转30度这三步去掉某一步,海龟的最终朝向就不是原来的方向了。它回到分叉点后,朝向如果和进入前不一致,后面的分支就会整体偏斜。所以“怎么进去,就怎么回来”是递归画图里最朴素的箴言。

第二个是递归太深导致的绘制时间暴涨。深度从5加到6,看起来只是多了两三层,但笔划数量几乎是翻倍的,因为每次递归都会额外画出一组左右分支。我在某次演示中把深度直接调到了9,结果程序跑了将近半分钟才画完,屏幕上密密麻麻全是短线,根本看不清。建议成品控制在5到7之间,既有树形又不会卡死。

第三个是画完树枝后海龟没隐藏。分形树最后一层会停在某个末梢位置,如果不hideturtle(),画面上就会残留一个小箭头,影响观感。还有就是递归函数的代码顺序别写反:先forward再分支,画出来的才是树;先分支再forward,就变成从树梢往根部画了,虽然也不是不能看,但方向会反过来,初学者容易懵。

4. 实例三:科赫雪花

4.1 平面分形曲线怎么拆解

科赫雪花是分形几何里最有名的一类曲线了。它由三条科赫曲线围成一个封闭的六边形轮廓,每条科赫曲线本身又是一个无限迭代的“尖刺”结构。听起来玄乎,但画法拆开看特别简单。

一条科赫曲线的基础单元是:一条线段平均分成三段,中间一段拔起,变成向外凸出的两条等长线段。下一次迭代时,组成这条曲线的每一小段,再各自按同样的规则细分。用turtle画这个,本质就是递归:“画一条科赫曲线”可以拆成“画四段较小的科赫曲线,段与段之间转60度或-120度”——这个拆法,是比分形树更贴近“分形”定义的一步。

为什么是60度和-120度?因为中间那段线段变成了一个等边三角形的两边:原线段被分成的三段中,第一段保持原方向,第二段和第三段折出60度角,而折回来时需要转-120度。你把一张草稿纸画成一条横线,标出三个等分点,中间点向上凸,两个新线段各形成60度夹角,这组角度就一目了然了。

4.2 完整代码与坐标衔接

import turtle t = turtle.Turtle() t.speed(0) t.penup() t.goto(-200, 100) t.pendown() def koch_line(n, length): if n == 0: t.forward(length) return length /= 3 koch_line(n - 1, length) t.left(60) koch_line(n - 1, length) t.right(120) koch_line(n - 1, length) t.left(60) koch_line(n - 1, length) for _ in range(3): koch_line(4, 400) t.right(120) t.hideturtle() turtle.done()

注意我在画之前把海龟挪到了(-200, 100),原因是科赫雪花三条边加起来会长达1200步,如果从原点开始画,有一边会跑出屏幕。先估算整个图案的尺寸,再把海龟放到稍左上的位置,能让成品整体居中。这个习惯在画任何大型图形时都值得养成。

迭代次数n=4时,单条科赫曲线其实已经由4^4=256段小直线组成,三条边总共768个forward调用,视觉上已经非常平滑。再把n加大到5,数量会暴涨到4^5=1024段每边,整图超过3000次forward。绘制时间会明显变长,但肉眼分辨率未必能看出多大区别。所以建议交互演示时n取4,想截高清大图时才取5或6。

4.3 填充颜色与性能开关

科赫雪花最经典的效果是填充实色。turtle的填充接口特别简单,在画封闭图形之前调用t.begin_fill(),画完之后调用t.end_fill(),图形就会自动填上颜色。但要注意,你必须先把整个封闭轮廓画完整,再结束填充;如果中间有断点或线条交叉错位,填充区域可能变得不可控。

以雪花为例,只需在外层循环开始前调用begin_fill(),三次koch_line完成、外轮廓闭合后调用end_fill()。代码里的三次right(120)已经让轮廓回到起点,所以填充一定能成功。颜色建议用背景对比强的深色系,比如黑底白雪花或者蓝底深蓝雪花,效果好且不会让人视觉疲劳。

性能方面,很多人不知道turtle.tracer(0)这个开关。默认情况下turtle会在每画一小步时刷新屏幕,当线条数量达到几千段时,刷新开销会拖慢整体速度。如果只关心最终成品,不在乎绘制过程,在程序开头加上screen.tracer(0),全画完后再screen.update(),绘制速度会快上好几倍。代价是屏幕不显示过程动画,所以教学演示现场我一般不开,但用来出图或跑大深度迭代时必开。

5. 实例四:随机星空与动态流星

5.1 用随机数和海龟结合

前几个实例都是确定性的几何图形,第四类玩法开始把随机性引进来,让画面变得不可预测。随机星空是我非常喜欢的一个练习:画面上随机撒一些五角星,每个星星的位置、大小、颜色都由随机数决定,最后再加上一条扫过画面留下痕迹的“流星”动态效果。整个程序不复杂,但你能明显感受到每次运行结果都不一样。

随机性在turtle里的实现,就是Python的random模块。比如random.randint(-300, 300)生成坐标,random.random()生成0到1之间的浮点数,直接拿来当RGB颜色分量。turtle的颜色接口支持(r, g, b)三元组,值范围正好是0到1,所以可以写出极其丰富的颜色变化。位置随机和颜色随机组合,每次生成的星空图案都不一样,这个特性拿来演示“随机种子”之类的概念也很合适。

5.2 静态星空实现代码

import turtle import random screen = turtle.Screen() screen.bgcolor("black") screen.tracer(0) t = turtle.Turtle() t.speed(0) t.hideturtle() def draw_star(size): t.begin_fill() for _ in range(5): t.forward(size) t.right(144) t.end_fill() for _ in range(80): x = random.randint(-300, 300) y = random.randint(-200, 200) size = random.randint(10, 30) r = random.random() g = random.random() b = random.random() t.penup() t.goto(x, y) t.pendown() t.color(r, g, b) draw_star(size) screen.update() turtle.done()

这里用right(144)画五角星,原理和画正多边形其实是同一套逻辑:五角星的每个尖角外角是36度,但如果你每次都转36度,画出来的是正五边形。外角144度才是“走一步,转144度,再走一步,再转144度……”每重复五次后刚好闭合。这个144度的推导过程,是从内角108度出发:180减108等于72,但五角星转角需要跨两个外角,所以是144度。推导一下很值得,因为很多图形设计早期翻车就翻在这个角度的正负号上。

我在实际运行中还把星星的数量提到200,用screen.tracer(0)加screen.update()渲染,速度完全能接受。如果不用tracer,80颗星星的绘制过程会一格一格刷新,视觉上虽然也好看,但运行时间长很多。静态星空的要点是:先penup()再goto(),落笔再画,不然星星之间会连出乱七八糟的线。这个错误我在写第一个随机图时就犯过,当时整张图变成了“一坨连线怪物”,排查半天才发现是忘了抬笔。

5.3 从静态到动态:简单流星动画

静态星空有了,再加点动态效果就能让整个作品“活”起来。turtle做动画的思路其实很朴素:在一个循环里,先绘制当前帧的内容,短暂停顿,然后清空画面重新绘制下一帧。下面是流星扫过画面的简化版思路:

import turtle import time screen = turtle.Screen() screen.bgcolor("black") screen.tracer(0) t = turtle.Turtle() t.speed(0) t.hideturtle() for step in range(-300, 300, 10): t.clear() t.penup() t.goto(step, 100) t.pendown() t.color("white") t.forward(80) t.penup() t.goto(step - 40, 80) t.pendown() t.color("orange") t.forward(30) screen.update() time.sleep(0.02) turtle.done()

这段代码的原理是:每一帧都清空全部画面,重新在当前位置画一条白色“流星头”和一条橙色“尾焰”,然后向右移动10个像素。screen.clear()会把所有内容擦掉,包括原有星空,所以更合理的做法是在每一帧里只来回重画星星或者把它们的位置记录在一个列表中;但这个简化版画流星的本意,已经能把“帧循环+更新+延时”这套动画模板讲明白了。

这里唯一要小心的是,动画循环中的sleep时间不要太短。如果设成0,画面刷新速度会远超显示器刷新率,看起来反而像闪烁;设成0.02秒(每帧20毫秒)左右,刚好全速运行且不会闪。同理,如果某帧计算量大导致卡顿,比一味调小睡眠时间有用的做法是降低图形复杂度、少画几颗星星,而不是靠睡眠时间来硬撑,那样只是把卡顿变成变慢。

6. 常见运行问题与优化备忘

6.1 窗口闪退、卡死和无响应

turtle程序最常见的“翻车现场”,集中在窗口状态管理上。我在前面反复提过turtle.done()和exitonclick(),这里再展开讲。闪退一般不是程序逻辑错,而是脚本跑完后解释器立刻退出,窗口没机会保持。解决办法就是显式调用done(),或者在最后一行放turtle.mainloop(),效果相同。如果是在Jupyter或某些IDE的交互式环境里跑,偶尔还会遇到窗口能开但程序永远不结束的情况,那是因为done()进入了事件循环,只能手动关窗口或重启内核。

另一个容易卡死的点是用while True做动画却忘了给循环一个终止条件。turtle是图形界面程序,无限循环会直接占满CPU并让窗口“假死”。动画循环里一定要有明确的结束条件,或者用类似time.sleep(0.02)的方式让出一部分执行时间,否则画完一轮后窗口会停在无法互动的状态。

6.2 绘制速度慢和渲染闪烁

绘制慢的问题,根因通常是逐笔刷新。turtle默认每画一笔都会把当前状态同步到屏幕上,当图案包含几千上万甚至几十万条线段时,这个同步成本会占掉绝大部分时间。解决方案有两个。

一是t.speed(0),这个大部分人都知道,它直接把动画速度拉满,但本质上还是逐笔刷新,只是省去了动画延迟。二是screen.tracer(0)加screen.update(),前者关闭自动刷新,后者在图形全部画完后一次性输出。这两个方案我实测差别非常大:深度为7的分形树,用默认模式要跑十几秒,用tracer(0)模式基本能压到一两秒以内。

还有一个小技巧是减少不透明的中间状态。比如在begin_fill()和end_fill()之间,turtle会实时计算填充边界,如果这个区域特别大,每一笔都可能触发填充重绘,速度会更慢。这时候可以考虑把这个图形拆成若干小份分别填充,不要一次性填一大片。

6.3 坐标越界、图形偏移和线条错乱

坐标越界也是高频问题。默认画布大小约800x600左右,但不同系统中实际尺寸略有差异。图形稍微大一点就跑到窗口外,画到一半看不到后面内容,非常影响调试。我的习惯是:正式绘制前先用t.getscreen().window_width()和window_height()打印一下真实画布尺寸,再做居中平移。比如科赫雪花,我用goto(-200, 100)把起始点搬到偏左上的位置,就是为了让出右边和下面的绘制空间。

图形偏移还有一种情况是递归或循环里忘记恢复海龟位置。前面分形树那个例子,如果省略最后的backward(length),树的所有右分支都会从错误的位置长出来,看起来像被人从中间掰弯了一样。排查这类问题时,我会在关键位置插入t.position()和t.heading()打印,确认每层进入和退出分叉点时坐标和角度是否一致。

线条错乱多半是penup()/pendown()没配对。尤其在做随机撒点或跳点绘制时,如果忘了在移动前抬笔,屏幕上会多出一堆连接两个随机点的长线。我自己调试星空程序时就犯过一次,那一整张图密密麻麻全是横七竖八的连线,根本看不清星星。所以“移动前先抬笔,到了新位置再落笔”这条规则,写进肌肉记忆里都不为过。

6.4 让作品更像“成品”的收尾细节

绘制完成不等于作品完成。为了让输出效果更像一个能拿得出手的成品,我会在最后统一做三件事:t.hideturtle()隐藏海龟箭头、把窗口标题改成项目名称、在背景上加一个合适的主色调。窗口标题用turtle.title("某个演示项目")就能改,虽然不影响绘图本身,但演示给别人看时会显得更完整。

颜色搭配方面,我建议一个作品里不要超过四种主色调。turtle自带的默认颜色列表虽然丰富,但一股脑往上堆很容易变成“万花筒”。想做出高级感,可以先选定背景色,再选两到三个对比明显的线条色,然后通过随机数或取模循环在这些颜色之间切换。黑底+高饱和色系是出图最容易好看的组合,白底+浅色系则更适合打印和手抄笔记场景。

最后再分享一个我个人的动手习惯:任何一个turtle实例,我都建议把它拆成函数而不是一段从头写到尾的脚本。比如画星星就写draw_star(size),画树就写draw_tree(length, n),画科赫线段就写koch_line(n, length)。好处不只是代码好读,而是你可以把每个函数单独拿出来调参数、试角度、改颜色,改完再拼回去。用turtle做图形探索,最大的乐趣本来就是“调一个数字,立刻看到画面变化”。把这种试错过程变成函数级别的快速迭代,进步速度会比闷头写大脚本快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进