Python-游乐场第二版-全-
Python 游乐场第二版(全)
原文:
zh.annas-archive.org/md5/cf54567a3db581c123d85b484aeed1ae译者:飞龙
序言

欢迎来到Python Playground的第二版!在这本书中,你将发现 15 个令人兴奋的项目,旨在鼓励你探索 Python 编程的世界。这些项目涵盖了广泛的主题,如绘制涡旋图样式的图案、3D 渲染、与音乐同步投射激光图案以及使用机器学习进行语音识别。这些项目不仅本身很有趣,而且提供了大量扩展的空间,旨在成为你探索自己创意的起点。
这本书适合谁?
Python Playground是为任何好奇如何通过编程理解和探索想法的人写的。本书中的项目假设你已经掌握了基本的 Python 语法和编程概念,并且熟悉高中数学。我尽力详细解释了所有项目所需的数学内容。
本书并非为你的第一本 Python 书籍设计。我不会带你走过基础知识。然而,我会展示如何使用 Python 解决一系列实际问题。通过这些项目,你将深入了解 Python 编程语言的细节,并学习如何使用一些流行的 Python 库。但或许更重要的是,你将学会如何将一个问题分解为多个部分,开发一个算法来解决该问题,然后从头开始使用 Python 实现解决方案。
解决现实世界的问题可能是困难的,因为这些问题通常是开放式的,并且需要在多个领域拥有专业知识。但 Python 提供了促进问题解决的工具。克服困难并找到解决实际问题的方案是你成为一名专家程序员道路上最重要的部分。
本书内容
让我们快速浏览一下本书的章节。第一部分包含了一些入门项目,帮助你开始。
第一章:科赫雪花 使用递归函数和turtle图形绘制一个有趣的分形图形。
第二章:涡旋图 使用参数方程和turtle图形绘制类似玩具涡旋图产生的曲线。
第二部分包含使用数学模型创建计算机模拟现实世界现象的项目。
第三章:康威的生命游戏 使用numpy和matplotlib实现一个著名的细胞自动机,基于几个简单的规则,生成一个不断演化、栩栩如生的系统。
第四章:卡尔普斯-斯特朗音阶利用该算法逼真地模拟拨弦乐器的声音,并通过pyaudio播放这些声音。
第五章:群体行为使用numpy和matplotlib实现 Boids 算法,并模拟鸟类的群体行为。
第三部分中的项目将介绍如何使用 Python 读取和操作 2D 图像。
第六章:ASCII 艺术通过将图像转换为基于文本的 ASCII 艺术,介绍了Pillow,它是 Python 图像库(PIL)的一个分支。
第七章:照片拼贴通过拼接一系列较小的图像来创建一个可以识别的大图像。
第八章:自适应立体图使用深度图和像素操作将 3D 图像的幻觉嵌入到 2D 图像中。
在第四部分中,你将学习如何使用着色器和 OpenGL 库,通过直接操作图形处理单元(GPU)快速高效地渲染 3D 图形。
第九章:理解 OpenGL 介绍了使用 OpenGL 创建简单 3D 图形的基础。
第十章:环面上的康威生命游戏回顾了第三章的模拟,并将其呈现在 3D 环面表面上。
第十一章:体积渲染实现体积光线投射算法以渲染体积数据,这是一种常用于医学影像(如 MRI 和 CT 扫描)的技术。
最后,在第五部分中,你将与树莓派及其他电子元件一起工作,将 Python 部署到嵌入式系统中。
第十二章:树莓派 Pico 上的卡尔普斯-斯特朗通过在微型树莓派 Pico 微控制器上实现第四章中的卡尔普斯-斯特朗算法,创建一个可演奏的电子乐器,使用 MicroPython。
第十三章:树莓派激光音频显示利用树莓派上的 Python 控制两个旋转镜子和激光器,生成与声音响应的激光光秀。
第十四章:物联网花园使用蓝牙低能耗将树莓派与运行 CircuitPython 的 Adafruit 硬件连接,创建一个物联网系统以监控你花园中的温度和湿度水*。
第十五章:树莓派上的音频机器学习通过在树莓派上实现语音识别系统,介绍了使用 TensorFlow 的激动人心的机器学习世界。
每章的结尾都有一个“实验!”部分,提供了如何扩展项目或进一步探索当前主题的建议。
第二版的新内容
第二版新增了五个项目,包括第一章中的科赫雪花项目和第十章中的康威生命游戏 3D 版本。最显著的更新出现在硬件部分,该部分已简化,专注于基于 Raspberry Pi 的系统,而不再是 Raspberry Pi 和 Arduino 的混合。因此,第 V 部分中的每个项目要么是新的(第十二章、第十四章、第十五章),要么已被彻底重设计(第十三章)。
仅依赖 Raspberry Pi 简化了硬件项目的设置过程,同时也保持了书籍对 Python 编程的关注。你不再需要在 Python 和用 Arduino 编程语言(C++的一种变体)编写的代码之间切换。通过更新后的第 V 部分,你还将体验使用 MicroPython 和 CircuitPython 进行编程,它们是为资源受限的嵌入式系统优化的两种 Python 变体。
第二版的其他重要更新包括:
-
• 在第四章中,使用
pyaudio而不是pygame来播放 WAV 文件。 -
• 在第七章中,比较线性搜索算法和 k-d 树数据结构在查找最佳图像匹配时的性能,用于光栅拼图。
-
• 在第八章中,提供了如何创建自己的深度图以生成自动立体图的指南。
-
• 在附录 A 中,简化了使用标准 Anaconda 发行版 Python 的安装说明。
除了这些具体的更新外,整个文本已被审查、修正和澄清,代码也根据需要进行了更新,以反映自第一版发布以来 Python 的变化。
为什么选择 Python?
Python 是探索编程的理想语言。作为一种多范式语言,它为你提供了大量的灵活性来组织程序。你可以将 Python 用作脚本语言,简单地执行代码,也可以用作过程式语言,将程序组织成互相调用的函数集合,或者用作面向对象语言,通过类、继承和模块建立层次结构。这种灵活性使你能够选择最适合特定项目的编程风格。
当你使用像 C 或 C++ 这样更传统的语言进行开发时,你必须先编译和链接代码才能运行。而在 Python 中,你可以在编辑后直接运行代码。(在后台,Python 会将你的代码编译成中间字节码,然后由 Python 解释器运行,但这些过程对你作为用户是透明的。)实际上,使用 Python 修改和反复运行代码的过程要简单得多。
Python 提供了一组非常简单且强大的数据结构。如果你已经理解了字符串、列表、元组、字典、列表推导式以及 for 和 while 循环等基本控制结构,那么你已经迈出了很好的第一步。Python 简洁而富有表现力的语法使得你可以用少量的代码行执行复杂的操作。一旦你熟悉了 Python 内建和第三方模块,你将拥有一整套工具来解决像本书中讨论的实际问题。Python 提供了标准的方式来从 Python 调用 C/C++ 代码,反之亦然,并且因为你可以找到几乎能做任何事的 Python 库,所以将 Python 与其他语言模块结合在更大的项目中非常容易。这就是为什么 Python 被认为是一个出色的 粘合剂 语言——它使得将不同的软件组件组合起来变得轻而易举。在 第 IV 部分 中的 3D 图形项目展示了 Python 如何与类似 C 的 OpenGL 着色语言并肩工作,而在 第十四章 中,你甚至会将一些 HTML、CSS 和 JavaScript 混合在一起,为你的物联网花园监控器创建一个 Web 界面。实际的软件项目通常使用多种软件技术的组合,Python 在这样的分层架构中非常合适。
Python 还提供了一个方便的工具——Python 解释器。它让你可以轻松检查代码语法,进行快速计算,甚至测试正在开发中的代码。当我编写 Python 代码时,我会同时打开三个窗口:一个文本编辑器,一个终端,和一个 Python 解释器。当我在编辑器中开发代码时,我会将我的函数或类导入到解释器中并实时测试。
我还使用解释器来了解新模块的使用方式,随后再将它们整合到我的代码中。例如,在开发 第十四章 中的物联网花园项目时,我想测试 sqlite3 数据库模块。我启动了 Python 解释器,并尝试了以下操作,以确保我理解如何创建和添加数据库条目:
import sqlite3
con = sqlite3.connect('test.db')
cur = con.cursor()
cur.execute("CREATE TABLE sensor_data (TS datetime, ID text, VAL numeric)")
for i in range(10):
... cur.execute("INSERT into sensor_data VALUES (datetime('now'),'ABC', ?)", (i, ))
con.commit()
con.close()
exit()
然后,为了确保它正常工作,我执行了以下操作来检索我添加的一些数据:
>>> `con = sqlite3.connect('test.db')`
>>> `cur = con.cursor()`
>>> `cur.execute("SELECT * FROM sensor_data WHERE VAL > 5")`
>>> `print(cur.fetchall())`
[('2021-10-16 13:01:22', 'ABC', 6), ('2021-10-16 13:01:22', 'ABC', 7),
('2021-10-16 13:01:22', 'ABC', 8), ('2021-10-16 13:01:22', 'ABC', 9)]
这个示例展示了 Python 解释器作为一个强大开发工具的实际应用。你不需要编写完整的程序来做快速实验;只需打开解释器,开始实验。这只是我喜欢 Python 的众多原因之一,也是我认为你也会喜欢 Python 的原因。
本书中的代码
在本书中,我已经尽力详细地带领你逐步走过每个项目的代码。你可以自己输入代码,或者通过github.com/mkvenkit/pp2e下载本书中所有程序的完整源代码(使用下载 Zip 选项)。
在接下来的几页中,你将会看到几个令人兴奋的项目。我希望你在玩这些项目时能像我创建它们时一样开心。别忘了通过每个项目结尾处的实验进一步探索。我祝你在使用Python Playground编程时度过许多快乐的时光!
第一部分:# 热身
在初学者的心态中,存在许多可能性;在专家的心态中,却很少。
—鈴木俊隆
第一章:# 科赫雪花

我们将通过弄清楚如何绘制一个有趣的形状——科赫雪花来开始我们的 Python 冒险。它是瑞典数学家赫尔格·冯·科赫(Helge von Koch)于 1904 年发明的。科赫雪花是一种分形——一种随着你不断放大它,它会重复自身的图形。
分形的重复性质来自于递归,这是一种将某物定义为其自身的技巧。特别是,你通过递归算法绘制分形,这是一种重复的过程,其中一次重复的输出成为下次重复的输入。
在本章学习过程中,你将学到:
-
• 递归算法和函数的基础
-
• 如何使用
turtle模块创建图形 -
• 绘制科赫雪花的递归算法
-
• 一些线性代数
它是如何工作的
图 1-1 显示了科赫雪花的样子。注意中间的大分支在左右两侧以较小的比例重复出现。类似地,中间的大分支本身由一些更小的分支构成,重复了更大的形状。这就是分形的重复自相似特性。

图 1-1:科赫雪花
如果你知道如何计算构成雪花基本形状的点,你就可以开发一个算法,通过递归执行相同的计算。通过这种方式,你将绘制出该形状的越来越小的版本,从而构建出分形。在本节中,我们将大致了解递归是如何工作的。接着,我们将考虑如何结合递归、一些线性代数和 Python 的turtle模块来绘制科赫雪花。
使用递归
为了感受递归是如何工作的,我们先来看一个简单的递归算法:计算一个数字的阶乘。一个数字的阶乘可以通过一个函数来定义,如下所示:
f(N) = 1 × 2 × 3 × . . . × (N − 1) × N
换句话说,N的阶乘就是 1 到N之间所有数字的乘积。你可以将其重写为:
f(N) = N × (N − 1) × . . . × 3 × 2 × 1
它可以再次被重写为:
f(N) = N × f(N − 1)
等等,你刚刚做了什么?你把f定义为它自身!这就是递归。调用f(N)最终会调用f(N − 1),而f(N − 1)又会调用f(N − 2),依此类推。那么,怎么知道什么时候停止呢?你需要将f(1)定义为 1,这就是递归的最深层次。
这是如何在 Python 中实现递归阶乘函数的:
def factorial(N):
❶ if N == 1:
return 1
else:
❷ return N * factorial(N-1)
当N等于 1 时,通过简单地返回1 ❶来处理这种情况,并通过再次调用factorial()实现递归调用 ❷,这次传入N-1。该函数将一直调用自身,直到N等于 1。其效果是,当函数返回时,它将计算出从 1 到N的所有数字的乘积。
通常,在尝试使用递归实现算法时,按照以下步骤操作:
-
1. 定义递归结束的基本情况。在我们的阶乘示例中,通过将f(1)定义为 1 来完成此操作。
-
2. 定义递归步骤。为此,你需要考虑如何将算法表达为递归过程。在某些算法中,函数可以从多个递归调用中调用,正如你将很快看到的那样。
递归是解决可以自然地分割成更小版本的问题的有用工具。阶乘算法就是这种分割的完美例子,正如你很快会看到的,绘制科赫雪花也是如此。尽管如此,递归并不总是解决问题的最有效方法。在某些情况下,重新用循环实现递归算法可能更合理。但事实是,递归算法通常比它们的循环对应物更紧凑和优雅。
计算雪花
现在让我们看看如何构造科赫雪花。图 1-2 展示了绘制雪花的基本图案。我将这种图案称为雪花。图案的基础是长度为d的线段
。该段被分为三等分部分,
、
和
,每个部分的长度为r。不直接连接点P[1]和P[3],而是通过P[2]连接这些点,选择P[2]使得P[1]、P[2]和P[3]形成边长为r、高度为h的等边三角形。点C,P[1]和P[3](以及A和B)的中点,正好位于P[2]的正下方,因此
和
是垂直的。

图 1-2:绘制科赫雪花的基本图案
一旦你理解了如何计算图 1-2 中显示的点,你就能递归地绘制越来越小的雪花,以重现科赫雪花。基本上,你的目标是这样的:给定点A和B,你想计算出点P[1]、P[2]和P[3],并像图中显示的那样将它们连接起来。为了计算这些点,你需要使用一些线性代数,这是一门数学学科,让你可以计算距离,并根据向量确定点的坐标,向量是具有大小和方向的量。
这里有一个你将使用的简单线性代数公式。假设你在 3D 空间中有一个点 A 和一个单位向量
(单位向量是长度为 1 单位的向量)。沿着该单位向量,距离 d 的点 B 的坐标为:
B = A + d × 
你可以通过一个例子轻松验证这一点。假设 A = (5, 0, 0) 且
= (0, 1, 0)。那么,点 B 在沿着
方向上与 A 相距 10 个单位的坐标是多少呢?使用之前的公式,你得到:
B = (5, 0, 0) + 10 × (0, 1, 0) = (5, 10, 0)
换句话说,从 A 到 B,你沿着正 y 轴移动了 10 个单位。
这是另一个你将使用的结果——我们称之为垂直向量技巧。假设你有一个向量
= (a, b)。如果你有另一个向量
,它与
垂直,那么它可以表示为
= (−b, a)。你可以通过对
和
进行点积来验证这个技巧的正确性。为了计算一对二维向量的点积,你需要将每个向量的第一个分量相乘,然后将每个向量的第二个分量相乘,最后将结果加在一起。在这种情况下,
和
的点积为:
= (a × −b) + (b × a) = −ab + ab = 0
两个垂直向量的点积总是零,因此
确实与
垂直。
有了这个公式,让我们回到图 1-2 中的雪花。给定点 A 和 B 的坐标,如何计算 P[2] 的位置?你知道 P[2] 离点 C 的距离是 h,并且沿着单位向量
方向。你的第一个线性代数公式告诉你:
P[2] = C + h × 
现在让我们把这些变量转换成你已知的形式。首先,C 是线段
的中点,因此 C = (A + B) / 2。接下来,h 是一个边长为 r 的等边三角形的高度。毕达哥拉斯定理告诉你:

在这种情况下,r 仅仅是从 A 到 B 的三分之一的距离。如果 A 的坐标是 (x[1], y[1]),B 的坐标是 (x[2], y[2]),你可以通过以下公式计算它们之间的距离:

然后只需将 d 除以 3 即可得到 r。
最后,你需要一种方法来表示
。你知道
垂直于向量
,你可以通过将点 A 的坐标减去点 B 的坐标来表示
:
= (x[2] − x[1], y[2] − y[1])
的大小由 d =
给出。现在你可以使用垂直向量技巧,将
用 A 和 B 来表示:

接下来你需要计算 P[1] 和 P[3]。为此,你将使用线性代数中的另一个结果。假设你有一条线
和这条线上的一点 C。设 a 为 C 到 A 的距离,b 为 C 到 B 的距离。点 C 可以表示为:

为了理解这个公式,想象一下如果 C 是 A 和 B 的中点,也就是说 a 和 b 会相等。在这种情况下,你可以直观地认为 C 应该等于 (A + B) / 2。将先前方程中的所有 b 替换为 a,你将得到:

现在有了这个新公式,你可以计算 P[1] 和 P[3]。这些点将线
分成三等分,这意味着从 P[1] 到 B 的距离是从 P[1] 到 A 的两倍(b = 2a),而从 P[3] 到 A 的距离是从 P[3] 到 B 的两倍(a = 2b)。将这个代入公式,你就可以计算出这些点:
和 
现在你已经具备了绘制雪花分形第一层所需的一切。决定了 A 和 B 后,你就知道如何计算点 P[1]、P[2] 和 P[3]。但是分形的第二层会发生什么呢?你将第一层雪花中的每一条线段(图 1-2)替换为一小段雪花。结果如 图 1-3 所示。

图 1-3:Koch 雪花构造的第二步
请注意,图 1-2 中每个四个线段的变化,
,
,
, 和
,都成为了新雪花的基础。在科赫雪花程序中,你将能够使用每个线段的端点,例如,A和P[1],作为新的A和B的值,并递归执行相同的计算,得到图 1-2 中的各个点。
在分形的每一层,你将再次细分雪花,绘制越来越小的自相似图形。这是算法的递归步骤,你将重复该步骤,直到达到基本情况。当
小于某个阈值时——比如 10 像素——就应停止递归,直接绘制线段。
为了让最终的输出更加花哨,你可以绘制三个相连的雪花,作为分形的第一层。这将呈现出实际雪花的六角对称性。图 1-4 展示了开始绘制时的效果。

图 1-4:结合三个雪花
现在你知道了如何计算雪花的坐标,我们来看看如何在 Python 中使用这些坐标实际绘制图像。
使用 turtle 图形绘制
在本章中,你将使用 Python 的turtle模块来绘制雪花;这是一个简单的绘图程序,模仿了乌龟用尾巴在沙子里拖动,创造图案的概念。turtle模块包括一些方法,你可以使用这些方法来设置画笔的位置和颜色(即乌龟的尾巴),还有许多其他用于绘图的有用函数。
如你所见,绘制科赫雪花所需的仅仅是少数几个图形函数。实际上,从turtle的角度来看,绘制雪花几乎和绘制三角形一样简单。为了证明这一点,并让你感受turtle的工作方式,以下程序使用turtle绘制了一个三角形。输入代码,保存为test_turtle.py,并在 Python 中运行:
❶ import turtle
def draw_triangle(x1, y1, x2, y2, x3, y3, t):
# go to start of triangle
❷ t.up()
❸ t.setpos(x1, y1)
❹ t.down()
t.setpos(x2, y2)
t.setpos(x3, y3)
t.setpos(x1, y1)
t.up()
def main():
print('testing turtle graphics...')
❺ t = turtle.Turtle()
❻ t.hideturtle()
❼ draw_triangle(-100, 0, 0, -173.2, 100, 0, t)
❽ turtle.mainloop()
# call main
if __name__ == '__main__':
main()
首先导入 turtle 模块 ❶。接下来,定义 draw_triangle() 方法,方法的参数是三对 x 坐标和 y 坐标(三个三角形的角),以及 t,一个 turtle 对象。该方法首先调用 up() ❷。这告诉 Python 将画笔抬起;换句话说,把画笔从虚拟纸面上提起来,以便在移动海龟时不会画出任何东西。在开始绘制之前,你需要定位海龟的位置。setpos() 调用 ❸ 将海龟的位置设置为第一对 x 和 y 坐标。调用 down() ❹ 将画笔放下,在接下来的每次 setpos() 调用时,海龟移动到下一个坐标点,画出一条线。最终结果是绘制出一个三角形。
接下来,你声明一个 main() 函数来实际进行绘制。在该函数中,你创建了一个用于绘制的 turtle 对象 ❺,并隐藏了海龟 ❻。如果没有这个命令,你将看到一个小形状,表示海龟出现在正在绘制的线条前端。然后你调用 draw_triangle() 来绘制三角形 ❼,并传入所需的坐标作为参数。调用 mainloop() ❽ 可以在三角形绘制完成后保持 tkinter 窗口打开。(tkinter 是 Python 的默认图形界面库。)
图 1-5 显示了这个简单程序的输出。

图 1-5:一个简单的 turtle 程序的输出
现在你拥有了完成项目所需的一切。让我们开始绘制雪花吧!
需求
在这个项目中,你将使用 Python 的 turtle 模块来绘制雪花。
代码
要绘制 Koch 雪花,定义一个递归函数 drawKochSF()。该函数根据 图 1-2 中的 A 和 B 计算 P[1]、P[2] 和 P[3],然后递归调用自身,进行相同的计算,逐渐处理更小的线段,直到达到最小的基准情况。然后使用 turtle 绘制雪花。有关完整的项目代码,请跳转到 “完整代码”,位于 第 16 页。代码也可以在本书的 GitHub 仓库中找到,链接为 github.com/mkvenkit/pp2e/blob/main/koch/koch.py。
计算各个点
在 drawKochSF() 函数中,首先计算出绘制基本雪花图案所需的所有点的坐标,如 图 1-2 所示。
def drawKochSF(x1, y1, x2, y2, t):
d = math.sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2))
r = d/3.0
h = r*math.sqrt(3)/2.0
p3 = ((x1 + 2*x2)/3.0, (y1 + 2*y2)/3.0)
p1 = ((2*x1 + x2)/3.0, (2*y1 + y2)/3.0)
c = (0.5*(x1+x2), 0.5*(y1+y2))
n = ((y1-y2)/d, (x2-x1)/d)
p2 = (c[0]+h*n[0], c[1]+h*n[1])
你定义了drawKochSF(),并传入了线段
的 x 和 y 坐标,这构成了雪花的一个边,如图 1-4 所示。你还传入了turtle对象t,它用于实际绘制。然后,你计算出图 1-2 中显示的所有参数,如“计算雪花”部分中所述,从d开始,d是点A到B的距离。将d除以 3 得到r,这是构成雪花的一条边的长度。你使用r来计算h,雪花锥体的高度。
你将其余的参数计算为包含 x 和 y 坐标的元组。p3和p1元组描述了雪花锥形部分底部的两个点。点c是p1和p3的中点,n是垂直于线段
的单位向量。连同h一起,它们帮助你计算雪花锥体的顶点p2。
递归
drawKochSF()函数的下一部分使用递归将一级雪花分解为更小的版本。
❶ if d > 10:
# flake #1
❷ drawKochSF(x1, y1, p1[0], p1[1], t)
# flake #2
drawKochSF(p1[0], p1[1], p2[0], p2[1], t)
# flake #3
drawKochSF(p2[0], p2[1], p3[0], p3[1], t)
# flake #4
drawKochSF(p3[0], p3[1], x2, y2, t)
首先检查递归停止的条件❶。如果d,即线段的长度
,大于 10 像素,则继续递归。你通过再次调用drawKochSF()函数来实现这一点——四次!每次调用时,你会传入一组不同的参数,对应于构成雪花的四条线段的坐标,这些坐标是在函数开始时计算出来的。例如,在❷处,你会调用drawKochSF()来处理线段
。其他的函数调用则对应于线段
,
和
。在这些递归调用中,你将基于新计算的点A和B的值,执行一组新的计算,如果d仍然大于 10 像素,你将继续进行四次递归调用drawKochSF(),以此类推。
绘制雪花
现在我们来看一下如果线段
小于 10 像素时会发生什么。这是递归算法的基本情况。由于你已低于阈值,因此不再进行递归。相反,你将绘制构成单个雪花图案的四条线段,并从函数中返回。你使用了up()、down()和setpos()方法,这些方法来自turtle模块,你在“使用turtle图形绘制”部分已经学习过。
else:
# draw cone
t.up()
❶ t.setpos(p1[0], p1[1])
t.down()
t.setpos(p2[0], p2[1])
t.setpos(p3[0], p3[1])
# draw sides
t.up()
❷ t.setpos(x1, y1)
t.down()
t.setpos(p1[0], p1[1])
t.up()
❸ t.setpos(p3[0], p3[1])
t.down()
t.setpos(x2, y2)
首先,你绘制由点 p1、p2 和 p3 形成的圆锥形 ❶。然后你绘制线条
❷ 和
❸。由于你在函数开始时已经完成了所有必要的计算,绘制仅仅是将适当的坐标传递给 setpos() 方法的问题。
编写 main() 函数
main() 函数设置了一个 turtle 对象,并调用 drawKochSF()。
def main():
print('Drawing the Koch Snowflake...')
t = turtle.Turtle()
t.hideturtle()
# draw
try:
❶ drawKochSF(-100, 0, 100, 0, t)
❷ drawKochSF(0, -173.2, -100, 0, t)
❸ drawKochSF(100, 0, 0, -173.2, t)
❹ except:
print("Exception, exiting.")
exit(0)
# wait for user to click on screen to exit
❺ turtle.Screen().exitonclick()
在 图 1-4 中,你看到你将如何绘制三个雪花,以获得六边形对称的最终输出。你通过三次调用 drawKochSF() 来实现这一点。用于点 A 和 B 的坐标为:第一个雪花 ❶ 的坐标是 (-100, 0), (100, 0),第二个雪花 ❷ 的坐标是 (0, -173.2), (-100, 0),第三个雪花 ❸ 的坐标是 (100, 0), (0, -173.2)。注意,这些坐标和你之前在 test_turtle.py 程序中绘制三角形时使用的坐标是一样的。试着自己算出这些坐标。(提示:
)。
drawKochSF() 的调用被封装在一个 Python try 块中,以捕获绘图过程中发生的任何异常。例如,如果你在绘制过程中关闭窗口,就会抛出异常。你在 except 块 ❹ 中捕获它,打印一条消息并退出程序。如果你允许绘制完成,你将进入 turtle.Screen().exitonclick() ❺,该方法会等待你通过点击窗口中的任何地方来关闭窗口。
运行雪花代码
在终端中运行代码,如下所示。图 1-6 展示了输出结果。
$ `python koch.py`

图 1-6:科赫雪花输出
这就是你美丽的雪花!
总结
在这一章中,你学习了递归函数和算法的基础知识。你还学习了如何使用 Python 的 turtle 模块绘制简单的图形。你将这些概念结合在一起,创造了一个有趣的分形图形——科赫雪花。
实验!
现在你已经完成了一个分形图形,我们来看看另一个有趣的图形,叫做 Sierpiński 三角形,它以波兰数学家瓦茨瓦夫·谢尔*斯基(Wacław Sierpiński)命名。图 1-7 展示了它的样子。

图 1-7:Sierpiński 三角形
尝试使用turtle图形绘制谢尔宾斯基三角形。你可以使用递归算法,就像你绘制科赫雪花时那样。如果你查看图 1-7,你会看到大三角形被分成三个较小的三角形,中间有一个倒三角形的空洞。这三个较小的三角形每个又被分成三个三角形,且中间都有一个空洞,依此类推。这为你如何拆分递归提供了一个提示。
(此问题的解决方案在 GitHub 上的书籍仓库中,链接为github.com/mkvenkit/pp2e/blob/main/koch/koch.py)
完整代码
这是该项目的完整代码列表:
"""
koch.py
A program that draws the Koch snowflake.
Author: Mahesh Venkitachalam
"""
import turtle
import math
# draw the recursive Koch snowflake
def drawKochSF(x1, y1, x2, y2, t):
d = math.sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2))
r = d/3.0
h = r*math.sqrt(3)/2.0
p3 = ((x1 + 2*x2)/3.0, (y1 + 2*y2)/3.0)
p1 = ((2*x1 + x2)/3.0, (2*y1 + y2)/3.0)
c = (0.5*(x1+x2), 0.5*(y1+y2))
n = ((y1-y2)/d, (x2-x1)/d)
p2 = (c[0]+h*n[0], c[1]+h*n[1])
if d > 10:
# flake #1
drawKochSF(x1, y1, p1[0], p1[1], t)
# flake #2
drawKochSF(p1[0], p1[1], p2[0], p2[1], t)
# flake #3
drawKochSF(p2[0], p2[1], p3[0], p3[1], t)
# flake #4
drawKochSF(p3[0], p3[1], x2, y2, t)
else:
# draw cone
t.up()
t.setpos(p1[0], p1[1])
t.down()
t.setpos(p2[0], p2[1])
t.setpos(p3[0], p3[1])
# draw sides
t.up()
t.setpos(x1, y1)
t.down()
t.setpos(p1[0], p1[1])
t.up()
t.setpos(p3[0], p3[1])
t.down()
t.setpos(x2, y2)
# main() function
def main():
print('Drawing the Koch Snowflake...')
t = turtle.Turtle()
t.hideturtle()
# draw
try:
drawKochSF(-100, 0, 100, 0, t)
drawKochSF(0, -173.2, -100, 0, t)
drawKochSF(100, 0, 0, -173.2, t)
except:
print("Exception, exiting.")
exit(0)
# wait for user to click on screen to exit
turtle.Screen().exitonclick()
# call main
if __name__ == '__main__':
main()
第二章:# Spirographs

你可以使用 Spirograph 玩具(如图 2-1 所示)来绘制数学曲线。该玩具由两个不同大小的齿轮环组成,一个大一个小。小的环有几个孔。你将笔或铅笔插入一个孔,然后旋转小齿轮环,使其在大齿轮环内旋转(大齿轮环的内侧有齿轮),保持两个环相互接触,从而绘制出无尽的复杂且对称的图案。
在这个项目中,你将使用 Python 创建类似 Spirograph 的曲线动画。该程序将使用参数方程来描述 Spirograph 环的运动,并绘制曲线(我称之为spiros)。你将把完成的绘图保存为 PNG 图像文件。程序会绘制随机的 spiros,或者你可以使用命令行选项来绘制具有特定参数的 spiro。

图 2-1:Spirograph 玩具
在这个项目中,你将学习如何在计算机上绘制 spiros。你还将学习如何做以下事情:
-
• 使用参数方程生成曲线。
-
• 使用
turtle模块将曲线绘制为一系列直线。 -
• 使用定时器来使图形动画化。
-
• 将图形保存为图像文件。
提醒一句:我选择使用 turtle 模块来绘制 spiros,主要是为了演示目的,并且因为它很有趣,但 turtle 运行较慢,在性能至关重要时并不理想。(你能指望海龟做什么呢?)如果你想快速绘制一些东西,有更好的方法可以做到这一点,你将在接下来的项目中探索其中的一些选项。
工作原理
这个项目的核心是使用 参数方程,即将曲线上的点的坐标表示为一个或多个变量(称为 参数)的函数。你将把参数的值代入方程,计算出形成 spiro 图案的点。然后,你将把这些点传递给 turtle 模块来绘制曲线。
理解参数方程
为了理解参数方程如何工作,我们从一个简单的例子开始:圆。考虑一个半径为 r 的圆,圆心位于二维*面的原点。这个圆包含所有满足方程 x² + y² = r² 的点。然而,这并不是一个参数方程。一个参数方程会根据某个变量(即参数)的变化,给出所有可能的 x 和 y 值。
现在,考虑以下方程:
x = r cos(θ)
y = r sin(θ)
这些方程是我们圆的 参数化 表示,其中参数是 θ,表示点 (x, y) 相对于正 x 轴的角度。任何 (x, y) 的值都将满足原始的 x² + y² = r² 方程。当你将 θ 从 0 变化到 2π 时,这些方程生成的 x 和 y 坐标将形成圆形。图 2-2 展示了这一方案。

图 2-2:用参数方程描述一个圆
请记住,这两个方程适用于以坐标系原点为中心的圆。你可以通过将圆心从 (0, 0) *移到 (a, b) 来将圆放置在 XY *面上的任何点。然后,较一般的参数方程变为 x = a + r cos(θ) 和 y = b + r sin(θ)。
开发参数方程来模拟涡轮图玩具与开发圆的参数方程并没有太大区别,因为从本质上讲,涡轮图只不过是画两个互锁的圆。图 2-3 展示了类似涡轮图运动的数学模型。该模型没有齿轮齿;齿轮齿仅用于涡轮图玩具中防止滑动,在理想的数学建模世界中,你不需要担心任何东西会滑动。

图 2-3:涡轮图玩具的数学模型
在图 2-3 中,C 是较小圆的中心,P 是笔尖,q 是 C 相对于正 x 轴的角度。较大圆的半径是 R,较小圆的半径是 r。你可以将半径的比值表示为变量 k,如下所示:

线段
告诉你笔尖距离较小圆中心的距离。你可以将
与较小圆的半径 r 的比值表示为变量 l,如以下所示:

你现在可以将这些变量结合成以下参数方程,表示较小圆在较大圆内旋转时 P 点(即笔尖)的 x 和 y 坐标:


注意 这些曲线被称为 内切轨迹。虽然方程式看起来有些吓人,但推导过程非常直接。如果你想深入了解数学部分,可以参考维基百科上的涡轮图页面:en.wikipedia.org/wiki/Spirograph。
图 2-4 展示了使用这些方程绘制的示例曲线。对于这条曲线,我将R设置为 220,r设置为 65,l设置为 0.8。通过选择这三个参数的不同值并增量地改变角度θ,你可以生成各种各样迷人的曲线。

图 2-4:一个示例曲线
唯一剩下的任务是确定何时停止绘制,因为 Spirographs 可能需要小圆围绕大圆旋转多次才能形成完整的图案。你可以通过观察内外圆的半径比来计算 Spirograph 的周期性(即 Spirograph 开始重复的时间间隔):

通过将分子和分母除以最大公约数(GCD),可以简化这个分数。然后,分子告诉你曲线需要多少周期才能完成自身。例如,在图 2-4 中,(r, R)的 GCD 是 5:

这告诉你,在小圆围绕大圆旋转 13 圈后,曲线将开始重复自身。分母中的 44 告诉你小圆围绕其自身中心旋转的次数,这也为你提供了曲线形状的提示。如果你数一下图 2-4 中的花瓣(或叶片),你会发现正好是 44 个!
一旦你将半径比表示为简化形式的r/R,绘制 Spirograph 的角度参数θ的范围是[0, 2πr]。这告诉你何时停止绘制特定的 Spirograph。在图 2-4 的例子中,你应该在θ达到 26π(即 2π × 13)时停止。如果没有知道角度的结束范围,你将会不停地循环,重复曲线。
使用 turtle 图形绘制曲线
Python 的turtle模块没有绘制曲线的功能。相反,你将通过之前讨论的参数方程计算出不同点之间的直线来绘制 Spirograph。只要从一个点到下一个点的角度θ变化相对较小,结果就会看起来是弯曲的。
为了演示,以下程序使用turtle绘制一个圆。它利用我们基本的圆的参数方程,x = a + r cos(θ) 和 y = b + r sin(θ),来计算圆上的点,并通过直线连接这些点。从技术上讲,该程序实际上生成的是一个N边的多边形,但由于角度参数会以小的增量变化,N会非常大,因此多边形看起来就像一个圆。输入以下代码,将其保存为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()
这里你定义了 drawCircleTurtle() 函数,其参数是要绘制的圆的中心(x,y)和圆的半径 r。该函数首先将海龟移动到圆的水*轴上的第一个点位置:(x + r,y)❶。调用 up() 和 down() 防止海龟在进入位置时进行绘制。接下来,你启动一个使用 range(0, 365, 5) 的循环,它将变量 i 从 0 增加到 360,每次增加 5❷。i 变量是你将传递给参数化圆方程的角度参数,但首先你需要将其从度数转换为弧度❸。(大多数计算机程序需要使用弧度进行角度计算。)
使用两个参数方程计算下一组圆坐标,并相应地设置海龟的位置❹。这会从海龟的上一个位置绘制一条直线到新计算的位置。由于你每次只改变角度参数 5 度,直线将呈现出圆形的外观。
现在你有了你的函数,你可以调用它来绘制一个圆❺。调用 turtle.mainloop() 会保持 tkinter 窗口打开,这样你就可以欣赏你的作品了。(tkinter 是 Python 默认的图形用户界面库。)
现在你准备好绘制一些螺旋图案了!你将使用之前展示过的相同 turtle 方法。唯一需要更改的是用于计算点的参数方程的细节。
要求
你将使用以下代码来创建你的螺旋图案:
-
•
turtle模块用于绘图 -
•
Pillow,一个 Python Imaging Library (**PIL) 的分支,用于保存螺旋图像
代码
首先你将定义一个 Spiro 类来绘制曲线。你可以使用这个类来绘制单个具有可定制参数的曲线,或者作为一个动画的一部分,绘制多个随机螺旋图案并行运行。为了协调动画,你将定义另一个名为 SpiroAnimator 的类。在程序的顶层,你将编写一个函数来将你的绘图保存为图像文件,并使用 main() 函数来获取用户输入并启动绘图。
要查看完整的项目代码,请跳到“完整代码”在第 36 页。你也可以从github.com/mkvenkit/pp2e/blob/main/spirograph/spiro.py下载该项目的代码。
绘制螺旋图案
Spiro 类提供了绘制单个螺旋图案的方法。以下是 Spiro 类的构造函数:
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 对象 ❶。这样,每个独立的 Spiro 对象都会有一个与之关联的 turtle 对象,这意味着你可以创建多个 Spiro 对象,同时绘制多个螺旋线。你将海龟光标的形状设置为海龟 ❷。(你可以在 docs.python.org/3/library/turtle.xhtml 的 turtle 文档中找到其他形状选项。)你将参数绘图的角度增量设置为 5 度 ❸,并创建一个布尔类型的 drawingComplete 标志,用于指示螺旋线是否绘制完成 ❹。这个标志在多个 Spiro 对象并行绘制时非常有用,它可以帮助你追踪某一条螺旋线是否完成。构造函数的最后,你调用了两个设置方法,接下来会讨论这些方法。
设置方法
Spiro 类的 setparams() 和 restart() 方法都用于在绘制螺旋图案之前进行必要的设置。我们首先来看一下 setparams() 方法:
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 = math.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
首先,你存储螺旋线中心的坐标(xc 和 yc)。然后你将每个圆的半径(R 和 r)转换为整数并存储这些值。你还存储了 l,它定义了画笔的位置,以及 col,它决定了螺旋线的颜色。接下来,你使用 Python 内置 math 模块中的 gcd() 方法来计算半径的最大公约数 ❶。你利用这些信息来确定曲线的周期性,并将其保存为 self.nRot ❷。最后,你将角度参数 a 的起始值设为 0 ❸。
restart() 方法通过重置 Spiro 对象的绘图参数并将其定位到绘制螺旋线的位置来继续设置工作。这个方法使得可以重复使用同一个 Spiro 对象来依次绘制多个螺旋线,作为程序动画的一部分。每当对象准备好绘制新的螺旋线时,程序会调用 restart() 方法。以下是该方法的代码:
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 标志重置为 False,表示该对象准备好绘制新的螺旋线。然后你显示海龟光标,以防它被隐藏。接下来抬起画笔 ❶,这样你可以在不绘制线条的情况下移动到第一个位置 ❹。在 ❷ 处,你只是使用一些局部变量来保持代码的简洁。然后你将这些变量传递给螺旋线的参数方程,计算曲线起点的 x 和 y 坐标,使用 0 作为角度 a 的初始值 ❸。最后,一旦海龟定位好,你就放下画笔,让海龟开始绘制螺旋线 ❺。
draw() 方法
如果你使用命令行选项来设置螺旋线的参数,程序只会绘制那一条螺旋线,使用 Spiro 类的 draw() 方法。该方法一次性绘制整个螺旋线,作为一连串连续的直线段:
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))
try:
❸ self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# drawing is now done so hide the turtle cursor
❹ self.t.hideturtle()
在这里,你遍历参数i的完整范围,该范围以度数表示为 360 乘以nRot ❶。你使用参数方程计算每个i参数值对应的 x 和 y 坐标 ❷,调用海龟的setpos()方法 ❸,从一个点画到下一个点。此方法被包含在try块中,这样如果出现异常—比如用户在绘制过程中关闭了窗口—你可以捕获异常并优雅地退出。最后,你隐藏光标,因为你已经完成绘制 ❹。
update() 方法
如果你没有使用任何命令行选项,程序将绘制多个随机的螺旋图形作为动画。这个方法需要对我们刚刚看到的绘图代码进行一些重构。你需要一种方法来绘制螺旋的单个线段,而不是一次性绘制整个螺旋图形。然后,你将在动画的每个时间步调用该方法。Spiro类的update()方法正好满足这个需求:
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))
try:
❹ self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# 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()
首先检查drawingComplete标志是否已设置 ❶;如果没有,继续执行其余代码。你增加当前角度 ❷,计算与当前角度对应的 (x, y) 位置 ❸,并将海龟移动到该位置,在此过程中绘制线段 ❹。这就像是draw()方法中的for()循环内部的代码,区别在于它只执行一次。
当我讨论 Spirograph 参数方程时,我提到过曲线的周期性。Spirograph 在某个角度之后会开始重复。你通过检查角度是否已达到为此特定曲线计算的完整范围来完成update()函数 ❺。如果是这样,你就设置drawingComplete标志,因为螺旋图形已经完成。最后,你隐藏海龟光标,这样你就可以看到你美丽的作品。
协调动画
SpiroAnimator类将允许你同时绘制多个随机的螺旋图形作为动画。该类协调多个Spiro对象的活动,这些对象具有随机分配的参数,并使用计时器定期调用每个Spiro对象的update()方法。这种技术定期更新图形,并让程序处理诸如按钮按下、鼠标点击等事件。
让我们首先看看SpiroAnimator类的构造函数:
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()
# restarting
❸ self.restarting = False
# 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,这是你将在定时器中使用的毫秒时间间隔❶。然后你存储海龟窗口的尺寸❷,并初始化一个标志,用于指示重启正在进行中❸。在一个重复N次的循环中(N是作为构造函数参数传递给SpiroAnimator的),你创建新的Spiro对象❺并将它们添加到spiros列表中。在创建每个Spiro对象之前,你调用genRandomParams()辅助方法❹来随机分配螺旋的参数(我们接下来会看这个方法)。这里的rparams是一个元组,你需要将它传递给Spiro构造函数。然而,构造函数期望多个参数,因此你使用 Python 的*运算符将元组解包成一系列参数。最后,你设置turtle.ontimer()方法,在deltaT毫秒后调用update()❻,从而启动动画。
生成随机参数
你将使用genRandomParams()方法生成随机参数,并在每个Spiro对象创建时将这些参数发送给它,以生成各种各样的曲线。每次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模块中的三个方法:randint(),它返回指定范围内的随机整数;uniform(),它对浮点数执行相同操作;以及random(),它返回一个介于 0 和 1 之间的浮点数。你将R设置为 50 到窗口最小维度一半之间的随机整数,并将r设置为R的 10%到 90%之间。然后你将l设置为 0.1 到 0.9 之间的随机分数。
接下来,你选择屏幕上的一个随机点来确定螺旋的中心,通过在屏幕边界内随机选择 x 和 y 坐标(xc和yc)。你通过设置随机的红色、绿色和蓝色色彩分量(这些值在 0 到 1 的范围内)为曲线分配一个随机颜色col。最后,所有计算出的参数将作为一个元组❶返回。
重启动画
SpiroAnimator类有它自己的restart()方法,用于重启动画以绘制一组新的螺旋:
def restart(self):
# ignore restart if already in the middle of restarting
❶ if self.restarting:
return
else:
self.restarting = True
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()
# done restarting
❷ self.restarting = False
这个方法遍历所有的Spiro对象。对于每个对象,你清除之前的绘图并随机生成一组新的螺旋参数。然后你使用Spiro对象的设置方法setparams()和restart(),为它分配新的参数,并让对象准备好绘制下一个螺旋。self.restarting标志❶防止这个方法在完成之前被调用,如果用户反复按空格键,就可能发生这种情况。该标志会在方法结束时重置,这样下次重启就不会被忽略❷。
更新动画
以下代码展示了 SpiroAnimator 中的 update() 方法,该方法每 10 毫秒由定时器调用一次,用于更新动画中使用的所有 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
try:
❺ turtle.ontimer(self.update, self.deltaT)
except:
print("Exception, exiting.")
exit(0)
update() 方法使用计数器 nComplete 来追踪已完成绘制的 Spiro 对象的数量 ❶。该方法遍历 Spiro 对象列表并更新它们 ❷,每次更新都会在每个 spiro 中绘制一个新的线段。如果某个 Spiro 已完成绘制,你会增加计数器 ❸。
在循环外,你检查计数器,以确定所有对象是否已经完成绘制 ❹。如果完成,你通过调用 restart() 方法重新开始动画,生成新的 spiro。update() 方法最后会调用 turtle 模块的 ontimer() 方法 ❺,在 deltaT 毫秒后再次调用 update()。这就是保持动画持续进行的方式。
显示或隐藏游标
你使用 SpiroAnimator 类的以下方法来切换 turtle 游标的显示与隐藏。关闭游标可以加快绘图速度。
def toggleTurtles(self):
for spiro in self.spiros:
if spiro.t.isvisible():
spiro.t.hideturtle()
else:
spiro.t.showturtle()
该方法使用内置的 turtle 方法来隐藏游标(如果它可见)或显示游标(如果它不可见)。稍后你将看到,当动画运行时,这个 toggleTurtles() 方法如何通过按键触发。
保存曲线
在你辛苦生成 spiro 之后,有一种方法可以保存结果会很方便。独立的 saveDrawing() 函数将绘图窗口的内容保存为 PNG 图片文件:
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 {}.eps/png'.format(fileName))
# get the tkinter canvas
canvas = turtle.getcanvas()
# save the drawing as a postscript 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()
你隐藏 turtle 游标,这样它们就不会出现在最终的绘图中 ❶。然后,你使用 datetime() 生成基于时间戳的唯一图像文件名(采用 日-月-年-小时-分钟-秒 格式) ❷。你将这个字符串附加到 spiro- 后生成文件名。
turtle 程序使用由 tkinter 创建的用户界面(UI)窗口,你使用 tkinter 的 canvas 对象将窗口保存为嵌入式 PostScript(EPS)文件格式 ❸。由于 EPS 是基于矢量的,你可以使用它以高分辨率打印图像,但 PNG 格式更为通用,因此你使用 Pillow 打开 EPS 文件 ❹ 并将其保存为 PNG 文件 ❺。最后,你会重新显示 turtle 游标。
解析命令行参数和初始化
本书中的大多数项目都有命令行参数,用于定制代码。与其试图手动解析它们并制造混乱,不如将这项繁琐的任务委托给 Python 的 argparse 模块。这就是你在 spiro 程序的 main() 函数的第一部分中所做的:
def main():
❶ 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()
你创建了一个ArgumentParser对象来管理命令行参数 ❶。然后,你将--sparams参数添加到解析器中 ❷。它包含三个组件,分别是涡旋的R、r和l参数。你使用dest选项指定参数解析后存储值的变量名,而required=False表示该参数是可选的。你调用parse_args()方法 ❸来实际解析参数。这将使得参数作为args对象的属性可以使用。在这个例子中,--sparams参数的值将通过args.sparams来访问。
注意:你将在本书中按照这里描述的基本模式,创建并解析每个项目的命令行参数。
main()函数继续设置一些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()方法使绘图窗口监听用户事件(例如按键输入) ❸。最后,你隐藏了海龟光标 ❹。
main()函数的其余部分如下:
# 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)]将创建一个包含前四个偶数的列表。)接着,你使用这些参数构造一个Spiro对象 ❸(通过 Python 的*运算符,它将列表解包成一系列参数),并调用draw()方法绘制涡旋图 ❹。
如果在命令行没有指定任何参数,则进入随机动画模式。为此,你需要创建一个SpiroAnimator对象 ❺,并传递参数4,该参数告诉它同时绘制四个涡旋图。接着,你使用两个onkey调用来捕捉额外的按键输入。按下 T 键将通过toggleTurtles()方法显示或隐藏海龟光标,而按下空格键(space)则会调用restart()方法,在任何时候中断动画并开始绘制四个不同的随机涡旋图。最后,你调用mainloop()方法,告诉tkinter窗口保持打开,监听事件 ❻。
运行涡旋动画
现在是时候运行你的程序了:
$ `python spiro.py`
默认情况下,spiro.py 程序同时绘制四个随机的 spiro,如图 2-5 所示。按 S 键保存绘图,按 T 键切换光标,按空格键重新开始动画。

图 2-5:spiro.py 的示例运行
现在再次运行程序,这次在命令行传递参数来绘制一个特定的 spiro:
$ `python spiro.py --sparams 300 100 0.9`
图 2-6 显示了输出。如你所见,这段代码根据用户指定的参数绘制了一个单一的 spiro,与图 2-5 的多个随机 spiro 动画展示不同。

图 2-6:带有特定参数的 spiro.py 示例运行
玩得开心,尝试不同的参数,看看它们如何影响结果曲线。
总结
在这个项目中,你学习了如何创建类似于旋转图形的曲线。你还学习了如何调整输入参数,以生成各种不同的曲线,并在屏幕上动画展示它们。希望你喜欢创建这些 spiro。(你会在第十三章发现一个惊喜,在那里你将学习如何将 spiro 投影到墙上!)
实验!
这里有一些进一步尝试 spiro 的方法:
-
1. 现在你知道如何绘制圆形了,写一个程序来绘制随机的 螺旋线。找到 对数螺旋 的参数方程,然后用它来绘制螺旋线。
-
2. 你可能注意到,乌龟光标在绘制曲线时总是朝右,但这并不是乌龟的移动方式!将乌龟的方向调整为,在绘制曲线时,它朝着绘制的方向。 (提示:计算每一步连续点之间的方向向量,并使用
turtle.setheading()方法重新调整乌龟的方向。)
完整代码
这是完整的 Spirograph 程序:
"""
spiro.py
A Python program that simulates a Spirograph.
Author: Mahesh Venkitachalam
"""
import random, argparse
import numpy as np
import math
import turtle
import random
from PIL import Image
from datetime import datetime
# a class that draws a spiro
class Spiro:
# constructor
def __init__(self, xc, yc, col, R, r, l):
# create own turtle
self.t = turtle.Turtle()
# set cursor shape
self.t.shape('turtle')
# set step in degrees
self.step = 5
# set drawing complete flag
self.drawingComplete = False
# set parameters
self.setparams(xc, yc, col, R, r, l)
# initialize drawing
self.restart()
# set parameters
def setparams(self, xc, yc, col, R, r, l):
# 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 smallest form by dividing with GCD
gcdVal = math.gcd(self.r, self.R)
self.nRot = self.r//gcdVal
# get ratio of radii
self.k = r/float(R)
# set color
self.t.color(*col)
# current angle
self.a = 0
# restart drawing
def restart(self):
# set flag
self.drawingComplete = False
# show turtle
self.t.showturtle()
# go to 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))
try:
self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
self.t.down()
# draw the whole thing
def draw(self):
# draw rest of 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))
try:
self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# done - hide turtle
self.t.hideturtle()
# update by one step
def update(self):
# skip if done
if self.drawingComplete:
return
# increment angle
self.a += self.step
# draw step
R, k, l = self.R, self.k, self.l
# set 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))
try:
self.t.setpos(self.xc + x, self.yc + y)
except:
print("Exception, exiting.")
exit(0)
# check if drawing is complete and set flag
if self.a >= 360*self.nRot:
self.drawingComplete = True
# done - hide turtle
self.t.hideturtle()
# clear everything
def clear(self):
# pen up
self.t.up()
# clear turtle
self.t.clear()
# a class for animating spiros
class SpiroAnimator:
# constructor
def __init__(self, N):
# timer value in milliseconds
self.deltaT = 10
# get window dimensions
self.width = turtle.window_width()
self.height = turtle.window_height()
# restarting
self.restarting = False
# create spiro objects
self.spiros = []
for i in range(N):
# generate random parameters
rparams = self.genRandomParams()
# set spiro params
spiro = Spiro(*rparams)
self.spiros.append(spiro)
# call timer
turtle.ontimer(self.update, self.deltaT)
# restart spiro drawing
def restart(self):
# ignore restart if already in the middle of restarting
if self.restarting:
return
else:
self.restarting = True
# restart
for spiro in self.spiros:
# clear
spiro.clear()
# generate random parameters
rparams = self.genRandomParams()
# set spiro params
spiro.setparams(*rparams)
# restart drawing
spiro.restart()
# done restarting
self.restarting = False
# 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 ones
if spiro.drawingComplete:
nComplete+= 1
# if all spiros are complete, restart
if nComplete == len(self.spiros):
self.restart()
# call timer
try:
turtle.ontimer(self.update, self.deltaT)
except:
print("Exception, exiting.")
exit(0)
# toggle turtle on/off
def toggleTurtles(self):
for spiro in self.spiros:
if spiro.t.isvisible():
spiro.t.hideturtle()
else:
spiro.t.showturtle()
# save spiros to image
def saveDrawing():
# hide turtle
turtle.hideturtle()
# generate unique filename
dateStr = (datetime.now()).strftime("%d%b%Y-%H%M%S")
fileName = 'spiro-' + dateStr
print('saving drawing to {}.eps/png'.format(fileName))
# get tkinter canvas
canvas = turtle.getcanvas()
# save postscript image
canvas.postscript(file = fileName + '.eps')
# use PIL to convert to PNG
img = Image.open(fileName + '.eps')
img.save(fileName + '.png', 'png')
# show turtle
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 to 80% screen width
turtle.setup(width=0.8)
# set cursor shape
turtle.shape('turtle')
# set title
turtle.title("Spirographs!")
# add key handler for saving images
turtle.onkey(saveDrawing, "s")
# start listening
turtle.listen()
# hide main turtle cursor
turtle.hideturtle()
# check args and draw
if args.sparams:
params = [float(x) for x in args.sparams]
# draw spirograph with given parameters
# black by default
col = (0.0, 0.0, 0.0)
spiro = Spiro(0, 0, col, *params)
spiro.draw()
else:
# create animator object
spiroAnim = SpiroAnimator(4)
# add key handler to toggle turtle cursor
turtle.onkey(spiroAnim.toggleTurtles, "t")
# add key handler to restart animation
turtle.onkey(spiroAnim.restart, "space")
# start turtle main loop
turtle.mainloop()
# call main
if __name__ == '__main__':
main()
第二部分:# 模拟生命
首先,我们假设这头牛是一个球体 . . .
—匿名物理笑话
第三章:# 康威生命游戏

你可以通过创建该系统的数学模型,编写程序来表示模型,然后让模型随时间演化来用计算机研究一个系统。计算机仿真有很多种,但我将专注于一个著名的仿真——康威的生命游戏,这是英国数学家约翰·康威的工作。生命游戏是一个细胞自动机的例子,细胞自动机是网格上由一组彩色细胞组成的集合,通过一系列时间步长根据规则定义相邻细胞的状态而演化。
在这个项目中,你将创建一个 N×N 的细胞网格,并通过应用康威生命游戏的规则来模拟系统随时间的演变。你将显示每个时间步长的游戏状态,并提供将输出保存到文件的方法。你将把系统的初始状态设置为随机分布或预先设计的模式。
这个仿真由以下几个组件组成:
-
• 在一维或二维空间中定义的属性
-
• 一个数学规则,用于在仿真中的每一步改变这一属性
-
• 显示或捕捉系统随时间演变状态的方法
康威生命游戏中的细胞可以是开(ON)或关(OFF)。游戏从一个初始状态开始,在这个状态下,每个细胞都被分配到这两种状态之一。然后,数学规则决定每个细胞状态如何随时间变化。康威生命游戏的神奇之处在于,仅凭四个简单的规则,系统就能演变出极其复杂的模式,几乎仿佛它们是活的。这些模式包括“滑翔者”在网格中滑动,“闪烁器”开关闪烁,甚至是自复制的模式。
当然,这个游戏的哲学意义也非常重大,因为它表明复杂的结构可以从简单的规则中演化出来,而不必遵循任何预设模式。
下面是本项目中涵盖的一些主要概念:
-
• 使用
matplotlib imshow来表示二维数据网格 -
• 使用
matplotlib进行动画处理 -
• 使用
numpy数组 -
• 使用
%运算符处理边界条件 -
• 设置随机值分布
它是如何工作的
因为生命游戏是基于九个方格的网格构建的,所以每个细胞都有八个相邻的细胞,如图 3-1 所示。仿真中给定的细胞 (i, j) 在网格 [i][j] 上访问,其中 i 和 j 分别是行和列的索引。给定时刻某个细胞的值依赖于前一个时间步长中其相邻细胞的状态。

图 3-1:一个中心细胞 (i, j) 和其八个相邻的细胞
康威的生命游戏有四个规则:
-
1. 如果一个单元格为开启状态且有少于两个相邻单元格为开启状态,则该单元格会变为关闭状态。
-
2. 如果一个单元格为开启状态且有两个或三个相邻单元格为开启状态,则该单元格保持开启状态。
-
3. 如果一个单元格为开启状态且有超过三个相邻单元格为开启状态,则该单元格会变为关闭状态。
-
4. 如果一个单元格为关闭状态且有恰好三个相邻单元格为开启状态,则该单元格会变为开启状态。
这些规则旨在模拟群体生物随时间变化的基本方式:人口过少和过多会通过在单元格相邻单元格少于两个或多于三个时将单元格关闭来杀死细胞;但当人口*衡时,细胞保持开启状态并通过将另一个单元格从关闭状态变为开启状态来繁殖。
我提到过每个单元格有八个邻居单元格,但对于网格边缘的单元格呢?它们的邻居是谁?为了解答这个问题,你需要考虑边界条件,即支配网格边缘或边界上单元格行为的规则。我将通过使用环形边界条件来回答这个问题,这意味着方形网格会像圆环一样环绕。正如图 3-2 所示,网格首先被弯曲,使其水*边缘(A 和 B)连接形成一个圆柱,然后圆柱的垂直边缘(C 和 D)连接形成一个环面。一旦环面形成,所有单元格都有邻居,因为整个空间没有边界。

图 3-2:环形边界条件的概念可视化
环形边界条件在二维模拟和游戏中很常见。例如,游戏吃豆人就使用了这种边界条件。如果你从屏幕顶部走出,你会从底部重新出现。如果你从屏幕的左侧走出,你会从右侧重新出现。对于“生命游戏”的模拟,你也会遵循相同的逻辑:例如,对于网格的左上角单元格,其上方的邻居将是左下角的单元格,其左侧的邻居将是右上角的单元格。
这是你将用于应用这四个规则并运行模拟的算法描述:
-
1. 初始化网格中的单元格。
-
2. 在每次模拟的时间步长中,对于网格中的每个单元格(i, j),执行以下操作:
-
a. 根据邻居的状态更新单元格(i, j)的值,同时考虑边界条件。
-
b. 更新网格值的显示。
-
需求
你将使用numpy数组和matplotlib库来显示模拟输出,并使用matplotlib的animation模块来更新模拟。
代码
我们将逐步分析程序的关键部分,包括如何使用numpy和matplotlib表示仿真网格,如何设置仿真的初始条件,如何处理环形边界条件,以及如何实现《生命游戏》规则。我们还将看看程序的main()函数,该函数将命令行参数传递给程序并启动仿真。要查看完整的项目代码,请跳转到“完整代码”部分,位于第 56 页。你也可以从github.com/mkvenkit/pp2e/tree/main/conway下载代码。
表示网格
为了表示网格上一个单元格是生存(ON)还是死亡(OFF),你将分别使用255和0来表示 ON 和 OFF。你将使用matplotlib中的imshow()方法显示网格的当前状态,该方法将一个数字矩阵表示为图像。为了了解它是如何工作的,我们在 Python 解释器中尝试一个简单的例子。输入以下内容:
>>> `import numpy as np`
>>> `import matplotlib.pyplot as plt`
❶ >>> `x = np.array([[0, 0, 255], [255, 255, 0], [0, 255, 0]])`
❷ >>> `plt.imshow(x, interpolation='nearest')`
>>> `plt.show()`
你定义了一个形状为(3, 3)的二维numpy数组❶,这意味着该数组有三行三列。数组的每个元素是一个整数值。然后,你使用plt.imshow()方法将这个数值矩阵显示为图像❷,并传递插值选项'nearest'以获得单元格的锐利边缘(否则它们会模糊)。图 3-3 显示了此代码的输出。

图 3-3:显示值的网格
注意,值为0(OFF)的方格会显示较暗的颜色,而值为255(ON)的方格会显示较亮的颜色。
设置初始条件
要开始仿真,首先为二维网格中的每个单元格设置初始状态。你可以使用随机分布的 ON 和 OFF 单元格,观察出现的各种模式,或者你可以添加一些特定的模式,看看它们如何演化。我们将讨论这两种方法。
要设置随机的初始状态,可以使用numpy中的random模块的choice()方法。在 Python 解释器中输入以下内容以查看其工作原理:
>>> `np.random.choice([0, 255], 4*4, p=[0.1, 0.9]).reshape(4, 4)`
输出将类似于这样:
array([[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 255],
[255, 255, 255, 0]])
np.random.choice()从给定的列表[0, 255]中选择一个值,选择每个值的概率由参数p=[0.1, 0.9]给定。在这里,你要求 0 出现的概率为 0.1(或 10%),要求 255 出现的概率为 90%。(p中的两个值之和必须为 1。) choice()方法创建一个一维数组,在此例中包含 16 个值(由4*4指定)。你使用reshape()将其转换为一个具有四行四列的二维数组。
要设置初始条件以匹配特定的模式,而不是仅仅填充一组随机值,首先使用 np.zeros() 初始化网格,将所有单元格设置为零:
grid = np.zeros(N*N).reshape(N, N)
这创建了一个 N×N 的零值数组。现在,定义一个函数,在网格的特定行和列添加一个模式:
def addGlider(i, j, grid):
"""adds a glider with top left cell at (i, j)"""
❶ glider = np.array([[0, 0, 255],
[255, 0, 255],
[0, 255, 255]])
❷ grid[i:i+3, j:j+3] = glider
你定义了一个滑翔机模式(一个在网格中稳定移动的观察模式),使用形状为 (3, 3) 的 numpy 数组 ❶。然后你使用 numpy 切片操作,将 glider 数组复制到模拟的二维网格中 ❷,并将模式的左上角放置在你指定的坐标(i 和 j)位置。
现在,你可以通过调用你刚才定义的 addGlider() 函数,将滑翔机模式添加到零值网格中:
addGlider(1, 1, grid)
你指定坐标(1, 1)将滑翔机添加到网格的左上角附*(即坐标为 (0, 0))。注意,对于 grid[i, j],i 从 0 开始并向下运行,而 j 从 0 开始并向右运行。
强制边界条件
现在我们可以考虑如何实现环形边界条件。首先,让我们看看在一个 N×N 大小的网格的右边缘发生了什么。第 i 行的最后一个单元格可以通过 grid[i, N-1] 访问。它右侧的邻居是 grid[i, N],但根据环形边界条件,访问 grid[i, N] 时应该用 grid[i, 0] 替代。下面是一种实现方式:
if j == N-1:
right = grid[i, 0]
else:
right = grid[i, j+1]
当然,你还需要在网格的左侧、上侧和下侧应用类似的边界条件,但这样做需要添加更多的代码,因为网格的四个边都需要进行测试。一个更简洁的实现边界条件的方式是使用 Python 的取模(%)操作符,这里在 Python 解释器中演示了这一点:
>>> `N = 16`
>>> `i1 = 14`
>>> `i2 = 15`
>>> `(i1+1)%N`
15
>>> `(i2+1)%N`
0
如你所见,% 操作符给出了整数除法的余数,除数为 N。在这个例子中,15%16 结果是 15,而 16%16 结果是 0。你可以通过重写网格访问代码来使用 % 操作符,让值在右边界处回绕,像这样:
right = grid[i, (j+1)%N]
现在,当一个单元格位于网格的边缘时(也就是说,当 j = N-1 时),使用这种方法请求右侧的单元格将得到 (j+1)%N,这会将 j 重置为 0,使网格的右侧回绕到左侧。当你对网格的底部做同样的处理时,它会回绕到顶部:
bottom = grid[(i+1)%N, j]
实现规则
生命游戏的规则基于邻居单元格的开(ON)或关(OFF)状态。为了简化这些规则的应用,你只需要计算邻居单元格中开(ON)状态的数量。因为开(ON)对应的值为 255,只需将所有邻居单元格的值相加,然后除以 255,就能得到开(ON)单元格的数量。以下是相关代码:
total = int((grid[i, (j-1)%N] + grid[i, (j+1)%N] +
grid[(i-1)%N, j] + grid[(i+1)%N, j] +
grid[(i-1)%N, (j-1)%N] + grid[(i-1)%N, (j+1)%N] +
grid[(i+1)%N, (j-1)%N] + grid[(i+1)%N, (j+1)%N])/255)
对于给定的单元格(i, j),你通过使用%运算符来考虑环形边界条件,求出其八个相邻单元格的值之和。将结果除以 255 即可得到开启状态的相邻单元格数量,这个值存储在变量total中。现在你可以使用total来应用生命游戏的规则:
# apply Conway's rules
if grid[i, j] == ON:
❶ if (total < 2) or (total > 3):
newGrid[i, j] = OFF
else:
❷ if total == 3:
newGrid[i, j] = ON
任何处于开启状态的单元格,如果其相邻单元格中开启的数量少于两个,或者多于三个,则会被关闭❶。else分支中的代码仅应用于关闭状态的单元格:如果恰好有三个相邻单元格处于开启状态,则该单元格会被开启❷。这些变化会被应用到newGrid中的对应单元格,newGrid最初是grid的副本。一旦每个单元格都被评估和更新,newGrid就包含了展示仿真下一时间步的数据。你不能直接修改grid,否则在评估单元格时它们的状态会不断变化。
向程序传递命令行参数
现在你可以开始编写仿真程序的main()函数,它首先将命令行参数传递给程序:
def main():
# command line arguments are in sys.argv[1], sys.argv[2], ...
# sys.argv[0] is the script name and can be ignored
# parse arguments
❶ parser = argparse.ArgumentParser(description="Runs Conway's Game of Life
simulation.")
# add arguments
❷ parser.add_argument('--grid-size', dest='N', required=False)
❸ parser.add_argument('--interval', dest='interval', required=False)
❹ parser.add_argument('--glider', action='store_true', required=False)
args = parser.parse_args()
你创建了一个argparse.ArgumentParser对象来为代码添加命令行选项❶,然后在接下来的行中为它添加了各种选项。❷处的选项指定了仿真网格大小N,❸处的选项设置了动画更新间隔(毫秒)。你还创建了一个选项,用于以滑行器模式启动仿真❹。如果没有设置此选项,仿真将以随机的开启和关闭状态开始。
初始化仿真
继续编写main()函数,你会看到以下部分,它初始化了仿真:
# set grid size
❶ N = 100
# set animation update interval
❷ updateInterval = 50
if args.interval:
updateInterval = int(args.interval)
# declare grid
grid = np.array([])
# check if "glider" demo flag is specified
❸ if args.glider:
grid = np.zeros(N*N).reshape(N, N)
addGlider(1, 1, grid)
❹ else:
# set N if specified and valid
if args.N and int(args.N) > 8:
N = int(args.N)
# populate grid with random on/off - more off than on
grid = randomGrid(N)
这部分代码应用在命令行传入的任何参数,一旦命令行选项被解析。首先,设置默认的网格大小为 100×100 单元格❶和 50 毫秒的更新时间间隔❷,以防这些选项没有在命令行中设置。然后,你设置初始条件,默认情况下是一个随机模式❹,或者是一个滑行器模式❸。
最后,你设置动画:
# set up the animation
❶ fig, ax = plt.subplots()
img = ax.imshow(grid, interpolation='nearest')
❷ ani = animation.FuncAnimation(fig, update, fargs=(img, grid, N, ),
interval=updateInterval,
save_count=50)
plt.show()
仍然在main()函数中,你配置matplotlib绘图和动画参数❶。然后,你设置animation.FuncAnimation(),定期调用程序前面定义的update()函数,该函数根据康威的“生命游戏”规则,使用环形边界条件更新网格❷。
运行生命游戏仿真
现在运行代码:
$ `python conway.py`
这使用了仿真的默认参数:一个 100×100 的单元格网格和 50 毫秒的更新时间间隔。当你观看仿真时,你将看到它如何发展,随着时间的推移创造并维持各种模式,如图 3-4 (a)和(b)所示。

(a)

(b)
图 3-4:生命游戏的进行状态
图 3-5 展示了模拟中需要注意的几种模式。除了滑翔机外,还可以观察到三格闪烁器和一些静态模式,如块状或面包形状。

图 3-5:生命游戏中的一些模式
现在稍微改变一下,通过以下参数运行模拟:
$ `python conway.py --grid-size 32 --interval 500 --glider`
这会创建一个 32×32 的模拟网格,每 500 毫秒更新一次动画,并使用右下角图 3-5 中显示的初始滑翔机模式。
总结
在这个项目中,你探索了康威的生命游戏。你学习了如何基于一些规则设置基本的计算机模拟,并且如何使用matplotlib来可视化系统在演变过程中的状态。
我对康威生命游戏的实现强调简洁性而非性能。你可以通过多种方式加速生命游戏中的计算,关于如何做到这一点已经有大量的研究。你可以通过快速的互联网搜索找到许多相关的研究。
实验!
下面是一些进一步实验康威生命游戏的方法:
-
1. 编写一个
addGosperGun()方法,将图 3-6 中显示的模式添加到网格中。这个模式被称为Gosper 滑翔机枪。运行模拟并观察枪的行为。![]()
图 3-6:Gosper 滑翔机枪
-
2. 编写一个
readPattern()方法,从文本文件中读取初始模式,并用它设置模拟的初始条件。你可以使用 Python 方法,如open和file.read来实现这一点。以下是输入文件的建议格式:8 0 0 0 255...文件的第一行定义了N,其余部分是N×N的整数(0 或 255),并用空格分隔。这个探索将帮助你研究任何给定模式在生命游戏规则下是如何演变的。添加一个
--pattern-file命令行选项,在运行程序时使用这个文件。
完整代码
下面是生命游戏项目的完整代码:
"""
conway.py
A simple Python/matplotlib implementation of Conway's Game of Life.
Author: Mahesh Venkitachalam
"""
import sys, argparse
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
def randomGrid(N):
"""returns a grid of NxN random values"""
return np.random.choice([255, 0], N*N, p=[0.2, 0.8]).reshape(N, N)
def addGlider(i, j, grid):
"""adds a glider with top left cell at (i, j)"""
glider = np.array([[0, 0, 255],
[255, 0, 255],
[0, 255, 255]])
grid[i:i+3, j:j+3] = glider
def update(frameNum, img, grid, N):
# copy grid since we require 8 neighbors for calculation
# and we go line by line
newGrid = grid.copy()
for i in range(N):
for j in range(N):
# compute 8-neighbor sum
# using toroidal boundary conditions - x and y wrap around
# so that the simulation takes place on a toroidal surface
total = int((grid[i, (j-1)%N] + grid[i, (j+1)%N] +
grid[(i-1)%N, j] + grid[(i+1)%N, j] +
grid[(i-1)%N, (j-1)%N] + grid[(i-1)%N, (j+1)%N] +
grid[(i+1)%N, (j-1)%N] + grid[(i+1)%N, (j+1)%N])/255)
# apply Conway's rules
if grid[i, j] == 255:
if (total < 2) or (total > 3):
newGrid[i, j] = 0
else:
if total == 3:
newGrid[i, j] = 255
# update data
img.set_data(newGrid)
grid[:] = newGrid[:]
# need to return a tuple here, since this callback
# function needs to return an iterable
return img,
# main() function
def main():
# command line args are in sys.argv[1], sys.argv[2]...
# sys.argv[0] is the script name itself and can be ignored
# parse arguments
parser = argparse.ArgumentParser(description="Runs Conway's Game of Life
simulation.")
# add arguments
parser.add_argument('--grid-size', dest='N', required=False)
parser.add_argument('--interval', dest='interval', required=False)
parser.add_argument('--glider', action='store_true', required=False)
parser.add_argument('--gosper', action='store_true', required=False)
args = parser.parse_args()
# set grid size
N = 100
# set animation update interval
updateInterval = 50
if args.interval:
updateInterval = int(args.interval)
# declare grid
grid = np.array([])
# check if "glider" demo flag is specified
if args.glider:
grid = np.zeros(N*N).reshape(N, N)
addGlider(1, 1, grid)
elif args.gosper:
grid = np.zeros(N*N).reshape(N, N)
addGosperGliderGun(10, 10, grid)
else:
# set N if specified and valid
if args.N and int(args.N) > 8:
N = int(args.N)
# populate grid with random on/off - more off than on
grid = randomGrid(N)
# set up animation
fig, ax = plt.subplots()
img = ax.imshow(grid, interpolation='nearest')
ani = animation.FuncAnimation(fig, update, fargs=(img, grid, N, ),
frames = 10,
interval=updateInterval)
plt.show()
# call main
if __name__ == '__main__':
main()
第四章:# 使用 Karplus-Strong 的音乐泛音

任何音乐声音的主要特征之一就是其音高,或称频率。这是声音每秒钟的振动次数,单位为赫兹(Hz)。例如,原声吉他的第四根弦发出 D 音符,其频率为 146.83 Hz。你可以通过在计算机上生成频率为 146.83 Hz 的正弦波来大致模拟这种声音,如图 4-1 所示。
不幸的是,如果你在计算机上播放这个正弦波,它听起来不会像吉他。它也不会像钢琴,或者任何其他现实中的乐器那样发声。那么,为什么计算机播放相同音符时,声音和乐器如此不同呢?

图 4-1:频率为 146.83 Hz 的正弦波
当你拨动吉他的弦时,乐器会发出多种频率的声音,且其强度不同。音调在刚击打时最为强烈,随时间衰减。以拨动吉他的 D 弦为例,你听到的主要频率,称为基频,是 146.83 Hz,但声音中还包含了该频率的某些倍频,称为泛音。事实上,任何乐器上的音符都由基频和泛音组成,正是这些不同频率的组合和不同强度的声音,使得吉他听起来像吉他,钢琴听起来像钢琴,等等。相比之下,由计算机生成的纯正弦波只包含基频,而没有泛音。
你可以通过频谱图看到泛音的证据,就像图 4-2 中表示吉他 D 弦的频谱图一样。频谱图显示了某一时刻声音中所有频率的存在情况以及这些频率的强度。请注意,图中的频谱图有许多不同的峰值,这告诉我们吉他 D 弦拨动的声音中存在多种频率。在图的最左侧,最高的峰值代表基频。其他峰值代表泛音,它们的强度较低,但仍然对声音的品质有所贡献。

图 4-2:吉他上弹奏 D3 音符的频谱图
如你所见,要在计算机上模拟拔弦乐器的声音,你需要能够生成基频和泛音。诀窍是使用 Karplus-Strong 算法。在这个项目中,你将使用 Karplus-Strong 算法生成五个类似吉他的音符,构成一个音阶(一系列相关的音符)。你将可视化生成这些音符所使用的算法,并将声音保存为 WAV 文件。你还将创建一个随机播放它们的方法,并学习如何完成以下操作:
-
• 使用 Python 的
deque类实现环形缓冲区。 -
• 使用
numpy数组。 -
• 使用
pyaudio播放 WAV 文件。 -
• 使用
matplotlib绘制图表。 -
• 播放五声音阶。
它是如何工作的
想象一根两端固定的弦,就像吉他上的弦。当你拨动这根弦时,它会震动一段时间,发出声音,然后回到静止位置。在弦震动的任何时刻,弦的不同部分将会处于与静止位置不同的位移状态。这些位移也可以看作是弦振动所产生的声波的振幅。Karplus-Strong 算法是一系列步骤,用于生成和更新这些位移或振幅值,以表示拨动弦上波动的运动。将这些值作为 WAV 文件回放,你会得到一个相当逼真的拔弦声音模拟。
Karplus-Strong 算法将位移值存储在一个 环形缓冲区(也叫 循环缓冲区)中,这是一个固定长度的缓冲区(就是一个值的数组),它会自我环绕。换句话说,当你到达缓冲区的末尾时,下一个访问的元素将是缓冲区的第一个元素。(有关环形缓冲区的更多信息,请参见 “使用 deque 实现环形缓冲区”,见 第 66 页)
环形缓冲区的长度(N)与您想要模拟的音符的基频有关,具体公式为 N = S/f,其中 S 是采样率(稍后会详细讲解),f 是频率。在模拟开始时,缓冲区被填充了范围为 [−0.5, 0.5] 的随机值,你可以将这些值理解为弦在刚被拨动时的随机位移。随着模拟的进行,这些值会根据 Karplus-Strong 算法的步骤进行更新,接下来我们将概述这些步骤。
除了环形缓冲区,你还将使用一个 样本缓冲区 来存储某一时刻的声音强度。这个缓冲区代表最终的声音数据,它是根据环形缓冲区中的值构建的。样本缓冲区的长度和采样率决定了声音片段的长度。
模拟过程
在每个时间步长的模拟过程中,环形缓冲区中的一个值被存储到样本缓冲区中,然后环形缓冲区中的值会在一种反馈机制下进行更新,如图 4-3 所示。一旦样本缓冲区满了,你就可以将其内容写入 WAV 文件,以便模拟的音符可以作为音频回放。对于每一个时间步长的模拟,你按照这些步骤操作,这些步骤一起构成了 Karplus-Strong 算法:
-
1. 将环形缓冲区中的第一个值存储到样本缓冲区中。
-
2. 计算环形缓冲区前两个元素的*均值。
-
3. 将这个*均值乘以衰减因子(在这个例子中是 0.995)。
-
4. 将该值附加到环形缓冲区的末尾。
-
5. 移除环形缓冲区的第一个元素。

图 4-3:环形缓冲区和 Karplus-Strong 算法
这个反馈机制旨在模拟波动通过振动弦的传播。环形缓冲区中的数字表示波在弦上每个点的能量。根据物理学,振动弦的基频与其长度成反比。由于我们感兴趣的是生成特定频率的声音,我们选择一个与所需频率成反比的环形缓冲区长度(这就是前面提到的N = S/f公式)。算法第二步中的*均值计算充当了一个低通滤波器,它切断了较高的频率并允许较低的频率通过,从而消除较高的谐波(即基频的更高倍数),因为你主要关心的是基频。第三步中的衰减因子模拟了波在弦上来回传播时能量的损失。这对应于声音随时间的衰退。
在模拟的第一步中,你添加到的样本缓冲区代表了生成的声音随时间变化的幅度。将衰减后的值存储到环形缓冲区的末尾(第 4 步),并移除环形缓冲区的第一个元素(第 5 步),确保了逐渐衰减的值会持续传递到样本缓冲区,从而构建出模拟的声音。
让我们来看一个 Karplus-Strong 算法的简单例子。表 4-1 表示两个连续时间步长下的环形缓冲区。环形缓冲区中的每个值代表声音的幅度,这与拨弦时弦上某一点从静止位置的位移相同。缓冲区有五个元素,初始时被填充了一些数字。
表 4-1:Karplus-Strong 算法中两个时间步长的环形缓冲区
| 时间步长 1 | 0.1 | −0.2 | 0.3 | 0.6 | −0.5 |
|---|---|---|---|---|---|
| 时间步长 2 | −0.2 | 0.3 | 0.6 | −0.5 | −0.04975 |
当你从时间步长 1 走到时间步长 2 时,你应用 Karplus-Strong 算法如下:第一行中的第一个值 0.1 被移除,时间步长 1 中的所有后续值按顺序加入到第二行,第二行表示时间步长 2。时间步长 2 中的最后一个值是时间步长 1 中第一个和第二个值的衰减*均值,计算方法是 0.995 × ((0.1 + −0.2) ÷ 2) = −0.04975。
WAV 文件格式
Waveform Audio File Format (WAV) 用于存储音频数据。这个格式适用于小型音频项目,因为它简单且不需要担心复杂的压缩技术。
在最简单的形式中,WAV 文件由一系列值组成,每个值表示在给定时间点存储的声音的幅度。每个值分配了一定数量的比特,称为 分辨率。在这个项目中,你将使用 16 位分辨率。WAV 文件还具有一个 采样率,即每秒钟音频被 采样 或读取的次数。在这个项目中,你将使用 44,100 Hz 的采样率,这也是音频 CD 使用的采样率。总的来说,当你生成一个模拟拨弦声音的 WAV 文件时,它将包含每秒 44,100 个 16 位的值。
对于这个项目,你将使用 Python 的 wave 模块,它包含了处理 WAV 文件的方法。为了熟悉如何操作,我们用 Python 生成一个持续五秒的 220 Hz 正弦波音频片段。首先,你可以用以下公式表示一个正弦波:
A = sin(2πft)
在这里,A 是波的幅度,f 是频率,t 是当前的时间索引。现在你可以将这个方程式改写如下:
A = sin(2πfi/R)
在这个方程中,i 是采样的索引,R 是采样率。使用这两个方程,你可以创建一个持续五秒的 200 Hz 正弦波 WAV 文件,具体如下。(此代码可以在本章的 GitHub 仓库中的 sine.py 文件中找到。)
import numpy as np
import wave, math
sRate = 44100
nSamples = sRate * 5
❶ x = np.arange(nSamples)/float(sRate)
❷ vals = np.sin(2.0*math.pi*220.0*x)
❸ data = np.array(vals*32767, 'int16').tostring()
file = wave.open('sine220.wav', 'wb')
❹ file.setparams((1, 2, sRate, nSamples, 'NONE', 'uncompressed'))
❺ file.writeframes(data)
file.close()
你创建一个从 0 到 nSamples − 1 的 numpy 数字数组,并通过采样率将这些数字除以,以得到每个音频片段采样时的时间值,单位为秒 ❶。这个数组代表了前面提到的正弦波方程中的 i/R 部分。接下来,你用这个数组创建第二个 numpy 数组,包含正弦波的幅度值,依旧遵循正弦波方程 ❷。numpy 数组是一个快速且便捷的方式,可以将诸如 sin() 函数等操作应用到多个数值上。
计算出的正弦波值在范围[−1, 1]内被缩放到 16 位值,并转换为字符串,以便写入 WAV 文件❸。然后,你设置 WAV 文件的参数;在这种情况下,它是单声道(mono)、2 字节(16 位)、无压缩格式❹。最后,你将数据写入文件❺。图 4-4 显示了在 Audacity(一款免费的音频编辑器)中生成的sine220.wav 文件。正如预期的那样,你会看到频率为 220 Hz 的正弦波,当你播放该文件时,会听到一个持续五秒钟的 220 Hz 音调。(注意,你需要使用 Audacity 中的缩放工具才能看到图 4-4 中所示的正弦波。)

图 4-4:220 Hz 正弦波,放大显示
在你的项目中,一旦你用音频数据填充了样本缓冲区,你将使用与图 4-4 中所示的相同模式将其写入 WAV 文件。
小调五声音阶
音乐音阶是一个音高(频率)升高或降低的音符序列。通常,一首音乐作品中的所有音符都是从某一特定音阶中选择的。音乐音程是两种音高之间的差距。半音是音阶的基本构成单位,是西方音乐中最小的音程。全音是半音的两倍长度。大调音阶是最常见的音乐音阶之一,其音程模式为全音-全音-半音-全音-全音-全音-半音。
我们将在这里简要介绍五声音阶,因为你将生成该音阶中的音符。本节将解释用于最终程序生成音符的频率数值来源,这些音符是通过 Karplus-Strong 算法生成的。五声音阶是一种五个音符的音乐音阶。这种音阶的变体是小调五声音阶,它的音程模式为(全音+半音)-全音-全音-(全音+半音)-全音。因此,C 小调五声音阶包括音符 C、E-flat、F、G 和 B-flat。表 4-2 列出了你将使用 Karplus-Strong 算法生成的 C 小调五声音阶中五个音符的频率。(这里,C4 指的是钢琴的第四个八度中的 C 音,或称为中央 C,这是约定俗成的表示方式。)
表 4-2:小调五声音阶中的音符
| 音符 | 频率(Hz) |
|---|---|
| C4 | 261.6 |
| E-flat | 311.1 |
| F | 349.2 |
| G | 392.0 |
| B-flat | 466.2 |
本项目的一个方面是将随机音符序列串联起来,创作旋律。我们专注于小调五声音阶的原因之一是,无论这些音符以何种顺序播放,都听起来都很悦耳。因此,这种音阶特别适合生成随机旋律,而其他音阶(如大调音阶)则不具备这种特点。
要求
在这个项目中,你将使用 Python 的wave模块来创建 WAV 格式的音频文件。为了实现 Karplus-Strong 算法,你将使用 Python collections模块中的deque类作为环形缓冲区,并使用numpy数组作为样本缓冲区。你还将使用matplotlib来可视化模拟的吉他弦,并使用pyaudio模块播放 WAV 文件。
代码
现在,让我们开发实现 Karplus-Strong 算法所需的各个代码片段,然后将它们组合成完整的程序。要查看完整的项目代码,请跳转到《完整代码》页面的第 74 页。你还可以从本书的 GitHub 仓库下载代码,网址是github.com/mkvenkit/pp2e/tree/main/karplus。
使用 deque 实现环形缓冲区
回顾之前,Karplus-Strong 算法使用环形缓冲区生成音符。你将使用deque容器(发音为“deck”)来实现环形缓冲区,它是 Python collections模块中专用容器数据类型的一部分。你可以从deque的开头(头部)或末尾(尾部)插入和移除元素(参见图 4-5)。这个插入和移除过程是一个O(1),即“常数时间”操作,这意味着无论deque容器多大,它所需的时间都是相同的。

图 4-5:使用deque实现的环形缓冲区
以下代码展示了如何在 Python 中使用deque的一个示例:
>>> `from collections import deque`
❶ >>> `d = deque(range(10), maxlen=10)`
>>> `print(d)`
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
❷ >>> `d.append(10)`
>>> `print(d)`
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], maxlen=10)
你通过传入使用range()函数创建的列表❶来创建deque容器。你还指定了deque的最大长度maxlen为10。接下来,你将元素10附加到deque容器的末尾❷。当你打印deque时,可以看到10已被附加到deque的末尾,而第一个元素0已被自动移除,以保持deque容器的最大长度为 10 个元素。这个方案将允许你同时实现 Karplus-Strong 算法的第 4 步和第 5 步——在环形缓冲区的末尾添加新值,同时移除第一个值。
实现 Karplus-Strong 算法
现在,你将在generateNote()函数中实现 Karplus-Strong 算法,使用deque容器实现环形缓冲区,并使用numpy数组实现样本缓冲区。在同一个函数中,你还将使用matplotlib可视化算法。图表将显示拨动弦的幅度如何随时间变化,从而展示弦在振动时的运动。
你从一些设置开始:
# initialize plotting
❶ fig, ax = plt.subplots(1)
❷ line, = ax.plot([], [])
def generateNote(freq):
"""generate note using Karplus-Strong algorithm"""
nSamples = 44100
sampleRate = 44100
❸ N = int(sampleRate/freq)
❹ if gShowPlot:
# set axis
ax.set_xlim([0, N])
ax.set_ylim([-1.0, 1.0])
line.set_xdata(np.arange(0, N))
# initialize ring buffer
❺ buf = deque([random.random() - 0.5 for i in range(N)], maxlen=N)
# init samples buffer
❻ samples = np.array([0]*nSamples, 'float32')
首先,你创建一个 matplotlib 图形 ❶ 和一条线性图 ❷,然后将数据填充到图形中。接下来,你开始定义 generateNote() 函数,该函数以要生成的音符频率作为参数。你将音频片段的样本数和采样率都设置为 44,100,这意味着生成的音频片段将是 1 秒钟长。然后,你将采样率除以所需的频率,以设置 Karplus-Strong 环形缓冲区的长度 N ❸。如果设置了 gShowPlot 标志 ❹,你将初始化图表的 x 和 y 范围,并使用 arange() 函数将 x 值初始化为 [0, ... N-1]。
接下来,你将环形缓冲区初始化为一个包含范围在 [−0.5, 0.5] 之间的随机数的 deque 容器,并将 deque 的最大长度设置为 N ❺。你还将样本缓冲区初始化为一个 numpy 浮动数组 ❻。你将数组的长度设置为音频片段所包含的样本数。
接下来是 generateNote() 函数的核心,在这里你实现了 Karplus-Strong 算法的步骤,并创建了可视化:
for i in range(nSamples):
❶ samples[i] = buf[0]
❷ avg = 0.995*0.5*(buf[0] + buf[1])
❸ buf.append(avg)
# plot of flag set
❹ if gShowPlot:
if i % 1000 == 0:
line.set_ydata(buf)
fig.canvas.draw()
fig.canvas.flush_events()
# samples to 16-bit to string
# max value is 32767 for 16-bit
❺ samples = np.array(samples * 32767, 'int16')
❻ return samples.tobytes()
在这里,你遍历样本缓冲区中的每个元素,并执行 Karplus-Strong 算法的步骤。在每次迭代中,你将环形缓冲区中的第一个元素复制到样本缓冲区 ❶。然后,你通过对环形缓冲区中的前两个元素求*均并将结果乘以 0.995 来执行低通滤波和衰减 ❷。这个衰减后的值被附加到环形缓冲区的末尾 ❸。由于表示环形缓冲区的 deque 具有最大长度,append() 操作还会移除缓冲区中的第一个元素。
samples 数组通过将每个值乘以 32,767 转换为 16 位格式 ❺(16 位有符号整数只能取值从 −32,768 到 32,767,而 0.5 × 65,534 = 32,767)。然后,数组被转换为 wave 模块的字节表示,你将使用该模块将数据保存到文件 ❻。
当算法运行时,你可以可视化环形缓冲区的演变 ❹。每当有一千个样本时,你就使用环形缓冲区中的值更新 matplotlib 图表,显示数据如何随时间变化。
写入 WAV 文件
一旦你获得音频数据,你可以使用 Python 的 wave 模块将其写入 WAV 文件。定义一个 writeWAVE() 函数来执行此操作:
def writeWAVE(fname, data):
# open file
❶ file = wave.open(fname, 'wb')
# WAV file parameters
nChannels = 1
sampleWidth = 2
frameRate = 44100
nFrames = 44100
# set parameters
❷ file.setparams((nChannels, sampleWidth, frameRate, nFrames,
'NONE', 'noncompressed'))
❸ file.writeframes(data)
file.close()
你创建一个 WAV 文件 ❶,并使用单声道、16 位、无压缩格式设置其参数 ❷。然后你将数据写入文件 ❸。
使用 pyaudio 播放 WAV 文件
现在,你将使用 Python 的 pyaudio 模块播放由算法生成的 WAV 文件。pyaudio 是一个高性能、低级别的库,可以让你访问计算机上的声音设备。为了方便,你将代码封装在一个 NotePlayer 类中,如下所示:
class NotePlayer:
# constructor
def __init__(self):
# init pyaudio
❶ self.pa = pyaudio.PyAudio()
# open stream
❷ self.stream = self.pa.open(
format=pyaudio.paInt16,
channels=1,
rate=44100,
output=True)
# dictionary of notes
❸ self.notes = []
在 NotePlayer 类的构造函数中,你首先创建用于播放 WAV 文件的 PyAudio 对象 ❶。然后,你打开一个 16 位单声道的 PyAudio 输出流 ❷。你还会创建一个空的列表,稍后将用生成的五个五声音阶音符的 WAV 文件名填充 ❸。
在 Python 中,当对象的所有引用都被删除时,该对象会被称为垃圾回收的过程销毁。此时,如果已定义 __del__() 方法,也就是析构函数,该方法会被调用。以下是 NotePlayer 类的析构函数:
def __del__(self):
# destructor
self.stream.stop_stream()
self.stream.close()
self.pa.terminate()
该方法确保当 NotePlayer 对象被销毁时,PyAudio 流被清理。如果一个类没有提供 __del__() 方法,可能会在对象反复创建和销毁时引发问题,因为某些系统级资源(例如 pyaudio)可能无法正确清理。
NotePlayer 类的其余方法致力于构建可能音符的列表并播放它们。首先是 add() 方法,它用于将一个 WAV 文件名添加到类中:
def add(self, fileName):
self.notes.append(fileName)
该方法将一个与生成的 WAV 文件之一对应的文件名作为参数,并将其添加到你在类的构造函数中初始化的 notes 列表中。类会在需要播放 WAV 文件时使用这个列表。
接下来,我们来看一下用于播放音符的 play() 方法:
def play(self, fileName):
try:
print("playing " + fileName)
# open WAV file
❶ wf = wave.open(fileName, 'rb')
# read a chunk
❷ data = wf.readframes(CHUNK)
# read rest
while data != b'':
❸ self.stream.write(data)
❹ data = wf.readframes(CHUNK)
# clean up
❺ wf.close()
except BaseException as err:
❻ print(f"Exception! {err=}, {type(err)=}.\nExiting.")
exit(0)
这里你使用 Python 的 wave 模块打开所需的 WAV 文件 ❶。然后,你从文件中读取 CHUNK 帧(此处全局定义为 1,024)到 data 中 ❷。接下来,在 while 循环内,你将 data 的内容写入 PyAudio 输出流 ❸,并从 WAV 文件中读取下一个数据块 ❹。写入输出流的效果是通过计算机的默认音频设备(通常是扬声器)播放音频。你按块读取数据是为了保持输出端的采样率。如果数据块过大,并且在读取和写入之间耗费的时间过长,音频就会出现问题。
while 循环会持续进行,直到没有更多数据可读——即,直到 data 为空。此时,你关闭 WAV 文件对象 ❺。你通过打印错误 ❻ 并退出程序来处理在播放过程中可能发生的任何异常(例如,用户按下 CTRL-C)。
最后,NotePlayer 类的 playRandom() 方法会从你生成的五个音符中随机选择一个并进行播放:
def playRandom(self):
"""play a random note"""
index = random.randint(0, len(self.notes)-1)
note = self.notes[index]
self.play(note)
该方法从 notes 列表中选择一个随机的 WAV 文件名,并将该文件名传递给 play() 方法进行播放。
创建音符并解析参数
现在我们来看一下程序的 main() 函数,它负责创建音符并处理各种命令行选项来播放音符:
def main():
--`snip`--
parser = argparse.ArgumentParser(description="Generating sounds with
Karplus-Strong Algorithm")
# add arguments
parser.add_argument('--display', action='store_true', required=False)
parser.add_argument('--play', action='store_true', required=False)
args = parser.parse_args()
# show plot if flag set
❶ if args.display:
gShowPlot = True
plt.show(block=False)
# create note player
❷ nplayer = NotePlayer()
print('creating notes...')
for name, freq in list(pmNotes.items()):
fileName = name + '.wav'
❸ if not os.path.exists(fileName) or args.display:
data = generateNote(freq)
print('creating ' + fileName + '...')
writeWAVE(fileName, data)
else:
print('fileName already created. skipping...')
# add note to player
❹ nplayer.add(name + '.wav')
# play note if display flag set
if args.display:
❺ nplayer.play(name + '.wav')
time.sleep(0.5)
# play a random tune
if args.play:
while True:
try:
❻ nplayer.playRandom()
# rest - 1 to 8 beats
❼ rest = np.random.choice([1, 2, 4, 8], 1,
p=[0.15, 0.7, 0.1, 0.05])
time.sleep(0.25*rest[0])
except KeyboardInterrupt:
exit()
首先,使用argparse为程序设置一些命令行选项,正如之前项目中讨论的那样。--display选项会依次播放五个音符,同时使用matplotlib可视化每个音符的波形。--play选项则使用这五个音符生成一个随机旋律。
如果使用了--display命令行选项 ❶,你会设置一个matplotlib图表,显示在 Karplus-Strong 算法过程中波形的演变。plt.show(block=False)调用确保matplotlib显示方法不会阻塞。这样,当你调用这个函数时,它会立即返回,并继续执行下一个语句。这是你需要的行为,因为你需要每一帧手动更新图表。
接下来,你创建NotePlayer类的一个实例 ❷。然后你生成 C 小调五声音阶的五个音符的 WAV 文件。这些音符的频率在全局字典pmNotes中定义,内容如下所示:
pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G': 391, 'Bb': 466}
要生成音符,你需要遍历字典,首先使用字典的键加上.wav扩展名构建音符的文件名——例如,C4.wav。你可以使用os.path.exists()方法检查特定音符的 WAV 文件是否已创建 ❸。如果已经创建,则跳过该音符的计算。(如果你多次运行该程序,这是一个非常实用的优化。)否则,你会使用之前定义的generateNote()和writeWAVE()函数来生成音符。一旦音符计算完成并且 WAV 文件创建成功,你将音符的文件名添加到NotePlayer对象的音符列表中 ❹,然后如果使用了--display命令行选项,你会播放这个音符 ❺。
如果使用了--play选项,NotePlayer中的playRandom()方法会反复随机播放五个音符中的一个音符 ❻。为了使音符序列听起来有些音乐感,你需要在播放的音符之间添加休止符,因此你使用numpy中的random.choice()方法来选择一个随机的休止符间隔 ❼。该方法还允许你选择休止符间隔的概率,你可以将其设置为最有可能出现的是两拍的休止符,而八拍的休止符最不可能出现。试着改变这些值,创造你自己的随机音乐风格吧!
运行拨弦模拟
要运行该项目的代码,在命令行中输入以下内容:
$ `python ks.py --display`
正如你在图 4-6 中看到的,matplotlib的图表显示了 Karplus-Strong 算法如何将初始的随机位移转换为所需频率的波形。

图 4-6:拨弦模拟的示例运行
现在尝试使用以下命令播放一个随机音符序列:
$ `python ks.py --play`
这将播放一个使用生成的五声音阶 WAV 文件的随机音符序列。
摘要
在这个项目中,你使用了 Karplus-Strong 算法来模拟弹奏弦乐的声音,并从生成的 WAV 文件中播放音符。你学习了如何使用deque容器作为环形缓冲区来实现 Karplus-Strong 算法。你还了解了 WAV 文件格式以及如何使用pyaudio播放 WAV 文件,并学会了如何使用matplotlib可视化振动的弦。你甚至学习了五声音阶!
实验!
这里有一些实验的想法:
-
1. 我已经说过,Karplus-Strong 算法通过生成泛音和音符的基频来创建逼真的弹奏弦乐声音。那么你如何知道它是否有效呢?通过创建你的 WAV 文件的谱图,就像图 4-2 中显示的那样。你可以使用免费的程序 Audacity 来做到这一点。打开其中一个 WAV 文件,选择分析‣绘制 频谱。你应该会看到声音包含了许多频率。
-
2. 使用你在本章中学到的技巧,创建一种方法来复制两个不同频率的弦振动的声音。记住,Karplus-Strong 算法生成一个充满声音幅度值的环形缓冲区。你可以通过将两个声音的幅度相加来合成这两个声音。
-
3. 复制前一个实验中描述的两个弦振动的声音,但在第一次和第二次弦弹之间添加一个时间延迟。
-
4. 写一个方法从文本文件中读取音乐并生成音符。然后使用这些音符播放音乐。你可以使用一种格式,其中音符名称后跟整数的休止时间间隔,例如:C4 1 F4 2 G4 1 . . .
完整代码
这里是这个项目的完整代码:
"""
ks.py
Uses the Karplus-Strong algorithm to generate musical notes
in a pentatonic scale.
Author: Mahesh Venkitachalam
"""
import sys, os
import time, random
import wave, argparse
import numpy as np
from collections import deque
import matplotlib
# to fix graph display issues on macOS
matplotlib.use('TkAgg')
from matplotlib import pyplot as plt
import pyaudio
# show plot of algorithm in action?
gShowPlot = False
# notes of a pentatonic minor scale
# piano C4-E(b)-F-G-B(b)-C5
pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G':391, 'Bb':466}
CHUNK = 1024
# initialize plotting
fig, ax = plt.subplots(1)
line, = ax.plot([], [])
# write out WAV file
def writeWAVE(fname, data):
"""write data to WAV file"""
# open file
file = wave.open(fname, 'wb')
# WAV file parameters
nChannels = 1
sampleWidth = 2
frameRate = 44100
nFrames = 44100
# set parameters
file.setparams((nChannels, sampleWidth, frameRate, nFrames,
'NONE', 'noncompressed'))
file.writeframes(data)
file.close()
def generateNote(freq):
"""generate note using Karplus-Strong algorithm"""
nSamples = 44100
sampleRate = 44100
N = int(sampleRate/freq)
if gShowPlot:
# set axis
ax.set_xlim([0, N])
ax.set_ylim([-1.0, 1.0])
line.set_xdata(np.arange(0, N))
# initialize ring buffer
buf = deque([random.random() - 0.5 for i in range(N)], maxlen=N)
# init sample buffer
samples = np.array([0]*nSamples, 'float32')
for i in range(nSamples):
samples[i] = buf[0]
avg = 0.995*0.5*(buf[0] + buf[1])
buf.append(avg)
# plot of flag set
if gShowPlot:
if i % 1000 == 0:
line.set_ydata(buf)
fig.canvas.draw()
fig.canvas.flush_events()
# samples to 16-bit to string
# max value is 32767 for 16-bit
samples = np.array(samples * 32767, 'int16')
return samples.tobytes()
# play a WAV file
class NotePlayer:
# constructor
def __init__(self):
# init pyaudio
self.pa = pyaudio.PyAudio()
# open stream
self.stream = self.pa.open(
format=pyaudio.paInt16,
channels=1,
rate=44100,
output=True)
# dictionary of notes
self.notes = []
def __del__(self):
# destructor
self.stream.stop_stream()
self.stream.close()
self.pa.terminate()
# add a note
def add(self, fileName):
self.notes.append(fileName)
# play a note
def play(self, fileName):
try:
print("playing " + fileName)
# open WAV file
wf = wave.open(fileName, 'rb')
# read a chunk
data = wf.readframes(CHUNK)
# read rest
while data != b'':
self.stream.write(data)
data = wf.readframes(CHUNK)
# clean up
wf.close()
except BaseException as err:
print(f"Exception! {err=}, {type(err)=}.\nExiting.")
exit(0)
def playRandom(self):
"""play a random note"""
index = random.randint(0, len(self.notes)-1)
note = self.notes[index]
self.play(note)
# main() function
def main():
# declare global var
global gShowPlot
parser = argparse.ArgumentParser(description="Generating sounds with
Karplus-Strong Algorithm.")
# add arguments
parser.add_argument('--display', action='store_true', required=False)
parser.add_argument('--play', action='store_true', required=False)
args = parser.parse_args()
# show plot if flag set
if args.display:
gShowPlot = True
# plt.ion()
plt.show(block=False)
# create note player
nplayer = NotePlayer()
print('creating notes...')
for name, freq in list(pmNotes.items()):
fileName = name + '.wav'
if not os.path.exists(fileName) or args.display:
data = generateNote(freq)
print('creating ' + fileName + '...')
writeWAVE(fileName, data)
else:
print('fileName already created. skipping...')
# add note to player
nplayer.add(name + '.wav')
# play note if display flag set
if args.display:
nplayer.play(name + '.wav')
time.sleep(0.5)
# play a random tune
if args.play:
while True:
try:
nplayer.playRandom()
# rest - 1 to 8 beats
rest = np.random.choice([1, 2, 4, 8], 1,
p=[0.15, 0.7, 0.1, 0.05])
time.sleep(0.25*rest[0])
except KeyboardInterrupt:
exit()
# call main
if __name__ == '__main__':
main()
第五章:# 鸟群聚集

仔细观察一群鸟或一群鱼,你会发现尽管群体由独立的个体组成,但整个群体似乎拥有自己的生命。当鸟群移动时,它们会与彼此保持一致,并且能够绕过障碍物。它们在受到惊扰时会打乱队形,但随即会重新聚集,就好像受到某种更大力量的控制。
1986 年,克雷格·雷诺兹创造了一个逼真的鸟群行为模拟,名为 Boids 模型。Boids 模型的一个显著特点是,尽管鸟群个体之间的交互只受到三条简单规则的支配,但该模型却能生成非常逼真的鸟群行为。Boids 模型被广泛研究,甚至被用于动画制作,例如电影《蝙蝠侠归来》(1992)中的行进企鹅。
在这个项目中,你将使用雷诺兹的三条规则来创建一个模拟鸟群行为的 Boids 模型,模拟N只鸟并绘制它们随时间变化的位置信息和运动方向。你还将提供一个方法,用于将鸟加入鸟群,并且设计一个分散效果,供你研究局部干扰对鸟群的影响。Boids 被称为 N 体模拟,因为它模拟了一个由 N 个粒子组成的动态系统,这些粒子之间会相互施加力。
工作原理
Boids 模拟的三条核心规则如下:
分离力保持鸟群之间的最小距离。
对齐点让每只鸟保持与其局部鸟群成员*均运动方向的一致性。
凝聚力使每只鸟朝向其局部鸟群成员的质心移动。
Boids 模拟还可以加入其他规则,例如避免障碍物或在鸟群受到干扰时使鸟群分散,正如你在接下来的章节中将会学习的那样。为了创建 Boids 动画,你需要在每一个模拟时间步长中执行以下操作:
-
1. 对于鸟群中的所有鸟:
-
a. 应用三条核心规则。
-
b. 应用任何附加规则。
-
c. 应用所有边界条件。
-
-
2. 更新鸟群的位置信息和速度。
-
3. 绘制新的位置和速度。
正如你将看到的,这些简单的步骤能够创建一个具有演变复杂行为的鸟群。
要求
以下是你在模拟中将使用的 Python 模块:
-
• 使用
numpy数组来存储鸟群的位置信息和速度 -
• 使用
matplotlib库来对鸟群进行动画处理 -
• 使用
argparse来处理命令行选项 -
• 使用
scipy.spatial.distance模块,它包含一些非常方便的方法用于计算点之间的距离
我选择使用matplotlib来绘制 boid,因为它简单方便。为了尽可能快速地绘制大量 boid,你可能会使用像 OpenGL 这样的库。在本书的第三部分中,我们将更详细地探讨图形学。
代码
你将通过一个名为Boids的类来封装一组 boid 的行为。首先,你会设置 boid 的初始位置和速度。接着,你会设置仿真的边界条件,查看 boid 的绘制方式,并实现之前讨论的 Boids 仿真规则。最后,你将通过允许用户添加 boid 并散播群体,来为仿真增添一些有趣的事件。要查看完整的项目代码,请跳到“完整代码”,位于第 96 页。你也可以从本书的 GitHub 仓库下载它:github.com/mkvenkit/pp2e/blob/main/boids/boids.py。
初始化仿真
Boids 仿真需要在每一步计算 boid 的位置和速度,通过从numpy数组中获取信息。在仿真的开始,你使用Boids类的__init__()方法来创建这些数组,并将所有 boid 初始化在屏幕的中央,速度随机设定。
import argparse
import math
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from scipy.spatial.distance import squareform, pdist
from numpy.linalg import norm
❶ width, height = 640, 480
class Boids:
"""class that represents Boids simulation"""
def __init__(self, N):
"""initialize the Boids simulation"""
# init position & velocities
❷ self.pos = [width/2.0, height/2.0] +
10*np.random.rand(2*N).reshape(N, 2)
# normalized random velocities
❸ angles = 2*math.pi*np.random.rand(N)
❹ self.vel = np.array(list(zip(np.cos(angles), np.sin(angles))))
self.N = N
首先,导入程序所需的模块,并设置仿真窗口在屏幕上的宽度和高度 ❶。接着,开始声明Boids类。在该类的__init__()方法中,创建一个名为pos的numpy数组,用于存储所有 boid 的 x 和 y 坐标 ❷。对于每一对坐标的初始值,你从窗口的中心[width/2.0, height/2.0]开始,并加入最大 10 个单位的随机偏移。代码np.random.rand(2*N)会创建一个包含 2N 个随机数的一维数组,范围是[0, 1],你再将其乘以 10,将范围缩放到[0, 10]。reshape()方法将一维数组转换为形状为(N, 2)的二维数组,完美地用于存储N对 x 和 y 坐标。同时,注意到numpy的广播规则:代表窗口中心的 1×2 数组[width/2.0, height/2.0]被加到N*×2 数组的每个元素上,从而随机偏移每个 boid 的位置。
接下来,你为每个 boid 创建一个随机的单位速度向量数组(这些向量的大小为 1.0,指向随机方向),使用以下方法:给定一个角度t,一对数字(cos(t), sin(t))位于半径为 1.0 的圆上,圆心在原点(0, 0)。如果你从原点画一条线到圆上的某一点,那么这条线就成为一个单位向量,取决于角度t。所以如果你随机选择t,你最终会得到一个随机的速度向量。图 5-1 展示了这一方案。

图 5-1:生成随机单位速度向量
回到代码中,你首先生成一个在[0, 2π]范围内的N个随机角度数组❸。然后通过计算这些角度的余弦和正弦,创建一个随机单位速度向量数组❹。你使用 Python 内置的zip()方法将每个向量的坐标分组。以下是zip()的一个简单示例。它将两个列表合并成一个包含元组的列表。因为直接调用zip()只会生成一个迭代器,所以需要使用list()来将其转换为列表,这样才能获取所有元素。
>>> list(`zip([0, 1, 2], [3, 4, 5]))`
[(0, 3), (1, 4), (2, 5)]
总结来说,你已经生成了两个在整个模拟过程中都很有用的数组,pos和vel。第一个数组包含聚集在屏幕中心 10 像素半径范围内的随机位置,第二个数组包含指向随机方向的单位速度向量。这意味着在模拟开始时,所有 boid 都将悬停在屏幕中心,朝随机方向指向。
__init__()方法继续声明一些常量值,这些常量值将帮助管理模拟:
# min dist of approach
❶ self.minDist = 25.0
# max magnitude of velocities calculated by "rules"
❷ self.maxRuleVel = 0.03
# max magnitude of final velocity
❸ self.maxVel = 2.0
在这里,你定义了两个 boid 之间的最小接*距离❶。稍后你将使用这个值来应用分离规则。接着你定义了maxRuleVel,它限制了每次应用模拟规则时 boid 的速度变化量❷。你还定义了maxVel,它为 boid 的速度设定了一个总体限制❸。
设置边界条件
鸟儿飞翔在广阔的天空中,但 boid 必须在有限的空间内活动。为了创建这个空间,你需要设置边界条件,正如你在第三章的生命游戏模拟中使用环形边界条件一样。在这种情况下,你将应用*铺边界条件(实际上是你在第三章中使用的边界条件的连续空间版本)。
将 Boids 仿真看作是在一个瓷砖空间中进行:当一个 boid 移出一个瓷砖时,它将从对面方向进入一个相同的瓷砖。环形和瓷砖边界条件的主要区别在于,这个 Boids 仿真不会在一个离散的网格上进行;相反,鸟类将在一个连续的区域上移动。图 5-2 显示了这些瓷砖边界条件的样子。

图 5-2:瓷砖边界条件
看看中间的瓷砖。飞出右侧的鸟类正进入右侧的瓷砖,但边界条件确保它们实际上会通过左侧的瓷砖重新进入中央瓷砖。你可以看到同样的情况发生在顶部和底部的瓷砖上。
你将 Boids 仿真的瓷砖边界条件作为 Boids 类中的一个方法来实现:
def applyBC(self):
"""apply boundary conditions"""
deltaR = 2.0
for coord in self.pos:
❶ if coord[0] > width + deltaR:
coord[0] = - deltaR
if coord[0] < - deltaR:
coord[0] = width + deltaR
if coord[1] > height + deltaR:
coord[1] = - deltaR
if coord[1] < - deltaR:
coord[1] = height + deltaR
这个方法将瓷砖边界条件应用于 pos 数组中的每组 boid 坐标。例如,如果 x 坐标大于窗口的宽度 ❶,你将其重新设置到窗口的左边缘。该行中的 deltaR 提供了一个轻微的缓冲区,允许 boid 稍微超出窗口边界,然后从对面方向重新进入,从而产生更好的视觉效果。你会在窗口的左边、顶部和底部执行类似的检查。
绘制一个 Boid
为了构建动画,你需要知道每个 boid 的位置和速度,并且有一种方法来在每个时间步长上表示位置和运动方向。
绘制 Boid 的身体和头部
为了给 boids 动画化,你使用 matplotlib 和一个小技巧来绘制位置和速度。将每个 boid 绘制为两个圆形,如 图 5-3 所示。较大的圆表示身体,较小的圆表示头部。点 P 标记身体的中心位置。为了我们的目的,你可以将 P 视为 boid 的位置,并使用前面讨论过的 pos 数组中的坐标来设置它。点 H 是头部的中心。你根据公式 H = P + k × V 计算 H 的位置,其中 V 是 boid 的速度,k 是一个常数,表示从身体中心到头部中心的距离。这样,boid 的头部将在任何给定时刻与其运动方向对齐,这比单独绘制身体更清晰地传达了 boid 的运动方向。

图 5-3:表示一个 boid
在程序的 main() 函数中的以下代码片段中,你使用 matplotlib 以圆形标记的方式绘制 boid 的身体和头部:
fig = plt.figure()
ax = plt.axes(xlim=(0, width), ylim=(0, height))
❶ pts, = ax.plot([], [], markersize=10, c='k', marker='o', ls='None')
❷ head, = ax.plot([], [], markersize=4, c='r', marker='o', ls='None')
❸ anim = animation.FuncAnimation(fig, tick, fargs=(pts[0], head, boids),
interval=50)
你设置了鸟群身体(pts) ❶ 和头部(head) ❷ 的标记大小和形状。'k' 和 'r' 字符串分别指定了黑色和红色,而 'o' 会生成圆形标记。ax.plot() 方法返回一个 matplotlib.lines.Line2D 对象的列表。这些行中的 , 语法提取了列表中的第一个也是唯一一个元素。
接下来,你初始化一个 matplotlib animation.FuncAnimation() 对象 ❸,它设置了一个回调函数 tick(),该函数将在每一帧动画中被调用(我们稍后将在本章中讨论这个函数)。fargs 参数允许你指定回调函数的参数,同时你还设置了时间间隔(在此为 50 毫秒),即该函数被调用的时间间隔。现在你已经知道如何绘制身体和头部,接下来我们来看如何更新它们的位置。
更新鸟群的位置
动画开始后,你需要更新鸟群的位置和头部位置,后者告诉你鸟群的移动方向。你可以使用以下代码实现:
vec = self.pos + 10*self.vel/self.maxVel
head.set_data(vec.reshape(2*self.N)[::2], vec.reshape(2*self.N)[1::2])
首先,你通过应用前面讨论的公式 H = P + k × V 来计算头部的位置。你在速度 (vel) 方向上使用 k 值为 10 单位。然后,你用新的头部位置更新 (reshape) matplotlib 轴 (set_data)。[::2] 从速度列表中挑选出偶数编号的元素(x 轴值),而 [1::2] 则挑选出奇数编号的元素(y 轴值)。
应用鸟群规则
在本节中,我们将探讨如何实现鸟群仿真中的三条规则——分离、对齐和聚合——以便在每个时间步重新计算鸟群的速度。我们首先将重点放在分离规则上。目标是为每只鸟群生成一个新的速度向量,使其远离附*的群体伙伴,这些伙伴被定义为位于某个半径 R 内的所有鸟群。给定两只鸟群 i 和 j,它们的位置分别为 P[i] 和 P[j],则 P[i] − P[j] 会生成一个新的速度向量,使得鸟群 i 向远离鸟群 j 的方向移动。我们将其称为 位移向量。为了计算鸟群 i 的新速度向量 V[i],该速度向量会将其*均推离所有附*的群体伙伴,只需将鸟群 i 与半径 R 内每只鸟群的位移向量相加即可。换句话说,V[i] = (P[i] − P[1]) + (P[i] − P[2]) + . . . (P[i] − P[N]),前提是鸟群 i 和 j 之间的距离小于 R。你可以更正式地写成:

注意,实施这个规则——实际上,实施其他 Boids 规则——涉及到计算每个 boid 与每个其他 boid 之间的距离,以确定哪些 boid 是局部的群体成员。然而,传统的方法是在 Python 中使用一对嵌套循环来遍历 boids。正如你将看到的,numpy数组提供了更高效的方法,可以绕过使用循环的需求。我们将实现两种方法并比较结果,然后将我们学到的应用到实际的仿真代码中。
使用嵌套循环
首先,我们定义一个函数test1(),它以一种直接的方式实现分离规则,使用循环:
def test1(pos, radius):
# fill output with zeros
vel = np.zeros(2*N).reshape(N, 2)
# for each pos
❶ for (i1, p1) in enumerate(pos):
# velocity contribution
val = np.array([0.0, 0.0])
# for each other pos
❷ for (i2, p2) in enumerate(pos):
if i1 != i2:
# calculate distance from p1
dist = math.sqrt((p2[0]-p1[0])*(p2[0]-p1[0]) +
(p2[1]-p1[1])*(p2[1]-p1[1]))
# apply threshold
❸ if dist < radius:
❹ val += (p2 - p1)
# set velocity
vel[i1] = val
# return computed velocity
return vel
这段代码使用了一对嵌套的循环。外循环❶遍历pos数组中的每个 boid。内循环❷计算当前 boid 与数组中每个其他 boid 之间的距离。如果距离小于作为函数radius参数定义的阈值❸,你就按之前讨论的方式计算位移向量,并将结果添加到val❹中。在每次内循环的末尾,val保存了一个新的速度,该速度将推动当前 boid 远离其邻居。你将该速度重新存储回vel数组中。
使用 numpy 方法
现在,让我们定义一个函数test2(),它以“numpy方式”实现相同的功能,避免使用循环,并利用高度优化的numpy方法。你还会使用scipy.spatial.distance模块中的方法来高效地计算点之间的距离:
def test2(pos, radius):
# get distance matrix
❶ distMatrix = squareform(pdist(pos))
# apply threshold
❷ D = distMatrix < radius
# compute velocity
❸ vel = pos*D.sum(axis=1).reshape(N, 1) - D.dot(pos)
return vel
你使用scipy库中的squareform()和pdist()方法来计算pos数组中每一对点之间的距离 ❶。对于一个包含N个点的数组,squareform()会给出一个N×N矩阵,其中任意给定的条目M[ij]表示点P[i]和P[j]之间的距离。让我们看一个简单的例子,看看它是如何工作的。在这段代码中,你对包含三个点的数组调用这些方法:
>>> `import numpy as np`
>>> `from scipy.spatial.distance import squareform, pdist`
>>> `x = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]])`
>>> `squareform(pdist(x))`
array([[0. , 1.41421356, 2.82842712],
[1.41421356, 0. , 1.41421356],
[2.82842712, 1.41421356, 0. ]])
由于你提供了一个包含三个点的数组,结果是一个 3×3 的距离计算矩阵。例如,第一行的值告诉你第一个点([0.0, 0.0])与数组中每个点之间的距离。沿对角线的零值对应于每个点与自身之间的距离。
回到test2()函数,你接下来根据距离是否小于指定的radius ❷来筛选矩阵。以包含三个点的示例数组为例,结果如下:
>>> `squareform(``pdist(x)) < 1.4`
array([[ True, False, False],
[False, True, False],
[False, False, True]])
<比较生成一个布尔矩阵,True/False值对应于原始距离矩阵——如果距离小于给定阈值(在本例中为 1.4),则为True。
回到test2(),你使用之前讨论过的V[i]方程的修改版,广播到整个pos数组 ❸。该方程可以重写为:

在这里,右侧的第二个求和项只包括满足距离条件的点 P。求和项中的元素个数是 m。这个方程可以重新写成:

其中 D[ij] 是你生成的布尔矩阵的第 i 行 ❷,m 是该行中 True 值的个数,P[j] 是所有在当前鸟群指定半径内的点 P。
D.sum 方法 ❸ 按列的方式将布尔矩阵中的 True 值加总,得到方程中的 m。之所以需要 reshape,是因为求和的结果是一个一维数组,包含 N 个值(形状为 (N, )),而你希望它的形状为 (N, 1),以便与位置数组进行乘法运算。该行的 D.dot(pos) 部分则是对布尔矩阵和鸟群位置数组进行点积(乘法),对应方程中的 D[ij]P[j] 部分。
比较方法
比较这两种方法,test2() 比 test1() 更紧凑,但它的真正优势在于速度。我们使用 Python 的 timeit 模块来评估这两个函数的性能。首先,将 test1() 和 test2() 函数的代码输入到名为 test.py 的文件中,如下所示:
import math
import numpy as np
from scipy.spatial.distance import squareform, pdist, cdist
N = 100
width, height = 640, 480
pos = np.array(list(zip(width*np.random.rand(N), height*np.random.rand(N))))
def test1(pos, radius):
--`snip`--
def test2(post, radius):
--`snip`--
现在在 Python 解释器会话中使用 timeit 模块来比较这两个函数的性能:
fromtimeit import timeit
timeit('test1(pos, 100)', 'from test import test1, N, pos, width, height', number=100)
7.880876064300537
timeit('test2(pos, 100)', 'from test import test2, N, pos, width, height', number=100)
0.036969900131225586
在我的计算机上,numpy 的无循环代码比使用显式循环的代码运行速度快大约 200 倍!但为什么呢?它们不都是在做差不多的事情吗?
作为一种解释性语言,Python 本身的运行速度比 C 等编译语言慢。numpy 库通过提供高效优化的数组操作方法,使 Python 既保持了便利性,又几乎达到了 C 的性能。通常,当你将算法重新组织成对整个数组一次性操作的步骤,而不是对单独元素进行逐一循环计算时,numpy 的效果最好。
编写最终方法
现在你已经比较了这两种方法,你可以利用所学知识编写一个最终版本的方法,应用模拟的所有三个规则,并返回所有鸟群的更新速度。applyRules() 方法是 Boids 类的一部分,采用了前面讨论的优化 numpy 技术。
def applyRules(self):
# get pairwise distances
❶ self.distMatrix = squareform(pdist(self.pos))
# apply rule #1: separation
D = self.distMatrix < self.minDist
❷ vel = self.pos*D.sum(axis=1).reshape(self.N, 1) - D.dot(self.pos)
❸ self.limit(vel, self.maxRuleVel)
# distance threshold for alignment (different from separation)
❹ D = self.distMatrix < 50.0
# apply rule #2: alignment
❺ vel2 = D.dot(self.vel)
self.limit(vel2, self.maxRuleVel)
❻ vel += vel2
# apply rule #3: cohesion
❼ vel3 = D.dot(self.pos) - self.pos
self.limit(vel3, self.maxRuleVel)
❽ vel += vel3
return vel
你使用 scipy 库中的 squareform() 和 pdist() 方法计算 boids 之间的成对距离矩阵,如前所述❶。当你使用 numpy 方法应用分离规则❷时,每个 boid 会被推离距离小于 minDist(25 像素)范围内的相邻 boid。计算出的速度会被限制到一个最大值,使用 Boids 类的 limit() 方法❸来实现,这部分我们稍后会介绍。如果没有这种限制,速度会随着每个时间步的进行而增加,仿真将会失控。
接下来,你生成一个新的布尔矩阵,使用 50 像素的距离阈值,而不是 25 像素❹。你将使用这个更广泛的邻居群体定义来应用对齐和凝聚规则。对齐规则的实现方式是让每个 boid 受到其邻居*均速度的影响,并与其对齐。你通过将 D(布尔矩阵)与速度数组做点积来计算*均速度❺。再次强调,你会限制计算出的速度的最大值,以防它们无限增加。(使用简洁的 numpy 语法让所有这些计算变得既简单又快速。)
最后,你通过将所有邻* boid 的位置加起来,再减去当前 boid 的位置❼来应用凝聚规则。这会产生一个指向邻居 质心 或几何中心的速度向量。再次强调,你会限制速度以防它们失控。
每一条规则都会为每个 boid 产生自己的速度向量。在 ❻ 和 ❽ 处,你将这些向量相加,为每个 boid 产生一个整体速度向量,反映所有三个仿真规则的影响。你将最终的速度向量存储在 vel 数组中。
限制速度
在上一节中,你看到了在应用每个规则后如何调用 limit() 方法,以防止 boids 的速度失控。下面是该方法:
def limit(self, X, maxVal):
"""limit the magnitude of 2D vectors in array X to maxValue"""
❶ for vec in X:
self.limitVec(vec, maxVal)
该方法旨在接受一个速度向量数组,提取每个独立的向量❶,并将其传递给 limitVec() 方法,方法如下:
def limitVec(self, vec, maxVal):
"""limit the magnitude of the 2D vector"""
❶ mag = norm(vec)
if mag > maxVal:
❷ vec[0], vec[1] = vec[0]*maxVal/mag, vec[1]*maxVal/mag
你使用 numpy 库中的 norm() 函数计算向量的大小❶。如果它超过最大值,你会按照向量的大小比例缩放向量的 x 和 y 分量❷。最大值被定义为 self.maxRuleVel = 0.03,这是在 Boids 类初始化时设置的。
影响仿真
Boids 仿真中的核心规则会导致 boids 自动表现出群聚行为。但为了让事情更有趣,我们允许用户在仿真进行时进行干预。具体来说,你将创建一个功能,允许用户通过点击鼠标向群体中添加 boids 或让群体散开。
向正在运行的仿真中注入事件的第一步是向 matplotlib 画布添加一个 事件处理程序。这是一段代码,每当发生某个特定事件(如鼠标点击)时就会调用一个函数。以下是如何操作的:
cid = fig.canvas.mpl_connect('button_press_event', boids.buttonPress)
你使用 mpl_connect() 方法向 matplotlib 画布添加一个按钮按下事件处理程序。每次在仿真窗口中按下鼠标按钮时,这个处理程序都会调用 Boids 类的 buttonPress() 方法。接下来,你需要定义 buttonPress() 方法。
添加一个 Boid
buttonPress() 方法的第一部分是在鼠标光标所在的位置将一个 Boid 添加到仿真中,并在按下 左 键时为该 Boid 分配一个随机速度。
def buttonPress(self, event):
"""event handler for matplotlib button presses"""
# left-click to add a boid
❶ if event.button is 1:
❷ self.pos = np.concatenate((self.pos,
np.array([[event.xdata, event.ydata]])),
axis=0)
# generate a random velocity
angles = 2*math.pi*np.random.rand(1)
v = np.array(list(zip(np.sin(angles), np.cos(angles))))
❸ self.vel = np.concatenate((self.vel, v), axis=0)
❹ self.N += 1
首先,你需要确保鼠标事件是一次左键点击 ❶。然后,你将鼠标位置(由 event.xdata, event.ydata 提供)添加到 Boid 位置数组中 ❷。你还会生成一个随机速度向量,将其添加到 Boid 速度数组中 ❸,并将 Boid 的数量增加 1 ❹。
散开 Boid
这三个仿真规则保证 Boid 在移动过程中保持成群。然而,当群体受到干扰时会发生什么呢?为了模拟这种情况,你可以引入一个“散开”效果:当你在仿真窗口右键点击时,群体会从点击位置散开。你可以将其想象为群体如何应对捕食者的突然出现或吓到鸟群的巨响。你可以将这个效果作为 buttonPress() 方法的延续来实现:
# right-click to scatter boids
❶ elif event.button is 3:
# add scattering velocity
self.vel += 0.1*(self.pos - np.array([[event.xdata, event.ydata]]))
在这里,你检查鼠标按钮按下事件是否是右键点击事件 ❶。如果是,你会通过添加一个指向干扰发生点(即鼠标点击位置)的向量来改变每个 Boid 的速度。你可以像计算分离规则的位移向量那样计算这个向量。如果 P[i] 是一个 Boid 的位置,而 P[m] 是鼠标点击的点,那么 P[i] − P[m] 就是一个指向鼠标点击位置的向量。你将这个向量乘以 0.1 来保持干扰的幅度较小。最初,Boid 会飞离那个点,但正如你将看到的,三个规则仍然占主导地位,Boid 将重新聚集成一个群体。
增加仿真事件
在每个仿真步骤中,你需要应用这些规则来计算 Boid 的新速度,根据这些速度更新 Boid 的位置,强制执行边界条件,并在显示窗口中重新绘制所有内容。你可以通过 tick() 函数来协调所有这些活动,它将在每帧 matplotlib 动画中被调用。
def tick(frameNum, pts, head, boids):
"""update function for animation"""
boids.tick(frameNum, pts, head)
return pts, head
独立的 tick() 函数只是调用 Boids 类的 tick() 方法。后者定义如下:
def tick(self, frameNum, pts, head):
"""update the simulation by one time step"""
# apply rules
❶ self.vel += self.applyRules()
❷ self.limit(self.vel, self.maxVel)
❸ self.pos += self.vel
❹ self.applyBC()
# update data
❺ pts.set_data(self.pos.reshape(2*self.N)[::2],
self.pos.reshape(2*self.N)[1::2])
❻ vec = self.pos + 10*self.vel/self.maxVel
❼ head.set_data(vec.reshape(2*self.N)[::2],
vec.reshape(2*self.N)[1::2])
这个方法是将一切整合在一起的地方。你使用我们之前看过的 applyRules() 方法来应用 boid 规则 ❶。然后,使用 self.maxVel 阈值限制计算出的 boid 速度 ❷。(即使你限制了每个单独规则生成的速度向量,通过将所有三个规则加在一起得到的整体速度可能仍然过大。)接下来,你通过将新的速度向量加到旧的位置信息数组中来计算 boids 的更新位置 ❸。例如,如果一个 boid 的位置是 [0, 0],并且它的速度向量是 [1, 1],那么在一个时间步之后,它的新位置将是 [1, 1]。你通过调用 applyBC() 来应用仿真的边界条件 ❹。
调用 pts.set_data() ❺ 会使用 boids 的新位置更新 matplotlib 坐标轴。[::2] 从 pos 数组中选择偶数索引的元素(x 轴值),而 [1::2] 选择奇数索引的元素(y 轴值)。这将重新绘制表示 boids 身体的较大圆圈。接下来,你需要绘制表示 boids 头部的较小圆圈。你通过应用之前讨论的 H = P + k × V 公式来计算每个 boid 头部的位置,使其指向 boid 的运动方向 ❻。回想一下,P 是 boid 身体的中心,k 是一个常数,表示从身体中心到头部中心的距离(你使用 10 个单位的值),V 是 boid 的速度。一旦你获得了新的头部位置,就可以通过与绘制身体时相同的技巧来绘制它们 ❼。
解析参数并实例化 Boids 类
程序的 main() 函数首先处理命令行参数,并实例化 Boids 类:
def main():
# use sys.argv if needed
print('starting boids...')
parser = argparse.ArgumentParser(description="Implementing Craig
Reynolds's Boids...")
# add arguments
❶ parser.add_argument('--num-boids', dest='N', required=False)
args = parser.parse_args()
# set the initial number of boids
❷ N = 100
if args.N:
N = int(args.N)
# create boids
❸ boids = Boids(N)
你使用熟悉的 argparse 模块创建命令行选项,用于设置仿真中的初始 boid 数量 ❶。如果命令行没有提供参数,则仿真默认使用 100 个 boid ❷。通过创建一个 Boids 类的对象,你可以启动仿真 ❸。
main() 函数继续执行创建和动画化 matplotlib 图表的代码。我们已经在 “绘制 Boid 的身体和头部” 这一节中讨论过这些代码,详见 第 84 页。
运行 Boids 仿真
让我们看看运行仿真时会发生什么。输入以下命令:
$ `python boids.py`
Boids 仿真应该以所有 boid 集中在窗口中心的状态开始。让仿真运行一段时间,boid 会开始聚集成群,形成类似于 图 5-4 所示的图案。

图 5-4:Boids 仿真的一次示例运行
左键单击模拟窗口。新的 boid 应该出现在该位置,并且当它遇到鸟群时,它的速度应该发生变化。现在右键单击。鸟群应该最初从光标散开,但随后重新聚集。
总结
在这个项目中,你使用 Craig Reynolds 提出的三条规则模拟了鸟群(或 boids)的聚集。你观察到一次性操作整个 numpy 数组比在显式循环中执行相同的操作要快得多。你使用了 scipy.spatial 模块来执行快速且方便的距离计算,并实现了一个 matplotlib 技巧,使用两个标记表示点的位置和方向。最后,你通过在 matplotlib 图形中添加事件处理程序,增加了与按钮按下的交互性。
实验!
这里有一些方法可以进一步探索群体行为:
-
1. 通过编写一个新的方法
avoidObstacle()来实现你的 boid 群体的障碍物回避功能,并在应用三条规则后立即应用该方法,如下所示:self.vel += self.applyRules() self.vel += self.avoidObstacle()avoidObstacle()方法应该使用预定义的元组 (x, y, R),为 boid 添加一个额外的速度项,将其推离障碍物位置 (x, y),但仅当 boid 位于障碍物半径 R 内时才有效。可以将其视为 boid 看到障碍物并从其方向偏移的距离。你可以通过命令行选项指定 (x, y, R) 元组。 -
2. 当 boids 穿越强风时会发生什么?通过在模拟中的随机时间步骤为所有 boid 添加一个全局速度分量来模拟这一点。boids 应该暂时受到风的影响,但一旦风停了,它们应该恢复到原来的聚集状态。
完整代码
以下是 Boids 模拟的完整代码:
"""
boids.py
Craig Reynolds 的 Boids 模拟实现。
作者:Mahesh Venkitachalam
"""
import argparse
import math
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from scipy.spatial.distance import squareform, pdist
from numpy.linalg import norm
宽度,高度 = 640, 480
class Boids:
"""表示 Boids 模拟的类"""
def init(self, N):
"""初始化 Boids 模拟"""
初始化位置和速度
self.pos = [width/2.0, height/2.0] +
10np.random.rand(2N).reshape(N, 2)
标准化的随机速度
angles = 2math.pinp.random.rand(N)
self.vel = np.array(list(zip(np.cos(angles), np.sin(angles))))
self.N = N
最小接*距离
self.minDist = 25.0
通过“规则”计算的速度最大大小
self.maxRuleVel = 0.03
最终速度的最大大小
self.maxVel = 2.0
def tick(self, frameNum, pts, head):
"""通过一个时间步长更新模拟"""
应用规则
self.vel += self.applyRules()
self.limit(self.vel, self.maxVel)
self.pos += self.vel
self.applyBC()
更新数据
pts.set_data(self.pos.reshape(2*self.N)[::2],
self.pos.reshape(2*self.N)[1::2])
vec = self.pos + 10*self.vel/self.maxVel
head.set_data(vec.reshape(2*self.N)[::2],
vec.reshape(2*self.N)[1::2])
def limitVec(self, vec, maxVal):
"""限制二维向量的大小"""
mag = norm(vec)
if mag > maxVal:
vec[0], vec[1] = vec[0]maxVal/mag, vec[1]maxVal/mag
def limit(self, X, maxVal):
"""限制数组 X 中二维向量的大小至 maxValue"""
for vec in X:
self.limitVec(vec, maxVal)
def applyBC(self):
"""应用边界条件"""
deltaR = 2.0
for coord in self.pos:
if coord[0] > width + deltaR:
coord[0] = - deltaR
if coord[0] < - deltaR:
coord[0] = width + deltaR
if coord[1] > height + deltaR:
coord[1] = - deltaR
if coord[1] < - deltaR:
coord[1] = height + deltaR
def applyRules(self):
获取成对的距离
self.distMatrix = squareform(pdist(self.pos))
应用规则 #1 - 分离
D = self.distMatrix < self.minDist
vel = self.pos*D.sum(axis=1).reshape(self.N, 1) - D.dot(self.pos)
self.limit(vel, self.maxRuleVel)
不同的距离阈值
D = self.distMatrix < 50.0
应用规则 #2 - 对齐
vel2 = D.dot(self.vel)
self.limit(vel2, self.maxRuleVel)
vel += vel2;
应用规则 #1 - 凝聚
vel3 = D.dot(self.pos) - self.pos
self.limit(vel3, self.maxRuleVel)
vel += vel3
return vel
def buttonPress(self, event):
"""matplotlib 按钮点击事件处理器"""
左键点击 - 添加一个 Boid
if event.button == 1:
self.pos = np.concatenate((self.pos,
np.array([[event.xdata, event.ydata]])),
axis=0)
随机速度
angles = 2math.pinp.random.rand(1)
v = np.array(list(zip(np.sin(angles), np.cos(angles))))
self.vel = np.concatenate((self.vel, v), axis=0)
self.N += 1
右键点击 - 散布
elif event.button == 3:
添加散布速度
self.vel += 0.1*(self.pos - np.array([[event.xdata, event.ydata]]))
def tick(frameNum, pts, head, boids):
"""动画更新函数"""
boids.tick(frameNum, pts, head)
return pts, head
main() function
def main():
如果需要,使用 sys.argv
print('启动 Boids...')
parser = argparse.ArgumentParser(description=
"实现 Craig Reynolds 的 Boids...")
添加参数
parser.add_argument('--num-boids', dest='N', required=False)
args = parser.parse_args()
Boid 数量
N = 100
if args.N:
N = int(args.N)
创建 Boids
boids = Boids(N)
设置图表
fig = plt.figure()
ax = plt.axes(xlim=(0, width), ylim=(0, height))
pts = ax.plot([], [], markersize=10,
c='k', marker='o', ls='None')
head, = ax.plot([], [], markersize=4,
c='r', marker='o', ls='None')
anim = animation.FuncAnimation(fig, tick, fargs=(pts[0], head, boids),
interval=50)
添加 "按钮点击" 事件处理器
cid = fig.canvas.mpl_connect('button_press_event', boids.buttonPress)
plt.show()
call main
if name == 'main':
main()
第三部分:# 玩转图像
你通过观察可以学到很多。
—尤吉·贝拉
第六章:# ASCII 艺术

在 1990 年代,当电子邮件盛行而图形处理能力有限时,通常会在电子邮件中附上一个由文本组成的图形签名,通常称为 ASCII 艺术。(ASCII 只是一个字符编码方案。)图 6-1 显示了几个例子。虽然互联网让图像共享变得无比容易,但这种简朴的文本图形并没有完全消失。
ASCII 艺术起源于 19 世纪末的打字机艺术。到了 1960 年代,当时计算机的图形处理硬件非常有限,ASCII 被用来表示图像。如今,ASCII 艺术仍然作为一种表达形式存在于互联网上,你可以在线找到各种创意的例子。

图 6-1:ASCII 艺术示例
在这个项目中,你将使用 Python 创建一个程序,将图形图像转换为 ASCII 艺术。该程序将允许你指定输出的宽度(文本的列数)并设置垂直缩放因子。它还支持两种将灰度值映射到 ASCII 字符的方式:一种是稀疏的 10 级映射,另一种是更精细的 70 级映射。
要从图像生成你的 ASCII 艺术,你将学习以下内容:
-
• 使用
Pillow(Python 图像库 PIL 的一个分支)将图像转换为灰度。 -
• 使用
numpy计算灰度图像的*均亮度。 -
• 使用字符串作为灰度值的快速查找表。
它是如何工作的
本项目利用了这样一个事实:从远处看,我们将灰度图像感知为其亮度的*均值。例如,在图 6-2 中,你可以看到一张建筑物的灰度图像,以及它旁边的另一张图像,显示了建筑物图像的*均亮度值。如果你从远处看这些图像,它们会显得相似。
ASCII 艺术是通过将图像分割成多个小块,并根据每个小块的*均亮度值,用 ASCII 字符替换每个小块来生成的。较亮的小块会被替换为稀疏的 ASCII 字符(即包含大量空白的字符),如句点(.)或冒号(:),而较暗的小块则会被替换为更密集的 ASCII 字符,如@或$。从远处看,由于我们的眼睛分辨率有限,我们在 ASCII 艺术中看到的是“*均”值,而失去了本来能使艺术作品看起来更真实的细节。

图 6-2:灰度图像的*均值
该程序将接受给定的图像,并首先将其转换为 8 位灰度图像,这样每个像素的灰度值将在[0, 255]范围内(8 位整数的范围)。可以把这个 8 位值看作是像素的亮度,其中 0 代表黑色,255 代表白色,介于两者之间的是灰度色调。
接下来,程序会将图像拆分成一个 M×N 的网格(其中 M 是 ASCII 图像中的行数,N 是列数)。然后,程序会计算每个网格块的*均亮度值,并通过预定义的色阶(一个递增的 ASCII 字符集合)来匹配合适的 ASCII 字符,表示灰度值范围[0, 255]。它将使用这些色阶值作为亮度值的查找表。
完成的 ASCII 艺术仅仅是一堆文本行。为了显示这些文本,你需要使用一个等宽字体(也叫做等距字体),例如 Courier,因为如果每个文本字符的宽度不相同,图像中的字符将无法沿着网格正确排列,最终输出将变得不均匀且混乱。
使用的纵横比(宽度与高度的比值)也会影响最终图像。如果字符所占空间的纵横比与字符替代的图像块的纵横比不同,最终的 ASCII 图像会显得扭曲。实际上,你是在尝试用 ASCII 字符替代图像块,所以它们的形状需要匹配。例如,如果你将图像拆分成正方形块,然后将每个块替换为字符比宽度更高的字体,最终输出的图像会显得垂直拉伸。为了解决这个问题,你需要调整网格中的行以匹配 Courier 字体的纵横比。(你可以通过命令行参数修改程序的缩放,以匹配其他字体。)
总结一下,程序生成 ASCII 图像的步骤如下:
-
1. 将输入图像转换为灰度图像。
-
2. 将图像拆分成 M×N 个小块。
-
3. 修正 M(行数),使其与图像和字体的纵横比匹配。
-
4. 计算每个图像块的*均亮度,然后为每个块查找一个合适的 ASCII 字符。
-
5. 拼接 ASCII 字符字符串的行,并将其打印到文件中,形成最终图像。
要求
在这个项目中,你将使用Pillow,Python 图像库的友好分支,来读取图像,访问它们的底层数据,并创建和修改它们。你还将使用numpy库来计算*均值。
代码
你将首先定义用于生成 ASCII 艺术的灰度级别。然后你会查看如何将图像拆分成小块,以及如何计算这些小块的*均亮度。接下来,你将替换小块中的内容,使用 ASCII 字符生成最终输出。最后,你将为程序设置命令行解析,允许用户指定输出大小、输出文件名等。
要查看完整的项目代码,请跳转至“完整代码”的第 109 页。你也可以从github.com/mkvenkit/pp2e/blob/main/ascii/ascii.py下载该项目的代码。
定义灰度级别和网格
创建程序的第一步是定义用于将图像亮度值转换为 ASCII 字符的灰度级别,将其设为全局值。
70 级灰度
gscale1 = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,"^`'. "
10 级灰度
gscale2 = "@%#*+=-:. "
值gscale1是一个 70 级灰度渐变,而gscale2是一个更简单的 10 级灰度渐变。这两个值都作为字符串存储,字符的范围从最暗到最亮。程序默认使用gscale2渐变,但你可以通过命令行选项来使用更细致的gscale1渐变。
注意:要了解更多关于字符如何表示为灰度值的信息,请参阅 Paul Bourke 的《灰度图像的字符表示》:paulbourke.net/dataformats/asciiart/。
现在你有了灰度渐变,你可以开始设置图像。以下代码使用Pillow打开图像并将其分割成网格:
# open the image and convert to grayscale
image = Image.open(fileName).convert("L")
# store the image dimensions
❶ W, H = image.size[0], image.size[1]
# compute the tile width
❷ w = W/cols
# compute the tile height based on the aspect ratio and scale of the font
❸ h = w/scale
# compute the number of rows to use in the final grid
❹ rows = int(H/h)
首先,Image.``open()打开输入图像文件,Image.``convert()将图像转换为灰度。"L"代表亮度,是图像亮度的度量。你存储输入图像的宽度和高度(以像素为单位) ❶。然后你计算一个小块的宽度,这个宽度是由用户指定的列数(cols)决定的 ❷。(如果用户没有在命令行中设置其他值,程序将使用 80 列作为默认值。)你使用浮点数而不是整数除法,以避免在计算小块尺寸时发生截断误差。
一旦你知道了一个小块的宽度,你就可以使用传入的垂直缩放因子scale ❸来计算它的高度。这样,每个小块将匹配你用来显示文本的字体的纵横比,从而确保最终图像不会变形。scale的值可以作为参数传递,或者默认为0.43,这个值在 Courier 字体中显示效果很好。在计算完每行的高度后,你可以计算网格中的行数 ❹。
计算*均亮度
接下来,你需要一种方法来计算灰度图像中某个瓦片的*均亮度。getAverageL()函数可以完成这个任务。
def getAverageL(image):
# get the image as a numpy array
❶ im = np.array(image)
# get the dimensions
w,h = im.shape
# get the average
❷ return np.average(im.reshape(w*h))
图像瓦片作为一个 PIL Image对象传递到函数中。你将图像转换成一个numpy数组❶,此时im变成了一个二维数组,包含了图像像素的亮度值。你存储数组的维度(宽度和高度),然后使用numpy.reshape()将二维数组转换成一个一维的扁*数组,其长度是原数组宽度和高度的乘积(w*h)。你将重塑后的数组传递给numpy.average(),该函数会对数组的值进行求和,并计算整个图像瓦片的*均亮度值❷。
从图像生成 ASCII 内容
程序的主要部分从图像生成 ASCII 内容:
# an ASCII image is a list of character strings
❶ aimg = []
# generate the list of tile dimensions
❷ for j in range(rows):
y1 = int(j*h)
y2 = int((j+1)*h)
# correct the last tile
if j == rows-1:
❸ y2 = H
# append an empty string
❹ aimg.append("")
❺ for i in range(cols):
# crop the image to fit the tile
x1 = int(i*w)
x2 = int((i+1)*w)
# correct the last tile
if i == cols-1:
x2 = W
# crop the image to extract the tile into another Image object
❻ img = image.crop((x1, y1, x2, y2))
# get the average luminance
❼ avg = int(getAverageL(img))
# look up the ASCII character for grayscale value (avg)
if moreLevels:
❽ gsval = gscale1[int((avg*69)/255)]
else:
❾ gsval = gscale2[int((avg*9)/255)]
# append the ASCII character to the string
❿ aimg[j] += gsval
在程序的这一部分,首先将 ASCII 图像存储为一个字符串列表,你将其初始化❶。接下来,你迭代图像瓦片的行❷,计算给定行中每个图像瓦片的上下 y 坐标,分别记为y1和y2。这些是浮点数计算,但在将它们传递给图像裁剪方法之前,你将其截断为整数。
接下来,因为将图像划分成瓦片时,只有当图像宽度是列数的整数倍时,才会创建大小相同的边缘瓦片,所以你通过将最后一行瓦片的底部 y 坐标设置为图像的实际高度(H)来修正它❸。这样做可以确保图像的底部边缘不会被截断。
你在 ASCII 图像列表中添加一个空字符串,以简洁的方式表示当前的图像行❹。接下来,你会填充这个字符串。实际上,你将这个字符串当作一个可以追加字符的字符列表。然后,你按列遍历图像中给定行的所有瓦片❺。你计算每个瓦片的左右 x 坐标,分别记为x1和x2。当你到达行中的最后一个瓦片时,你将右边的 x 坐标设置为图像的宽度(W),理由与之前修正 y 坐标的方式相同。
你现在已经计算出了(x1, y1) 和 (x2, y2),即当前图像块的左上角和右下角的坐标。你将这些坐标传递给 image.crop() 来从完整的图像中提取图像块 ❻。然后,你将该图像块(它是一个 PIL Image 对象)传递给 getAverageL() 函数 ❼,该函数在 “计算*均亮度” 的 第 105 页 中定义,用于获取该图像块的*均亮度。你将*均亮度值从 [0, 255] 范围缩放到 [0, 9],这是默认的 10 级灰度阶梯的值范围 ❾。然后,你使用 gscale2(存储的阶梯字符串)作为查找表,找到对应的 ASCII 字符。❽ 处的代码类似,但它将亮度值缩放到 70 级灰度阶梯的 [0, 69] 范围。此行代码仅在设置了 moreLevels 命令行标志时使用。最后,你将查找出的 ASCII 字符 gsval 添加到文本行 ❿,代码会循环直到所有行都处理完成。
创建命令行选项
接下来,为程序定义一些命令行选项。此代码使用内置的 argparse.ArgumentParser 类:
parser = argparse.ArgumentParser(description="descStr")
# add expected arguments
parser.add_argument('--file', dest='imgFile', required=True)
parser.add_argument('--scale', dest='scale', required=False)
parser.add_argument('--out', dest='outFile', required=False)
parser.add_argument('--cols', dest='cols', required=False)
parser.add_argument('--morelevels', dest='moreLevels', action='store_true')
你可以包含以下选项:
--file 指定要输入的图像文件。这是唯一必需的参数。
--scale 设置其他字体的垂直缩放因子,而不是 Courier 字体。
--out 设置生成的 ASCII 艺术的输出文件名。默认为 out.txt。
--cols 设置 ASCII 输出中的文本列数。
--morelevels 选择 70 级灰度阶梯,而不是默认的 10 级阶梯。
将 ASCII 艺术字符串写入文本文件
最后,获取生成的 ASCII 字符串列表,并将这些字符串写入文本文件:
# open a new text file
❶ f = open(outFile, 'w')
# write each string in the list to the new file
❷ for row in aimg:
f.write(row + '\n')
# clean up
❸ f.close()
你使用内置的 open() 函数打开一个新的文本文件进行写入 ❶。然后,你遍历 aimg 列表中的每个字符串,并将其写入文件 ❷。完成后,你关闭文件对象以释放系统资源 ❸。
运行 ASCII 艺术生成器
要运行你的完成程序,输入如下命令,将data/robot.jpg替换为你想使用的图片文件的相对路径:
$ `python ascii.py --file` `data/robot.jpg` `--cols 100`
图 6-3 显示了发送图像 robot.jpg(左侧)生成的 ASCII 艺术。试着添加 --morelevels 选项,看看 70 级灰度阶梯与 10 级灰度阶梯的对比。

图 6-3:ascii.py 的示例运行
现在,你已准备好创建自己的 ASCII 艺术了!
总结
在这个项目中,你学会了如何将任何输入图像转换为 ASCII 艺术。在这个过程中,你学会了如何将图像分割成瓦片网格,如何计算每个瓦片的*均亮度值,以及如何根据亮度值用字符替换每个瓦片。享受创作你自己的 ASCII 艺术吧!
实验!
这里有一些进一步探索 ASCII 艺术的想法:
-
1. 使用命令行选项
--scale 1.0运行程序。结果图像看起来如何?尝试不同的scale值。将输出复制到文本编辑器并尝试设置为不同的等宽字体,以查看这样做如何影响最终图像的外观。 -
2. 向程序添加命令行选项
--invert,以反转生成的 ASCII 图像,使黑色变为白色,反之亦然。(提示:尝试在查找时从 255 中减去瓦片亮度值。) -
3. 在这个项目中,你根据两个硬编码字符坡道创建了灰度值查找表。实现一个命令行选项,传递不同的字符坡道来创建 ASCII 艺术,像这样:
$ ``python ascii.py --map "@$%^`."``这应该使用给定的六字符坡道创建 ASCII 输出,其中
@映射到亮度值 0,.映射到亮度值 255。
完整代码
这是完整的 ASCII 艺术程序。
"""
ascii.py
一个将图像转换为 ASCII 艺术的 Python 程序。
作者:马赫什·文基塔查拉姆
"""
import sys, random, argparse
import numpy as np
import math
from PIL import Image
灰度级值来自:
http://paulbourke.net/dataformats/asciiart/
70 级灰度
gscale1 = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,"^`'. "
10 级灰度
gscale2 = '@%#*+=-:. '
def getAverageL(image):
"""
给定 PIL 图像,返回灰度值的*均值
"""
将图像转换为 numpy 数组
im = np.array(image)
获取形状
w,h = im.shape
获取*均值
return np.average(im.reshape(w*h))
def convertImageToAscii(fileName, cols, scale, moreLevels):
"""
给定图像和尺寸(行数,列数)返回一个 m*n 的图像列表
"""
声明全局变量
global gscale1, gscale2
打开图像并转换为灰度
image = Image.open(fileName).convert('L')
存储尺寸
W, H = image.size[0], image.size[1]
print("输入图像尺寸: {} x {}".format(W, H))
计算瓦片的宽度
w = W/cols
根据长宽比和缩放计算瓦片高度
h = w/scale
计算行数
rows = int(H/h)
print("列数: {}, 行数: {}".format(cols, rows))
print("瓦片尺寸: {} x {}".format(w, h))
检查图像尺寸是否过小
if cols > W or rows > H:
print("图片太小,无法满足指定的列数!")
exit(0)
一个 ASCII 图像是一个字符字符串的列表
aimg = []
生成尺寸列表
for j in range(rows):
y1 = int(j*h)
y2 = int((j+1)*h)
修正最后一个瓦片
if j == rows-1:
y2 = H
添加一个空字符串
aimg.append("")
for i in range(cols):
裁剪图像为瓦片
x1 = int(i*w)
x2 = int((i+1)*w)
修正最后一个块
if i == cols-1:
x2 = W
裁剪图像以提取块
img = image.crop((x1, y1, x2, y2))
获取*均亮度
avg = int(getAverageL(img))
查找 ASCII 字符
if moreLevels:
gsval = gscale1[int((avg*69)/255)]
else:
gsval = gscale2[int((avg*9)/255)]
将 ASCII 字符添加到字符串
aimg[j] += gsval
返回图像
return aimg
main() 函数
def main():
创建解析器
descStr = "该程序将图像转换为 ASCII 艺术。"
parser = argparse.ArgumentParser(description=descStr)
添加预期的参数
parser.add_argument('--file', dest='imgFile', required=True)
parser.add_argument('--scale', dest='scale', required=False)
parser.add_argument('--out', dest='outFile', required=False)
parser.add_argument('--cols', dest='cols', required=False)
parser.add_argument('--morelevels',dest='moreLevels',action='store_true')
解析参数
args = parser.parse_args()
imgFile = args.imgFile
设置输出文件
outFile = 'out.txt'
if args.outFile:
outFile = args.outFile
设置默认缩放比例为 0.43,适用于 Courier 字体
scale = 0.43
if args.scale:
scale = float(args.scale)
设置列数
cols = 80
if args.cols:
cols = int(args.cols)
print('生成 ASCII 艺术中...')
将图像转换为 ASCII 文本
aimg = convertImageToAscii(imgFile, cols, scale, args.moreLevels)
打开文件
f = open(outFile, 'w')
写入文件
for row in aimg:
f.write(row + '\n')
清理
f.close()
print("ASCII 艺术已写入 {}.".format(outFile))
调用 main
if name == 'main':
main()
第七章:# 照片马赛克

当我上六年级时,我看到了一张类似于图 7-1 中的图片,但当时并没完全搞懂那是什么。盯着它看了一会儿后,我终于弄明白了。(把书倒过来,从房间对面看。这个秘密我不告诉别人。)
这个谜题的巧妙之处在于人眼的工作方式。图中的低分辨率、块状图像在*距离下很难识别,但当从远处看时,你就能知道它代表什么,因为眼睛看到的细节更少,这让边缘变得*滑。
照片马赛克 是根据类似的原理制作的图像。你将一个 目标 图像拆分成一个矩形网格,然后用另一个匹配目标图像部分的较小图像替换每个矩形。当你从远处看照片马赛克时,你看到的只是目标图像,但如果你走*一点,真相就揭晓了:这幅图像实际上是由许多小图像组成的!

图 7-1:一张让人困惑的图片
在这个项目中,你将使用 Python 创建一个照片马赛克。你将把目标图像划分成一个网格,并用合适的图像替换网格中的每一个小块,从而创建出原图的照片马赛克。你可以指定网格的尺寸,并选择是否在马赛克中重复使用输入图像。
在这个项目中,你将学习如何完成以下任务:
-
• 使用 Python 图像库(PIL)创建图像。
-
• 计算图像的*均 RGB 值。
-
• 剪裁图像。
-
• 通过粘贴另一张图像来替换图像的一部分。
-
• 使用三维的*均距离度量来比较 RGB 值。
-
• 使用一种叫做 k-d 树 的数据结构来高效地找到与目标图像某部分最匹配的图像。
它是如何工作的
为了创建一个照片马赛克,首先使用一个块状的低分辨率版本的目标图像(因为在高分辨率图像中,瓦片图像的数量会太大)。用户输入马赛克的尺寸 M×N(其中 M 是行数,N 是列数)。接下来,根据以下方法构建马赛克:
-
1. 读取输入图像,这些图像将用于替换原图中的瓦片。
-
2. 读取目标图像并将其拆分成 M×N 的瓦片网格。
-
3. 对于每个瓦片,从输入图像中找到最佳匹配。
-
4. 通过将选定的输入图像排列成 M×N 网格来创建最终的马赛克。
拆分目标图像
我们将从如何将目标图像拆分成 M×N 的瓦片网格开始。请参照图 7-2 中的方案。

图 7-2:拆分目标图像
我们将原始图像划分为一个由N列和M行组成的瓦片网格,列沿 x 轴排列,行沿 y 轴排列。每个瓦片由一个索引(i, j)表示,宽度为w像素,高度为h像素。根据这个方案,原始图像的宽度为w × N像素,高度为h × M像素。
图 7-2 的右侧展示了如何计算从此网格中单个瓦片的像素坐标。索引为(i, j)的瓦片,其左上角坐标为(i × w, i × j),右下角坐标为((i + 1) × w, (j + 1) × h)。这些坐标可以与 PIL 一起使用,从原始图像中裁剪并创建瓦片。
*均颜色值
图像中的每个像素都有一个颜色,可以通过其红、绿、蓝值来数值表示。在这种情况下,你使用的是 8 位图像,因此每个颜色分量的 8 位值在[0, 255]范围内。因此,你可以通过计算图像所有像素的红、绿、蓝值的*均值来确定图像的*均颜色。给定一幅包含总共N个像素的图像,*均 RGB 的计算方法如下:

像单个像素的 RGB 一样,整幅图像的*均 RGB 是一个三元组,而不是一个标量或单一数值,因为*均值是分别对每个颜色分量计算的。你计算*均 RGB 值来将目标图像的瓦片与输入图像中的替代图像进行匹配。
匹配图像
对于目标图像中的每个瓦片,你需要从用户指定的输入文件夹中的图像中找到一个匹配的图像。为了判断两幅图像是否匹配,使用*均 RGB 值。最佳匹配是输入图像,其*均 RGB 值最接*目标图像瓦片的*均 RGB 值。
最简单的找到最佳匹配的方法是计算*均 RGB 值之间的距离,就像它们是 3D 空间中的点一样。毕竟,每个*均 RGB 由三个数字组成,你可以将其视为 x 轴、y 轴和 z 轴坐标。因此,你可以使用几何学中的以下公式来计算两个 3D 点之间的距离:

在这里,你计算点(r[1], g[1], b[1])和(r[2], g[2], b[2])之间的距离。给定一个目标*均 RGB 值(r[1], g[1], b[1]),你可以将输入图像中的*均 RGB 值列表代入先前的公式,作为(r[2], g[2], b[2])来找到最接*的匹配图像。然而,可能有成百上千张输入图像需要检查。因此,我们应该考虑如何高效地搜索输入图像集以找到最佳匹配。
使用线性搜索
搜索匹配项的最简单方法是 线性搜索。在这种方法中,你只需逐个遍历所有 RGB 值,并找到与查询值之间最小距离的那个值。代码看起来大致如下:
min_dist = MAX_VAL
for val in vals:
dist = distance(query, val)
if dist < MAX_VAL:
min_dist = dist
你依次遍历列表中的每个值 vals,并计算该值与 query 之间的距离。如果结果小于 min_dist(它初始化为两个点之间的最大可能距离),你就用刚刚计算的距离更新 min_dist。检查完 vals 中的每一项后,min_dist 将包含整个数据集中最小的距离。
虽然线性搜索方法易于理解和实现,但它并不是非常高效。如果 vals 列表中有 N 个值,搜索将需要与 N 成正比的时间。你可以通过使用不同的数据结构和搜索算法来获得更好的性能。
使用 k-d 树
k-d 树,或者叫做 k 维树,是一种数据结构,它将 k 维空间划分——也就是说,它将空间划分成多个不重叠的子空间。这个数据结构提供了一种方法,用于对数据集进行排序和搜索,数据集中的每个成员都是 k 维空间中的一个点。数据集被表示为一个 二叉树:数据集中的每个点都是树中的一个节点,每个节点可以有两个子节点。换句话说,树中的每个节点将空间划分成两部分,称为 子树。一部分指向节点的左侧(节点的左子节点及其后代),另一部分指向节点的右侧(节点的右子节点及其后代)。
树的每个节点都与空间的一个维度相关联,这个维度用于决定点是属于节点的左子树还是右子树。例如,如果一个节点与 x 轴相关联,那么 x 值小于该节点的 x 值的点将放入该节点的左子树,而 x 值大于该节点的 x 值的点将放入右子树。选择每个节点关联的维度的常见方法是,在向下遍历树的过程中按顺序循环这些维度。例如,在三维 k-d 树的情况下,你可以设置维度为 x、y、z、x、y、z,依此类推,随着树的深入。位于相同树高度的节点将具有相同的划分维度。
让我们看一个简单的 k-d 树的例子。假设你有以下点集,P:
P = {(5, 3), (2, 4), (1, 2), (6, 6), (7, 2), (4, 6), (2, 8)}
在这种情况下,你将构建一个二维的 k-d 树,因为 P 中的每个成员都描述了二维空间中的一个点。首先将第一个节点,或者称为 根 节点(5, 3),与 x 维度关联。然后将下一个点(2, 4)作为根节点的左子节点,因为该点的 x 坐标 2 小于根节点的 x 坐标 5。节点(2, 4)位于 k-d 树的第二层,将使用 y 维度进行划分。接下来列表中的点是(1, 2)。从根节点开始,1 < 5,因此你转到根节点的左子节点。然后使用 y 维度比较(1, 2)和(2, 4)。由于 2 < 4,你将(1, 2)作为(2, 4)的左子节点。
如果你按照这种方式处理 P 中的所有点,你将创建图 7-3 中所示的树和空间划分。

图 7-3:k-d 树的示例
图 7-3 的顶部图像展示了我们刚才讨论的树的空间划分方案。从点(5, 3)开始,通过在该点绘制一条垂直线,沿着 x 维度将空间分成两部分。接下来,使用点(2, 4)沿着 y 维度划分第一个分区的左半部分,在该点绘制一条水*线,直到线与垂直线相交。按照这种方式继续处理剩余的点,你将得到图中所示的划分方案。
为什么你要关心 k-d 树?答案是,一旦你按照这种方式排列数据集,你可以更快地搜索它。具体来说,最*邻搜索——找到离查询点最*的点——使用 k-d 树比线性搜索要快得多。对于一个包含 N 个值的数据集,k-d 树的*均最*邻搜索时间是与 log(N) 成正比的,而线性搜索的时间则与 N 成正比。
为了演示,我们来尝试找到离点 q(2, 3)最*的 P 中的点,该点显示在图 7-3 中。从图中可以看出,点(2, 4)是匹配点。最*邻算法通过从(5, 3)到(2, 4)遍历树来找到匹配点。算法知道,例如,根节点的右子树可以跳过,因为 q 的 x 坐标小于根节点的 x 坐标。因此,空间划分方案让你跳过比线性搜索更多的比较。这就是 k-d 树对我们问题有用的原因。
如何在照片马赛克代码中使用 k-d 树?你可以尝试从头开始实现,但还有一个更简单的选项:scipy 库已经内置了一个 k-d 树类。我们将在本章后面介绍如何利用这个类。
要求
在这个项目中,你将使用Pillow读取图像,访问它们的底层数据,并创建和修改图像。你还将使用numpy来操作图像数据,使用scipy通过 k-d 树查找图像数据。
代码部分
你将首先读取输入图像,这些图像将用于创建照片马赛克。接下来,你将计算图像的*均 RGB 值,将目标图像划分为网格,并找到最匹配每个网格块的图像。最后,你将拼接图像块,生成实际的照片马赛克。要查看完整的项目代码,请跳到“完整代码”在第 129 页。你也可以在github.com/mkvenkit/pp2e/tree/main/photomosaic找到代码。
读取输入图像
首先从给定的文件夹中读取输入图像。以下是操作步骤:
def getImages(imageDir):
"""
given a directory of images, return a list of Images
"""
❶ files = os.listdir(imageDir)
images = []
for file in files:
❷ filePath = os.path.abspath(os.path.join(imageDir, file))
try:
# explicit load so we don't run into resource crunch
❸ fp = open(filePath, "rb")
im = Image.open(fp)
images.append(im)
# force loading the image data from file
❹ im.load()
# close the file
❺ fp.close()
except:
# skip
print("Invalid image: %s" % (filePath,))
return images
你首先使用os.listdir()来收集imageDir目录中的文件名,并将其存储在一个名为files的列表中❶。接下来,你遍历列表中的每个文件并将其加载为一个 PIL Image对象。
你使用os.path.abspath()和os.path.join()来获取图像的完整文件名❷。这种习惯用法在 Python 中非常常见,可以确保你的代码既能处理相对路径(例如,\foo\bar*)和绝对路径(c:\foo\bar*),也能在不同的操作系统之间兼容,因不同操作系统的目录命名约定(Windows 中使用\,而 Linux 中使用/)。
为了将文件加载为 PIL Image对象,你可以将每个文件名传递给Image.open()方法,但如果你的照片马赛克文件夹中有数百或数千张图像,这样做会非常占用资源。相反,你可以使用 Python 打开每个图像文件,并将文件句柄fp传递给 PIL,使用Image.open()方法加载图像。加载完图像后,关闭文件句柄并释放系统资源。
你使用open()打开图像文件❸,然后将文件句柄传递给Image.open(),并将返回的图像im存储到名为images的列表中。调用Image.load()❹强制加载im中的图像数据,因为open()是一个懒加载操作。它仅识别图像,但在你实际使用图像之前并不会读取所有的图像数据。最后,你通过关闭文件句柄来释放系统资源❺。
计算图像的*均颜色值
一旦你读取了输入图像,你需要计算每张图像的*均颜色值。你还需要为目标图像的每个区域计算*均颜色值。创建一个getAverageRGB()函数来处理这两个任务。
def getAverageRGB(image):
"""
return the average color value as (r, g, b) for each input image
"""
# get each tile image as a numpy array
❶ im = np.array(image)
# get the shape of each input image
❷ w,h,d = im.shape
# get the average RGB value
❸ return tuple(np.average(im.reshape(w*h, d), axis=0))
该函数接受一个Image对象——它可以是输入图像之一,或者是目标图像的一个部分——并使用numpy将其转换为数据数组❶。结果的numpy数组的形状为(w, h, d),其中w是图像的宽度,h是高度,d是深度,在 RGB 图像的情况下,d的值为 3(分别对应 R、G 和 B)。你存储了shape元组❷,然后通过将数组重塑为更方便的形状(w*h, d)来计算*均 RGB 值,这样你就可以使用numpy.average()❸来计算*均值。(你在第六章中执行了类似的操作,以获取灰度图像的*均亮度。)你将结果作为一个元组返回。
将目标图像拆分为网格
现在你需要将目标图像拆分为一个M×N的小图像网格。让我们创建一个函数来实现这一点:
def splitImage(image, size):
"""
given the image and dimensions (rows, cols), return an m*n list of images
"""
❶ W, H = image.size[0], image.size[1]
❷ m, n = size
❸ w, h = int(W/n), int(H/m)
# image list
imgs = []
# generate a list of images
for j in range(m):
for i in range(n):
# append cropped image
❹ imgs.append(image.crop((i*w, j*h, (i+1)*w, (j+1)*h)))
return imgs
首先,你收集目标图像的尺寸❶和网格大小❷。然后,你使用基本的除法计算目标图像中每个瓷砖的尺寸❸。接下来,你需要遍历网格的尺寸,裁剪并存储每个瓷砖作为一个独立的图像。调用image.crop()❹可以使用图像的左上角和右下角坐标作为参数来裁剪图像的一部分(如“拆分目标图像”在第 115 页中讨论的)。最后,你会得到一系列图像——首先是网格中第一行的所有图像,从左到右;然后是第二行的所有图像;依此类推。
查找瓷砖的最佳匹配
现在让我们从输入图像的文件夹中找到最匹配的瓷砖。我们将探讨两种方法:使用线性搜索和使用 k-d 树。对于线性搜索方法,你创建了一个工具函数getBestMatchIndex(),如下所示:
def getBestMatchIndex(input_avg, avgs):
"""
return index of the best image match based on average RGB value distance
"""
# input image average
avg = input_avg
# get the closest RGB value to input, based on RGB distance
index = 0
❶ min_index = 0
❷ min_dist = float("inf")
❸ for val in avgs:
❹ dist = ((val[0] - avg[0])*(val[0] - avg[0]) +
(val[1] - avg[1])*(val[1] - avg[1]) +
(val[2] - avg[2])*(val[2] - avg[2]))
❺ if dist < min_dist:
min_dist = dist
min_index = index
index += 1
return min_index
你正在尝试搜索 avgs,一个包含输入图像的*均 RGB 值的列表,以找到与 input_avg(目标图像中某个瓷砖的*均 RGB 值)最接*的那个。首先,你将最接*的匹配项索引初始化为 0 ❶,并将最小距离初始化为无穷大 ❷。然后,你循环遍历*均值列表中的值 ❸,并开始使用 “匹配图像” 中的标准公式计算距离 ❹(你跳过计算*方根以减少计算时间)。如果计算出的距离小于存储的最小距离 min_dist,则用新的最小距离替换它 ❺。这个测试在第一次执行时总是会通过,因为任何距离都小于无穷大。在迭代结束时,min_index 是 avgs 列表中与 input_avg 最接*的*均 RGB 值的索引。现在,你可以使用这个索引从输入图像列表中选择匹配的图像。
现在,让我们使用 k-d 树而不是线性搜索来找到最佳匹配。以下是函数:
def getBestMatchIndicesKDT(qavgs, kdtree):
"""
return indices of best Image matches based on RGB value distance
uses a k-d tree
"""
# e.g., [array([2.]), array([9], dtype=int64)]
❶ res = list(kdtree.query(qavgs, k=1))
❷ min_indices = res[1]
return min_indices
getBestMatchIndicesKDT() 函数接受两个参数:qavgs 是目标图像中每个瓷砖的*均 RGB 值列表,kdtree 是使用输入图像的*均 RGB 值列表创建的 scipy KDTree 对象。(我们将在 “创建照片马赛克” 中的 第 124 页创建 KDTree 对象。)你使用 KDTree 对象的 query() 方法来获取树中与 qavgs 中点最接*的点 ❶。这里,k 参数是你想要返回的查询点的最*邻数量。你只需要最接*的匹配项,所以传入 k=1。query() 方法的返回值是一个包含两个 numpy 数组的元组,分别表示匹配项的距离和索引。你需要索引,因此从结果中选择第二个值 ❷。
请注意,query() 方法 ❶ 允许你传入一个查询点列表,而不仅仅是一个。这实际上比逐个查询结果要快,而且这意味着你只需要调用 getBestMatchIndicesKDT() 函数一次,而线性搜索的 getBestMatch() 函数需要为照片马赛克中的每个瓷砖调用多次。
完整的程序将包括一个选项,用于选择使用前两个函数中的哪一个:线性搜索版本或 k-d 树版本。它还将有一个计时器,用来测试哪种搜索方法更快。
创建图像网格
在继续创建照片马赛克之前,你还需要一个实用函数。createImageGrid() 函数将创建一个 M×N 大小的图像网格。这个图像网格是最终的照片马赛克图像,由选定的输入图像列表创建。
def createImageGrid(images, dims):
"""
given a list of images and a grid size (m, n), create a grid of images
"""
❶ m, n = dims
# sanity check
assert m*n == len(images)
# get the maximum height and width of the images
# don't assume they're all equal
❷ width = max([img.size[0] for img in images])
height = max([img.size[1] for img in images])
# create the target image
❸ grid_img = Image.new('RGB', (n*width, m*height))
# paste the tile images into the image grid
for index in range(len(images)):
❹ row = int(index/n)
❺ col = index - n*row
❻ grid_img.paste(images[index], (col*width, row*height))
return grid_img
该函数有两个参数:一个图像列表(基于与目标图像单个图像块最接*的 RGB 匹配所选择的输入图像),以及一个包含照片马赛克尺寸的元组(你希望它具有的行数和列数)。你先收集网格的尺寸 ❶,然后使用 assert 来检查传递给 createImageGrid() 的图像数量是否与网格大小匹配。(assert 方法用于检查代码中的假设,尤其是在开发和测试期间。)接下来,你计算所选图像的最大宽度和高度 ❷,因为它们的大小可能不完全相同。你将使用这些最大尺寸来设置照片马赛克的标准图像块大小。如果输入图像无法完全填充一个图像块,默认情况下,图像块之间的空白部分将显示为纯黑色。
接下来,你创建一个空的 Image,其大小足以容纳网格中的所有图像 ❸;你将把图像块粘贴到这个图像中。然后,通过循环遍历选择的图像,将它们粘贴到网格中的适当位置,使用 Image.paste() 方法 ❻。Image.paste() 的第一个参数是要粘贴的 Image 对象,第二个是左上角的坐标。现在你需要确定将输入图像粘贴到图像网格中的哪一行和哪一列。为此,你需要将图像索引表示为行和列的形式。图像网格中图像块的索引由 N × row + col 给出,其中 N 是图像网格的宽度,(row, col) 是网格中的坐标;在 ❹,你根据前面的公式确定行,接着在 ❺ 确定列。
创建照片马赛克
现在你已经拥有了所有必需的工具,让我们编写创建照片马赛克的主函数。以下是函数的开始部分:
def createPhotomosaic(target_image, input_images, grid_size,
reuse_images, use_kdt):
"""
creates photomosaic given target and input images
"""
print('splitting input image...')
# split target image
❶ target_images = splitImage(target_image, grid_size)
print('finding image matches...')
# for each target image, pick one from input
output_images = []
# for user feedback
count = 0
❷ batch_size = int(len(target_images)/10)
# calculate input image averages
avgs = []
❸ for img in input_images:
avgs.append(getAverageRGB(img))
# compute target averages
avgs_target = []
❹ for img in target_images:
# target subimage average
avgs_target.append(getAverageRGB(img))
createPhotomosaic() 函数接受目标图像、输入图像列表、生成的照片马赛克的大小(行数和列数)以及标志,指示图像是否可以重用,是否使用 k-d 树来搜索图像匹配。该函数首先调用 splitImage() ❶ 将目标图像拆分为一个小图像块的网格。图像拆分后,你就可以开始从输入文件夹中的图像中找到每个图像块的匹配项。然而,由于这个过程可能比较耗时,因此最好向用户提供反馈,让他们知道程序仍在运行。为此,你将 batch_size 设置为图像块总数的十分之一 ❷。选择十分之一是任意的,仅仅是为了让程序告诉用户“我还在运行”。每次程序处理完十分之一的图像时,它都会打印一条消息,表明程序仍在运行。
为了找到图像匹配,你需要计算*均 RGB 值。你遍历输入图像❸并使用getAverageRGB()函数计算每个图像的*均 RGB 值,将结果存储在avgs列表中。然后你对目标图像中的每个瓦片❹进行相同的操作,将*均 RGB 值存储到avgs_target列表中。
该函数通过if...else语句继续运行,使用 k-d 树或线性搜索来查找 RGB 匹配项。我们先来看if分支,当use_kdt标志被设置为True时,该分支会执行:
# use k-d tree for average match?
if use_kdt:
# create k-d tree
❶ kdtree = KDTree(avgs)
# query k-d tree
❷ match_indices = getBestMatchIndicesKDT(avgs_target, kdtree)
# process matches
❸ for match_index in match_indices:
❹ output_images.append(input_images[match_index])
你使用输入图像的*均 RGB 值列表❶创建一个KDTree对象,并通过将avgs_target和KDTree对象传递给getBestMatchIndicesKDT()辅助函数❷来检索最佳匹配的索引。然后,你遍历所有匹配的索引❸,找到对应的输入图像,并将它们添加到output_images列表❹。
接下来,让我们来看一下else分支,它执行线性搜索以寻找匹配项:
else:
# use linear search
❶ for avg in avgs_target:
# find match index
❷ match_index = getBestMatchIndex(avg, avgs)
❸ output_images.append(input_images[match_index])
# user feedback
❹ if count > 0 and batch_size > 10 and count % batch_size == 0:
print('processed %d of %d...' %(count, len(target_images)))
count += 1
# remove selected image from input if flag set
❺ if not reuse_images:
input_images.remove(match)
对于线性搜索,你开始遍历目标图像瓦片的*均 RGB 值❶。对于每个瓦片,你使用getBestMatchIndex()❷在输入图像的*均 RGB 值列表中搜索最接*的匹配项。结果会返回一个索引,你可以使用这个索引来检索Image对象并将其存储在output_images列表中❸。对于每处理完batch_size个图像❹,你会打印一条消息给用户。如果reuse_images标志被设置为False❺,你会从列表中删除已选中的输入图像,以确保它不会在另一个瓦片中被重用。(当你有大量输入图像可以选择时,这种方式效果最好。)
在createPhotomosaic()函数中剩下的就是将输入图像排列成最终的照片马赛克:
print('creating mosaic...')
# draw mosaic to image
❶ mosaic_image = createImageGrid(output_images, grid_size)
# return mosaic
return mosaic_image
你使用createImageGrid()函数构建照片马赛克❶。然后你将生成的图像作为mosaic_image返回。
编写 main()函数
程序的main()函数接收并解析命令行参数,加载所有图像并进行一些额外的设置。然后它调用createPhotomosaic()函数并保存生成的照片马赛克。随着照片马赛克的构建,Python 会记录过程的时间,从而让你比较 k-d 树与线性搜索的性能。
添加命令行选项
main()函数支持以下命令行选项:
# parse arguments
parser = argparse.ArgumentParser(description='Creates a photomosaic from
input images')
# add arguments
parser.add_argument('--target-image', dest='target_image', required=True)
parser.add_argument('--input-folder', dest='input_folder', required=True)
parser.add_argument('--grid-size', nargs=2, dest='grid_size',
required=True)
parser.add_argument('--output-file', dest='outfile', required=False)
parser.add_argument('--kdt', action='store_true', required=False)
这段代码包含三个必需的命令行参数:目标图像的名称、输入图像文件夹的名称和网格大小。第四个参数是可选的输出文件名。如果省略文件名,照片马赛克将写入名为mosaic.png的文件。第五个参数是一个布尔标志,它启用 k-d 树搜索,而不是线性搜索来匹配*均 RGB 值。
控制照片马赛克的大小
一旦所有图像加载完成,main()函数中需要解决的一个问题是最终光拼贴图的大小(以像素为单位)。如果你仅仅根据目标中的匹配瓦片盲目地将输入图像拼接在一起,最终可能得到一个比目标图像大得多的光拼贴图。为避免这种情况,可以调整输入图像的大小,使其与网格中每个瓦片的大小匹配。(这还可以加快 RGB 计算的速度,因为你将使用较小的图像。)以下是main()函数中处理此任务的部分代码:
print('resizing images...')
# for given grid size, compute the maximum width and height of tiles
❶ dims = (int(target_image.size[0]/grid_size[1]),
int(target_image.size[1]/grid_size[0]))
print("max tile dims: %s" % (dims,))
# resize
for img in input_images:
❷ img.thumbnail(dims)
你根据网格中指定的行数和列数计算目标尺寸❶;然后,使用 PIL 的Image.``thumbnail()方法调整输入图像的大小以适应这些尺寸❷。
性能计时
当程序运行时,你可能想知道它执行所需的时间。为此,可以使用 Python 的timeit模块。计算执行时间的方法如下:
import timeit
# start timing
❶ start = timeit.default_timer()
# run some code here...
--`snip`--
# stop timing
❷ stop = timeit.default_timer()
print('Execution time: %f seconds' % (stop - start, ))
使用timeit模块的默认计时器记录开始时间❶。然后,在运行某些代码后,记录停止时间❷。计算二者的差值即可得到以秒为单位的执行时间。
运行光拼贴图生成器
首先,让我们使用默认的线性搜索方法运行程序。光拼贴图将由 128×128 图像的网格组成:
$ `python photomosaic.py --target-image test-data/cherai.jpg --input-folder`
` test-data/set6/ --grid-size 128 128`
reading input folder...
starting photomosaic creation...
resizing images...
max tile dims: (23, 15)
splitting input image...
finding image matches...
processed 1638 of 16384...
processed 3276 of 16384...
processed 4914 of 16384...
processed 6552 of 16384...
processed 8190 of 16384...
processed 9828 of 16384...
processed 11466 of 16384...
processed 13104 of 16384...
processed 14742 of 16384...
processed 16380 of 16384...
creating mosaic...
saved output to mosaic.png
done.
Execution time: setup: 0.402047 seconds
❶ Execution time: creation: 2.123931 seconds
Execution time: total: 2.525978 seconds
图 7-4(a)显示了目标图像,图 7-4(b)显示了生成的光拼贴图。你可以在图 7-4(c)中看到光拼贴图的特写。正如你在输出中看到的,使用线性搜索,为光拼贴图中的每个 16,384 个瓦片找到最佳匹配需要大约 2.1 秒❶。这还不错,但我们可以做得更好。

图 7-4:光拼贴图生成器的一个示例运行
现在,使用--kdt选项运行相同的程序,该选项启用了使用 k-d 树进行图像匹配搜索。以下是结果:
$ `python photomosaic.py --target-image test-data/cherai.jpg --input-folder`
` test-data/set6/ --grid-size 128 128 --kdt`
reading input folder...
starting photomosaic creation...
resizing images...
max tile dims: (23, 15)
splitting input image...
finding image matches...
creating mosaic...
saved output to mosaic.png
done.
Execution time: setup: 0.410334 seconds
❶ Execution time: creation: 1.089237 seconds
Execution time: total: 1.499571 seconds
使用 k-d 树后,光拼贴图的创建时间从大约 2.1 秒降至不到 1.1 秒❶。这几乎是两倍的速度提升!
总结
在这个项目中,你学会了如何在给定目标图像和输入图像集合的情况下创建光拼贴图。从远处看,光拼贴图看起来像原始图像,但靠*时,你可以看到构成拼贴图的单个图像。你还学习了一种有趣的数据结构——k-d 树,它显著加速了为拼贴图中的每个瓦片找到最接*匹配的过程。
实验!
下面是一些进一步探索光拼贴图的方法:
-
1. 编写一个程序,创建任何图像的方块版本,类似于图 7-1。
-
2. 使用本章中的代码,你通过将匹配的图像无缝拼接在一起创建了光栅马赛克。更具艺术感的呈现方式可能会在每个图像块周围加入一个均匀的间隙。你将如何创建这个间隙?(提示:在计算最终图像尺寸以及在
createImageGrid()中粘贴图像时考虑间隙。)
完整代码
这是项目的完整代码:
"""
photomosaic.py
Creates a photomosaic given a target image and a folder of input images.
Author: Mahesh Venkitachalam
"""
import os, random, argparse
from PIL import Image
import numpy as np
from scipy.spatial import KDTree
import timeit
def getAverageRGBOld(image):
"""
given PIL Image, return average value of color as (r, g, b)
"""
# no. of pixels in image
npixels = image.size[0]*image.size[1]
# get colors as [(cnt1, (r1, g1, b1)), ...]
cols = image.getcolors(npixels)
# get [(c1*r1, c1*g1, c1*g2), ...]
sumRGB = [(x[0]*x[1][0], x[0]*x[1][1], x[0]*x[1][2]) for x in cols]
# calculate (sum(ci*ri)/np, sum(ci*gi)/np, sum(ci*bi)/np)
# the zip gives us [(c1*r1, c2*r2, ...), (c1*g1, c1*g2, ...), ...]
avg = tuple([int(sum(x)/npixels) for x in zip(*sumRGB)])
return avg
def getAverageRGB(image):
"""
given PIL Image, return average value of color as (r, g, b)
"""
# get image as numpy array
im = np.array(image)
# get shape
w,h,d = im.shape
# get average
return tuple(np.average(im.reshape(w*h, d), axis=0))
def splitImage(image, size):
"""
given Image and dims (rows, cols) returns an m*n list of Images
"""
W, H = image.size[0], image.size[1]
m, n = size
w, h = int(W/n), int(H/m)
# image list
imgs = []
# generate list of images
for j in range(m):
for i in range(n):
# append cropped image
imgs.append(image.crop((i*w, j*h, (i+1)*w, (j+1)*h)))
return imgs
def getImages(imageDir):
"""
given a directory of images, return a list of Images
"""
files = os.listdir(imageDir)
images = []
for file in files:
filePath = os.path.abspath(os.path.join(imageDir, file))
try:
# explicit load so we don't run into resource crunch
fp = open(filePath, "rb")
im = Image.open(fp)
images.append(im)
# force loading image data from file
im.load()
# close the file
fp.close()
except:
# skip
print("Invalid image: %s" % (filePath,))
return images
def getBestMatchIndex(input_avg, avgs):
"""
return index of best Image match based on RGB value distance
"""
# input image average
avg = input_avg
# get the closest RGB value to input, based on x/y/z distance
index = 0
min_index = 0
min_dist = float("inf")
for val in avgs:
dist = ((val[0] - avg[0])*(val[0] - avg[0]) +
(val[1] - avg[1])*(val[1] - avg[1]) +
(val[2] - avg[2])*(val[2] - avg[2]))
if dist < min_dist:
min_dist = dist
min_index = index
index += 1
return min_index
def getBestMatchIndicesKDT(qavgs, kdtree):
"""
return indices of best Image matches based on RGB value distance
using a k-d tree
"""
# e.g., [array([2.]), array([9], dtype=int64)]
res = list(kdtree.query(qavgs, k=1))
min_indices = res[1]
return min_indices
def createImageGrid(images, dims):
"""
given a list of images and a grid size (m, n), create
a grid of images
"""
m, n = dims
# sanity check
assert m*n == len(images)
# get max height and width of images
# i.e., not assuming they are all equal
width = max([img.size[0] for img in images])
height = max([img.size[1] for img in images])
# create output image
grid_img = Image.new('RGB', (n*width, m*height))
# paste images
for index in range(len(images)):
row = int(index/n)
col = index - n*row
grid_img.paste(images[index], (col*width, row*height))
return grid_img
def createPhotomosaic(target_image, input_images, grid_size,
reuse_images, use_kdt):
"""
creates photomosaic given target and input images
"""
print('splitting input image...')
# split target image
target_images = splitImage(target_image, grid_size)
print('finding image matches...')
# for each target image, pick one from input
output_images = []
# for user feedback
count = 0
batch_size = int(len(target_images)/10)
# calculate input image averages
avgs = []
for img in input_images:
avgs.append(getAverageRGB(img))
# compute target averages
avgs_target = []
for img in target_images:
# target subimage average
avgs_target.append(getAverageRGB(img))
# use k-d tree for average match?
if use_kdt:
# create k-d tree
kdtree = KDTree(avgs)
# query k-d tree
match_indices = getBestMatchIndicesKDT(avgs_target, kdtree)
# process matches
for match_index in match_indices:
output_images.append(input_images[match_index])
else:
# use linear search
for avg in avgs_target:
# find match index
match_index = getBestMatchIndex(avg, avgs)
output_images.append(input_images[match_index])
# user feedback
if count > 0 and batch_size > 10 and count % batch_size == 0:
print('processed {} of {}...'.format(count,
len(target_images)))
count += 1
# remove selected image from input if flag set
if not reuse_images:
input_images.remove(match)
print('creating mosaic...')
# draw mosaic to image
mosaic_image = createImageGrid(output_images, grid_size)
# return mosaic
return mosaic_image
# gather our code in a main() function
def main():
# command line args are in sys.argv[1], sys.argv[2]...
# sys.argv[0] is the script name itself and can be ignored
# parse arguments
parser = argparse.ArgumentParser(description='Creates a photomosaic
from input images')
# add arguments
parser.add_argument('--target-image', dest='target_image', required=True)
parser.add_argument('--input-folder', dest='input_folder', required=True)
parser.add_argument('--grid-size', nargs=2, dest='grid_size',
required=True)
parser.add_argument('--output-file', dest='outfile', required=False)
parser.add_argument('--kdt', action='store_true', required=False)
args = parser.parse_args()
# start timing
start = timeit.default_timer()
###### INPUTS ######
# target image
target_image = Image.open(args.target_image)
# input images
print('reading input folder...')
input_images = getImages(args.input_folder)
# check if any valid input images found
if input_images == []:
print('No input images found in %s. Exiting.' % (args.input_folder, ))
exit()
# shuffle list - to get a more varied output?
random.shuffle(input_images)
# size of grid
grid_size = (int(args.grid_size[0]), int(args.grid_size[1]))
# output
output_filename = 'mosaic.png'
if args.outfile:
output_filename = args.outfile
# reuse any image in input
reuse_images = True
# resize the input to fit original image size?
resize_input = True
# use k-d trees for matching
use_kdt = False
if args.kdt:
use_kdt = True
##### END INPUTS #####
print('starting photomosaic creation...')
# if images can't be reused, ensure m*n <= num_of_images
if not reuse_images:
if grid_size[0]*grid_size[1] > len(input_images):
print('grid size less than number of images')
exit()
# resizing input
if resize_input:
print('resizing images...')
# for given grid size, compute max dims w,h of tiles
dims = (int(target_image.size[0]/grid_size[1]),
int(target_image.size[1]/grid_size[0]))
print("max tile dims: %s" % (dims,))
# resize
for img in input_images:
img.thumbnail(dims)
# setup time
t1 = timeit.default_timer()
# create photomosaic
mosaic_image = createPhotomosaic(target_image, input_images, grid_size,
reuse_images, use_kdt)
# write out mosaic
mosaic_image.save(output_filename, 'PNG')
print("saved output to %s" % (output_filename,))
print('done.')
# creation time
t2 = timeit.default_timer()
print('Execution time: setup: %f seconds' % (t1 - start, ))
print('Execution time: creation: %f seconds' % (t2 - t1, ))
print('Execution time: total: %f seconds' % (t2 - start, ))
# standard boilerplate to call the main() function to begin
# the program.
if __name__ == '__main__':
main()
第八章:# 自动立体图

准备好凝视图 8-1 一分钟。你看到除了随机点以外的其他东西了吗?图 8-1 是一个自动立体图,它是一个二维图像,能创造出三维的幻觉。自动立体图通常由重复的图案组成,经过仔细观察后会变成三维。如果你看不到任何图像,不用担心;我自己也花了一些时间和实验才看到。(如果你在书中打印的版本看不清楚,试试书的 GitHub 仓库中images文件夹里的彩色版本。图注的脚注会告诉你应该看到什么。)
在本项目中,你将使用 Python 来创建自动立体图。以下是本项目中涉及的一些概念:
-
• 线性间距与深度感知
-
• 深度图
-
• 使用
Pillow创建和编辑图像 -
• 使用
Pillow在图像中绘制

图 8-1:一个可能让你头疼的难题 1
你将在本项目中生成的自动立体图是为“斜视”观看设计的。查看这些图像的最佳方法是将眼睛聚焦于图像后面的一个点(例如墙壁)。几乎神奇的是,一旦你在图案中感知到某些东西,你的眼睛应该会自动将其聚焦,当三维图像“锁定”后,你将很难把它移除。(如果你仍然难以查看图像,可以参考 Gene Levin 的文章《如何观看立体图和观看练习》2 寻求帮助。)
工作原理
自动立体图从一个具有重复*铺图案的图像开始。通过改变重复图案之间的线性间距,将隐藏的 3D 图像嵌入其中,从而创造出深度的幻觉。当你在自动立体图中查看重复的图案时,大脑可以将这些间距解读为深度信息,特别是当有多个不同间距的图案时。
在自动立体图中感知深度
当你的眼睛聚焦于图像后面的一个假想点时,大脑会将左眼看到的点与右眼看到的不同组点进行匹配,从而使你看到这些点位于图像后的*面上。感知到的*面距离取决于图案中的间距。例如,图 8-2 显示了三行A。这些A在每一行内的间距是相等的,但它们的水*间距从上到下逐渐增大。

图 8-2:线性间距与深度感知
当以“交叉眼”的方式查看此图像时,图 8-2 中的最上面一行应该看起来位于纸张的后面,中间一行应该显得稍微在第一行的后面,最下面一行应该看起来离你的眼睛最远。标有浮动文本的文字应该看起来“漂浮”在这些行的上方。
为什么你的大脑将这些图案之间的间距解释为深度?通常,当你看远处的物体时,你的眼睛会一起工作,对焦并向同一点汇聚,两只眼睛会向内旋转,直接指向物体。但当你查看“交叉眼”自动立体图时,焦点和汇聚发生在不同的位置。你的眼睛对焦于自动立体图,但大脑将重复的图案视为来自同一个虚拟(假想的)物体,而你的眼睛则汇聚在图像的后方,如图 8-3 所示。这种焦点与汇聚解耦的组合使你能够在自动立体图中看到深度。

图 8-3:在自动立体图中看到深度
自动立体图的感知深度取决于像素的水*间距。由于图 8-2 中的第一行间距最小,它看起来位于其他行的前面。然而,如果图像中的点间距有所变化,大脑会感知每个点的不同深度,并且你可以看到一个虚拟的三维图像显现出来。
与深度图一起工作
自动立体图中的隐藏图像来自深度图,它是一种图像,其中每个像素的值表示深度值,即从眼睛到该像素所代表的物体部分的距离。深度图通常以灰度图像显示,靠*的点为亮色,远处的点为暗色,如图 8-4 所示。

图 8-4:深度图
注意到鲨鱼的鼻子,图像中最亮的部分,看起来离你最*。朝尾部延伸的较暗区域似乎最远。(顺便提一下,图 8-4 中的图像与用于创建图 8-1 中展示的第一个自动立体图的深度图相同。)
由于深度图表示的是从每个像素中心到眼睛的深度或距离,因此你可以用它来获取与图像中某个像素位置相关的深度值。你知道,水*位移在图像中被感知为深度。因此,如果你根据相应像素的深度值按比例移动图像中的某个像素,你将为该像素创建与深度图一致的深度感知。如果你对所有像素执行此操作,你将把整个深度图编码到图像中,从而创建出自动立体图。
深度图存储每个像素的深度值,值的分辨率取决于用于表示它的位数。由于本章将使用常见的 8 位图像,深度值将位于 [0, 255] 范围内。
为了本项目的目的,我已经将几个示例深度图上传到本书的 GitHub 仓库。你可以下载这些图像,并将它们作为生成自动立体图的输入。不过,你也可以尝试自己制作深度图,创造一些更有创意的图像。有两种方法可以选择:使用 3D 建模软件创建的合成图像,或使用智能手机相机拍摄的照片。
从 3D 模型创建深度图
如果你使用像 Blender 这样的 3D 计算机图形程序创建了某个物体的 3D 模型,你也可以使用该程序生成该模型的深度图。图 8-5 展示了一个示例。

(a)

(b)
图 8-5:3D 模型(a)及其对应的深度图(b)
图 8-5(a)展示了使用 Blender 渲染的 3D 模型,图 8-5(b)展示了从该模型创建的深度图。在 YouTube 上搜索“Blender 深度图 5 分钟教程!”可以找到 Jonty Schmidt 的教程 3,介绍如何做到这一点。关键是根据从相机的 Z 距离为图像着色。
从智能手机照片创建深度图
如今,许多智能手机相机都有 人像模式,它不仅拍摄照片,还记录深度信息,以便选择性地模糊背景。如果你能获取到这些深度数据,你将得到该照片的深度图,进而可以用来创建自动立体图!图 8-6 展示了一个示例。

(a)

(b)
图 8-6:iPhone 11 相机拍摄的人像模式照片(a)和深度图(b)
图 8-6(a)展示了使用 iPhone 11 拍摄的竖屏照片,图 8-6(b)展示了相应的深度图。深度图是通过开源软件 ExifTool 和以下命令创建的:
exiftool -b -MPImage2 photo.jpg > depth.jpg
该命令从文件 photo.jpg 的元数据中提取深度信息,并将其保存到文件 depth.jpg 中。可以从exiftool.org下载 ExifTool,自己尝试这个过程。该命令适用于 iPhone 的照片,但你也可以使用类似的技术从其他类型的手机拍摄的图像中提取深度数据。还有一些应用程序可在 Android 和 iOS 应用商店中找到,可以帮助你完成此操作。这里有一个在线深度图提取器,适用于多种手机型号拍摄的肖像模式图像:www.hasaranga.com/dmap。
*移像素
我们已经了解了大脑如何将图像中重复元素之间的间距视为深度信息,并且我们已经看到深度信息是如何通过深度图传递的。现在让我们看看如何根据深度图中的值来*移瓷砖图像中的像素。这是创建自立体图像的关键步骤。
瓷砖图像是通过在 x 轴和 y 轴方向上重复一个较小的图像(即瓷砖)来创建的,但对于深度感知,我们只关心 x 轴方向。如果构成图像的瓷砖宽度为 w 像素,你知道图像的像素颜色值会在 x 轴的每一行中每隔 w 像素重复一次。换句话说,某一行中位于 x 轴上 i 点的像素的颜色可以表示为:
C[i] = C[i − w] 对于 i ≥ w
让我们考虑一个例子。给定一个 100 像素宽度的瓷砖,对于一个 x 轴位置为 140 的像素,方程式告诉你 C[140] = C[140 − 100] = C[40]。这意味着 x 位置为 140 的像素的颜色值与 x 位置为 40 的像素相同,因为图像是重复的。(对于前面公式中小于w的i值,颜色就是 C[i],因为瓷砖尚未重复。)
目标是根据深度图中的值来*移瓷砖图像中的像素。令 δ[i] 为深度图中 x 位置 i 的值。相应像素在瓷砖图像中的*移后的颜色值由以下公式给出:

返回到之前的例子,假设瓷砖宽度为 100 像素,给定一个 x 位置为 140 的像素以及一个对应的深度图值为 10,公式表示 C[140] = C[140 − 100 + 10] = C[50]。由于深度图,位置为 140 的像素的颜色应当改变为与位置 50 的像素颜色一致。由于 C[50] 与 C[150] 相同,这实际上是将 x 位置为 150 的像素向左移动了 10 个像素。结果,位置 50 和 150 之间的重复变得 10 像素更窄,而你的大脑会将这一变化感知为深度信息。
为了创建完整的自视立体图,你将沿着图像的宽度和所有行重复这一移动过程。你可以在查看代码时看到如何实现这种移动。
要求
在本项目中,你将使用Pillow读取图像,访问它们的底层数据,并创建和修改图像。
代码
本项目的代码将按照以下步骤创建自视立体图:
-
1. 读取深度图。
-
2. 读取图块图像或创建一个“随机点”图块。这将作为自视立体图的重复模式基础。
-
3. 通过重复拼接图块创建一个新图像。该图像的尺寸应与深度图相匹配。
-
4. 对于新图像中的每个像素,根据深度图中对应像素的深度值,按比例移动该像素。
-
5. 将生成的自视立体图写入文件。
若要查看完整项目,请跳到“完整代码”部分,见第 147 页。你还可以从github.com/mkvenkit/pp2e/tree/main/autos下载本章的完整代码。
从随机圆形创建图块
用户可以选择在程序开始时提供一个图块图像(我已将一幅基于 M.C. Escher 画作的图像上传到 GitHub 以供使用)。如果没有提供,将使用createRandomTile()函数创建一个随机圆点图块。
def createRandomTile(dims):
# create image
❶ img = Image.new('RGB', dims)
❷ draw = ImageDraw.Draw(img)
# set the radius of a random circle to 1% of
# width or height, whichever is smaller
❸ r = int(min(*dims)/100)
# number of circles
❹ n = 1000
# draw random circles
for i in range(n):
# -r makes sure that the circles stay inside and aren't cut off
# at the edges of the image so that they'll look better when tiled
❺ x, y = random.randint(r, dims[0]-r), random.randint(r, dims[1]-r)
❻ fill = (random.randint(0, 255), random.randint(0, 255),
random.randint(0, 255))
❼ draw.ellipse((x-r, y-r, x+r, y+r), fill)
return img
首先,你创建一个新的 Python 图像库(PIL)Image对象,其尺寸由dims ❶给出。然后使用ImageDraw.Draw() ❷在图像中绘制圆形,半径(r)任意选择为图像宽度或高度的 1/100,取较小值 ❸。(Python 的*操作符解包dims元组中的宽度和高度值,以便将其传递给min()方法。)
你设置绘制的圆形数量为1000 ❹,然后通过调用random.randint()获取随机整数,计算每个圆形的中心点的 x 和 y 坐标,范围为[r, width-r]和[r, height-r] ❺。通过将范围偏移r,可以确保生成的圆形完全落在图块的边界内。如果不这样做,圆形可能会画到图像的边缘,导致部分被裁剪。如果你用这样的图像拼接生成自视立体图,结果看起来不好,因为两个图块之间的边缘处,圆形没有间隔。
接下来,你为每个圆选择一个填充颜色,方法是从 [0, 255] 范围内随机选择 RGB 值❻。最后,你使用 draw 中的 ellipse() 方法绘制每个圆❼。此方法的第一个参数是一个元组,定义了圆的边界框,由左上角和右下角的坐标 (x-r, y-r) 和 (x+r, y+r) 给出,其中 (x, y) 是圆心,r 是圆的半径。另一个参数是随机选择的填充颜色。
你可以在 Python 解释器中按如下方式测试此方法:
>>> `import autos`
>>> `img = autos.createRandomTile((256, 256))`
>>> `img.save('out.png')`
>>> `exit()`
图 8-7 展示了测试的输出结果。

图 8-7:createRandomTile() 的示例运行
如你在图 8-7 中所见,你已经创建了一个带有随机点的图像,可以将其用作自动立体图的*铺图案。
重复给定的*铺图案
既然你已经有了一个*铺图案,可以通过重复该图案来创建图像。这将成为你的自动立体图的基础。定义一个createTiledImage()函数来完成这项工作。
def createTiledImage(tile, dims):
# create the new image
❶ img = Image.new('RGB', dims)
W, H = dims
w, h = tile.size
# calculate the number of tiles needed
❷ cols = int(W/w) + 1
❸ rows = int(H/h) + 1
# paste the tiles into the image
for i in range(rows):
for j in range(cols):
❹ img.paste(tile, (j*w, i*h))
# output the image
return img
该函数接收一张将作为*铺图案的图像(tile),以及输出图像的期望尺寸(dims)。尺寸以元组的形式给出,格式为(width,height)。你使用提供的尺寸创建一个新的Image对象❶。接下来,你存储了单个*铺图案和整个图像的宽度和高度。将整个图像的尺寸除以*铺图案的尺寸可以得到所需的列数❷和行数❸。你需要对每个计算结果加 1,以确保当输出图像尺寸不是*铺图案尺寸的整数倍时,右侧的最后一列和底部的最后一行不会丢失。没有这一预防措施,图像的右边和底部可能会被裁剪。最后,你通过行和列循环,将其填充为*铺图案❹。你通过将 (j*w, i*h) 相乘来确定*铺图案左上角的位置,这样它就能与行列对齐,就像你在摄影马赛克项目中做的那样。完成后,函数返回一个指定尺寸的Image对象,该对象已用输入图像tile进行*铺。
创建自动立体图
现在,让我们创建一些自动立体图。createAutostereogram() 函数完成大部分工作。代码如下:
def createAutostereogram(dmap, tile):
# convert the depth map to a single channel if needed
❶ if dmap.mode != 'L':
dmap = dmap.convert('L')
# if no image is specified for a tile, create a random circles tile
❷ if not tile:
tile = createRandomTile((100, 100))
# create an image by tiling
❸ img = createTiledImage(tile, dmap.size)
# create a shifted image using depth map values
❹ sImg = img.copy()
# get access to image pixels by loading the Image object first
❺ pixD = dmap.load()
pixS = sImg.load()
# shift pixels horizontally based on depth map
❻ cols, rows = sImg.size
for j in range(rows):
for i in range(cols):
❼ xshift = pixD[i, j]/10
❽ xpos = i - tile.size[0] + xshift
❾ if xpos > 0 and xpos < cols:
❿ pixS[i, j] = pixS[xpos, j]
# display the shifted image
return sImg
首先,你将提供的深度图(dmap)转换为单通道灰度图像(如有需要) ❶。如果用户没有提供*铺图像,你将使用之前定义的 createRandomTile() 函数创建一个随机圆形的*铺 ❷。接下来,你使用 createTiledImage() 函数创建一个与提供的深度图像大小匹配的*铺图像 ❸。然后,你复制该*铺图像 ❹。此副本将成为最终的自动立体图。
函数接下来使用 Image.load() 方法加载深度图和输出图像 ❺。该方法将图像数据加载到内存中,使你可以将图像的像素作为二维数组 [i, j] 来访问。你将图像尺寸存储为行数和列数 ❻,将图像视为一个像素网格。
自动立体图生成算法的核心在于根据深度图信息调整*铺图像中的像素。为此,你需要遍历*铺图像并处理每个像素。首先,你查找深度图中对应像素的值,并将该值除以 10,以确定*铺图像的位移值 ❼。除以 10 是因为这里使用的是 8 位深度图,这意味着深度值的范围是从 0 到 255。如果将这些值除以 10,你将得到大约在 0 到 25 之间的深度值。由于深度图输入图像的尺寸通常是数百像素,这些位移值是可行的。(你可以通过改变除数来实验,看看它如何影响最终图像。)
接下来,你使用在 “像素位移” 中讨论的公式,在 第 140 页 计算 x 轴坐标,以寻找像素的新颜色值 ❽。深度图值为 0(黑色)的像素将不会被位移,并将被视为背景。检查确保你没有试图访问图像中不存在的像素(因为位移可能会发生在图像的边缘) ❾,然后将每个像素替换为其位移后的值 ❿。
提供命令行选项
程序的 main() 函数提供了一些命令行选项,用于自定义自动立体图。
def main():
# create a parser
parser = argparse.ArgumentParser(description="Autostereograms...")
# add expected arguments
❶ parser.add_argument('--depth', dest='dmFile', required=True)
parser.add_argument('--tile', dest='tileFile', required=False)
parser.add_argument('--out', dest='outFile', required=False)
# parse args
args = parser.parse_args()
# set the output file
outFile = 'as.png'
if args.outFile:
outFile = args.outFile
# set tile
tileFile = False
if args.tileFile:
tileFile = Image.open(args.tileFile)
与之前的项目一样,你使用 argparse 来定义程序的命令行选项。唯一必需的参数是深度图文件的名称 ❶。还有两个可选参数,一个用于提供作为*铺模式的图像文件,另一个用于设置输出文件的名称。如果未指定*铺图像,程序将生成一个随机圆形*铺。如果未指定输出文件名,自动立体图将写入名为 as.png 的文件。
运行自动立体图生成器
现在让我们使用一个凳子的深度图(stool-depth.png),它在这个项目的 GitHub 仓库中的 data 文件夹里,来运行程序:
$ `python autos.py --depth data/stool-depth.png`
图 8-8 显示了左侧的深度图像和右侧生成的自动立体图。因为你没有为图块提供图像,所以这个自动立体图是通过随机图块生成的。

图 8-8:autos.py 的一次示例运行
现在让我们提供一个图块图像作为输入。使用之前提到的 stool-depth.png 深度图,但这次为图块提供图像 escher-tile.jpg。
$ `python autos.py --depth data/stool-depth.png –-tile data/escher-tile.jpg`
图 8-9 显示了输出结果。

图 8-9:使用图块的 autos.py 示例运行
享受使用我在 GitHub 上提供的图像或你自己的深度图来创建莫尔纹自动立体图吧!
总结
在这个项目中,你学习了如何创建自动立体图。从深度图像开始,你现在可以创建一个随机点的自动立体图,或者创建一个用你提供的图像拼贴的自动立体图。
实验!
下面是一些进一步探索自动立体图的方法:
-
- 编写代码创建一个类似于图 8-2 的图像,演示图像中线性间距的变化如何产生深度的错觉。(提示:使用图像拼贴和
Image.paste()方法。)
- 编写代码创建一个类似于图 8-2 的图像,演示图像中线性间距的变化如何产生深度的错觉。(提示:使用图像拼贴和
-
- 向程序添加一个命令行选项,指定要应用于深度图值的缩放比例。(记住,代码会将深度图值除以 10。)改变这个值会如何影响自动立体图?
完整代码
这是完整的自动立体图项目代码:
"""
autos.py
A program to create autostereograms.
Author: Mahesh Venkitachalam
"""
import sys, random, argparse
from PIL import Image, ImageDraw
# create spacing/depth example
def createSpacingDepthExample():
tiles = [Image.open('test/a.png'), Image.open('test/b.png'),
Image.open('test/c.png')]
img = Image.new('RGB', (600, 400), (0, 0, 0))
spacing = [10, 20, 40]
for j, tile in enumerate(tiles):
for i in range(8):
img.paste(tile, (10 + i*(100 + j*10), 10 + j*100))
img.save('sdepth.png')
# create image filled with random dots
def createRandomTile(dims):
# create image
img = Image.new('RGB', dims)
draw = ImageDraw.Draw(img)
# calculate radius - % of min dimension
r = int(min(*dims)/100)
# number of dots
n = 1000
# draw random circles
for i in range(n):
# -r is used so circle stays inside - cleaner for tiling
x, y = random.randint(r, dims[0]-r), random.randint(r, dims[1]-r)
fill = (random.randint(0, 255), random.randint(0, 255),
random.randint(0, 255))
draw.ellipse((x-r, y-r, x+r, y+r), fill)
# return image
return img
# create a larger image of size dims by tiling the given image
def createTiledImage(tile, dims):
# create output image
img = Image.new('RGB', dims)
W, H = dims
w, h = tile.size
# calculate # of tiles needed
cols = int(W/w) + 1
rows = int(H/h) + 1
# paste tiles
for i in range(rows):
for j in range(cols):
img.paste(tile, (j*w, i*h))
# output image
return img
# create a depth map for testing:
def createDepthMap(dims):
dmap = Image.new('L', dims)
dmap.paste(10, (200, 25, 300, 125))
dmap.paste(30, (200, 150, 300, 250))
dmap.paste(20, (200, 275, 300, 375))
return dmap
# given a depth map (image) and an input image, create a new image
# with pixels shifted according to depth
def createDepthShiftedImage(dmap, img):
# size check
assert dmap.size == img.size
# create shifted image
sImg = img.copy()
# get pixel access
pixD = dmap.load()
pixS = sImg.load()
# shift pixels output based on depth map
cols, rows = sImg.size
for j in range(rows):
for i in range(cols):
xshift = pixD[i, j]/10
xpos = i - 140 + xshift
if xpos > 0 and xpos < cols:
pixS[i, j] = pixS[xpos, j]
# return shifted image
return sImg
# given a depth map (image) and an input image, create a new image
# with pixels shifted according to depth
def createAutostereogram(dmap, tile):
# convert depth map to single channel if needed
if dmap.mode != 'L':
dmap = dmap.convert('L')
# if no tile specified, use random image
if not tile:
tile = createRandomTile((100, 100))
# create an image by tiling
img = createTiledImage(tile, dmap.size)
# create shifted image
sImg = img.copy()
# get pixel access
pixD = dmap.load()
pixS = sImg.load()
# shift pixels output based on depth map
cols, rows = sImg.size
for j in range(rows):
for i in range(cols):
xshift = pixD[i, j]/10
xpos = i - tile.size[0] + xshift
if xpos > 0 and xpos < cols:
pixS[i, j] = pixS[xpos, j]
# return shifted image
return sImg
# main() function
def main():
# use sys.argv if needed
print('creating autostereogram...')
# create parser
parser = argparse.ArgumentParser(description="Autostereograms...")
# add expected arguments
parser.add_argument('--depth', dest='dmFile', required=True)
parser.add_argument('--tile', dest='tileFile', required=False)
parser.add_argument('--out', dest='outFile', required=False)
# parse args
args = parser.parse_args()
# set output file
outFile = 'as.png'
if args.outFile:
outFile = args.outFile
# set tile
tileFile = False
if args.tileFile:
tileFile = Image.open(args.tileFile)
# open depth map
dmImg = Image.open(args.dmFile)
# create stereogram
asImg = createAutostereogram(dmImg, tileFile)
# write output
asImg.save(outFile)
# call main
if __name__ == '__main__':
main()
1 隐藏的图像是鲨鱼。
2 colorstereo.com/texts_.txt/practice.htm
3 www.youtube.com/watch?v=oqpDqKpOChE
第四部分:# 进入三维空间
在一维空间中,移动的点不是产生了一个具有两个端点的线段吗?
在二维空间中,移动的线段不是产生了一个具有四个端点的正方形吗?
在三维空间中,移动的正方形不是产生了——我的眼睛不是看到了它——
那位受祝福的存在,一个立方体,有八个端点?
—爱德温·A·阿博特,《*面国:多维之恋》
第九章:# 理解 OpenGL

在这个项目中,你将创建一个简单的程序,使用 OpenGL 和 GLFW 显示一个纹理映射的方形。OpenGL 是你与图形处理单元(GPU)之间的软件接口,而 GLFW 是一个窗口工具包。你还将学习如何使用类似 C 语言的 OpenGL 着色语言(GLSL)来编写 着色器——在 GPU 上执行的代码。着色器为 OpenGL 中的计算带来了巨大的灵活性。我将向你展示如何使用 GLSL 着色器来变换和上色几何体,同时创建一个旋转的、带纹理的多边形(如图 9-1 所示)。
GPU 被优化为在大量数据上反复执行相同的操作,并行处理,这使得它们在渲染计算机图形时比中央处理单元(CPU)更快。此外,GPU 还被用于通用计算,专门的编程语言现在允许你将 GPU 硬件应用于各种各样的应用程序。在本项目中,你将利用 GPU、OpenGL 和着色器。

图 9-1:本章项目的最终图像——一个旋转的多边形,带有星形图像。这个方形多边形边界使用着色器被裁剪成黑色圆形。
Python 是一种出色的“胶水”语言。有大量的 Python 绑定 可用于其他语言编写的库,例如 C,使你能够在 Python 中使用这些库。在本章以及第十章和第十一章中,你将使用 PyOpenGL,它是 OpenGL 的 Python 绑定,用于创建计算机图形。
下面是本项目中介绍的一些概念:
-
• 使用 GLFW 窗口库进行 OpenGL 编程
-
• 使用 GLSL 编写顶点着色器和片段着色器
-
• 执行纹理映射
-
• 使用 3D 变换
首先,让我们来看看 OpenGL 是如何工作的。
注意:OpenGL 在几年前经历了重大变革。它从使用固定功能的图形管线转变为使用具有专用着色语言的可编程管线。我们称之为 现代 OpenGL,而这就是本书中使用的版本。具体来说,我们将使用 OpenGL 版本 4.1。
OpenGL 工作原理
现代 OpenGL 通过一系列操作将图形呈现在屏幕上,这些操作通常被称为 3D 图形管线。图 9-2 显示了 OpenGL 3D 图形管线的简化表示。

图 9-2:简化的 OpenGL 图形管线
从本质上讲,计算机图形学归结为计算屏幕上像素的颜色值。假设你想让一个三角形出现。在管线的第一步,你通过定义三角形的 3D 顶点并指定与每个顶点相关的颜色来定义 3D 几何形状。这些顶点和颜色会保存在一种叫做顶点缓冲对象(VBOs)的数据结构中。接下来,你会对顶点进行变换:第一步变换将顶点置于 3D 空间中,第二步将 3D 坐标投影到 2D 空间,以便在 2D 屏幕上显示。此步骤中还会根据照明等因素计算相应顶点的颜色值,通常在名为顶点着色器的代码中进行计算。
接下来,几何图形会被光栅化(从 3D 表示转换为 2D 像素),对于每个像素(或者更准确地说是片段),会执行另一段代码,叫做片段着色器。就像顶点着色器操作 3D 顶点一样,片段着色器则作用于光栅化后的 2D 片段。我使用片段而不是像素,因为像素是显示在屏幕上的内容,而片段是片段着色器计算的输出,根据管线中的下一步,片段可能在成为屏幕上的像素之前被丢弃。
最后,每个片段会经过一系列帧缓冲操作,在这些操作中,它会进行深度缓冲测试(检查一个片段是否遮挡了另一个片段)、混合(将两个具有透明度的片段混合)以及其他将其当前颜色与帧缓冲区中该位置已有的颜色相结合的操作。这些更改最终会出现在最终的帧缓冲区中,通常会显示在屏幕上。
几何图元
由于 OpenGL 是一个低级图形库,你不能直接要求它绘制一个立方体或球体,尽管建立在 OpenGL 基础上的库可以为你完成这些任务。OpenGL 只理解低级的几何图元,如点、线和三角形。
现代 OpenGL 仅支持以下基本图元类型:GL_POINTS、GL_LINES、GL_LINE_STRIP、GL_LINE_LOOP、GL_TRIANGLES、GL_TRIANGLE_STRIP 和 GL_TRIANGLE_FAN。图 9-3 显示了这些图元的顶点是如何组织的。每个顶点都有一个像 (x, y, z) 这样的 3D 坐标。

图 9-3:OpenGL 基本图元
在 OpenGL 中绘制一个球体,首先需要通过数学定义球体的几何形状并计算其 3D 顶点。然后将这些顶点组装成基本的几何图元;例如,可以将每组三个顶点组成一个三角形。接下来使用 OpenGL 渲染这些顶点。
3D 变换
你不学习 3D 变换就无法学习计算机图形学。从概念上讲,这些是相当简单的理解。你有一个对象——你可以对它做什么?你可以移动它,拉伸(或压缩)它,或者旋转它。你还可以对它做其他事情,但这三项任务——*移、缩放和旋转——是对一个对象执行的最常见操作或变换。除了这些常用的变换之外,你还会使用透视投影将 3D 对象映射到屏幕的 2D *面上。这些变换都应用于你要变换的对象的坐标。
虽然你可能已经熟悉形式为(x,y,z)的 3D 坐标,但在 3D 计算机图形学中,你使用的是形式为(x,y,z,w)的坐标,称为 齐次坐标。(这些坐标来源于一种叫做 射影几何 的数学分支,超出了本书的范围。)齐次坐标使你能够将常见的 3D 变换(如*移、缩放和旋转)表示为 4×4 矩阵。但对于这些 OpenGL 项目的目的来说,你只需要知道齐次坐标(x,y,z,w)等同于 3D 坐标(x/w,y/w,z/w,1.0)。一个 3D 点(1.0,2.0,3.0)可以表示为齐次坐标(1.0,2.0,3.0,1.0)。
这是一个使用 4×4 矩阵进行 3D 变换的例子。请看矩阵乘法是如何将一个点(x,y,z,1.0)转换为(x + t[x], y + t[y],z + t[z],1.0)的:

由于此操作是将一个点在空间中*移,所涉及的 4×4 矩阵被称为 *移矩阵。
现在让我们来看另一个用于 3D 变换的有用矩阵——旋转矩阵。下面的矩阵将一个点(x,y,z,1.0)绕 x 轴逆时针旋转 θ 弧度:

但有一点需要记住:如果你打算在着色器代码中应用此旋转,矩阵将以 列主格式 存储,这意味着你应该按以下方式声明它:
// rotational transform
mat4 rot = mat4(
vec4(1.0, 0.0, 0.0, 0.0),
vec4(0.0, cos(uTheta), sin(uTheta), 0.0),
vec4(0.0, -sin(uTheta), cos(uTheta), 0.0),
vec4(0.0, 0.0, 0.0, 1.0)
);
注意,在代码中,与 R[θ][,][x] 的定义相比,矩阵沿其对角线被翻转了。
在 OpenGL 中,你会经常遇到两个术语:模型视图和投影变换。随着现代 OpenGL 可自定义着色器的出现,模型视图和投影已经成为通用变换。从历史上看,在旧版 OpenGL 中,模型视图变换用于将你的 3D 模型定位在空间中,而投影变换用于将 3D 坐标映射到 2D 表面以进行显示,正如你稍后会看到的那样。模型视图变换是用户定义的变换,让你能够定位 3D 对象,而投影变换是将 3D 映射到 2D 的投影变换。
最常用的两种 3D 图形投影变换是正交投影和透视投影,但在这里你将只使用透视投影,它由视场(眼睛可以看到的范围)、*裁剪面(离眼睛最*的*面)、远裁剪面(离眼睛最远的*面)和宽高比(*裁剪面宽度与高度的比率)定义。这些参数共同构成一个相机模型,用于定义如何将 3D 物体映射到 2D 屏幕上,如图 9-4 所示。图中的截头金字塔就是视锥体,眼睛则是放置相机的 3D 位置。(对于正交投影,眼睛的位置将在无穷远处,且金字塔将变成一个矩形立方体。)

图 9-4:一个透视投影相机模型
一旦透视投影完成并且在光栅化之前,图形原语会根据图 9-4 中显示的*裁剪面和远裁剪面进行裁剪(或剪切)。选择*裁剪面和远裁剪面时,确保你希望在屏幕上显示的 3D 物体位于视锥体内部;否则,它们会被裁剪掉。
着色器
你已经了解了着色器如何融入现代 OpenGL 可编程图形管线。现在让我们来看一对简单的顶点和片段着色器,来感受一下 GLSL 是如何工作的。
一个顶点着色器
这里是一个简单的顶点着色器,它计算顶点的位置和颜色:
❶ # version 410 core
❷ in vec3 aVert;
❸ uniform mat4 uMVMatrix;
❹ uniform mat4 uPMatrix;
❺ out vec4 vCol;
void main() {
// apply transformations
❻ gl_Position = uPMatrix * uMVMatrix * vec4(aVert, 1.0);
// set color
❼ vCol = vec4(1.0, 0.0, 0.0, 1.0);
}
你首先在着色器中设置所使用的 GLSL 版本为 4.1 ❶。然后,使用关键字in定义一个名为aVert的输入变量,其类型为vec3(一个三维向量)❷。接下来,你定义了两个类型为mat4(4×4 矩阵)的变量,分别对应于模型视图矩阵 ❸ 和投影矩阵 ❹。这些变量前缀为uniform,表示在给定的渲染调用中,这些变量在顶点着色器执行过程中不会改变。你使用out前缀来定义顶点着色器的输出变量,它是一个类型为vec4的颜色变量(一个 4 维向量,用于存储红色、绿色、蓝色和透明度通道)❺。
现在你进入了main()函数,顶点着色器程序从这里开始。gl_Position的值是通过使用传入的 uniform 矩阵对输入的aVert进行变换来计算的 ❻。GLSL 变量gl_Position用于存储变换后的顶点。你将顶点着色器的输出颜色设置为红色且不透明,使用的值是(1, 0, 0, 1) ❼。你将在管线中的下一个着色器中使用这个值作为输入。
一个片段着色器
现在让我们来看一个简单的片段着色器,它根据传入的顶点颜色计算片段颜色:
❶ # version 410 core
❷ in vec4 vCol;
❸ out vec4 fragColor;
void main() {
// use vertex color
❹ fragColor = vCol;
}
在设置着色器使用的 GLSL 版本 ❶ 后,你将 vCol 设置为片段着色器的输入 ❷。这个变量 vCol 是从顶点着色器的输出设置的。(记住,顶点着色器在 3D 场景中的每个顶点上执行,而片段着色器在屏幕上的每个片段上执行。)你还设置了片段着色器的输出颜色变量 fragColor ❸。
在光栅化过程中(发生在顶点着色器和片段着色器之间),OpenGL 将变换后的顶点转换为片段,并通过对顶点的颜色值进行插值,计算位于顶点之间的片段的颜色;vCol 是前面代码中的插值颜色。你将片段着色器的输出设置为与输入到片段着色器中的插值颜色相同❹。默认情况下,在大多数情况下,片段着色器的目标输出是屏幕,而你设置的颜色最终会显示在屏幕上(除非受到诸如深度测试等操作的影响,这些操作发生在图形管线的最后阶段)。
为了让 GPU 执行着色器代码,它需要被编译并链接到硬件可以理解的指令。OpenGL 提供了方法来实现这一点,并报告详细的编译器和链接器错误,帮助你开发着色器代码。编译过程还会生成一个表,列出你在着色器中声明的变量的位置或索引,以便你可以将它们连接到 Python 代码中的变量。
顶点缓冲区
顶点缓冲区是 OpenGL 着色器中使用的一个重要机制。现代图形硬件和 OpenGL 设计用于处理大量的 3D 几何体。因此,OpenGL 内置了多种机制,以帮助将数据从程序传输到 GPU。绘制 3D 几何体的典型设置将执行以下操作:
-
1. 为每个顶点的 3D 几何体定义坐标、颜色和其他属性的数组。
-
2. 创建一个顶点数组对象(VAO)并绑定到它。
-
3. 为每个属性创建顶点缓冲对象(VBO),并按顶点为单位定义。
-
4. 绑定到 VBO 并使用预定义数组设置缓冲数据。
-
5. 指定着色器中将使用的顶点属性的数据和位置。
-
6. 启用顶点属性。
-
7. 渲染数据。
在你定义了 3D 几何体的顶点之后,你创建并绑定一个顶点数组对象。VAO 是一种方便的方式,用于将几何体按坐标、颜色等多个数组进行分组。然后,为每个顶点的每个属性,创建一个顶点缓冲对象,并将你的 3D 数据设置进去。VBO 将顶点数据存储在 GPU 内存中。现在,只剩下连接缓冲数据,这样你就可以在着色器中访问它。你可以通过使用着色器中使用的变量位置来完成此操作。
纹理映射
现在让我们来看一下纹理映射,这是一种在本章中将使用的重要计算机图形学技术。纹理映射 是一种通过使用 3D 物体的 2D 图像(类似舞台背景)来赋予场景现实感的方法。纹理通常是从图像文件中读取的,并通过将 2D 坐标(在 [0, 1] 范围内)映射到多边形的 3D 坐标,拉伸并覆盖在几何区域上。例如,图 9-5 展示了一个图像被覆盖在立方体的一个面上。(我使用了 GL_TRIANGLE_STRIP 基元来绘制立方体面,并且顶点的顺序通过面上的线条表示。)

图 9-5:纹理映射
在图 9-5 中,纹理的(0, 0)角被映射到立方体面部的左下顶点。同样,你可以看到纹理的其他角是如何映射的,最终效果是纹理被“粘贴”到立方体的这一面。立方体面部的几何形状定义为三角带,顶点从底部到左上和从底部到右上呈之字形排列。纹理是非常强大且多功能的计算机图形工具,正如你将在第十一章中看到的那样。
OpenGL 上下文
现在让我们讨论如何让 OpenGL 在屏幕上绘制内容。存储所有 OpenGL 状态信息的实体被称为 OpenGL 上下文。上下文具有一个可视化的、类似窗口的区域,OpenGL 绘制内容会显示在这个区域上,并且每个进程或应用程序运行可以有多个上下文,但每次只有一个上下文可以是当前上下文(线程)。 (幸运的是,窗口工具包会处理大部分上下文的管理。)
为了让你的 OpenGL 输出出现在屏幕上的窗口中,你需要操作系统的帮助。对于这些项目,你将使用 GLFW,一个轻量级的跨*台 C 库,它允许你创建和管理 OpenGL 上下文、在窗口中显示 3D 图形,并处理用户输入(如鼠标点击和键盘按键)。(附录 A 涵盖了此库的安装细节。)
由于你正在使用 Python 编写代码而不是 C,你还将使用一个 Python 绑定库来访问 GLFW(glfw.py,可以在本书代码库的 common 目录中找到),这样你就可以使用 Python 访问所有 GLFW 功能。
需求
你将使用 PyOpenGL,一个流行的 OpenGL Python 绑定库来进行渲染,并且你会使用 numpy 数组来表示 3D 坐标和变换矩阵。
代码
在这个项目中,你将构建一个简单的 Python 应用程序,用 OpenGL 显示一个旋转的纹理多边形。要查看完整的项目代码,请跳到 “完整代码”,位于 第 172 页。我们简单的 OpenGL 应用程序的完整代码分布在两个文件中。本章讨论的主要项目代码在 simpleglfw.py 中,代码可以在 github.com/mkvenkit/pp2e/tree/main/simplegl 找到。辅助函数在 glutils.py 中,可以在 GitHub 仓库的 common 目录下找到。
RenderWindow 类
RenderWindow 类管理显示 OpenGL 图形的窗口的创建。它初始化 GLFW,设置 OpenGL,管理渲染,并设置回调以接收键盘输入。
创建一个 OpenGL 窗口
RenderWindow 类的首要任务是设置 GLFW,以便你有一个 OpenGL 窗口用于渲染。该类的初始化代码完成了这一任务:
class RenderWindow:
"""GLFW rendering window class"""
def __init__(self):
# save current working directory
cwd = os.getcwd()
# initialize glfw
❶ glfw.glfwInit()
# restore cwd
os.chdir(cwd)
# version hints
❷ glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MAJOR, 4)
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MINOR, 1)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_PROFILE,
glfw.GLFW_OPENGL_CORE_PROFILE)
# make a window
self.width, self.height = 800, 600
self.aspect = self.width/float(self.height)
❸ self.win = glfw.glfwCreateWindow(self.width, self.height,
b'simpleglfw')
# make the context current
❹ glfw.glfwMakeContextCurrent(self.win)
你初始化了 GLFW 库❶,然后从 ❷ 开始,将 OpenGL 版本设置为 OpenGL 4.1 核心配置文件。接着,你创建了一个 OpenGL 可用的窗口,尺寸为 800×600❸。最后,你使上下文变为当前❹,然后就可以开始执行 OpenGL 调用了。
接下来,仍在 __init__() 定义中,你会进行一些初始化调用:
# initialize GL
❶ glViewport(0, 0, self.width, self.height)
❷ glEnable(GL_DEPTH_TEST)
❸ glClearColor(0.5, 0.5, 0.5, 1.0)
在这里,你设置视口或屏幕尺寸(宽度和高度),这是 OpenGL 渲染你的 3D 场景的区域❶。然后,你通过 GL_DEPTH_TEST 开启深度测试❷,并设置当执行 glClear() 渲染时背景的颜色❸。你选择 50% 灰色,并将 alpha 设置为 1.0(Alpha 是片段透明度的度量——1.0 表示完全不透明)。
设置回调
你通过注册用户界面事件的回调函数来完成 __init__() 定义,这样你就可以响应按键事件。
# set window callbacks
glfw.glfwSetKeyCallback(self.win, self.onKeyboard)
这段代码设置了键盘按键的回调。每次发生这些事件时,注册为回调的函数 onKeyboard() 会被执行。现在让我们看看该键盘回调函数的定义:
def onKeyboard(self, win, key, scancode, action, mods):
# print 'keyboard: ', win, key, scancode, action, mods
❶ if action == glfw.GLFW_PRESS:
# ESC to quit
if key == glfw.GLFW_KEY_ESCAPE:
❷ self.exitNow = True
else:
# toggle cut
❸ self.scene.showCircle = not self.scene.showCircle
每次发生键盘事件时,都会调用 onKeyboard() 回调函数。函数的参数中会包含有用的信息,例如发生了什么类型的事件(例如,按键抬起与按键按下)以及哪个键被按下。代码 glfw.GLFW_PRESS 表示只监听按键按下(PRESS)事件❶。如果按下 ESC 键,你会设置一个退出标志❷。如果按下其他任何键,则切换 showCircle 布尔值❸。这个变量将在片段着色器中用于保留或丢弃圆形区域外的片段。
定义主循环
RenderWindow类还通过其run()方法定义了程序的主循环。(GLFW 并没有提供默认的程序循环。)run()方法以预设的时间间隔更新 OpenGL 窗口。调用渲染方法绘制场景后,它还会轮询系统,查看是否有待处理的窗口或键盘事件。让我们看看方法的定义:
def run(self):
# initializer timer
❶ glfw.glfwSetTime(0)
t = 0.0
❷ while not glfw.glfwWindowShouldClose(self.win) and not self.exitNow:
# update every x seconds
❸ currT = glfw.glfwGetTime()
if currT - t > 0.1:
# update time
t = currT
# clear
❹ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# set viewport
❺ self.width, self.height = glfw.glfwGetFramebufferSize(self.win)
❻ self.aspect = self.width/float(self.height)
❼ glViewport(0, 0, self.width, self.height)
在主循环中,glfw.glfwSetTime()将 GLFW 计时器重置为 0❶。你将使用这个计时器在定时的间隔内重新绘制图形。你启动一个while循环❷,只有当窗口关闭或exitNow被设置为True时才退出。当循环退出时,调用glfw.glfwTerminate()以清理地关闭 GLFW。
在循环内,glfw.glfwGetTime()获取当前的计时器值❸,你可以用它来计算自上次绘制以来经过的时间。通过在这里设置一个期望的间隔(在本例中为 0.1 秒或 100 毫秒),你可以调整渲染帧率。接下来,glClear()清除深度和颜色缓冲区,并用设定的背景色替换它们,为下一帧做好准备❹。
你使用glfwGetFramebufferSize()函数查询并设置窗口的宽度和高度❺。这样做是为了防止用户改变窗口的大小。请注意,在某些系统(例如带有 Retina 显示屏的 MacBook)中,窗口大小和帧缓冲区大小可能不同,因此为了安全起见,始终查询后者。接下来,你计算窗口的宽高比❻,稍后将用它来设置投影矩阵。然后,使用你获取的新帧缓冲区尺寸清除视口❼。
现在让我们看一下run()方法的剩余部分:
# build projection matrix
❶ pMatrix = glutils.perspective(45.0, self.aspect, 0.1, 100.0)
❷ mvMatrix = glutils.lookAt([0.0, 0.0, -2.0], [0.0, 0.0, 0.0],
[0.0, 1.0, 0.0])
# render
❸ self.scene.render(pMatrix, mvMatrix)
# step
❹ self.scene.step()
❺ glfw.glfwSwapBuffers(self.win)
# poll for and process events
❻ glfw.glfwPollEvents()
# end
glfw.glfwTerminate()
仍然在while循环中,你使用glutils.py中定义的perspective()方法❶计算投影矩阵。投影矩阵是将 3D 场景映射到 2D 屏幕的变换。这里你设置了 45 度的视场角和*/远*面的距离为 0.1/100.0。然后,你使用glutils.py中定义的lookAt()方法❷设置模型视图矩阵。默认的 OpenGL 视图将你的眼睛放在原点,朝向负 z 方向。lookAt()方法创建的模型视图矩阵会将顶点进行转换,使得视图与眼睛位置和方向匹配。你将眼睛位置设置为(0, 0, -2),并朝向原点(0, 0, 0),"上"向量为(0, 1, 0)。接下来,你调用scene对象的render()方法❸,传入这些矩阵,并调用scene.step()来更新时间步长所需的变量❹。(我们接下来会看Scene类,它封装了多边形的设置和渲染。)glfwSwapBuffers()调用❺交换前后缓冲区,从而显示更新后的 3D 图形,而glfwPollEvents()调用❻检查任何 UI 事件,并将控制权返回给while循环。
场景类
现在让我们来看一下 Scene 类,它负责初始化和绘制 3D 几何体。以下是类声明的开头:
class Scene:
""" OpenGL 3D scene class"""
# initialization
def __init__(self):
# create shader
❶ self.program = glutils.loadShaders(strVS, strFS)
❷ glUseProgram(self.program)
在 Scene 类构造函数中,首先编译并加载着色器。为此,你使用在 glutils.py 中定义的工具方法 loadShaders() ❶,它为从字符串加载着色器代码、编译代码并将其链接到 OpenGL 程序对象所需的一系列 OpenGL 调用提供了一个便捷的封装。由于 OpenGL 是一个状态机,因此你需要使用 glUseProgram() 调用 ❷ 设置代码以使用特定的“程序对象”(因为一个项目可能有多个程序)。
__init__() 方法继续通过将 Python 代码中的变量与着色器中的变量连接起来:
self.pMatrixUniform = glGetUniformLocation(self.program, b'uPMatrix')
self.mvMatrixUniform = glGetUniformLocation(self.program, b'uMVMatrix')
纹理
self.tex2D = glGetUniformLocation(self.program, b'tex2D')
这段代码使用 glGetUniformLocation() 方法检索顶点着色器和片段着色器中定义的变量 uPMatrix、uMVMatrix 和 tex2D 的位置。然后可以使用这些位置设置着色器变量的值。
定义 3D 几何体
Scene 类的 __init__() 方法的下一部分定义了场景的 3D 几何体。首先,你定义了多边形的几何形状,这将呈现为一个正方形:
# define triangle strip vertices
❶ vertexData = numpy.array(
[-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
-0.5, 0.5, 0.0,
0.5, 0.5, 0.0], numpy.float32)
# set up vertex array object (VAO)
❷ self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
# vertices
❸ self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
# set buffer data
❹ glBufferData(GL_ARRAY_BUFFER, 4*len(vertexData), vertexData,
GL_STATIC_DRAW)
# enable vertex array
❺ glEnableVertexAttribArray(0)
# set buffer data pointer
❻ glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, None)
# unbind VAO
❼ glBindVertexArray(0)
首先,你定义了用于绘制正方形的三角形带的顶点数组❶。可以将一个边长为 1.0,中心位于原点的正方形想象出来。该正方形的左下角顶点坐标为(−0.5,−0.5,0.0);接下来的顶点(右下角)坐标为(0.5,−0.5,0.0);依此类推。四个坐标的顺序是 GL_TRIANGLE_STRIP 的顺序。实际上,你通过定义两个共享斜边的直角三角形来创建正方形。
接下来,你创建一个 VAO ❷。一旦绑定到该 VAO,所有后续的调用将绑定到它。然后你创建一个 VBO 来管理顶点数据的渲染❸。一旦缓冲区被绑定,你就可以从已定义的顶点设置缓冲区数据❹。
现在你需要启用着色器以访问这些数据。为此,你调用 glEnableVertexAttribArray() ❺。你使用索引 0,因为这是你在顶点着色器中为顶点数据变量设置的位置。调用 glVertexAttribPointer() 设置顶点属性数组的位置和数据格式❻。属性的索引是 0,组件数为 3(你使用 3D 顶点),顶点的数据类型为 GL_FLOAT。然后你解除绑定 VAO❼,以防其他相关调用干扰它。在 OpenGL 中,完成后重置状态是一种最佳实践。OpenGL 是一个状态机,如果你不清理,它们将保持原样。
以下代码将一张星星的图像加载为 OpenGL 纹理:
# texture
self.texId = glutils.loadTexture('star.png')
返回的纹理 ID 将在渲染时使用。
旋转正方形
接下来,你需要更新Scene对象中的变量,使得正方形能够在屏幕上旋转。使用类的step()方法:
# step
def step(self):
# increment angle
❶ self.t = (self.t + 1) % 360
在❶处,你递增角度变量t并使用取模运算符(%)保持该值在[0, 360]范围内。这个变量将用于更新顶点着色器中的旋转角度。
渲染场景
现在让我们来看一下Scene对象的主要渲染代码:
def render(self, pMatrix, mvMatrix):
# use shader
❶ glUseProgram(self.program)
# set projection matrix
❷ glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
# set modelview matrix
glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
# set shader angle in radians
❸ glUniform1f(glGetUniformLocation(self.program, 'uTheta'),
math.radians(self.t))
# show circle?
❹ glUniform1i(glGetUniformLocation(self.program, b'showCircle'),
self.showCircle)
# enable texture
❺ glActiveTexture(GL_TEXTURE0)
❻ glBindTexture(GL_TEXTURE_2D, self.texId)
❼ glUniform1i(self.tex2D, 0)
# bind VAO
❽ glBindVertexArray(self.vao)
# draw
❾ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
# unbind VAO
❿ glBindVertexArray(0)
首先,你设置渲染使用着色器程序❶。从❷开始,你使用glUniformMatrix4fv()方法在着色器中设置计算得到的投影矩阵和模型视图矩阵。然后,你使用glUniform1f()方法设置着色器程序中的uTheta❸。你像之前一样使用glGetUniformLocation()获取着色器中的uTheta角度变量的位置,然后使用 Python 的math.radians()方法将角度从度数转换为弧度。接下来,你使用glUniform1i()设置片段着色器中showCircle变量的当前值❹。OpenGL 有多个纹理单元的概念,glActiveTexture()❺激活纹理单元 0(默认)。你将之前从star.png图像生成的纹理 ID 绑定到当前激活的渲染单元❻。片段着色器中的sampler2D变量被设置为纹理单元 0❼。
你继续绑定之前创建的 VAO❽。现在你可以看到使用 VAO 的好处:你无需在实际绘制之前重复一堆与顶点缓冲区相关的调用。然后你调用glDrawArrays()来渲染绑定的顶点缓冲区❾。基本图元类型是三角带,渲染的顶点数量为四个。最后,你在❿处解绑 VAO,这始终是一种良好的编程实践。
定义 GLSL 着色器
现在让我们来看一下项目中最激动人心的部分——GLSL 着色器。首先,来看一下顶点着色器,它计算顶点的位置和纹理坐标:
# version 410 core
❶ layout(location = 0) in vec3 aVert;
❷ uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform float uTheta;
❸ out vec2 vTexCoord;
void main() {
// rotational transform
❹ mat4 rot = mat4(
vec4(1.0, 0.0, 0.0, 0.0),
vec4(0.0, cos(uTheta), -sin(uTheta), 0.0),
vec4(0.0, sin(uTheta), cos(uTheta), 0.0),
vec4(0.0, 0.0, 0.0, 1.0)
);
// transform vertex
❺ gl_Position = uPMatrix * uMVMatrix * rot * vec4(aVert, 1.0);
// set texture coordinate
❻ vTexCoord = aVert.xy + vec2(0.5, 0.5);
}
你使用layout关键字❶显式地设置顶点属性aVert的位置——在此案例中是 0。这个属性让顶点着色器访问你为多边形定义的顶点。从❷开始,你声明了三个uniform变量,用于投影矩阵、模型视图矩阵和旋转角度。这些变量将在 Python 代码中设置。你还设置了一个 2D 向量vTexCoord作为此着色器的输出❸。它将作为片段着色器的输入。
在着色器的main()方法中,你设置了一个旋转矩阵❹,它绕着 x 轴按给定的角度uTheta进行旋转。你通过连接投影矩阵、模型视图矩阵和旋转矩阵来计算gl_Position❺。这将为你提供着色器输出顶点的位置。接着,你设置了一个二维向量作为纹理坐标❻。你可能记得你为一个以原点为中心、边长为 1.0 的正方形定义了三角形条带。因为纹理坐标的范围是[0, 1],你可以通过将(0.5, 0.5)加到 x 和 y 值上来生成这些纹理坐标。这也展示了着色器在计算中的强大和巨大的灵活性。纹理坐标和其他变量并非不可更改;你可以将它们设置为几乎任何值。
现在我们来看一下片段着色器,它计算我们 OpenGL 程序的输出像素:
# version 410 core
❶ in vec2 vTexCoord;
❷ uniform sampler2D tex2D;
❸ uniform bool showCircle;
❹ out vec4 fragColor;
void main() {
if (showCircle) {
// discard fragment outside circle
❺ if (distance(vTexCoord, vec2(0.5, 0.5)) > 0.5) {
discard;
}
else {
❻ fragColor = texture(tex2D, vTexCoord);
}
}
else {
❼ fragColor = texture(tex2D, vTexCoord);
}
}
你首先定义了片段着色器的输入——在本例中是你在顶点着色器中设置为输出的纹理坐标❶。回想一下,片段着色器是按像素操作的,因此为这些变量设置的值是当前像素的值,这些值是跨多边形进行插值的。你声明了一个sampler2D变量❷,它链接到一个特定的纹理单元,并用于查找纹理值,还有一个布尔型的 uniform 标志showCircle❸,它是从 Python 代码中设置的。你还声明了fragColor作为片段着色器的输出❹。默认情况下,它会被送到屏幕上(经过最终的帧缓冲操作,如深度测试和混合)。
在main()方法中,如果没有设置showCircle标志❼,你使用 GLSL 的texture()方法,通过纹理坐标和采样器查找纹理颜色值。实际上,你只是用星形图像给三角形条带加上纹理。然而,如果showCircle标志为true❺,你使用 GLSL 内建的distance()方法来检查当前像素与多边形中心的距离。它使用(插值后的)纹理坐标进行此操作,而这些坐标是由顶点着色器传递过来的。如果距离大于某个阈值(在此案例中为 0.5),你调用 GLSL 的discard()方法,这将丢弃当前像素。如果距离小于阈值,则从纹理中设置相应的颜色❻。基本上,这么做是为了忽略位于正方形中心半径为 0.5 的圆形外部的像素,从而在showCircle被设置时将多边形切割成一个圆形。
工具函数
我提到过为了方便使用 OpenGL,你可以使用在glutils.py中定义的几个工具函数。现在我们来看其中一个函数的例子。loadTexture()函数将图像加载到 OpenGL 纹理中:
def loadTexture(filename):
"""load OpenGL 2D texture from given image file"""
❶ img = Image.open(filename)
❷ imgData = numpy.array(list(img.getdata()), np.int8)
❸ texture = glGenTextures(1)
❹ glBindTexture(GL_TEXTURE_2D, texture)
❺ glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
❻ glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
❼ glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
❽ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.size[0], img.size[1],
0, GL_RGBA, GL_UNSIGNED_BYTE, imgData)
return texture
loadTexture() 函数使用 Python Imaging Library (PIL) 的 Image 模块来读取图像文件 ❶。然后,它将 Image 对象中的数据提取到一个 8 位的 numpy 数组 ❷ 中,并创建一个 OpenGL texture 对象 ❸,这是在 OpenGL 中操作纹理的前提。接下来,你执行了现在熟悉的绑定操作,将 texture 对象 ❹ 绑定,这样所有后续与纹理相关的设置都会应用于该对象。你将数据的解包对齐方式设置为 1 ❺,这意味着图像数据将被硬件视为 1 字节或 8 位数据。从 ❻ 开始,你告诉 OpenGL 如何处理纹理的边缘。在这个例子中,你指示它将纹理颜色限制到几何体的边缘。(在指定纹理坐标时,通常使用字母 S 和 T 来表示轴,而不是 x 和 y。)在 ❼ 和接下来的行中,你指定了纹理在拉伸或压缩时映射到多边形的插值方式。在这种情况下,指定了线性过滤。最后,你将图像数据设置到已绑定的纹理 ❽ 中。此时,图像数据已传输到图形内存,纹理已经可以使用。
运行 OpenGL 应用程序
这是该项目的一个示例运行:
$ `python simpleglfw.py`
你可以在 图 9-1 中看到输出。记得尝试按键切换圆形的显示与隐藏。
总结
恭喜你完成了使用 Python 和 OpenGL 编写的第一个程序!通过这个项目,你学会了如何创建 3D 变换,使用 OpenGL 3D 图形管线,以及使用 GLSL 顶点和片段着色器来创建有趣的 3D 图形。你已经开始了进入迷人的 3D 图形编程世界的旅程。
实验!
这是一些修改此项目的想法:
-
1. 本项目中的顶点着色器使得正方形围绕 x 轴 (1, 0, 0) 旋转。你能使其围绕 y 轴 (0, 0, 1) 旋转吗?你可以通过两种方式来实现:第一,修改着色器中的旋转矩阵;第二,在 Python 代码中计算这个矩阵,并将其作为uniform 传递给着色器。试试看这两种方法!
-
2. 在该项目中,纹理坐标是在顶点着色器内生成并传递到片段着色器的。这是一种技巧,只有选择了合适的三角形条的顶点值才有效。将纹理坐标作为单独的属性传递到顶点着色器中,类似于传递顶点的方式。现在,你能让星形纹理拼接在三角形条上吗?你希望在正方形上显示一个 4×4 的星形网格,而不是单一的星星。(提示:使用大于 1.0 的纹理坐标,并在
glTexParameterf()中将GL_TEXTURE_WRAP_S/T参数设置为GL_REPEAT。) -
- 只需修改你的片段着色器,你能让你的方形看起来像图 9-6 吗?(提示:使用 GLSL 的
sin()函数。)
- 只需修改你的片段着色器,你能让你的方形看起来像图 9-6 吗?(提示:使用 GLSL 的

图 9-6:使用片段着色器绘制同心圆
完整代码
这是完整的simpleglfw.py代码:
"""
simpleglfw.py
A simple Python OpenGL program that uses PyOpenGL + GLFW to get an
OpenGL 4.1 context.
Author: Mahesh Venkitachalam
"""
import OpenGL
from OpenGL.GL import *
import numpy, math, sys, os
import glutils
import glfw
strVS = """
# version 410 core
layout(location = 0) in vec3 aVert;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform float uTheta;
out vec2 vTexCoord;
void main() {
// rotational transform
mat4 rot = mat4(
vec4(1.0, 0.0, 0.0, 0.0),
vec4(0.0, cos(uTheta), sin(uTheta), 0.0),
vec4(0.0, -sin(uTheta), cos(uTheta), 0.0),
vec4(0.0, 0.0, 0.0, 1.0)
);
// transform vertex
gl_Position = uPMatrix * uMVMatrix * rot * vec4(aVert, 1.0);
// set texture coord
vTexCoord = aVert.xy + vec2(0.5, 0.5);
}
"""
strFS = """
# version 410 core
in vec2 vTexCoord;
uniform sampler2D tex2D;
uniform bool showCircle;
out vec4 fragColor;
void main() {
if (showCircle) {
// discard fragment outside circle
if (distance(vTexCoord, vec2(0.5, 0.5)) > 0.5) {
discard;
}
else {
fragColor = texture(tex2D, vTexCoord);
}
}
else {
fragColor = texture(tex2D, vTexCoord);
}
}
"""
class Scene:
""" OpenGL 3D scene class"""
# initialization
def __init__(self):
# create shader
self.program = glutils.loadShaders(strVS, strFS)
glUseProgram(self.program)
self.pMatrixUniform = glGetUniformLocation(self.program,
b'uPMatrix')
self.mvMatrixUniform = glGetUniformLocation(self.program,
b'uMVMatrix')
# texture
self.tex2D = glGetUniformLocation(self.program, b'tex2D')
# define triangle strip vertices
vertexData = numpy.array(
[-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
-0.5, 0.5, 0.0,
0.5, 0.5, 0.0], numpy.float32)
# set up vertex array object (VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
# vertices
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
# set buffer data
glBufferData(GL_ARRAY_BUFFER, 4*len(vertexData), vertexData,
GL_STATIC_DRAW)
# enable vertex array
glEnableVertexAttribArray(0)
# set buffer data pointer
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, None)
# unbind VAO
glBindVertexArray(0)
# time
self.t = 0
# texture
self.texId = glutils.loadTexture('star.png')
# show circle?
self.showCircle = False
# step
def step(self):
# increment angle
self.t = (self.t + 1) % 360
# render
def render(self, pMatrix, mvMatrix):
# use shader
glUseProgram(self.program)
# set proj matrix
glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
# set modelview matrix
glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
# set shader angle in radians
glUniform1f(glGetUniformLocation(self.program, 'uTheta'),
math.radians(self.t))
# show circle?
glUniform1i(glGetUniformLocation(self.program, b'showCircle'),
self.showCircle)
# enable texture
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, self.texId)
glUniform1i(self.tex2D, 0)
# bind VAO
glBindVertexArray(self.vao)
# draw
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
# unbind VAO
glBindVertexArray(0)
class RenderWindow:
"""GLFW rendering window class"""
def __init__(self):
# save current working directory
cwd = os.getcwd()
# initialize glfw - this changes cwd
glfw.glfwInit()
# restore cwd
os.chdir(cwd)
# version hints
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MAJOR, 4)
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MINOR, 1)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_PROFILE,
glfw.GLFW_OPENGL_CORE_PROFILE)
# make a window
self.width, self.height = 800, 600
self.aspect = self.width/float(self.height)
self.win = glfw.glfwCreateWindow(self.width, self.height,
b'simpleglfw')
# make context current
glfw.glfwMakeContextCurrent(self.win)
# initialize GL
glViewport(0, 0, self.width, self.height)
glEnable(GL_DEPTH_TEST)
glClearColor(0.5, 0.5, 0.5, 1.0)
# set window callbacks
glfw.glfwSetKeyCallback(self.win, self.onKeyboard)
# create 3D
self.scene = Scene()
# exit flag
self.exitNow = False
def onKeyboard(self, win, key, scancode, action, mods):
# print 'keyboard: ', win, key, scancode, action, mods
if action == glfw.GLFW_PRESS:
# ESC to quit
if key == glfw.GLFW_KEY_ESCAPE:
self.exitNow = True
else:
# toggle cut
self.scene.showCircle = not self.scene.showCircle
def run(self):
# initializer timer
glfw.glfwSetTime(0)
t = 0.0
while not glfw.glfwWindowShouldClose(self.win) and not self.exitNow:
# update every x seconds
currT = glfw.glfwGetTime()
if currT - t > 0.1:
# update time
t = currT
# clear
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# set viewport
self.width, self.height =
glfw.glfwGetFramebufferSize(self.win)
self.aspect = self.width/float(self.height)
glViewport(0, 0, self.width, self.height)
# build projection matrix
pMatrix = glutils.perspective(45.0, self.aspect, 0.1, 100.0)
mvMatrix = glutils.lookAt([0.0, 0.0, -2.0], [0.0, 0.0, 0.0],
[0.0, 1.0, 0.0])
# render
self.scene.render(pMatrix, mvMatrix)
# step
self.scene.step()
glfw.glfwSwapBuffers(self.win)
# poll for and process events
glfw.glfwPollEvents()
# end
glfw.glfwTerminate()
def step(self):
# step
self.scene.step()
# main() function
def main():
print("Starting simpleglfw. "
"Press any key to toggle cut. Press ESC to quit.")
rw = RenderWindow()
rw.run()
# call main
if __name__ == '__main__':
main()
第十章:# 圆环上的康威生命游戏

在第三章中,我们使用 Python 和matplotlib库实现了康威的生命游戏。你可能还记得那个项目中的一个有趣方面:它使用了环形边界条件。图 3-2 在第 48 页展示了我们如何有效地将*面的 2D 网格视为一个 3D 环形表面,这要归功于边界条件如何将边缘连接起来。在前一章,你接触了 OpenGL 并学会了如何渲染 3D 对象。现在,让我们将你在生命游戏和 OpenGL 中的经验结合起来,重新创建康威的 3D 生命游戏模拟,并展示在实际的圆环上。
在本项目中,你将首先计算圆环的 3D 几何形状。然后,你将以一种易于在 OpenGL 中绘制和着色的方式排列圆环的顶点。你将设置一个旋转摄像机,从各个角度查看圆环,并在着色器中实现一些基本的光照。最后,你将改编第三章中的生命游戏代码,用来在圆环的网格上进行着色。随着模拟的进行,你将看到生命游戏在圆环表面上“复活”!
以下是本项目涉及的主要内容:
-
• 使用矩阵运算构建圆环的 3D 几何形状
-
• 在圆环上实现生命游戏网格的着色方案
-
• 在 OpenGL 中实现旋转摄像机
-
• 在 OpenGL 中实现简单的光照
工作原理
在进入代码之前,让我们考虑一下如何使用 OpenGL 渲染、照明和查看一个 3D 圆环。首先要做的是计算组成圆环的顶点。
计算顶点
圆环本质上是一些圆形或环形的集合,这些圆形按顺序围绕一个中心点排列。然而,在 OpenGL 中无法直接绘制圆形;它们需要被离散化,或者表示为由直线连接的顶点序列。图 10-1 中的简化模型展示了如何将圆环定义为一组顶点。

图 10-1:圆环渲染模型。简化的圆环在左侧,组成圆环的单个“环”在右侧。
图 10-1 的右侧显示了一个半径为r的环,并将其离散化为M = 5 个点。图 10-1 的左侧则展示了一个简单的环面,主半径为R,通过将N = 6 个离散化的环(标记为 0 到 5)围绕一个中心点排列来构建。(主半径是环面孔的中心到外环中心的距离。)不要担心图中环面的方块感,随着N和M值的增加,环面会变得更加*滑。
通过绘制带子连接相邻的环来填充环面。你将使用GL_TRIANGLE_STRIP原语来绘制这些带子,并且每个生命游戏模拟中的单元格将由带上的两个相邻三角形组成,这些三角形共同形成一个四边形。当单元格为开启状态时,你将其四边形涂成黑色,当单元格为关闭状态时,你将其四边形涂成白色。
为了计算环面的顶点,你首先需要为其定义一个坐标系。假设环面位于 XY *面上,且以原点为中心,穿过环面中心的线与 z 轴对齐,如图 10-2 所示。

图 10-2:环面渲染策略
在与 x 轴成θ角度的环面上,圆C[3]的顶点可以按如下方式计算:
-
1. 计算 XZ *面上圆C[1]的顶点,该圆的半径为r,并且圆心位于原点。
-
将圆C[1]围绕 z 轴旋转θ角度。这会得到圆C[2]。
-
3. 将圆C[2]沿角度θ方向*移R的距离,得到位于环面正确位置的圆C[3]。
你可能还记得在第二章的陀螺图形项目中使用参数方程来定义一个圆。在这里我们将使用相同的概念。标记 XZ *面上圆C[1](半径为r,以原点为中心)周长的顶点由以下公式给出:
P = (r cos(α), 0, r sin(α))
这里,α是点P与 x 轴之间的角度。当α从 0 变化到 360 度(或 2π弧度)时,P点将形成一个圆。请注意,在前面的方程中,点的 y 坐标为零。这是可以预期的,因为圆位于 XZ *面上。
现在你必须将这些点绕 z 轴旋转θ角度。此操作的旋转矩阵如下所示:

一旦旋转了这些点,你需要将它们*移到正确的位置。这是通过以下*移矩阵完成的。(该格式在第九章中有讨论。)

因此,环形体上的变换点由以下公式给出:
Pʹ = T × R [θ,Z] × P
这与以下内容相同:

在前面的方程中,P首先与旋转矩阵相乘,这样它就能正确对齐,然后与*移矩阵相乘,这样“推动”点到达环面上的正确位置。注意,P使用齐次坐标(x,y,z,1.0)表示,这在上一章中有讨论。
计算光照法向量
为了让环面看起来更加美观,你需要对几何体应用光照,这意味着你需要计算前一节中计算的点P的法向量。表面上的光照取决于表面与入射光之间的方向,而方向可以通过法向量来量化,法向量是指在特定点上垂直于表面的向量。请查看图 10-3 以查看示例。

图 10-3:计算法向量
由于环面的几何形状,环上点s的法向量与连接点s和环中心的线方向相同。这意味着法向量与旋转后的点相同。*移矩阵不影响法向量的方向,因此你可以按如下方式计算法向量:
N = R[θ,Z] × P
请注意,你需要在进行任何光照计算之前归一化法向量。你可以通过将法向量除以其大小来实现这一点。
实际的光照将来自一个固定位置的单一光源。光照将在顶点着色器中定义。
渲染
现在你已经拥有了环面的顶点和法向量,接下来我们来讨论如何使用 OpenGL 渲染它。首先,你需要将环面划分成带状区域,如图 10-4 所示。每个带状区域是两个相邻环之间的区域。

图 10-4:使用三角带渲染环面
每个带状区域都是使用 OpenGL 的GL_TRIANGLE_STRIP原语进行渲染的。除了构成环面的基础构件外,这些三角带还提供了一种方便的方式来创建“生命游戏”模拟网格:网格中的每个单元格由由两个相邻三角形组成的四边形表示。图 10-5 展示了环面中一个带状区域的详细信息。

图 10-5:使用三角带渲染带状区域
该带状区域由相邻的C⁰和C¹环组成。每个环有M个顶点。构成该带状区域的三角带由M对顶点组成,顶点在两个环之间来回交错:

但还有一对额外的顶点需要添加:
。你需要重复前两个顶点来关闭带子末端的间隙。因此,形成该带子的三角形带中的顶点总数为 2 × M + 2。
图 10-5 中显示的带子是由环
组成的。圆环被分为 N 条带,其中 N 是环的数量:

请注意,最后一条带子是如何通过回到第一个环来绕一圈的,C⁰。这意味着渲染圆环所需的顶点总数由 N × (2 × M + 2) 给出。你将在查看代码时看到更多实现的细节。
现在,让我们来看一下圆环的着色方案。
给三角形带着色
你需要单独为《生命游戏》模拟中的每个格子着色。你知道每个格子是一个四边形——由两个三角形组成,属于一个更大的三角形带。例如,顶点组成的四边形
由两个三角形组成:
和
。每个顶点都有一个对应的颜色,该颜色是一个三元组,形式为 (r, g, b),表示红色、绿色和蓝色的分量。默认情况下,四边形中第一个顶点(在这种情况下是
)的颜色为四边形中第一个三角形设置颜色,而第二个顶点(
)的颜色为四边形中第二个三角形设置颜色。只要你将这两种颜色设置为相同,你就能均匀地给四边形上色。我们将在查看代码时进一步讨论 OpenGL 的顶点颜色约定。
注意,OpenGL 函数 glProvokingVertex() 改变了哪一个颜色值被映射到顶点的约定。
控制相机
为了查看圆环,你需要创建一个围绕 3D 场景原点旋转的相机,并从上方以一定角度俯视。图 10-6 展示了相机的设置。

图 10-6:实现一个旋转相机
将相机视为点E,它被放置在一个半径为R,高度为H的圆柱体上,朝向原点O。相机由相互垂直的向量V、U和N定义,其中V是从E指向O的视线向量;U是相机的上向量;N是与V和U都垂直的向量。每个时间步,你会让相机沿着圆柱体的边缘以恒定的距离移动。这一运动由角度β来参数化,如图 10-6 所示。正如你在第九章中学到的,你使用lookAt()方法来设置视图,它接受三个参数:眼睛、中心和上向量。中心就是原点:(0, 0, 0)。眼睛的三维坐标为:
E = (R cos(β), R sin(β), H)
当相机沿着圆柱体的边缘移动时,它会始终朝向O,并且上向量U也会不断变化。要计算上向量U,首先从一个初始猜测Uʹ开始,它与 z 轴*行。然后找到N,即垂直于Uʹ和V所定义*面的向量。可以通过以下方式计算:
N = V × Uʹ
N由V和Uʹ的叉积给出。那么,如果你将N和V进行叉积,会发生什么呢?你将得到一个垂直于 NV *面的向量,也就是你要找的上向量U!
U = N × V = (V × Uʹ) × V
一旦计算出U,请确保在使用之前将其标准化。完成后,你就可以用lookAt()方法来设置相机:E(眼睛)、O(中心)和U(上向量)。
将网格映射到环面
最后,让我们看看二维生命游戏的模拟网格是如何映射到三维环面上的,因为该网格具有环形边界条件。图 10-7 展示了这一映射。

图 10-7:将模拟网格映射到环面
二维生命游戏网格有NX列和NY行。你可以在图的右侧看到,划分每一行的NX个点是如何绕着环面圆柱体的管道展开的。点的索引从 0 到NX − 1。下一个索引NX与 0 相同,因为存在绕回。y 方向也会发生类似的绕回,你有NY个单元格:索引为NY的点与索引为 0 的点相同。
你之前已经看到,每个环面环的离散化包含了M个点。为了将二维网格映射到环面,设置NX = M。类似地,设置NY = N,其中N是环面上的带数。
需求
我们将使用PyOpenGL和 GLFW 进行 OpenGL 渲染,正如在第九章中所示,并使用numpy进行矩阵/向量计算。
代码
该项目的代码分为多个文件:
torus.py 这个文件包含了环面几何计算和渲染代码。
gol.py 这个文件实现了康威的生命游戏,改编自第三章。
camera.py 这个文件包含了旋转相机的实现,用于查看环面。
gol_torus.py 这是主文件,用于设置 OpenGL 和 GLFW,并调用其他模块中的渲染代码。
完整的项目代码可以在线访问,链接:github.com/mkvenkit/pp2e/blob/main/gol_torus。
渲染环面
我们将首先考虑渲染环面的代码,它封装在一个名为Torus的类中,定义在文件torus.py中。要查看完整的代码列表,请跳转到“完整的环面渲染代码”,见第 203 页。
定义着色器
首先,定义环面的 GLSL 着色器。以下是顶点着色器,它获取每个顶点的属性(位置、颜色、法线)并计算传递给片段着色器的变换输入:
strVS = """
# version 410 core
layout(location = 0) in vec3 aVert;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec3 aNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
❶ flat out vec3 vColor;
❷ out vec3 vNormal;
❸ out vec3 fragPos;
void main() {
// transform vertex
gl_Position = uPMatrix * uMVMatrix * vec4(aVert, 1.0);
❹ fragPos = aVert;
vColor = aColor;
vNormal = aNormal;
}
"""
你将顶点着色器代码定义为一个存储在strVS中的字符串。着色器的属性变量有aVert、aColor和aNormal,分别表示每个顶点的坐标、颜色和法向量。注意在初始化vColor时使用了flat限定符,❶ 表示该变量在片段着色器中不会进行插值。实际上,我们是在说这个变量将在整个图元(一个三角形带中的三角形之一)上保持不变。这确保了每个“生命游戏”单元格将呈现单一颜色。这种类型的图元着色被称为*面着色。接下来的顶点着色器输出是vNormal ❷,默认情况下它将在片段着色器中进行插值。你需要这个输出,以便计算图元上的光照,但稍后你将看到如何修改这个着色器代码来支持*面着色。另一个输出叫做fragPos ❸。在主着色器代码中,你将这个输出设置为aVert ❹,以便将其传递到片段着色器进行光照计算。着色器还计算gl_Position,并将接收到的颜色和法线数据传递给片段着色器。
这是片段着色器,应用光照并计算片段的最终颜色。它作为另一个字符串定义,叫做strFS。
strFS = """
# version 410 core
flat in vec3 vColor;
in vec3 vNormal;
in vec3 fragPos;
out vec4 fragColor;
void main() {
❶ vec3 lightPos = vec3(10.0, 10.0, 10.0);
❷ vec3 lightColor = vec3(1.0, 1.0, 1.0);
❸ vec3 lightDir = normalize(lightPos - fragPos);
float diff = max(dot(vNormal, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
float ambient = 0.1;
❹ vec3 result = (ambient + diffuse) * vColor.xyz;
fragColor = vec4(result, 1.0);
}
"""
请注意,颜色、法线和片段位置变量,这些是顶点着色器的输出,现在作为片段着色器的输入。在主着色器代码中,你定义了光源的位置❶和颜色❷。接着你计算了光源的方向❸。最终的颜色❹是环境光和漫反射光的混合,并作为片段着色器的输出。
请记住,fragPos和vNormal是通过插值为每个片段计算的,而vColor对于给定的原语是常量。其净效果是,原语(在此情况下是三角形条带)的内在颜色保持不变,而感知到的颜色则根据原语相对于光源的朝向而变化。这正是你需要的效果,可以让每个生命游戏单元显示为固色,同时通过改变颜色来创建光照效果。
初始化 Torus 类
现在让我们来看一下Torus类构造函数中的初始化代码:
class Torus:
""" OpenGL 3D scene class"""
# initialization
❶ def __init__(self, R, r, NX, NY):
global strVS, strFS
# modify shader for flat shading
# create shader
❷ self.program = glutils.loadShaders(strVS, strFS)
glProvokingVertex(GL_FIRST_VERTEX_CONVENTION)
self.pMatrixUniform = glGetUniformLocation(self.program,
b'uPMatrix')
self.mvMatrixUniform = glGetUniformLocation(self.program,
b'uMVMatrix')
# torus geometry
self.R = R
self.r = r
# grid size
self.NX = NX
self.NY = NY
# no. of points
❸ self.N = self.NX
self.M = self.NY
# time
self.t = 0
# compute parameters for glMultiDrawArrays
M1 = 2*self.M + 2
❹ self.first_indices = [2*M1*i for i in range(self.N)]
self.counts = [2*M1 for i in range(self.N)]
# colors: {(i, j) : (r, g, b)}
# with NX * NY entries
❺ self.colors_dict = self.init_colors(self.NX, self.NY)
# create an empty array to hold colors
❻ self.colors = np.zeros((3*self.N*(2*self.M + 2), ), np.float32)
# get vertices, normals, indices
❼ vertices, normals = self.compute_vertices()
❽ self.compute_colors()
# set up vertex buffer objects
❾ self.setup_vao(vertices, normals, self.colors)
Torus类在其构造函数❶中包含以下参数:外环的半径R、圆环管道的半径r,以及NX和NY,分别表示生命游戏模拟单元在 x 和 y 方向上的数量。构造函数的首要任务是加载着色器。你使用在公共glutils.py文件中定义的loadShaders()方法❷。在接下来的几行代码中,你将传递给Torus构造函数的变量存储在实例变量中,例如self.R,以便可以在其他方法中访问。然后,你将外环圆上的点数N设置为NX,即 x 方向上的单元数❸。同样,你将圆环较小半径r上的点数M设置为NY。这个方案在“将网格映射到圆环”部分中进行了讨论。
接下来,你需要做一些额外的准备工作,以渲染将形成圆环外缘带状三角形条带的几何图形。你最终将使用glMultiDrawArrays() OpenGL 方法一次性渲染所有三角形条带。该方法是一种高效的方式,允许你通过一次函数调用绘制多个三角形条带原语。如在“渲染”部分所述,每个三角形条带有 2M + 2 个顶点,而你有N个这样的条带。因此,这些三角形条带的起始索引将是[0, (2M + 2), (2M + 2) × 2, . . . , (2M + 2) × N]。因此,你设置了first_indices和counts❹,它们将是调用glMultiDrawArrays()时需要的参数。
init_colors()方法❺初始化了color_dict,它将每个网格单元映射到一个颜色——黑色或白色。我们很快会详细讲解init_colors()方法的内容。你将numpy数组colors初始化为零❻。稍后,你会将正确的值填充到这个数组中。你通过计算圆环的顶点和法线❼,以及颜色❽,并设置顶点数组对象(VAO)来渲染圆环❾,来结束构造函数。
现在,让我们来看一下刚刚提到的Torus类中的init_colors()方法:
def init_colors(self, NX, NY):
"""initialize color dictionary"""
colors = {}
c1 = [1.0, 1.0, 1.0]
for i in range(NX):
for j in range(NY):
❶ colors[(i, j)] = c1
return colors
init_colors()方法创建了一个名为colors的字典,映射从仿真单元索引(i, j)到应应用于该单元的颜色。首先,你将所有单元的颜色值设置为c1,即纯白色❶。随着生命游戏仿真进展,字典中的值将被更新,以开启或关闭单元格。
计算顶点
接下来几个方法将一起工作,用来计算所有圆环的顶点。我们从compute_vertices()方法开始:
def compute_vertices(self):
R, r, N, M = self.R, self.r, self.N, self.M
# create an empty array to hold vertices/normals
vertices = []
normals = []
for i in range(N):
# for all M points around a ring
for j in range(M+1):
# compute angle theta of point
❶ theta = (j % M) *2*math.pi/M
#---ring #1------
# compute angle
❷ alpha1 = i*2*math.pi/N
# compute transforms
❸ RM1, TM1 = self.compute_rt(R, alpha1)
# compute points
❹ Pt1, NV1 = self.compute_pt(r, theta, RM1, TM1)
#---ring #2------
# index of next ring
❺ ip1 = (i + 1) % N
# compute angle
❻ alpha2 = ip1*2*math.pi/N
# compute transforms
RM2, TM2 = self.compute_rt(R, alpha2)
# compute points
Pt2, NV2 = self.compute_pt(r, theta, RM2, TM2)
# store vertices/normals in right order for GL_TRIANGLE_STRIP
❼ vertices.append(Pt1[0:3])
vertices.append(Pt2[0:3])
# add normals
normals.append(NV1[0:3])
normals.append(NV2[0:3])
# return vertices and colors in correct format
❽ vertices = np.array(vertices, np.float32).reshape(-1)
normals = np.array(normals, np.float32).reshape(-1)
# print(vertices.shape)
return vertices, normals
compute_vertices()方法首先创建空列表来存储顶点和法线。然后,你通过使用嵌套循环来实现我们在“渲染”部分讨论的策略,来计算圆环的顶点和法线。外层循环遍历构成圆环的N个环。内层循环遍历每个环上的M个点。在循环内部,你首先计算角度theta,它是索引j的环上某一点所形成的角度❶。你使用j % M,并让内层循环遍历范围[0, M+1),这样当j等于M时,(j % M)就会回到0。这是为了完成圆环的最后一个部分。
圆环以一组带(*面三角带)呈现,每个带由两个相邻的圆环组成。你计算alpha1,即带中第一个环的角度,它在索引i处❷,然后使用alpha1通过compute_rt()方法计算这个第一个环的旋转和位移矩阵❸。接着,你将这些矩阵传递给compute_pt()方法,以计算角度theta处环上点的顶点和法线❹。我们稍后将了解compute_rt()和compute_pt()方法的具体实现。
接下来,你需要移动到索引为i+1的相邻环,使用ip1 = (i+1) % N来确保在结束时回到零❺。你计算索引ip1处环的角度alpha2❻,然后像对待第一个环一样,计算ip1环上角度theta处的顶点和法线。
从❼开始,你将相邻圆环的顶点和法线附加到方法开始时创建的列表中。你只选择每个顶点和法线的前三个坐标,如Pt1[0:3],因为所有矩阵变换都使用齐次坐标(x, y, z, w)形式进行,并且只需要(x, y, z)。此操作将顶点和法线存储为一个 Python 列表,列表中的元素是[[x1, y1, z1], [x2, y2, z2], ...]格式的三元组。然而,OpenGL 期望顶点属性以已知大小的扁*数组提供。因此,你将vertices和normals列表转换为 32 位浮点数的numpy数组❽,并使用reshape(-1)确保它们是扁*数组,形式为[x1, y1, z1, x2, y2, z2, ...]。
现在让我们来看一下帮助你计算顶点和法线的compute_rt()和compute_pt()方法。我们将从compute_rt()开始,它计算渲染圆环所需的旋转和*移矩阵:
def compute_rt(self, R, alpha):
# compute position of ring
❶ Tx = R*math.cos(alpha)
Ty = R*math.sin(alpha)
Tz = 0.0
# rotation matrix
❷ RM = np.array([
[math.cos(alpha), -math.sin(alpha), 0.0, 0.0],
[math.sin(alpha), math.cos(alpha), 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0]
], dtype=np.float32)
# translation matrix
❸ TM = np.array([
[1.0, 0.0, 0.0, Tx],
[0.0, 1.0, 0.0, Ty],
[0.0, 0.0, 1.0, Tz],
[0.0, 0.0, 0.0, 1.0]
], dtype=np.float32)
return (RM, TM)
你首先使用参数方程计算矩阵的*移分量❶。然后,你创建旋转矩阵❷和*移矩阵❸,作为numpy数组。你在“计算顶点”部分见过这些矩阵。你在方法结束时返回这些数组。
这是另一个辅助方法compute_pt(),它使用*移和旋转矩阵来确定圆环上某个点的顶点和法向量:
def compute_pt(self, r, theta, RM, TM):
# compute point coords
❶ P = np.array([r*math.cos(theta), 0.0, r*math.sin(theta), 1.0],
dtype=np.float32)
# print(P)
# apply rotation - this also gives us the vertex normals
❷ NV = np.dot(RM, P)
# apply translation
❸ Pt = np.dot(TM, NV)
return (Pt, NV)
你计算圆环上角度为theta的点P,该点位于 XZ *面❶上。然后,你通过将其与旋转矩阵❷相乘来对该点进行旋转。这也给出了该点的法向量。你将法向量与*移矩阵相乘,得到圆环上的顶点❸。
管理单元格颜色
现在我们来看看一些有助于设置圆环上单元格颜色的方法。首先是compute_colors()方法,我们最初在Torus类的构造函数中调用过。它根据“生命游戏”模拟确定的值,设置组成圆环的三角带中每个三角形的颜色。
def compute_colors(self):
R, r, N, M = self.R, self.r, self.N, self.M
# the points on the ring are generated on the X-Z plane
# then they are rotated and translated into the correct
# position on the torus
# for all N rings around the torus
for i in range(N):
# for all M points around a ring
for j in range(M+1):
# j value
jj = j % M
# store colors - same color applies to (V_i_j, V_ip1_j)
❶ col = self.colors_dict[(i, jj)]
# get index into array
❷ index = 3*(2*i*(M+1) + 2*j)
# set color
❸ self.colors[index:index+3] = col
❹ self.colors[index+3:index+6] = col
该方法遵循在“为三角形条纹着色”中描述的逻辑,在第 185 页更新colors数组中的值,该数组初始化为全零的数组。你从colors_dict中获取单元格(i, jj)的颜色,colors_dict是你之前创建的将单元格与颜色映射的字典❶。(你定义jj = j % M,使其在结束时回滚为零。)然后你计算应更新新计算值的colors数组中的索引❷。组成带的每对环有2*(M+1)个顶点,且共有N对这些环。从数组中的每个位置开始,你存储三个连续的值(单元格颜色的 RGB 分量)。因此,环中第j个颜色在第 i段圆环中的索引将由3*(2*i*(M+1) + 2*j)给出。注意,在计算索引时你使用的是j而非jj,因为你在此处存储的是计算出的值,不希望索引回滚为零。现在你得到了索引,你就可以用新的计算值更新colors数组。你同时在[index:index+3]❸和[index+3:index+6]❹处更新该数组,因为圆环上的每个单元格是一个四边形,由两个相邻的三角形组成。
现在让我们来看一下recalc_colors()方法,这是在生命游戏模拟的每一步更新 GPU 上存储的颜色值的方法:
def recalc_colors(self):
# get colors
self.compute_colors()
# bind VAO
glBindVertexArray(self.vao)
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
# set buffer data
❶ glBufferSubData(GL_ARRAY_BUFFER, 0, 4*len(self.colors), self.colors)
# unbind VAO
glBindVertexArray(0)
在每一步模拟中,单元格的颜色都会更新,这意味着你需要更新圆环上所有三角形条纹的颜色,并且你需要高效地完成这一操作,以免拖慢渲染速度。recalc_colors()方法通过使用 OpenGL 的glBufferSubData()方法❶来完成这一操作。顶点、法线和颜色被存储在 GPU 上的属性数组中。由于顶点和法线不会变化,因此你只需在开始时通过调用类构造方法中的compute_vertices()计算一次。当颜色发生变化时,glBufferSubData()会更新颜色属性数组,而不是重新创建它们。
绘制圆环
最后,这是绘制圆环的render()方法:
def render(self, pMatrix, mvMatrix):
# use shader
❶ glUseProgram(self.program)
# set proj matrix
❷ glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
# set modelview matrix
❸ glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
# bind VAO
❹ glBindVertexArray(self.vao)
# draw
❺ glMultiDrawArrays(GL_TRIANGLE_STRIP, self.first_indices,
self.counts, self.N)
# unbind VAO
glBindVertexArray(0)
该方法类似于你在上一章中看到的渲染方法。你首先调用使用着色器程序❶,并设置投影❷和模型视图❸矩阵的统一变量。然后,你绑定到顶点数组对象❹,这是你通过在类的构造函数中调用setup_vao()创建的。VAO 包含了你所需的所有属性数组缓冲区。接下来,你使用glMultiDrawArrays()方法绘制N个三角形条纹❺。你已经在Torus构造函数中计算了first_indices和counts。
实现生命游戏模拟
在第三章中,你通过使用matplotlib来可视化更新后的模拟网格值,来实现了康威的《生命游戏》(GOL)。在这里,你将之前的实现改编为更新单元格颜色的字典,而不是更新网格值,该字典将用于更新环面的颜色。相关代码封装在一个名为GOL的类中,该类在文件gol.py中声明。要查看完整的代码清单,请跳到“完整的生命游戏模拟代码”在第 209 页。
类的构造函数
首先,让我们看一下GOL类的构造函数:
class GOL:
❶ def __init__(self, NX, NY, glider):
"""GOL constructor"""
# a grid of NX x NY random values
self.NX, self.NY = NX, NY
if glider:
❷ self.addGlider(1, 1, NX, NY)
else:
❸ self.grid = np.random.choice([1, 0], NX * NY,
p=[0.2, 0.8]).reshape(NX, NY)
GOL构造函数接受网格尺寸NX和NY作为输入,以及一个布尔标志glider❶。如果设置了该标志,你将使用addGlider()方法❷初始化带有“滑翔机”图案的模拟网格。由于我们已经在第三章中讨论了此方法,这里不再详细说明。如果没有设置glider标志,你将只初始化一个随机的零和一的网格❸。
GOL类使用update()方法在每个时间步更新模拟网格。同样,这与之前的实现完全相同。
get_colors() 方法
get_colors() 方法是本章《生命游戏》实现与第三章的不同之处。该方法构建了一个字典,将每个《生命游戏》单元格映射到模拟中某一步的颜色值:开启时为黑色,关闭时为白色。这个字典将在场景更新时传递给Torus对象。
def get_colors(self):
colors = {}
❶ c1 = np.array([1.0, 1.0, 1.0], np.float32)
❷ c2 = np.array([0.0, 0.0, 0.0], np.float32)
for i in range(self.NX):
for j in range (self.NY):
if self.grid[i, j] == 1:
colors[(i, j)] = c2
else :
colors[(i, j)] = c1
return colors
在这里,你遍历模拟网格中的所有单元格,并根据网格值是0还是1来设置 RGB 颜色。可能的颜色定义为c1表示白色❶,c2表示黑色❷。这些颜色将在渲染环面时使用。
创建相机
在“控制相机”中,位于第 185 页,我们讨论了如何构建一个环绕环面旋转的相机。现在让我们来看一下其实现。代码封装在名为OrbitCamera的类中,该类在文件camera.py中声明。要查看完整的代码清单,请跳到“完整的相机代码”在第 211 页。
构造类
这是OrbitCamera类的构造函数:
class OrbitCamera:
"""helper class for viewing"""
def __init__(self, height, radius, beta_step=1):
❶ self.radius = radius
❷ self.beta = 0
❸ self.beta_step = beta_step
❹ self.height = height
# initial eye vector is (-R, 0, -H)
rr = radius/math.sqrt(2.0)
❺ self.eye = np.array([rr, rr, height], np.float32)
# compute up vector
❻ self.up = self.__compute_up_vector(self.eye )
# center is origin
❼ self.center = np.array([0, 0, 0], np.float32)
你首先设置传递给OrbitCamera构造函数的相机参数。这些参数包括相机的轨道半径❶和beta,即视角向量(投影在 XY *面上)与 x 轴的夹角❷。你还设置beta在每个相机旋转时间步长中的增量❸以及相机距离 XY *面的高度❹。
接下来,你将眼睛位置的初始值设置为位于正 x 轴和正 y 轴之间的中点,距离原点 R,并悬停在指定的 height 处 ❺。你可以通过以下公式计算出这个位置:

最后,你计算相机的向上向量 ❻ 并将中心设置为原点 (0, 0, 0) ❼。请记住,这些信息是 OpenGL 在模拟相机时所需要的,连同眼睛位置一起。
计算向上向量
这是你在 OrbitCamera 类的构造函数中调用的方法,用于计算向上向量:
def __compute_up_vector(self, E):
# N = (E x k) x E
Z = np.array([0, 0, 1], np.float32)
❶ U = np.cross(np.cross(E, Z), E)
# normalize
❷ U = U / np.linalg.norm(U)
return U
__compute_up_vector() 方法根据我们在 “控制相机” 中讨论的方式,计算出向上向量 U,该内容位于 第 185 页。具体来说,你通过叉积运算和初始的向上向量猜测值 (0, 0, 1) 来计算正确的向上向量 ❶。然后,在返回之前,你对向上向量 ❷ 进行归一化处理。
旋转相机
每次你需要围绕圆环旋转相机一步时,都会调用 OrbitCamera 类的 rotate() 方法。以下是该方法的定义:
def rotate(self):
"""rotate by one step and compute new camera parameters"""
❶ self.beta = (self.beta + self.beta_step) % 360
# recalculate eye E
❷ self.eye = np.array([self.radius*math.cos(math.radians(self.beta)),
self.radius*math.sin(math.radians(self.beta)),
self.height], np.float32)
# up vector
❸ self.up = self.__compute_up_vector(self.eye)
你通过增加增量 beta_step 来增加角度 beta,并使用 % 运算符确保当角度达到 360 度时会回绕到 0 ❶。然后,你使用新的 beta 值来计算更新后的眼睛位置 ❷,并用新的眼睛位置来通过 __compute_up_vector() 方法计算新的向上向量 ❸。
将一切组合在一起
你已经构建了渲染圆环所需的所有类。现在,你需要一些代码将这些类连接起来,创建和管理 OpenGL 窗口,并协调渲染的对象。为此,创建 RenderWindow 类(定义在 gol_torus.py 中)。它类似于 第九章 中使用的 RenderWindow 类,因此我们只讨论与当前项目独特的代码部分。要查看完整的代码清单,请跳至 “完整的 RenderWindow 代码” 在 第 211 页。
main() 函数
在我们检查 RenderWindow 类之前,先来看一下程序的 main() 函数,它启动了整个仿真。这个函数也定义在 gol_torus.py 中。
def main():
print("Starting GOL. Press ESC to quit.")
# parse arguments
parser = argparse.ArgumentParser(description="Runs Conway's Game of Life
simulation on a Torus.")
# add arguments
❶ parser.add_argument('--glider', action='store_true', required=False)
args = parser.parse_args()
glider = False
if args.glider:
❷ glider = True
❸ rw = RenderWindow(glider)
❹ rw.run()
你添加了一个名为 --glider 的命令行参数,这样你就可以通过仅带有滑翔模式的圆环来启动 ❶,并设置相应的标志 ❷。然后你创建一个 RenderWindow 对象 ❸,它初始化了程序所需的所有其他对象,并通过调用 RenderWindow 对象的 run() 方法 ❹ 来启动渲染。
RenderWindow 构造函数
RenderWindow类的构造函数以你在第九章中看到的标准 GLFW OpenGL 设置开始,包括设置窗口大小、调用渲染方法以及处理窗口和键盘事件。然后,构造函数继续进行以下生命游戏(Game of Life)特定的初始化:
class RenderWindow:
def __init__(self, glider):
--`snip`--
# create 3D
NX = 64
NY = 64
R = 4.0
r = 1.0
❶ self.torus = Torus(R, r, NX, NY)
❷ self.gol = GOL(NX, NY, glider)
# create a camera
❸ self.camera = OrbitCamera(5.0, 10.0)
# exit flag
❹ self.exitNow = False
# rotation flag
❺ self.rotate = True
# skip count
❻ self.skip = 0
首先,你为模拟设置一些参数,包括网格中细胞的数量以及环面的内外半径。然后,你使用这些参数❶创建Torus对象,并创建将管理模拟的GOL对象❷。你还创建了一个环绕相机,距离原点 5 个单位,距离 XY *面 10 个单位❸。
接下来,你设置退出标志以退出程序❹,并将旋转标志初始化为True❺。最后,你设置一个skip变量❻,它将用于控制模拟更新的频率。你将在本节后面看到skip变量是如何工作的。
run()和step()方法
RenderWindow对象的run()方法负责运行模拟,并借助step()方法。我们首先来看一下run()方法:
def run(self):
# initializer timer
glfw.glfwSetTime(0)
t = 0.0
❶ while not glfw.glfwWindowShouldClose(self.win) and not self.exitNow:
# update every x seconds
currT = glfw.glfwGetTime()
❷ if currT - t > 0.05:
# update time
t = currT
# set viewport
❸ self.width, self.height = glfw.glfwGetFramebufferSize(self.win)
self.aspect = self.width/float(self.height)
glViewport(0, 0, self.width, self.height)
# clear
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# build projection matrix
pMatrix = glutils.perspective(60.0, self.aspect, 0.1, 100.0)
mvMatrix = glutils.lookAt(self.camera.eye, self.camera.center,
self.camera.up)
# render
❹ self.torus.render(pMatrix, mvMatrix)
# step
❺ if self.rotate:
self.step()
glfw.glfwSwapBuffers(self.win)
# poll for and process events
glfw.glfwPollEvents()
# end
glfw.glfwTerminate()
渲染方案的设计是保持渲染帧循环,直到窗口关闭或按下 ESC 键❶。在继续之前,你会检查自上次渲染以来是否经过了超过 0.05 秒❷。这有助于维持最大帧率。从❸开始,你执行一些标准的 OpenGL 操作,比如设置视口、清除屏幕以及计算需要设置到顶点着色器中的当前变换。然后,你渲染环面❹并调用step()方法❺,它会旋转相机并更新生命游戏模拟一步。渲染完成后,你交换 OpenGL 缓冲区并轮询进一步的窗口事件。如果退出循环,你会调用glfwTerminate()方法进行清理。
这是step()方法,它会增加相机和模拟的步进:
def step(self):
❶ if self.skip == 9:
# update GOL
❷ self.gol.update()
❸ colors = self.gol.get_colors()
❹ self.torus.set_colors(colors)
# step
❺ self.torus.step()
# reset
❻ self.skip = 0
# update skip
❼ self.skip += 1
# rotate camera
❽ self.camera.rotate()
每次调用此方法时,它会将相机旋转一步❽。你还想更新生命游戏模拟,但以相同的速度更新模拟与相机的移动相比视觉效果会不太理想。因此,你使用skip变量来将模拟的速度减慢到相对于相机运动的 1/9。该变量从0开始,每次调用step()方法时递增❼。当skip达到9❶时,你将更新模拟的时间步长。为此,你首先调用GOL类的update()方法❷,该方法根据康威的生命游戏规则打开或关闭细胞。然后,你从模拟中获取更新后的细胞颜色❸,将它们设置到环面(torus)❹中,并调用torus.step()❺,这将更新属性缓冲区中的新颜色。最后,你将skip变量重置为0,以便该过程可以重复❻。
运行 3D 生命游戏模拟
现在我们准备好运行代码了。在终端中输入以下内容:
$ `python gol_torus.py`
图 10-8 展示了输出结果。

图 10-8:环面上的生命游戏渲染
程序会打开一个窗口,显示你精心构建的环面,并在其表面运行生命游戏模拟!随着模拟的演变,试着找到你在第三章中看到的一些熟悉的生命游戏模式。注意,光照方向保持不变,而摄像头绕环面旋转。当摄像头转动时,你将能够看到环面的明暗部分。
现在让我们尝试一下滑行器选项:
$ `python gol_torus.py --glider`
图 10-9 展示了输出结果。

图 10-9:环面上的生命游戏滑行器
放轻松,享受观看孤独的滑行器在环面表面上移动吧!
摘要
在本章中,你实现了康威生命游戏在环面上的运行。你学习了如何计算环面的顶点,如何使用 OpenGL 渲染它,并且你看到了代码如何从一个上下文(生命游戏模拟的*面渲染)适配到另一个上下文(同一模拟的 3D 渲染)。在这个过程中,我希望你对我们在第三章讨论的环面边界条件有了更直观的理解。
实验!
这是你可以尝试的一些实验:
-
- 在本章的实现中,环面由一个光源照亮。尝试在着色器代码中添加另一个光源。现在,计算得到的顶点颜色将是两个光源贡献的总和。尝试改变光源的位置和颜色,看看它们对环面照明的影响。
-
- 为了获得模拟的代表性视图,你定义了一个围绕环面 z 轴旋转的摄像头,摄像头运动在一个与 XY *面*行的*面上。现在,创建一个摄像头,让它飞越环面。你的摄像头将从负 z 轴方向向下看环面,并沿 XZ *面以固定的距离绕着环面的中心旋转。思考一下如何计算每一步运动的眼点、视线方向和上向量。
完整的环面渲染代码
这里是文件torus.py的完整代码列表:
"""
torus.py
A Python OpenGL program that generates a torus.
Author: Mahesh Venkitachalam
"""
import OpenGL
from OpenGL.GL import *
import numpy as np
import math, sys, os
import glutils
import glfw
strVS = """
# version 330 core
layout(location = 0) in vec3 aVert;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec3 aNormal;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
flat out vec3 vColor;
out vec3 vNormal;
out vec3 fragPos;
void main() {
// transform vertex
gl_Position = uPMatrix * uMVMatrix * vec4(aVert, 1.0);
fragPos = aVert;
vColor = aColor;
vNormal = aNormal;
}
"""
strFS = """
# version 330 core
flat in vec3 vColor;
in vec3 vNormal;
in vec3 fragPos;
out vec4 fragColor;
void main() {
vec3 lightPos = vec3(10.0, 10.0, 10.0);
vec3 lightColor = vec3(1.0, 1.0, 1.0);
vec3 lightDir = normalize(lightPos - fragPos);
float diff = max(dot(vNormal, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
float ambient = 0.1;
vec3 result = (ambient + diffuse) * vColor.xyz;
fragColor = vec4(result, 1.0);
}
"""
class Torus:
""" OpenGL 3D scene class"""
# initialization
def __init__(self, R, r, NX, NY):
global strVS, strFS
# create shader
self.program = glutils.loadShaders(strVS, strFS)
glProvokingVertex(GL_FIRST_VERTEX_CONVENTION)
self.pMatrixUniform = glGetUniformLocation(self.program,
b'uPMatrix')
self.mvMatrixUniform = glGetUniformLocation(self.program,
b'uMVMatrix')
# torus geometry
self.R = R
self.r = r
# grid size
self.NX = NX
self.NY = NY
# no. of points
self.N = self.NX
self.M = self.NY
# time
self.t = 0
# compute parameters for glMultiDrawArrays
M1 = 2*self.M + 2
self.first_indices = [2*M1*i for i in range(self.N)]
self.counts = [2*M1 for i in range(self.N)]
# colors: {(i, j) : (r, g, b)}
# with NX * NY entries
self.colors_dict = self.init_colors(self.NX, self.NY)
# create an empty array to hold colors
self.colors = np.zeros((3*self.N*(2*self.M + 2), ), np.float32)
# get vertices, normals, indices
vertices, normals = self.compute_vertices()
self.compute_colors()
# set up vertex buffer objects
self.setup_vao(vertices, normals, self.colors)
def init_colors(self, NX, NY):
"""initialize color dictionary"""
colors = {}
c1 = [1.0, 1.0, 1.0]
for i in range(NX):
for j in range (NY):
colors[(i, j)] = c1
return colors
def compute_rt(self, R, alpha):
# compute position of ring
Tx = R*math.cos(alpha)
Ty = R*math.sin(alpha)
Tz = 0.0
# rotation matrix
RM = np.array([
[math.cos(alpha), -math.sin(alpha), 0.0, 0.0],
[math.sin(alpha), math.cos(alpha), 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0]
], dtype=np.float32)
# translation matrix
TM = np.array([
[1.0, 0.0, 0.0, Tx],
[0.0, 1.0, 0.0, Ty],
[0.0, 0.0, 1.0, Tz],
[0.0, 0.0, 0.0, 1.0]
], dtype=np.float32)
return (RM, TM)
def compute_pt(self, r, theta, RM, TM):
# compute point coords
P = np.array([r*math.cos(theta), 0.0, r*math.sin(theta), 1.0],
dtype=np.float32)
# print(P)
# apply rotation - this also gives us the vertex normals
NV = np.dot(RM, P)
# normalize
# NV = NV / np.linalg.norm(NV)
# apply translation
Pt = np.dot(TM, NV)
return (Pt, NV)
def compute_vertices(self):
"""compute vertices for the torus
returns np float32 array of n coords (x, y, z): shape (3*n, )
"""
R, r, N, M = self.R, self.r, self.N, self.M
# create an empty array to hold vertices/normals
vertices = []
normals = []
# the points on the ring are generated on the X-Z plane
# then they are rotated and translated into the correct
# position on the torus
# for all N rings around the torus
for i in range(N):
# for all M points around a ring
for j in range(M+1):
# compute angle theta of point
theta = (j % M) *2*math.pi/M
#---ring #1------
# compute angle
alpha1 = i*2*math.pi/N
# compute transforms
RM1, TM1 = self.compute_rt(R, alpha1)
# compute points
Pt1, NV1 = self.compute_pt(r, theta, RM1, TM1)
#---ring #2------
# index of next ring
ip1 = (i + 1) % N
# compute angle
alpha2 = ip1*2*math.pi/N
# compute transforms
RM2, TM2 = self.compute_rt(R, alpha2)
# compute points
Pt2, NV2 = self.compute_pt(r, theta, RM2, TM2)
# store vertices/normals in right order for GL_TRIANGLE_STRIP
vertices.append(Pt1[0:3])
vertices.append(Pt2[0:3])
# add normals
normals.append(NV1[0:3])
normals.append(NV2[0:3])
# return vertices and colors in correct format
vertices = np.array(vertices, np.float32).reshape(-1)
normals = np.array(normals, np.float32).reshape(-1)
# print(vertices.shape)
return vertices, normals
def compute_colors(self):
"""compute vertices for the torus
returns np float32 array of n coords (x, y, z): shape (3*n, )
"""
R, r, N, M = self.R, self.r, self.N, self.M
# the points on the ring are generated on the X-Z plane
# then they are rotated and translated into the correct
# position on the torus
# for all N rings around the torus
for i in range(N):
# for all M points around a ring
for j in range(M+1):
# j value
jj = j % M
# store colors - same color applies to (V_i_j, V_ip1_j)
col = self.colors_dict[(i, jj)]
# get index into array
index = 3*(2*i*(M+1) + 2*j)
# set color
self.colors[index:index+3] = col
self.colors[index+3:index+6] = col
def setup_vao(self, vertices, normals, colors):
# set up vertex array object (VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
# --------
# vertices
# --------
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
# set buffer data
glBufferData(GL_ARRAY_BUFFER, 4*len(vertices), vertices,
GL_STATIC_DRAW)
# enable vertex attribute array
glEnableVertexAttribArray(0)
# set buffer data pointer
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, None)
# normals
# --------
self.normalBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.normalBuffer)
# set buffer data
glBufferData(GL_ARRAY_BUFFER, 4*len(normals), normals,
GL_STATIC_DRAW)
# enable vertex attribute array
glEnableVertexAttribArray(2)
# set buffer data pointer
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 0, None)
# --------
# colors
# --------
self.colorBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
# set buffer data
glBufferData(GL_ARRAY_BUFFER, 4*len(colors), colors,
GL_STATIC_DRAW)
# enable color attribute array
glEnableVertexAttribArray(1)
# set buffer data pointer
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, None)
# unbind VAO
glBindVertexArray(0)
def set_colors(self, colors):
self.colors_dict = colors
self.recalc_colors()
def recalc_colors(self):
# get colors
self.compute_colors()
# bind VAO
glBindVertexArray(self.vao)
# --------
# colors
# --------
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
# set buffer data
glBufferSubData(GL_ARRAY_BUFFER, 0, 4*len(self.colors), self.colors)
# unbind VAO
glBindVertexArray(0)
# step
def step(self):
# recompute colors
self.recalc_colors()
# render
def render(self, pMatrix, mvMatrix):
# use shader
glUseProgram(self.program)
# set proj matrix
glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
# set modelview matrix
glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
# bind VAO
glBindVertexArray(self.vao)
# draw
glMultiDrawArrays(GL_TRIANGLE_STRIP, self.first_indices,
self.counts, self.N)
# unbind VAO
glBindVertexArray(0)
完整的生命游戏模拟代码
这里是文件gol.py的完整代码列表。
"""
gol.py
实现康威生命游戏。
作者:Mahesh Venkitachalam
"""
import numpy as np
class GOL:
"""GOL - 实现康威生命游戏的类
"""
def init(self, NX, NY, glider):
"""GOL 构造函数"""
一个 NX x NY 的随机值网格
self.NX, self.NY = NX, NY
if glider:
self.addGlider(1, 1, NX, NY)
else:
self.grid = np.random.choice([1, 0], NX * NY, p=[0.2, 0.8]).reshape(NX, NY)
def addGlider(self, i, j, NX, NY):
"""在(i, j)处添加一个滑翔机,左上角的单元格为(i, j)"""
self.grid = np.zeros(NX * NY).reshape(NX, NY)
glider = np.array([[0, 0, 1],
[1, 0, 1],
[0, 1, 1]])
self.grid[i:i+3, j:j+3] = glider
def update(self):
"""更新 GOL 模拟的一个时间步骤"""
复制网格,因为我们需要 8 个邻居来计算
然后逐行进行
newGrid = self.grid.copy()
NX, NY = self.NX, self.NY
for i in range(NX):
for j in range(NY):
计算 8 个邻居的和
使用环面边界条件 - x 和 y 会环绕
使得模拟在环面上进行
total = (self.grid[i, (j-1) % NY] + self.grid[i, (j+1) % NY] +
self.grid[(i-1) % NX, j] + self.grid[(i+1) % NX, j] +
self.grid[(i-1) % NX, (j-1) % NY] + self.grid[(i-1) % NX, (j+1) % NY] +
self.grid[(i+1) % NX, (j-1) % NY] + self.grid[(i+1) % NX, (j+1) % NY])
应用康威的规则
if self.grid[i, j] == 1:
if (total < 2) or (total > 3):
newGrid[i, j] = 0
else:
if total == 3:
newGrid[i, j] = 1
更新数据
self.grid[:] = newGrid[:]
def get_colors(self):
"""返回颜色字典"""
colors = {}
c1 = np.array([1.0, 1.0, 1.0], np.float32)
c2 = np.array([0.0, 0.0, 0.0], np.float32)
for i in range(self.NX):
for j in range(self.NY):
if self.grid[i, j] == 1:
colors[(i, j)] = c2
else :
colors[(i, j)] = c1
return colors
完整的相机代码
这是文件camera.py中的完整代码:
"""
camera.py
A simple camera class for OpenGL rendering.
Author: Mahesh Venkitachalam
"""
import numpy as np
import math
class OrbitCamera:
"""helper class for viewing"""
def __init__(self, height, radius, beta_step=1):
self.radius = radius
self.beta = 0
self.beta_step = beta_step
self.height = height
# initial eye vector is (-R, 0, -H)
rr = radius/math.sqrt(2.0)
self.eye = np.array([rr, rr, height], np.float32)
# compute up vector
self.up = self.__compute_up_vector(self.eye )
# center is origin
self.center = np.array([0, 0, 0], np.float32)
def __compute_up_vector(self, E):
"""compute up vector
N = (E x k) x E
"""
# N = (E x k) x E
Z = np.array([0, 0, 1], np.float32)
U = np.cross(np.cross(E, Z), E)
# normalize
U = U / np.linalg.norm(U)
return U
def rotate(self):
"""rotate by one step and compute new camera parameters"""
self.beta = (self.beta + self.beta_step) % 360
# recalculate eye E
self.eye = np.array([self.radius*math.cos(math.radians(self.beta)),
self.radius*math.sin(math.radians(self.beta)),
self.height], np.float32)
# up vector
self.up = self.__compute_up_vector(self.eye)
完整的 RenderWindow 代码
完整代码清单对于gol_torus.py,包括RenderWindow类和main()函数,如下所示。
"""
gol_torus.py
一个显示环面图形的 Python OpenGL 程序。
作者:Mahesh Venkitachalam
"""
import OpenGL
来自 OpenGL.GL 导入 *
import numpy, math, sys, os
import argparse
import glutils
import glfw
来自 torus 的 Torus
来自 camera 的 OrbitCamera
来自 gol 的 GOL
class RenderWindow:
"""GLFW 渲染窗口类"""
def init(self, glider):
保存当前工作目录
cwd = os.getcwd()
初始化 glfw - 这会改变 cwd
glfw.glfwInit()
恢复 cwd
os.chdir(cwd)
版本提示
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MAJOR, 3)
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MINOR, 3)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_PROFILE, glfw.GLFW_OPENGL_CORE_PROFILE)
创建一个窗口
self.width, self.height = 640, 480
self.aspect = self.width/float(self.height)
self.win = glfw.glfwCreateWindow(self.width, self.height, b'gol_torus')
使上下文当前
glfw.glfwMakeContextCurrent(self.win)
初始化 GL
glViewport(0, 0, self.width, self.height)
glEnable(GL_DEPTH_TEST)
glClearColor(0.2, 0.2, 0.2, 1.0)
glClearColor(0.11764706, 0.11764706, 0.11764706, 1.0)
设置窗口回调
glfw.glfwSetMouseButtonCallback(self.win, self.onMouseButton)
glfw.glfwSetKeyCallback(self.win, self.onKeyboard)
创建 3D
NX = 64
NY = 64
R = 4.0
r = 1.0
self.torus = Torus(R, r, NX, NY)
self.gol = GOL(NX, NY, glider)
创建相机
self.camera = OrbitCamera(5.0, 10.0)
退出标志
self.exitNow = False
旋转标志
self.rotate = True
跳过计数
self.skip = 0
def onMouseButton(self, win, button, action, mods):
打印 'mouse button: ', win, button, action, mods
pass
def onKeyboard(self, win, key, scancode, action, mods):
打印 'keyboard: ', win, key, scancode, action, mods
if action == glfw.GLFW_PRESS:
按 ESC 退出
if key == glfw.GLFW_KEY_ESCAPE:
self.exitNow = True
elif key == glfw.GLFW_KEY_R:
self.rotate = not self.rotate
def run(self):
初始化计时器
glfw.glfwSetTime(0)
t = 0.0
while not glfw.glfwWindowShouldClose(self.win) and not self.exitNow:
每 x 秒更新
currT = glfw.glfwGetTime()
if currT - t > 0.05:
更新时间
t = currT
设置视口
self.width, self.height = glfw.glfwGetFramebufferSize(self.win)
self.aspect = self.width/float(self.height)
glViewport(0, 0, self.width, self.height)
清理
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
构建投影矩阵
pMatrix = glutils.perspective(60.0, self.aspect, 0.1, 100.0)
mvMatrix = glutils.lookAt(self.camera.eye, self.camera.center, self.camera.up)
渲染
self.torus.render(pMatrix, mvMatrix)
步骤
if self.rotate:
self.step()
glfw.glfwSwapBuffers(self.win)
轮询并处理事件
glfw.glfwPollEvents()
结束
glfw.glfwTerminate()
def step(self):
if self.skip == 9:
更新 GOL
self.gol.update()
colors = self.gol.get_colors()
self.torus.set_colors(colors)
步骤
self.torus.step()
重置
self.skip = 0
更新跳过
self.skip += 1
旋转相机
self.camera.rotate()
main() 函数
def main():
print("开始 GOL。按 ESC 退出。")
解析参数
parser = argparse.ArgumentParser(description="运行康威的生命游戏模拟")
在 Torus 上。)
添加参数
parser.add_argument('--glider', action='store_true', required=False)
args = parser.parse_args()
设置参数
glider = False
if args.glider:
glider = True
rw = RenderWindow(glider)
rw.run()
调用 main
if name == 'main':
main()
第十一章:# 卷积渲染

磁共振成像(MRI)和计算机断层扫描(CT)是诊断过程,用于生成体积数据,这类数据由一组 2D 图像组成,显示了通过 3D 体积的横截面。卷积渲染是一种计算机图形技术,用于从这种类型的体积数据中构建 3D 图像。尽管卷积渲染通常用于分析医学扫描数据,但它也可以用于在地质学、考古学和分子生物学等学术领域中创建 3D 科学可视化。
MRI 和 CT 扫描捕获的数据通常采用N[x]×N[y]×N[z]维度的 3D 网格形式。换句话说,有N[z]个 2D“切片”,每个切片的大小为N[x]×N[y]。卷积渲染算法用于以某种透明度显示收集到的切片数据,采用各种技术突出渲染体积中感兴趣的部分。
在本项目中,您将研究一种叫做体积光线投射的卷积渲染方法,它充分利用图形处理单元(GPU)通过 OpenGL 着色语言(GLSL)着色器执行计算。您的代码会对每个屏幕上的像素进行计算,并利用 GPU 进行高效的并行计算。您将使用一组包含来自 3D 数据集切片的 2D 图像的文件夹,通过体积光线投射算法构建卷积渲染图像。您还将实现一种方法,展示数据在 x、y 和 z 方向上的 2D 切片,用户可以使用箭头键浏览这些切片。键盘命令将允许用户在 3D 渲染和 2D 切片之间切换。
以下是本项目中涵盖的一些主题:
-
• 使用 GLSL 进行 GPU 计算
-
• 创建顶点和片段着色器
-
• 表示 3D 体积数据并使用体积光线投射算法
-
• 使用
numpy数组进行 3D 变换矩阵
工作原理
渲染 3D 数据集有多种方式。在本项目中,您将使用体积光线投射方法,这是一种基于图像的渲染技术,用于逐像素生成最终图像。与之相比,典型的 3D 渲染方法是基于对象的:它们从 3D 对象表示开始,然后应用变换生成投影 2D 图像中的像素。
在你将要使用的体积光线投射方法中,对于输出图像中的每一个像素,都会向离散的 3D 体积数据集发射一条光线,这些数据集通常表示为一个长方体。当光线穿过体积时,数据会按照规则的间隔进行采样,并将这些采样结果进行组合或合成,以计算最终图像的颜色值或强度。(你可以将这个过程理解为像是将一堆透明片堆叠在一起,并将其举到明亮的光源下,以查看所有图层的混合效果。)
虽然体积光线投射渲染实现通常会采用诸如应用渐变以改善最终渲染效果、滤波以隔离 3D 特征、以及使用空间优化技术以提高速度等技巧,但你将仅实现基本的光线投射算法,并通过 X 射线投射合成最终图像。(我的实现主要基于 2003 年 Kruger 和 Westermann 在该领域的开创性论文。1)
数据格式
在这个项目中,你将使用来自斯坦福体积数据档案的 3D 扫描医学数据。2 该档案提供了一些优秀的 3D 医学数据集(包括 CT 和 MRI)TIFF 图像,每一幅图像代表体积的 2D 切面。你将把这些图像文件夹读入 OpenGL 3D 纹理;这有点像是将一组 2D 图像堆叠起来形成一个长方体,如图 11-1 所示。

图 11-1:从 2D 切片构建 3D 体积数据
回想一下第九章中提到的,在 OpenGL 中,2D 纹理是通过 2D 坐标(s,t)来寻址的。类似地,3D 纹理是通过形如(s,t,p)的 3D 纹理坐标来寻址的。正如你将看到的,将体积数据存储为 3D 纹理可以让你快速访问数据,并为光线投射方案提供所需的插值值。
光线生成
你在这个项目中的目标是生成 3D 体积数据的透视投影,如图 11-2 所示。该图展示了 OpenGL 视锥体,如第九章所讨论。具体来说,它展示了一个从眼睛发出的光线如何在**面进入视锥体,通过包含体积数据的立方体体积,并从远*面后方退出。

图 11-2:3D 体积数据的透视投影
要实现光线投射,你需要生成进入体积的光线。对于输出窗口中的每个像素,如 图 11-2 所示,你需要生成一个进入体积的向量 R,你可以认为该体积是一个单位立方体(我称之为 颜色立方体),它的坐标范围在 (0, 0, 0) 和 (1, 1, 1) 之间。你为立方体内的每个点着色,RGB 值等于该点的 3D 坐标。原点的颜色是 (0, 0, 0),即黑色;(1, 0, 0) 角是红色;与原点对角的立方体上的点的颜色是 (1, 1, 1),即白色。图 11-3 显示了这个立方体。

图 11-3:一个颜色立方体
注意:在 OpenGL 中,颜色可以表示为一组 8 位无符号值 (r, g, b),其中 r、g 和 b 的取值范围是 [0, 255]。它也可以表示为一组三个 32 位浮点值 (r, g, b),其中 r、g 和 b 的取值范围是 [0.0, 1.0]。这两种表示方式是等价的。例如,前者中的红色(255, 0, 0)与后者中的(1.0, 0.0, 0.0)是一样的。
为了绘制这个立方体,首先使用 OpenGL 基本图元 GL_TRIANGLES 绘制其六个面。然后为每个顶点着色,并在 OpenGL 光栅化多边形时利用其提供的插值功能,处理各顶点之间的颜色。例如,图 11-4(a) 显示了立方体的三个前面。立方体的后面通过在 图 11-4(b) 中设置 OpenGL 剔除前面来绘制。

图 11-4:用于计算光线的颜色立方体
如果你将 图 11-4(a) 中的颜色从 图 11-4(b) 中减去,方法是将 (r, g, b)[front] 减去 (r, g, b)[back],你实际上计算了一组从立方体前面到后面的向量,因为这个立方体上每个颜色 (r, g, b) 都等同于该颜色位置的 3D 坐标。图 11-4(c) 显示了结果。(为了说明的目的,负值已被转换为正值,因为负数不能直接显示为颜色。)读取像素的颜色值 (r, g, b),如 图 11-4(c) 所示,会给出穿过该点的光线的 (r[x],r[y],r[z]) 坐标。
一旦你获得了光线投射结果,就可以将它们渲染为图像或 2D 纹理,以便稍后与 OpenGL 的帧缓冲对象(FBO)功能一起使用。在生成此纹理后,你可以在着色器中访问它,以实现光线投射算法。
GPU 中的光线投射
要实现光线投射算法,首先在 FBO 中绘制颜色立方体的背面。接着,前面绘制到屏幕上。大部分光线投射算法发生在第二次渲染的片段着色器中,该着色器针对输出的每个像素运行。光线是通过从纹理中读取颜色立方体的背面颜色,并减去传入片段的前面颜色来计算的。然后,计算出的光线被用来通过 3D 体积纹理数据累加和计算最终的像素值,这些数据在着色器中可用。
显示 2D 切片
除了 3D 渲染,你还可以通过从 3D 数据中提取垂直于 x、y 或 z 轴的 2D 截面并将其作为纹理应用于四边形,来显示数据的 2D 切片。由于你将体积数据存储为 3D 纹理,你可以通过指定纹理坐标(s,t,p)轻松获取所需的数据。OpenGL 内建的纹理插值使你能够在 3D 纹理内部的任何位置获得纹理值。
OpenGL 窗口
与其他 OpenGL 项目一样,该项目使用 GLFW 库来显示 OpenGL 窗口。你将使用处理程序来绘制、调整窗口大小以及处理键盘事件。你将使用键盘事件在体积和切片渲染之间切换,还可以旋转和切片 3D 数据。
要求
你将使用流行的 Python OpenGL 绑定库 PyOpenGL 进行渲染。你还将使用 Python 图像库(PIL)加载来自体积数据集的 2D 图像,并使用 numpy 数组表示 3D 坐标和变换矩阵。
代码
你将从读取的图像文件中的体积数据生成 3D 纹理。接着,你会学习一种颜色立方体技术,用于生成从眼睛发出的射线指向体积,这在实现体积光线投射算法时是一个关键概念。你将学习如何定义立方体几何体,以及如何绘制立方体的前后面。然后,你将探索体积光线投射算法及相关的顶点着色器和片段着色器。最后,你将学习如何实现体积数据的 2D 切片。
这个项目有七个 Python 文件:
glutils.py 包含 OpenGL 着色器、变换等的实用方法
makedata.py 包含用于创建测试用体积数据的实用方法
raycast.py 实现了用于光线投射的 RayCastRender 类
raycube.py 实现了用于 RayCastRender 的 RayCube 类
slicerender.py 实现了用于体积数据 2D 切片的 SliceRender 类
volreader.py 包含将体积数据读入 OpenGL 3D 纹理的实用方法
volrender.py 包含创建 GLFW 窗口和渲染器的主要方法
本章将涵盖除了两个文件以外的所有文件。makedata.py文件与本章的其他项目文件一起位于github.com/mkvenkit/pp2e/tree/main/volrender。glutils.py文件可以从github.com/mkvenkit/pp2e/tree/main/common下载。
生成 3D 纹理
第一步是从包含图像的文件夹中读取体积数据,如以下代码所示。要查看完整的volreader.py代码,请跳转到“完整的 3D 纹理代码”在第 241 页。你也可以在github.com/mkvenkit/pp2e/tree/main/volrender找到volreader.py文件。请注意,此文件中的loadTexture()函数用于打开图像文件,读取内容,并创建一个 OpenGL 纹理对象,随后该对象将在渲染中使用。
def loadVolume(dirName):
"""read volume from directory as a 3D texture"""
# list images in directory
❶ files = sorted(os.listdir(dirName))
print('loading images from: %s' % dirName)
imgDataList = []
count = 0
width, height = 0, 0
for file in files:
❷ file_path = os.path.abspath(os.path.join(dirName, file))
try:
# read image
❸ img = Image.open(file_path)
imgData = np.array(img.getdata(), np.uint8)
# check if all images are of the same size
❹ if count is 0:
width, height = img.size[0], img.size[1]
imgDataList.append(imgData)
else:
❺ if (width, height) == (img.size[0], img.size[1]):
imgDataList.append(imgData)
else:
print('mismatch')
raise RunTimeError("image size mismatch")
count += 1
# print img.size
❻ except:
# skip
print('Invalid image: %s' % file_path)
# load image data into single array
depth = count
❼ data = np.concatenate(imgDataList)
print('volume data dims: %d %d %d' % (width, height, depth))
loadVolume()方法首先使用os模块的listdir()方法列出给定目录中的文件❶。然后,你逐一加载图像文件。为此,你将当前文件名附加到目录路径,使用os.path.abspath()和os.path.join()❷,这样就避免了需要处理相对路径和操作系统特定的路径约定。(你经常会在遍历文件和目录的 Python 代码中看到这个有用的惯用法。)接下来,你使用 PIL 的 Image 类将当前图像加载到 8 位的 numpy 数组中❸。如果指定的文件不是图像或图像加载失败,则会抛出异常,你可以通过打印错误信息来捕获此异常❻。
因为你正在将这些图像切片加载到一个 3D 纹理中,所以需要确保它们的尺寸(宽度×高度)一致,你可以在❹和❺确认这一点。你存储第一张图像的尺寸,并将其与新加载的图像进行比较。一旦所有图像加载到独立的数组中,你可以使用 numpy 的 concatenate() 方法将这些数组连接成一个包含 3D 数据的最终数组❼。
loadVolume()函数继续将 3D 图像数据数组加载到 OpenGL 纹理中:
# load data into 3D texture
❶ texture = glGenTextures(1)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
glBindTexture(GL_TEXTURE_3D, texture)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
❷ glTexImage3D(GL_TEXTURE_3D, 0, GL_RED,
width, height, depth, 0,
GL_RED, GL_UNSIGNED_BYTE, data)
# return texture
❸ return (texture, width, height, depth)
在这里,你会创建一个 OpenGL 纹理❶并设置过滤和解包的参数。然后,你将 3D 数据数组加载到 OpenGL 纹理中❷。此处使用的格式是GL_RED,数据格式是GL_UNSIGNED_BYTE,因为每个像素的数据仅包含一个 8 位值。最后,你返回 OpenGL 纹理 ID 和 3D 纹理的尺寸❸。
生成光线
生成射线的代码被封装在一个名为RayCube的类中。这个类负责绘制颜色立方体,并且有方法将立方体的背面绘制到 FBO 或纹理中,将前面绘制到屏幕上。如需查看完整的raycube.py代码,可以跳到“完整的射线生成代码”,该内容位于第 242 页。你还可以在github.com/mkvenkit/pp2e/tree/main/volrender找到raycube.py文件。
首先,让我们定义此类使用的着色器。着色器将在RayCube类的构造函数中编译:
❶ strVS = """
# version 410 core
layout(location = 1) in vec3 cubePos;
layout(location = 2) in vec3 cubeCol;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
out vec4 vColor;
void main()
{
// set back-face color
vColor = vec4(cubeCol.rgb, 1.0);
// transformed position
vec4 newPos = vec4(cubePos.xyz, 1.0);
// set position
gl_Position = uPMatrix * uMVMatrix * newPos;
}
"""
❷ strFS = """
# version 410 core
in vec4 vColor;
out vec4 fragColor;
void main()
{
fragColor = vColor;
}
"""
你定义了RayCube类使用的顶点着色器❶。该着色器有两个输入属性,cubePos和cubeCol,分别用于访问顶点的位置和颜色值。模型视图和投影矩阵通过统一变量uMVMatrix和uPMatrix传入。vColor变量被声明为输出,因为它需要传递给片段着色器,在那里它将被插值。片段着色器❷将片段的颜色设置为顶点着色器中传入的(插值后的)vColor值。
定义颜色立方体几何形状
现在,让我们看看RayCube类的构造函数中定义的颜色立方体几何形状:
class RayCube:
def __init__(self, width, height):
--`snip`--
# cube vertices
❶ vertices = numpy.array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
1.0, 0.0, 1.0,
1.0, 1.0, 1.0,
0.0, 1.0, 1.0
], numpy.float32)
# cube colors
❷ colors = numpy.array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
1.0, 0.0, 1.0,
1.0, 1.0, 1.0,
0.0, 1.0, 1.0
], numpy.float32)
# individual triangles
❸ indices = numpy.array([
4, 5, 7,
7, 5, 6,
5, 1, 6,
6, 1, 2,
1, 0, 2,
2, 0, 3,
0, 4, 3,
3, 4, 7,
6, 2, 7,
7, 2, 3,
4, 0, 5,
5, 0, 1
], numpy.int16)
你将立方体的几何形状❶和颜色❷定义为numpy数组。注意,这两种定义中的数值是相同的。正如我们之前讨论的,颜色立方体中每个像素的颜色对应于该像素的三维坐标。颜色立方体有六个面,每个面可以由两个三角形构成,共有 6×6 或 36 个顶点。但你并不需要指定所有 36 个顶点,而是只指定立方体的八个角❶,然后使用indices数组❸定义由这些角点构成的三角形,如图 11-5 所示。例如,前两个三元组索引(4, 5, 7)和(7, 5, 6)定义了立方体顶部面的三角形。

图 11-5:通过索引,立方体可以表示为一组三角形,每个面由两个三角形组成。
接下来,仍然在RayCube类的构造函数中,你需要将顶点信息放入缓冲区:
设置顶点数组对象(VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
顶点缓冲区
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(vertices), vertices, GL_STATIC_DRAW)
顶点缓冲区 – 立方体顶点颜色
self.colorBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(colors), colors, GL_STATIC_DRAW)
索引缓冲区
self.indexBuffer = glGenBuffers(1)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.indexBuffer); ❶
glBufferData(GL_ELEMENT_ARRAY_BUFFER, 2*len(indices), indices,
GL_STATIC_DRAW)
与之前的项目一样,你会创建并绑定一个顶点数组对象(VAO),然后定义它管理的缓冲区。这里的一个不同之处是,indices 数组被指定为 GL_ELEMENT_ARRAY_BUFFER ❶,这意味着它的缓冲区中的元素将用于索引和访问颜色与顶点缓冲区中的数据。
创建帧缓冲对象
现在让我们跳转到 RayCube 类的方法,它创建了帧缓冲对象,你将进行渲染:
def initFBO(self):
# create frame buffer object
self.fboHandle = glGenFramebuffers(1)
# create texture
self.texHandle = glGenTextures(1)
# create depth buffer
self.depthHandle = glGenRenderbuffers(1)
# bind
glBindFramebuffer(GL_FRAMEBUFFER, self.fboHandle)
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, self.texHandle)
# set parameters to draw the image at different sizes
❶ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
# set up texture
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.width, self.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, None)
# bind texture to FBO
❷ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, self.texHandle, 0)
# bind
❸ glBindRenderbuffer(GL_RENDERBUFFER, self.depthHandle)
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24,
self.width, self.height)
# bind depth buffer to FBO
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_RENDERBUFFER, self.depthHandle)
# check status
❹ status = glCheckFramebufferStatus(GL_FRAMEBUFFER)
if status == GL_FRAMEBUFFER_COMPLETE:
pass
# print "fbo %d complete" % self.fboHandle
elif status == GL_FRAMEBUFFER_UNSUPPORTED:
print "fbo %d unsupported" % self.fboHandle
else:
print "fbo %d Error" % self.fboHandle
在这里,你创建一个帧缓冲对象、一个 2D 纹理和一个渲染缓冲对象;然后设置纹理参数 ❶。纹理被绑定到帧缓冲 ❷,在 ❸ 和接下来的几行代码中,渲染缓冲设置了一个 24 位深度缓冲并附加到帧缓冲。接下来,你检查帧缓冲的状态 ❹,并在出现问题时打印状态信息。现在,只要帧缓冲和渲染缓冲正确绑定,所有的渲染都会输出到纹理中。
渲染立方体的背面
以下是渲染颜色立方体背面的代码:
def renderBackFace(self, pMatrix, mvMatrix):
"""renders back-face of ray-cube to a texture and returns it"""
# render to FBO
❶ glBindFramebuffer(GL_FRAMEBUFFER, self.fboHandle)
# set active texture
glActiveTexture(GL_TEXTURE0)
# bind to FBO texture
glBindTexture(GL_TEXTURE_2D, self.texHandle)
# render cube with face culling enabled
❷ self.renderCube(pMatrix, mvMatrix, self.program, True)
# unbind texture
❸ glBindTexture(GL_TEXTURE_2D, 0)
glBindFramebuffer(GL_FRAMEBUFFER, 0)
glBindRenderbuffer(GL_RENDERBUFFER, 0)
# return texture ID
❹ return self.texHandle
首先你绑定 FBO ❶,设置活动纹理单元,并绑定纹理句柄,以便你可以渲染到 FBO。然后你调用 RayCube 类的 renderCube() 方法 ❷,我们稍后会看看它。该方法将面剔除标志作为参数,允许你使用相同的代码绘制立方体的正面或背面。你将该标志设置为 True,以便在 FBO 纹理中显示背面。
接下来,你进行必要的调用,以解除 FBO 的绑定,这样其他渲染代码就不会受到影响 ❸。最后,你返回 FBO 纹理 ID ❹,以便在算法的下一阶段使用。
渲染立方体的正面
以下代码用于在射线投射算法的第二次渲染过程中绘制颜色立方体的正面:
def renderFrontFace(self, pMatrix, mvMatrix, program):
"""render front-face of ray-cube"""
# no face culling
self.renderCube(pMatrix, mvMatrix, program, False)
该方法仅调用 renderCube(),并将面剔除标志设置为 False,以便显示正面。
渲染整个立方体
现在让我们看一下 renderCube() 方法,它绘制了前面讨论的颜色立方体:
def renderCube(self, pMatrix, mvMatrix, program, cullFace):
"""renderCube uses face culling if flag set"""
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# set shader program
glUseProgram(program)
# set projection matrix
glUniformMatrix4fv(glGetUniformLocation(program, b'uPMatrix'),
1, GL_FALSE, pMatrix)
# set modelview matrix
glUniformMatrix4fv(glGetUniformLocation(program, b'uMVMatrix'),
1, GL_FALSE, mvMatrix)
# enable face culling
glDisable(GL_CULL_FACE)
❶ if cullFace:
glFrontFace(GL_CCW)
glCullFace(GL_FRONT)
glEnable(GL_CULL_FACE)
# bind VAO
glBindVertexArray(self.vao)
# animated slice
❷ glDrawElements(GL_TRIANGLES, self.nIndices, GL_UNSIGNED_SHORT, None)
# unbind VAO
glBindVertexArray(0)
# reset cull face
if cullFace:
# disable face culling
glDisable(GL_CULL_FACE)
你清除颜色和深度缓冲区,选择着色器程序,并设置变换矩阵。然后你设置一个标志来控制面剔除 ❶,它决定是否绘制立方体的正面或背面。注意,你使用 glDrawElements() ❷,因为你使用的是索引数组来渲染立方体,而不是顶点数组。
调整窗口大小
因为 FBO 是为特定的窗口大小创建的,当窗口大小变化时,你需要重新创建它。为此,你为 RayCube 类创建了一个调整大小处理函数,如下所示:
def reshape(self, width, height):
self.width = width
self.height = height
self.aspect = width/float(height)
# re-create FBO
self.clearFBO()
self.initFBO()
reshape() 方法在 OpenGL 窗口大小调整时被调用。它检查新的窗口尺寸,然后清除并重新创建 FBO。
实现射线投射算法
接下来,你将在RayCastRender类中实现射线投射算法。算法的核心在于该类使用的片段着色器,它还借助RayCube类生成射线。要查看完整的raycast.py代码,请跳到“完整的体积射线投射代码”,在第 248 页。你也可以在github.com/mkvenkit/pp2e/tree/main/volrender找到该文件。
在RayCastRender的构造函数中,首先创建一个RayCube对象,并加载着色器:
class RayCastRender:
def __init__(self, width, height, volume):
"""RayCastRender construction"""
# create RayCube object
❶ self.raycube = raycube.RayCube(width, height)
# set dimensions
self.width = width
self.height = height
self.aspect = width/float(height)
# create shader
❷ self.program = glutils.loadShaders(strVS, strFS)
# texture
❸ self.texVolume, self.Nx, self.Ny, self.Nz = volume
# initialize camera
❹ self.camera = Camera()
构造函数创建了一个RayCube类型的对象 ❶,用于生成射线。你加载射线投射所需的着色器 ❷,然后设置 OpenGL 的 3D 纹理和维度 ❸,这些数据作为元组volume传递给构造函数。接着,你创建一个Camera对象 ❹,用来设置 OpenGL 的透视变换,以进行 3D 渲染。
注意:Camera类,也在raycast.py中声明,基本与第十章中使用的类相同。你将在第 248 页的完整代码清单中看到它。
这是RayCastRender的渲染方法:
def draw(self):
# build projection matrix
❶ pMatrix = glutils.perspective(45.0, self.aspect, 0.1, 100.0)
# modelview matrix
❷ mvMatrix = glutils.lookAt(self.camera.eye, self.camera.center,
self.camera.up)
# render
# generate ray-cube back-face texture
❸ texture = self.raycube.renderBackFace(pMatrix, mvMatrix)
# set shader program
❹ glUseProgram(self.program)
# set window dimensions
glUniform2f(glGetUniformLocation(self.program, b"uWinDims"),
float(self.width), float(self.height))
# bind to texture unit 0, which represents back-faces of cube
❺ glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, texture)
glUniform1i(glGetUniformLocation(self.program, b"texBackFaces"), 0)
# texture unit 1: 3D volume texture
❻ glActiveTexture(GL_TEXTURE1)
glBindTexture(GL_TEXTURE_3D, self.texVolume)
glUniform1i(glGetUniformLocation(self.program, b"texVolume"), 1)
# draw front-face of cubes
❼ self.raycube.renderFrontFace(pMatrix, mvMatrix, self.program)
首先,你需要为渲染设置一个透视投影矩阵,使用glutils.perspective()工具方法 ❶。然后,你将当前的相机参数设置到glutils.lookAt()方法中 ❷。接着,进行渲染的第一遍 ❸,使用RayCube中的renderBackFace()方法将颜色立方体的背面绘制到纹理中。(此方法还会返回生成纹理的 ID。)
然后,启用射线投射算法的着色器 ❹。接下来,为着色器程序设置纹理。步骤❸中返回的纹理被设置为纹理单元 0 ❺,而从你读取的体积数据中创建的 3D 纹理被设置为纹理单元 1 ❻。最后,使用RayCube中的renderFrontFace()方法渲染立方体的正面 ❼。当这段代码执行时,RayCastRender的着色器将作用于顶点和片段。
顶点着色器
现在,我们来看RayCastRender使用的着色器。首先来看顶点着色器:
strVS = """
# version 410 core
❶ layout(location = 1) in vec3 cubePos;
layout(location = 2) in vec3 cubeCol;
❷ uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
❸ out vec4 vColor;
void main()
{
// set position
❹ gl_Position = uPMatrix * uMVMatrix * vec4(cubePos.xyz, 1.0);
// set color
❺ vColor = vec4(cubeCol.rgb, 1.0);
}
"""
首先,你设置位置和颜色的输入变量❶。布局使用与RayCube顶点着色器中定义的相同索引,因为RayCastRender使用该类中定义的 VBO 来绘制几何体,并且着色器中的位置必须匹配。然后,你定义输入的变换矩阵❷,并设置颜色值作为着色器输出❸。常规的变换计算内建的gl_Position输出❹,然后将输出设置为当前的立方体顶点颜色❺。后者将在顶点之间插值,以便在片段着色器中获得正确的颜色。
片段着色器
片段着色器是整个过程的核心。它实现了射线投射算法的核心部分。
strFS = """
# version 410 core
in vec4 vColor;
uniform sampler2D texBackFaces;
uniform sampler3D texVolume;
uniform vec2 uWinDims;
out vec4 fragColor;
void main()
{
// start of ray
❶ vec3 start = vColor.rgb;
// calculate texture coordinates at fragment,
// which is a fraction of window coordinates
❷ vec2 texc = gl_FragCoord.xy/uWinDims.xy;
// get end of ray by looking up back-face color
❸ vec3 end = texture(texBackFaces, texc).rgb;
// calculate ray direction
❹ vec3 dir = end – start;
// normalized ray direction
vec3 norm_dir = normalize(dir);
// the length from front to back is calculated and
// used to terminate the ray
float len = length(dir.xyz);
// ray step size
float stepSize = 0.01;
// X-ray projection
vec4 dst = vec4(0.0);
// step through the ray
❺ for(float t = 0.0; t < len; t += stepSize) {
// set position to endpoint of ray
❻ vec3 samplePos = start + t*norm_dir;
// get texture value at position
❼ float val = texture(texVolume, samplePos).r;
vec4 src = vec4(val);
// set opacity
❽ src.a *= 0.1;
src.rgb *= src.a;
// blend with previous value
❾ dst = (1.0 - dst.a)*src + dst;
// exit loop when alpha exceeds threshold
❿ if(dst.a >= 0.95)
break;
}
// set fragment color
fragColor = dst;
}
"""
片段着色器的输入是立方体的顶点颜色。片段着色器还可以访问通过渲染颜色立方体生成的 2D 纹理、包含体积数据的 3D 纹理以及 OpenGL 窗口的尺寸。
在片段着色器执行时,你将立方体的正面传入,因此通过查找传入的颜色值❶,你可以得到进入立方体的射线起点。(回顾一下在“射线生成”一节中关于立方体内颜色与射线方向之间关系的讨论,第 217 页)
你计算传入片段在屏幕上的纹理坐标❷。这里,通过将片段在窗口坐标中的位置除以窗口尺寸,将位置映射到[0, 1]的范围内。射线的终点通过使用这个纹理坐标查找立方体的背面颜色来获得❸。
接下来,你计算射线的方向❹,然后计算该射线的标准化方向和长度,这在射线投射计算中会派上用场。然后,你通过射线的起点和方向遍历体积,直到它击中射线的终点❺。在这个循环中,你计算射线在数据体积内的当前位置❻,并查找该点的数据值❼。然后,你在❽和❾处执行混合方程,产生 X 射线效果。你将dst值与当前的强度值结合(该强度值通过 alpha 值进行衰减),并且该过程沿射线继续进行。alpha 值会不断增加,直到它达到最大阈值 0.95❿,此时你退出循环。最终结果是每个像素在体积中的一种*均不透明度,从而产生“透视”或 X 射线效果。(尝试改变阈值和 alpha 衰减,产生不同的效果。)
显示 2D 切片
除了显示体积数据的 3D 视图外,你还希望在屏幕上显示沿 x、y 和 z 方向的 2D 切片数据。为此,代码封装在一个名为 SliceRender 的类中,该类创建 2D 体积切片。要查看完整的 slicerender.py 代码,请跳到《完整的 2D 切片代码》,它位于第 251 页。你还可以在 github.com/mkvenkit/pp2e/tree/main/volrender 找到 slicerender.py 文件。
这是 SliceRender 类构造函数中的初始化代码,它设置了切片的几何形状:
class SliceRender:
def __init__(self, width, height, volume):
--`snip`--
# set up vertex array object (VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
# define quad vertices
❶ vertexData = numpy.array([0.0, 1.0, 0.0,
0.0, 0.0, 0.0,
1.0, 1.0, 0.0,
1.0, 0.0, 0.0], numpy.float32)
# vertex buffer
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(vertexData), vertexData,
GL_STATIC_DRAW)
# enable arrays
glEnableVertexAttribArray(self.vertIndex)
# set buffers
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glVertexAttribPointer(self.vertIndex, 3, GL_FLOAT, GL_FALSE, 0, None)
# unbind VAO
glBindVertexArray(0)
这段代码设置了一个 VAO 来管理 VBO,和之前的示例一样。你在 XY *面 ❶ 中定义了一个正方形的几何形状。(顶点顺序使用的是 GL_TRIANGLE_STRIP,该顺序在第九章中介绍过。)无论你显示的是与 x、y 还是 z 垂直的切片,你都会使用相同的几何形状。在这些情况下唯一变化的是你选择在 3D 纹理中显示的数据*面。我们在查看顶点着色器时会回到这个概念。
这是渲染 2D 切片的方法:
def draw(self):
# clear buffers
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# build projection matrix
❶ pMatrix = glutils.ortho(-0.6, 0.6, -0.6, 0.6, 0.1, 100.0)
# modelview matrix
❷ mvMatrix = numpy.array([1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
-0.5, -0.5, -1.0, 1.0], numpy.float32)
# use shader
glUseProgram(self.program)
# set projection matrix
glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
# set modelview matrix
glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
# set current slice fraction
❸ glUniform1f(glGetUniformLocation(self.program, b"uSliceFrac"),
float(self.currSliceIndex)/float(self.currSliceMax))
# set current slice mode
❹ glUniform1i(glGetUniformLocation(self.program, b"uSliceMode"),
self.mode)
# enable texture
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_3D, self.texture)
glUniform1i(glGetUniformLocation(self.program, b"tex"), 0)
# bind VAO
glBindVertexArray(self.vao)
# draw
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
# unbind VAO
glBindVertexArray(0)
每个 2D 切片都是一个正方形,你使用 OpenGL 三角形带原语将其构建起来。此代码处理三角形带的渲染设置。请注意,你在 ❶ 使用 glutils.ortho() 方法实现了正交投影。你设置了一个投影,在表示切片的单位正方形周围添加了 0.1 的缓冲区。
当你使用 OpenGL 绘制某些东西时,默认视图(没有应用任何变换)会将视角设定在 (0, 0, 0),并沿着 z 轴查看,y 轴指向上方。将*移变换(−0.5, −0.5, −1.0)应用到几何体上会使其在 z 轴上居中 ❷。你设置当前的切片分数 ❸(例如,100 个切片中的第 10 个切片为 0.1),设置切片模式 ❹(选择沿 x、y 或 z 方向查看切片,分别由整数 0、1 和 2 表示),并将这两个值传递给着色器。
顶点着色器
现在让我们来看一下 SliceRender 的顶点着色器:
strVS = """
# version 410 core
in vec3 aVert;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform float uSliceFrac;
uniform int uSliceMode;
out vec3 texcoord;
void main() {
// x slice
if (uSliceMode == 0) {
❶ texcoord = vec3(uSliceFrac, aVert.x, 1.0-aVert.y);
}
// y slice
else if (uSliceMode == 1) {
❷ texcoord = vec3(aVert.x, uSliceFrac, 1.0-aVert.y);
}
// z slice
else {
❸ texcoord = vec3(aVert.x, 1.0-aVert.y, uSliceFrac);
}
// calculate transformed vertex
gl_Position = uPMatrix * uMVMatrix * vec4(aVert, 1.0);
}
"""
顶点着色器将三角形带顶点数组作为输入,并将纹理坐标作为输出。当前的切片分数和切片模式作为统一变量 uSliceFrac 和 uSliceMode 传入。
该着色器有三个分支,具体取决于切片模式。例如,如果 uSliceMode 为 0,您将计算 x 切片的纹理坐标 ❶。由于您在 x 方向上进行切片,因此您希望得到与 YZ *面*行的切片。传入顶点着色器的 3D 顶点也作为 3D 纹理坐标,因为它们的范围是 [0, 1],因此纹理坐标表示为 (f, V[x], V[y]),其中 f 是在 x 轴方向上切片编号的分数,而 V[x] 和 V[y] 是顶点坐标。不幸的是,生成的图像会颠倒过来,因为 OpenGL 的坐标系统原点位于左下角,y 方向朝上;这与您希望的效果相反。为了解决这个问题,您将纹理坐标 t 修改为 (1 − t) 并使用 (f, V[x], 1 − V[y]) ❶。如果 uSliceMode 的值为 1 或 2,您将使用类似的逻辑来计算 y 方向 ❷ 和 z 方向 ❸ 的切片纹理坐标。
片段着色器
这是片段着色器:
strFS = """
# version 410 core
❶ in vec3 texcoord;
❷ uniform sampler3D texture;
out vec4 fragColor;
void main() {
// look up color in texture
❸ vec4 col = texture(tex, texcoord);
❹ fragColor = col.rrra;
}
"""
片段着色器将 texcoord 声明为输入 ❶,这在顶点着色器中设置为输出。纹理采样器声明为 uniform ❷。您使用 texcoord ❸ 查找纹理颜色,并将 fragColor 设置为输出 ❹。(因为您只读取纹理的红色通道,所以使用 col.rrra。)
2D 切片的用户界面
现在您需要一种方法,让用户能够切片数据。可以通过在 SliceRender 类上使用键盘处理方法来实现:
def keyPressed(self, key):
"""keypress handler"""
❶ if key == 'x':
self.mode = SliceRender.XSLICE
# reset slice index
self.currSliceIndex = int(self.Nx/2)
self.currSliceMax = self.Nx
elif key == 'y':
self.mode = SliceRender.YSLICE
# reset slice index
self.currSliceIndex = int(self.Ny/2)
self.currSliceMax = self.Ny
elif key == 'z':
self.mode = SliceRender.ZSLICE
# reset slice index
self.currSliceIndex = int(self.Nz/2)
self.currSliceMax = self.Nz
elif key == 'l':
❷ self.currSliceIndex = (self.currSliceIndex + 1) % self.currSliceMax
elif key == 'r':
self.currSliceIndex = (self.currSliceIndex - 1) % self.currSliceMax
当按下键盘上的 X、Y 或 Z 键时,SliceRender 切换到 x、y 或 z 切片模式。例如,在 x 切片模式下,您可以看到这种效果 ❶,在这里,您设置适当的模式,将当前切片索引设置为数据的中间位置,并更新最大切片数。
当按下键盘上的左箭头或右箭头键时,您可以翻页切片。例如,当按下左箭头键时,切片索引会增加 ❷。取模运算符 (%) 确保当索引超过最大值时,“回绕”到 0。
将代码整合在一起
让我们快速查看项目中的主文件 volrender.py。该文件使用一个 RenderWin 类,它创建并管理 GLFW OpenGL 窗口。(由于与 第九章 和 第十章 中使用的类类似,因此我不会详细介绍此类。)要查看完整的 volrender.py 代码,请跳到 “完整的主文件代码”,位于 第 254 页。您还可以在 github.com/mkvenkit/pp2e/tree/main/volrender 找到 volrender.py 文件。
在此类的初始化代码中,您按如下方式创建渲染器:
class RenderWin:
def __init__(self, imageDir):
--`snip`--
# load volume data
❶ self.volume = volreader.loadVolume(imageDir)
# create renderer
❷ self.renderer = RayCastRender(self.width, self.height, self.volume)
在这里,你使用我们之前讨论的 loadVolume() 函数将 3D 数据读取到 OpenGL 纹理中❶。然后,你创建一个 RayCastRender 类型的对象来显示这些数据❷。
按键处理器
RenderWindow 类需要一个自己的键盘处理方法,用于在体积渲染和切片渲染之间切换,以及关闭窗口。这个方法还会将按键事件传递给 RayCastRender 和 SliceRender 类的键盘处理器,用于旋转相机或在 2D 切片中导航。
def onKeyboard(self, win, key, scancode, action, mods):
# print 'keyboard: ', win, key, scancode, action, mods
# ESC to quit
if key is glfw.GLFW_KEY_ESCAPE:
self.renderer.close()
self.exitNow = True
else:
❶ if action is glfw.GLFW_PRESS or action is glfw.GLFW_REPEAT:
if key == glfw.GLFW_KEY_V:
# toggle render mode
❷ if isinstance(self.renderer, RayCastRender):
self.renderer = SliceRender(self.width, self.height,
self.volume)
else:
self.renderer = RayCastRender(self.width, self.height,
self.volume)
# call reshape on renderer
self.renderer.reshape(self.width, self.height)
else:
# send keypress to renderer
❸ keyDict = {glfw.GLFW_KEY_X: 'x', glfw.GLFW_KEY_Y: 'y',
glfw.GLFW_KEY_Z: 'z', glfw.GLFW_KEY_LEFT: 'l',
glfw.GLFW_KEY_RIGHT: 'r'}
try:
self.renderer.keyPressed(keyDict[key])
except:
pass
按下 ESC 键退出程序。你设置了其他按键事件,无论是按下键还是保持按下都能触发❶。如果按下 V 键,则在体积和切片渲染之间切换❷,通过 Python 的 isinstance() 方法来识别当前类类型。为了处理其他按键事件(如 X、Y、Z 或左右箭头键),你使用字典 ❸ 并将按键事件传递给当前渲染器的 keyPressed() 处理方法。例如,我们在《2D 切片的用户界面》一章的第 237 页中查看了切片渲染器的 keyPressed() 方法。
注意:我选择不直接传递 glfw.KEY 值,而是使用字典将这些值转换为字符值,因为减少源文件中的依赖是一个好习惯。目前,项目中唯一依赖于 GLFW 的文件是 volrender.py。如果你将 GLFW 特定的类型传递给其他代码,它们也需要导入并依赖于 GLFW 库。然后,如果你切换到不同的 OpenGL 窗口工具包,代码就会变得凌乱。
运行程序
这里是使用斯坦福体积数据档案中的数据运行应用程序的示例:
$ `python volrender.py --dir mrbrain-8bit/`
你应该看到类似于图 11-6 的内容。

图 11-6:volrender.py 的一个示例运行。左侧是体积渲染图像,右侧是 2D 切片图像。
当应用程序运行时,使用 V 键在体积和切片渲染之间切换。在切片模式下,使用 X、Y 和 Z 键来更改切片轴,使用箭头键来更改切片位置。
总结
在这一章中,你实现了使用 Python 和 OpenGL 的体积射线投射算法。你学习了如何使用 GLSL 着色器高效实现该算法,以及如何从体积数据中创建 2D 切片。
实验!
这里有几种方法,你可以继续修改体积射线投射程序:
-
1. 当前,在射线投射模式下,很难看到体积数据“立方体”的边界。实现一个类
WireFrame,在这个立方体周围绘制一个框。将 x 轴、y 轴和 z 轴分别涂成红色、绿色和蓝色,并为每个轴指定不同的着色器。你将在RayCastRender类中使用WireFrame。 -
2. 实现数据缩放。在当前实现中,你为体积绘制了一个立方体,为二维切片绘制了一个正方形,这假设你有一个对称的数据集(每个方向上的切片数量相同),但大多数真实数据的切片数量是变化的。尤其是医学数据,通常在 z 方向上切片较少,例如,维度可能是 256×256×99。为了正确显示这些数据,你需要在计算中引入缩放。可以通过将缩放应用于立方体顶点(3D 体积)和正方形顶点(2D 切片)来实现这一点。用户可以通过命令行参数输入缩放参数。
-
3. 我们的体积射线投射实现使用 X 射线投射来计算像素的最终颜色或强度。另一种常用的方法是使用最大强度投影(MIP),在每个像素处设置最大强度。请在代码中实现此功能。(提示:在
RayCastRender的片段着色器中,修改通过射线逐步计算的代码,检查并设置沿射线的最大值,而不是混合值。) -
4. 目前,你实现的唯一 UI 功能是围绕 x、y 和 z 轴的旋转。实现一个缩放功能,使得按 I/O 键可以放大/缩小体积渲染的图像。你可以通过在
glutils.lookAt()方法中设置适当的相机参数来实现这一点,但有一个警告:如果你将视角移到数据立方体内部,射线投射将会失败,因为 OpenGL 会裁剪立方体的前面;射线投射所需的计算需要同时渲染立方体的前面和背面。相反,你可以通过调整glutils.projecton()方法中的视场来实现缩放。
完整的 3D 纹理代码
这是volreader.py的完整代码列表。
import os
import numpy as np
from PIL import Image
import OpenGL
from OpenGL.GL import *
from scipy import misc
def loadVolume(dirName):
"""read volume from directory as a 3D texture"""
# list images in directory
files = sorted(os.listdir(dirName))
print('loading images from: %s' % dirName)
imgDataList = []
count = 0
width, height = 0, 0
for file in files:
file_path = os.path.abspath(os.path.join(dirName, file))
try:
# read image
img = Image.open(file_path)
imgData = np.array(img.getdata(), np.uint8)
# check if all are of the same size
if count is 0:
width, height = img.size[0], img.size[1]
imgDataList.append(imgData)
else:
if (width, height) == (img.size[0], img.size[1]):
imgDataList.append(imgData)
else:
print('mismatch')
raise RunTimeError("image size mismatch")
count += 1
# print img.size
except:
# skip
print('Invalid image: %s' % file_path)
# load image data into single array
depth = count
data = np.concatenate(imgDataList)
print('volume data dims: %d %d %d' % (width, height, depth))
# load data into 3D texture
texture = glGenTextures(1)
glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
glBindTexture(GL_TEXTURE_3D, texture)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameterf(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexImage3D(GL_TEXTURE_3D, 0, GL_RED,
width, height, depth, 0,
GL_RED, GL_UNSIGNED_BYTE, data)
# return texture
return (texture, width, height, depth)
# load texture
def loadTexture(filename):
img = Image.open(filename)
img_data = np.array(list(img.getdata()), 'B')
texture = glGenTextures(1)
glPixelStorei(GL_UNPACK_ALIGNMENT,1)
glBindTexture(GL_TEXTURE_2D, texture)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.size[0], img.size[1],
0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
return texture
完整的射线生成代码
这是RayCube类的完整代码列表。
导入 OpenGL
从 OpenGL.GL 导入*
从 OpenGL.GL.shaders 导入*
导入 numpy, math, sys
导入 volreader, glutils
strVS = """
版本 330 核心
布局(位置 = 1) 输入 vec3 cubePos;
布局(位置 = 2) 输入 vec3 cubeCol;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
输出 vec4 vColor;
void main()
{
// 设置背面颜色
vColor = vec4(cubeCol.rgb, 1.0);
// 变换后的位置
vec4 newPos = vec4(cubePos.xyz, 1.0);
// 设置位置
gl_Position = uPMatrix * uMVMatrix * newPos;
}
"""
strFS = """
版本 330 核心
输入 vec4 vColor;
输出 vec4 fragColor;
void main()
{
fragColor = vColor;
}
"""
类 RayCube:
"""用于生成射线的类,供射线投射使用"""
def init(self, width, height):
"""RayCube 构造函数"""
设置尺寸
self.width, self.height = width, height
创建着色器
self.program = glutils.loadShaders(strVS, strFS)
立方体顶点
vertices = numpy.array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
1.0, 0.0, 1.0,
1.0, 1.0, 1.0,
0.0, 1.0, 1.0
], numpy.float32)
立方体颜色
colors = numpy.array([
0.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0,
1.0, 0.0, 1.0,
1.0, 1.0, 1.0,
0.0, 1.0, 1.0
], numpy.float32)
单独的三角形
indices = numpy.array([
4, 5, 7,
7, 5, 6,
5, 1, 6,
6, 1, 2,
1, 0, 2,
2, 0, 3,
0, 4, 3,
3, 4, 7,
6, 2, 7,
7, 2, 3,
4, 0, 5,
5, 0, 1
], numpy.int16)
self.nIndices = indices.size
设置顶点数组对象(VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
顶点缓冲区
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(vertices), vertices, GL_STATIC_DRAW)
立方体顶点颜色 - 顶点缓冲区
self.colorBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(colors), colors, GL_STATIC_DRAW);
索引缓冲区
self.indexBuffer = glGenBuffers(1)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.indexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, 2*len(indices), indices,
GL_STATIC_DRAW)
使用着色器中的布局索引启用属性
aPosLoc = 1
aColorLoc = 2
绑定缓冲区
glEnableVertexAttribArray(1)
glEnableVertexAttribArray(2)
顶点
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glVertexAttribPointer(aPosLoc, 3, GL_FLOAT, GL_FALSE, 0, None)
颜色
glBindBuffer(GL_ARRAY_BUFFER, self.colorBuffer)
glVertexAttribPointer(aColorLoc, 3, GL_FLOAT, GL_FALSE, 0, None)
索引
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.indexBuffer)
解绑 VAO
glBindVertexArray(0)
FBO
self.initFBO()
def renderBackFace(self, pMatrix, mvMatrix):
"""将射线立方体的背面渲染到纹理并返回"""
渲染到 FBO
glBindFramebuffer(GL_FRAMEBUFFER, self.fboHandle)
设置激活的纹理
glActiveTexture(GL_TEXTURE0)
绑定到 FBO 纹理
glBindTexture(GL_TEXTURE_2D, self.texHandle)
启用面剔除渲染立方体
self.renderCube(pMatrix, mvMatrix, self.program, True)
解绑纹理
glBindTexture(GL_TEXTURE_2D, 0)
glBindFramebuffer(GL_FRAMEBUFFER, 0)
glBindRenderbuffer(GL_RENDERBUFFER, 0)
返回纹理 ID
return self.texHandle
def renderFrontFace(self, pMatrix, mvMatrix, program):
"""渲染射线立方体的前面"""
不进行面剔除
self.renderCube(pMatrix, mvMatrix, program, False)
def renderCube(self, pMatrix, mvMatrix, program, cullFace):
"""使用面剔除渲染立方体(如果标志设置了的话)"""
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
设置着色器程序
glUseProgram(program)
设置投影矩阵
glUniformMatrix4fv(glGetUniformLocation(program, b'uPMatrix'),
1, GL_FALSE, pMatrix)
设置模型视图矩阵
glUniformMatrix4fv(glGetUniformLocation(program, b'uMVMatrix'),
1, GL_FALSE, mvMatrix)
启用面剔除
glDisable(GL_CULL_FACE)
if cullFace:
glFrontFace(GL_CCW)
glCullFace(GL_FRONT)
glEnable(GL_CULL_FACE)
绑定 VAO
glBindVertexArray(self.vao)
动画切片
glDrawElements(GL_TRIANGLES, self.nIndices, GL_UNSIGNED_SHORT, None)
解除绑定 VAO
glBindVertexArray(0)
重置面剔除
if cullFace:
禁用面剔除
glDisable(GL_CULL_FACE)
def reshape(self, width, height):
self.width = width
self.height = height
self.aspect = width/float(height)
重新创建 FBO
self.clearFBO()
self.initFBO()
def initFBO(self):
创建帧缓冲对象
self.fboHandle = glGenFramebuffers(1)
创建纹理
self.texHandle = glGenTextures(1)
创建深度缓冲区
self.depthHandle = glGenRenderbuffers(1)
绑定
glBindFramebuffer(GL_FRAMEBUFFER, self.fboHandle)
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, self.texHandle)
设置参数以绘制不同尺寸的图像
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
设置纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.width, self.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, None)
绑定纹理到 FBO
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, self.texHandle, 0)
绑定
glBindRenderbuffer(GL_RENDERBUFFER, self.depthHandle)
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24,
self.width, self.height)
绑定深度缓冲区到 FBO
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_RENDERBUFFER, self.depthHandle)
检查状态
status = glCheckFramebufferStatus(GL_FRAMEBUFFER)
if status == GL_FRAMEBUFFER_COMPLETE:
pass
打印 "fbo %d 完成" % self.fboHandle
elif status == GL_FRAMEBUFFER_UNSUPPORTED:
print("fbo %d 不支持" % self.fboHandle)
else:
print("fbo %d 错误" % self.fboHandle)
glBindTexture(GL_TEXTURE_2D, 0)
glBindFramebuffer(GL_FRAMEBUFFER, 0)
glBindRenderbuffer(GL_RENDERBUFFER, 0)
return
def clearFBO(self):
"""清除旧的 FBO"""
删除 FBO
if glIsFramebuffer(self.fboHandle):
glDeleteFramebuffers(1, int(self.fboHandle))
删除纹理
if glIsTexture(self.texHandle):
glDeleteTextures(int(self.texHandle))
def close(self):
"""调用此函数以释放 OpenGL 资源"""
glBindTexture(GL_TEXTURE_2D, 0)
glBindFramebuffer(GL_FRAMEBUFFER, 0)
glBindRenderbuffer(GL_RENDERBUFFER, 0)
删除 FBO
if glIsFramebuffer(self.fboHandle):
glDeleteFramebuffers(1, int(self.fboHandle))
删除纹理
if glIsTexture(self.texHandle):
glDeleteTextures(int(self.texHandle))
删除渲染缓冲区
"""
if glIsRenderbuffer(self.depthHandle):
glDeleteRenderbuffers(1, int(self.depthHandle))
"""
删除缓冲区
"""
glDeleteBuffers(1, self._vertexBuffer)
glDeleteBuffers(1, &_indexBuffer)
glDeleteBuffers(1, &_colorBuffer)
"""
完整的体积光线投射代码
这是完整的raycast.py代码列表。
import OpenGL
from OpenGL.GL import *
from OpenGL.GL.shaders import *
import numpy as np
import math, sys
import raycube, glutils, volreader
strVS = """
# version 330 core
layout(location = 1) in vec3 cubePos;
layout(location = 2) in vec3 cubeCol;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
out vec4 vColor;
void main()
{
// set position
gl_Position = uPMatrix * uMVMatrix * vec4(cubePos.xyz, 1.0);
// set color
vColor = vec4(cubeCol.rgb, 1.0);
}
"""
strFS = """
# version 330 core
in vec4 vColor;
uniform sampler2D texBackFaces;
uniform sampler3D texVolume;
uniform vec2 uWinDims;
out vec4 fragColor;
void main()
{
// start of ray
vec3 start = vColor.rgb;
// calculate texture coords at fragment,
// which is a fraction of window coords
vec2 texc = gl_FragCoord.xy/uWinDims.xy;
// get end of ray by looking up back-face color
vec3 end = texture(texBackFaces, texc).rgb;
// calculate ray direction
vec3 dir = end - start;
// normalized ray direction
vec3 norm_dir = normalize(dir);
// the length from front to back is calculated and
// used to terminate the ray
float len = length(dir.xyz);
// ray step size
float stepSize = 0.01;
// X-ray projection
vec4 dst = vec4(0.0);
// step through the ray
for(float t = 0.0; t < len; t += stepSize) {
// set position to endpoint of ray
vec3 samplePos = start + t*norm_dir;
// get texture value at position
float val = texture(texVolume, samplePos).r;
vec4 src = vec4(val);
// set opacity
src.a *= 0.1;
src.rgb *= src.a;
// blend with previous value
dst = (1.0 - dst.a)*src + dst;
// exit loop when alpha exceeds threshold
if(dst.a >= 0.95)
break;
}
// set fragment color
fragColor = dst;
}
"""
class Camera:
"""helper class for viewing"""
def __init__(self):
self.r = 1.5
self.theta = 0
self.center = [0.5, 0.5, 0.5]
self.eye = [0.5 + self.r, 0.5, 0.5]
self.up = [0.0, 0.0, 1.0]
def rotate(self, clockWise):
"""rotate eye by one step"""
if clockWise:
self.theta = (self.theta + 5) % 360
else:
self.theta = (self.theta - 5) % 360
# recalculate eye
self.eye = [0.5 + self.r*math.cos(math.radians(self.theta)),
0.5 + self.r*math.sin(math.radians(self.theta)),
0.5]
class RayCastRender:
"""class that does Ray Casting"""
def __init__(self, width, height, volume):
"""RayCastRender constr"""
# create RayCube object
self.raycube = raycube.RayCube(width, height)
# set dimensions
self.width = width
self.height = height
self.aspect = width/float(height)
# create shader
self.program = glutils.loadShaders(strVS, strFS)
# texture
self.texVolume, self.Nx, self.Ny, self.Nz = volume
# initialize camera
self.camera = Camera()
def draw(self):
# build projection matrix
pMatrix = glutils.perspective(45.0, self.aspect, 0.1, 100.0)
# modelview matrix
mvMatrix = glutils.lookAt(self.camera.eye, self.camera.center,
self.camera.up)
# render
# generate ray-cube back-face texture
texture = self.raycube.renderBackFace(pMatrix, mvMatrix)
# set shader program
glUseProgram(self.program)
# set window dimensions
glUniform2f(glGetUniformLocation(self.program, b"uWinDims"),
float(self.width), float(self.height))
# texture unit 0, which represents back-faces of cube
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, texture)
glUniform1i(glGetUniformLocation(self.program, b"texBackFaces"), 0)
# texture unit 1: 3D volume texture
glActiveTexture(GL_TEXTURE1)
glBindTexture(GL_TEXTURE_3D, self.texVolume)
glUniform1i(glGetUniformLocation(self.program, b"texVolume"), 1)
# draw front-face of cubes
self.raycube.renderFrontFace(pMatrix, mvMatrix, self.program)
#self.render(pMatrix, mvMatrix)
def keyPressed(self, key):
if key == 'l':
self.camera.rotate(True)
elif key == 'r':
self.camera.rotate(False)
def reshape(self, width, height):
self.width = width
self.height = height
self.aspect = width/float(height)
self.raycube.reshape(width, height)
def close(self):
self.raycube.close()
完整的 2D 切片代码
这是完整的 2D 切片代码列表。
import OpenGL
from OpenGL.GL import *
from OpenGL.GL.shaders import *
import numpy, math, sys
import volreader, glutils
strVS = """
version 330 core
in vec3 aVert;
uniform mat4 uMVMatrix;
uniform mat4 uPMatrix;
uniform float uSliceFrac;
uniform int uSliceMode;
out vec3 texcoord;
void main() {
// x 切片
if (uSliceMode == 0) {
texcoord = vec3(uSliceFrac, aVert.x, 1.0-aVert.y);
}
// y 切片
else if (uSliceMode == 1) {
texcoord = vec3(aVert.x, uSliceFrac, 1.0-aVert.y);
}
// z 切片
else {
texcoord = vec3(aVert.x, 1.0-aVert.y, uSliceFrac);
}
// 计算变换后的顶点
gl_Position = uPMatrix * uMVMatrix * vec4(aVert, 1.0);
}
"""
strFS = """
version 330 core
in vec3 texcoord;
uniform sampler3D tex;
out vec4 fragColor;
void main() {
// 在纹理中查找颜色
vec4 col = texture(tex, texcoord);
fragColor = col.rrra;
}
"""
class SliceRender:
切片模式
XSLICE, YSLICE, ZSLICE = 0, 1, 2
def init(self, width, height, volume):
"""SliceRender 构造函数"""
self.width = width
self.height = height
self.aspect = width/float(height)
切片模式
self.mode = SliceRender.ZSLICE
创建着色器
self.program = glutils.loadShaders(strVS, strFS)
glUseProgram(self.program)
self.pMatrixUniform = glGetUniformLocation(self.program, b'uPMatrix')
self.mvMatrixUniform = glGetUniformLocation(self.program, b"uMVMatrix")
属性
self.vertIndex = glGetAttribLocation(self.program, b"aVert")
设置顶点数组对象(VAO)
self.vao = glGenVertexArrays(1)
glBindVertexArray(self.vao)
定义四边形顶点
vertexData = numpy.array([0.0, 1.0, 0.0,
0.0, 0.0, 0.0,
1.0, 1.0, 0.0,
1.0, 0.0, 0.0], numpy.float32)
顶点缓冲区
self.vertexBuffer = glGenBuffers(1)
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glBufferData(GL_ARRAY_BUFFER, 4*len(vertexData), vertexData,
GL_STATIC_DRAW)
启用数组
glEnableVertexAttribArray(self.vertIndex)
设置缓冲区
glBindBuffer(GL_ARRAY_BUFFER, self.vertexBuffer)
glVertexAttribPointer(self.vertIndex, 3, GL_FLOAT, GL_FALSE, 0, None)
解绑 VAO
glBindVertexArray(0)
加载纹理
self.texture, self.Nx, self.Ny, self.Nz = volume
当前切片索引
self.currSliceIndex = int(self.Nz/2);
self.currSliceMax = self.Nz;
def reshape(self, width, height):
self.width = width
self.height = height
self.aspect = width/float(height)
def draw(self):
清空缓冲区
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
构建投影矩阵
pMatrix = glutils.ortho(-0.6, 0.6, -0.6, 0.6, 0.1, 100.0)
模型视图矩阵
mvMatrix = numpy.array([1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
-0.5, -0.5, -1.0, 1.0], numpy.float32)
使用着色器
glUseProgram(self.program)
设置投影矩阵
glUniformMatrix4fv(self.pMatrixUniform, 1, GL_FALSE, pMatrix)
设置模型视图矩阵
glUniformMatrix4fv(self.mvMatrixUniform, 1, GL_FALSE, mvMatrix)
设置当前切片比例
glUniform1f(glGetUniformLocation(self.program, b"uSliceFrac"),
float(self.currSliceIndex)/float(self.currSliceMax))
设置当前切片模式
glUniform1i(glGetUniformLocation(self.program, b"uSliceMode"),
self.mode)
启用纹理
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_3D, self.texture)
glUniform1i(glGetUniformLocation(self.program, b"tex"), 0)
绑定 VAO
glBindVertexArray(self.vao)
绘制
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
解绑 VAO
glBindVertexArray(0)
def keyPressed(self, key):
"""键盘按键处理"""
if key == 'x':
self.mode = SliceRender.XSLICE
重置切片索引
self.currSliceIndex = int(self.Nx/2)
self.currSliceMax = self.Nx
elif key == 'y':
self.mode = SliceRender.YSLICE
重置切片索引
self.currSliceIndex = int(self.Ny/2)
self.currSliceMax = self.Ny
elif key == 'z':
self.mode = SliceRender.ZSLICE
重置切片索引
self.currSliceIndex = int(self.Nz/2)
self.currSliceMax = self.Nz
elif key == 'l':
self.currSliceIndex = (self.currSliceIndex + 1) % self.currSliceMax
elif key == 'r':
self.currSliceIndex = (self.currSliceIndex - 1) % self.currSliceMax
def close(self):
pass
完整的主文件代码
这是主文件的完整代码列表。
import sys, argparse, os
from slicerender import *
from raycast import *
import glfw
class RenderWin:
"""GLFW 渲染窗口类"""
def init(self, imageDir):
保存当前工作目录
cwd = os.getcwd()
初始化 glfw; 这会改变工作目录
glfw.glfwInit()
恢复工作目录
os.chdir(cwd)
版本提示
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MAJOR, 3)
glfw.glfwWindowHint(glfw.GLFW_CONTEXT_VERSION_MINOR, 3)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE)
glfw.glfwWindowHint(glfw.GLFW_OPENGL_PROFILE,
glfw.GLFW_OPENGL_CORE_PROFILE)
创建一个窗口
self.width, self.height = 512, 512
self.aspect = self.width/float(self.height)
self.win = glfw.glfwCreateWindow(self.width, self.height, b"volrender")
使上下文当前
glfw.glfwMakeContextCurrent(self.win)
初始化 GL
glViewport(0, 0, self.width, self.height)
glEnable(GL_DEPTH_TEST)
glClearColor(0.0, 0.0, 0.0, 0.0)
设置窗口回调
glfw.glfwSetMouseButtonCallback(self.win, self.onMouseButton)
glfw.glfwSetKeyCallback(self.win, self.onKeyboard)
glfw.glfwSetWindowSizeCallback(self.win, self.onSize)
加载体积数据
self.volume = volreader.loadVolume(imageDir)
创建渲染器
self.renderer = RayCastRender(self.width, self.height, self.volume)
退出标志
self.exitNow = False
def onMouseButton(self, win, button, action, mods):
打印 '鼠标按钮: ', win, button, action, mods
pass
def onKeyboard(self, win, key, scancode, action, mods):
打印 '键盘按键: ', win, key, scancode, action, mods
按 ESC 退出
if key is glfw.GLFW_KEY_ESCAPE:
self.renderer.close()
self.exitNow = True
else:
if action is glfw.GLFW_PRESS or action is glfw.GLFW_REPEAT:
if key == glfw.GLFW_KEY_V:
切换渲染模式
if isinstance(self.renderer, RayCastRender):
self.renderer = SliceRender(self.width, self.height,
self.volume)
else:
self.renderer = RayCastRender(self.width, self.height,
self.volume)
调用渲染器的 reshape
self.renderer.reshape(self.width, self.height)
else:
发送按键到渲染器
keyDict = {glfw.GLFW_KEY_X: 'x', glfw.GLFW_KEY_Y: 'y',
glfw.GLFW_KEY_Z: 'z', glfw.GLFW_KEY_LEFT: 'l',
glfw.GLFW_KEY_RIGHT: 'r'}
try
self.renderer.keyPressed(keyDict[key])
except:
pass
def onSize(self, win, width, height):
打印 'onsize: ', win, width, height
self.width = width
self.height = height
self.aspect = width/float(height)
glViewport(0, 0, self.width, self.height)
self.renderer.reshape(width, height)
def run(self):
启动循环
while not glfw.glfwWindowShouldClose(self.win) and not self.exitNow:
渲染
self.renderer.draw()
交换缓冲区
glfw.glfwSwapBuffers(self.win)
等待事件
glfw.glfwWaitEvents()
结束
glfw.glfwTerminate()
main() 函数
def main():
print('启动体积渲染...')
创建解析器
parser = argparse.ArgumentParser(description="体积渲染...")
添加预期参数
parser.add_argument('--dir', dest='imageDir', required=True)
解析参数
args = parser.parse_args()
创建渲染窗口
rwin = RenderWin(args.imageDir)
rwin.run()
调用 main
if name == 'main':
main()
1 J. Kruger 和 R. Westermann, “基于 GPU 的体积渲染加速技术,”IEEE Visualization, 2003.
2 graphics.stanford.edu/data/voldata/
第五部分:# 硬件黑客
系统中你可以用锤子敲击的部分(不推荐)叫做硬件;那些你只能骂的程序指令叫做软件。
—匿名
第十二章:# 在树莓派 Pico 上实现 Karplus-Strong 算法

在第四章中,你学会了如何使用 Karplus-Strong 算法制作拨弦音。你将生成的声音保存为 WAV 文件,并在电脑上播放五声音阶的音符。在这一章中,你将学习如何将该项目缩减以适应一块小型硬件:树莓派 Pico。
Pico(见图 12-1)使用 RP2040 微控制器芯片构建,该芯片仅具有 264KB 的随机存取内存(RAM)。与典型个人计算机的数十 GB 内存相比,这差距非常大!Pico 还具有 2MB 的闪存,位于一个独立的芯片上,而普通计算机的硬盘空间通常有数百 GB。尽管存在这些限制,Pico 仍然非常强大。它可以执行许多有用的任务,同时比普通计算机便宜且能耗低。你的手表、空调、衣物干燥机、汽车、手机——像 RP2040 这样的微控制器无处不在!

图 12-1:树莓派 Pico
这个项目的目标是使用树莓派 Pico 创建一个五个按钮的乐器。每按一个按钮,就会播放五声音阶中的一个音符,该音符由 Karplus-Strong 算法生成。通过这个项目,你将学习到以下一些概念:
-
• 使用 MicroPython 编程微控制器,MicroPython 是专为像 Pico 这样的设备优化的 Python 实现
-
• 使用 Pico 在面包板上构建简单的音频电路
-
• 使用 I2S 数字音频协议和 I2S 放大器将音频数据发送到扬声器
-
• 在资源受限的微控制器上实现第四章中的 Karplus-Strong 算法
它是如何工作的
我们在第四章中详细讨论了 Karplus-Strong 算法,因此在这里不再重复。相反,我们将重点关注此版本项目的不同之处。你在第四章中的程序是为了在笔记本电脑或台式机上运行而设计的。由于电脑拥有充足的内存和硬盘资源,因此它能够轻松使用 Karplus-Strong 算法生成 WAV 文件并通过扬声器播放音频,使用pyaudio库没有任何问题。现在的挑战是将项目代码适配到资源受限的树莓派 Pico 上。这将需要以下修改:
-
• 使用较小的音频采样率以减少内存需求
-
• 使用简单的二进制文件存储原始生成的样本,而不是 WAV 文件
-
• 使用 I2S 协议将音频数据发送到外部音频放大器
-
• 使用内存管理技术以避免重复复制相同的数据
我们将在修改过程中详细讨论这些细节。
输入与输出
为了使项目具有互动性,你需要让 Pico 根据用户输入生成声音。为此,你需要将五个按钮连接到 Pico,因为 Pico 没有键盘或鼠标。(你将使用第六个按钮来运行程序。)我们还需要弄清楚如何产生声音输出,因为与个人计算机不同,Pico 板没有内置扬声器。图 12-2 展示了项目的框图。

图 12-2:项目的框图
当你按下按钮时,运行在 Pico 上的 MicroPython 代码将使用 Karplus-Strong 算法生成一个拨弦音效。算法生成的数字音频样本将被发送到一个独立的 MAX98357A 放大器板,该板将数字数据解码为模拟音频信号。MAX98357A 还会放大模拟信号,使你能够将其输出连接到外部 8 欧姆扬声器,从而听到音频。图 12-3 展示了 Adafruit MAX98357A 板。

图 12-3:Adafruit MAX98357A I2S 放大器板
Pi Pico 需要以特定的格式将数据发送到放大器板,才能成功地将其解读为音频信号。这时就需要使用 I2S 协议。
I2S 协议
Inter-IC Sound (I2S)协议是一个标准,用于在设备之间发送数字音频数据。这是一种简单且便捷的方式,可以从微控制器获取高质量的音频输出。该协议通过三种数字信号来传输音频,具体如图 12-4 所示。

图 12-4:I2S 协议
第一个信号,SCK,是时钟信号,它以固定的速度在高低电*之间交替。这设定了数据传输的速率。接下来,WS 是字选择信号。它稳步地在高低电*之间交替,指示当前传输的是左声道还是右声道。最后,SD 是串行数据信号,它携带实际的音频信息,以 N 位二进制值的形式表示声音的幅度。
为了理解这如何工作,我们来看一个例子。假设你想以 16,000 Hz 的采样率发送立体声音频,并且希望每个声音样本的幅度是 16 位值。WS 的频率应该与采样率相同,因为这是你发送每个幅度值的速率。这样,WS 信号每秒会在高低之间交替 16,000 次;当它为高时,SD 会发送一个音频通道的幅度值;当它为低时,SD 会发送另一个音频通道的幅度值。由于每个通道的幅度值由 16 位组成,SD 必须以比采样率快 16 × 2 = 32 倍的速率传输。时钟控制传输速率,因此 SCK 的频率必须是 16,000 Hz × 32 = 512,000 Hz。
在这个项目中,Pico 将充当 I2S 发射器,因此它将生成 SCK、WS 和 SD 信号。MicroPython 实际上为 Pico 提供了一个完全实现的I2S模块,因此大部分生成信号的工作将由你幕后完成。如你所见,Pico 将信号发送到 MAX98357A 板,该板专门设计用于通过 I2S 协议接收音频数据。然后该板将 I2S 数据转换为模拟音频信号,可以通过扬声器播放。
要求
你将使用 MicroPython 为 Raspberry Pi Pico 编写项目代码。你将需要以下硬件:
-
• 一块基于 RP2040 芯片的 Raspberry Pi Pico 开发板
-
• 一个 Adafruit MAX98357A I2S 扩展板
-
• 一个 8 欧姆扬声器
-
• 六个按键
-
• 五个 10 kΩ电阻
-
• 一块面包板
-
• 一组连接线
-
• 一根 Micro USB 线,用于上传代码到 Pico
硬件设置
你将在面包板上组装硬件。图 12-5 显示了连接方式。

图 12-5:硬件连接
图 12-6 显示了 Pico 的引脚图,来自官方数据手册,是一个方便的连接参考。

图 12-6:来自 Raspberry Pi Pico 数据手册的引脚图
表 12-1 总结了你需要在面包板上实现的电气连接。图 12-5 显示了这些连接。
表 12-1:电气连接
| Pico 引脚 | 连接 |
|---|---|
| GP3 | 按钮 1(另一引脚通过 10 kΩ电阻连接到 VDD) |
| GP4 | 按钮 2(另一引脚通过 10 kΩ电阻连接到 VDD) |
| GP5 | 按钮 3(另一引脚通过 10 kΩ电阻连接到 VDD) |
| GP6 | 按钮 4(另一引脚通过 10 kΩ电阻连接到 VDD) |
| GP7 | 按钮 5(另一引脚通过 10 kΩ电阻连接到 VDD) |
| RUN | 按钮 6(另一引脚连接到 GND) |
| GP0 | MAX98357A BCLK |
| GP1 | MAX98357A LRC |
| GP2 | MAX98357A DIN |
| GND | MAX98357A GND |
| 3V3(OUT) | MAX98357A Vin |
一旦你连接了硬件,你的项目应该会像 图 12-7 中那样。

图 12-7:完整搭建的硬件
然而,在开始使用 Pico 之前,你需要先设置 MicroPython。
MicroPython 设置
设置你的 Raspberry Pi Pico 和 MicroPython 是相当简单的。请按照以下步骤操作:
-
1. 访问
micropython.org,进入下载页面,找到 Raspberry Pi Pico。 -
2. 下载包含 Pico 的 MicroPython 实现的 UF2 二进制文件(版本 1.18 或更高)。
-
3. 按下 Pico 上的白色 BOOTSEL 按钮,同时按住此按钮,用你的 Micro USB 电缆将 Pico 连接到电脑。然后松开按钮。
-
4. 你应该会看到一个名为 RPI-RP2 的文件夹出现在你的电脑上。将 UF2 文件拖放到这个文件夹中。
一旦复制完成并且 Pico 重启,你就可以开始使用 MicroPython 编程 Pico 了!
代码
代码包括一些初始设置,接着是用于生成和播放五个音符的函数。然后,所有内容都将在程序的 main() 函数中汇总。要查看完整的程序,请跳到 “完整代码” 第 275 页。代码也可以在 GitHub 上找到:github.com/mkvenkit/pp2e/blob/main/karplus_pico/karplus_pico.py。
设置
代码以一些基本的设置开始。首先,导入所需的 MicroPython 模块:
import time
import array
import random
import os
from machine import I2S
from machine import Pin
你导入了 time 模块来使用其“sleep”功能,以便在代码执行过程中创建定时暂停。array 模块让你可以创建数组,通过 I2S 发送声音数据。数组是 Python 列表的高效版本,因为它要求所有成员具有相同的数据类型。你将使用 random 模块填充初始缓冲区,使用随机值(这是 Karplus-Strong 算法的第一步),并且使用 os 模块检查某个音符是否已经在文件系统中保存。最后,I2S 模块将让你发送声音数据,Pin 模块则让你设置 Pico 的引脚输出。
你通过声明一些有用的信息来完成设置:
# notes of a minor pentatonic scale
# piano C4-E(b)-F-G-B(b)-C5
❶ pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G':391, 'Bb':466}
# button to note mapping
❷ btnNotes = {0: ('C4', 262), 1: ('Eb', 311), 2: ('F', 349), 3: ('G', 391),
4: ('Bb', 466)}
# sample rate
❸ SR = 16000
在这里,你定义了一个字典pmNotes,它将音符的名称映射到其整数频率值❶。你将使用音符的名称来保存包含声音数据的文件,使用频率值来通过 Karplus-Strong 算法生成声音。你还定义了一个字典btnNotes,它将每个按键的 ID(以整数 0 到 4 表示)映射到一个元组,元组包含对应的音符名称和频率值❷。这个字典控制了用户按下每个按钮时播放的音符。
最后,你将采样率定义为 16,000 Hz❸。这意味着每秒将有 16,000 个声音幅度值通过 I2S 输出。请注意,这比在第四章中使用的 44,100 Hz 采样率要低得多。这是因为与标准计算机相比,Pico 的内存有限。
生成音符
你通过两个函数:generate_note()和create_notes()来生成五个音阶音符。generate_note()函数使用 Karplus-Strong 算法来计算单个音符的幅度值,而create_notes()函数则协调生成所有五个音符并将它们的样本数据保存到 Pico 的文件系统中。我们先来看一下generate_note()函数。(你在第四章中实现了一个类似的函数,因此这可能是复习原始实现的好时机。)
# generate note of given frequency
def generate_note(freq):
nSamples = SR
N = int(SR/freq)
# initialize ring buffer
❶ buf = [2*random.random() - 1 for i in range(N)]
# init sample buffer
❷ samples = array.array('h', [0]*nSamples)
for i in range(nSamples):
❸ samples[i] = int(buf[0] * (2 ** 15 - 1))
❹ avg = 0.4975*(buf[0] + buf[1])
buf.append(avg)
buf.pop(0)
❺ return samples
该函数首先设置nSamples,即将保存最终音频数据的样本缓冲区的长度,等于SR,即采样率。由于SR是每秒的样本数,这意味着你将创建一个持续一秒钟的音频片段。然后,你通过将采样率除以正在生成的音符的频率,来计算 Karplus-Strong 环形缓冲区中的样本数N。
接下来,你初始化缓冲区。首先,你创建了一个带有随机初始值的环形缓冲区❶。random.random()方法返回[0.0, 1.0]范围内的值,因此2*random.random() - 1将这些值缩放到[−1.0, 1.0]范围内。记住,你需要正负幅度值来进行算法。注意,你将环形缓冲区实现为普通的 Python 列表,而不是像在第四章中使用的deque对象。MicroPython 的deque实现有一些限制,无法满足环形缓冲区的需求。因此,你将使用普通的append()和pop()列表方法来向缓冲区添加和移除元素。你还创建了一个样本缓冲区,作为一个长度为nSamples的array对象,初始值为零❷。'h'参数指定该数组中的每个元素是一个带符号短整型,即一个 16 位值,可以是正数或负数。由于每个样本将由 16 位值表示,因此这正是你需要的。
接下来,你遍历 samples 数组中的项目,并使用 Karplus-Strong 算法构建音频片段。你取环形缓冲区中的第一个样本值,并将其从范围 [−1.0, 1.0] 缩放到范围 [−32767, 32767] ❸。(16 位有符号短整型的范围是 [−32767, 32768]。你将幅度值缩放到尽可能高,这样可以获得最高的音量输出。)然后,你计算环形缓冲区中前两个样本的衰减*均值 ❹。(这里,0.4975 与原始实现中的 0.995*0.5 相同。)你使用 append() 将新的幅度值添加到环形缓冲区的末尾,同时使用 pop() 移除第一个元素,从而保持缓冲区的固定大小。循环结束时,样本缓冲区已满,因此你将其返回以便进一步处理 ❺。
注意 使用 append() 和 pop() 来更新环形缓冲区是有效的,但这并不是一种高效的计算方法。我们将在 “实验!” 中进一步探讨优化,在 第 273 页。
现在让我们来看一下 create_notes() 函数:
def create_notes():
"create pentatonic notes and save to files in flash"
❶ files = os.listdir()
❷ for (k, v) in pmNotes.items():
# set note filename
❸ file_name = k + ".bin"
# check if file already exists
❹ if file_name in files:
print("Found " + file_name + ". Skipping...")
continue
# generate note
print("Generating note " + k + "...")
❺ samples = generate_note(v)
# write to file
print("Writing " + file_name + "...")
❻ file_samples = open(file_name, "wb")
❼ file_samples.write(samples)
❽ file_samples.close()
你不想每次用户按下按钮播放音符时都运行 Karplus-Strong 算法,因为那样会太慢。相反,这个函数在第一次运行代码时创建音符,并将它们以 .bin 文件的形式存储在 Pico 的文件系统中。然后,一旦用户按下按钮,你就能读取相应的文件,并通过 I2S 输出声音数据。
你首先使用 os 模块列出 Pico 上的文件 ❶。(Pico 上没有“硬盘”,而是 Pico 板上的闪存芯片用于存储数据,MicroPython 提供了一种像普通文件系统一样访问这些数据的方法。)然后你遍历 pmNotes 字典中的项目,该字典将音符名称映射到频率 ❷。对于每个音符,你根据字典中的名称生成文件名(例如 C4.bin) ❸。如果该目录中已经存在该名称的文件 ❹,则说明你已经生成了该音符,可以跳到下一个音符。否则,你使用 generate_note() 函数生成该音符的声音样本 ❺。然后,你创建一个适当名称的二进制文件 ❻ 并将样本写入其中 ❼。最后,通过关闭文件来进行清理 ❽。
第一次运行代码时,create_notes() 会运行 generate_note() 函数,为每个音符创建一个文件,使用 Karplus-Strong 算法。这将会在 Pico 上创建 C4.bin、Eb.bin、F.bin、G.bin 和 Bb.bin 文件。在随后的运行中,函数会发现这些文件仍然存在,因此不需要重新创建它们。
播放音符
play_note() 函数通过使用 I2S 协议输出样本来播放五声音阶中的一个音符。以下是定义:
def play_note(note, audio_out):
"read note from file and send via I2S"
❶ fname = note[0] + ".bin"
print("opening " + fname)
# open file
try:
print("opening {}...".format(fname))
❷ file_samples = open(fname, "rb")
except:
print("Error opening file: {}!".format(fname))
return
# allocate sample array
❸ samples = bytearray(1000)
# memoryview used to reduce heap allocation
❹ samples_mv = memoryview(samples)
# read samples and send to I2S
try:
❺ while True:
❻ num_read = file_samples.readinto(samples_mv)
# end of file?
❼ if num_read == 0:
break
else:
# send samples via I2S
❽ num_written = audio_out.write(samples_mv[:num_read])
❾ except (Exception) as e:
print("Exception: {}".format(e))
# close file
❿ file_samples.close()
函数有两个参数:note,一个元组,形式为('C4', 262),表示音符名称和频率;audio_out,是一个I2S模块的实例,用于声音输出。你首先根据要播放的音符名称创建适当的.bin文件名 ❶。然后打开该文件 ❷。你期望此时文件已经存在,因此如果打开失败,直接从函数返回。
函数的其余部分通过 I2S 输出音频数据,按 1,000 个样本为一批进行工作。为了调解数据传输,你创建了一个 MicroPython 的bytearray,包含 1,000 个样本 ❸,并且创建了一个样本的memoryview ❹。这是一个 MicroPython 优化技术,可以避免在将数组切片传递给file_samples.readinto()和audio_out.write()等函数时,整个数组被复制。
注意:数组的切片表示数组中一段值的范围。例如,a[100:200]是一个切片,表示数组中a[100]到a[199]的值。
接下来,你开始一个while循环,从文件中读取样本 ❺。在循环中,你使用readinto()方法 ❻将一批样本读取到memoryview对象中,该方法返回读取的样本数量(num_read)。你通过 I2S 使用audio_out.write()方法 ❽将样本从memoryview对象输出。[:num_read]的切片表示法确保你输出与读取的样本数量相同。你在❾处处理任何异常。当memoryview对象中读取到零个样本时 ❼,即已完成数据输出,你可以跳出while循环并关闭.bin文件 ❿。
编写 main()函数
现在让我们来看一下main()函数,它将所有代码结合在一起:
def main():
# set up LED
❶ led = Pin(25, Pin.OUT)
# turn on LED
led.toggle()
# create notes and save in flash
❷ create_notes()
# create I2S object
❸ audio_out = I2S(
0, # I2S ID
sck=Pin(0), # SCK Pin
ws=Pin(1), # WS Pin
sd=Pin(2), # SD Pin
mode=I2S.TX, # I2S transmitter
bits=16, # 16 bits per sample
format=I2S.MONO, # Mono - single channel
rate=SR, # sample rate
ibuf=2000, # I2S buffer length
)
# set up btns
❹ btns = [Pin(3, Pin.IN, Pin.PULL_UP),
Pin(4, Pin.IN, Pin.PULL_UP),
Pin(5, Pin.IN, Pin.PULL_UP),
Pin(6, Pin.IN, Pin.PULL_UP),
Pin(7, Pin.IN, Pin.PULL_UP)]
# "ready" note
❺ play_note(('C4', 262), audio_out)
print("Piano ready!")
# turn off LED
❻ led.toggle()
while True:
for i in range(5):
if btns[i].value() == 0:
❼ play_note(btnNotes[i], audio_out)
break
❽ time.sleep(0.2)
函数开始时设置了 Pico 的板载 LED ❶。它在开始时被切换为开启状态,以指示 Pico 正在忙于初始化。接下来,调用create_notes()函数 ❷。正如我们所讨论的,这个函数只有在.bin文件在文件系统中不存在的情况下才会创建相应的音符文件。为了管理音频输出,你实例化了I2S模块为audio_out ❸。该模块需要一些输入参数。第一个参数是 I2S ID,对于 Raspberry Pi Pico 来说是0。接下来是与时钟(SCK)、字选择(WS)和数据(SD)信号对应的引脚编号。我们在“I2S 协议” 第 262 页中讨论了这些信号。然后你将 I2S 模式设置为TX,表示这是一个 I2S 发送器。接着,将bits设置为16,表示每个样本的位数,format设置为MONO,因为只有一个音频输出通道。将采样率设置为SR,最后,将内部 I2S 缓冲区ibuf的值设置为2000。
注意:流畅的音频体验需要一个不断输出数据的流。MicroPython 利用 Pico 中的一个特殊硬件模块,称为直接内存访问(DMA)来实现这一点。DMA 可以将数据从内存传输到 I2S 输出,而不直接涉及 CPU。CPU 只需要保持内部缓冲区(代码中的ibuf)充满数据,并且可以在 DMA 执行任务时自由地做其他事情。内部缓冲区的大小通常设置为至少是音频输出大小的两倍,以避免 DMA 没有足够的数据进行传输,从而导致音频失真。在本例中,你将一次传输 1,000 字节到 I2S,因此你将ibuf设置为它的两倍。
接下来,你需要设置按钮,以便在按钮按下时播放音符。为此,你创建一个Pin对象的列表,名为btns❹。对于列表中的每个按钮,你需要指定引脚号、引脚的数据方向(在此情况下为Pin.IN,即输入),以及引脚是否具有上拉电阻。在本例中,所有按钮的引脚上都有一个 10 kΩ的上拉电阻。这意味着默认情况下,引脚的电压会被“拉高”到 VDD,即 3.3 V,而当按钮被按下时,电压会下降到 GND,即 0 V。你将利用这一点来检测按钮按下。
一旦设置完成,你可以通过play_note()函数播放一个 C4 音符,表示 Pico 已准备好接受按钮按下的操作❺,同时你也将板载 LED 关闭❻。然后,你启动一个while循环来监测按钮的按下。在这个循环中,你使用for循环检查五个按钮中是否有任何一个按钮的值为0,表示该按钮被按下。如果是这样,你会在btnNotes字典中查找对应按钮的音符,并通过play_note()播放该音符❼。音符播放完成后,你会跳出for循环,等待 0.2 秒❽后继续执行外部的while循环。
运行 Pico 代码
现在,你准备好测试你的项目了!为了在 Pico 上运行代码,安装两个软件是很有帮助的。第一个是 Thonny,一个开源且易于使用的 Python 集成开发环境(IDE),你可以从thonny.org下载。Thonny 使得将你的项目代码复制到 Raspberry Pi Pico 并管理 Pico 上的文件变得非常容易。一个典型的开发周期如下:
-
- 通过 USB 将 Pico 连接到你的计算机。
-
- 打开 Thonny。点击窗口右下角的 Python 版本号,将解释器更改为MicroPython (Raspberry Pi Pico)。
-
- 将你的代码复制到 Thonny 中,点击红色的停止/重启按钮,停止代码在 Pico 上的运行。这将显示 IDE 底部的 Python 解释器。
-
- 在 Thonny 中编辑你的代码。
-
- 当你准备好保存文件时,选择文件‣另存为,系统会提示你将文件保存在树莓派 Pico 上。接下来的对话框也会列出 Pico 上的文件。将你的代码保存为main.py。你还可以使用此对话框右键点击并删除 Pico 上的现有文件。
-
- 在保存文件后,按下你连接到 Pico 上的 RUN 引脚的额外按键,你的代码就会开始运行。
-
- 每次你想编辑代码时,点击 IDE 中的停止/重启按钮,Thonny 会将你带到 Pico 上的 Python 解释器。
另一个对 Pico 工作非常有用的软件是 CoolTerm,你可以从freeware.the-meiers.org下载它。CoolTerm 可以让你监控 Pico 的串行输出。你程序中的所有打印语句都会出现在这里。使用 CoolTerm 时,请确保 Thonny 没有“停止”状态。Pico 的代码应该处于运行状态,因为 Pico 不能同时连接 Thonny 和 CoolTerm。
一旦代码开始运行,依次按下按钮,你将会听到从扬声器中传出的美妙五声音阶音符。图 12-8 展示了典型会话的串行输出。

图 12-8:CoolTerm 中树莓派 Pico 的输出示例
看看你能用数字乐器的五个按键来组成和演奏什么旋律!
总结
在这一章中,你将你的 Karplus-Strong 算法实现从第四章移植到一个微型控制器上,并使用树莓派 Pico 构建了一个数字乐器。你学习了如何在 Pico 上运行 Python(以 MicroPython 的形式),以及如何使用 I2S 协议传输音频数据。你还了解了将代码从个人电脑移植到像 Pico 这样资源有限的设备时的局限性。
实验!
-
- MAX98357A I2S 板可以让你增加输出音量(增益)。查看该板的 datasheet,并尝试提升从扬声器传出的声音。
-
-
当前的
generate_note()实现速度并不是很快。对于这个项目来说,这个问题并不算太重要,因为你只需要生成一次音符。然而,你能让这个方法更快吗?这里有一些策略可以尝试: -
a. 不要在
buf列表上使用append()和pop()操作,而是通过跟踪当前列表的位置并使用模运算%N来使列表变成循环缓冲区。 -
b. 使用整数操作代替浮点数操作。你需要考虑如何生成和缩放初始随机值。
MicroPython 文档中的语言参考页面(
docs.micropython.org)有一篇关于如何最大化代码速度的文章。文档还建议了如何测试你的结果。首先,定义一个函数来测量时间:def timed_function(f, *args, **kwargs): myname = str(f).split(' ')[1] def new_func(*args, **kwargs): t = time.monotonic() result = f(*args, **kwargs) delta = time.monotonic() - t print('Function {} Time = {:f} s'.format(myname, delta)) return result return new_func然后将
timed_function()作为装饰器应用于你想要计时的函数:# generate note of given frequency @timed_function def generateNote(freq): nSamples = SR N = int(SR/freq) --`snip`--当你在主代码中调用
generateNote()时,你会在串口输出中看到类似这样的内容:Function generateNote Time = 1019.711ms -
-
3. 当你按下硬件按钮时,它并不会直接从开到关,或反向切换。按钮内部的弹簧接触点会在开和关之间反复跳动,发生多次开关操作,在极短的时间内触发多个软件事件。想一想这会如何影响你的项目,然后了解一下去抖动,这是一类减少此问题的技术。你可以采取哪些步骤来去抖动你的按钮?
-
4. 当你按下按钮时,直到当前音符播放完毕,才会播放新的音符。如果在按下新按钮时你想立刻停止当前音符的播放并切换到新音符,应该怎么做?
完整代码
这是这个项目的完整代码列表。
"""
karplus_pico.py
Uses the Karplus-Strong algorithm to generate musical notes in a
pentatonic scale. Runs on a Raspberry Pi Pico. (MicroPython)
Author: Mahesh Venkitachalam
"""
import time
import array
import random
import os
from machine import I2S
from machine import Pin
# notes of a minor pentatonic scale
# piano C4-E(b)-F-G-B(b)-C5
pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G':391, 'Bb':466}
# button to note mapping
btnNotes = {0: ('C4', 262), 1: ('Eb', 311), 2: ('F', 349), 3: ('G', 391),
4: ('Bb', 466)}
# sample rate
SR = 16000
def timed_function(f, *args, **kwargs):
myname = str(f).split(' ')[1]
def new_func(*args, **kwargs):
t = time.ticks_us()
result = f(*args, **kwargs)
delta = time.ticks_diff(time.ticks_us(), t)
print('Function {} Time = {:6.3f}ms'.format(myname, delta/1000))
return result
return new_func
# generate note of given frequency
# (Uncomment line below when you need to time the function.)
# @timed_function
def generate_note(freq):
nSamples = SR
N = int(SR/freq)
# initialize ring buffer
buf = [2*random.random() - 1 for i in range(N)]
# init sample buffer
samples = array.array('h', [0]*nSamples)
for i in range(nSamples):
samples[i] = int(buf[0] * (2 ** 15 - 1))
avg = 0.4975*(buf[0] + buf[1])
buf.append(avg)
buf.pop(0)
return samples
# generate note of given frequency - improved method
def generate_note2(freq):
nSamples = SR
sampleRate = SR
N = int(sampleRate/freq)
# initialize ring buffer
buf = [2*random.random() - 1 for i in range(N)]
# init sample buffer
samples = array.array('h', [0]*nSamples)
start = 0
for i in range(nSamples):
samples[i] = int(buf[start] * (2**15 - 1))
avg = 0.4975*(buf[start] + buf[(start + 1) % N])
buf[(start + N) % N] = avg
start = (start + 1) % N
return samples
def play_note(note, audio_out):
"read note from file and send via I2S"
fname = note[0] + ".bin"
# open file
try:
print("opening {}...".format(fname))
file_samples = open(fname, "rb")
except:
print("Error opening file: {}!".format(fname))
return
# allocate sample array
samples = bytearray(1000)
# memoryview used to reduce heap allocation
samples_mv = memoryview(samples)
# read samples and send to I2S
try:
while True:
num_read = file_samples.readinto(samples_mv)
# end of file?
if num_read == 0:
break
else:
# send samples via I2S
num_written = audio_out.write(samples_mv[:num_read])
except (Exception) as e:
print("Exception: {}".format(e))
# close file
file_samples.close()
def create_notes():
"create pentatonic notes and save to files in flash"
files = os.listdir()
for (k, v) in pmNotes.items():
# set note filename
file_name = k + ".bin"
# check if file already exists
if file_name in files:
print("Found " + file_name + ". Skipping...")
continue
# generate note
print("Generating note " + k + "...")
samples = generate_note(v)
# write to file
print("Writing " + file_name + "...")
file_samples = open(file_name, "wb")
file_samples.write(samples)
file_samples.close()
def main():
# set up LED
led = Pin(25, Pin.OUT)
# turn on LED
led.toggle()
# create notes and save in flash
create_notes()
# create I2S object
audio_out = I2S(
0, # I2S ID
sck=Pin(0), # SCK Pin
ws=Pin(1), # WS Pin
sd=Pin(2), # SD Pin
mode=I2S.TX, # I2S transmitter
bits=16, # 16 bits per sample
format=I2S.MONO, # Mono - single channel
rate=SR, # sample rate
ibuf=2000, # I2S buffer length
)
# set up btns
btns = [Pin(3, Pin.IN, Pin.PULL_UP),
Pin(4, Pin.IN, Pin.PULL_UP),
Pin(5, Pin.IN, Pin.PULL_UP),
Pin(6, Pin.IN, Pin.PULL_UP),
Pin(7, Pin.IN, Pin.PULL_UP)]
# "ready" note
play_note(('C4', 262), audio_out)
print("Piano ready!")
# turn off LED
led.toggle()
while True:
for i in range(5):
if btns[i].value() == 0:
play_note(btnNotes[i], audio_out)
break
time.sleep(0.2)
# call main
if __name__ == '__main__':
main()
第十三章:# 使用树莓派显示激光音频

在第十二章中,你使用了一个微型控制器 Pico 来生成音乐音调。在本章中,你将使用一个更强大的嵌入式系统——树莓派,根据音频信号生成有趣的激光图案。
上一章节的 Pico 配备了一个 RP2040 微控制器,拥有双核 ARM Cortex-M0 处理器,运行速度可达 133 MHz,具有 264KB 的随机存取内存(RAM)和 2MB 的非易失性存储空间,存储在外部闪存芯片中。相比之下,树莓派 3B+配备了一个更强大的 ARM Cortex-A53 处理器,运行速度为 1.4 GHz,具有 1GB 的 RAM,并根据使用的 SD 卡提供几 GB 的存储空间。虽然这与标准的桌面或笔记本电脑相比仍显得微不足道,但树莓派仍然能够运行基于 Linux 的操作系统和完整的 Python 环境,而不像 Pico 那样受限。
在本章中,你将使用树莓派上的 Python 读取 WAV 格式的音频文件,基于实时音频数据进行计算,并使用这些数据来调整激光显示设备中两个电机的旋转速度和方向。你将把镜子安装在电机上,反射来自便宜激光模块的光束,生成类似 Spirograph 的图案,这些图案会根据音频发生变化。你还将同时将音频流传输到扬声器,这样你就可以在观看激光灯光秀的同时听到 WAV 文件的播放。
本项目将进一步拓展你对 Python 的理解,帮助你学习如何使用树莓派。以下是我们将要涉及的一些主题:
-
• 使用激光和两个旋转镜子生成有趣的图案
-
• 使用快速傅里叶变换(FFT)从信号中获取频率信息
-
• 使用
numpy计算 FFT -
• 从 WAV 文件中读取音频
-
• 使用
pyaudio输出音频数据 -
• 使用树莓派驱动电机
-
• 使用金属氧化物半导体场效应晶体管(MOSFET)开关激光模块的开/关
工作原理
你将使用树莓派处理音频数据并控制硬件。图 13-1 显示了你将在本项目中创建的框图。

图 13-1:激光音频项目的框图
树莓派将以两种方式使用 WAV 文件。它将通过pyaudio将文件播放通过附加的扬声器,同时使用一种名为快速傅里叶变换(FFT)的数学技术实时分析音频数据。树莓派将利用 FFT 数据通过其通用输入/输出(GPIO)引脚驱动电动机和激光,但为了保护树莓派不受损坏,你不会直接将其连接到这些外部组件。相反,你将通过电动机驱动板和 MOSFET 间接连接它。在开始之前,让我们更详细地考虑一下这些项目方面的工作原理。
用激光生成图案
为了在本项目中生成激光图案,你将使用一个激光模块和两个附着在两个小型直流电动机轴上的镜子,如图 13-2 所示。可以把激光看作是一个强烈的光束,即使投射到很远的地方,它也会始终聚焦在一个微小的点上。这种聚焦之所以能实现,是因为激光束的波长是有序的,波的传播方向一致,且它们相位相同。如果你将激光照射到一个*面镜(图 13-2 中的镜子 A)的表面,反射出来的点将保持固定,即使电动机在旋转。由于激光的反射面垂直于电动机的旋转轴,所以即使镜子在旋转,反射的激光点看起来就像是镜子根本没有旋转。
现在,假设镜子相对于电动机轴以一定角度附着,如图 13-2 右侧所示(镜子 B)。随着轴的旋转,投影点将描绘出一个椭圆形,如果电动机旋转得足够快,观察者将看到该移动点呈现为一个连续的形状。

图 13-2:*面镜(镜子 A)反射一个点。倾斜镜子(镜子 B)的反射随着电动机的旋转形成一个圆圈。
如果两个镜子都倾斜,并且你将它们排列,使得从镜子 A 反射的点投射到镜子 B 上呢?现在,当电动机 A 和 B 旋转时,反射点产生的图案将是电动机 A 和 B 两种旋转运动的组合,产生有趣的图案,如图 13-3 所示。

图 13-3:通过两个旋转的倾斜镜子反射激光光线,会产生有趣且复杂的图案。
产生的精确图案将取决于两个电动机的旋转速度和方向,但它们将类似于你在第二章中探讨的由 Spirograph 生成的假极线。
电动机控制
你将使用树莓派通过一种叫做脉宽调制(PWM)的技术来控制电机的速度和方向。这是一种通过快速开关的数字脉冲来为设备(如电机)供电的方式,使得设备“看到”一个持续的电压。发送到设备的信号有固定的频率,但数字脉冲开启的时间比例(即占空比)是可变的。占空比以百分比表示。举例来说,图 13-4 显示了三个频率相同但占空比不同的信号——分别是 25%、50%和 75%。

图 13-4:具有不同占空比的 PWM 信号
占空比百分比越高,每个信号周期中脉冲打开的时间就越长。接收到信号的电机会将这些较长的脉冲感知为更高的持续电压。通过调节占空比,你可以为这个项目中的电机提供不同的功率,从而导致电机速度的变化和激光模式的变化。
注意:PWM 在电机控制之外还有许多应用。例如,它还可以用于控制可调光 LED 的亮度。
电机工作时电压较高,但树莓派只能承受有限的电流,超过这个电流就会被损坏。你将使用一个 TB6612FNG 电机驱动模块板,类似于图 13-5 中显示的那种,作为树莓派与电机之间的中介,确保树莓派的安全。市面上有许多这种模块的变种,你可以选择其中任何一种,只要小心正确接线即可。

图 13-5:TB6612FNG 电机驱动模块的印刷电路板(PCB)
模块板的底部应该有引脚信息。查看 TB6612FNG 芯片的数据手册也是一个好主意,你可以从互联网上下载。引脚名称中的A和B表示两个电机。IN 引脚控制电机的方向,01 和 02 引脚为电机提供电源,而 PWM 引脚则使用脉宽调制来控制电机的速度。通过向这些引脚写入数据,你可以控制每个电机的旋转方向和速度,这正是这个项目所需要的。我们不会详细讲解这个模块的工作原理,但如果你感兴趣,可以从学习H 桥开始,这是一种常见的电路设计,利用 MOSFET 控制电机。
注意:你可以用任何你熟悉的电机控制电路来替换这个模块,只要你相应地修改代码即可。
激光模块
对于激光,你将使用一个价格低廉的激光模块板,类似于图 13-6 中展示的那种。

图 13-6:激光模块
市面上有不同版本的激光模块。你需要一个工作在 5 伏(V)的 650 纳米(nm)红色激光模块。(650 nm 指的是激光的波长。)在使用该板进行项目之前,确保你了解它的极性和连接方式。使用 5 V 电源单独测试它。
MOSFET
要使用树莓派打开和关闭激光模块,你将使用N-channel MOSFET,你可以把它看作是一个电控开关。你可以使用几乎任何 N-channel MOSFET 来完成这个项目,但 BS170 既便宜又容易获取。图 13-7 显示了 MOSFET 的引脚编号,以及如何将其连接到激光模块和树莓派。

图 13-7:BS170 MOSFET 连接图
10 kΩ 电阻将 MOSFET 的门引脚“拉”到地面,这样当树莓派 GPIO 引脚处于浮空状态时(例如,在 GPIO 清理后),它不会被触发。当你向 GPIO 发送 HIGH 信号时,MOSFET 开关会打开,实际上将激光模块连接到 VM 和 GND,从而为其供电。
为什么需要一个 MOSFET?不能直接将激光模块连接到树莓派的 GPIO 引脚吗?这个主意并不好,因为 MOSFET 能承受的电流远大于树莓派。使用 MOSFET 可以将树莓派与负载电流激增的情况隔离开来。宁愿烧坏你便宜的 MOSFET,也不要烧坏你相对昂贵的树莓派!一般来说,每当你想用树莓派控制外部设备时,记得使用 MOSFET 是一个好主意。
使用快速傅里叶变换分析音频
因为这个项目的最终目标是根据音频输入控制电机速度,你需要能够实时分析音频。回想一下第四章,声学乐器发出的音调是多种频率或泛音的混合。事实上,任何声音都可以通过傅里叶变换分解成它的组成频率。当傅里叶变换应用于数字信号时,结果被称为离散傅里叶变换(**DFT),因为数字信号是由许多离散样本组成的。在这个项目中,你将使用 Python 实现一个快速傅里叶变换(FFT)算法来计算 DFT。(在本章中,我将使用FFT 来指代算法和结果。)
图 13-8 展示了 FFT 的一个简单例子。图中的顶部框展示了一个由两个正弦波组成的信号波形。这个图是时域图,因为它展示了信号的振幅如何随时间变化。图中的底部框展示了该信号的 FFT。FFT 是频域图,它显示了在某一时刻信号中存在哪些频率。

图 13-8:包含多个频率的音频信号(顶部)及其对应的 FFT(底部)
顶部框中的波形可以通过以下方程表示,该方程将两个正弦波相加:
y(t) = 4sin(2π10t) + 2.5sin(2π30t)
注意表达式中第一个波的 4 和 10——4 是波的振幅,10 是频率(单位赫兹)。而第二个波的振幅为 2.5,频率为 30 赫兹。观察图中底部框的 FFT,你会看到它有两个峰值,分别在 10 赫兹和 30 赫兹。FFT 揭示了信号的组成频率。FFT 还识别了每个频率的相对振幅;第一个峰的强度大约是第二个峰的两倍。
现在我们来看一个更现实的例子。图 13-9 展示了顶部框中的复杂音频信号和底部框中的对应 FFT。注意,FFT 包含了更多的峰值,并且它们的强度各不相同,表明信号包含了更多的频率。

图 13-9:FFT 算法获取一个振幅信号(顶部),并计算其组成的频率(底部)
为了计算 FFT,你需要一组采样数据。选择采样数量有些任意,但样本太少不会给你信号频率内容的清晰图像,而且可能还会导致更高的计算负担,因为你需要每秒计算更多的 FFT。另一方面,样本数量过大则会*滑信号的变化,因此你将无法得到信号的“实时”频率响应。对于本项目,2,048 个样本是可行的。在 44,100 赫兹的采样率下,2,048 个样本代表约 0.046 秒的音频。
你将使用numpy来计算 FFT,将音频数据分解为其组成的频率,然后利用这些信息来控制电机。首先,你将把频率范围(单位为赫兹)分为三个频段:[0, 100]、[100, 1000]和[1000, 2500]。你将为每个频段计算一个*均振幅值,每个值将以不同的方式影响电机和最终的激光图案,具体如下:
-
• 低频率的*均振幅变化会影响第一个电机的速度。
-
• 中频的*均振幅变化会影响第二个电机的速度。
-
• 当高频率超过某个阈值时,第一个电机将改变方向。
根据这些规则,激光图案会响应音频信号发生变化。
要求
在这个项目中,你将使用以下 Python 模块:
-
•
RPi.GPIO用于设置 PWM 和控制引脚的输出 -
•
time用于操作之间的暂停 -
•
wave用于读取 WAV 文件 -
•
pyaudio用于处理和流式传输音频数据 -
•
numpy用于 FFT 计算 -
•
argparse用于处理命令行参数
你还需要以下物品来构建该项目:
-
• 一个 Raspberry Pi 3B+ 或更新版本
-
• 一个 5 V 适配器,用于为 Raspberry Pi 供电
-
• 一个带 AUX(线路输入)接口的有源扬声器(现在大多数蓝牙扬声器都有 AUX 输入)
-
• 一个 TB6612FNG 电机扩展板
-
• 一个激光模块扩展板
-
• 一个 10 kΩ 电阻
-
• 一个 BS170 N-channel MOSFET 或等效元件
-
• 两个 9 V 额定的小型玩具用直流电机
-
• 两个小镜子,直径大约为 1 英寸或更小
-
• 一个 3.7 V 18650 2000 mAh(3C)锂离子电池和电池座(或者使用四节 AA 电池和电池座)
-
• 两个 3D 打印的零件,用于将镜子固定在电机轴上(可选)
-
• 一个大约 8 英寸 × 6 英寸的矩形底座,用于安装硬件
-
• 一些 LEGO 积木,用于将电机和激光模块抬起,以便镜子可以自由旋转
-
• 一把热熔胶枪
-
• 超级胶水用于将镜子固定到电机轴上
-
• 一把焊接铁
-
• 一个面包板
-
• 用于连接的电线(双头公针单股连接线效果很好)
设置 Raspberry Pi
要设置你的 Raspberry Pi,请参见 附录 B。按照附录中的说明操作,确保已经安装了本项目所需的 numpy 和 pyaudio Python 包。你将通过安全外壳(SSH)在 Raspberry Pi 上编写代码。你可以通过 SSH 将 Microsoft Visual Studio Code 设置为远程连接 Pi,操作可以在你的笔记本电脑或台式机上进行。这一点在 附录 B 中也有说明。
构建激光显示
在连接所有硬件之前,你应该准备电机和激光模块用于激光显示。首先要做的是将镜子安装到电机上。每个镜子必须相对于电机轴稍微倾斜。可以使用热熔胶来完成这项工作。将镜子放在*坦的表面上,镜面朝下,在中心滴上一滴热熔胶。小心地将电机轴浸入胶水中,确保其相对于镜子垂直线有一个轻微的角度,直到胶水凝固。
更好的方法是使用带有倾斜面的电机法兰,您可以轻松地将镜子粘贴到其上。但是,您在哪里能找到这样的部件呢?您可以使用 3D 打印自己制作!图 13-10(a)展示了我使用名为 OpenSCAD 的免费开源程序创建的 3D 设计。您可以从本书的 GitHub 仓库下载该设计。图 13-10(b)展示了 3D 打印的部件。激光首先照射到的镜子将使用倾斜较小的法兰(5 度),而第二面镜子将使用倾斜较大的法兰(10 度)。

(a)

(b)
图 13-10:OpenSCAD 模型(a)和 3D 打印法兰(b)
如果您有 3D 打印机,可以自己打印法兰,或者从 3D 打印服务商那里打印。(无论哪种方式,都不会很贵。)一旦得到这些部件,使用强力胶将法兰固定到电机轴上,再将镜子粘贴到法兰上。图 13-11 展示了完全组装好的部件。

图 13-11:将镜子以轻微的角度固定到每个电机轴上。
要测试装配效果,用手旋转镜子,同时将激光模块对准它。您应该会发现,激光点的反射在*面表面上呈椭圆形移动。对第二面镜子也做相同的操作。由于相对于电机轴的角度较大,它应该会形成一个更宽的椭圆。
镜子对准
接下来,将激光模块与镜子对准,使激光从镜子 A 反射到镜子 B,如图 13-12 所示。确保从镜子 A 反射出来的激光光束在镜子 A 的整个旋转范围内都保持在镜子 B 的圆周内。(这将需要一些反复试验。)为了测试这一排列,手动旋转镜子 A。同时,确保将镜子 B 放置好,使得其表面反射的光束在两个镜子旋转的整个范围内都会落到一个*面表面(如墙壁)上。

图 13-12:激光与镜子的对准
注意:在调整对准时,您需要保持激光指示器开启。您可以通过运行以下项目代码来实现:python laser_audio.py --test_laser。这个命令只是开启控制激光模块的 MOSFET,稍后我们会在本章中讨论这个问题。
一旦你对镜子的放置感到满意,用热熔胶将激光模块和附加镜子的两个电机固定在三个相同的积木上(乐高积木非常适用!),将其抬高,以便电机能够自由旋转。接着,将这些积木放在安装板上,当你对它们的排列感到满意时,用铅笔勾画出它们的位置,然后将积木用热熔胶粘在板上。或者,使用乐高底板,并将乐高积木直接附加到底板上。
电机供电
如果你的电机没有附带连接端子上的电线(大多数没有),请在两个端子上焊接电线,确保留下足够的电线(例如 6 英寸),以便将电机连接到电机驱动板。电机可以由 3.7V 锂电池或 4 节 AA 电池包供电。
连接硬件
现在开始连接硬件。你需要将树莓派、电机驱动板、MOSFET、激光模块板和电机连接起来。树莓派拥有一系列 GPIO 引脚,可以连接到其他硬件。为了了解引脚布局,我强烈建议你访问网站pinout.xyz。它提供了一个方便的视觉参考,并解释了各种引脚的功能。
注意:有几种不同的惯例用于引用树莓派上的引脚编号。对于这个项目,我们将使用BCM 引脚编号惯例。
表 13-1 列出了你需要连接的各个接口。
表 13-1:硬件接线连接
| 从 | 到 |
|---|---|
| 树莓派 GPIO 12 | TB6612FNG PWMA |
| 树莓派 GPIO 13 | TB6612FNG PWMB |
| 树莓派 GPIO 7 | TB6612FNG AIN1 |
| 树莓派 GPIO 8 | TB6612FNG AIN2 |
| 树莓派 GPIO 5 | TB6612FNG BIN1 |
| 树莓派 GPIO 6 | TB6612FNG BIN2 |
| 树莓派 GPIO 22 | TB6612FNG STBY |
| 树莓派 GND | TB6612FNG GND |
| 树莓派 3V3 | TB6612FNG VCC |
| 树莓派 GPIO 25 | BS170 栅极(也连接到 GND 通过 10 kΩ电阻) |
| 树莓派 GND | BS170 源极 |
| 激光模块 GND | BS170 漏极 |
| 激光模块 VCC | 电池包 VCC(+) |
| 电池包 GND(−) | TB6612FNG GND |
| 电池包 VCC(+) | TB6612FNG VM |
| 电机#1 连接器#1(极性无关) | TB6612FNG A01 |
| 电机#1 连接器#2(极性无关) | TB6612FNG A02 |
| 电机#2 连接器#1(极性无关) | TB6612FNG B01 |
| 电机#2 连接器#2(极性无关) | TB6612FNG B02 |
| 树莓派 3.5 毫米音频接口 | 带电扬声器的 AUX 输入 |
图 13-13 显示了所有的接线。

图 13-13:完全接线的激光显示器
现在让我们来看一下代码。
代码
这个项目的代码在文件laser_audio.py中。你将从一些基本的设置开始。然后,你将定义用于操作和测试电机与激光的函数,并定义一个用于处理 WAV 文件中音频数据并根据这些数据控制电机的函数。最后,你会将所有内容整合起来,并通过main()函数接收命令行选项。要查看完整程序,请跳到“完整代码”章节,第 305 页。你也可以在github.com/mkvenkit/pp2e/tree/main/laser_audio下载代码。
设置
首先导入所需的模块:
import RPi.GPIO as GPIO
import time
import argparse
import pyaudio
import wave
import numpy as np
RPi.GPIO模块让你能够使用树莓派的引脚。你将使用time模块来在代码中添加延迟,并且使用argparse来为程序添加命令行参数。pyaudio和wave模块将帮助你从 WAV 文件中读取数据并输出音频流。最后,你将使用numpy来计算音频数据的 FFT。
接下来,你初始化一些全局变量:
# define pin numbers
# uses TB6612FNG motor driver pin naming
PWMA = 12
PWMB = 13
AIN1 = 7
AIN2 = 8
BIN1 = 5
BIN2 = 6
STBY = 22
LASER = 25
这段代码存储了项目中所有使用的树莓派引脚的编号。PWMA、PWMB、AIN1、AIN2、BIN1、BIN2和STBY是连接到 TB6612FNG 电机驱动器的引脚。LASER引脚将连接到 MOSFET 的门极,用来控制激光模块的开关。请注意,你在这里使用的是 BCM 引脚编号约定。
继续定义更多的全局变量:
# global PWM objects
pwm_a = None
pwm_b = None
# size of audio data read in
CHUNK = 2048
# FFT size
N = CHUNK
在这里,你初始化了pwm_a和pwm_b变量,它们将代表PWM对象,用于控制电机。由于在代码中此时创建实际的PWM对象还为时过早,所以你将它们设置为None。你还设置了CHUNK,即每次从 WAV 文件中读取的音频数据样本数量,以及N,即用于计算 FFT 的样本数量。
你通过初始化 GPIO 引脚来完成设置。这是使用引脚所必须的。为此,你定义了一个init_pins()函数:
def init_pins():
"""set up pins"""
❶ global pwm_a, pwm_b
# use BCM pin numbering
❷ GPIO.setmode(GPIO.BCM)
# put pins into a list
pins = [PWMA, PWMB, AIN1, AIN2, BIN1, BIN2, STBY, LASER]
# set up pins as outputs
❸ GPIO.setup(pins, GPIO.OUT)
# set PWM
pwm_a = GPIO.PWM(PWMA, 100)
pwm_b = GPIO.PWM(PWMB, 100)
首先,你声明pwm_a和pwm_b是全局变量❶,因为你将在这个函数内部设置它们。接着,你将引脚模式设置为 BCM 编号约定❷。然后,你将之前设置的引脚变量放入一个pins列表中,这样你可以通过一次调用将它们全部声明为输出引脚❸。最后,你创建两个PWM对象,并将它们分配给全局变量pwm_a和pwm_b。参数100是信号的频率,单位为赫兹,用于驱动每个电机。你将通过调整这些信号的占空比来控制电机的速度,使用脉宽调制。
控制硬件
你需要一些辅助函数来控制激光模块和电机。首先让我们看看切换激光模块的函数:
def laser_on(on):
# pin 25 controls laser ctrl mosfet
GPIO.output(LASER, on)
这个函数接受一个参数on,它是一个布尔值True/False。你将该参数传递给GPIO.output()方法,以便设置LASER引脚为开(True)或关(False)。这将触发 MOSFET 开关,控制激光模块的开关。
接下来,定义一个函数start_motors(),在项目开始时启动电机:
def start_motors():
"""start both motors"""
# enable driver chip
❶ GPIO.output(STBY, GPIO.HIGH)
# set motor direction for channel A
❷ GPIO.output(AIN1, GPIO.HIGH)
❸ GPIO.output(AIN2, GPIO.LOW)
# set motor direction for channel B
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.LOW)
# set PWM for channel A
duty_cycle = 10
❹ pwm_a.start(duty_cycle)
# set PWM for channel B
pwm_b.start(duty_cycle)
首先,你将STBY(待机)引脚设置为HIGH ❶,这样实际上就打开了电机驱动器。然后,你将AIN1和AIN2引脚分别设置为HIGH ❷和LOW ❸。这将使电机 A 朝一个方向旋转。(如果交换这两个引脚的HIGH/LOW值,电机会朝相反方向旋转。)你对电机 B 执行相同的操作。最后,你使用PWM对象来设置电机的速度 ❹。你将占空比(与电机速度相关)设置为 10%,一个相对较低的值,因为这只是初始化调用。
你还需要一个函数,在项目结束时停止电机旋转。下面是定义:
def stop_motors():
"""stop both motors"""
# stop PWM
❶ pwm_a.stop()
❷ pwm_b.stop()
# brake A
GPIO.output(AIN1, GPIO.HIGH)
GPIO.output(AIN2, GPIO.HIGH)
# brake B
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.HIGH)
# disable driver chip
❸ GPIO.output(STBY, GPIO.LOW)
为了停止电机旋转,你首先停止发送到PWMA ❶和PWMB ❷引脚的 PWM 信号。然后,你将AIN1、AIN2、BIN1和BIN2引脚都设置为HIGH,这会起到“刹车”的作用,迫使每个电机停下来。最后,你通过将STBY引脚设置为LOW ❸来禁用电机驱动器。待机模式可以在电机不需要运转时节省电力。
你还需要一个辅助函数,用于设置两个电机的速度和方向。你将使用此函数,根据实时音频分析来调整电机。
def set_motor_speed_dir(dca, dcb, dira, dirb):
"""set speed and direction of motors"""
# set duty cycle
❶ pwm_a.ChangeDutyCycle(dca)
pwm_b.ChangeDutyCycle(dcb)
# set direction A
❷ if dira:
GPIO.output(AIN1, GPIO.HIGH)
GPIO.output(AIN2, GPIO.LOW)
❸ else:
GPIO.output(AIN1, GPIO.LOW)
GPIO.output(AIN2, GPIO.HIGH)
if dirb:
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.LOW)
else:
GPIO.output(BIN1, GPIO.LOW)
GPIO.output(BIN2, GPIO.HIGH)
set_motor_speed_dir()函数接受四个参数:dca和dcb确定每个电机的占空比,而dira和dirb是布尔值,决定电机的旋转方向。你使用ChangeDutyCycle()方法来更新电机的占空比(速度),将传入函数的值 ❶。然后,你调整电机的旋转方向。如果dira为True ❷,你将AIN1和AIN2引脚设置为HIGH和LOW,使电机 A 朝一个方向旋转。但是,如果dira为False ❸,你会将引脚设置为相反方向,使电机朝另一个方向旋转。你对电机 B 也执行相同的操作,使用dirb参数。
处理音频
该项目的核心是process_audio()函数,它从 WAV 文件读取音频数据,通过pyaudio输出音频流,计算 FFT 来分析音频数据,并利用结果来控制电机。我们将分部分来看这个函数。
def process_audio(filename):
print("opening {}...".format(filename))
# open WAV file
❶ wf = wave.open(filename, 'rb')
# print audio details
❷ print("SW = {}, NCh = {}, SR = {}".format(wf.getsampwidth(),
wf.getnchannels(), wf.getframerate()))
# check for supported format
❸ if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
print("Only single channel 16 bit WAV files are supported!")
wf.close()
return
# create PyAudio object
❹ p = pyaudio.PyAudio()
# open an output stream
❺ stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True)
# read first frame
你从使用 wave 模块打开传递给 process_audio() 函数的音频文件开始❶。wave.open() 函数返回一个 Wave_read 对象,你将用它来读取 WAV 文件中的数据。你打印出关于读取到的 WAV 文件的一些信息❷:SW 是样本宽度(单位为字节),NCh 是音频的声道数,SR 是采样率。为了简化项目,你只支持单声道、16 位的 WAV 文件作为输入。你检查这些规格❸,如果输入不符合要求,就从函数中返回。
接下来,你创建一个 PyAudio 对象,用来将数据从 WAV 文件流式传输到输出❹。然后你打开一个 pyaudio 输出流(如 output=True 参数所示),并将其配置为与 WAV 文件具有相同的样本宽度、声道数和采样率❺。对于树莓派,默认的音频输出是板上的 3.5 毫米音频插孔。只要你的扬声器插入该插孔,你就能听到音频输出。
这是函数的下一部分:
❶ data = wf.readframes(CHUNK)
❷ buf = np.frombuffer(data, dtype=np.int16)
# store sample rate
❸ SR = wf.getframerate()
# start motors
start_motors()
# laser on
laser_on(True)
这里你从 WAV 文件中读取 CHUNK 个样本到变量 data 中❶。记住,你已将 CHUNK 设置为 2048,每个样本是 2 字节宽,因此你将读取 2,048 个 16 位值。由于函数主循环的结构,你一次只读取一个数据块,稍后你会看到具体实现。
readframes() 方法返回一个 bytes 对象,但你使用 numpy 库的 frombuffer() 函数将 bytes 对象转换为一个名为 buf 的 16 位整数的 numpy 数组❷。你将采样率(wave 模块称之为 帧率)存储在变量 SR 中❸;稍后你会用到它。然后你调用 start_motors() 和 laser_on() 函数,这两个函数我们之前已经讨论过,用来启动电动机和激光模块。
接下来,你进入函数的主循环,该循环输出音频并执行 FFT。循环一次处理一个音频数据块,这就是为什么在前面的代码中你只读取一个数据块的原因。注意,循环是在一个 try 块中进行的。稍后,你会写一个 except 块来处理在循环执行过程中出现的任何问题。
# read audio data from WAV file
try:
# loop till there is no data to be read
❶ while len(data) > 0:
# write stream to output
❷ stream.write(data)
# ensure enough samples for FFT
❸ if len(buf) == N:
❹ buf = np.frombuffer(data, dtype=np.int16)
# do FFT
❺ fft = np.fft.rfft(buf)
❻ fft = np.abs(fft) * 2.0/N
# calc levels
# get average of 3 frequency bands
# 0-100 Hz, 100-1000 Hz, and 1000-2500 Hz
❼ levels = [np.sum(fft[0:100])/100,
np.sum(fft[100:1000])/900,
np.sum(fft[1000:2500])/1500]
主循环会一直重复,直到 data 为空❶,这意味着你已经读取到 WAV 文件的末尾。在循环中,你将当前的数据块写入 pyaudio 输出流❷。这将使你在处理 WAV 文件的同时,能够听到它的声音,同时驱动电动机。然后你检查当前数据块是否有 N 个样本用于计算 FFT❸(你在代码开始时将 N 设置为 2048,与数据块大小相同)。这个检查是必要的,因为最后一个读取的数据块可能没有足够的样本来进行 FFT。在这种情况下,你会跳过 FFT 的计算和电动机的更新,因为音频文件基本上已经结束了。
接下来,你将音频数据加载到一个numpy的 16 位整数数组中❹。在这种格式下,计算 FFT 非常简单:你只需要使用numpy.fft模块中的rfft()方法❺。这个方法接受由实数(如音频数据)组成的信号,并计算 FFT,结果通常是一个复数的集合。然而,你希望继续使用实数,所以你使用abs()方法获取这些复数的幅度,它们是实数❻。2.0/N是一个归一化因子,用于将 FFT 值映射到预期的范围。
继续循环,你从 FFT 中提取相关信息来控制电机。为了分析音频信号,你将频率范围分为三个频段:0 到 100 Hz(低音)、100 到 1,000 Hz(中音)和 1,000 到 2,500 Hz(高音)。你特别关注低音和中音频段,它们大致对应于歌曲中的节奏和人声部分。你使用numpy.``sum()方法计算每个频段中频率的*均幅度值,并将结果除以该频段中的频率数量❼。你将这三个*均值存储在 Python 列表中。
注意,在while循环中你正在做两件不同的事:将音频发送到输出并计算相同音频的 FFT。你能做到这一点并保持音频输出的连续性,因为numpy的 FFT 计算速度足够快——它会在当前音频数据播放完之前就计算完成。尝试一个实验:在 FFT 之后加入延时,看看音频输出会发生什么!
现在,你需要将 FFT 的*均幅度转换为电机的速度和方向,仍然在之前列出的while循环中进行。速度需要是百分比,而方向则是True/False值。
# speed1
❶ dca = int(5*levels[0]) percent 60
# speed2
❷ dcb = int(100 + levels[1]) percent 60
# dir
dira = False
dirb = True
❸ if levels[2] > 0.1:
dira = True
# set motor direction and speed
❹ set_motor_speed_dir(dca, dcb, dira, dirb)
首先,你取最低频段的值,将其乘以5,转换为整数,然后使用取模运算符(percent)确保该值位于[0, 60]范围内❶。这个值控制电机 A 的速度。然后,你将中频段的值加上100,再使用取模运算符将其放置在[0, 60]范围内❷。这个值控制电机 B 的速度。
注意:一开始不要让电机转速过快,这就是为什么这段代码将电机的速度限制在 60% 的原因。一旦你确认显示屏正常工作,就可以尝试提高在❶和❷处的速度阈值。
默认情况下,你将dira设置为False以使电机 A 朝一个方向旋转,但如果来自最高频段的值超过0.1的阈值,则切换到另一个方向❸。与此同时,通过将dirb设置为True,你保持电机 B 的方向不变。最后,你调用set_motor_speed_dir()函数以按照你计算的速度和方向运行电机❹。
注意:没有特别优雅的规则来转换 FFT 信息以获取电机速度和方向。FFT 值随音频信号不断变化,因此您提出的任何方法都将根据音乐改变激光图案。我通过试验得出了这里描述的方法;我在播放各种类型的音乐时查看了 FFT 值,并选择了产生多种漂亮图案的计算方法。我鼓励您尝试使用这些计算并创建您自己的转换。在这里没有错误的答案,只要您的方法将电机速度设置在 [0, 100] 范围内(或更低以避免高速度),并设置方向为 True 或 False。
这里是 process_audio() 函数的剩余部分:
# read next
❶ data = wf.readframes(CHUNK)
❷ except BaseException as err:
print("Unexpected {}, type={}".format(err, type(err)))
❸ finally:
print("Finally: Pyaudio clean up...")
stream.stop_stream()
stream.close()
# stop motors
stop_motors()
首先,通过读取下一个要处理的音频数据块来结束 while 循环 ❶。请记住,while 循环位于 try 块内。except 块 ❷ 捕获循环期间可能出现的任何异常。例如,在程序运行时按下 CTRL-C 将引发异常并停止循环,读取数据时发生任何错误也会停止循环。最后,在 finally 块 ❸ 中进行一些清理工作,无论是否抛出异常都会执行。在此块中,您停止 pyaudio 输出流并关闭它,并调用 stop_motors() 函数停止电机旋转。
测试电机
出于测试目的,手动设置电机的速度和方向并查看生成的激光图案非常有用。这是一个 test_motors() 函数,可以实现这一点:
def test_motors():
"""test motors by manually setting speed and direction"""
# turn laser on
❶ laser_on(True)
# start motors
❷ start_motors()
# read user input
try:
while True:
❸ print("Enter dca dcb dira dirb (eg. 50 100 1 0):")
# read input
str_in = input()
# parse values
❹ vals = [int(val) for val in str_in.split()]
# sanity check
if len(vals) == 4:
❺ set_motor_speed_dir(vals[0], vals[1], vals[2], vals[3])
else:
print("Input error!")
except:
print("Exiting motor test!")
❻ finally:
# stop motors
stop_motors()
# turn laser off
laser_on(False)
您首先打开激光 ❶ 并启动电机 ❷。然后,您进入一个循环以从用户获取信息。该循环提示用户输入四个整数值 ❸:dca 和 dcb 是电机的占空比(速度)(从 0 到 100),dira 和 dirb 是电机方向(0 或 1)。等待输入,然后解析它,使用 split() 根据空格分割输入字符串,并使用列表推导将每个子字符串转换为整数 ❹。经过一些检查以确保确实得到了四个输入数字后,使用提供的值运行电机 ❺。
这个函数运行在一个循环中,所以您可以尝试输入各种速度和方向值来查看结果。由于 while 循环位于 try 块内,按下 CTRL-C 将引发异常,并在您准备好时退出测试。然后,在 finally 块 ❻ 中,您停止电机并关闭激光。
整合一切
通常,main() 函数接受命令行参数并启动项目。首先让我们看看命令行参数:
def main():
"""main calling function"""
# set up args parser
❶ parser = argparse.ArgumentParser(description="A laser audio display.")
# add arguments
parser.add_argument('--test_laser', action='store_true', required=False)
parser.add_argument('--test_motors', action='store_true', required=False)
parser.add_argument('--wav_file', dest='wav_file', required=False)
args = `parser`.`parse_args`()
在这里,您遵循了创建ArgumentParser对象以解析程序命令行参数的常见模式 ❶。程序将支持三种不同的命令行参数。--test_laser选项仅打开激光,当您在组装电机和激光器时非常有用。--test_motors选项用于测试电机,--wav_file选项让您指定要读取的激光音频显示的 WAV 文件。
下面是main()函数的其余部分:
# initialize pins
❶ init_pins()
# main loop
try:
❷ if args.test_laser:
print("laser on...")
laser_on(True)
try:
# wait in a loop
while True:
time.sleep(0.1)
except:
# turn laser off
laser_on(False)
❸ elif args.test_motors:
print("testing motors...")
test_motors()
❹ elif args.wav_file:
print("starting laser audio display...")
process_audio(args.wav_file)
except (Exception) as e:
print("Exception: {}".format(e))
print("Exiting.")
# turn laser off
❺ laser_on(False)
# call at the end
❻ GPIO.cleanup()
print("Done.")
您调用之前定义的init_pins()函数来初始化 Raspberry Pi 的 GPIO 引脚 ❶。接下来,您会处理命令行参数。如果用户输入了--test_laser参数,则args.test_laser会被设置为True。通过打开激光并等待用户按 CTRL-C 终止循环来处理这个情况 ❷。类似地,通过调用test_motors()来处理--test_motors选项 ❸。要启动激光音频显示,用户需要使用--wav_file命令行参数。在这种情况下 ❹,您调用process_audio()函数。同样,所有这些都嵌套在try块中,这样当用户按 CTRL-C 时,您可以在三种模式中的任何一种中跳出循环。最后,您关闭激光 ❺,并在退出程序前进行 GPIO 清理 ❻。
运行激光显示
要测试该项目,请组装硬件,确保电池组已连接,并将所有设备摆放到位,使激光投射到*面表面(如墙壁)上。然后,使用 SSH 登录到您的 Raspberry Pi,按照附录 B 中的讨论,通过终端运行程序。我建议首先通过以测试模式运行程序来测试激光显示部分。
警告:该项目包含高速旋转的镜面。运行程序前,请佩戴适当的眼部保护装备,或用透明盒子覆盖设备,以避免受伤。
这是测试模式的示例运行:
$ `python laser_audio.py --test_motors`
testing motors...
Enter dca dcb dira dirb (eg. 50 100 1 0):
`30 40 0 1`
Enter dca dcb dira dirb (eg. 50 100 1 0):
`40 30 1 0`
您可以使用此测试通过不同的速度和方向组合来运行两个电机。当您更改值时,应该能看到不同的激光图案投射到墙上。要停止程序和电机,请按 CTRL-C。注意,如果您输入大于 80 的占空比(速度)值,电机会旋转得非常快,请小心!
如果测试成功,您就可以开始进行正式的展示了。将您最喜欢的音乐 WAV 文件复制到 Raspberry Pi 中。请记住,为了保持简单,程序只接受 16 位格式的单声道 WAV 文件。您可以使用免费的 Audacity 软件将任何音频文件转换为这种格式。(项目的 GitHub 仓库中也有一个示例文件。)当音频文件准备好后,按如下方式运行程序,在--wave_file选项后替换为您想要的文件名:
`python3 laser_audio.py --wav_file bensound-allthat-16.wav`
你应该能看到激光显示产生许多有趣的图案,并随着音乐的变化而变化,如图 13-14 所示。

图 13-14:激光显示的完整接线和投影到墙上的图案
尝试使用不同的 WAV 文件,或者使用不同的计算方法将 FFT 信息转换为电机设置,看看可视化效果如何变化。
总结
在本章中,你通过构建一个相当复杂的项目提高了你的 Python 和硬件技能。你学会了如何使用 Python、Raspberry Pi 和电机驱动器控制电机。你使用numpy计算音频数据的 FFT,并使用pyaudio实时流式传输音频输出。你甚至学会了如何使用 MOSFET 控制激光!
实验!
这里有一些修改这个项目的方法:
-
- 程序使用了一种任意方案将 FFT 值转换为电机速度和方向数据。尝试更改这个方案。例如,尝试不同的频带和改变电机方向的标准。
-
- 在这个项目中,你将从音频信号中收集的频率信息转换为电机速度和方向设置。尝试让电机根据音乐的整体“脉冲”或音量来移动。为此,你可以计算信号幅度的均方根(RMS)值。这个计算类似于 FFT 计算。读取一块音频数据并将其放入
numpy数组x后,你可以按如下方式计算 RMS 值:
rms = np.sqrt(np.mean(x**2))此外,请记住,你项目中的幅度是以 16 位有符号整数表示的,最大值为 32,768(这是一个有用的归一化参考值)。使用这个 RMS 幅度值与 FFT 一起生成更多变化的激光图案。
- 在这个项目中,你将从音频信号中收集的频率信息转换为电机速度和方向设置。尝试让电机根据音乐的整体“脉冲”或音量来移动。为此,你可以计算信号幅度的均方根(RMS)值。这个计算类似于 FFT 计算。读取一块音频数据并将其放入
-
- 你现在知道,频率内容,因此音频数据的 FFT,会随着音频的同步变化。你能否创建一个实时可视化,如图 13-15 所示,同时展示音频数据和 FFT,并在音频通过扬声器播放时实时显示?这应该在你的计算机上运行,而不是在 Raspberry Pi 上。
![]()
图 13-15:实时 FFT 可视化
以下是解决这个问题的一些提示:
-
◦ 使用
matplotlib进行绘图。 -
◦ 使用 Python 的
multiprocessing包,以便你的音乐流式输出和绘图可以同时进行。 -
◦ 使用
numpy.fft.``rfftfreq()方法获取对应 FFT 值的频率,便于绘图。
(这个实验的解决代码可以在书本的 GitHub 仓库中找到,但先自己试试看!)
完整代码
这是该项目的完整 Python 代码:
"""
laser_audio.py
Creates a laser display that changes in time to music.
Uses Python on a Raspberry Pi.
Author: Mahesh Venkitachalam
"""
import RPi.GPIO as GPIO
import time
import argparse
import pyaudio
import wave
import numpy as np
# define pin numbers
# uses TB6612FNG motor driver pin naming
PWMA = 12
PWMB = 13
AIN1 = 7
AIN2 = 8
BIN1 = 5
BIN2 = 6
STBY = 22
LASER = 25
# global PWM objects
pwm_a = None
pwm_b = None
# size of audio data read in
CHUNK = 2048
# FFT size
N = CHUNK
def init_pins():
"""set up pins"""
global pwm_a, pwm_b
# use BCM pin numbering
GPIO.setmode(GPIO.BCM)
# put pins into a list
pins = [PWMA, PWMB, AIN1, AIN2, BIN1, BIN2, STBY, LASER]
# set up pins as outputs
GPIO.setup(pins, GPIO.OUT)
# set PWM
pwm_a = GPIO.PWM(PWMA, 100)
pwm_b = GPIO.PWM(PWMB, 100)
def laser_on(on):
"""turn laser MOSFET on/off"""
# pin 25 controls laser ctrl mosfet
GPIO.output(LASER, on)
def test_motors():
"""test motors by manually setting speed and direction"""
# turn laser on
laser_on(True)
# start motors
start_motors()
# read user input
try:
while True:
print("Enter dca dcb dira dirb (eg. 50 100 1 0):")
# read input
str_in = input()
# parse values
vals = [int(val) for val in str_in.split()]
# sanity check
if len(vals) == 4:
set_motor_speed_dir(vals[0], vals[1], vals[2], vals[3])
else:
print("Input error!")
except:
print("Exiting motor test!")
finally:
# stop motors
stop_motors()
# turn laser off
laser_on(False)
def start_motors():
"""start both motors"""
# enable driver chip
GPIO.output(STBY, GPIO.HIGH)
# set motor direction for channel A
GPIO.output(AIN1, GPIO.HIGH)
GPIO.output(AIN2, GPIO.LOW)
# set motor direction for channel B
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.LOW)
# set PWM for channel A
duty_cycle = 0
pwm_a.start(duty_cycle)
# set PWM for channel B
pwm_b.start(duty_cycle)
def stop_motors():
"""stop both motors"""
# stop PWM
pwm_a.stop()
pwm_b.stop()
# brake A
GPIO.output(AIN1, GPIO.HIGH)
GPIO.output(AIN2, GPIO.HIGH)
# brake B
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.HIGH)
# disable driver chip
GPIO.output(STBY, GPIO.LOW)
def set_motor_speed_dir(dca, dcb, dira, dirb):
"""set speed and direction of motors"""
# set duty cycle
pwm_a.ChangeDutyCycle(dca)
pwm_b.ChangeDutyCycle(dcb)
# set direction A
if dira:
GPIO.output(AIN1, GPIO.HIGH)
GPIO.output(AIN2, GPIO.LOW)
else:
GPIO.output(AIN1, GPIO.LOW)
GPIO.output(AIN2, GPIO.HIGH)
if dirb:
GPIO.output(BIN1, GPIO.HIGH)
GPIO.output(BIN2, GPIO.LOW)
else:
GPIO.output(BIN1, GPIO.LOW)
GPIO.output(BIN2, GPIO.HIGH)
def process_audio(filename):
"""reads WAV file, does FFT and controls motors"""
print("opening {}...".format(filename))
# open WAV file
wf = wave.open(filename, 'rb')
# print audio details
print("SW = {}, NCh = {}, SR = {}".format(wf.getsampwidth(),
wf.getnchannels(), wf.getframerate()))
# check for supported format
if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
print("Only single channel 16 bit WAV files are supported!")
wf.close()
return
# create PyAudio object
p = pyaudio.PyAudio()
# open an output stream
stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True)
# read first frame
data = wf.readframes(CHUNK)
buf = np.frombuffer(data, dtype=np.int16)
# store sample rate
SR = wf.getframerate()
# start motors
start_motors()
# laser on
laser_on(True)
# read audio data from WAV file
try:
# loop till there is no data to be read
while len(data) > 0:
# write stream to output
stream.write(data)
# ensure enough samples for FFT
if len(buf) == N:
buf = np.frombuffer(data, dtype=np.int16)
# do FFT
fft = np.fft.rfft(buf)
fft = np.abs(fft) * 2.0/N
# calc levels
# get average of 3 frequency bands
# 0-100 Hz, 100-1000 Hz, and 1000-2500 Hz
levels = [np.sum(fft[0:100])/100,
np.sum(fft[100:1000])/900,
np.sum(fft[1000:2500])/1500]
# speed1
dca = int(5*levels[0]) percent 60
# speed2
dcb = int(100 + levels[1]) percent 60
# dir
dira = False
dirb = True
if levels[2] > 0.1:
dira = True
# set motor direction and speed
set_motor_speed_dir(dca, dcb, dira, dirb)
# read next
data = wf.readframes(CHUNK)
except BaseException as err:
print("Unexpected {}, type={}".format(err, type(err)))
finally:
print("Finally: Pyaudio clean up...")
stream.stop_stream()
stream.close()
# stop motors
stop_motors()
# close WAV file
wf.close()
def main():
"""main calling function"""
# set up args parser
parser = argparse.ArgumentParser(description="A laser audio display.")
# add arguments
parser.add_argument('--test_laser', action='store_true', required=False)
parser.add_argument('--test_motors', action='store_true', required=False)
parser.add_argument('--wav_file', dest='wav_file', required=False)
args = parser.parse_args()
# initialize pins
init_pins()
# main loop
try:
if args.test_laser:
print("laser on...")
laser_on(True)
try:
# wait in a loop
while True:
time.sleep(1)
except:
# turn laser off
laser_on(False)
elif args.test_motors:
print("testing motors...")
test_motors()
elif args.wav_file:
print("starting laser audio display...")
process_audio(args.wav_file)
except (Exception) as e:
print("Exception: {}".format(e))
print("Exiting.")
# turn laser off
laser_on(False)
# call at the end
GPIO.cleanup()
print("Done.")
# call main
if __name__ == '__main__':
main()
第十四章:# 物联网花园

我们生活在一个手机与灯泡对话、牙刷也想连接互联网的时代。这一切通过物联网(IoT)成为可能,物联网是由嵌入传感器的日常设备组成的网络,这些设备彼此和互联网进行通信,通常是无线方式。在本章中,你将构建自己的物联网传感器网络来监控花园中的温湿度状况。这个网络将由一个或多个低功耗设备组成,这些设备运行 Python 代码并将实时传感器数据通过无线方式传输到树莓派。树莓派将记录数据并通过本地 Web 服务器提供数据。你将能够通过 Web 浏览器查看传感器数据,并且在出现极端情况时,通过移动设备实时接收警报。
通过这个项目,你将学习到一些概念:
-
• 组建低功耗物联网传感器网络
-
• 理解低功耗蓝牙(BLE)协议的基础
-
• 在树莓派上构建 BLE 扫描器
-
• 使用 SQLite 数据库存储传感器数据
-
• 在树莓派上使用
Bottle运行 Web 服务器 -
• 使用 IFTTT 向你的手机发送警报
工作原理
本项目中使用的物联网(IoT)设备是 Adafruit BLE Sense 开发板,它们内置了温度和湿度传感器。设备会定期进行测量,并通过低功耗蓝牙(BLE)无线传输传感器数据,我们将在接下来讨论。数据通过运行 BLE 扫描器的树莓派(Raspberry Pi)接收。树莓派使用数据库存储和检索数据,并且运行一个 Web 服务器,使其能够在网页上显示数据。此外,树莓派还具有检测温湿度数据异常的逻辑,并在发生异常时通过 IFTTT 服务向用户的移动设备发送警报。图 14-1 总结了项目的架构。

图 14-1:物联网花园系统架构
你可能会想,为什么一定需要树莓派呢?为什么传感器设备不能直接与互联网通信,而是通过树莓派进行中转?答案在于功耗。如果设备通过像 Wi-Fi 这样的协议直接与互联网连接,它们通常会消耗比使用 BLE 高出 10 倍以上的电量。这非常重要,因为物联网设备通常由电池供电,我们期望它们能够持续很长时间。图 14-1 中展示的这种架构,其中低功耗无线设备通过网关(在此为树莓派)进行通信,是物联网世界中常见的架构。
本项目架构的另一个特点是你的数据始终掌握在自己手中——准确地说,是存储在树莓派上的数据库中。你不会将数据上传到互联网,而是将其局限于本地网络。这对于基础的园艺数据来说可能不那么关键,但依然值得知道,物联网设备并不总是需要将所有数据通过互联网发送出去才能发挥作用。隐私和安全是两个重要原因,确保数据只有在真正必要时才暴露到互联网。在本项目中,你依然会稍微利用互联网,发送 IFTTT 警报。
蓝牙低功耗
BLE 是与蓝牙耳机和扬声器相同无线技术标准的子集,但它针对低功耗、使用电池的设备进行了优化。例如,BLE 就是你的智能手机与智能手表或健身追踪器之间通信的方式。通过 BLE 通信的设备可以分为中央设备和外围设备。通常,中央设备是功能更强的硬件,如笔记本电脑和手机,而外围设备则是功能较弱的硬件,如健身带和信标。在这个项目中,树莓派是中央设备,Adafruit BLE Sense 板是外围设备。
注意:中央设备和外围设备之间的区别并非总是那么明确。现代的 BLE 芯片允许同一硬件既能作为中央设备,也能作为外围设备,甚至两者兼有。
一个 BLE 外围设备通过广告数据包向外界表明其存在,如图 14-2 所示。这些数据包通常每隔几毫秒就会发送一次,包含的信息有外围设备的名称、传输功率、制造商数据、是否可以与中央设备连接等。中央设备持续扫描广告数据包,并可以利用包中的信息与外围设备建立通信。

图 14-2:BLE 广告方案
广告数据包的大小限制为仅 31 字节,以节省外围设备的电池,但外围设备可以选择通过单独的传输发送额外的信息包,这个过程被称为扫描响应。外围设备会在其正常广告数据包中指示是否有扫描响应可用。如果中央设备需要额外的数据,它会发送扫描响应请求,这会促使外围设备暂停发送广告数据包,改为发送扫描响应。不过,对于这个项目,你只需要传感器提供的非常少量的数据,因此你可以直接将温湿度数据放入广告数据包中。
在树莓派侧,你将使用 BlueZ(Linux 上的官方蓝牙协议栈)构建一个 BLE 扫描器。具体来说,你将使用以下三个命令行程序:
hciconfig 在程序初始化时重置树莓派上的 BLE
hcitool 扫描 BLE 外设
hcidump 读取来自 BLE 外设的广告数据
hciconfig和hcitool是树莓派操作系统安装的一部分,但你需要通过终端安装hcidump,如下所示:
$ `sudo apt-get install bluez-hcidump`
以下是树莓派上使用这些工具的典型命令行会话。首先,在终端中运行hcidump,准备在扫描开始时输出数据包:
pi@iotsensors:~ $ `sudo hcidump --raw`
HCI sniffer - Bluetooth packet analyzer ver 5.50
device: hci0 snap_len: 1500 filter: 0xffffffff
这告诉你hcidump正在等待 BLE 输入。接下来,在另一个终端中运行hcitool的lescan命令,开始扫描 BLE 设备:
pi@iotsensors:~ $ `sudo hcitool lescan`
LE Scan...
DE:74:03:D9:3D:8B (unknown)
DE:74:03:D9:3D:8B IOTG1
36:D2:35:5A:BF:B0 (unknown)
8C:79:F5:8C:AE:DA (unknown)
5D:9F:EC:A0:09:51 (unknown)
5D:9F:EC:A0:09:51 (unknown)
60:80:0A:83:18:40 (unknown)
--`snip`--
这表示扫描器已经检测到大量 BLE 设备(现在它们无处不在!)。当你运行lescan命令时,hcidump开始输出广告包数据,因此你的hcidump终端现在应该充满了如下信息:
< 01 0B 20 07 01 10 00 10 00 00 00
> 04 0E 04 01 0B 20 00
< 01 0C 20 02 01 01
> 04 0E 04 01 0C 20 00
> 04 3E 1B 02 01 02 01 8B 3D D9 03 74 DE 0F 0E FF 22 08 0A 31
FE 49 4F 54 47 31 1B 36 30 CB
> 04 3E 16 02 01 04 01 8B 3D D9 03 74 DE 0A 02 0A 00 06 09 49
4F 54 47 31 CB
> 04 3E 23 02 01 03 01 03 58 0A 00 6A 35 17 16 FF 06 00 01 09
21 0A 13 71 DA 7D 1A 00 52 6F 63 69 6E 61 6E 74 65 C5
> 04 3E 1F 02 01 03 01 B9 D4 AE 7E 01 0E 13 12 FF 06 00 01 09
21 0A 9E 54 20 C5 51 48 6D 61 6E 64 6F BE
由于你使用--raw选项启动了hcidump,所以消息作为十六进制字节输出(而不是人类可读的文本)。
这个示例展示了如何在命令行中手动使用 BlueZ 工具。对于这个项目,你将从树莓派上运行的 Python 代码中执行这些命令。Python 代码还会读取广告包并获取传感器数据。
Bottle Web 框架
要通过网页接口监控传感器数据,你需要让树莓派运行一个 web 服务器。为此,你将使用Bottle,一个具有简单界面的 Python web 框架。(实际上,整个库由一个名为bottle.py的源文件组成。)以下是使用Bottle从树莓派提供一个简单网页所需的代码:
from bottle import route, run
❶ @route('/hello')
def hello():
❷ return "Hello Bottle World!"
❸ run(host='`iotgarden.local`', port=`8080`, debug=True)
这段代码首先定义了一个 URL 或路径(在本例中是/hello),客户端可以在该路径上发送数据请求 ❶,并使用Bottle中的route()方法作为 Python 装饰器绑定到hello()函数,后者将作为该路径的处理函数。这样,当用户访问该路径时,Bottle将调用hello()函数,返回一个字符串 ❷。run()方法 ❸ 启动Bottle服务器,现在可以接受客户端的连接。这里我们假设服务器在名为iotgarden的树莓派的 8080 端口上运行。注意,debug标志已设置为True,以便更容易诊断问题。
在你的 Wi-Fi 连接的树莓派上运行这段代码,在任何连接到本地网络的计算机上打开浏览器,并访问http://Bottle应该会为你提供一个显示“Hello Bottle World!”的网页。只需几行代码,你就创建了一个 web 服务器。
请注意,在你的项目中,你将稍微不同地使用Bottle路由函数,因为你将把路由绑定到类方法,而不是像之前的hello()那样绑定到自由函数。稍后会详细讲解这一点。
SQLite 数据库
你需要一个地方来存储传感器数据,以便将来可以检索它。你可以将数据写入文本文件,但检索过程会很快变得繁琐。相反,你将使用 SQLite 来存储数据,SQLite 是一个轻量级、易于使用的数据库,非常适合像树莓派这样的嵌入式系统。为了在 Python 中访问 SQLite,你需要使用sqlite3库。
SQLite 数据库是通过 SQL 来操作的,SQL 是数据库系统的标准语言。这些 SQL 语句在你的 Python 代码中作为字符串编写。然而,对于这个项目,你不需要成为 SQL 专家。你只需要掌握几个基本命令,我们会在它们出现时逐一讲解。为了让你更好地了解它的工作方式,下面我们看一个简单的例子,展示如何在 Python 解释器会话中使用 SQLite。首先,下面是如何创建数据库并向其中添加一些条目的代码:
import sqlite3
con = sqlite3.``connect('test.db')❶
cur = con.``cursor()❷
cur.``execute("``CREATE TABLE sensor_data (``TS datetime,ID text,VAL numeric)")❸
for i in range(10):
... cur.execute("INSERT into sensor_data VALUES (datetime('now'),'ABC', ?)", (i, )) ❹
con.``commit()❺
con.``close()
exit()
在这里,你使用sqlite3.connect()方法并传入数据库的名称(在这个例子中是test.db)❶。该方法要么返回一个已存在数据库的连接,要么如果数据库不存在则创建一个新的数据库。然后,你使用连接对象创建一个cursor❷。这是一种构造,它允许你与数据库进行交互,创建表格、插入新条目和检索数据。你通过游标执行一个 SQL 语句,创建一个名为sensor_data的数据库表,其中包含以下列:TS(时间戳),类型为datetime;ID,类型为text;VAL,类型为numeric❸。接下来,你通过在for循环中执行 SQL INSERT语句,向这个数据库添加 10 个条目。每个语句都添加当前时间戳、字符串'ABC'和循环索引i❹。?是 SQLite 使用的格式占位符,实际值通过元组来指定。最后,你将更改提交到数据库,以使其永久生效❺,然后关闭数据库连接。
现在让我们从数据库中检索一些值:
>>> `con = sqlite3.``connect('test.db')`
>>> `cur = con.``cursor()`
❶ >>> `cur.``execute("``SELECT * FROM sensor_data WHERE VAL > 5")`
>>> `print(cur.``fetchall())`
[('2021-10-16 13:01:22', 'ABC', 6), ('2021-10-16 13:01:22', 'ABC', 7),
('2021-10-16 13:01:22', 'ABC', 8), ('2021-10-16 13:01:22', 'ABC', 9)]
再次,你需要连接数据库并创建一个游标以与其交互。然后执行一个 SELECT SQL 查询来检索一些数据 ❶。在这个查询中,你请求从 sensor_data 表中选择所有行(SELECT *),并且 VAL 列中的值大于 5。你通过游标的 fetchall() 方法打印查询结果。
要求
在 Raspberry Pi 上,你需要使用 bottle 模块来创建一个 Web 服务器,使用 sqlite3 模块来操作 SQLite 数据库,并使用 matplotlib 来绘制传感器数据。由于 BLE Sense 开发板的计算能力不足以运行完整的 Python 版本,因此你将使用 CircuitPython 编程,这是一种由 Adafruit 维护的开源 MicroPython 衍生版本。我们在这个项目中使用 CircuitPython,而不是你在 第十二章 中看到的 MicroPython,因为前者对 Adafruit 制作的设备有更多的库支持。
你还需要以下硬件来完成此项目:
-
• 一个或多个 Adafruit Feather Bluefruit nRF52840 Sense 开发板,根据需求选择
-
• 一块 Raspberry Pi 3B+(或更新版)开发板,带有 SD 卡和电源适配器
-
• 每个 BLE Sense 开发板需要一个 3.7 V LiPo 电池或 USB 电源
图 14-3 显示了所需的硬件。

图 14-3:项目所需的硬件
你可以将 Raspberry Pi 安装在室内,靠*你的花园,并将你的 BLE Sense 开发板与适当的电源和保护外壳放置在花园中。
Raspberry Pi 设置
要开始此项目,你需要设置你的 Raspberry Pi。请按照 附录 B 中的说明操作。接下来的项目代码假设你已将 Pi 命名为 iotgarden,这使得你可以通过网络访问它,地址为 iotgarden.local。
CircuitPython 设置
要安装 CircuitPython,请按照以下步骤操作,为你的每个 Adafruit BLE Sense 开发板安装:
-
1. 访问
circuitpython.org/downloads,搜索你的 Bluefruit Sense 开发板,并下载该开发板的 CircuitPython 安装文件,该文件具有 .uf2 扩展名。请注意你正在下载的 CircuitPython 版本号。 -
2. 将 Adafruit 开发板连接到计算机的 USB 端口,并双击开发板上的重置按钮。开发板上的 LED 应该会变绿,并且你应该会看到一个新的驱动器出现在你的计算机上,名为 FTHRSNSBOOT。
-
3. 将 .uf2 文件拖动到 FTHRSNSBOOT 驱动器中。一旦文件复制完成,开发板上的 LED 会闪烁,计算机上将会出现一个名为 CIRCUITPY 的新驱动器。
接下来,你需要在开发板上安装所需的 Adafruit 库。操作步骤如下:
-
1. 访问
circuitpython.org/libraries并下载与你的 CircuitPython 版本对应的.zip文件,其中包含库的捆绑包。 -
2. 解压下载的文件,并将以下文件/文件夹复制到 CIRCUITPY 驱动器中的一个名为lib的文件夹内。(如果lib文件夹不存在,请创建它。)
-
◦ adafruit_apds9960
-
◦ adafruit_ble
-
◦ adafruit_bme280
-
◦ adafruit_bmp280.mpy
-
◦ adafruit_bus_device
-
◦ adafruit_lis3mdl.mpy
-
◦ adafruit_lsm6ds
-
◦ adafruit_register
-
◦ adafruit_sht31d.mpy
-
◦ neopixel.mpy
-
-
3. 按下板上的重置按钮,项目就可以开始使用了。
默认情况下,CircuitPython 会运行 CIRCUITPY 驱动器中任何名为code.py的文件中的代码。你需要将以下章节中讨论的ble_sensors.py文件复制到驱动器,并将其重命名为code.py,才能运行该项目。
If This Then That 设置
IFTTT 是一个 Web 服务,可以让你为特定的操作创建自动响应。你将使用 IFTTT 在传感器检测到温湿度异常时向你的手机发送警报。按照以下步骤设置以接收 IFTTT 警报:
-
1. 访问 IFTTT 网站(
ifttt.com)并注册一个账户。 -
2. 下载并设置 IFTTT 应用程序到你的智能手机。
-
3. 在浏览器中登录到你的 IFTTT 账户后,点击创建。然后点击“If This”框中的添加按钮。
-
4. 在弹出的“选择服务”页面上,搜索并选择Webhooks。然后选择接收带 JSON 负载的 Web 请求。
-
5. 在事件名称下,输入TH_alert(注意大小写),然后按创建触发器。
-
6. 现在你应该已经回到创建页面。点击“Then That”框中的添加按钮。
-
7. 搜索并选择通知。然后点击从 IFTTT 应用发送通知。
-
8. 在弹出的页面中,将文本“T/H Alert!”添加到消息框中。然后点击添加成分并选择OccuredAt。再点击添加成分并选择JsonPayload。
-
9. 点击创建动作按钮返回到创建页面。然后点击继续和完成以最终确认警报。
你需要 IFTTT 密钥才能通过 Python 代码触发警报。要查找它,请按照以下步骤操作:
-
1. 在 IFTTT 网站上,点击屏幕右上角的圆形账户图标并选择我的服务。
-
2. 点击Webhooks链接,然后点击文档按钮。
-
3. 页面加载时,顶部会显示你的密钥。记下这个密钥。你还会在此页面上找到如何将测试警报发送到你的智能手机的信息。如果进行测试,请确保填入TH_alert作为事件名称。
代码
该项目的代码分布在以下 Python 文件中:
ble_sensors.py 运行在 Adafruit BLE Sense 板上的 CircuitPython 代码。它从温湿度传感器读取数据,并将数据放入 BLE 广告数据包中。
BLEScanner.py 实现了在 Raspberry Pi 上运行的 BLE 扫描器,使用 BlueZ 工具读取广告数据。此代码还会发送 IFTTT 警报。
server.py 实现了在 Raspberry Pi 上运行的Bottle Web 服务器。
iotgarden.py 主程序文件。此代码设置 SQLite 数据库,并启动 BLE 扫描器和 Web 服务器。
除了 Python 文件,项目中还有一个名为static的子文件夹,里面包含一些由Bottle Web 服务器使用的额外文件:
static/style.css 服务器返回的 HTML 页面的样式表。
static/server.js 由服务器返回的 JavaScript 代码,用于获取传感器数据。
项目的完整代码可以在 github.com/mkvenkit/pp2e/tree/main/iotgarden 上找到。
CircuitPython 代码
运行在 BLE Sense 板上的 CircuitPython 代码有一个直接的目的:它从内置的温湿度传感器读取数据,并将这些数据放入板上的 BLE 广告数据包中。这个看似简单的任务需要导入意想不到数量的模块。要查看完整的代码列表,请跳到 “完整的 CircuitPython 代码”,该内容位于 第 343 页。代码也可以在 github.com/mkvenkit/pp2e/blob/main/iotgarden/ble_sensors/ble_sensors.py 找到。
import time, struct
import board
import adafruit_bmp280
import adafruit_sht31d
from adafruit_ble import BLERadio
❶ from adafruit_ble.advertising import Advertisement, LazyObjectField
❷ from adafruit_ble.advertising.standard import ManufacturerData,
ManufacturerDataField
import _bleio
import neopixel
你导入了 Python 的内建模块time和struct,分别用于睡眠和打包数据。board模块让你能够访问I2C库,允许板上的 BLE 芯片通过 I²C 协议(读作“eye-squared-C”)与传感器进行通信。adafruit_bmp280和adafruit_sht31d模块用于与传感器通信,而BLERadio类用于启用 BLE 并发送广告数据包。在❶和❷位置的导入语句使你能够访问 Adafruit BLE 广告模块,这些模块对于创建你自己定制的、包含传感器数据的广告数据包是必要的。此外,你还将使用_bleio模块获取设备的 MAC 地址,并使用neopixel控制板上的 LED。
py`#### Preparing BLE Packets Next, define a class called `IOTGAdvertisement` to help create the BLE advertisement packets. The `adafruit_ble` library already has an `Advertisement` class that handles BLE advertisements. You create `IOTGAdvertisement` as a subclass of `Advertisement` to use the parent class’s features while adding your own customization: 类 IOTGAdvertisement(Advertisement): flags = None ❶ match_prefixes = ( struct.pack( "<BHBH", # 前缀格式 0xFF, # 0xFF 是根据 BLE 规范定义的 "厂商特定数据" 0x0822, # 2 字节公司 ID struct.calcsize("<H9s"), # 数据格式 0xabcd # 我们的 ID ), # 需要逗号 - 期望元组 ) ❷ manufacturer_data = LazyObjectField( ManufacturerData, "manufacturer_data", advertising_data_type=0xFF, # 0xFF 是根据 BLE 规范定义的 "厂商特定数据" # 根据 BLE 规范 company_id=0x0822, # 2 字节公司 ID key_encoding="<H", ) # 设置厂商数据字段 ❸ md_field = ManufacturerDataField(0xabcd, "<9s") py The BLE standard is very particular, so this code may look intricate, but all it’s really doing is putting some custom data in the advertisement packet. First you fill a tuple called `match_prefixes` ❶, which the `adafruit_ble` library will use to manage various fields in the advertisement packet. The tuple has only one element, a packed structure of bytes that you create using the Python `struct` module. Next, you define the `manufacturer_data` field ❷, which will use the format described at ❶. The manufacturer data field is a standard part of a BLE advertisement packet that has some space for whatever custom data the manufacturer (or the user) wants to include. Finally, you create a custom `ManufacturerDataField` object ❸, which you’ll keep updating as sensor values change. #### Reading and Sending Data The `main()` function of the CircuitPython program reads and sends the sensor data. The function begins with some initializations: def main(): # 初始化 I2C ❶ i2c = board.I2C() # 初始化传感器 ❷ bmp280 = adafruit_bmp280.Adafruit_BMP280_I2C(i2c) ❸ sht31d = adafruit_sht31d.SHT31D(i2c) # 初始化 BLE ❹ ble = BLERadio() # 创建自定义广告对象 ❺ advertisement = IOTGAdvertisement() # 将 MAC 地址的前 2 个十六进制字节(4 个字符)附加到名称中 ❻ addr_bytes = _bleio.adapter.address.address_bytes name = "{0:02x}{1:02x}".format(addr_bytes[5], addr_bytes[4]).upper() # 设置设备名称 ❼ ble.name = "IG" + name py First you initialize the `I2C` module ❶ so the BLE chip can communicate with the sensors. Then you initialize the modules for the temperature (`bmp280`) ❷ and humidity (`sht31d`) ❸ sensors. You also initialize the BLE radio ❹, which is required for transmitting the advertisement packets, and create an instance of your custom `IOTGAdvertisement` class ❺. Next, you set the name of the BLE device to the string `IG` (for IoT Garden) followed by the first four hexadecimal digits (or two bytes) of the device’s MAC address ❼. For example, if the MAC address of the device is `de:74:03:d9:3d:8b`, the name of the device will be set to `IGDE74`. To do this, you first get the MAC address as bytes ❻. The bytes are in reverse order of the string representation, however—in our example MAC address, for instance, the first byte would be `0x8b`. What you’re looking for are the first two bytes, `0xde` and `0x74`, which are at indices `5` and `4` in `address_bytes`, respectively. You use string formatting to convert these bytes to string representation and convert them to uppercase using `upper()`. Now let’s look at the rest of the initialization: # 设置初始值 # 仅使用名称的前 5 个字符 ❶ advertisement.md_field = ble.name[:5] + "0000" # BLE 广播间隔,单位秒 BLE_ADV_INT = 0.2 # 启动 BLE 广播 ❷ ble.start_advertising(advertisement, interval=BLE_ADV_INT) # 设置 NeoPixels 并关闭它们 pixels = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.1, auto_write=False) py Here you set an initial value for your custom manufacturer data ❶. For this, you concatenate the first five characters of the device name followed by four bytes of zeros. You’ll update the first two bytes with the sensor data. As for the remaining two bytes, there’s an exercise waiting for you in “Experiments!” on page 343 where you can put them to use. Next, you set the BLE advertisement interval (`BLE_ADV_INT`) to `0.2`, meaning the device will send out an advertisement packet every 0.2 seconds. Then you call the method to start sending advertisement packets ❷, passing your custom advertisement class and the time interval as arguments. You also initialize the `neopixel` library to control the LED on the board. The `board.NEOPIXEL` argument sets the pin number for the neopixel LED, and `1` represents the number of LEDs on the board. The brightness setting is `0.1` (the maximum being `1.0`), and setting the `auto_write` flag to `False` means you’ll need to call the `show()` method explicitly for the values to take. (You’ll see this in action soon.) The `main()` function continues with a loop that reads the sensor data and updates the BLE packets: # 主循环 while True: # 打印值 - 这将通过串行接口显示 ❶ print("Temperature: {:.1f} C".format(bmp280.temperature)) print("Humidity: {:.1f} %".format(sht31d.relative_humidity)) # 获取传感器数据 ❷ T = int(bmp280.temperature)+ 40 H = int(sht31d.relative_humidity) # 停止广播 ❸ ble.stop_advertising() # 更新广告数据 ❹ advertisement.md_field = ble.name[:5] + chr(T) + chr(H) + "00" # 启动广播 ❺ ble.start_advertising(advertisement, interval=BLE_ADV_INT) # 闪烁 NeoPixel LED pixels.fill((255, 255, 0)) pixels.show() time.sleep(0.1) pixels.fill((0, 0, 0)) pixels.show() # 睡眠 2 秒 ❻ time.sleep(2) py You begin the loop by printing out the values read by the sensors ❶. You can use this output to confirm that your sensors are putting out reasonable values. To see the values, connect the board via USB to your computer and use a serial terminal application such as CoolTerm, as explained in Chapter 12. The output should look something like this: 温度: 26.7 C 湿度: 55.6 % py Next, you read the temperature and humidity values from the sensors ❷, converting both to integers since you have only one byte to represent each value. You add 40 to the temperature to accommodate negative values. The BMP280 temperature sensor on the board has a range of −40°C to 85°C, so adding 40 converts this range to [0, 125]. You’ll be back to the correct range on the Raspberry Pi once the data is parsed from the BLE advertisement data. You have to stop BLE advertising ❸ so you can change the data in the packets. Then you set the manufacturer data field with the first five characters of the device name, followed by one byte each of temperature and humidity values ❹. You’re using `chr()` here to encode each 1-byte value into a character. You also set the last two bytes in the data field to be zeros. Now that you’ve updated the sensor values in the packet, you restart advertising ❺. This way, the scanner on the Pi will pick up the new sensor values from the BLE board. To provide a visual indicator that the device is alive, you blink its neopixel LED by turning it on for 0.1 second. The `fill()` method sets a color using an (R, G, B) tuple, and `show()` sets the value to the LED. Finally, you add a two-second delay before restarting the loop and checking the sensors again ❻. During that delay, the board will continue sending out the same advertisement packet every 0.2 seconds, as per its advertisement interval. NOTE When you’ve tested the code and are ready to deploy your IoT device, I recommend commenting out the `print()` statements and the neopixel code to conserve power. Remember, BLE is all about low energy!```` py### The BLE Scanner Code The code to have your Raspberry Pi listen for and process sensor data over BLE is encapsulated in a class called BLEScanner. To see the complete code listing, skip ahead to “The Complete BLE Scanner Code” on page 345. You’ll also find this code in the book’s GitHub repository at [github.com/mkvenkit/pp2e/blob/main/iotgarden/BLEScanner.py](https://github.com/mkvenkit/pp2e/blob/main/iotgarden/BLEScanner.py). Here’s the constructor for this class: ``` 类 BLEScanner: def __init__(self, dbname): """BLEScanner 构造函数""" self.T = 0 self.H = 0 # 最大值 self.TMAX = 30 self.HMIN = 20 # 上次警报的时间戳 ❶ self.last_alert = time.time() # 警报间隔,单位秒 ❷ self.ALERT_INT = 60 # 扫描间隔,单位秒 ❸ self.SCAN_INT = 10 ❹ self._dbname = dbname ❺ self.hcitool = None self.hcidump = None ❻ self.task = None # ----------------------------------------------- # 外围设备允许列表 - 在此添加您的设备! # ----------------------------------------------- ❼ self.allowlist = ["DE:74:03:D9:3D:8B"] ```py You start by defining instance variables TandHto keep track of the latest temperature and humidity values read from the sensors. Then you set threshold values for triggering the automated alerts. If the temperature goes aboveTMAXor the humidity goes belowHMIN, the program will issue an alert using IFTTT. You create the last_alertvariable at ❶ to store the time when the most recent alert was sent, and at ❷ you set the minimum interval between alerts. This is so you don’t keep sending yourself alerts continuously when an alert condition is met. At ❸, you set the scan interval in seconds to control how often the Pi will scan for BLE devices. Next, you save the name of the SQLite database that was passed into the constructor ❹. You need this to save values from the sensors. At ❺ and the following line, you create a couple of instance variables to store process IDs for thehcitoolandhcidumpprograms, which will run later. At ❻, you create ataskinstance variable. Later, you’ll be creating a separate thread to run this task, which will be doing the scanning, while the main thread of the program runs the web server. Finally, at ❼ you create a list of the BLE devices you want to listen to. When you run the BLE scanner, you’re likely to pick up many BLE peripherals, not just the Adafruit Sense boards. Keeping a list with your sensor device IDs makes the BLE data easier to parse. Later in the chapter, I’ll show you how to look up the device IDs so you can add them here to your allowlist. #### Working with the BlueZ Tools As we discussed in “Bluetooth Low Energy” on page 313, this project uses BlueZ, Linux’s official Bluetooth protocol stack, to scan for BLE data. TheBLEScannerclass needs methods for working with these tools. First we’ll examine thestart_scan()method, which sets up the BlueZ tools for BLE scanning. ``` def start_scan(self): """启动扫描所需的 BlueZ 工具""" print("BLE 扫描启动...") # 重置设备 ❶ ret = subprocess.run(['sudo', '-n', 'hciconfig', 'hci0', 'reset'], stdout=subprocess.DEVNULL) print(ret) # 启动 hcitool 进程 ❷ self.hcitool = subprocess.Popen(['sudo', '-n', 'hcitool', 'lescan', '--duplicates'], stdout=subprocess.DEVNULL) # 启动 hcidump 进程 ❸ self.hcidump = subprocess.Popen(['sudo', '-n', 'hcidump', '--raw'], stdout=subprocess.PIPE) ```py First you reset the BLE device of the Raspberry Pi using thehciconfigtool ❶. You use the Pythonsubprocessmodule to run this process. Thesubprocess.run()method takes the process arguments as a list, so this call executes the commandsudo -n hciconfig hci0 reset. The output of this process stdoutis set toDEVNULL, which just means you don’t care about messages printed out by this command. (The -nflag insudomakes it noninteractive.) You next use a differentsubprocessmethod calledPopen()to run the commandsudo -n hcitool lescan --duplicates❷. This process scans for BLE peripheral devices. The--duplicatesflag ensures that the same device can come up in the scan list repeatedly. You need this, since the sensor data in the advertisement packets keeps changing, and you need the latest values. NOTE The difference betweensubprocess.run()andsubprocess.Popen()is that the former waits for the process to complete, whereas the latter returns immediately while the process runs in the background. Finally, you usesubprocess.Popen()to run another command:sudo -n hcidump --raw❸. As we discussed earlier in the chapter, this command intercepts and prints out the advertisement data as hexadecimal bytes. Notice thatstdoutis set tosubprocess.PIPEin this case. This means you can read the output from this process similar to how you read the contents of a file. More on this in “Parsing the Data” below. Now let’s look at thestop_scan()method, which kills the processes begun in thestart_scan()method when you’re ready to stop scanning for BLE packets. ``` def stop_scan(self): """通过结束 BlueZ 工具进程来停止 BLE 扫描""" subprocess.run(['sudo', 'kill', str(self.hcidump.pid), '-s', 'SIGINT']) subprocess.run(['sudo', '-n', 'kill', str(self.hcitool.pid), '-s', "SIGINT"]) print("BLE 扫描停止.") ```py Here you kill off thehcidumpandhcitoolprocesses usingpid, their process IDs. The command sudo -n kill pid -s SIGINTkills a process with the givenpidby sending it theSIGINTinterrupt signal. #### Parsing the Data The scanner needs methods for parsing the BLE data it receives. First we’ll consider theparse_hcidump()method, which parses the output from thehcidumpprocess: ``` def parse_hcidump(self): data = "" (macid, name, T, H) = (None, None, None, None) while True: ❶ line = self.hcidump.stdout.readline() ❷ line = line.decode() if line.startswith('> '): data = line[2:] elif line.startswith('< '): data = "" else: if data: ❸ data += line ❹ data = " ".join(data.split()) ❺ fields = self.parse_data(data) success = False ❻ try: macid = fields["macid"] T = fields["T"] H = fields["H"] name = fields["name"] success = True except KeyError: # 跳过此错误,因为它表示 # 数据无效 ❼ pass if success: ❽ return (macid, name, T, H) ```py Within awhileloop, you start reading one line at a time fromself.hcidump.stdoutusing thereadline()method ❶, much like you’d read lines from a file. To understand the code that follows, it helps to know a bit about the data being parsed. This is what a typical output fromhcidumplooks like: ``` > 04 3E 1B 02 01 02 01 8B 3D D9 03 74 DE 0F 0E FF 22 08 0A 31 FE 49 4F 54 47 31 1B 36 30 CB ```py The output is split across two lines, and it starts with a *>* character. You want to take these two lines and combine them to get a single string such as"04 3E 1B 02 01 02 01 8B 3D D9 03 74 DE 0F 0E FF 22 08 0A 31 FE 49 4F 54 47 31 1B 36 30 CB". To do this, you convert the bytes output from readline()to a string using thedecode()method ❷. Then you use some logic to build up the final string that you want. If the line starts with>, you know it’s the first of the set of two lines making up an hcidumpentry, so you store the line and go on to the next one. Lines that start with<are ignored. If a line starts with neither>nor<, then it’s the second line of the advertisement, and you join the lines together ❸. The resulting data will have newline characters in the middle and the end. You get rid of those using a combination of the split()andjoin()string methods ❹. This example illustrates how the scheme works: ``` >>>x = "ab cd\n ef\tff\r\n">>>x.split()['ab', 'cd', 'ef', 'ff'] >>>" ".join(x.split())'ab cd ef ff' ```py Notice from the first line of output how thesplit()method automatically splits up the string at the whitespace characters, producing a list of substrings and removing the whitespace characters in the process. This gets rid of the unwanted newline characters, but it also gets rid of the spaces, which you want to keep. That’s where thejoin()method comes in. It merges the list items back into a single string, with a space between each substring, as you can see in the second output line. Returning to theparse_hcidump()method, you now have a complete BLE advertisement packet stored as a string in variabledata. You call the parse_data()method on this string to get the device details in the form of afieldsdictionary ❺. We’ll look at this method soon. Then you retrieve the MAC ID, name, temperature, and humidity values from the dictionary. This code is enclosed in atryblock ❻ in case the data isn’t what you expect. In that case, an exception will be thrown, and you skip that advertisement packet by callingpass❼. If you successfully retrieve all the values, they’re returned as a tuple ❽. Now let’s take a look at theparse_data()method you used inparse_hcidump()to build thefields` dictionary: ``` def parse_data(self, data): fields = {} # 解析 MACID ❶ x = [int(val, 16) for val in data.split()] ❷ macid = ":".join([format(val, '02x').upper() for val in x[7:13][::-1]]) # 检查 MACID 是否在允许列表中 ❸ if macid in self.allowlist: # 查看第 6 字
第十五章:# 树莓派上的音频机器学习

在过去的十年里,机器学习(ML)已经风靡全球。从面部识别到预测文本再到自动驾驶汽车,机器学习无处不在,而且我们似乎每天都在听到有关机器学习新应用的消息。在本章中,你将使用 Python 和 TensorFlow 开发一个基于机器学习的语音识别系统,该系统将运行在廉价的树莓派计算机上。
语音识别系统已经在大量设备和家电中得到应用,形式为语音助手,如 Alexa、Google 和 Siri。这些系统可以执行从设置提醒到在办公室控制家里灯光等任务。但所有这些*台都需要你的设备连接到互联网,并且你需要注册他们的服务。这就引出了隐私、安全性和电力消耗的问题。你的灯泡真的需要连接互联网才能响应语音命令吗?答案是不。通过这个项目,你将了解如何设计一个在低功耗设备上运行的语音识别系统,而不需要设备连接到互联网。
通过这个项目,你将学习到一些概念,包括:
-
• 使用机器学习工作流来解决问题
-
• 使用 TensorFlow 和 Google Colab 创建机器学习模型
-
• 精简一个机器学习模型以便在树莓派上使用
-
• 处理音频并使用短时傅里叶变换(STFT)生成谱图
-
• 利用多进程并行运行任务
机器学习概述
在一本书的单一章节中很难公正地讲解像机器学习这么广泛的话题。因此,我们的做法是将机器学习视为解决问题的另一个工具——在本例中,就是区分不同的语音单词。事实上,像 TensorFlow 这样的机器学习框架已经变得如此成熟和易于使用,以至于即使你不是这个领域的专家,也能够有效地将机器学习应用于实际问题。所以,在本节中,我们将简要介绍与本项目相关的机器学习术语。
机器学习(ML)是更大计算机科学领域——人工智能(AI)的一部分,尽管在大众媒体提到人工智能时,通常指的就是机器学习。机器学习本身由不同的方法和算法组成的多个子学科构成。在这个项目中,你将使用机器学习的一个子集——深度学习,它利用深度神经网络(DNNs)来识别大量数据中的特征和模式。深度神经网络源自于人工神经网络(ANNs),后者大致模仿我们大脑中的神经元。人工神经网络由多个节点构成,每个节点有多个输入。每个节点还有一个与之相关的权重。人工神经网络的输出通常是输入和权重的非线性函数。这个输出可以连接到另一个人工神经网络的输入。当你有多个层的人工神经网络时,网络就变成了深度神经网络。通常,网络的层数越多——即网络越深——学习模型的准确性也就越高。
在这个项目中,你将使用监督学习过程,该过程可以分为两个阶段。第一个阶段是训练阶段,你需要向模型展示若干输入及其期望的输出。例如,如果你正在构建一个人类存在检测系统来识别视频帧中是否有人,你将在训练阶段展示这两种情况的示例(有人的情况与没有人的情况),每个示例都要正确标注。接下来是推理阶段,在这个阶段,你会展示新的输入,模型根据在训练过程中学到的内容对其进行预测。继续上述示例,你将向人类存在检测系统展示新的视频帧,模型会预测每一帧中是否有一个人。(还有无监督学习过程,在该过程中,机器学习系统通过未标记的数据尝试自行发现模式。)
一个机器学习模型有许多数值参数,这些参数帮助模型处理数据。在训练过程中,这些参数会自动调整,以最小化期望值和模型预测值之间的误差。通常,使用一种叫做梯度下降的算法来最小化误差。除了机器学习模型在训练过程中调整的参数外,还有一些超参数,这些变量会调整整个模型的设置,比如使用哪种神经网络架构或训练批次的大小。图 15-1 展示了我为这个项目选择的神经网络架构。

图 15-1:语音识别项目的神经网络架构
网络架构中的每一层代表对数据的一种处理形式,旨在帮助提高模型的准确性。网络设计并非简单任务,仅仅定义每一层并不能告诉我们它是如何工作的。一个更广泛的问题是,为什么我选择了这个特定的网络。答案是,通过实验需要确定适合当前项目的最佳网络架构。通常的做法是尝试不同的神经网络架构,并观察哪一种在训练后能产生最准确的结果。也有一些由机器学习研究者发布的架构,已知它们表现良好,这为实际应用提供了一个很好的起点。
注:有关机器学习的更多信息,我强烈推荐 Andrew Glassner(《深度学习:一种视觉方法》,No Starch Press,2021 年)所著的书籍。该书将帮助你对该主题有更好的直观理解,而不会过多涉及数学或代码。对于全面且实践性强的学习方式,我还推荐 Andrew Ng 教授在 Coursera 上的在线机器学习课程。
工作原理
在这个项目中,你将使用谷歌的 TensorFlow 机器学习框架,使用包含语音命令的音频文件集训练神经网络。然后,你将把训练好的模型的优化版本加载到配有麦克风的树莓派上,以便树莓派在你发出命令时能够识别它们。图 15-2 展示了该项目的框图。

图 15-2:语音识别项目的框图
在项目的训练部分,你将在 Google Colab(即 Colaboratory)中进行操作,这是一个免费的云端服务,允许你在网页浏览器中编写和运行 Python 程序。使用 Colab 有两个优点。首先,你不需要在本地计算机上安装 TensorFlow,也不用处理与不同版本 TensorFlow 相关的不兼容问题。其次,Colab 运行的机器通常比你的计算机更强大,因此训练过程将更快。作为训练数据,你将使用来自 Google 的 Mini Speech Commands 数据集,这是 2018 年发布的更大 Speech Commands 数据集的一个子集。它包含成千上万条样本录音,内容包括yes(是)、no(否)、up(向上)、down(向下)、left(向左)、right(向右)、stop(停止)和go(开始),所有录音均标准化为 16 位 WAV 文件,采样率为 16,000 Hz。你将生成每个录音的声谱图,这是一种图像,显示音频的频率内容如何随时间变化,并利用这些声谱图通过 TensorFlow 训练一个深度神经网络(DNN)。
注意:本项目的训练部分灵感来自谷歌官方的 TensorFlow 示例“简单音频识别”。你将使用与该示例相同的神经网络架构。然而,本项目的其余部分与谷歌的示例有很大不同,因为我们的目标是在树莓派上识别实时音频,而后者是在现有的 WAV 文件上进行推理。
一旦训练完成,你将把训练好的模型转换为一个简化格式,称为 TensorFlow Lite,该格式旨在运行在硬件性能较低的设备上,如嵌入式系统,并将该精简模型加载到树莓派上。在树莓派上,你将运行 Python 代码,持续监控 USB 麦克风的音频输入,获取音频的频谱图,并对这些数据进行推理,识别出训练集中的语音命令。你将把模型识别出的命令打印到串口监视器上。
频谱图
本项目中的一个关键步骤是生成音频数据的频谱图——包括用于训练模型的预先存在的数据和在推理过程中遇到的实时数据。在第四章中,你已经看到如何通过频谱图揭示音频样本在特定时刻的频率。接着,在第十三章中,你学习了如何使用一种数学工具,离散傅里叶变换(DFT),来计算频谱图。频谱图本质上就是一系列通过傅里叶变换器生成的频谱图,这些频谱图共同展示了某些音频数据的频率内容如何随时间变化。
你需要每个音频样本的频谱图,而不是单一的频谱图,因为人类语音的声音非常复杂。即使是一个单词,其声音中的频率也会发生显著变化,而且这种变化具有独特的方式。当这个词被发音时,频率会随着时间的推移而变化。对于本项目,你将处理每个持续一秒钟的音频片段,每个片段由 16,000 个样本组成。如果你一次性计算整个片段的单一离散傅里叶变换(DFT),你无法准确显示频率如何随时间变化,因此也无法可靠地识别正在说的词。相反,你将把音频片段分成一段段重叠的时间窗口,并计算每个时间窗口的 DFT,从而得到频谱图所需的一系列频谱图。图 15-3 展示了这种计算方法,称为短时傅里叶变换(STFT)。

图 15-3:计算信号的频谱图
STFT 为你提供了音频的M个离散傅里叶变换(DFT),它们在均匀的时间间隔内取样。时间沿着频谱图的 x 轴显示。每个 DFT 给出了N个频率桶,以及每个桶内的声音强度。频率桶被映射到频谱图的 y 轴。因此,频谱图呈现出一个M×N的图像。图像中的每一列像素代表一个 DFT,颜色则用来表示给定频率带中的信号强度。
你可能会想,为什么这个项目需要使用傅里叶变换呢?为什么不直接使用音频片段的波形,而是从这些波形中提取频率信息呢?要回答这个问题,可以参考图 15-4。

图 15-4:语音样本的波形和频谱图
图的上半部分显示了一个录音的波形,该录音为“左、右、左、右”序列。图的下半部分显示了该录音的频谱图。仅从波形来看,可以看到两个左听起来大致相似,两个右也类似,但很难从每个词的波形中提取出明显的识别特征。相反,频谱图揭示了与每个词相关的更多视觉特征,比如每个右实例中的明亮 C 形曲线(由箭头所示)。我们可以用自己的眼睛更清楚地看到这些独特的特征,神经网络同样也能“看到”它们。
最终,由于频谱图本质上是图像,获取数据的频谱图将语音识别问题转化为图像分类问题,使我们能够利用已有的丰富的机器学习技术来进行图像分类。(当然,波形也可以作为图像处理,但正如你所看到的,频谱图在捕捉音频数据的“特征”方面更为出色,因此更适合用于机器学习应用。)
树莓派上的推理
树莓派上的代码必须完成几个任务:它需要读取来自附加麦克风的音频输入,计算该音频的频谱图,并使用训练好的 TensorFlow Lite 模型进行推理。以下是一个可能的操作顺序:
-
- 读取麦克风数据,持续一秒钟。
-
- 处理数据。
-
- 进行推理。
-
- 重复。
然而,这种方法有一个大问题。你在忙于执行步骤 2 和 3 时,可能会错过更多的语音数据。解决方法是使用 Python 的多进程功能并行执行不同任务。你的主进程只负责收集音频数据并将其放入队列。在另一个独立且同时进行的进程中,你会从队列中取出这些数据并对其进行推理。以下是新的方案:
主进程
-
- 读取麦克风数据,持续一秒钟。
-
- 将数据放入队列。
推理进程
-
1. 检查队列中是否有数据。
-
2. 对数据进行推理。
现在主进程不会错过任何音频输入,因为将数据放入队列是一个非常快速的操作。但另一个问题也随之而来。你正在从麦克风连续收集一秒钟的音频样本并进行处理,但你不能假设所有的语音命令都能完美地适应这些一秒钟的时间间隔。命令可能出现在边缘,并被分割到两个连续的样本中,在这种情况下,它可能在推理过程中无法识别。一个更好的方法是创建重叠样本,如下所示:
主进程
-
1. 对于第一个帧,收集一个两秒钟的样本。
-
2. 将两秒钟的样本放入队列。
-
3. 收集另一个一秒钟的样本。
-
4. 通过将步骤 2 中的样本后半部分移到前面,并用步骤 3 中的样本替换后半部分来创建一个两秒钟的样本。
-
5. 返回步骤 2。
推理过程
-
1. 检查队列中是否有数据。
-
2. 基于峰值幅度对两秒数据中的一秒部分进行推理。
-
3. 返回步骤 1。
在这个新方案中,每个放入队列的样本都是两秒钟长的,但连续样本之间有一秒钟的重叠,如图 15-5 所示。这样,即使一个词在一个样本中被部分截断,你也可以在下一个样本中获得完整的词。你仍然只会对一秒钟的片段进行推理,并且会以两秒钟样本中幅度最大的位置为中心来处理这些片段。这是最有可能包含语音单词的部分。你需要将片段长度保持为一秒钟,以保持与训练数据的一致性。

图 15-5:两帧重叠方案
通过这种多进程和重叠样本的结合,你将设计一个语音识别系统,最大限度地减少丢失的输入并提高推理结果。
要求
对于这个项目,你需要在 Google Colab 上注册,以便训练你的机器学习模型。在树莓派上,你需要以下 Python 模块:
-
•
tflite_runtime用于运行 TensorFlow 推理 -
•
scipy用于计算音频波形的 STFT -
•
numpy数组用于处理音频数据 -
•
pyaudio用于从麦克风输入流式传输音频数据
这些模块的安装请参见附录 B。你还将使用 Python 内置的multiprocessing模块来在与音频处理线程分开的线程中运行机器学习推理。
在硬件方面,你将需要以下设备:
-
• 一台树莓派 3B+或更新版本
-
• 一个 5V 电源供应给树莓派
-
• 一张 16GB 的 SD 卡
-
• 一只与树莓派兼容的单通道 USB 麦克风
各种类型的 USB 麦克风都可以与 Raspberry Pi 兼容。图 15-6 展示了一个例子。

图 15-6:Raspberry Pi 的 USB 麦克风
要检查你的 Pi 是否能识别 USB 麦克风,可以通过 SSH 连接到 Pi 并运行以下命令:
$ `dmesg -w`
现在,将你的麦克风插入 Pi 的 USB 端口。你应该会看到类似以下的输出:
[26965.023138] usb 1-1.3: 新的 USB 设备已找到,idVendor=cafe,idProduct=4010,bcdDevice= 1.00
[26965.023163] usb 1-1.3: 新的 USB 设备字符串:Mfr=1,Product=2,SerialNumber=3
[26965.023179] usb 1-1.3: 产品:Mico
[26965.023194] usb 1-1.3: 厂商:Electronut Labs
[26965.023209] usb 1-1.3: 序列号:123456
信息应该与麦克风的规格匹配,表明它已被正确识别。
代码
这个项目的代码分为两个部分:训练部分,你将在 Google Colab 中运行;推理部分,你将在 Raspberry Pi 上运行。我们将逐一检查这两个部分。
在 Google Colab 中训练模型
在本节中,我们将查看在 Google Colab 中训练语音识别模型所需的代码。我建议在 Chrome 浏览器中使用 Colab。你将从设置环境并下载训练数据集开始。然后,你将运行一些代码来了解数据。你会清理数据以准备训练,并探索如何从数据生成频谱图。最后,你将通过创建和训练模型将所学知识付诸实践。最终结果将是一个 .tflite 文件,这是训练模型的精简版 TensorFlow Lite,可以加载到你的 Raspberry Pi 上。你还可以从本书的 GitHub 仓库 github.com/mkvenkit/pp2e/tree/main/audioml 下载该文件。
Google Colab 笔记本由一系列单元格组成,你可以在其中输入一行或多行 Python 代码。输入所需的代码后,可以通过点击单元格左上角的播放图标来运行它。与该单元格代码相关的任何输出将显示在单元格下方。在本节中,每个代码示例代表一个完整的 Google Colab 单元格。如果有输出,它将以灰色显示在示例的末尾,位于虚线下方。
设置
你开始你的 Colab 笔记本时进行一些初始设置。首先导入所需的 Python 模块:
import os
import pathlib
import matplotlib.pyplot as plt
import numpy as np
import scipy
import scipy.signal
from scipy.io import wavfile
import glob
import tensorflow as tf
from tensorflow.keras.layers.experimental import preprocessing
from tensorflow.keras import layers
from tensorflow.keras import models
from tensorflow.keras import applications
在下一个单元格中,你进行一些初始化操作:
# set seed for random functions
seed = 42
tf.random.set_seed(seed)
np.random.seed(seed)
在这里,你初始化了将用于打乱输入文件名的随机函数。
接下来,下载训练数据:
data_dir = 'data/mini_speech_commands'
data_path = pathlib.Path(data_dir)
filename = 'mini_speech_commands.zip'
url = "http://storage.googleapis.com/download.tensorflow.org/data/mini_speech_commands.zip"
if not data_path.exists():
tf.keras.utils.get_file(filename, origin=url, extract=True, cache_dir='.',
cache_subdir='data')
这个单元格从 Google 下载 Mini Speech Commands 数据集,并将数据提取到一个名为data的目录中。由于你使用的是 Colab,数据将被下载到云端的文件系统中,而不是本地计算机,当你的会话结束时,这些文件将被删除。然而,在会话仍然有效的情况下,你不希望每次运行单元格时都要重新下载数据。tf.keras.utils.get_file()函数会缓存数据,这样你就不需要一直下载它了。
了解数据
在开始训练模型之前,查看一下你刚刚下载的数据,熟悉数据内容会很有帮助。你可以使用 Python 的glob模块,它通过模式匹配帮助你查找文件和目录:
glob.glob(data_dir + '/*')
['data/mini_speech_commands/up',
'data/mini_speech_commands/no',
'data/mini_speech_commands/README.md',
'data/mini_speech_commands/stop',
'data/mini_speech_commands/left',
'data/mini_speech_commands/right',
'data/mini_speech_commands/go',
'data/mini_speech_commands/down',
'data/mini_speech_commands/yes']
You pass `glob` the `'/*'` pattern to list all the first-level directories within the *data* directory (`*` is a wildcard character). The output shows you that the dataset comes with a *README.md* text file and eight subdirectories for the eight speech commands you’ll be training the model to identify. For convenience, you create a list of the commands:
commands = ['up', 'no', 'stop', 'left', 'right', 'go', 'down', 'yes']
In your machine learning model, you’ll be matching audio samples to a `label_id` integer denoting one of the commands. These integers will correspond to the indices from the `commands` list. For example, a `label_id` of `0` indicates `'up'`, and a `label_id` of `6` indicates `'down'`.
Now take a look at what’s in those subdirectories:
❶ wav_file_names = glob.glob(data_dir + '//')
❷ np.random.shuffle(wav_file_names)
print(len(wav_file_names))
for file_name in wav_file_names[:5]:
print(file_name)
8000
data/mini_speech_commands/down/27c30960_nohash_0.wav
data/mini_speech_commands/go/19785c4e_nohash_0.wav
data/mini_speech_commands/yes/d9b50b8b_nohash_0.wav
data/mini_speech_commands/no/f953e1af_nohash_3.wav
data/mini_speech_commands/stop/f632210f_nohash_0.wav
你再次使用glob,这次使用模式'/*/*'来列出子目录中的所有文件 ❶。然后你随机打乱返回的文件名列表,以减少训练数据中的任何偏差 ❷。你打印出找到的文件总数,以及前五个文件名。输出结果表明数据集中有 8000 个 WAV 文件,并且能让你大致了解文件的命名方式——例如,f632210f_nohash_0.wav。
接下来,查看数据集中的一些单独 WAV 文件:
filepath = 'data/mini_speech_commands/stop/f632210f_nohash_1.wav' ❶
rate, data = wavfile.read(filepath) ❷
print("rate = {}, data.shape = {}, data.dtype = {}".format(rate, data.shape, data.dtype))
filepath = 'data/mini_speech_commands/no/f953e1af_nohash_3.wav'
rate, data = wavfile.read(filepath)
print("rate = {}, data.shape = {}, data.dtype = {}".format(rate, data.shape, data.dtype))
rate = 16000, data.shape = (13654,), data.dtype = int16
rate = 16000, data.shape = (16000,), data.dtype = int16
你设置一个 WAV 文件的名称,想要查看该文件❶,然后使用wavefile模块从scipy读取文件中的数据❷。接着,你打印采样率、形状(样本数)和数据类型。你对第二个 WAV 文件做相同的操作。输出结果显示,两个 WAV 文件的采样率都为 16,000,符合预期,并且每个样本都是 16 位整数,这也是预期的。然而,形状表明第一个文件只有 13,654 个样本,这是一个问题。为了训练神经网络,每个 WAV 文件需要有相同的长度;在这种情况下,你希望每个录音文件的长度为一秒钟,即 16,000 个样本。不幸的是,数据集中的并非所有文件都符合这个标准。我们稍后会看看这个问题的解决方案,但首先,试着绘制其中一个 WAV 文件的数据:
filepath = 'data/mini_speech_commands/stop/f632210f_nohash_1.wav'
rate, data = wavfile.read(filepath)
❶ plt.plot(data)

你使用matplotlib创建音频波形图❶。该数据集中的 WAV 文件包含 16 位有符号数据,范围从−32,768 到+32,767。图表的 y 轴显示,这个文件中的数据仅在大约−10,000 到+7,500 之间。图表的 x 轴也强调了数据不足 16,000 个样本——x 轴只显示到 14,000。
数据清理
你已经看到,数据集需要标准化,以便每个片段的长度为一秒钟。这类预处理工作称为数据清理,你可以通过用零填充音频数据,直到其长度达到 16,000 个样本来完成这项工作。你还应该进一步清理数据,进行归一化——将每个样本的值从范围[−32,768, +32,767]映射到[−1, 1]的范围。这种类型的归一化对机器学习至关重要,因为保持输入数据小且一致有助于训练。(对于那些对数学感兴趣的人来说,输入数据中较大的数值会导致梯度下降算法在训练数据时收敛出现问题。)
作为数据清理的一个例子,你将在上一段中查看的 WAV 文件上应用填充和归一化操作。然后,你绘制结果以确认清理工作已经完成。
❶ padded_data = np.zeros((16000,), dtype=np.int16)
❷ padded_data[:data.shape[0]] = data
❸ norm_data = np.array(padded_data/32768.0, dtype=np.float32)
plt.plot(norm_data)

你创建一个长度为 16,000 的 16 位numpy数组,填充为零❶。然后你使用数组切片操作符[:],将过短的 WAV 文件的内容复制到数组的开头❷。这里,data.shape[0]给出了原始 WAV 文件中的样本数,因为data.shape是一个形如(13654,)的元组。现在你得到了一个一秒钟的 WAV 数据,包含了原始的音频数据,后面跟着必要的零填充。接下来,你通过将数组中的值除以 32,768(16 位整数的最大值)来创建数据的归一化版本❸。然后,你绘制数据。
输出的 x 轴显示数据已经被填充,扩展到了 16,000 个样本,其中从 14,000 到 16,000 的值都为零。同时,y 轴显示所有的值都已被归一化,恰好落在(−1, 1)的范围内。
查看频谱图
正如我们讨论过的,你不会在 WAV 文件的原始数据上训练模型。相反,你将生成文件的频谱图,并用它们来训练模型。下面是生成频谱图的一个示例:
filepath = 'data/mini_speech_commands/yes/00f0204f_nohash_0.wav'
rate, data = wavfile.read(filepath)
❶ f, t, spec = scipy.signal.stft(data, fs=16000, nperseg=255,
noverlap = 124, nfft=256)
❷ spec = np.abs(spec)
print("spec: min = {}, max = {}, shape = {}, dtype = {}".format(np.min(spec),
np.max(spec), spec.shape, spec.dtype))
❸ X = t * 129*124
❹ plt.pcolormesh(X, f, spec)
spec: min = 0.0, max = 2089.085693359375, shape = (129, 124), dtype = float32

You pick an arbitrary WAV file from the *yes* subdirectory and extract its data using the `wavfile` module from `scipy`, as before. Then you use the `scipy.signal.stft()` function to compute the spectrogram of the data ❶. In this function call, `fs` is the sampling rate, `nperseg` is the length of each segment, and `noverlap` is the number of overlapping samples between consecutive segments. The `stft()` function returns a tuple comprising three members: `f`, an array of frequencies; `t`, an array of the time intervals mapped to the range [0.0, 1.0]; and `spec`, the STFT itself, a grid of 129×124 complex numbers (these dimensions are given as `shape` in the output). You use `np.abs()` to convert the complex numbers in `spec` into real numbers ❷. Then you print some information about the computed spectrogram. Next, you create an array `X` to hold the sample numbers corresponding to the time intervals ❸. You get these by multiplying `t` by the dimensions of the grid. Finally, you use the `pcolormesh()` method to plot the grid in `spec`, using the values in `X` as the grid’s x-axis and the values in `f` as the y-axis ❹.
The output shows the spectrogram. This 129×124 grid of values (an image), and many more like it, will be the input for the neural network. The bright spots around 1,000 Hz and lower, starting around 4,000 samples in, are where the frequency content is most prominent, while darker areas represent less prominent frequencies.
NOTE Notice that the y-axis in the spectrogram images goes up to only about 8,000 Hz. This is a consequence of the *sampling theorem* in digital signal processing, which states that the maximum frequency that can be accurately measured in a digitally sampled signal is half the sampling rate. In this case, that maximum frequency works out to 16,000/2 = 8,000 Hz.
训练模型
现在你已经准备好将注意力转向训练机器学习模型,这主要意味着要抛弃像 numpy 和 scipy 这样的 Python 库,转而使用像 tf.Tensor 和 tf.data.Dataset 这样的 TensorFlow 方法和数据结构。到目前为止,你一直在使用 numpy 和 scipy,因为它们提供了一个方便的方式来探索语音命令数据集,实际上你仍然可以继续使用它们,但那样你就错过了 TensorFlow 提供的优化机会,TensorFlow 是为大规模机器学习系统设计的。你会发现,TensorFlow 为大多数你到目前为止做的计算提供了几乎相同的函数,因此过渡会非常*滑。就我们的讨论而言,当我提到 张量 时,你可以理解为它类似于 numpy 数组。
为了训练机器学习模型,你需要能够从输入音频文件的文件路径中提取频谱图和标签 ID(即说出的命令)。为此,首先创建一个计算 STFT 的函数:
def stft(x):
❶ f, t, spec = scipy.signal.stft(x.numpy(), fs=16000, nperseg=255,
noverlap=124, nfft=256)
❷ return tf.convert_to_tensor(np.abs(spec))
这个函数接收 x,即从 WAV 文件中提取的数据,并像之前一样使用 scipy 计算其 STFT ❶。然后你将返回的 numpy 数组转换为 tf.Tensor 对象并返回结果 ❷。事实上,有一个类似于 scipy.signal.stft() 方法的 TensorFlow 方法叫做 tf.signal.stft(),那么为什么不使用它呢?答案是,TensorFlow 方法在树莓派上不可用,在那里你将使用精简版的 TensorFlow Lite 解释器。在训练阶段进行的任何预处理,应该与推理阶段的预处理相同,因此你需要确保在 Colab 中使用的函数与在树莓派上使用的函数相同。
现在,你可以在一个辅助函数中使用 stft() 函数,从文件路径中提取频谱图和标签 ID。
def get_spec_label_pair(filepath):
# read WAV file
file_data = tf.io.read_file(filepath)
data, rate = tf.audio.decode_wav(file_data)
data = tf.squeeze(data, axis=-1)
# add zero padding for N < 16000
❶ zero_padding = tf.zeros([16000] - tf.shape(data), dtype=tf.float32)
# combine data with zero padding
❷ padded_data = tf.concat([data, zero_padding], 0)
# compute spectrogram
❸ spec = tf.py_function(func=stft, inp=[padded_data], Tout=tf.float32)
spec.set_shape((129, 124))
spec = tf.expand_dims(spec, -1)
# get label
❹ cmd = tf.strings.split(filepath, os.path.sep)[-2]
❺ label_id = tf.argmax(tf.cast(cmd == commands, "uint32"))
# return tuple
return (spec, label_id)
您首先使用 tf.io.read_file() 读取文件,然后使用 tf.audio.decode_wav() 函数解码 WAV 格式。(后者类似于您之前使用的 scipy.io.wavfile.read() 函数。)接下来,您使用 tf.squeeze() 将 data 张量的形状从 (N, 1) 改变为 (N, ),这是后续函数所需的形状。接着,您创建一个用于对数据进行零填充的张量 ❶。然而,张量是不可变对象,因此您不能像之前处理 numpy 数组那样直接将 WAV 数据复制到全零张量中。相反,您需要创建一个包含所需零数的张量,并将其与数据张量进行连接 ❷。
接下来,您使用 tf.py_function() 调用您之前定义的 stft() 函数 ❸。在此调用中,您还需要指定输入和输出的数据类型。这是从 TensorFlow 调用非 TensorFlow 函数的常见方法。然后,您对 stft() 返回的张量进行一些重塑操作。首先,您使用 set_shape() 将其重塑为 (129, 124),这是因为您要从 TensorFlow 函数转到 Python 函数再返回。然后,您运行 tf.expand_dims(spec, -1) 来添加第三个维度,从 (129, 124) 变为 (129, 124, 1)。神经网络模型需要这额外的维度。最后,您提取与文件路径相关联的标签(例如,'no')并将标签字符串转换为整数 label_id ❺,这是您 commands 列表中字符串的索引。
接下来,您需要准备用于训练的输入文件。回想一下,您有 8,000 个音频文件在子目录中,并且已经随机对它们的文件路径字符串进行了洗牌,并将它们放入名为 wav_file_names 的列表中。现在,您将数据分为三部分:80%(6,400 个文件)用于训练;10%(800 个文件)用于验证;另外 10% 用于测试。这种分割在机器学习中是常见的做法。一旦使用训练数据训练模型,您可以使用验证数据通过更改超参数来调整模型的准确性。测试数据仅用于检查(调整后的)模型的最终准确性。
train_files = wav_file_names[:6400]
val_files = wav_file_names[6400:7200]
test_files = wav_file_names[7200:]
现在,您将文件路径字符串加载到 TensorFlow 的Dataset对象中。这些对象对于使用 TensorFlow 非常关键;它们保存您的输入数据并允许进行数据转换,所有这些操作都可以大规模进行:
train_ds = tf.data.Dataset.from_tensor_slices(train_files)
val_ds = tf.data.Dataset.from_tensor_slices(val_files)
test_ds = tf.data.Dataset.from_tensor_slices(test_files)
看一下您刚刚创建的内容:
对于 train_ds 中的每个值,执行以下操作(取前 5 个):
打印 val。
tf.Tensor(b'data/mini_speech_commands/stop/b4aa9fef_nohash_2.wav', shape=(), dtype=string)
tf.Tensor(b'data/mini_speech_commands/stop/962f27eb_nohash_0.wav', shape=(), dtype=string)
--snip--
tf.Tensor(b'data/mini_speech_commands/left/cf87b736_nohash_1.wav', shape=(), dtype=string)
每个 Dataset 对象包含一组 string 类型的张量,每个张量都保存一个文件路径。然而,你真正需要的是与这些文件路径对应的 (spec, label_id) 对。在这里你创建了这些对:
train_ds = train_ds.map(get_spec_label_pair)
val_ds = val_ds.map(get_spec_label_pair)
test_ds = test_ds.map(get_spec_label_pair)
你使用 map() 函数将 get_spec_label_pair() 应用到每个 Dataset 对象。这种将函数映射到列表的技术在计算中很常见。实际上,你在遍历 Dataset 对象中的每个文件路径时,会调用 get_spec_label_pair(),并将生成的 (spec, label_id) 对保存在一个新的 Dataset 对象中。
现在,你需要通过将数据集拆分成更小的批次来进一步为训练做准备:
batch_size = 64
train_ds = train_ds.batch(batch_size)
val_ds = val_ds.batch(batch_size)
在这里,你将训练集和验证集的批量大小设置为 64。这是加速训练过程的常见技术。如果你尝试一次性处理所有 6400 个训练样本和 800 个验证样本,将会占用大量内存并且导致训练变慢。
现在,你终于准备好创建你的神经网络模型了:
❶ input_shape = (129, 124, 1)
❷ num_labels = 8
norm_layer = preprocessing.Normalization()
❸ norm_layer.adapt(train_ds.map(lambda x, _: x))
❹ model = `models`.Sequential([
layers.Input(shape=input_shape),
preprocessing.Resizing(32, 32),
norm_layer,
layers.Conv2D(32, 3, activation='relu'),
layers.Conv2D(64, 3, activation='relu'),
layers.MaxPooling2D(),
layers.Dropout(0.25),
layers.Flatten(),
layers.Dense(128, activation='relu'),
layers.Dropout(0.5),
layers.Dense(num_labels),
])
❺ model.summary()
Model: "sequential_3"
_____________________________________________________________________
Layer (type) Output Shape Param #
=====================================================================
resizing_3 (Resizing) (None, 32, 32, 1) 0
normalization_3 (Normalization) (None, 32, 32, 1) 3
conv2d_5 (Conv2D) (None, 30, 30, 32) 320
conv2d_6 (Conv2D) (None, 28, 28, 64) 18496
max_pooling2d_3 (MaxPooling2D) (None, 14, 14, 64) 0
dropout_6 (Dropout) (None, 14, 14, 64) 0
flatten_3 (Flatten) (None, 12544) 0
dense_6 (Dense) (None, 128) 1605760
dropout_7 (Dropout) (None, 128) 0
dense_7 (Dense) (None, 8) 1032
=====================================================================
Total params: 1,625,611
Trainable params: 1,625,608
Non-trainable params: 3
You set the shape of the input into the first layer of the model ❶ and then set the number of labels ❷, which will be the number of units in the model’s output layer. Next, you set up a normalization layer for the spectrogram data. This will scale and shift the data to a distribution centered on 1 with a standard deviation of 1\. This is a common practice in ML that improves training. Don’t let the `lambda` scare you ❸. All it’s doing is defining an anonymous function that picks out just the spectrogram from each `(spec, label_id)` pair in the training dataset. The `x, _: x` is just saying to ignore the second element in the pair and return only the first element.
You next create the neural network model, one layer at a time ❹. The layers correspond to the architecture we viewed earlier in Figure 15-1. Finally, you print out a summary of the model ❺, which is shown in the output. The summary tells you all the layers in the model, the shape of the output tensor at each stage, and the number of trainable parameters in each layer.
Now you need to compile the model. The compilation step sets the optimizer, the loss function, and the data collection metrics for the model:
model.compile(
optimizer=tf.keras.optimizers.Adam(),
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=['accuracy'],
)
A *loss function* is a function used to measure how well a neural network is doing by comparing the output of the model to the known correct training data. An *optimizer* is a method used to adjust the trainable parameters in a model to reduce the losses. In this case, you’re using an optimizer of type `Adam` and a loss function of type `SparseCategoricalCrossentropy`. You also get set up to collect some accuracy metrics, which you’ll use to check how the training went.
Next, you train the model:
EPOCHS = 10
history = model.fit(
train_ds,
validation_data=val_ds,
epochs=EPOCHS,
callbacks=tf.keras.callbacks.EarlyStopping(verbose=1, patience=15), ❶
)
Epoch 1/10
100/100 [==============================] - 38s 371ms/step - loss: 1.7219 - accuracy: 0.3700
- val_loss: 1.2672 - val_accuracy: 0.5763
Epoch 2/10
100/100 [==============================] - 37s 368ms/step - loss: 1.1791 - accuracy: 0.5756
- val_loss: 0.9616 - val_accuracy: 0.6650
--snip--
Epoch 10/10
100/100 [==============================] - 39s 388ms/step - loss: 0.3897 - accuracy: 0.8639
- val_loss: 0.4766 - val_accuracy: 0.8450
You train the model by passing the training dataset `train_ds` into `model.fit()`. You also specify the validation dataset `val_ds`, which is used to evaluate how accurate the model is. The training takes place over 10 *epochs*. During each epoch, the complete set of training data is shown to the neural network. The data is randomly shuffled each time, so training across multiple epochs allows the model to learn better. You use the `callback` option ❶ to set up a function to exit the training if it turns out that the training loss is no longer decreasing with each epoch.
Running this Colab cell will take some time. The progress will be shown on the screen as the training is in process. Looking at the output, the `val_accuracy` listed under Epoch 10 shows that the model was about 85 percent accurate at running inference on the validation data by the end of the training. (The `val_accuracy` metric corresponds to the validation data, while `accuracy` corresponds to the training data.)
Now you can try the model by running inference on the testing portion of the data:
test_audio = []
test_labels = []
❶ 对于音频和标签,在 test_ds 中进行迭代:
test_audio.append(audio.numpy())
test_labels.append(label.numpy())
❷ test_audio = np.array(test_audio)
test_labels = np.array(test_labels)
❸ y_pred = np.argmax(model.predict(test_audio), axis=1)
y_true = test_labels
❹ test_acc = sum(y_pred == y_true) / len(y_true)
print(f'测试集准确率: {test_acc:.0%}')
25/25 [==============================] - 1s 35ms/step
测试集准确率:84%
你首先通过遍历测试数据集 test_ds ❶ 填充两个列表,test_audio 和 test_labels。然后,你从这些列表中创建 numpy 数组 ❷,并对数据进行推断 ❸。你通过计算预测结果与真实值匹配的次数,并将其除以总项数,来计算测试精度 ❹。输出显示精度为 84%。虽然不是完美的,但对于这个项目来说已经足够好了。
导出模型到 Pi
恭喜!你已经拥有了一个完全训练好的机器学习模型。现在,你需要将它从 Colab 移到 Raspberry Pi。第一步是保存模型:
model.save('audioml.sav')
这会将模型保存到云端的一个名为 audioml.sav 的文件中。接下来,将该文件转换为 TensorFlow Lite 格式,以便你可以在 Raspberry Pi 上使用它:
❶ converter = tf.lite.TFLiteConverter.from_saved_model('audioml.sav')
❷ tflite_model = converter.convert()
❸ with open('audioml.tflite', 'wb') as f:
f.write(tflite_model)
你创建一个 TFLiteConverter 对象,并传入保存的模型文件名 ❶。然后你进行转换 ❷ 并将简化后的 TensorFlow 模型写入名为 audioml.tflite 的文件中 ❸。现在,你需要将这个 .tflite 文件从 Colab 下载到你的计算机。运行以下代码片段会弹出一个浏览器提示框,要求你保存 .tflite 文件:
from google.colab import files
files.download('audioml.tflite')
一旦你有了文件,就可以使用我们在其他章节中讨论过的 SSH 将其传输到你的 Raspberry Pi。
在 Raspberry Pi 上使用模型
现在我们将关注 Raspberry Pi 部分的代码。这段代码使用并行处理从麦克风获取音频数据,将数据准备好供你训练的 ML 模型使用,并将数据传给模型进行推理。像往常一样,你可以在本地计算机上编写代码,然后通过 SSH 将其传输到 Pi 上。要查看完整代码,请参见“完整代码”(第 389 页)。你也可以从github.com/mkvenkit/pp2e/tree/main/audioml下载代码。
设置
首先,导入所需的模块:
from scipy.io import wavfile
from scipy import signal
import numpy as np
import argparse
import pyaudio
import wave
import time
from tflite_runtime.interpreter import Interpreter
from multiprocessing import Process, Queue
接下来,你初始化一些被定义为全局变量的参数:
VERBOSE_DEBUG = False
CHUNK = 4000
FORMAT = pyaudio.paInt16
CHANNELS = 1
SAMPLE_RATE = 16000
RECORD_SECONDS = 1
NCHUNKS = int((SAMPLE_RATE * RECORD_SECONDS) / CHUNK)
ND = 2 * CHUNK * NCHUNKS
NDH = ND // 2
# device index of microphone
❶ dev_index = -1
VERBOSE_DEBUG是你将在代码中多个地方使用的一个标志。现在,你将其设置为False,但如果通过命令行选项将其设置为True,它将打印出大量的调试信息。
注意:我已经省略了后续代码清单中的print()调试语句。你可以在完整代码清单和本书的 GitHub 存储库中找到它们。
下一个全局变量用于处理音频输入。CHUNK设置了使用PyAudio一次读取的数据样本数,FORMAT指定音频数据将由 16 位整数构成。你将CHANNELS设置为1,因为你将使用单声道麦克风,并将SAMPLE_RATE设置为16000,以与 ML 训练数据保持一致。RECORD_SECONDS表示你将把音频分成一秒钟的片段(如前所述,稍后将拼接成重叠的两秒片段)。你计算每秒录音中的块数为NCHUNKS。你将使用ND和NDH来实现重叠技术——稍后会详细说明。
最后,你初始化麦克风的设备索引号为-1 ❶。一旦你知道麦克风的索引,您需要在命令行中更新此值。以下是一个帮助你找出麦克风索引的函数。你可以将此函数作为命令行选项调用。
def list_devices():
"""list pyaudio devices"""
# initialize pyaudio
❶ p = pyaudio.PyAudio()
# get device list
index = None
❷ nDevices = p.get_device_count()
print('\naudioml.py:\nFound the following input devices:')
❸ for i in range(nDevices):
deviceInfo = p.get_device_info_by_index(i)
if deviceInfo['maxInputChannels'] > 0:
print(deviceInfo['index'], deviceInfo['name'],
deviceInfo['defaultSampleRate'])
# clean up
❹ p.terminate()
你初始化PyAudio ❶并获取它检测到的音频设备数量❷。然后,你遍历这些设备❸。对于每个设备,你通过get_device_info_by_index()获取设备信息,并打印出具有一个或多个输入通道的设备——也就是麦克风。最后,你清理PyAudio ❹。
这是该函数的典型输出:
audioml.py:
Found the following input devices:
1 Mico: USB Audio (hw:3,0) 16000.0
这表示有一个名为 Mico 的输入设备,其默认采样率为 16,000,索引为1。
获取音频数据
Pi 的主要任务之一是不断从麦克风获取音频输入,并将其分割成可以进行推理的片段。为此,你创建了一个get_live_input()函数。该函数需要一个interpreter对象来与 TensorFlow Lite 模型一起工作。以下是函数的开始部分:
def get_live_input(interpreter):
# create a queue object
❶ dataq = Queue()
# start inference process
❷ proc = Process(target = inference_process, args=(dataq, interpreter))
proc.start()
正如我们在“它是如何工作的”中讨论的,你需要使用不同的进程来读取音频数据和执行推理,以避免丢失任何输入。你创建了一个multiprocessing. Queue对象,进程将通过它进行通信❶。然后,你使用multiprocessing. Process()创建推理进程❷。你指定了进程的处理函数名称为inference_process,该函数以dataq和interpreter对象作为参数(稍后我们将查看该函数)。接下来,你启动进程,使推理过程与数据捕获并行运行。
你继续get_live_input()函数,通过初始化PyAudio:
# initialize pyaudio
❶ p = pyaudio.PyAudio()
print('opening stream...')
❷ stream = p.open(format = FORMAT,
channels = CHANNELS,
rate = SAMPLE_RATE,
input = True,
frames_per_buffer = CHUNK,
input_device_index = dev_index)
# discard first 1 second
❸ for i in range(0, NCHUNKS):
data = stream.read(CHUNK, exception_on_overflow = False)
你创建一个PyAudio对象p❶并打开一个音频输入流❷,使用一些全局变量作为参数。然后,你丢弃前一秒钟的数据❸。这是为了忽略在麦克风首次启用时进入的任何无效数据。
现在你准备开始读取数据了:
# count for gathering two frames at a time
❶ count = 0
❷ inference_data = np.zeros((ND,), dtype=np.int16)
print("Listening...")
try:
❸ while True:
chunks = []
❹ for i in range(0, NCHUNKS):
data = stream.read(CHUNK, exception_on_overflow = False)
chunks.append(data)
# process data
buffer = b''.join(chunks)
❺ audio_data = np.frombuffer(buffer, dtype=np.int16)
你将count初始化为0❶。你将使用这个变量来跟踪读取的一秒钟音频数据帧的数量。然后你初始化一个 16 位数组inference_data,并用零填充❷。它有ND个元素,代表两秒钟的音频。接下来,你进入一个while循环来持续处理音频数据❸。在这个循环中,你使用for循环❹逐块读取每秒钟的音频数据,并将这些块附加到chunks列表中。一旦你获得了一秒钟的数据,就将其转换为numpy数组❺。
接下来,仍然在前一段代码中开始的while循环内,你实现了我们在“它是如何工作的”中讨论的技术,创建重叠的两秒钟音频片段。你将使用NDH全局变量提供帮助。
if count == 0:
# set first half
❶ inference_data[:NDH] = audio_data
count += 1
elif count == 1:
# set second half
❷ inference_data[NDH:] = audio_data
# add data to queue
❸ dataq.put(inference_data)
count += 1
else:
# move second half to first half
❹ inference_data[:NDH] = inference_data[NDH:]
# set second half
❺ inference_data[NDH:] = audio_data
# add data to queue
❻ dataq.put(inference_data)
第一次读取一秒钟的帧时,它会被存储在inference_data的前半部分❶。接下来的数据帧存储在inference_data的后半部分❷。现在你有了完整的两秒钟音频数据,所以你将inference_data放入队列中,等待推理过程来提取它❸。对于每个后续帧,数据的后半部分会被移到inference_data的前半部分❹,新数据会设置到后半部分❺,然后inference_data会添加到队列❻。这就创建了每个连续两秒钟音频片段之间所需的重叠一秒钟。
while循环发生在一个try块内部。要退出循环,你只需按下 CTRL-C 并触发以下except块:
except KeyboardInterrupt:
print("exiting...")
stream.stop_stream()
stream.close()
p.terminate()
这个except块进行了一些基本的清理工作,停止并关闭流,并终止PyAudio。
准备音频数据
接下来,你将创建一些函数来准备音频数据进行推理。第一个是process_audio_data(),它接收一个从队列中提取的原始两秒音频片段,并基于峰值振幅提取最有趣的那一秒音频。我们将在几个代码片段中查看这个函数:
def process_audio_data(waveform):
# compute peak to peak based on scaling by max 16-bit value
❶ PTP = np.ptp(waveform / 32768.0)
# return None if too silent
❷ if PTP < 0.3:
return []
如果没有人说话,你希望跳过对麦克风音频输入的推理。然而,环境中总会有一些噪声,因此你不能简单地检查信号是否为 0。相反,当音频的峰峰振幅(即最高值与最低值之间的差)低于某个阈值时,你会跳过推理。为此,首先你将音频除以32768,将其标准化到(−1,1)范围内,然后将结果传递给np.ptp()来获取峰峰振幅❶。标准化使得阈值可以更方便地表达为一个分数。如果峰峰振幅低于0.3❷,你将返回一个空列表(这将跳过推理过程)。你可能需要根据环境噪声级别调整这个阈值值。
process_audio_data()函数继续使用另一种标准化音频数据的方法,适用于不会被跳过的音频:
# normalize audio
wabs = np.abs(waveform)
wmax = np.max(wabs)
❶ waveform = waveform / wmax
# compute peak to peak based on normalized waveform
❷ PTP = np.ptp(waveform)
# scale and center
❸ waveform = 2.0*(waveform - np.min(waveform))/PTP – 1
当你在跳过安静音频样本之前对数据进行标准化时,你将音频除以 32,768,这是 16 位有符号整数的最大可能值。然而,在大多数情况下,音频数据的峰值振幅远低于此值。现在你希望将音频标准化,使其最大振幅,无论是多少,都被缩放到 1。为此,你首先确定音频信号的峰值振幅,然后用该振幅值除以信号❶。接着你计算标准化音频的新的峰峰振幅❷,并使用这个值来缩放和居中数据❸。具体来说,表达式(waveform – np.min(waveform))/PTP会将波形值缩放到(0,1)的范围。将其乘以 2 并减去 1,将值放到(−1,1)的范围内,这正是你需要的。
函数的下一部分从数据中提取一秒钟的音频:
# extract 16000 len (1 second) of data
❶ max_index = np.argmax(waveform)
❷ start_index = max(0, max_index-8000)
❸ end_index = min(max_index+8000, waveform.shape[0])
❹ waveform = waveform[start_index:end_index]
# padding for files with less than 16000 samples
waveform_padded = np.zeros((16000,))
waveform_padded[:waveform.shape[0]] = waveform
return waveform_padded
你需要确保从数据中获取最有趣的那一秒,所以你找到音频振幅最大时的数组索引❶。然后你尝试抓取该索引前❷和后❸的 8,000 个值,以获取完整的一秒数据,使用max()和min()来确保起始和结束索引不会超出原始音频片段的范围。你使用切片提取相关的音频数据❹。由于max()和min()操作,你最终可能会得到少于 16,000 个样本,但神经网络严格要求每个输入为 16,000 个样本长度。为了解决这个问题,你用零填充数据,采用训练过程中使用的相同numpy技巧。然后你返回结果。
图 15-7 总结了process_audio_data()函数,显示了各处理阶段的示例波形。

图 15-7:各个阶段的音频准备过程
图 15-7 中的顶部波形显示了未经处理的音频。第二个波形显示了值归一化到范围(−1, 1)的音频。第三个波形显示了经过偏移和缩放的音频——请注意 y 轴上的波形如何现在填满了(−1, 1)范围。第四个波形由从第三个波形中提取的 16000 个样本组成,以峰值幅度为中心。
接下来,您需要一个get_spectrogram()函数来计算音频数据的频谱图:
def get_spectrogram(waveform):
❶ waveform_padded = process_audio_data(waveform)
❷ if not len(waveform_padded):
return []
# compute spectrogram
❸ f, t, Zxx = signal.stft(waveform_padded, fs=16000, nperseg=255,
noverlap = 124, nfft=256)
# output is complex, so take abs value
❹ spectrogram = np.abs(Zxx)
return spectrogram
调用process_audio_data()函数来准备音频 ❶。如果函数返回空列表(因为音频太安静),get_spectrogram()也会返回空列表 ❷。接下来,使用scipy中的signal.stft()计算频谱图,就像在训练模型时一样 ❸。然后,计算 STFT 的绝对值 ❹,以从复数转换,再次与训练时一样,并返回结果。
运行推理
这个项目的核心是使用您训练过的模型对传入的音频数据进行推理,并识别任何口头命令。请注意,这与从麦克风获取音频数据的代码是分开的。以下是协调这个过程的处理函数:
def inference_process(dataq, interpreter):
success = False
while True:
❶ if not dataq.empty():
# get data from queue
❷ inference_data = dataq.get()
# run inference only if previous one was not successful
❸ if not success:
success = run_inference(inference_data, interpreter)
else:
# skipping, reset flag for next time
❹ success = False
推理过程在一个while内持续运行。在这个循环中,您检查队列中是否有数据 ❶,如果有,则检索它 ❷。然后,使用run_inference()函数对其进行推理,我们将在下面看到,但仅当success标志为False时 ❸。此标志防止重复响应相同的语音命令。请记住,由于重叠技术的影响,一个音频片段的后半部分将作为下一个片段的前半部分重复。这让您可以捕获可能跨两帧分割的任何音频命令,但这意味着一旦成功推理,您应该跳过队列中的下一个元素,因为它将具有来自上一个元素的部分音频。当您像这样跳过时,您将success重置为False ❹,以便在下一个进入的数据上重新开始运行推理。
现在让我们看一下run_inference()函数,这里实际上进行推理:
def run_inference(waveform, interpreter):
# get spectrogram data
❶ spectrogram = get_spectrogram(waveform)
if not len(spectrogram):
return False
# get input and output tensors details
❷ input_details = interpreter.get_input_details()
❸ output_details = interpreter.get_output_details()
该函数接收原始音频数据(waveform)用于与 TensorFlow Lite 模型(interpreter)进行交互。你调用get_spectrogram()来处理音频并生成谱图❶,如果音频太安静,则返回False。然后,你从 TensorFlow Lite 解释器中获取输入❷和输出❸的详细信息。这些信息告诉你模型期望的输入是什么,以及你可以从中获得的输出。input_details看起来是这样的:
[{'name': 'serving_default_input_5:0', 'index': 0, 'shape': array([1, 129, 124, 1]),
'shape_signature': array([ -1, 129, 124, 1]), 'dtype': <class 'numpy.float32'>,
'quantization': (0.0, 0), 'quantization_parameters': {'scales': array([], dtype=float32),
'zero_points': array([], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}]
请注意,input_details是一个包含字典的数组。特别关注'shape'条目:array([1, 129, 124, 1])。你已经确保了将要作为输入传递给解释器的谱图,其形状符合这个值。'index'条目仅表示该张量在解释器内部张量列表中的索引,'dtype'是期望的输入数据类型,在这种情况下是float32,即有符号的 32 位浮动数。你稍后需要在run_inference()函数中引用'index'和'dtype'。
这是output_details:
[{'name': 'StatefulPartitionedCall:0', 'index': 17, 'shape': array([1, 8]), 'shape_signature':
array([-1, 8]), 'dtype': <class 'numpy.float32'>, 'quantization': (0.0, 0),
'quantization_parameters': {'scales': array([], dtype=float32), 'zero_points':
array([], dtype=int32), 'quantized_dimension': 0}, 'sparsity_parameters': {}}]
请注意,这个字典中的'shape'条目。它显示输出将是形状为(1, 8)的数组。该形状对应于八个语音命令的标签 ID。
你继续实现run_inference()函数,实际在输入数据上运行推理:
# set input
❶ input_data = spectrogram.astype(np.float32)
❷ interpreter.set_tensor(input_details[0]['index'], input_data)
# run interpreter
print("running inference...")
❸ interpreter.invoke()
# get output
❹ output_data = interpreter.get_tensor(output_details[0]['index'])
❺ yvals = output_data[0]
print(yvals)
首先,你将谱图数据转换为 32 位浮动数值❶。回想一下,你的音频数据最初是 16 位整数。缩放和其他处理操作将数据转换为 64 位浮动数,但正如你在input_details中看到的,TensorFlow Lite 模型需要 32 位浮动数,因此需要进行此转换。接着,你将输入值设置为解释器中适当的张量❷。这里的[0]访问input_details中的第一个(也是唯一的)元素,它是一个字典,['index']检索该字典下对应键的值,用以指定你正在设置的张量。然后,你使用invoke()方法在输入上运行推理❸。接着,你使用类似的索引方法获取输出张量❹,并通过从output_data数组中提取第一个元素来获取输出本身❺。(因为你只提供了一个输入,所以你预期只有一个输出。)以下是yvals的示例:
[ 6.640185 -26.032831 -26.028618 8.746256 62.545185 -0.5698182 -15.045679 -29.140179 ]
这八个数字对应你用来训练模型的八个命令。它们的值表示输入数据是每个单词的可能性。在这个特定的数组中,索引4的值最大,因此神经网络预测这个值为最可能的答案。下面是如何解释这个结果:
# Important! This should exactly match training labels/ids.
commands = ['up', 'no', 'stop', 'left', 'right', 'go', 'down', 'yes']
print(">>> " + commands[np.argmax(output_data[0])].upper())
你按训练时使用的顺序定义commands列表。保持训练和推理过程中的顺序一致非常重要,否则你可能会误解结果!然后,你使用np.``argmax()来获取输出数据中最大值的索引,并利用该索引从commands中提取相应的字符串。
编写main()函数
现在让我们看看main()函数,它将所有内容结合在一起:
def main():
# globals set in this function
global VERBOSE_DEBUG
# create parser
descStr = "This program does ML inference on audio data."
parser = argparse.ArgumentParser(description=descStr)
# add a mutually exclusive group
❶ group = parser.add_mutually_exclusive_group(required=True)
# add mutually exclusive arguments
❷ group.add_argument('--list', action='store_true', required=False)
❸ group.add_argument('--input', dest='wavfile_name', required=False)
❹ group.add_argument('--index', dest='index', required=False)
# add other arguments
❺ parser.add_argument('--verbose', action='store_true', required=False)
# parse args
args = parser.parse_args()
你首先将VERBOSE_DEBUG设置为全局变量,因为你会在这个函数中设置它,并且不希望它被当作局部变量。接着,你创建一个熟悉的argparse.ArgumentParser对象,并向解析器添加一个互斥组❶,因为你的某些命令行选项相互不兼容。它们分别是:--list选项❷,该选项列出所有的PyAudio设备,帮助你获取麦克风的索引号;--input选项❸,允许你指定一个 WAV 文件作为输入,而不是从麦克风获取实时数据(对测试很有用);以及--index选项❹,该选项会启动麦克风指定索引的音频捕获并进行推理。你还添加了非互斥的--verbose选项❺,以便在程序运行时打印出详细的调试信息。
接下来,你创建 TensorFlow Lite 解释器,以便使用 ML 模型:
# load TF Lite model
interpreter = Interpreter('audioml.tflite')
interpreter.allocate_tensors()
在这里,你创建一个Interpreter对象,传入你在训练过程中创建的audioml.tflite文件。然后你调用allocate_tensors()来准备运行推理所需的张量。
main()函数以不同命令行参数的分支结束:
# check verbose flag
if args.verbose:
VERBOSE_DEBUG = True
# test WAV file
if args.wavfile_name:
❶ wavfile_name = args.wavfile_name
# get audio data
❷ rate, waveform = wavfile.read(wavfile_name)
# run inference
❸ run_inference(waveform, interpreter)
elif args.list:
# list devices
❹ list_devices()
else:
# store device index
❺ dev_index = int(args.index)
# get live audio
❻ get_live_input(interpreter)
print("done.")
如果使用了--input命令行选项,你将获取 WAV 文件的名称❶,并读取其内容❷。结果数据将传递给推理❸。如果使用了--list选项,你会调用list_devices()函数❹。如果使用了--index选项,你会解析设备索引❺,并通过调用get_live_input()函数❻开始处理实时音频。
运行语音识别系统
要运行这个项目,把你的 Python 代码和audioml.tflite文件放在 Pi 上的一个文件夹里。为了测试,你还可以从书籍的 GitHub 仓库下载right.wav文件,并将其添加到该文件夹中。你可以通过 SSH 连接到你的 Pi,具体方法请参考附录 B。
首先,尝试使用--input命令行选项对 WAV 文件进行推理:
$ `sudo python audioml.py --input right.wav`
这是输出:
running inference...
[ 6.640185 -26.032831 -26.028618 8.746256 62.545185 -0.5698182
-15.045679 -29.140179 ]
❶ >>> RIGHT
run_inference: 0.029174549999879673s
done.
请注意,程序已经正确识别了 WAV 文件中录制的right命令❶。
现在将麦克风插入树莓派,并使用--list选项来确定它的索引号,如下所示:
$ `sudo python audioml.py --list`
你的输出应该类似于以下内容:
audioml.py:
Found the following input devices:
1 Mico: USB Audio (hw:3,0) 16000.0
done.
在这个例子中,麦克风的索引是1。使用这个数字运行--index命令进行实时语音检测!以下是一个运行示例:
$ `sudo python audioml.py --index 1`
--`snip`--
opening stream...
Listening...
running inference...
[-2.647918 0.17592785 -3.3615346 6.6812882 4.472283 -3.7535028
1.2349942 1.8546474 ]
❶ >>> LEFT
run_inference: 0.03520956500142347s
running inference...
[-2.7683923 -5.9614644 -8.532391 6.906795 19.197264 -4.0255833
1.7236844 -4.374415 ]
❷ >>> RIGHT
run_inference: 0.03026762299850816s
--`snip`--
^C
KeyboardInterrupt
exiting...
done.
启动程序并得到“正在监听……”提示后,我说出了left和right这两个词。❶和❷的输出表明程序能够正确识别这些命令。
尝试使用--verbose选项运行程序,查看更多有关它如何工作的详细信息。此外,尝试快速连续地说出不同的命令,以验证多进程和重叠技术是否有效。
总结
本章介绍了机器学习的世界。你学习了如何使用 TensorFlow 框架训练一个深度神经网络来识别语音命令,并将生成的模型转换为 TensorFlow Lite 格式,以便在资源受限的树莓派上使用。你还了解了频谱图及在机器学习训练之前处理输入数据的重要性。你练习了使用 Python 多进程、通过PyAudio在树莓派上读取 USB 麦克风输入,并运行 TensorFlow Lite 推理器进行机器学习推理。
实验!
-
1. 现在你已经知道如何在树莓派上处理语音命令,你可以构建一个响应这些命令的辅助设备,不仅仅是打印出识别的词汇。例如,你可以使用命令left、right、up、down、stop和go来控制一个安装在云台上的相机(或激光器!)。提示:你需要用这六个命令重新训练机器学习模型。你还需要一个带有两个伺服电机的二维云台支架。伺服电机将连接到树莓派,并根据推理结果进行控制。
-
2. 了解mel 频谱图,这是你在本项目中使用的频谱图的一种变体,更适合人类语音数据。
-
3. 尝试通过添加或删除某些层来修改神经网络。例如,删除第二个 Conv2D 层。观察这些变化如何影响模型的训练精度和树莓派上的推理精度。
-
4. 本项目使用了一个专门的神经网络,但也有可用的预训练神经网络,你可以利用它们。例如,了解一下 MobileNet V2。要将你的项目改为使用这个网络,需要做什么更改?
完整代码
这是在树莓派上运行的完整代码列表,包括用于详细调试的print()语句。Google Colab 笔记本代码可以在github.com/mkvenkit/pp2e/blob/main/audioml/audioml.ipynb找到。
"""
simple_audio.py
This programs collects audio data from an I2S mic on the Raspberry Pi
and runs the TensorFlow Lite interpreter on a per-build model.
Author: Mahesh Venkitachalam
"""
from scipy.io import wavfile
from scipy import signal
import numpy as np
import argparse
import pyaudio
import wave
import time
from tflite_runtime.interpreter import Interpreter
from multiprocessing import Process, Queue
VERBOSE_DEBUG = False
CHUNK = 4000 # choose a value divisible by SAMPLE_RATE
FORMAT = pyaudio.paInt16
CHANNELS = 1
SAMPLE_RATE = 16000
RECORD_SECONDS = 1
NCHUNKS = int((SAMPLE_RATE * RECORD_SECONDS) / CHUNK)
ND = 2 * SAMPLE_RATE * RECORD_SECONDS
NDH = ND // 2
# device index of microphone
dev_index = -1
def list_devices():
"""list pyaudio devices"""
# initialize pyaudio
p = pyaudio.PyAudio()
# get device list
index = None
nDevices = p.get_device_count()
print('\naudioml.py:\nFound the following input devices:')
for i in range(nDevices):
deviceInfo = p.get_device_info_by_index(i)
if deviceInfo['maxInputChannels'] > 0:
print(deviceInfo['index'], deviceInfo['name'], deviceInfo['defaultSampleRate'])
# clean up
p.terminate()
def inference_process(dataq, interpreter):
"""infererence process handler"""
success = False
while True:
if not dataq.empty():
# get data from queue
inference_data = dataq.get()
# run inference only if previous one was not success
# otherwise we will get duplicate results because of
# overlap in input data
if not success:
success = run_inference(inference_data, interpreter)
else:
# skipping, reset flag for next time
success = False
def process_audio_data(waveform):
"""Process audio input.
This function takes in raw audio data from a WAV file and does scaling
and padding to 16000 length.
"""
if VERBOSE_DEBUG:
print("waveform:", waveform.shape, waveform.dtype, type(waveform))
print(waveform[:5])
# compute peak to peak based on scaling by max 16-bit value
PTP = np.ptp(waveform / 32768.0)
if VERBOSE_DEBUG:
print("peak-to-peak (16 bit scaling): {}".format(PTP))
# return None if too silent
if PTP < 0.3:
return []
# normalize audio
wabs = np.abs(waveform)
wmax = np.max(wabs)
waveform = waveform / wmax
# compute peak to peak based on normalized waveform
PTP = np.ptp(waveform)
if VERBOSE_DEBUG:
print("peak-to-peak (after normalize): {}".format(PTP))
print("After normalization:")
print("waveform:", waveform.shape, waveform.dtype, type(waveform))
print(waveform[:5])
# scale and center
waveform = 2.0*(waveform - np.min(waveform))/PTP - 1
# extract 16000 len (1 second) of data
max_index = np.argmax(waveform)
start_index = max(0, max_index-8000)
end_index = min(max_index+8000, waveform.shape[0])
waveform = waveform[start_index:end_index]
# padding for files with less than 16000 samples
if VERBOSE_DEBUG:
print("After padding:")
waveform_padded = np.zeros((16000,))
waveform_padded[:waveform.shape[0]] = waveform
if VERBOSE_DEBUG:
print("waveform_padded:", waveform_padded.shape,
waveform_padded.dtype, type(waveform_padded))
print(waveform_padded[:5])
return waveform_padded
def get_spectrogram(waveform):
"""computes spectrogram from audio data"""
waveform_padded = process_audio_data(waveform)
if not len(waveform_padded):
return []
# compute spectrogram
f, t, Zxx = signal.stft(waveform_padded, fs=16000, nperseg=255,
noverlap = 124, nfft=256)
# output is complex, so take abs value
spectrogram = np.abs(Zxx)
if VERBOSE_DEBUG:
print("spectrogram:", spectrogram.shape, type(spectrogram))
print(spectrogram[0, 0])
return spectrogram
def run_inference(waveform, interpreter):
# start timing
start = time.perf_counter()
# get spectrogram data
spectrogram = get_spectrogram(waveform)
if not len(spectrogram):
if VERBOSE_DEBUG:
print("Too silent. Skipping...")
return False
if VERBOSE_DEBUG:
print("spectrogram: %s, %s, %s" % (type(spectrogram),
spectrogram.dtype, spectrogram.shape))
# get input and output tensors details
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
if VERBOSE_DEBUG:
print("input_details: {}".format(input_details))
print("output_details: {}".format(output_details))
# reshape spectrogram to match interpreter requirement
spectrogram = np.reshape(spectrogram, (-1, spectrogram.shape[0],
spectrogram.shape[1], 1))
# set input
input_data = spectrogram.astype(np.float32)
interpreter.set_tensor(input_details[0]['index'], input_data)
# run interpreter
print("running inference...")
interpreter.invoke()
# get output
output_data = interpreter.get_tensor(output_details[0]['index'])
yvals = output_data[0]
if VERBOSE_DEBUG:
print(output_data)
print(yvals)
# Important! This should exactly match training labels/ids.
commands = ['up', 'no', 'stop', 'left', 'right', 'go', 'down', 'yes']
print(">>> " + commands[np.argmax(output_data[0])].upper())
# stop timing
end = time.perf_counter()
print("run_inference: {}s".format(end - start))
# return success
return True
def get_live_input(interpreter):
"""this function gets live input from the microphone
and runs inference on it"""
# create a queue object
dataq = Queue()
# start inference process
proc = Process(target = inference_process, args=(dataq, interpreter))
proc.start()
# initialize pyaudio
p = pyaudio.PyAudio()
print('opening stream...')
stream = p.open(format = FORMAT,
channels = CHANNELS,
rate = SAMPLE_RATE,
input = True,
frames_per_buffer = CHUNK,
input_device_index = dev_index)
# discard first 1 second
for i in range(0, NCHUNKS):
data = stream.read(CHUNK, exception_on_overflow = False)
# count for gathering two frames at a time
count = 0
inference_data = np.zeros((ND,), dtype=np.int16)
print("Listening...")
try:
while True:
# print("Listening...")
chunks = []
for i in range(0, NCHUNKS):
data = stream.read(CHUNK, exception_on_overflow = False)
chunks.append(data)
# process data
buffer = b''.join(chunks)
audio_data = np.frombuffer(buffer, dtype=np.int16)
if count == 0:
# set first half
inference_data[:NDH] = audio_data
count += 1
elif count == 1:
# set second half
inference_data[NDH:] = audio_data
# add data to queue
dataq.put(inference_data)
count += 1
else:
# move second half to first half
inference_data[:NDH] = inference_data[NDH:]
# set second half
inference_data[NDH:] = audio_data
# add data to queue
dataq.put(inference_data)
# print("queue: {}".format(dataq.qsize()))
except KeyboardInterrupt:
print("exiting...")
stream.stop_stream()
stream.close()
p.terminate()
def main():
"""main function for the program"""
# globals set in this function
global VERBOSE_DEBUG
# create parser
descStr = "This program does ML inference on audio data."
parser = argparse.ArgumentParser(description=descStr)
# add a mutually exclusive group
group = parser.add_mutually_exclusive_group(required=True)
# add mutually exclusive arguments
group.add_argument('--list', action='store_true', required=False)
group.add_argument('--input', dest='wavfile_name', required=False)
group.add_argument('--index', dest='index', required=False)
# add other arguments
parser.add_argument('--verbose', action='store_true', required=False)
# parse args
args = parser.parse_args()
# load TF Lite model
interpreter = Interpreter('audioml.tflite')
interpreter.allocate_tensors()
# check verbose flag
if args.verbose:
VERBOSE_DEBUG = True
# test WAV file
if args.wavfile_name:
wavfile_name = args.wavfile_name
# get audio data
rate, waveform = wavfile.read(wavfile_name)
# run inference
run_inference(waveform, interpreter)
elif args.list:
# list devices
list_devices()
else:
# store device index
dev_index = int(args.index)
# get live audio
get_live_input(interpreter)
print("done.")
# main method
if __name__ == '__main__':
main()
第十六章:A
Python 安装

本附录介绍了如何安装 Python 以及书中使用的外部模块和代码。使用 Raspberry Pi 进行硬件项目的安装方法在 附录 B 中有介绍。本书中的项目已在 Python 3.9 环境下进行测试。
安装书中的项目源代码
你可以从 github.com/mkvenkit/pp2e 下载书中项目的源代码。在该网站上使用“Download ZIP”选项来获取代码。
一旦下载并解压代码,你需要将下载代码中的 common 文件夹路径(通常是 pp-master/common/)添加到 PYTHONPATH 环境变量中,这样模块才能找到并使用这些 Python 文件。
在 Windows 上,你可以通过创建一个 PYTHONPATH 环境变量,或者如果已经存在该变量,则进行添加。在 macOS 上,你可以将这一行添加到主目录下的 .profile 文件中(如果需要的话,可以在该目录下创建该文件):
export PYTHONPATH=$PYTHONPATH:`path_to_common_folder`
根据需要填写公共文件夹的路径。
Linux 用户可以在 .bashrc、.bash_profile 或 .cshrc/.login 文件中进行类似的操作,具体取决于使用的环境。使用 echo $SHELL 命令来查看默认的 shell。
安装 Python 和 Python 模块
我建议使用 Anaconda 发行版的 Python 来运行本书的项目,因为它已经包含了你所需的大部分 Python 模块。本节将介绍 Windows、macOS 和 Linux 上的安装过程。
Windows
访问 www.anaconda.com 下载适用于 Windows 的 Anaconda 发行版。安装完成后,打开 Anaconda 提示符(在搜索栏中输入 Anaconda prompt),你将使用它来运行你的程序。只需 cd 进入书中的代码目录,准备好就可以开始了。
同样,将 Anaconda 及其支持文件的位置添加到 Path 环境变量中也是很有用的(在搜索栏中输入 Edit Environment Variables):
`C:\Users\mahes\anaconda3`
`C:\Users\mahes\anaconda3`\Scripts
`C:\Users\mahes\anaconda3`\Library\bin
在这个例子中,我的 Anaconda 安装目录是 C:\Users\mahes\anaconda3\。根据需要修改此路径。
安装 GLFW
对于本书中的基于 OpenGL 的 3D 图形项目,你需要安装 GLFW 库,可以从 www.glfw.org 下载。在 Windows 上,安装 GLFW 后,设置一个 GLFW_LIBRARY 环境变量(在搜索栏中输入 Edit Environment Variables),其值为已安装的 glfw3.dll 文件的完整路径,这样 Python 绑定的 GLFW 就能找到这个库。路径会像 C:\glfw-3.0.4.bin.WIN32\lib-msvc120\glfw3.dll 这样的格式。
要在 Python 中使用 GLFW,你需要一个名为pyglfw的模块,它由一个名为glfw.py的 Python 文件组成。你不需要安装pyglfw,因为它随书中的源代码一起提供,位于common目录中。万一你需要安装一个更新版本,这里是源代码:github.com/rougier/pyglfw。
你还需要确保你的显卡驱动程序已安装在计算机上。这通常是件好事,因为许多程序(特别是游戏)都会使用图形处理单元(GPU)。
安装附加模块
你需要安装一些不包含在标准 Anaconda 发行版中的附加模块。请在 Anaconda 提示符下运行以下命令:
`conda install -c anaconda pyaudio`
`conda install -c anaconda pyopengl`
macOS
访问www.anaconda.com并下载适用于 macOS 的 Anaconda 发行版。安装完成后,打开一个终端应用程序窗口并输入which python。输出应该指向 Anaconda Python 的版本。如果没有,手动将路径添加到你的.profile文件中。例如:
export PYTHONPATH=`your_anaconda_install_dir_path`:$PYTHONPATH
根据需要填写你的 Anaconda 安装目录路径。
安装 GLFW
对于本书中的基于 OpenGL 的 3D 图形项目,你需要 GLFW 库,可以在www.glfw.org下载。选择 macOS 预编译二进制选项,并将下载的文件夹复制到你的Home文件夹中。例如,在我的例子中,我的Home文件夹是/Users/mahesh/。
现在你需要将以下内容添加到你的.profile文件中,并根据需要修改路径:
export GLFW_LIBRARY=/Users/mahesh/glfw-3.3.8.bin.MACOS/lib-universal/libglfw.3.dylib
当你第一次尝试运行使用 GLFW 的程序时,可能会收到安全警告。你需要在“系统偏好设置”中的“安全性”下允许它运行。
安装附加模块
接下来,你需要安装一些不包含在标准 Anaconda 发行版中的附加模块。在终端窗口中,运行以下命令:
`conda install -c anaconda pyaudio`
`conda install -c anaconda pyopengl`
Linux
Linux 通常自带 Python 以及构建所需包的所有开发工具。因此,你不需要安装 Anaconda Python。在大多数 Linux 发行版中,你应该能够使用pip3来获取书中所需的包。你可以像这样使用pip3安装包:
sudo pip3 install matplotlib
安装包的另一种方法是下载该模块的源代码发行版,通常是.gz或.zip文件。将这些文件解压到一个文件夹中后,你可以按以下方式安装它们:
sudo python setup.py install
对于书中需要的每个包,使用以下方法之一进行安装。
第十七章:B
Raspberry Pi 设置

本附录介绍如何设置 Raspberry Pi,以便你可以使用它进行第十三章、第十四章和第十五章中的项目。这些项目适用于 Raspberry Pi 3 Model B+ 或 Raspberry Pi 4 Model B,两个型号的设置方法相同。除了主板,你还需要一个兼容的电源和一张容量为 16GB 或更大的 micro SD 卡。
设置软件
有几种方式可以设置你的 Pi。这些步骤概述了最简单的方法之一,使用 Raspberry Pi Imager:
-
1. 从 Raspberry Pi 网站下载 Raspberry Pi Imager:
www.raspberrypi.com/software。 -
2. 将 SD 卡插入计算机。(根据你的系统,可能需要一个 micro SD 卡适配器。)
-
3. 打开 Pi Imager 并点击选择操作系统按钮。图 B-1 显示了相应的对话框。
![]()
图 B-1:Raspberry Pi Imager 中的选择操作系统对话框
-
4. 点击Raspberry Pi OS选项。
-
5. 点击选择存储按钮。你应该会看到类似图 B-2 的屏幕。
![]()
图 B-2:Raspberry Pi Imager 中的选择存储对话框
-
6. 屏幕应该列出你的 SD 卡。点击它。
-
7. 点击齿轮图标以打开高级选项对话框,如图 B-3 所示。
![]()
图 B-3:Raspberry Pi Imager 中的高级选项对话框
-
8. 在设置主机名框中输入你的 Pi 的名称。我在图 B-3 中将名称设置为
audioml。由于 Raspberry Pi OS 默认启用了名为 Avahi 的服务,你可以通过在设备名称后面添加.local来通过本地网络访问你的 Pi——例如,audioml.local。这比记住并使用 IP 地址方便得多。 -
9. 在同一个对话框中,设置你的用户名和密码,并启用 SSH。然后滚动下方,查看 Wi-Fi 连接选项,如图 B-4 所示。
![]()
图 B-4:Raspberry Pi Imager 中的 Wi-Fi 详情
-
10. 输入你的 Wi-Fi 详情,类似于图 B-4 所示。完成后,点击保存,然后点击写入按钮将所有这些信息写入 SD 卡。
-
11. 当 SD 卡准备好后,将其插入 Pi 中。然后启动你的 Pi,它会自动连接到你的 Wi-Fi 网络。
现在你应该能够通过 SSH 安全外壳远程登录到你的 Pi,稍后我们将讨论这一点。
测试你的连接
要检查你的 Pi 是否已连接到本地网络,可以在计算机的命令行中输入 ping,后跟你 Pi 的设备名称。例如,以下是在 Windows 命令行中执行 ping 命令时的输出:
$ `ping audioml.local`
Pinging audioml.local [fe80::e3e0:1223:9b20:2d6f%6] with 32 bytes of data:
Reply from fe80::e3e0:1223:9b20:2d6f%6: time=66ms
Reply from fe80::e3e0:1223:9b20:2d6f%6: time=3ms
Reply from fe80::e3e0:1223:9b20:2d6f%6: time=2ms
Reply from fe80::e3e0:1223:9b20:2d6f%6: time=3ms
Ping statistics for fe80::e3e0:1223:9b20:2d6f%6:
Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 2ms, Maximum = 66ms, Average = 18ms
这个 ping 输出显示了发送的字节数以及接收回复所需的时间。如果你看到 Request timeout... 消息,那说明你的 Pi 没有连接到网络。在这种情况下,可以尝试在网上查找故障排除策略。例如,在 Windows 计算机上,你可以尝试以管理员身份打开命令提示符并输入 arp -d 命令。这会清除 ARP 缓存。(ARP 是一种用于检测网络上其他计算机的协议。)然后再试一次 ping 命令。如果仍然失败,最好连接一个显示器和键盘到你的 Pi,以检查它是否真的能连接到互联网。
通过 SSH 登录到你的 Pi
你可以将键盘、鼠标和显示器连接到 Pi 上直接使用,但为了本书的目的,最方便的方式是通过 SSH 从桌面或笔记本电脑远程登录到你的 Pi。如果你经常这么做,并且每次都从同一台计算机登录,你可能会觉得每次都输入密码非常麻烦。使用 SSH 自带的 ssh-keygen 工具,你可以设置一个公钥/私钥方案,这样就可以安全地登录到 Pi,而无需输入密码。对于 macOS 和 Linux 用户,按照以下步骤操作。(对于 Windows 用户,PuTTY 也能做类似的操作。搜索“使用 PuTTY 生成 SSH 密钥”了解更多信息。)
- 1. 在你的计算机的终端中,输入以下命令以生成密钥文件:
$ `ssh-keygen`
Generating public/private rsa key pair.
Enter file in which to save the key (/Users/`xxx`/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /Users/`xxx`/.ssh/id_rsa.
Your public key has been saved in /Users/`xxx`/.ssh/id_rsa.pub.
The key fingerprint is:
--`snip`--
- 2. 将密钥文件复制到 Pi 上。你可以使用
scp命令,这是 SSH 的一部分。输入以下命令,并适当替换 Pi 的 IP 地址:
$ `scp ~/.ssh/id_rsa.pub pi@``192.168.4.32``:.ssh/`
The authenticity of host '192.168.4.32 (192.168.4.32)' can't
be established.
RSA key fingerprint is f1:ab:07:e7:dc:2e:f1:37:1b:6f:9b:66:85:2a:33:a7.
Are you sure you want to continue connecting (yes/no)? `yes`
Warning: Permanently added '192.168.4.32' (RSA) to the list of
known hosts.
pi@192.168.4.32's password:
id_rsa.pub 100% 398 0.4KB/s 00:00
- 3. 登录到 Pi 并验证密钥文件是否已被复制,同样替换为你 Pi 的 IP 地址:
$ `ssh pi@``192.168.4.32`
pi@192.168.4.32's password:
$ `cd .ssh`
$ `ls`
id_rsa.pub known_hosts
$ `cat id_rsa.pub >> authorized_keys`
$ `ls`
authorized_keys id_rsa.pub known_hosts
$ `logout`
下次登录到 Pi 时,你将不再需要输入密码。另外,请注意,在这个例子中,我在 ssh-keygen 中使用了一个空的密码短语,这并不安全。这个设置对于那些不太关心安全的 Raspberry Pi 硬件项目来说可能是可以接受的,但你可能会考虑使用密码短语。
安装 Python 模块
在第十三章、第十四章和第十五章中的大部分 Python 模块已经包含在 Raspberry Pi 安装中。对于其余的模块,在通过 SSH 连接到你的 Pi 后,依次运行以下命令进行安装:
$ `sudo pip3 install bottle`
$ `sudo apt install python3-matplotlib`
$ `sudo apt-get install python3-scipy`
$ `sudo apt-get install python3-pyaudio`
$ `sudo pip3 install tflite-runtime`
这应该能帮助你开始书中所有使用树莓派的项目。
使用 Visual Studio Code 远程工作
一旦你获得了对 Pi 的 SSH 访问权限,你可以在计算机上编辑源代码并通过 scp 命令将其传输到 Pi,但这很快会变得繁琐。其实有更好的方法。Visual Studio Code(VS Code)是微软出品的一个流行代码编辑器。这个软件支持大量的插件或扩展,以增强其功能。其中之一是 Visual Studio Code Remote - SSH 扩展,它可以让你连接到 Pi 并直接从计算机上编辑文件。你可以在 code.visualstudio.com/docs/remote/ssh 找到该扩展的安装详情。
第十八章:索引
符号
2D 切片, 216, 220
3D 图形管线, 154
3D 打印, 289
3D 纹理, 217
3D 变换, 156–158
8 位图像, 103
16 位数据, 65, 69
_bleio, 322
%(取模运算符), 51
A
Adafruit
adafruit_ble 库
Advertisement 类, 321
BLE_ADV_INT, 323
BLERadio, 322
LazyObjectField, 322
ManufacturerDataField, 322
start_advertising(), 323
stop_advertising(), 324
adafruit_bmp280, 322
adafruit_sht31d, 322
Feather Bluefruit nRF52840, 318
振幅, 63
匿名函数, 374
Arduino 草图, 293
argparse 模块, 33, 52, 72
add_argument(), 33, 199, 301, 340, 385
ArgumentParser, 33, 94, 107, 126, 145, 199, 301, 340, 385
add_mutually_exclusive_group(), 385
parse_args(), 33, 199, 301, 340, 385
人工生命, 45
ASCII 艺术, 101–102
长宽比, 103
亮度, 103, 104, 105
命令行选项, 107
字体, 103
生成, 102–104, 106–107
梯度, 103, 104
瓷砖, 103
写入文本文件, 107
长宽比, 103, 158
assert 方法, 123
Audacity, 65, 74
音频信号, 287
自动立体图, 135
命令行选项, 145
创建拼贴图像, 144
深度图, 138
深度感知, 137, 138
示例, 136, 146
线性间距, 136, 137
随机点, 142
重复图案, 137, 143
斜视, 137
*均色, 115
避免循环, 87
B
BLE, 313
广告, 313
中央, 313
外围, 313
扫描响应, 314
混合, 155, 216, 233
蓝牙低能耗. 见 BLE
BlueZ, 314
fromhex, 329
hciconfig, 314
hcidump, 314
输出, 328
hcitool, 314
解析数据, 327
启动扫描, 326
停止扫描, 327
board 模块
创建,332
I2C,322
路径,332
提供静态文件,332
Boids 仿真,79
添加一个 Boid,91–92
动画,92
边界条件,83
绘图,84–86
初始条件,81
障碍物回避,95
规则,80,86
散射,92
*铺边界条件,83
Bottle 模块,315
route(),315
运行,315,316
网络框架,315
BS170 MOSFET,285
bytearray.fromhex(),329
字节码,xxiv
C
元胞自动机,45
中央处理单元(CPU),153
重心,90
CircuitPython,318
collections 模块,66
颜色立方体,218
颜色表示,219
公共文件夹,395
复数,298
计算机生成的蜂群,79
计算机仿真边界条件,47
常数时间,66
康威,约翰,45
康威的生命游戏,45
边界条件,47
滑翔机,50
Gosper 滑翔机枪,55
初始条件,50
模式,54
规则,47,48
环形边界条件,47,51
CoolTerm,273
CPU(中央处理单元),153
CT 扫描, 215
D
DC 电动机, 281
去抖动, 274
装饰器, 274, 316
深度学习, 356
深度编码, 138
深度图, 138
深度感知, 137
deque
类, 66
容器, 66
示例, 67
直接内存访问(DMA), 272
离散傅里叶变换(DFT), 286
dmesg 命令, 364
E
邮件签名, 101
嵌入式 PostScript(EPS), 32
纪元, 375
F
面剔除, 219
阶乘, 4
远*面, 158
快速傅里叶变换(FFT), 286
振幅, 286
示例, 286, 287
频率, 287
采样率, 287
FBO(帧缓冲对象), 220
视场, 158
文件句柄, 120
分形, 15
片段着色器, 155
帧缓冲对象(FBO), 220
频率, 59
基础, 60, 62
G
生命游戏。见 康威的生命游戏
几何图元, 158
GitHub 书籍仓库, 395
GLFW, 154, 220, 238
glfwCreateWindow(), 163
glfwGetFramebufferSize(), 200
glfwGetTime(), 164, 200
glfwInit(), 162
glfwMakeContextCurrent(), 163
glfwPollEvents(), 165
glfwSetKeyCallback(), 163
glfwSetTime(), 164, 200
glfwSwapBuffers(), 165
glfwTerminate(), 165, 200
glfwWindowHint(), 162
glfwWindowShouldClose(), 164, 200
安装, 396, 397
键盘事件, 163
GL_LINE_LOOP, 155–156
GL_LINES, 155–156
GL_LINE_STRIP, 155–156
glob 模块, 366
GL_POINTS, 155–156
GLSL (OpenGL 着色语言), 153, 155, 158, 159, 160, 168, 169, 170, 216, 223, 224, 231, 232
编译, 165
计算位置, 169
discard() 方法, 169, 170
示例, 158
片段着色器, 159, 169, 189, 224, 232, 236
gl_FragCoord, 232
gl_Position, 159, 169, 188, 223, 231, 236
长度, 232
mat4,157,159,168,169,188,223,231
normalize(),189,204,232
sampler2D,168,169,232
sampler3D,232,237
纹理,169,232,237
uniform,159,168,169,188,223,231,232,235,236
vec2,168,169,173
vec3,158,168,172,188,189,223,232
vec4,157,159,169,173,188,223,224,231,232,236,237
顶点着色器,158,159,168,188,231,235
glTexImage3D(),223
GL_TRIANGLE_FAN,155–156
GL_TRIANGLES,155–156,219
GL_TRIANGLE_STRIP, 155–156, 161, 181, 184
脚本语言, xxv
Google Colab, 365
下载文件, 376
图形处理单元(GPU), 153, 216
灰度图像和数值, 102
最大公约数(GCD), 23
吉他, 60
H
谐波, 63
齐次坐标, 156, 182
热熔胶, 289
假圆轨迹, 22, 282
I
I2S 协议, 262
振幅, 263
SCK 信号, 262
SD 信号, 262
波形, 262
WS 信号, 262
IFTTT(如果这,那么那个), 312
发送警报, 330, 342
设置, 319–320
幻觉, 135
基于图像的渲染, 216
树莓派上的推理, 376
input(), 300
物联网(IoT), 311
Io 模块
io.BytesIO, 334
物联网花园, 311
adafruit_ble, 321
adafruit_bmp280, 321
adafruit_sht31d, 321
架构, 312
BLE 扫描仪, 325
完整代码, 345
Bottle 模块
完整代码, 349
创建路由, 332
主页 HTML 页面, 333
CircuitPython 代码, 321, 343
代码文件, 320
完整代码, 351
CSS, 338
获取传感器数据, 335
硬件, 318
IFTTT 警报, 342
JavaScript, 336
async, 336
await, 336
Date(), 336
日期选择器, 343
fetch_data(), 336
getElementById(), 336
onload, 337
setInterval(), 338
树莓派设置, 318
要求, 317
运行, 341
传感器数据, 333
SQLite 设置, 339
Web 服务器完整代码, 331
K
Karplus 在 Pico 上
组装好的硬件, 265
方框图, 261
代码清单, 266
完整代码, 275
电气连接, 265
生成笔记, 267
硬件连接, 264
硬件要求, 263
实现, 260
主函数, 270
五度音阶(小调), 267
播放音符, 269
环形缓冲区, 268
运行代码, 272
串行输出, 273
Thonny, 272–273
Karplus-Strong 算法, 62
低通滤波器, 63
k-d 树, 114
定义, 117
示例, 117
最*邻搜索, 118
Koch 雪花, 3
组合, 9
完整代码清单, 16
计算, 5
分形, 3, 8
GitHub 代码, 11
递归, 3
基本情况, 5, 8
递归步骤, 5, 8
递归算法, 3
运行代码, 14
L
lambda, 374
激光, 279
激光音频
对准镜子, 289
附加镜子, 290
阻止图, 280
完整代码, 293, 305
构建激光显示, 289
显示, 303
硬件
已完成, 292
连接, 291, 292
控制, 294
要求, 288
激光与镜子, 282
驱动电机, 291
处理音频, 296
树莓派设置, 289
所需软件, 288
运行代码, 302–303
设置, 293
测试电机, 300
测试运行, 302
激光模块, 284
激光指示器, 281
线性代数, 6
线性搜索, 116
线性间距, 136, 137
列表推导式, 34
避免循环, 87
损失函数, 374
亮度, 105
M
MAC 地址, 323
机器学习
深度神经网络 (DNN), 356
梯度下降, 357
超参数, 357
推理, 356
监督学习, 356
训练, 356
无监督学习, 357
磁共振成像 (MRI), 215
向量的大小, 82
大调, 65
映射, 102
math 模块
sqrt, 87, 197
matplotlib, 72
库, 48
animation, 48, 53
imshow, 49
interpolation, 49
模块
animation, 85
mpl_connect, 91
矩阵乘法, 157
MAX98357A, 261
医疗数据, 217
微控制器, 259
MicroPython
array 模块, 267
bytearray, 269
close, 269, 270
I2S 模块, 263
创建, 271
内部缓冲区, 271
写入, 270
列表
append() 方法, 268
pop(), 268
memoryview, 269, 270, 276
打开, 269
os 模块
listdir, 268
Pin 模块, 270
按钮输入, 271
切换, 271
random 模块, 267
readinto, 270
设置, 266
切片, 270
定时, 274
写入, 269
min() 方法, 142
小型语音命令数据集, 359
小调五声音阶, 66
镜子, 281
附加, 289
激光, 282
模型视图矩阵, 224
模运算符 (%), 51
电机驱动,284
multiprocessing 模块,305
Process(),378
Queue,378
empty(),383
get(),383
put(),380
音阶
大调,65
小调五声音阶,66
音调,65
N
N体模拟,80
**面,158
neopixel,321
show(),324
归一化向量,183
nRF52840,318
numpy 模块,119,162
abs(),297,381,382
argmax(),375,381,385
排列,64
数组,49,50,53,85,87,92,105,120,166,222
reshape(),81
*均值,120
average(),105
广播,82
cos(),81
十字,198
FFT。参见 快速傅里叶变换
rfft(),297
rfftfreq(),305
frombuffer(),297
图像,106
linalg
norm,198
数学
弧度,198
max(),381
min(),381
优化,87
ptp(),380
random, 50, 72–73, 81
choice, 73, 196
shuffle, 367
sin(), 64, 81
sum(), 298
zeros(), 190, 379, 381
O
面向对象语言, xxiv
OpenGL
3D 图形管线, 154
3D 变换, 156–158
限制纹理, 171
颜色表示, 219
上下文, 161
显示, 161
面剔除, 219
几何原语, 158
glActiveTexture(), 168, 226
glBindBuffer(), 166, 195, 225, 234
glBindFramebuffer(), 226
glBindRenderbuffer(), 226
glBindTexture(), 168, 170, 222, 226
glBindVertexArray(), 166, 168, 195, 225, 228, 234
glBufferData(), 166, 225, 234
glBufferSubData(), 195
GL_CCW, 217
glCheckFramebufferStatus(), 227
glClear(), 164, 200
glClearColor(), 163
GL_CULL_FACE, 228
glDrawArrays(), 168
glDrawElements(), 228
GL_ELEMENT_ARRAY_BUFFER,226
glEnable(),163
glEnableVertexAttribArray(),166,234
glFramebufferRenderbuffer(),227
glFramebufferTexture2D(),226
glGenBuffers(),166,225,234
glGenFramebuffers(),226
glGenRenderbuffers(),226
glGenTextures(),170,222,226
glGenVertexArrays(),166,225,234
glGetUniformLocation(),166,190
glMultiDrawArrays(),191,195
glPixelStorei(),170,222
glRenderbufferStorage(),226
GLSL(OpenGL 着色语言),153,155,158,159,160,168,169,170,223,224,231,232
编译,165
计算位置,169
discard() 方法,169,170
示例,158
片段着色器,159,169,189,224,232,236
gl_FragCoord,232
gl_Position, 159, 169, 188, 223, 231, 236
length, 232
mat4, 157, 159, 168, 169, 188, 223, 231
normalize(), 189, 204, 232
sampler2D, 168, 169, 232
sampler3D, 232, 237
texture, 169, 232, 237
uniform, 159, 168, 169, 188, 223, 231, 232, 235, 236
vec2, 168, 169, 173
vec3, 158, 168, 172, 188, 189, 223, 232
vec4, 157, 159, 169, 173, 188, 223, 224, 231, 232, 236, 237
顶点着色器,158,159,168,188,231,235
glTexImage2D(),170,226
glTexImage3D(),223
glTexParameterf(),170,222
glTexParameteri(),226
GL_TRIANGLES,219
GL_TRIANGLE_STRIP,155,161,184
glUniform1i(),168
glUniformMatrix4fv(),167,195,228
glUseProgram(),165,167,195
glVertexAttribPointer(),166,234
glViewport(),163,200
线性过滤,171
modelview,157
投影,157
光栅化,155
纹理单元,168
三角形带,161
顶点数组对象(VAO),160
顶点缓冲对象(VBO),160
OpenSCAD,289
优化器,374
正交投影,158
os 模块
listdir(),119,221
path,72,119,222
泛音,60
P
并行处理,153
参数方程,20
对于一个旋画,20–24
模式,281
五声音阶,65
次要,267
性能分析,88
透视投影, 158
照片马赛克, 113
*均颜色值, 115, 120
命令行选项, 126
创建图像网格, 123–124
距离, 122
网格, 114, 120
图像匹配, 116, 121–122
测量距离, 116
读取输入图像, 119
RGB 值, 116
拆分目标图像, 115, 120
Pico, 259–263
引脚图, 264
使用 MicroPython 设置, 266
PIL(Python 图像库), 25
Pillow 模块, 25, 104, 119, 141
程序化语言, xxiv
投影矩阵, 224
公钥/私钥方案, 402
脉宽调制, 282
PuTTY, 402
PWM 对象, 282, 284
pyaudio 模块
close(), 299
创建, 296, 378
get_device_count(), 378
get_device_info_by_index(), 378
open(), 296, 379
读取, 379
stop_stream, 299
terminate(), 378
write(), 297
pygame 模块, 66, 69
PyOpenGL 模块, 162, 187, 220
Python 图像库(PIL), 25
convert() 方法, 105
Image,104,105
convert(),104,105,144
copy(),56,144
crop(),106,107,121
load(),119,120,144,145
new(),123,142,143
open(),32,104,105,119,120
paste(),123,124,143,147
pixel access,144
size(),105,120,123,127,143
thumbnail(),127
ImageDraw,142
亮度,105
Python 安装
Anaconda
macOS,397
Windows,396
PYTHONPATH,396,397
Python 解释器,xxv
R
random 模块
choice(),73
hideturtle(),31–32
randint(),142
showturtle(),31–32
range() 方法,24
树莓派
BCM 引脚编号,291
主机名,401
Imager,399
安装 Python 模块,404
local,401
使用 SSH 登录,402
Pico,259–263
引脚图,264
使用 MicroPython 设置, 266
ping, 402
引脚布局, 291
scp, 403
设置, 399
Visual Studio Code, 404
光栅化, 155
ray, 216
生成, 217–218
光线投射算法, 216, 220, 229
反射, 281
相对路径, 119
requests模块
post(), 330
分辨率, 113
声音, 64
Reynolds, Craig, 79
RGB 值, 116
环形缓冲区, 67
均方根(RMS), 304
RP2040 芯片, 259
RPi模块
GPIO, 293
输出, 294, 295
PWM, 294
setmode(), 294
setup(), 294
GPIO.PWM
ChangeDutyCycle(), 295
start(), 295
stop(), 295
S
采样率, 64
采样定理, 370
scipy模块
spatial.distance模块, 88
spatial.pdist()模块, 88
spatial.squareform(), 87
stft(), 371, 382
scipy.spatial模块, 80, 87
KDTree
创建, 125
query(), 122
脚本语言, xxiv
安全外壳(SSH), 402
半音, 65
着色器, 153, 158
片段, 159
顶点, 158–159
轴, 281
短时傅里叶变换(STFT), 360
Sierpiński, Wacław, 15
Sierpiński 三角形, 15
正弦波, 59, 64
声音
振幅, 63
频率, 59
基本频率, 60
泛音, 60
谱图, 74
声谱图, 359
语音识别, 355
音频准备, 382
方框图, 358
清洗数据, 368
训练代码, 365
完整代码, 389
深度神经网络(DNN)架构, 357
下载训练数据, 366
导出模型, 376
推理策略, 362
列出输入设备, 377
Mini 语音命令数据集, 366
准备音频数据, 380
所需硬件, 364
所需软件, 364
运行, 386
运行推理, 383
声谱图示例, 369
训练模型, 370
螺旋, 36
螺旋图方程, 21
周期性, 23–24
sqlite 模块, 316
close(), 317
commit(), 317
connect(), 317
CREATE TABLE, 317
cursor(), 317
execute(), 317
fetchall(), 317
ID, 317
SELECT, 317
sqlite3, 316
TS, 317
VAL, 317
ssh-keygen, 402
短时傅里叶变换 (STFT), 360
string 模块
decode(), 327
split(), 300, 327
startswith(), 327
系统资源, 120
T
TB6612FNG, 283
连接中, 291
TensorFlow
allocate_tensors(), 386
argmax(), 371
astype(), 384
audio.decode_wav(), 371
concat(), 371
convert_to_tensor(), 371
expand_dims(), 371
keras.utils.get_file(), 366
py_function(), 371
shape(), 371
squeeze(), 371
Tensor, 371
tf.data.Dataset
batch(), 373
from_tensor_slices(), 372
map(), 373
tf.io.read_file(), 371
tf.keras.callbacks.EarlyStopping(), 375
tf.keras.layers
adapt(), 373
Conv2D(), 373
Dense(), 373
Dropout(), 373
Flatten(), 373
MaxPooling2D(), 373
tf.keras.layers.experimental.Normalization(), 373
tf.keras.losses.SparseCategoricalCrossentropy(), 374
tf.keras.Model
fit(), 375
save(), 376
summary(), 373
tf.keras.models.Sequential(), 373
tf.keras.optimizers.Adam(), 374
tflite_runtime
get_input_details(), 383
get_output_details(), 383
get_tensor(), 384
Interpreter, 386
invoke(), 384
tflite_runtime,377
tf.lite.TFLiteConverter,376
tf.string.split(),371
zeros(),371
TensorFlow Lite,359,376
文本图形,101
纹理映射,160–161
纹理单元,168
timeit 模块,89,127
time 模块
sleep(),72,301
time(),330
时序,88
tkinter 模块,10,25
canvas,32
音调,65
环面
计算顶点,191
相机,185
完整代码,211
单元颜色,194
着色,185
完整代码,203
计算法线,183
计算顶点,180
创建相机,197
片段着色器,189
生命游戏,196
完整代码,209
渲染,202
映射模拟网格,187
渲染,183
RenderWindow
完整代码,211
旋转相机,198
运行代码,201
环面类,190
变换矩阵,182
顶点着色器,188
*移矩阵,156
turtle 模块,24
down(),24
绘制圆形,24
绘制旋花,27
图形,9
down(),10
mainloop(),10
setpos(), 10
tkinter(), 10
up(), 10
hideturtle(), 31–32
隐藏光标, 31–32
listen(), 33
mainloop(), 24
onkey(), 33
ontimer(), 29
setheading(), 36
setpos(), 24
设置光标, 25–26
setup(), 33–34
showturtle(), 31–32
title(), 33
up(), 24
window_height(), 29
window_width(), 29
U
USB 麦克风, 364
V
向量
幅度, 82
velocity, 82
顶点数组对象, 160
顶点缓冲对象, 160
顶点着色器, 155
体积渲染, 215
2D 切片, 220, 233, 234, 235, 236, 237
3D 纹理坐标, 217
彩色立方体, 218, 220, 225, 226, 227, 228
几何定义, 224
最大强度投影, 240
光线投射, 216, 220, 221, 229, 230, 231, 232, 233
算法, 216, 220, 229
读取数据, 221–223
扩展,240
体积数据, 215
W
wavefile.read(),367,386
wave 模块,64
getframerate(),296,297
getnchannels(),296
getsampwidth(),296
open(),296
readframes(),297
WAV 文件格式,64–65
创建,64
玩,69
写作,69
Z
zip() 方法,82
更多直截了当的书籍来自 

深度学习
视觉化方法
由 ANDREW GLASSNER 编著
768 页,$99.99
ISBN 978-1-7185-0072-3
全彩

递归的递归书
用 Python 和 JavaScript 赢得编程面试
由 AL SWEIGART 编著
328 页,$39.99
ISBN 978-1-7185-0202-4

科学家的 Python 工具
使用 Anaconda、JupyterLab 和 Python 科学库的入门
由 LEE VAUGHAN 编著
744 页,$49.99
ISBN 978-1-7185-0266-6

超简单 Python
给急躁程序员的地道 Python
由 JASON C. MCDONALD 编著
752 页,$59.99
ISBN 978-1-7185-0092-1

超越 Python 基础
编写清晰代码的最佳实践
由 AL SWEIGART 编著
384 页,$34.95
ISBN 978-1-59327-966-0

计算机是如何真正工作的
机器内部工作原理的动手指南
由 MATTHEW JUSTICE 编著
392 页,$39.95
ISBN 978-1-7185-0066-2








浙公网安备 33010602011771号