Python极客项目编程

978-7-115-44976-4
作者: 【美】Mahesh Venkitachalam
译者: 王海鹏
编辑: 陈冀康
分类: Python

图书目录:

详情

本书结合了软件开发工程师的兴趣来教授Python编程实战。全书分为5个部分共14章,首先是基础知识,然后是模拟游戏,然后分别从图像、3D图形和硬件等入手,以不同类型的项目实践的方式,告诉读者如何将Python编程技能应用到实际的项目中。 本书充分考虑到读者的学习兴趣和习惯,案例使用价值很高,是一本真正能够帮助中级程序员快速上手使用Pyhton的实战指南。

图书摘要

版权信息

书名:Python极客项目编程

ISBN:978-7-115-44976-4

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Mahesh Venkitachalam

  译    王海鹏

  责任编辑 陈冀康

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


读者服务:

微信扫码关注【异步社区】微信公众号,回复“e44976”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

Simplified Chinese-language edition copyright © 2017 by Posts and Telecom Press.

Copyright © 2016 by Mahesh Venkitachalam. Title of English-language original: Python Playground ,ISBN-13: 978-1-59327-604-1,published by No Starch Press.

All rights reserved.

本书中文简体字版由美国No Starch出版社授权人民邮电出版社出版。未经出版者书面许可,对本书任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


Python是一种解释型、面向对象、动态数据类型的高级程序设计语言。通过Python编程,我们能够解决现实生活中的很多任务。

本书通过14个有趣的项目,帮助和鼓励读者探索Python编程的世界。全书共14章,分别介绍了通过Python编程实现的一些有趣项目,包括解析iTunes播放列表、模拟人工生命、创建ASCII码艺术图、照片拼接、生成三维立体图、创建粒子模拟的烟花喷泉效果、实现立体光线投射算法,以及用Python结合Arduino和树莓派等硬件的电子项目。本书并不介绍Python语言的基础知识,而是通过一系列不简单的项目,展示如何用Python来解决各种实际问题,以及如何使用一些流行的Python库。

本书适合那些想要通过Python编程来进行尝试和探索的读者,适合了解基本的Python语法和基本的编程概念的读者进一步学习,对于Python程序员有一定的启发和参考价值。


写一本书就像跑马拉松。有人这么对我说过。但我确实知道,写这本书考验了我的耐力极限,没有亲朋好友和家人为我摇旗呐喊,我不可能完成。

首先,我感谢我的妻子Hema,她不变的爱、鼓励和耐心,贯穿了完成这项工作的整整两年时间。我感谢我的朋友Raviprakash Jayaraman,他是我所有不确定的项目的“同谋”,也是本书的技术评审,我们一起吃了许多有趣的午餐、看了不少电影,多次去逛S.P. Road Zoo。我感谢我的朋友Seby Kallarakkal,他推动我编写这本书,进行了多次有趣的讨论。我很感谢我的朋友Santosh Hemachandra博士在快速傅里叶变换上的有益讨论。我感谢Karthikeyan Chellappa,帮助我测试Python模块的安装,和我一起围绕Kaikondrahalli湖跑步。我还要感谢Matthew Denham(我与他在Reddit上交谈),他对万花尺(Spirograph)的数学知识提供了帮助。

我感谢No Starch出版社的Tyler Ortman和Bill Pollock,他们接受了我编写本书的意向。还要感谢Serena Yang编辑本书的专业工作。我感谢Nicholas Kramer对本书的技术评阅。 我感谢我的父母A.V. Venkitachalam和N. Saraswathy,他们为我提供的教育远远超越了他们的财务能力。最后,我感谢所有给我启发的老师,我希望一辈子做一名学生。


“在初学者的头脑中有很多可能性,

在专家的头脑中,可能性很少。”

——铃木俊隆


我们的Python探险始于一个简单的项目,该项目在iTunes播放列表文件中查找重复的乐曲音轨,并绘制各种统计数据,如音轨长度和评分。你可以从查看iTunes播放列表格式开始,然后学习如何用Python提取这些文件的信息。为了绘制这些数据,要用到matplotlib库。

在这个项目中,我们将学习以下主题:

iTunes资料库中的信息可以导出为播放列表文件(在iTunes中选择File►Library►Export Playlist)。播放列表文件以可扩展标记语言(XML)写成,这是一种基于文本的语言,旨在分层表示基于文本的信息。它包括一些用户定义的标签所构成的树状集合,标签形如<MyTag>,每个标签可以有一些属性和子标签,其中包含附加的信息。

如果在文本编辑器中打开一个播放列表文件,你会看到类似这样的简化版本:

  <?xml version="1.0" encoding="UTF-8"?>
❶ <!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www
  apple.com/DTDs/PropertyList-1.0.dtd">
❷ <plist version="1.0">
❸ <dict>
❹ <key>Major Version</key><integer>1</integer>
      <key>Minor Version</key><integer>1</integer>
      --snip--
❺    <key>Tracks</key>
      <dict>
          <key>2438</key>
          <dict>
          <key>Track ID</key><integer>2438</integer>
          <key>Name</key><string>Yesterday</string>
          <key>Artist</key><string>The Beatles</string>
          <key>Composer</key><string>Lennon [John], McCartney [Paul]</string>
          <key>Album</key><string>Help!</string>
      </dict>
      --snip--
  </dict>
❻     <key>Playlists</key>
      <array>
          <dict>
              <key>Name</key><string>Now</string>
              <key>Playlist ID</key><integer>21348</integer>
              --snip--
              <array>
                <dict>
                    <key>Track ID</key><integer>6382</integer>
                </dict>
                --snip--
            </array>
        </dict>
      </array>
  </dict>
  </plist>

属性列表(P-list)文件将对象表示为字典,<dict> 和 <key> 标签与这种方式有关。字典是把键和值关联起来的数据结构,让查找值变得容易。属性列表文件使用字典的字典,其中和键关联的值往往自身又是另一个词典(甚至一个字典列表)。

<xml>标签确定文件为XML文件。在这个开始标签之后,文档类型定义(DTD)定义了XML文档的结构❶。如你所见,苹果在该标签中的统一资源定位符(URL)中定义了这种结构。

在❷行,文件声明了顶层<plist>标签,其唯一子元素是字典<dict> ❸。该字典包含了各种键,在❹行,包括Major Version、Minor Version,等等,但我们的兴趣在❺行的Tracks键。注意,该键对应的值也是一个字典,它将整数的音轨ID映射到另一个字典,其中包含Name、Artist等元素。音乐收藏中的每个音轨都有唯一的音轨ID键。

