定义一个名为Spiro的类,用于绘制曲线。这个类可用于根据自定义参数绘制单条繁花曲线,也可用于在动画中同时绘制多条随机繁花曲线。为协调动画,定义另一个名为SpiroAnimator的类。在程序的顶层编写一个将图形存储为图像文件的函数,同时使用函数main()来接收用户输入并启动动画。
要查看完整的项目代码,可参阅2.7节“完整代码”,也可见本书配套源代码中的“/spirograph/spiro.py”。
Spiro类包含几个用于绘制繁花曲线的方法。下面先来看这个类的构造函数:
class Spiro:
# 构造函数
def __init__(self, xc, yc, col, R, r, l):
# 创建turtle对象
❶ self.t = turtle.Turtle()
# 设置光标形状
❷ self.t.shape('turtle')
# 设置以度为单位的步长
❸ self.step = 5
# 设置绘画结束标志
❹ self.drawingComplete = False
# 设置参数
self.setparams(xc, yc, col, R, r, l)
# 开始绘画
self.restart()
这个构造函数首先创建一个新的turtle对象❶,让每个Spiro对象都有一个与之相关联的turtle对象,这意味着可同时使用多个Spiro对象绘制一系列繁花曲线。接下来,将光标形状设置为海龟❷(还可将光标设置为其他形状,详情请参阅Python官方文档中的“turtle——海龟绘图”部分)。然后,将角度参数的递增量设置为5°❸,并创建布尔标志drawingComplete,用于表明繁花曲线是否已绘制完毕❹。同时使用多个Spiro对象绘制繁花曲线时,这个标志很有用,能够确定某条特定的繁花曲线是否已绘制完毕。这个构造函数在最后调用了接下来将讨论的两个设置方法。
在Spiro类中,方法setparams()和restart()都用于在绘制繁花曲线前完成一些设置工作。下面先来看看方法setparams():
def setparams(self, xc, yc, col, R, r, l):
#设置定义繁花曲线的参数
self.xc = xc
self.yc = yc
self.R = int(R)
self.r = int(r)
self.l = l
self.col = col
# 通过除以GCD将分数约分
❶ gcdVal = math.gcd(self.r, self.R)
❷ self.nRot = self.r//gcdVal
# 计算半径比
self.k = r/float(R)
# 设置颜色
self.t.color(*col)
# 存储当前角度
❸ self.a = 0
首先,存储了繁花曲线的中心坐标xc和yc。然后,将每个圆的半径R和r都转换为整数,并存储转换结果。另外,还存储了l和col,它们分别指定了笔尖的位置和繁花曲线的颜色。接下来,使用Python内置模块math中的方法gcd()计算两个半径的GCD❶,再使用这项信息确定繁花曲线的周期性,并将其存储在self.nRot中❷。最后,将角度参数a的初始值设置为0❸。
方法restart()也用于完成设置任务,它能重置Spiro对象的绘图参数,并将笔尖放到正确的位置,为绘画做好准备。这个方法可用来在程序的绘画部分复用同一个Spiro对象来依次绘制多条繁花曲线。
为绘制新的繁花曲线做好准备后,程序将调用方法restart(),这个方法的代码如下:
def restart(self):
# 设置绘画结束标志
self.drawingComplete = False
# 显示海龟
self.t.showturtle()
# 移到起始位置
❶ self.t.up()
❷ R, k, l = self.R, self.k, self.l
a = 0.0
❸ x = R*((1-k)*math.cos(a) + l*k*math.cos((1-k)*a/k))
y = R*((1-k)*math.sin(a) - l*k*math.sin((1-k)*a/k))
❹ self.t.setpos(self.xc + x, self.yc + y)
❺ self.t.down()
首先将drawingComplete标志重置为False,指出Spiro对象,为绘制新的繁花曲线做好准备。然后,显示海龟光标,以防它被隐藏。接下来将画笔抬起❶,以免在❹处移到起始位置时绘制一条线段。❷处使用了一些局部变量,这旨在让代码更简洁。将这些变量代入计算繁花曲线的参数方程,以计算起点的坐标x和y❸,这是通过将角度参数a的初始值设置为0.0实现的。最后,将海龟移到正确的位置后放下画笔,以便能够开始绘制繁花曲线❺。
如果使用命令行参数来设置繁花曲线,这个程序将绘制指定的繁花曲线,这是使用Spiro类的方法draw()完成的。这个方法通过绘制一系列相连的线段一次性绘制整条繁花曲线:
def draw(self):
# 绘制余下的线段
R, k, l = self.R, self.k, self.l
❶ for i in range(0, 360*self.nRot + 1, self.step):
a = math.radians(i)
❷ x = R*((1-k)*math.cos(a) + l*k*math.cos((1-k)*a/k))
y = R*((1-k)*math.sin(a) - l*k*math.sin((1-k)*a/k))
try:
❸ self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# 繁花曲线已绘制完毕,因此隐藏海龟光标
❹ self.t.hideturtle()
在这个方法中,遍历参数i的取值范围,即0到360与nRot的乘积❶。在这个循环中,根据参数i的当前值计算出相应点的坐标x和y❷,并调用turtle对象的方法setpos()绘制一条从前一个点到当前点的线段❸。调用这个方法的代码放在一个try块中,以便能够捕获可能出现的异常(如用户在绘图期间关闭了窗口),并妥善地退出。最后,隐藏光标❹,因为绘制工作已完成。
如果没有使用命令行参数,这个程序将以动画方式绘制多条随机的繁花曲线,这要求对前面绘制繁花曲线的代码进行重构:不一次性绘制整条繁花曲线,而是编写一个只绘制繁花曲线中一条线段的方法,并在动画的每一步都调用这个方法。为满足这种需求,在Spiro类中定义了方法update(),如下所示:
def update(self):
# 如果繁花曲线已绘制完毕,就跳过后面的步骤
❶ if self.drawingComplete:
return
# 递增角度
❷ self.a += self.step
# 绘制一条线段
R, k, l = self.R, self.k, self.l
# 设置角度
❸ a = math.radians(self.a)
x = self.R*((1-k)*math.cos(a) + l*k*math.cos((1-k)*a/k))
y = self.R*((1-k)*math.sin(a) - l*k*math.sin((1-k)*a/k))
try:
❹ self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# 如果繁花曲线已绘制完毕,就设置相应的标志
❺ if self.a >= 360*self.nRot:
self.drawingComplete = True
# 繁花曲线已绘制完毕,因此隐藏海龟光标
self.t.hideturtle()
在这个方法中,首先检查是否设置了标志drawingComplete❶,如果否,就执行余下的代码。接着递增当前角度❷,根据当前角度计算相应点的坐标x和y❸,并将海龟移到该点,从而绘制一条线段❹。这些代码与方法draw()中for()循环内的代码一样,但只执行一次。
前面讨论繁花曲线的参数方程时,谈到了这种曲线的周期性。角度增加到一定程度后,繁花曲线将开始重复。在方法update()的最后,检查角度是否增大到了这样的程度❺,如果是,就设置标志drawingComplete,因为繁花曲线已绘制完毕。最后,隐藏海龟光标,以便欣赏漂亮的作品。
SpiroAnimator类能用于以动画方式绘制多条随机的繁花曲线。这个类负责协调多个参数随机的Spiro对象,这是通过使用定时器定期调用每个Spiro对象的方法update()实现的。这种技术可定期地更新图形,还可让程序能够处理诸如按键、单击等事件。
下面先来看看SpiroAnimator类的构造函数:
class SpiroAnimator:
# 构造函数
def __init__(self, N):
# 设置定时器值,单位为毫秒
❶ self.deltaT = 10
# 获取窗口尺寸
❷ self.width = turtle.window_width()
self.height = turtle.window_height()
# 设置重新开始标志
❸ self.restarting = False
# 创建Spiro对象
self.spiros = []
for i in range(N):
#生成随机参数
❹ rparams = self.genRandomParams()
#设置繁花曲线参数
❺ spiro = Spiro(*rparams)
self.spiros.append(spiro)
# 调用定时器
❻ turtle.ontimer(self.update, self.deltaT)
在SpiroAnimator类的构造函数中,将deltaT设置成了10❶,这是后面将用于定时器的时间间隔,单位为毫秒(ms)。接下来,存储了turtle窗口的尺寸❷,并初始化了一个标志❸,它用于指出是否正在重新开始绘制。在一个重复N次的循环中(N是在实例化SpiroAnimator对象时传入的),创建新的Spiro对象❺,并将它们添加到列表spiros中。创建每个Spiro对象前,都调用了辅助方法genRandomParams()❹(稍后将介绍这个方法)来随机地生成繁花曲线的参数。然而,Spiro类的构造函数要求向它提供多个参数,因此需使用Python运算符*将传入的元组拆分为一系列参数。最后,设置方法turtle.ontimer(),使其每隔deltaT毫秒就调用update()一次❻,以生成动画效果。
创建每个Spiro对象时,都将向它传递使用方法genRandomParams()生成的随机参数,以生成各种各样的繁花曲线。每当Spiro对象绘制完一条繁花曲线,并为绘制新的繁花曲线做好准备后,都要调用这个方法。
def genRandomParams(self):
width, height = self.width, self.height
R = random.randint(50, min(width, height)//2)
r = random.randint(10, 9*R//10)
l = random.uniform(0.1, 0.9)
xc = random.randint(-width//2, width//2)
yc = random.randint(-height//2, height//2)
col = (random.random(),
random.random(),
random.random())
❶ return (xc, yc, col, R, r, l)
为生成随机参数,使用了Python模块random中的3个方法:randint()、uniform()和random()。randint()随机地返回一个位于指定范围内的整数,uniform()随机地返回一个位于指定范围内的浮点数,而random()随机地返回一个位于0~1之间的浮点数。获取窗口宽度和高度中较小的那个值,并将其除以2,再将R设置为从50到这个计算结果之间的一个随机整数;将r设置为10到R的90%之间的一个随机整数;将l设置为0.1~0.9的一个随机浮点数。
接下来,在屏幕上随机地选择一个点作为繁花曲线的中心,即在屏幕边界内随机地选择 x 和 y 坐标作为xc和yc。为繁花曲线随机地选择一种颜色,即为红色、绿色和蓝色分量分别选择一个随机值(这些值在范围0~1内)。最后,将所有参数作为一个元组返回❶。
SpiroAnimator类有自己的方法restart(),这个方法用来重新开始,以绘制一组新的繁花曲线:
def restart(self):
# 如果正在重新开始,就不重新开始
❶ if self.restarting:
return
else:
self.restarting = True
for spiro in self.spiros:
# 清屏
spiro.clear()
# 生成随机参数
rparams = self.genRandomParams()
# 设置繁花曲线的参数
spiro.setparams(*rparams)
# 重新开始绘制
spiro.restart()
# 结束重新开始绘制过程
❷ self.restarting = False
这个方法遍历所有的Spiro对象,对于每个对象,都清除以前绘制的内容,并随机地生成一组新的繁花曲线参数。然后,使用Spiro对象的设置方法setparams()和restart()来设置参数,从而让Spiro对象为绘制下一个繁花曲线做好准备。标志self.restarting❶用于防止这个方法未执行完毕时被再次调用,例如当用户重复地按空格键时就可能出现这种情况。在方法restart()末尾重置了这个标志,以防忽略对方法restart()的下一次调用❷。
定时器每隔10ms就会调用SpiroAnimator类的方法update()一次,以更新动画中使用的所有Spiro对象,这个方法的代码如下:
def update(self):
# 更新所有的繁花曲线
❶ nComplete = 0
for spiro in self.spiros:
# 更新
❷ spiro.update()
# 计算已绘制完毕的繁花曲线数
❸ if spiro.drawingComplete:
nComplete += 1
# 如果所有的繁花曲线都已绘制完毕,就重新开始
❹ if nComplete == len(self.spiros):
self.restart()
# 调用定时器
try:
❺ turtle.ontimer(self.update, self.deltaT)
except:
print("Exception, exiting.")
exit(0)
方法update()使用计数器nComplete来跟踪有多少个Spiro对象已完成绘制❶。它遍历Spiro对象列表,并调用这些对象的方法update()❷,以绘制每条繁花曲线的下一条线段。如果一个Spiro对象已完成绘制,就将计数器加1❸。
在循环外检查计数器,以确定是否所有Spiro对象都已完成绘制❹。如果都已完成,就调用方法restart(),使用全新的Spiro对象重新开始绘制。在方法update()的末尾,调用了模块turtle的方法ontimer()❺,以便在deltaT毫秒后再次调用update()。正是这些代码让动画得以继续下去。
在SpiroAnimator类中,下面的方法用于在显示和隐藏海龟光标之间切换。隐藏光标后,可提高绘画速度。
def toggleTurtles(self):
for spiro in self.spiros:
if spiro.t.isvisible():
spiro.t.hideturtle()
else:
spiro.t.showturtle()
这个方法使用turtle内置的方法在隐藏和显示光标之间切换(如果当前可见就隐藏,如果当前不可见就显示)。在动画运行期间,当用户按T键时将调用方法toggleTurtles()。
好不容易生成繁花曲线后,能够保存结果就再好不过了。可使用独立函数saveDrawing()将绘图窗口的内容保存为PNG图像文件:
def saveDrawing():
# 隐藏海龟光标
❶ turtle.hideturtle()
# 生成独一无二的文件名
❷ dateStr = (datetime.now()).strftime("%d%b%Y-%H%M%S")
fileName = 'spiro-' + dateStr
print('saving drawing to {}.eps/png'.format(fileName))
# 获取tkinter画布
canvas = turtle.getcanvas()
# 将图形保存为EPS文件
❸ canvas.postscript(file = fileName + '.eps')
#使用模块Pillow将EPS文件转换为PNG文件
❹ img = Image.open(fileName + '.eps')
❺ img.save(fileName + '.png', 'png')
# 显示海龟光标
turtle.showturtle()
首先,隐藏了海龟光标❶,以防它出现在保存的图形中。然后,使用datetime()为图像文件生成基于时间戳的独一无二的名称(格式为“日-月-年-时-分-秒”)❷。为生成文件名,将这个字符串附加在spiro-后面。
这个海龟绘图法程序使用tkinter创建的用户界面(UI)窗口,因此使用tkinter的画布对象以EPS(Embedded PostScript,嵌入式PostScript)文件格式保存窗口中的图像❸。EPS是矢量图格式,可使用它以高分辨率输出图像,但PNG格式的用途更为广泛,因此使用Pillow打开EPS文件❹,再将其保存为PNG文件❺。最后,显示海龟光标。
本书的大多数项目都支持使用命令行参数进行定制。这里不使用人工来分析命令行参数(以防带来麻烦),而将这项烦琐的任务交给Python模块argparse去完成。在繁花曲线绘制程序的main()函数中,第一部分完成的就是命令行参数分析工作:
def main():
❶ parser = argparse.ArgumentParser(description=descStr)
# 添加要求的参数
❷ parser.add_argument('--sparams', nargs=3, dest='sparams', required=False,
help="The three arguments in sparams: R, r, l.")
# 分析参数
❸ args = parser.parse_args()
为管理命令行参数,创建了一个ArgumentParser对象❶。然后,在这个ArgumentParser对象中添加了参数--sparams❷,它由3部分组成,分别对应于繁花曲线的参数R、r和l。使用选项dest指定了对参数进行分析后应将得到的值存储在哪个变量中,并使用required=False指出参数--sparams是可选的。调用方法parse_args()来分析参数❸,通过对象args的属性访问参数。在这里,可通过args.sparams来访问参数--sparams的值。
注意
在本书的每个项目中,都将采用这种基本模式来创建和分析命令行参数。
接下来,用函数main()设置一些turtle参数:
# 将绘图窗口的宽度设置为屏幕宽度的80%
❶ turtle.setup(width=0.8)
# 将光标形状设置为海龟
turtle.shape('turtle')
# 将标题设置为“Spirographs!”
turtle.title("Spirographs!")
# 添加存储图形的按键处理程序
❷ turtle.onkey(saveDrawing, "s")
# 开始侦听
❸ turtle.listen()
# 隐藏海龟光标
❹ turtle.hideturtle()
使用setup()将绘图窗口的宽度设置为屏幕宽度的80%❶(还可向setup()传递高度参数和原点参数)。然后,将光标形状设置为海龟,并将程序窗口的标题设置为“Spirographs!”。接下来,使用onkey()让程序在用户按S键时调用函数saveDrawing()来保存图形❷。通过调用listen(),让绘图窗口侦听用户事件(如按键)❸。最后,隐藏海龟光标❹。
函数main()余下的代码如下:
# 检查是否向--sparams发送了参数并绘制繁花曲线
❶ if args.sparams:
❷ params = [float(x) for x in args.sparams]
# 使用给定的参数绘制繁花曲线
col = (0.0, 0.0, 0.0)
❸ spiro = Spiro(0, 0, col, *params)
❹ spiro.draw()
else:
# 创建SpiroAnimator对象
❺ spiroAnim = SpiroAnimator(4)
# 添加在显示/隐藏海龟光标之间切换的按键处理程序
turtle.onkey(spiroAnim.toggleTurtles, "t")
# 添加重新开始绘制的按键处理程序
turtle.onkey(spiroAnim.restart, "space")
# 开始turtle主循环
❻ turtle.mainloop()
首先检查是否给--sparams传递了参数❶,如果传递了,就只绘制这些参数定义的繁花曲线。这些参数是用字符串表示的,需要将它们解读为数字,因此使用列表推导式将它们转换为一个浮点数列表❷(列表推导式是一种Python结构,能够以紧凑而高效的方式创建列表。例如,a=[2*x for x in range (1, 5) ]创建一个列表,其中包含前4个正偶数)。然后,使用这些参数创建一个Spiro对象❸(这里借助了Python运算符*,它将列表拆分为一系列参数),并调用draw()来绘制相应的繁花曲线❹。
如果没有在命令行中指定参数,就进入随机动画模式。在这种模式下,创建一个SpiroAnimator对象❺并传入参数4,让程序同时绘制4条繁花曲线。然后,调用onkey()两次以捕获其他按键事件:按T键将调用方法toggleTurtles(),在显示和隐藏海龟光标之间切换;按空格键将调用restart()中断当前绘画,并开始绘制4条不同的随机繁花曲线。最后,调用mainloop()让tkinter窗口保持打开状态,以侦听事件❻。