播放列表顺序在❻行由Playlists定义,它是顶层字典的一个子节点。

在这个项目中,我们用内置模块plistlib来读取播放列表文件。我们还用matplotlib库来绘图,用numpy的数组来存储数据。

该项目的目标是找到你的音乐收藏中的重复乐曲,确定播放列表之间共同的音轨,绘制音轨时长的分布图,以及歌曲评分和时长之间的关系图。

随着音乐收藏不断增加,你总会遇到重复的乐曲。为了确定重复的乐曲,查找与Tracks键关联的字典中的名称(前面讨论过),找到重复的乐曲,并用音轨长度作为附加准则来检测重复的乐曲,因为名称相同、但长度不同的音轨,可能是不一样的。

要找到两个或多个播放列表之间共同的音轨,你需要将音乐收藏导出为播放列表文件,收集每个播放列表的音轨名称,作为集合进行比较,通过发现集合的交集来找到共同的音轨。

在收集音乐收藏数据的同时,我们将使用强大的matplotlib(http://matplotlib.org/)绘图软件包来创建一些图,该软件包由已故的John Hunter开发。我们可以绘制直方图来显示音轨时长的分布,绘制散点图来比较乐曲评分与长度。

要查看完整的项目代码,请直接跳到1.4节。

首先可以用findDuplicates()方法来查找重复的曲目,如下所示:

  def findDuplicates(fileName):
      print('Finding duplicate tracks in %s...' % fileName)
      # read in a playlist
❶     plist = plistlib.readPlist(fileName)
      # get the tracks from the Tracks dictionary
❷     tracks = plist['Tracks']
      # create a track name dictionary
❸     trackNames = {}
      # iterate through the tracks
❹     for trackId, track in tracks.items():
          try:
❺            name = track['Name']
              duration = track['Total Time']
              # look for existing entries
❻             if name in trackNames:
                  # if a name and duration match, increment the count
                  # round the track length to the nearest second
❼                 if duration//1000 == trackNames[name][0]//1000:
                      count = trackNames[name][1]
❽                     trackNames[name] = (duration, count+1)
              else:
                  # add dictionary entry as tuple (duration, count)
❾                 trackNames[name] = (duration, 1)
          except:
              # ignore
              pass

在❶行,readPlist()方法接受一个p-list文件作为输入,并返回顶层字典。在❷行,访问Tracks字典,在❸行,创建一个空的字典,用来保存重复的乐曲。在❹行,开始用items()方法迭代Tracks字典,这是Python在迭代字典时取得键和值的常用方法。

在❺行,取得字典中每个音轨的名称和时长。用in关键字,检查当前乐曲的名称是否已在被构建的字典中❻。如果是这样的,程序检查现有的音轨和新发现的音轨长度是否相同❼,用//操作符,将每个音轨长度除以1000,由毫秒转换为秒,并四舍五入到最接近的秒,以进行检查(当然,这意味着,只有毫秒差异的两个音轨被认为是相同的)。如果确定这两个音轨长度相等,就取得与name关联的值,这是(duration,count)元组,并在❽行增加计数。如果这是程序第一次遇到的音轨名称,就创建一个新条目,count为1❾。

将代码的主for循环放在try语句块中,这是因为一些乐曲音轨可能没有定义乐曲名称。在这种情况下,跳过该音轨,在except部分只包含pass(什么也不做)。

利用以下代码,提取重复的音轨:

      # store duplicates as (name, count) tuples
❶     dups = []
      for k, v in trackNames.items():
❷         if v[1] > 1:
              dups.append((v[1], k))
      # save duplicates to a file
❸     if len(dups) > 0:
          print("Found %d duplicates. Track names saved to dup.txt" % len(dups))
      else:
          print("No duplicate tracks found!")
❹     f = open("dups.txt", "w")
      for val in dups:
❺         f.write("[%d] %s\n" % (val[0], val[1]))
      f.close()

在❶行,创建一个空列表,保存重复乐曲。接下来,迭代遍历trackNames字典,如果count(用v[1]访问,因为它是元组的第二个元素)大于1❷,则将元组(name,count)添加到列表中。在❸行,程序打印它找到的信息,然后用open()方法将信息存入文件❹。在❺行,迭代遍历dups列表,写下重复的条目。

现在,让我们来看看如何找到多个播放列表中共同的乐曲音轨:

  def findCommonTracks(fileNames):
      # a list of sets of track names
❶     trackNameSets = []
      for fileName in fileNames:
          # create a new set
❷         trackNames = set()
          # read in playlist
❸         plist = plistlib.readPlist(fileName)
          # get the tracks
          tracks = plist['Tracks']
          # iterate through the tracks
          for trackId, track in tracks.items():
              try:
                  # add the track name to a set
❹                 trackNames.add(track['Name'])
            except:
              # ignore
              pass
        # add to list
❺       trackNameSets.append(trackNames)
      # get the set of common tracks
❻     commonTracks = set.intersection(*trackNameSets)
      # write to file
      if len(commonTracks) > 0:
❼         f = open("common.txt", "w")
          for val in commonTracks:
              s = "%s\n" % val
❽             f.write(s.encode("UTF-8"))
          f.close()
          print("%d common tracks found. "
                "Track names written to common.txt." % len(commonTracks))
      else:
          print("No common tracks!")

首先,将播放列表的文件名列表传入findCommonTracks(),它创建一个空列表❶,保存从每个播放列表创建的一组对象。然后程序迭代遍历列表中的每个文件。对每个文件,创建一个名为trackNames的Python set对象❷,然后像在findDuplicates()中一样,用plistlib读入文件❸,取得Tracks字典。接下来,迭代遍历该字典中的每个音轨,并添加trackNames对象❹。程序读完一个文件中的所有音轨后,将这个集合加入trackNameSets❺。

在❻行,使用set.intersection()方法来获得集合之间共同音轨的集合(用Python*的运算符来展开参数列表)。如果程序发现集合之间的共同音轨,就将音轨名称写入一个文件。在❼行,打开文件,接下来的两行代码完成写入。使用encode()来格式化输出,确保所有Unicode字符都正确处理❽。

接下来,用plotStats()方法,针对这些音轨名称收集统计信息:

  def plotStats(fileName):
      # read in a playlist
❶     plist = plistlib.readPlist(fileName)
      # get the tracks from the playlist
      tracks = plist['Tracks']
      # create lists of song ratings and track durations
❷     ratings = []
      durations = []
      # iterate through the tracks
      for trackId, track in tracks.items():
          try:
❸             ratings.append(track['Album Rating'])
              durations.append(track['Total Time'])
          except:
              # ignore
              pass

      # ensure that valid data was collected
❹     if ratings == [] or durations == []:
          print("No valid Album Rating/Total Time data in %s." % fileName)
          return

这里的目标是收集评分和音轨时长,然后画一些图。在❶行和接下来的代码行中,读取了播放列表文件,并访问Tracks字典。接下来,创建两个空列表,保存评分和时长❷(在iTunes播放列表中,评分是一个整数,范围是[0,100])。迭代遍历音轨,在❸行,将评分和时长添加到相应的列表中。最后,在❹行检查完整性,确保从播放列表文件收集了有效数据。

我们已准备好绘制一些数据了。

     # scatter plot
❶    x = np.array(durations, np.int32)
     # convert to minutes
❷    x = x/60000.0
❸    y = np.array(ratings, np.int32)
❹    pyplot.subplot(2, 1, 1)
❺    pyplot.plot(x, y, 'o')
❻    pyplot.axis([0, 1.05*np.max(x), -1, 110])
❼    pyplot.xlabel('Track duration')
❽    pyplot.ylabel('Track rating')

     # plot histogram
     pyplot.subplot(2, 1, 2)
❾    pyplot.hist(x, bins=20)
     pyplot.xlabel('Track duration')
     pyplot.ylabel('Count')

     # show plot
❿    pyplot.show()

在❶行,利用numpy.array()(在代码中作为np导入),将音轨时长数据放到32位整数数组中。然后在❷行,利用numpy,将一个操作应用于数组中的每个元素。在这个例子中,将每个以毫秒为单位的时长值除以值60×1000。在❸行,将乐曲评分保存另一个numpy数组y中。

用matplotlib在同一图像上绘制两张图。在❹行,提供给subplot()的参数(即,(2, 1, 1))告诉matplotlib,该图应该有两行(2)一列(1),且下一个点应在第一行(1)。在❺行,通过调用plot()创建一个点,并且o告诉matplotlib用圆圈来表示数据。

在❻行,为x轴和y轴设置略微大一点儿的范围,以便在图和轴之间留一些空间。在❼和❽行,为x轴和y轴设置说明文字。

现在用matplotlib的方法hist(),在同一张图中的第二行中,绘制时长直方图❾。bins参数设置了数据分区的个数,其中每分区用于添加在这个范围内的计数。最后,调用show()❿,matplotlib在新窗口中显示出漂亮的图。

现在,我们来看看该程序的main()方法如何处理命令行参数:

  def main():
      # create parser
      descStr = """
      This program analyzes playlist files (.xml) exported from iTunes.
      """
❶     parser = argparse.ArgumentParser(description=descStr)
      # add a mutually exclusive group of arguments
❷     group = parser.add_mutually_exclusive_group()

      # add expected arguments
❸     group.add_argument('--common', nargs='*', dest='plFiles', required=False)
❹     group.add_argument('--stats', dest='plFile', required=False)
❺     group.add_argument('--dup', dest='plFileD', required=False)

      # parse args
❻     args = parser.parse_args()

      if args.plFiles:
          # find common tracks
          findCommonTracks(args.plFiles)
      elif args.plFile:
          # plot stats
          plotStats(args.plFile)
      elif args.plFileD:
          # find duplicate tracks
          findDuplicates(args.plFileD)
      else:
❼         print("These are not the tracks you are looking for.")

本书的大多数项目都有命令行参数。不要尝试手工分析它们并搞得一团糟,要将这个日常的任务委派给Python的argparse模块。在❶行,为此创建了一个ArgumentParser对象。该程序可以做三件不同的事情,如发现播放列表之间的共同音轨,绘制统计数据,或发现播放列表中重复的曲目。但是,一个时间程序只能做其中一件事,如果用户决定同时指定两个或多个选项,我们不希望它崩溃。argparse模块为这个问题提供了一个解决方案,即相互排斥的参数分组。在❷行,用parser.add_mutually_exclusive_group()方法来创建这样一个分组。

在❸、❹和❺行,指定了前面提到的命令行选项,并输入应该将解析值存入的变量名(args.plFiles、args.plFile和args.plFileD),实际解析在❻行完成。参数解析后,就将它们传递给相应的函数,findCommonTracks()、plotStats()和findDuplicates(),本章前面讨论过这些函数。

要查看参数是否被解析,就测试args中相应的变量名。例如,如果用户没有使用--common选项(该选项找出播放列表之间的共同音轨),解析后args.plFiles应该设置为None。

在❼行,处理用户未输入任何参数的情况。

下面是完整的程序。在https://github.com/electronut/pp/tree/master/playlist/,你也可以找到本项目的代码和一些测试数据。

import re, argparse
import sys
from matplotlib import pyplot
import plistlib
import numpy as np

def findCommonTracks(fileNames):
    """
    Find common tracks in given playlist files,
    and save them to common.txt.
    """
    # a list of sets of track names
    trackNameSets = []
    for fileName in fileNames:
        # create a new set
        trackNames = set()
        # read in playlist
        plist = plistlib.readPlist(fileName)
        # get the tracks
        tracks = plist['Tracks']
        # iterate through the tracks
        for trackId, track in tracks.items():
            try:
                # add the track name to a set
                trackNames.add(track['Name'])
            except:
                # ignore
                pass
        # add to list
        trackNameSets.append(trackNames)
        # get the set of common tracks
        commonTracks = set.intersection(*trackNameSets)
        # write to file
        if len(commonTracks) > 0:
            f = open("common.txt", 'w')
            for val in commonTracks:
                s = "%s\n" % val
                f.write(s.encode("UTF-8"))
            f.close()
            print("%d common tracks found. "
                  "Track names written to common.txt." % len(commonTracks))
        else:
            print("No common tracks!")

    def plotStats(fileName):
        """
        Plot some statistics by reading track information from playlist.
        """
        # read in a playlist
        plist = plistlib.readPlist(fileName)
        # get the tracks from the playlist
        tracks = plist['Tracks']
        # create lists of song ratings and track durations
        ratings = []
        durations = []
        # iterate through the tracks
        for trackId, track in tracks.items():
            try:
                ratings.append(track['Album Rating'])
                durations.append(track['Total Time'])
            except:
                # ignore
                pass
        # ensure that valid data was collected
        if ratings == [] or durations == []:
            print("No valid Album Rating/Total Time data in %s." % fileName)
            return

        # scatter plot
        x= np.array(durations, np.int32)
        # convert to minutes
        x = x/60000.0
        y = np.array(ratings, np.int32)
        pyplot.subplot(2, 1, 1)
        pyplot.plot(x, y, 'o')
        pyplot.axis([0, 1.05*np.max(x), -1, 110])
        pyplot.xlabel('Track duration')
        pyplot.ylabel('Track rating')

        # plot histogram
        pyplot.subplot(2, 1, 2)
        pyplot.hist(x, bins=20)
        pyplot.xlabel('Track duration')
        pyplot.ylabel('Count')
        # show plot
        pyplot.show()

    def findDuplicates(fileName):
        """
        Find duplicate tracks in given playlist.
        """
        print('Finding duplicate tracks in %s...' % fileName)
        # read in playlist
        plist = plistlib.readPlist(fileName)
        # get the tracks from the Tracks dictionary
        tracks = plist['Tracks']
        # create a track name dictionary
        trackNames = {}
        # iterate through tracks
        for trackId, track in tracks.items():
            try:
                name = track['Name']
                duration = track['Total Time']
                # look for existing entries
                if name in trackNames:
                    # if a name and duration match, increment the count
                    # round the track length to the nearest second
                    if duration//1000 == trackNames[name][0]//1000:
                        count = trackNames[name][1]
                        trackNames[name] = (duration, count+1)
                else:
                    # add dictionary entry as tuple (duration, count)
                    trackNames[name] = (duration, 1)
            except:
                # ignore
                pass
        # store duplicates as (name, count) tuples
        dups = []
        for k, v in trackNames.items():
            if v[1] > 1:
                dups.append((v[1], k))
        # save duplicates to a file
        if len(dups) > 0:
            print("Found %d duplicates. Track names saved to dup.txt" % len(dups))
        else:
            print("No duplicate tracks found!")
        f = open("dups.txt", 'w')
        for val in dups:
            f.write("[%d] %s\n" % (val[0], val[1]))
        f.close()

    # gather our code in a main() function
    def main():
        # create parser
        descStr = """
        This program analyzes playlist files (.xml) exported from iTunes.
        """
        parser = argparse.ArgumentParser(description=descStr)
        # add a mutually exclusive group of arguments
        group = parser.add_mutually_exclusive_group()

        # add expected arguments
        group.add_argument('--common', nargs='*', dest='plFiles', required=False)
        group.add_argument('--stats', dest='plFile', required=False)
        group.add_argument('--dup', dest='plFileD', required=False)

        # parse args
        args = parser.parse_args()

        if args.plFiles:
            # find common tracks
            findCommonTracks(args.plFiles)
        elif args.plFile:
            # plot stats
            plotStats(args.plFile)
    elif args.plFileD:
        # find duplicate tracks
        findDuplicates(args.plFileD)
    else:
        print("These are not the tracks you are looking for.")

# main method
if __name__ == '__main__':
    main()

下面是该程序的运行示例:

$ python playlist.py --common test-data/maya.xml test-data/rating.xml

下面是输出:

5 common tracks found. Track names written to common.txt.
$ cat common.txt
God Shuffled His Feet
Rubric
Floe
Stairway To Heaven
Pi's Lullaby
moksha:playlist mahesh$

现在,让我们绘制这些音轨的一些统计数据。

$ python playlist.py --stats test-data/rating.xml

图1-1展示了这次运行的输出。

图1-1 playlist.py运行示例

在这个项目中,我们开发了一个程序,分析了iTunes播放列表。在这个过程中,我们学习了一些有用的Python结构。在接下来的项目中,你将基于这里介绍的一些基础知识,探索各种有趣的主题,深入地研究Python。

下面有一些方法可以扩展这个程序。

1.发现重复音轨时,考虑了以音轨时长作为附加标准,来确定两个音轨是否相同。但寻找共同的音轨时,只用了音轨名称进行比较。在findCommonTracks()中,请结合音轨时长作为额外的检查。

2.在plotStats()方法中,用了matplotlib的hist()方法来计算和显示柱状图。请编写代码手动计算直方图,不用hist()方法显示。要将结果显示为条形图,请阅读matplotlib文档中条形图的部分。

3.有一些数学公式用于计算相关系数,测量两个变量之间的关系强度。阅读相关性的资料,利用你自己的音乐数据,计算评分/时长散点图中的相关系数。请考虑可以利用播放列表中收集的数据,制作出另外那些散点图。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e44976”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


我们可以用万花尺玩具(如图2-1所示)来绘制数学曲线。这种玩具由两个不同尺寸的塑料齿轮组成,一大一小。小的齿轮有几个孔。把钢笔或铅笔放入一个孔,然后在较大齿轮(内部有齿)内旋转里面的小齿轮,保持笔与外轮接触,可以画出无数复杂而奇妙的对称图案。

在这个项目中,我们将用Python来创建动画,像万花尺一样绘制曲线。我们的spiro.py程序将用Python和参数方程来描述程序的万花尺齿轮的运动,并绘制曲线(我称之为螺线)。我们可以将完成的画图保存为PNG图像文件,并用命令行选项来指定参数或生成随机螺线。

在这个项目中,我们将学习如何在计算机上绘制螺线。还将学习以下几点:

图2-1 万花尺

关于这个项目要注意:我在这个项目中选择了turtle模块用于说明展示,因为它很有趣,但turtle比较慢,如果性能很关键,就不适合用它来创建图形(你对海龟有何期望?)。如果想快速画图,有更好的方法,后面的项目将探索一些可选方案。

在本节中,你将看到用参数方程来画圆的简单例子。参数方程将曲线上点的坐标表示为一个变量的函数,该变量称为参数。参数方程让绘制曲线变得容易,因为只要将参数代入方程就能产生曲线。

注意

 

如果你现在不想学习这部分数学知识,可以跳到下一部分,讨论针对万花尺项目的方程。

我们开始考虑用半径r来描述一个圆的方程,圆心位于二维平面的原点。xy坐标满足该方程的所有点构成了圆。

现在,请考虑下面的方程:

x = r cos(θ)  

y = r sin(θ)  

这些方程是圆的参数表示,其中角θ是参数。这些方程中(X,Y)的任何值,都满足前面描述的圆的方程,X2 + Y2= R2。如果让θ从0变到2π,可以用这些方程来计算圆上对应的xy坐标。图2-2展示了这种方案。

图2-2 用参数方程描述圆

记住,这两个方程适用于圆心在坐标系原点的圆。将圆心转换到点(a, b),就可以将圆置于xy平面的任何位置。所以更一般的参数方程就变成x = a + r cos(θ)和y = b + r cos(θ)。现在,让我们来看看描述螺线的方程。

图2-3展示了类似万花尺运动的数学模型。该模型没有齿轮,因为玩具中的齿轮只是为了防止打滑,而在这里不必担心打滑。

图2-3 万花尺数学模型

在图2-3中,C是较小的圆的圆心,P是笔尖。较大的圆半径为R,较小的圆半径为r。半径之比表示如下:

将线段PC与小圆半径r之比作为变量l(l = PC / r),它决定了笔尖离小圆圆心有多远。然后,组合这些变量来表示P的运动,得到如下的参数方程:

注意

 

这些曲线称为内旋轮线和外旋轮线。虽然方程可能看起来有点吓人,但推导是非常简单的。如果你想探索其中的数学,请参见维基百科。[1]

图2-4展示了如何用这些方程,基于参数的变化,产生一条曲线。通过改变参数Rrl,可以产生变化无穷的迷人曲线。

图2-4 示例曲线

将曲线绘制为一系列点之间的线段。如果这些点足够接近,图看起来就像平滑的曲线。真正玩过万花尺就知道,这取决于使用的参数,万花尺可能需要许多转数来完成。要确定何时停止绘图,就要利用万花尺的周期性(即万花尺图案多久开始重复),研究内外圆的半径之比:

分子分母除以它们的最大公约数(GCD),化简该分数,分子就告诉我们需要多少圈才能完成曲线。例如,在图2-4中,(r, R)的GCD是5。

下面是该分数化简后的形式:

这告诉我们,13圈后,曲线将开始重复。44告诉我们小圆围绕其中心旋转的圈数,它提示了曲线的形状。在图2-4中数一下,会看到图形中花瓣或叶的数目恰好是44!

一旦用简化形式表示了半径比r/R,画出螺线的参数θ范围就是[0,2πr]。这告诉我们何时停止绘制特定的螺线。不知道该角度的结束范围,就会循环不止,不必要地重复该曲线。

我们可以用Python的turtle模块来创建图案。这是一个简单的绘图程序,模型是一只海龟拖着尾巴穿过沙滩,留下图案。turtle模块包括了一些方法,用于设置笔(海龟的尾巴)的位置和颜色,以及其他有用的绘图函数。如你所见,只要少量绘图函数,就可以创建漂亮的螺线。

例如,这个程序用turtle画圆。输入以下代码,保存为drawcircle.py,在Python中运行它:

 import math
❶ import turtle

  # draw the circle using turtle
  def drawCircleTurtle(x, y, r):
      # move to the start of circle
❷     turtle.up()
❸     turtle.setpos(x + r, y)
❹     turtle.down()

      # draw the circle
❺     for i in range(0, 365, 5):
❻         a = math.radians(i)
❼         turtle.setpos(x + r*math.cos(a), y + r*math.sin(a))

❽ drawCircleTurtle(100, 100, 50)
❾ turtle.mainloop()

在❶行,从导入turtle模块开始。接下来,定义drawCircleTurtle()方法,它在❷行调用up()。这告诉Python提笔。换句话说,让笔离开虚拟的纸,这样移动海龟也不会画图。开始绘图之前,先定位海龟。

在❸行,将海龟的位置设置为横轴上的第一个点:(x + r, y),其中(x,y)是该圆的圆心。现在准备好画图了,所以在❹行调用down()。在❺行,利用range(0, 365, 5)开始循环,以5为步长递增变量i,从0到360,变量i是角度参数,将传入圆的参数方程,但首先在❻行将它从度转为弧度(大多数计算机程序的角度计算需要弧度)。

在❼行,利用前面讨论过的参数方程计算圆的坐标,并设置相应的海龟位置,这样就从海龟上一个位置画线到新计算的位置(从技术上讲,产生的是N边多边形,但因为用了很小的角度,N将非常大,多边形看起来像一个圆)。

在❽行,调用drawCircleTurtle()来画圆,在❾行,调用mainloop(),它保持tkinter窗口打开,让你可以欣赏你画的圆(Tkinter是Python默认的GUI库)。

现在,我们准备好画一些螺线了!

我们将利用下面的模块创建螺线:

首先,定义类Sipro,来绘制这些曲线。我们会用这个类一次画一条曲线(利用draw()方法),并利用一个定时器和update()方法,产生一组随机螺线的动画。为了绘制Spiro对象并产生动画,我们将使用SpiroAnimator类。

要查看完整的项目代码,请直接跳到2.4节。

下面是Spiro构造函数:

  # a class that draws a Spirograph
  class Spiro:
      # constructor
      def __init__(self, xc, yc, col, R, r, l):

          # create the turtle object
❶         self.t = turtle.Turtle()
          # set the cursor shape
❷         self.t.shape('turtle')
          # set the step in degrees
❸         self.step = 5
          # set the drawing complete flag
❹         self.drawingComplete = False

          # set the parameters
❺         self.setparams(xc, yc, col, R, r, l)

          # initialize the drawing
❻         self.restart()

在❶行,Spiro构造函数创建一个新的turtle对象,这将有助于我们同时绘制多条螺线。在❷行,将光标的形状设置为海龟(在https://docs.python.org/3.3/library/ turtle.html,你可以在turtle文档中找到其他选项)。在❸行,将参数绘图角度的增量设置为5度,在❹行,设置了一个标志,将在动画中使用它,它会产生一组螺线。

在❺和❻行,调用设置函数,接下来讨论该函数。

现在让我们看看getParams()方法,它帮助初始化Spiro对象,如下所示:

      # set the parameters
      def setparams(self, xc, yc, col, R, r, l):
          # the Spirograph parameters
❶         self.xc = xc
          self.yc = yc
❷         self.R = int(R)
          self.r = int(r)
          self.l = l
          self.col = col
          # reduce r/R to its smallest form by dividing with the GCD
❸         gcdVal = gcd(self.r, self.R)
❹         self.nRot = self.r//gcdVal
          # get ratio of radii
          self.k = r/float(R)
          # set the color
          self.t.color(*col)
          # store the current angle
❺         self.a = 0

在❶行,保存曲线中心的坐标。然后在❷行,将每个圆的半径(Rr)转换为整数并保存这些值。在❸行,用Python模块fractions内置的gcd()方法来计算半径的GCD。我们将用这些信息来确定曲线的周期性,在❹行将它保存为self.nRot。最后,在❺行,保存当前的角度,我们将用它来创建动画。

接下来,restart()方法重置Spiro对象的绘制参数,让它准备好重画:

      # restart the drawing
      def restart(self):
          # set the flag
❶         self.drawingComplete = False
          # show the turtle
❷         self.t.showturtle()
          # go to the first point
❸         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,来确定绘图是否已经完成,在❶行初始化该标志。绘制多个Spiro对象时,这个标志是有用的,因为它可以追踪某个特定的螺线是否完成。在❷行,显示海龟光标,以防它被隐藏。在❸行提起笔,这样就可以在❻行移动到第一个位置而不画线。在❹行,使用了一些局部变量,以保持代码紧凑。然后,在❺行,计算角度a设为0时的xy坐标,以获得曲线的起点。最后,在❼行,我们已完成,并落笔。Setpos()调用将绘制实际的线。

draw()方法用连续的线段绘制该曲线。

      # draw the whole thing
      def draw(self):
          # draw the rest of the points
          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))
              self.t.setpos(self.xc + x, self.yc + y)
          # drawing is now done so hide the turtle cursor
❸         self.t.hideturtle()

在❶行,迭代遍历参数i的完整范围,它以度表示,是360乘以nRot。在❷行,计算参数i的每个值对应的XY坐标。在❸行,隐藏光标,因为我们已完成绘制。

update()方法展示了一段一段绘制曲线来创建动画时所使用的绘图方法。

      # update by one step
      def update(self):
          # skip the rest of the steps if done
❶         if self.drawingComplete:
              return
          # increment the angle
❷         self.a += self.step
          # draw a step
          R, k, l = self.R, self.k, self.l
          # set the angle
❸         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))
          self.t.setpos(self.xc + x, self.yc + y)
          # if drawing is complete, set the flag
❹         if self.a >= 360*self.nRot:
              self.drawingComplete = True
              # drawing is now done so hide the turtle cursor
              self.t.hideturtle()

在❶行,update()方法检查drawingComplete标志是否设置。如果没有设置,则继续执行代码其余的部分。在❷行,update()增加当前的角度。从❸行开始,它计算当前角度对应的(X,Y)位置并将海龟移到那里,在这个过程中画出线段。

讨论万花尺方程时,我提到了曲线的周期性。在一定的角度后,万花尺的图案开始重复。在❹行,检查角度是否达这条特定曲线计算的完整范围。如果是这样,就设置drawingComplete标志,因为绘图完成了。最后,隐藏海龟光标,你可以看到自己美丽的创作。

SpiroAnimator类让我们同时绘制随机的螺线。该类使用一个计时器,每次绘制曲线的一段。这种技术定期更新图像,并允许程序处理事件,如按键、鼠标点击,等等。但是,这种计时器技术需要对绘制代码进行一些调整。

  # a class for animating Spirographs
  class SpiroAnimator:
      # constructor
      def __init__(self, N):
          # set the timer value in milliseconds
❶         self.deltaT = 10
          # get the window dimensions
❷         self.width = turtle.window_width()
          self.height = turtle.window_height()
          # create the Spiro objects
❸         self.spiros = []
          for i in range(N):
              # generate random parameters
❹             rparams = self.genRandomParams()
              # set the spiro parameters
❺             spiro = Spiro(*rparams)
              self.spiros.append(spiro)
              # call timer
❻             turtle.ontimer(self.update, self.deltaT)

在❶行,该SpiroAnimator构造函数将DeltaT设置为10,这是以毫秒为单位的时间间隔,将用于定时器。在❷行,保存海龟窗口的尺寸。然后在❸行创建一个空数组,其中将填入一些Spiro对象。这些封装的万花尺绘制,然后循环N次(N传入给构造函数SpiroAnimator),在❺行创建一个新的Spiro对象,并将它添加到Spiro对象的列表中。这里的rparams是一个元组,需要传入到Spiro构造函数。但是,构造函数需要一个参数列表,所以用Python的*运算符将元组转换为参数列表。

最后,在❻行,设置turtle.ontimer()方法每隔DeltaT毫秒调用update()。

请注意,在❹行调用了一个辅助方法,名为genRandomParams()。接下来就看看这个方法。

我们用genRandomParams()方法来生成随机参数,在每个Spiro对象创建时发送给它,来生成各种曲线。

      # generate random parameters
      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模块的两个方法:randint(),它返回指定范围内的随机整数,以及uniform(),它对浮点数做同样的事。在❶行,将R设置为50至窗口短边一半长度的随机整数,在❷行,将r设置为R的10%至90%之间。

然后,在❸行,将l设置为0.1至0.9之间的随机小数。在❹和❺行,在屏幕边界内随机选择xy坐标,选择屏幕上的一个随机点作为螺线的中心。在❻行随机设置为红、绿和蓝颜色的成分,为曲线指定随机的颜色。最后,在❼行,所有计算的参数作为一个元组返回。

我们将用另一个restart()方法来重新启动程序。

# restart spiro drawing
    def restart(self):
        for spiro in self.spiros:
            # clear
            spiro.clear()
            # generate random parameters
            rparams = self.genRandomParams()
            # set the spiro parameters
            spiro.setparams(*rparams)
            # restart drawing
            spiro.restart()

它遍历所有的Spiro对象,清除以前绘制的每条螺线,分配新的螺线参数,然后重新启动程序。

下面的代码展示了SproAnimator中的update()方法,它由定时器调用,以动画的形式更新所有的Spiro对象:

      def update(self):
         # update all spiros
❶        nComplete = 0
         for spiro in self.spiros:
             # update
❷            spiro.update()
             # count completed spiros
❸             if spiro.drawingComplete:
                  nComplete += 1
         # restart if all spiros are complete
❹        if nComplete == len(self.spiros):
              self.restart()
         # call the timer
❺        turtle.ontimer(self.update, self.deltaT)

update()方法使用一个计数器nComplete来记录已画的Spiro对象的数目。在❶行初始化后,它遍历Spiro对象的列表,在❷行更新它们,如果一个Spiro完成,就在❸行将计数器加1。

在循环外的❹行,检查计数器,看看是否所有对象都已画完。如果已画完,调用restart()方法重新开始新的螺线动画。在❺行restart()的末尾,调用计时器方法,它在DeltaT毫秒后再次调用update()。

最后,使用下面的方法来打开或关闭海龟光标。这可以让绘图更快。

    # toggle turtle cursor on and off
    def toggleTurtles(self):
        for spiro in self.spiros:
            if spiro.t.isvisible():
                spiro.t.hideturtle()
            else:
                spiro.t.showturtle()

使用saveDrawing()方法,将绘制保存为PNG图像文件。

  # save drawings as PNG files
  def saveDrawing():
      # hide the turtle cursor
❶         turtle.hideturtle()
          # generate unique filenames
❷         dateStr = (datetime.now()).strftime("%d%b%Y-%H%M%S")
          fileName = 'spiro-' + dateStr
          print('saving drawing to %s.eps/png' % fileName)
          # get the tkinter canvas
❸         canvas = turtle.getcanvas()
          # save the drawing as a postscipt image
❹         canvas.postscript(file = fileName + '.eps')
          # use the Pillow module to convert the postscript image file to PNG
❺         img = Image.open(fileName + '.eps')
❻         img.save(fileName + '.png', 'png')
          # show the turtle cursor
❼         turtle.showturtle()

在❶行,隐藏海龟光标,这样就不会在最后的图形中看到它。然后,在❷行,使用datetime(),利用当前时间和日期(以“日—月—年—时—分—秒”的格式),以生成图像文件的唯一名称。将这个字符串加在spiro-后面,生成文件名。

turtle程序采用tkinter创建的用户界面(UI)窗口,在❸和❹行,利用tkinter的canvas对象,将窗口保存为嵌入式PostScript(EPS)文件格式。由于EPS是矢量格式,你可以用高分辨率打印它,但PNG用途更广,所以在❺行用Pillow打开EPS文件,并在❻行将它保存为PNG文件。最后,在❼行,取消隐藏海龟光标。

像第1章中一样,在main()方法中用argparse来解析传入程序的命令行选项。

❶     parser = argparse.ArgumentParser(description=descStr)

     # add expected arguments
❷    parser.add_argument('--sparams', nargs=3, dest='sparams', required=False,
                          help="The three arguments in sparams: R, r, l.")

     # parse args
❸    args = parser.parse_args()

在❶行,创建参数解析器对象,在❷行,向解析器添加--sparams可选参数。在❸行,调用函数进行实际的解析。

接下来,代码设置了一些turtle参数。

     # set the width of the drawing window to 80 percent of the screen width
❶    turtle.setup(width=0.8)

     # set the cursor shape to turtle
❷    turtle.shape('turtle')

     # set the title to Spirographs!
❸    turtle.title("Spirographs!")
     # add the key handler to save our drawings
❹    turtle.onkey(saveDrawing, "s")
     # start listening
❺    turtle.listen()

     # hide the main turtle cursor
❻    turtle.hideturtle()

在❶行,用setup()将绘图窗口的宽度设置为80%的屏幕宽度(你也可以给setup指定高度和原点参数)。在❷行,设置光标形状为海龟,在❸行,设置程序窗口的标题为Spirographs!,在❹行,利用onkey()和saveDrawing,在按下S时保存图画。然后,在❺行,调用listen()让窗口监听用户事件。最后,在❻行,隐藏海龟光标。

命令行参数解析后,代码的其余部分进行如下:

      # check for any arguments sent to --sparams and draw the Spirograph
❶     if args.sparams:
❷         params = [float(x) for x in args.sparams]
          # draw the Spirograph with the given parameters
          col = (0.0, 0.0, 0.0)
❸         spiro = Spiro(0, 0, col, *params)
❹         spiro.draw()
      else:
          # create the animator object
❺         spiroAnim = SpiroAnimator(4)
          # add a key handler to toggle the turtle cursor
❻         turtle.onkey(spiroAnim.toggleTurtles, "t")
          # add a key handler to restart the animation
❼         turtle.onkey(spiroAnim.restart, "space")

      # start the turtle main loop
❽     turtle.mainloop()

在❶行,首先检查是否有参数赋给--sparams。如果有,就从字符串中提取它们,用“列表解析”将它们转换成浮点数❷(列表解析是一种Python结构,让你以紧凑而强大的方式创建一个列表,例如,a = [2*x for x in range(1, 5)]创建前4个偶数的列表)。

在❸行,利用任何提取的参数来构造Spiro对象(利用Python的*运算符,它将列表转换为参数)。然后,在❹行,调用draw(),绘制螺线。

现在,如果命令行上没有指定参数,就进入随机模式。在❺行,创建一个SpiroAnimator对象,向它传入参数4,告诉它创建4幅图画。在❻行,利用onkey()来捕捉按键T,这样就可以用它来切换海龟光标(toggleTurtles),在❼行,处理空格键(space),这样就可以用它在任何时候重新启动动画。最后,在❽行,调用mainloop()告诉tkinter窗口保持打开,监听事件。

下面是完整的万花尺程序。也可以从https://github.com/electronut/pp/blob/master/ spirograph/spiro.py下载该项目的代码。

import sys, random, argparse
import numpy as np
import math
import turtle
import random
from PIL import Image
from datetime import datetime
from fractions import gcd

# a class that draws a Spirograph
class Spiro:
    # constructor
    def __init__(self, xc, yc, col, R, r, l):

        # create the turtle object
        self.t = turtle.Turtle()
        # set the cursor shape
        self.t.shape('turtle')

        # set the step in degrees
        self.step = 5
        # set the drawing complete flag
        self.drawingComplete = False

        # set the parameters
        self.setparams(xc, yc, col, R, r, l)

        # initialize the drawing
        self.restart()

    # set the parameters
    def setparams(self, xc, yc, col, R, r, l):
        # the Spirograph parameters
        self.xc = xc
        self.yc = yc
        self.R = int(R)
        self.r = int(r)
        self.l = l
        self.col = col
        # reduce r/R to its smallest form by dividing with the GCD
        gcdVal = gcd(self.r, self.R)
        self.nRot = self.r//gcdVal
        # get ratio of radii
        self.k = r/float(R)
        # set the color
        self.t.color(*col)
        # store the current angle
        self.a = 0

    # restart the drawing
    def restart(self):
        # set the flag
        self.drawingComplete = False
        # show the turtle
        self.t.showturtle()
        # go to the first point
        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()

    # draw the whole thing
    def draw(self):
        # draw the rest of the points
        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))
            self.t.setpos(self.xc + x, self.yc + y)
            # drawing is now done so hide the turtle cursor
            self.t.hideturtle()

        # update by one step
        def update(self):
            # skip the rest of the steps if done
            if self.drawingComplete:
                return
        # increment the angle
        self.a += self.step
        # draw a step
        R, k, l = self.R, self.k, self.l
        # set the angle
        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))
        self.t.setpos(self.xc + x, self.yc + y)
        # if drawing is complete, set the flag
        if self.a >= 360*self.nRot:
            self.drawingComplete = True
            # drawing is now done so hide the turtle cursor
            self.t.hideturtle()

    # clear everything
    def clear(self):
        self.t.clear()

# a class for animating Spirographs
class SpiroAnimator:
    # constructor
    def __init__(self, N):
        # set the timer value in milliseconds
        self.deltaT = 10
        # get the window dimensions
        self.width = turtle.window_width()
        self.height = turtle.window_height()
        # create the Spiro objects
        self.spiros = []
        for i in range(N):
            # generate random parameters
            rparams = self.genRandomParams()
            # set the spiro parameters
            spiro = Spiro(*rparams)
            self.spiros.append(spiro)
        # call timer
        turtle.ontimer(self.update, self.deltaT)

    # restart spiro drawing
    def restart(self):
        for spiro in self.spiros:
            # clear
            spiro.clear()
            # generate random parameters
            rparams = self.genRandomParams()
            # set the spiro parameters
            spiro.setparams(*rparams)
            # restart drawing
            spiro.restart()

    # generate random parameters
    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)

    def update(self):
        # update all spiros
        nComplete = 0
        for spiro in self.spiros:
            # update
            spiro.update()
            # count completed spiros
            if spiro.drawingComplete:
                nComplete += 1
        # restart if all spiros are complete
        if nComplete == len(self.spiros):
            self.restart()
        # call the timer
        turtle.ontimer(self.update, self.deltaT)

    # toggle turtle cursor on and off
    def toggleTurtles(self):
        for spiro in self.spiros:
            if spiro.t.isvisible():
                spiro.t.hideturtle()
            else:
                spiro.t.showturtle()

# save drawings as PNG files
def saveDrawing():
    # hide the turtle cursor
    turtle.hideturtle()
    # generate unique filenames
    dateStr = (datetime.now()).strftime("%d%b%Y-%H%M%S")
    fileName = 'spiro-' + dateStr
    print('saving drawing to %s.eps/png' % fileName)
    # get the tkinter canvas
    canvas = turtle.getcanvas()
    # save the drawing as a postscipt image
    canvas.postscript(file = fileName + '.eps')
    # use the Pillow module to convert the poscript image file to PNG
    img = Image.open(fileName + '.eps')
    img.save(fileName + '.png', 'png')
    # show the turtle cursor
    turtle.showturtle()

# main() function
def main():
    # use sys.argv if needed
    print('generating spirograph...')
    # create parser
    descStr = """This program draws Spirographs using the Turtle module.
    When run with no arguments, this program draws random Spirographs.

    Terminology:

    R: radius of outer circle
    r: radius of inner circle
    l: ratio of hole distance to r
    """

    parser = argparse.ArgumentParser(description=descStr)

    # add expected arguments
    parser.add_argument('--sparams', nargs=3, dest='sparams', required=False,
                        help="The three arguments in sparams: R, r, l.")

    # parse args
    args = parser.parse_args()

    # set the width of the drawing window to 80 percent of the screen width
    turtle.setup(width=0.8)

    # set the cursor shape to turtle
    turtle.shape('turtle')

    # set the title to Spirographs!
    turtle.title("Spirographs!")
    # add the key handler to save our drawings
    turtle.onkey(saveDrawing, "s")
    # start listening
    turtle.listen()

    # hide the main turtle cursor
    turtle.hideturtle()

    # check for any arguments sent to --sparams and draw the Spirograph
    if args.sparams:
        params = [float(x) for x in args.sparams]
        # draw the Spirograph with the given parameters
        col = (0.0, 0.0, 0.0)
        spiro = Spiro(0, 0, col, *params)
        spiro.draw()
    else:
        # create the animator object
        spiroAnim = SpiroAnimator(4)
        # add a key handler to toggle the turtle cursor
        turtle.onkey(spiroAnim.toggleTurtles, "t")
        # add a key handler to restart the animation
        turtle.onkey(spiroAnim.restart, "space")

    # start the turtle main loop
    turtle.mainloop()

# call main
if __name__ == '__main__':
    main()

现在该运行程序了。

$ python spiro.py

默认情况下,spiro.py程序绘制随机螺线,如图2-5所示。按S键保存绘制。

图2-5 spiro.py的运行示例

现在,再次运行程序,这次在命令行传入参数,画出特定的螺线。

$ python spiro.py --sparams 300 100 0.9

图2-6展示了输出结果。如你所见,这段代码根据用户指定的参数绘制了一条螺线,图2-5和它不同,展示了几个随机螺线的动画。

图2-6 用具体参数运行spiro.py的示例

在这个项目中,我们学习了如何创建万花尺那样的曲线。我们还学习了如何调整输入参数,来生成各种不同的曲线,并在屏幕上产生动画。我希望你喜欢创造这些螺线(在第13章你会惊喜地发现,可以学到如何将螺线投影到墙上)。

下面有一些方法可以进一步尝试螺线。

1.现在你已知道如何画圆,请写一个程序来绘制随机的对数螺线。找到参数形式的对数螺线方程,然后用它来绘制螺线。

2.你可能已经注意到,画曲线时,海龟光标总是朝右,但这不是海龟移动的方式!请调整海龟的方向,在绘制曲线时,让它朝向绘制的方向(提示:每步计算连续点之间的方向矢量,用turtle.setheading()方法来调整海龟的方向)。

3.尝试用海龟绘制Koch snowflake(科赫雪花),它是利用递归(即调用自身的函数)的分形曲线。可以像这样组织递归函数调用:

# recursive Koch snowflake
def kochSF(x1, y1, x2, y2, t):
    # compute intermediate points p2, p3
    if segment_length > 10:
        # recursively generate child segments
        # flake #1
        kochSF(x1, y1, p1[0], p1[1], t)
        # flake #2
        kochSF(p1[0], p1[1], p2[0], p2[1], t)
        # flake #3
        kochSF(p2[0], p2[1], p3[0], p3[1], t)
        # flake #4
        kochSF(p3[0], p3[1], x2, y2, t)
    else:
        # draw
        # ...

如果你确实遇到困难,可以在http://electronut.in/koch-snowflake-and-the-thue- morse-sequence/找到我的解决方案。

[1] http://en.wikipedia.org/wiki/Spirograph/"。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e44976”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


相关图书

深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
动手学自然语言处理
动手学自然语言处理
Python高性能编程(第2版)
Python高性能编程(第2版)
图像处理与计算机视觉实践——基于OpenCV和Python
图像处理与计算机视觉实践——基于OpenCV和Python
Python数据科学实战
Python数据科学实战
Web应用安全
Web应用安全

相关文章

相关课程