Python-真实世界指南-全-

Python 真实世界指南(全)

原文:zh.annas-archive.org/md5/62261c4279cd6736e08ff06f3d175c15

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

Image

如果你已经学习了 Python 编程的基础,那么你已经可以编写完整的程序来处理现实世界的任务。在《现实世界中的 Python》一书中,你将编写程序,帮助阿波罗 8 号赢得登月竞赛,协助克莱德·汤博发现冥王星,为火星探测器选择着陆点,定位系外行星,向朋友发送超级机密的消息,战斗巨型突变体,救援遇难水手,逃离行尸走肉等,所有这些都使用 Python 编程语言。在此过程中,你将应用强大的计算机视觉、自然语言处理和科学模块,如 OpenCV、NLTK、NumPy、pandas 和 matplotlib,以及其他多个包,这些工具旨在让你的计算机使用更加轻松便捷。

谁应该阅读本书?

你可以把这本书看作是一本面向大二学生的 Python 书籍。它不是一本编程基础教程,而是帮助你继续通过项目驱动的方式进行学习。这样,你就不需要浪费时间和空间重新学习你已经掌握的概念。我仍然会逐步解释每一个项目,并提供关于如何使用库和模块的详细说明,包括如何安装它们。

这些项目适合任何希望利用编程进行实验、测试理论、模拟自然现象或只是为了娱乐的人。在完成这些项目的过程中,你将提升对 Python 库和模块的理解,并学习到实用的快捷方式、有用的函数和有益的技巧。与其专注于孤立的模块化代码片段,这些项目教你如何构建完整的、能在现实应用中运行的程序,涉及实际的应用、数据集和问题。

为什么选择 Python?

Python 是一种高级的、解释型的通用编程语言。它是免费的,具有高度交互性,并且可以跨所有主要平台和微控制器(如树莓派)使用。Python 支持函数式编程和面向对象编程,并能够与许多其他编程语言(如 C++)编写的代码进行交互。

由于 Python 易于初学者上手,又对专家有用,它已经渗透到学校、大学、大公司、金融机构以及几乎所有科学领域。因此,Python 现在已成为机器学习、数据科学和人工智能应用中最流行的语言。

本书包含什么内容?

以下是本书各章的概述。你不需要按顺序完成它们,但我会在首次介绍新模块和技巧时,进行更详细的解释。

第一章:利用贝叶斯定理救助遇难的水手 使用贝叶斯概率高效地指挥海岸警卫队在 Python 岬的搜索和救援工作。通过此项目,掌握 OpenCV、NumPy 和 itertools 模块的使用。

第二章:利用文体学确认作者 使用自然语言处理技术判断是阿瑟·柯南·道尔还是赫伯特·乔治·威尔斯创作了小说《失落的世界》。获得 NLTK、matplotlib 和文体学技术(如停用词、词性、词汇丰富度和 Jaccard 相似性)的实践经验。

第三章:利用自然语言处理总结演讲 从互联网上抓取著名演讲,并自动生成要点总结。然后,将小说的文本转化为一个酷炫的广告或宣传展示。获得 BeautifulSoup、Requests、正则表达式、NLTK、Collections、wordcloud 和 matplotlib 的实践经验。

第四章:用书籍密码发送超级机密信息 通过数字化复现 Ken Follet 的畅销间谍小说《Rebecca 的钥匙》中使用的一次性密码方法,与朋友分享无法破解的密码。获得 Collections 模块的实践经验。

第五章:寻找冥王星 复现克莱德·汤博发现冥王星的闪烁比较仪,该仪器于 1930 年首次使用。然后,利用现代计算机视觉技术自动发现并跟踪如彗星和小行星等在星空背景下微弱的瞬时变化。获得 OpenCV 和 NumPy 的实践经验。

第六章: 赢得登月竞赛—阿波罗 8 号 冒险并帮助美国通过阿波罗 8 号赢得登月竞赛。绘制并执行巧妙的自由返回轨道,这一轨道使 NASA 决定提前一年登月,并有效地终结了苏联的太空计划。获得 turtle 模块的实践经验。

第七章:选择火星着陆点 根据实际的任务目标,选择火星着陆器的潜在着陆地点。在火星地图上显示候选地点,并附上相关的统计数据总结。获得 OpenCV、Python 图像库、NumPy 和 tkinter 的实践经验。

第八章:检测远距离系外行星 模拟一个系外行星经过其太阳前方的过程,绘制由此产生的相对亮度变化,并估算该行星的直径。最后,模拟新型詹姆斯·韦伯太空望远镜直接观察系外行星的过程,包括估算该行星的一天长度。使用 OpenCV、NumPy 和 matplotlib。

第九章:识别敌友 编写一个机器人守卫枪程序,通过视觉区分太空部队海军陆战队和邪恶突变体。获得 OpenCV、NumPy、playsound、pyttsxw 和 datetime 的实践经验。

第十章:通过面部识别限制访问权限 使用面部识别技术限制对安全实验室的访问。使用 OpenCV、NumPy、playsound、pyttsxw 和 datetime。

第十一章:创建互动式僵尸逃脱地图 构建一张人口密度地图,帮助电视剧 行尸走肉(The Walking Dead)中的幸存者逃离亚特兰大,前往美国西部的安全地带。通过该项目,学习 pandas、bokeh、holoviews 和 webbrowser。

第十二章:我们生活在计算机模拟中吗? 找出一种方法,让模拟生命体——也许是我们——发现自己生活在计算机模拟中。使用 turtle、统计学和 perf_counter。

每一章的结尾都会有至少一个练习或挑战项目。你可以在附录或网上找到这些练习项目的解决方案。这些不是唯一的解决方案,也不一定是最好的;你可能自己能找到更好的方案。

然而,对于挑战性项目,你将独立完成。这是沉浮自知的时刻,非常适合学习!我希望本书能激励你创造新的项目,把这些挑战性项目看作是你想象力沃土上的种子。

你可以从本书的官方网站 nostarch.com/real-world-python/ 下载本书的所有代码,包括练习项目的解决方案。你还可以在该网站上找到勘误表及其他更新信息。

编写这样一本书几乎不可能没有一些初步的错误。如果你发现了问题,请将其反馈给出版社,邮箱是 errata@nostarch.com。我们将把任何必要的更正加入勘误表,并在未来的印刷版本中更新,你将因此获得永恒的荣耀。

Python 版本、平台和集成开发环境(IDE)

我在本书中的所有项目都是在 Microsoft Windows 10 环境下使用 Python v3.7.2 构建的。如果你使用的是不同的操作系统,也没问题:我会在适当的地方为其他平台推荐兼容的模块。

本书中的代码示例来自于 Python IDLE 文本编辑器或交互式 shell。IDLE 代表 集成开发与学习环境(Integrated Development and Learning Environment)。它是一个 集成开发环境(IDE),并且增加了一个 L 字母,以便这个缩写也能指代 蒙提·派森(Monty Python)名声在外的 Eric Idle。交互式 shell,也叫做 解释器,是一个可以让你立即执行命令并测试代码的窗口,无需创建文件。

IDLE 有很多缺点,比如没有行号列,但它是免费的并且捆绑在 Python 中,所有人都可以使用它。你可以随意使用任何你喜欢的 IDE。常用的选择包括 Visual Studio Code、Atom、Geany(发音为“genie”)、PyCharm 和 Sublime Text。这些都支持多种操作系统,包括 Linux、macOS 和 Windows。另一个 IDE,PyScripter,仅支持 Windows。有关可用的 Python 编辑器和兼容平台的详细列表,请访问 wiki.python.org/moin/PythonEditors/

安装 Python

你可以选择直接在机器上安装 Python,或者通过发行版进行安装。要直接安装,请在www.python.org/downloads/查找适合你操作系统的安装说明。Linux 和 macOS 机器通常会预装 Python,但你可能想要升级该安装。随着每个新版本的发布,Python 会增加一些新功能,同时弃用一些旧功能,因此,如果你的版本早于 Python v3.6,我建议你进行升级。

Python 网站上的下载按钮(图 1)可能默认安装 32 位版本的 Python。

图片

图 1:Python.org的下载页面,包含适用于 Windows 平台的“简易按钮”

如果你想要 64 位版本,请向下滚动到特定版本的列表(图 2),点击具有相同版本号的链接。

图片

图 2:在Python.org下载页面上列出特定版本

点击特定版本会带你进入图 3 中所示的页面。在此页面中,点击 64 位可执行安装程序,启动安装向导。按照向导的指示操作,并选择默认建议。

图片

图 3:在Python.org上查看 Python 3.8.2 版本的文件列表

本书中的一些项目要求安装非标准的包,你需要单独安装这些包。虽然这并不困难,但你可以通过安装一个有效加载和管理数百个 Python 包的 Python 发行版来简化这一过程。可以把它当作一站式购物。这些发行版中的包管理器会自动找到并下载包的最新版本,包括所有依赖项。

Anaconda 是由 Continuum Analytics 提供的流行免费 Python 发行版。你可以从www.anaconda.com/下载它。另一个是 Enthought Canopy,虽然只有基础版本是免费的。你可以在www.enthought.com/product/canopy/找到它。无论你是单独安装 Python 及其包,还是通过发行版安装,你都应该能顺利完成书中的项目。

运行 Python

安装后,Python 应该会出现在操作系统的应用程序列表中。当你启动它时,shell 窗口应该会出现(如图 4 背景所示)。你可以使用这个交互式环境来运行和测试代码片段。但要编写较大的程序,你将使用文本编辑器,它可以让你保存代码,如图 4(前景)所示。

图片

图 4:本地 Python shell 窗口(背景)和文本编辑器(前景)

要在 IDLE 文本编辑器中创建新文件,点击文件新建文件。要打开现有文件,点击文件打开文件最近文件。在这里,你可以通过点击运行运行模块,或者在编辑窗口中点击后按 F5 来运行代码。注意,如果你选择了使用像 Anaconda 这样的包管理器或像 PyCharm 这样的 IDE,你的环境可能与图 4 不同。

你也可以通过在 PowerShell 或终端中输入程序名称来启动 Python 程序。你需要处于存放 Python 程序的目录中。例如,如果你没有从正确的目录启动 Windows PowerShell,你需要使用 cd 命令更改目录路径(参见图 5)。

图片

图 5:在 Windows PowerShell 中更改目录并运行 Python 程序

要了解更多内容,请参阅 pythonbasics.org/execute-python-scripts/

使用虚拟环境

最后,你可能想为每一章在单独的虚拟环境中安装依赖包。在 Python 中,虚拟环境是一个自包含的目录树,包括 Python 安装和若干附加包。当你安装了多个版本的 Python 时,它们非常有用,因为某些包可能与某个版本兼容,但与其他版本不兼容。此外,某些项目可能需要同一包的不同版本。保持这些安装的独立性可以防止兼容性问题。

本书中的项目不需要使用虚拟环境,如果你按照我的指示操作,你将全局安装所需的包。不过,如果你确实需要将包与操作系统隔离开来,可以考虑为每一章安装一个不同的虚拟环境(请参见 docs.python.org/3.8/library/venv.html#module-venvdocs.python.org/3/tutorial/venv.html)。

前进!

本书中的许多项目依赖于几百年历史的统计学和科学概念,这些概念手工操作时不太实用。但随着 1975 年个人计算机的问世,我们存储、处理和共享信息的能力已经增加了数个数量级。

在 20 万年的人类历史中,只有过去 45 年里的人们才有幸使用这种神奇的设备,实现那些曾经遥不可及的梦想。用莎士比亚的话说:“我们这些人。我们这群幸福的人。”

让我们充分利用这个机会。在接下来的页面中,你将轻松完成那些曾让过去的天才们感到困惑的任务。你将触及我们最近取得的一些惊人壮举的表面,甚至可能开始想象未来将会有的发现。

第一章:使用贝叶斯定理拯救海上遇难海员

图片

大约在 1740 年左右,一位名叫托马斯·贝叶斯的英国长老会牧师决定用数学证明上帝的存在。他的独特解决方案——现在被称为贝叶斯定理——成为了有史以来最成功的统计概念之一。但由于其繁琐的数学计算无法手动完成,这个定理在 200 年里几乎被忽视。直到现代计算机的发明,贝叶斯定理才发挥出它的全部潜力。如今,得益于我们快速的处理器,它已经成为数据科学和机器学习的重要组成部分。

因为贝叶斯定理为我们提供了一个数学上正确的方式来纳入新数据并重新计算概率估计,它几乎渗透到了所有人类活动中,从破解密码到预测总统选举结果,再到证明高胆固醇会导致心脏病发作。贝叶斯定理的应用列表足以填满这一章。但因为没有什么比拯救生命更重要,我们将专注于如何利用贝叶斯定理帮助救援失事海员。

在本章中,你将创建一个模拟游戏,用于海岸警卫队的搜救行动。玩家将使用贝叶斯定理来指导决策,以便尽快定位失踪的海员。在此过程中,你将开始使用流行的计算机视觉和数据科学工具,如开源计算机视觉库(OpenCV)和 NumPy。

贝叶斯定理

贝叶斯定理帮助研究人员根据新证据确定某事是否成立的概率。正如伟大的法国数学家拉普拉斯所说:“一个原因的概率——给定一个事件——与这个事件的概率——给定它的原因——成正比。”基本公式是

图片

其中,A是一个假设,B是数据。P(A/B)表示在给定B的情况下,A的概率。P(B/A)表示在给定A的情况下,B的概率。例如,假设我们知道某个癌症的检测并不总是准确,可能会出现假阳性,表示你得了癌症而实际上并没有。贝叶斯表达式为

图片

初始概率将基于临床研究。例如,1,000 名患癌症的人中,800 人可能会收到阳性检测结果,而 100 人可能会被误诊。根据疾病的发生率,一个人患癌症的总体概率可能仅为每 10,000 人中有 50 人。因此,如果患癌症的总体概率较低,而得到阳性检测结果的总体概率相对较高,那么在阳性检测结果下患癌症的概率就会降低。如果研究记录了不准确检测结果的频率,贝叶斯定理可以修正测量误差!

现在你已经看到了一个应用实例,请查看图 1-1,它展示了贝叶斯定理中各个术语的名称,以及它们如何与癌症例子相关。

Image

图 1-1:定义了术语并与癌症检测示例相关的贝叶斯定理

为了进一步说明,假设一位女士在家里丢失了她的阅读眼镜。她最后记得戴眼镜时是在书房。她去了书房并四处寻找。她没有找到眼镜,但看到了一个茶杯,并记得她去了厨房。此时,她必须做出选择:要么更加彻底地搜索书房,要么离开去厨房检查。她决定去厨房。她不知不觉中做出了一个贝叶斯决策。

她首先去了书房,因为她觉得在书房找到眼镜的成功概率最高。从贝叶斯的角度来看,最初在书房找到眼镜的概率被称为先验。经过简略的搜索后,她根据两个新获得的信息改变了决定:她没有轻易找到眼镜,而且她看到了茶杯。这代表了一个贝叶斯更新,其中随着更多证据的出现,新的后验估计(在图 1-1 中为P(A/B))被计算出来。

假设这位女士决定在搜索过程中使用贝叶斯定理。她会为眼镜在书房或厨房中的可能性以及她在这两个房间中搜索的有效性分配实际的概率。与其凭直觉做决定,她的选择现在建立在数学基础上,如果未来的搜索失败,这些决策可以持续更新。

图 1-2 展示了这位女士在分配了这些概率后的眼镜搜索过程。

Image

图 1-2:眼镜位置的初始概率和搜索有效性(左)与更新后的眼镜目标概率(右)

左侧图表示最初的情况;右侧图是根据贝叶斯定理更新后的图。最初,假设在书房找到眼镜的概率是 85%,在厨房找到眼镜的概率是 10%。其他可能的房间被赋予 1%的概率,因为贝叶斯定理无法更新为零的目标概率(而且总是有一个小概率她将眼镜落在其他房间)。

左图中每个斜杠后的数字代表搜索有效性概率(SEP)。SEP 是对你搜索一个区域的有效性的估计。由于这位女士目前只在书房进行了搜索,因此其他房间的搜索有效性概率为零。在贝叶斯更新后(发现茶杯),她可以根据搜索结果重新计算概率,更新后的概率如右图所示。现在厨房是最可能的搜索地点,但其他房间的概率也有所增加。

人类直觉告诉我们,如果某物不在我们认为它所在的地方,那么它出现在其他地方的概率就会增加。贝叶斯法则考虑到了这一点,因此眼镜出现在其他房间的概率也会增加。但只有在一开始就有它们可能出现在其他房间的情况时,这种情况才会发生。

用于计算眼镜在给定房间中概率的公式,考虑了搜索效果,公式为:

Image

其中 G 是眼镜在某房间中的概率,E 是搜索效果,P[prior] 是接收到新证据前的先验概率估计。

你可以通过将目标和搜索效果概率插入方程来获取眼镜在书房中的更新概率,具体如下:

Image

如你所见,贝叶斯法则背后的简单数学运算如果手工计算会变得很繁琐。幸运的是,我们生活在计算机的奇妙时代,所以我们可以让 Python 来处理这些无聊的计算工作!

项目 #1:搜索与救援

在这个项目中,你将编写一个 Python 程序,使用贝叶斯法则来寻找在 Python 岬失踪的独自渔民。作为该地区海岸警卫队搜索与救援行动的负责人,你已经采访了他的妻子,并确定了他最后一次被见到的位置,距离现在已经超过六小时。他通过无线电报告说他要弃船,但没人知道他是坐上了救生艇还是漂浮在海中。岬角周围的水域温暖,但如果他被浸泡在水中,大约 12 小时后他会发生体温过低。如果他穿着个人漂浮装置并且幸运的话,他可能能撑三天。

Python 岬的海洋洋流复杂多变(图 1-3),目前风从西南方向吹来。能见度良好,但波浪较为汹涌,使得人头较难被察觉。

Image

图 1-3:海洋洋流——Python 岬

在现实生活中,你的下一步行动是将你所掌握的所有信息输入海岸警卫队的搜索与救援最优规划系统(SAROPS)。该软件会考虑风、潮汐、洋流、物体是否漂浮在水面或在船上等因素。然后,它会生成矩形搜索区域,计算在每个区域内找到失踪水手的初步概率,并绘制出最有效的飞行路线。

对于这个项目,你将假设 SAROPS 已经确定了三个搜索区域。你需要做的就是编写应用贝叶斯法则的程序。你也有足够的资源来在一天内搜索这三个区域中的两个。你必须决定如何分配这些资源。这很有压力,但你有一个强大的助手帮助你:贝叶斯法则。

目标

创建一个搜索与救援游戏,利用贝叶斯法则帮助玩家决定如何进行搜索。

策略

寻找水手就像我们之前例子中寻找丢失的眼镜一样。你将从水手位置的初始目标概率开始,并根据搜索结果更新它们。如果你有效地搜索了一个区域,但什么也没找到,那么水手在另一个区域的概率将增加。

但就像在现实生活中一样,事情可能会有两种方式出错:你彻底搜索了一个区域,但仍然没有找到水手,或者你的搜索不顺利,浪费了一整天的努力。为了与搜索效果评分相匹配,在第一种情况下,你的 SEP 可能是 0.85,但水手在剩下的 15%未搜索的区域中。在第二种情况下,你的 SEP 是 0.2,而你已经有 80%的区域没有搜索!

你可以看到真正的指挥官面临的困境。你是凭直觉行事并忽略贝叶斯方法吗?你是坚持贝叶斯的纯粹冷逻辑,因为你认为这是最好的答案吗?还是你为了权衡和保护自己的职业和声誉,即使怀疑贝叶斯方法,也选择采用它?

为了帮助玩家,你将使用 OpenCV 库来构建一个与程序交互的界面。虽然界面可以是简单的,例如在命令行中构建的菜单,但你还需要一张岬角和搜索区域的地图。你将使用这张地图来显示水手最后已知的位置和找到时的位置。OpenCV 库是这个游戏的理想选择,因为它允许你显示图像,并添加绘图和文本。

安装 Python 库

OpenCV 是世界上最流行的计算机视觉库。计算机视觉 是一个深度学习领域,使机器能够像人类一样看到、识别和处理图像。OpenCV 最初是英特尔研究的一个项目,始于 1999 年,现在由 OpenCV 基金会维护,这是一个非营利性基金会,提供免费的软件。

OpenCV 是用 C++编写的,但也有其他语言的绑定,例如 Python 和 Java。尽管主要面向实时计算机视觉应用,OpenCV 也包括常见的图像处理工具,例如 Python 图像库中的工具。截止本书编写时,当前版本为 OpenCV 4.1。

OpenCV 需要数值计算库(NumPy)和科学计算库(SciPy)来执行 Python 中的数值和科学计算。OpenCV 将图像视为三维 NumPy 数组(图 1-4)。这使得与其他 Python 科学库的互操作性达到最大化。

Image

图 1-4:三通道彩色图像数组的视觉表示

OpenCV 将图像的属性存储为行、列和通道。对于图 1-4 中表示的图像,其“形状”将是一个三元素元组(4, 5, 3)。每一堆单元格,如 0-20-40 或 19-39-59,代表一个单独的像素。显示的数字是该像素的每个颜色通道的强度值。

由于本书中的许多项目需要像 NumPy 和 matplotlib 这样的科学计算 Python 库,因此现在是安装它们的好时机。

安装这些包的方法有很多。一种方法是使用 SciPy,它是一个用于科学和技术计算的开源 Python 库(详情见 scipy.org/index.html)。

另外,如果你打算在自己的时间进行大量数据分析和绘图,你可能希望下载并使用一个免费的 Python 发行版,如 Anaconda 或 Enthought Canopy,它们都可以在 Windows、Linux 和 macOS 上使用。这些发行版省去了你寻找和安装所有所需数据科学库(如 NumPy、SciPy 等)的麻烦。可以在 scipy.org/install.html 找到这些发行版的列表,并附有它们的网站链接。

使用 pip 安装 NumPy 和其他科学包

如果你想直接安装这些软件包,可以使用首选安装程序(pip),这是一种软件包管理系统,可以轻松安装基于 Python 的软件(详情见 docs.python.org/3/installing/)。对于 Windows 和 macOS,Python 3.4 及更高版本已预安装 pip。Linux 用户可能需要单独安装 pip。要安装或升级 pip,查看 pip.pypa.io/en/stable/installing/ 上的说明,或在线搜索有关如何在你特定操作系统上安装 pip 的指南。

我使用 pip 安装了科学计算包,参考了* scipy.org/install.html *上的说明。由于 matplotlib 需要多个依赖包,你也需要安装这些依赖包。对于 Windows 系统,可以在包含当前 Python 安装目录的文件夹内,使用 PowerShell(通过 SHIFT-右键点击启动)运行以下 Python 3 特定命令:

$ python -m pip install --user numpy scipy matplotlib ipython jupyter pandas sympy nose

如果你安装了 Python 2 和 3,请使用 python3 来代替 python。

为了验证 NumPy 是否已经安装并且可以在 OpenCV 中使用,请打开 Python shell 并输入以下内容:

>>> import numpy

如果没有看到错误,你就可以准备安装 OpenCV 了。

使用 pip 安装 OpenCV

你可以在 pypi.org/project/opencv-python/ 找到 OpenCV 的安装说明。要为标准桌面环境(Windows、macOS 以及几乎所有 GNU/Linux 发行版)安装 OpenCV,请在 PowerShell 或终端窗口中输入以下命令:

pip install opencv-contrib-python

或者

python -m pip install opencv-contrib-python

如果你安装了多个 Python 版本(如 2.7 和 3.7),你需要指定你想要使用的 Python 版本。

py -3.7 -m pip install --user opencv-contrib-python

如果你使用 Anaconda 作为发行版媒介,你可以运行以下命令:

conda install opencv

为了检查一切是否正确加载,请在 shell 中输入以下内容:

>>> import cv2

没有错误意味着一切顺利!如果你遇到错误,可以阅读故障排除列表,访问 pypi.org/project/opencv-python/

贝叶斯代码

你将在本节中编写的 bayes.py 程序模拟了在三个连续搜索区域中寻找失踪水手的过程。它将显示地图,打印用户可以选择的搜索菜单,随机选择一个水手的位置,如果找到水手,将显示该位置,或者执行贝叶斯更新,更新每个搜索区域找到水手的概率。你可以从 nostarch.com/real-world-python/ 下载代码和地图图像(cape_python.png)。

导入模块

清单 1-1 通过导入所需的模块并分配一些常量来启动 bayes.py 程序。我们将在实现代码时查看这些模块的功能。

bayes.py, part 1
import sys
import random
import itertools
import numpy as np
import cv2 as cv

MAP_FILE = 'cape_python.png'

SA1_CORNERS = (130, 265, 180, 315)  # (UL-X, UL-Y, LR-X, LR-Y)
SA2_CORNERS = (80, 255, 130, 305)  # (UL-X, UL-Y, LR-X, LR-Y)
SA3_CORNERS = (105, 205, 155, 255)  # (UL-X, UL-Y, LR-X, LR-Y)

清单 1-1:导入模块并分配在 bayes.py 程序中使用的常量

当将模块导入到程序中时,推荐的顺序是首先导入 Python 标准库模块,其次是第三方模块,最后是用户定义的模块。sys 模块包含操作系统的命令,如退出。random 模块允许你生成伪随机数。itertools 模块帮助你进行循环。最后,numpy 和 cv2 分别导入 NumPy 和 OpenCV。你还可以为这些模块分配简短的名称(np,cv),以减少后续的输入。

接下来,分配一些常量。根据 PEP8 Python 风格指南 (www.python.org/dev/peps/pep-0008/),常量名称应全部大写。这并不意味着这些变量是不可变的,但它确实提醒其他开发者不应更改这些变量。

你将用于虚构的“海角 Python”区域的地图是一个名为cape_python.png的图像文件(图 1-5)。将此图像文件分配给一个名为 MAP_FILE 的常量变量。

Image

图 1-5:海角 Python 的灰度基础地图(cape_python.png)

你将把搜索区域作为矩形绘制在图像上。OpenCV 将通过角点的像素数定义每个矩形,因此要为这四个点分配一个元组变量。所需的顺序是左上角 x,左上角 y,右下角 x,右下角 y。在变量名中使用 SA 来表示“搜索区域”。

定义搜索类

是面向对象编程(OOP)中的一种数据类型。OOP 是功能/过程编程的另一种方法。它特别适用于大型复杂的程序,因为它产生的代码更易于更新、维护和重用,同时减少了代码重复。OOP 围绕着被称为对象的数据结构构建,这些对象由数据、方法及其之间的交互组成。因此,它非常适合游戏程序,游戏程序通常使用交互的对象,如宇宙飞船和小行星。

类是一个模板,可以用来创建多个对象。例如,你可以有一个类来构建二战游戏中的战列舰。每个战列舰将继承某些一致的特性,如吨位、巡航速度、燃料水平、损伤程度、武器等。你还可以给每个战列舰对象赋予独特的特性,比如不同的名字。一旦创建,或者说实例化,每个战列舰的个体特性将开始分化,具体取决于船只燃烧了多少燃料、受到多少伤害、使用了多少弹药等等。

bayes.py中,你将使用一个类作为模板,创建一个搜索和救援任务,允许三个搜索区域。列表 1-2 定义了 Search 类,它将作为你的游戏蓝图。

bayes.py, part 2
class Search():
    """Bayesian Search & Rescue game with 3 search areas."""

    def __init__(self, name):
        self.name = name

     ➊ self.img = cv.imread(MAP_FILE, cv.IMREAD_COLOR)
        if self.img is None:
            print('Could not load map file {}'.format(MAP_FILE),
                  file=sys.stderr)
            sys.exit(1)

     ➋ self.area_actual = 0 
        self.sailor_actual = [0, 0] # As "local" coords within search area

     ➌ self.sa1 = self.img[SA1_CORNERS[1] : SA1_CORNERS[3], 
                            SA1_CORNERS[0] : SA1_CORNERS[2]]

        self.sa2 = self.img[SA2_CORNERS[1] : SA2_CORNERS[3], 
                            SA2_CORNERS[0] : SA2_CORNERS[2]]

        self.sa3 = self.img[SA3_CORNERS[1] : SA3_CORNERS[3], 
                            SA3_CORNERS[0] : SA3_CORNERS[2]]

     ➍ self.p1 = 0.2
        self.p2 = 0.5
        self.p3 = 0.3

        self.sep1 = 0
        self.sep2 = 0
        self.sep3 = 0

列表 1-2:定义 Search 类和__init__()方法

从定义一个名为 Search 的类开始。根据 PEP8 规范,类名的首字母应该大写。

接下来,定义一个方法,用于为你的对象设置初始属性值。在面向对象编程(OOP)中,属性是与对象相关联的命名值。如果你的对象是一个人,属性可能是他们的体重或眼睛颜色。方法是属性的一种,它们实际上是函数,在运行时会传入对实例的引用。__init__()方法是一个特殊的内建函数,Python 会在创建新对象时自动调用它。它绑定每个新创建的类实例的属性。在这个例子中,你传递两个参数:self 和你想为对象使用的名字。

self 参数是对正在创建的类实例,或被调用方法的实例的引用,技术上称为上下文实例。例如,如果你创建了一个名为密苏里的战列舰,那么对于该对象,self 就成为了 Missouri,你可以通过点表示法调用该对象的方法,例如执行一个大炮开火的方法:Missouri.fire_big_guns()。通过在实例化时为对象赋予独特的名字,每个对象的属性范围都与其他对象分开。这样,一个战列舰所受的伤害就不会与其他舰船共享。

init() 方法下列出对象的所有初始属性值是一种良好的实践。这样,用户可以查看对象的所有关键属性,这些属性将在后续的各个方法中使用,并且你的代码将更具可读性和可更新性。在清单 1-2 中,这些是 self 属性,比如 self.name。

分配给 self 的属性也将像过程式编程中的全局变量一样行为。类中的方法可以直接访问这些属性,而无需传递参数。因为这些属性被“屏蔽”在的范畴下,它们的使用不像真正的全局变量那样受到限制,后者是在全局作用域中赋值并在单个函数的局部作用域中修改。

使用 OpenCV 的 imread() 方法 ➊ 将 MAP_FILE 变量分配给 self.img 属性。MAP_FILE 图像是灰度图像,但在搜索过程中你可能需要为其添加一些颜色。因此,使用 ImreadFlag,如 cv.IMREAD_COLOR,来以彩色模式加载图像。这将为你设置三个颜色通道(B、G、R),以便稍后使用。

如果图像文件不存在(或用户输入了错误的文件名),OpenCV 将抛出一个令人困惑的错误(NoneType 对象不可下标)。为了解决这个问题,使用条件语句检查 self.img 是否为 None。如果是,打印错误信息,然后使用 sys 模块退出程序。传递退出代码 1 表示程序以错误结束。设置 file=stderr 会导致在 Python 解释器窗口中使用标准的“错误红色”文本颜色,但在其他窗口如 PowerShell 中不会出现此颜色。

接下来,为找到的水手的实际位置分配两个属性。第一个将保存搜索区域的编号 ➋,第二个将保存精确的 (x, y) 坐标。分配的值现在将是占位符。稍后,你将定义一个方法来随机选择最终的值。请注意,使用列表作为位置坐标,因为你需要一个可变容器。

地图图像被加载为数组。数组是一个固定大小的、包含相同类型对象的集合。数组是内存高效的容器,提供快速的数值操作,并有效利用计算机的地址逻辑。使得 NumPy 特别强大的一个概念是向量化,它用更高效的数组表达式替代了显式的循环。基本上,操作是在整个数组上进行,而不是单独对其元素进行操作。使用 NumPy 时,内部循环会被导向高效的 C 和 Fortran 函数,这些函数比标准的 Python 技术更快。

为了便于在搜索区域内使用局部坐标,您可以从数组 ➌ 中创建一个子数组。请注意,这是通过索引实现的。首先提供从左上角的y值到右下角的y,然后是从左上角的x到右下角的x。这是 NumPy 的一个特性,刚开始可能需要适应,尤其是大多数人习惯于在笛卡尔坐标中x排在y之前。

对下两个搜索区域重复此过程,然后设置每个搜索区域中找到水手的前期搜索概率 ➍。在现实中,这些数据来自 SAROPS 程序。当然,p1 代表区域 1,p2 代表区域 2,以此类推。最后使用占位符属性表示 SEP。

绘制地图

在 Search 类中,您将使用 OpenCV 中的功能创建一个方法,显示基础地图。该地图将包括搜索区域、比例尺以及水手的最后已知位置(图 1-6)。

Image

图 1-6:bayes.py 的初始游戏屏幕(基础地图)

清单 1-3 定义了 draw_map()方法,用于显示初始地图。

bayes.py, part 3
def draw_map(self, last_known):
    """Display basemap with scale, last known xy location, search areas."""
    cv.line(self.img, (20, 370), (70, 370), (0, 0, 0), 2)
    cv.putText(self.img, '0', (8, 370), cv.FONT_HERSHEY_PLAIN, 1, (0, 0, 0))
    cv.putText(self.img, '50 Nautical Miles', (71, 370),
               cv.FONT_HERSHEY_PLAIN, 1, (0, 0, 0))

➊ cv.rectangle(self.img, (SA1_CORNERS[0], SA1_CORNERS[1]),
                           (SA1_CORNERS[2], SA1_CORNERS[3]), (0, 0, 0), 1)
   cv.putText(self.img, '1',
              (SA1_CORNERS[0] + 3, SA1_CORNERS[1] + 15),
              cv.FONT_HERSHEY_PLAIN, 1, 0)
   cv.rectangle(self.img, (SA2_CORNERS[0], SA2_CORNERS[1]),
                (SA2_CORNERS[2], SA2_CORNERS[3]), (0, 0, 0), 1)
   cv.putText(self.img, '2',
              (SA2_CORNERS[0] + 3, SA2_CORNERS[1] + 15),
              cv.FONT_HERSHEY_PLAIN, 1, 0)
   cv.rectangle(self.img, (SA3_CORNERS[0], SA3_CORNERS[1]),
                (SA3_CORNERS[2], SA3_CORNERS[3]), (0, 0, 0), 1)
   cv.putText(self.img, '3',
              (SA3_CORNERS[0] + 3, SA3_CORNERS[1] + 15),
              cv.FONT_HERSHEY_PLAIN, 1, 0)

➋ cv.putText(self.img, '+', (last_known),
              cv.FONT_HERSHEY_PLAIN, 1, (0, 0, 255))
   cv.putText(self.img, '+ = Last Known Position', (274, 355),
              cv.FONT_HERSHEY_PLAIN, 1, (0, 0, 255))
   cv.putText(self.img, '* = Actual Position', (275, 370),
              cv.FONT_HERSHEY_PLAIN, 1, (255, 0, 0))

➌ cv.imshow('Search Area', self.img)
   cv.moveWindow('Search Area', 750, 10)
   cv.waitKey(500)

清单 1-3:定义一个显示基础地图的方法

使用 self 和水手的最后已知坐标(last_known)作为两个参数,定义 draw_map()方法。然后使用 OpenCV 的 line()方法绘制比例尺。传递基础地图图像、左上角和右下角的(x, y)坐标元组、线条颜色元组以及线条宽度作为参数。

使用 putText()方法为比例尺添加注释。传递基础地图图像的属性,然后是实际文本,接着是文本左下角的坐标元组。然后添加字体名称、字体缩放和颜色元组。

现在为第一个搜索区域 ➊ 绘制一个矩形。像往常一样,传递基础地图图像、表示矩形四个角的变量,最后是颜色元组和线条宽度。再次使用 putText()方法在左上角内放置搜索区域编号。对于搜索区域 2 和 3,重复这些步骤。

使用 putText()方法在水手的最后已知位置 ➋ 处放置一个“+”。请注意,符号是红色的,但颜色元组为(0, 0, 255),而不是(255, 0, 0)。这是因为 OpenCV 使用蓝绿红(BGR)颜色格式,而不是更常见的红绿蓝(RGB)格式。

接下来,添加描述水手最后已知位置和实际位置符号的图例文本,这些符号应当在玩家的搜索中找到水手时显示。实际位置标记使用蓝色。

完成该方法,通过使用 OpenCV 的 imshow()方法 ➌ 显示基础地图。传递给它窗口的标题和图像。

为了尽可能避免基础地图和解释器窗口相互干扰,强制让基础地图显示在显示器的右上角(你可能需要根据你的机器调整坐标)。使用 OpenCV 的 moveWindow()方法,并传递窗口的名称‘Search Area’和左上角的坐标。

最后使用 waitKey()方法,它在渲染图像到窗口时引入* n *毫秒的延迟。传递 500,表示 500 毫秒。这应该会导致游戏菜单在基础地图之后半秒出现。

选择水手的最终位置

列表 1-4 定义了一个方法来随机选择水手的实际位置。为了方便起见,坐标最初在一个搜索区域的子数组内找到,然后转换为全局坐标,参照完整的基础地图图像。这个方法可行是因为所有搜索区域的大小和形状相同,因此可以使用相同的内部坐标。

bayes.py, part 4
def sailor_final_location(self, num_search_areas):
    """Return the actual x,y location of the missing sailor."""   
    # Find sailor coordinates with respect to any Search Area subarray.
    self.sailor_actual[0] = np.random.choice(self.sa1.shape[1], 1)
    self.sailor_actual[1] = np.random.choice(self.sa1.shape[0], 1)

➊ area = int(random.triangular(1, num_search_areas + 1))

    if area == 1:
        x = self.sailor_actual[0] + SA1_CORNERS[0]
        y = self.sailor_actual[1] + SA1_CORNERS[1]
     ➋ self.area_actual = 1
    elif area == 2:
        x = self.sailor_actual[0] + SA2_CORNERS[0]
        y = self.sailor_actual[1] + SA2_CORNERS[1]
        self.area_actual = 2
    elif area == 3:
        x = self.sailor_actual[0] + SA3_CORNERS[0]
        y = self.sailor_actual[1] + SA3_CORNERS[1]
        self.area_actual = 3
    return x, y

列表 1-4:定义一个方法来随机选择水手的实际位置

定义 sailor_final_location()方法,接受两个参数:self 和正在使用的搜索区域数量。对于 self.sailor_actual 列表中的第一个(x)坐标,使用 NumPy 的 random.choice()方法从区域 1 的子数组中选择一个值。记住,搜索区域是从更大的图像数组中复制出来的 NumPy 数组。因为搜索区域/子数组的大小相同,所以从一个区域中选择的坐标将适用于所有区域。

你可以通过以下方式获取数组的坐标:

>>> print(np.shape(self.SA1))
(50, 50, 3)

NumPy 数组的 shape 属性必须是一个元组,其元素个数与数组的维度数相同。并且记住,在 OpenCV 中的数组,元组中元素的顺序是行、列,再到通道。

现有的每个搜索区域都是一个大小为 50×50 像素的三维数组。因此,xy的内部坐标范围为 0 到 49。使用 random.choice()选择[0]意味着使用的是行,最后一个参数 1 选择单个元素。选择[1]则是从列中选择。

random.choice()生成的坐标范围是 0 到 49。为了将这些坐标与完整的基础地图图像结合使用,你首先需要选择一个搜索区域➊。使用在程序开始时导入的 random 模块来实现这一点。根据 SAROPS 输出,水手最有可能位于区域 2,其次是区域 3。由于这些初步的目标概率是基于猜测的,可能不会直接对应现实,因此使用三角分布来选择包含水手的区域。参数是低端和高端。如果没有提供最终的模式参数,默认的模式是低端和高端之间的中点。这将与 SAROPS 结果一致,因为区域 2 会被最常选择。

请注意,您在方法中使用的是局部变量 area,而不是 self.area 属性,因为没有必要与其他方法共享此变量。

要在基础地图上绘制水手的位置,您需要添加适当的搜索区域角点坐标。这将“本地”搜索区域坐标转换为“全局”基础地图图像的坐标。您还需要跟踪搜索区域,因此请更新self.area_actual属性 ➋。

对搜索区域 2 和 3 重复这些步骤,然后返回 (x, y) 坐标。

注意

在现实生活中,水手会漂移,随着每次搜索,他进入区域 3 的几率会增加。然而,我选择使用静态位置,以便尽可能清楚地展示贝叶斯定理背后的逻辑。因此,这种场景更像是在搜索一艘沉没的潜艇。

计算搜索有效性并执行搜索

在现实生活中,天气和机械问题可能导致低搜索有效性评分。因此,每次搜索的策略将是生成搜索区域内所有可能位置的列表,打乱列表,然后根据搜索有效性值进行抽样。因为 SEP 永远不会是 1.0,如果您只是从列表的开始或末尾进行抽样——不打乱列表——您将永远无法访问其“尾部”中隐藏的坐标。

列表 1-5,仍然在 Search 类中,定义了一个方法来随机计算给定搜索的有效性,并定义了另一个方法来执行搜索。

bayes.py, part 5
   def calc_search_effectiveness(self):
       """Set decimal search effectiveness value per search area."""
       self.sep1 = random.uniform(0.2, 0.9)
       self.sep2 = random.uniform(0.2, 0.9)
       self.sep3 = random.uniform(0.2, 0.9)

➊ def conduct_search(self, area_num, area_array, effectiveness_prob):
       """Return search results and list of searched coordinates."""
       local_y_range = range(area_array.shape[0])
       local_x_range = range(area_array.shape[1])
     ➋ coords = list(itertools.product(local_x_range, local_y_range))
       random.shuffle(coords)
       coords = coords[:int((len(coords) * effectiveness_prob))]
     ➌ loc_actual = (self.sailor_actual[0], self.sailor_actual[1])
       if area_num == self.area_actual and loc_actual in coords:
           return 'Found in Area {}.'.format(area_num), coords    
       else:
           return 'Not Found', coords

列表 1-5:定义方法以随机选择搜索有效性并执行搜索

首先定义搜索有效性方法。唯一需要的参数是 self。对于每个搜索有效性属性,如 E1,随机选择一个值,范围在 0.2 到 0.9 之间。这些是任意值,意味着您总是会至少搜索该区域的 20%,但不会超过 90%。

您可以认为三个搜索区域的搜索有效性属性是相关的。例如,雾霾可能会影响所有三个区域,导致结果普遍较差。另一方面,您的某些直升机可能配备红外成像设备,表现会更好。无论如何,像这里一样将它们设为独立,可以使模拟更加动态。

接下来,定义一个方法来执行搜索 ➊。必要的参数是对象本身、区域编号(由用户选择)、所选区域的子数组和随机选择的搜索有效性值。

您需要生成一个给定搜索区域内所有坐标的列表。命名一个变量 local_y_range,并根据数组形状元组中的第一个索引(表示行)为其分配一个范围。对 x_range 值重复此操作。

为了生成搜索区域内所有坐标的列表,使用 itertools 模块 ➋。该模块是 Python 标准库中的一组函数,用于创建高效的迭代器以便进行循环。product() 函数返回给定序列的所有带重复的排列组合。在此案例中,你是在寻找搜索区域内所有可能的 xy 的组合。要查看其实际操作,在终端中输入以下内容:

>>> import itertools
>>> x_range = [1, 2, 3]
>>> y_range = [4, 5, 6]
>>> coords = list(itertools.product(x_range, y_range))
>>> coords
[(1, 4), (1, 5), (1, 6), (2, 4), (2, 5), (2, 6), (3, 4), (3, 5), (3, 6)]

如你所见,coords 列表包含了 x_range 和 y_range 列表中所有可能的配对组合。

接下来,打乱坐标列表。这是为了避免每次搜索时都从列表的同一端开始。下一行中,使用索引切片根据搜索有效性概率来修剪列表。例如,0.3 的低搜索有效性意味着列表中仅包含该区域三分之一的可能位置。由于你将根据这个列表检查水手的实际位置,因此实际上你会将该区域的三分之二“未搜索”。

分配一个局部变量 loc_actual 来保存水手的实际位置 ➌。然后使用条件语句检查水手是否被找到。如果用户选择了正确的搜索区域,并且打乱和修剪后的 coords 列表包含水手的 (x, y) 位置,则返回一条字符串,表示水手已找到,以及 coords 列表。否则,返回一条字符串,表示水手未找到,并附上 coords 列表。

应用贝叶斯法则并绘制菜单

列表 1-6,仍然是在 Search 类中,定义了一个方法和一个函数。revise_target_probs() 方法使用贝叶斯法则来更新目标概率,这些目标概率表示在每个搜索区域内找到水手的概率。draw_menu() 函数在 Search 类外部定义,显示一个菜单,该菜单将作为图形用户界面(GUI)来运行游戏。

bayes.py, part 6
    def revise_target_probs(self):
        """Update area target probabilities based on search effectiveness.""" 
        denom = self.p1 * (1 - self.sep1) + self.p2 * (1 - self.sep2) \
                + self.p3 * (1 - self.sep3)
        self.p1 = self.p1 * (1 - self.sep1) / denom
        self.p2 = self.p2 * (1 - self.sep2) / denom
        self.p3 = self.p3 * (1 - self.sep3) / denom

def draw_menu(search_num):
    """Print menu of choices for conducting area searches."""
    print('\nSearch {}'.format(search_num))
    print(
        """
        Choose next areas to search:

        0 - Quit
        1 - Search Area 1 twice
        2 - Search Area 2 twice
        3 - Search Area 3 twice
        4 - Search Areas 1 & 2
        5 - Search Areas 1 & 3
        6 - Search Areas 2 & 3
        7 - Start Over
        """
        )

列表 1-6:在 Python Shell 中定义应用贝叶斯法则和绘制菜单的方法

定义 revise_target_probs() 方法来更新水手在每个搜索区域内的概率。它的唯一参数是 self。

为了方便起见,将贝叶斯方程分成两部分,从分母开始。你需要将前一个目标概率与当前的搜索有效性值相乘(请参阅第 5 页以复习这一过程)。

计算出分母后,使用它来完成贝叶斯方程。在面向对象编程(OOP)中,你不需要返回任何东西。你可以直接在方法中更新属性,就像它是过程式编程中的一个全局变量一样。

接下来,在全局作用域中定义 draw_menu() 函数来绘制菜单。它的唯一参数是正在进行的搜索编号。由于此函数没有“self”参数,因此不必将其包括在类定义中,尽管这也是一个有效的选项。

开始时打印搜索次数。你需要这个来跟踪是否已经在规定次数的搜索中找到水手,我们目前设置的搜索次数为 3 次。

使用三引号和 print() 函数显示菜单。注意,用户将有选择将两个搜索小组分配到同一区域,或将它们分配到两个不同区域的选项。

定义 main() 函数

现在你已经完成了 Search 类的学习,准备好将所有这些属性和方法投入实际应用了!清单 1-7 开始定义 main() 函数,用于运行程序。

bayes.py, part 7 
def main():    
    app = Search('Cape_Python')
    app.draw_map(last_known=(160, 290))
    sailor_x, sailor_y = app.sailor_final_location(num_search_areas=3)
    print("-" * 65)
    print("\nInitial Target (P) Probabilities:")
    print("P1 = {:.3f}, P2 = {:.3f}, P3 = {:.3f}".format(app.p1, app.p2, app.p3))
    search_num = 1

清单 1-7:定义 main() 函数的开始部分,用于运行程序

main() 函数不需要任何参数。首先,使用 Search 类创建一个名为 app 的游戏应用。将该对象命名为 Cape_Python。

接下来,调用显示地图的方法。传递给它水手的最后已知位置,作为一个 (x, y) 坐标的元组。注意使用关键字参数 last_known=(160, 290),以提高清晰度。

现在,通过调用相应的方法并传递搜索区域的数量,获取水手的 xy 位置。然后打印初始目标概率,或者先验概率,这些是你的海岸警卫队下属通过蒙特卡洛模拟计算的,而不是通过贝叶斯规则得出的。最后,命名一个变量 search_num,并将其值设置为 1。这个变量将跟踪你已经进行了多少次搜索。

评估菜单选择

清单 1-8 开始了 while 循环,用于在 main() 中运行游戏。在这个循环中,玩家评估并选择菜单选项。选项包括对单个区域进行两次搜索、将搜索工作分配到两个区域、重新开始游戏和退出游戏。请注意,玩家可以进行任意次数的搜索来找到水手;我们目前设置的三天限制并没有“硬编码”到游戏中。

bayes.py, part 8 
while True:
    app.calc_search_effectiveness()
    draw_menu(search_num)
    choice = input("Choice: ")

    if choice == "0":
        sys.exit()
➊ elif choice == "1":
       results_1, coords_1 = app.conduct_search(1, app.sa1, app.sep1)
       results_2, coords_2 = app.conduct_search(1, app.sa1, app.sep1)
    ➋ app.sep1 = (len(set(coords_1 + coords_2))) / (len(app.sa1)**2)
       app.sep2 = 0
       app.sep3 = 0

    elif choice == "2":
        results_1, coords_1 = app.conduct_search(2, app.sa2, app.sep2)
        results_2, coords_2 = app.conduct_search(2, app.sa2, app.sep2)
        app.sep1 = 0
        app.sep2 = (len(set(coords_1 + coords_2))) / (len(app.sa2)**2)
        app.sep3 = 0

    elif choice == "3":
        results_1, coords_1 = app.conduct_search(3, app.sa3, app.sep3)
        results_2, coords_2 = app.conduct_search(3, app.sa3, app.sep3)
        app.sep1 = 0
        app.sep2 = 0
        app.sep3 = (len(set(coords_1 + coords_2))) / (len(app.sa3)**2)

➌ elif choice == "4":
       results_1, coords_1 = app.conduct_search(1, app.sa1, app.sep1)
       results_2, coords_2 = app.conduct_search(2, app.sa2, app.sep2)
       app.sep3 = 0

   elif choice == "5":
       results_1, coords_1 = app.conduct_search(1, app.sa1, app.sep1)
       results_2, coords_2 = app.conduct_search(3, app.sa3, app.sep3)
       app.sep2 = 0

   elif choice == "6":
       results_1, coords_1 = app.conduct_search(2, app.sa2, app.sep2)
       results_2, coords_2 = app.conduct_search(3, app.sa3, app.sep3)
       app.sep1 = 0

➍ elif choice == "7":
       main()

   else:
       print("\nSorry, but that isn't a valid choice.", file=sys.stderr)
       continue

清单 1-8:使用循环来评估菜单选择并运行游戏

启动一个 while 循环,直到用户选择退出。立即使用点符号调用计算搜索效果的方法。然后调用显示游戏菜单的函数,并将搜索次数传递给它。最后,通过使用 input() 函数要求用户做出选择,完成准备阶段。

玩家选择将通过一系列条件语句进行评估。如果选择 0,则退出游戏。退出操作使用了你在程序开头导入的 sys 模块。

如果玩家选择了 1、2 或 3,意味着他们希望将两个搜索队伍派往对应编号的区域。你需要调用 conduct_search() 方法两次,生成两组结果和坐标 ➊。这里的难点是确定总体的 SEP,因为每次搜索都有自己的 SEP。为此,将两个 coords 列表合并,并将结果转换为集合以去除重复项 ➋。获取集合的长度,然后除以 50×50 搜索区域中的像素数量。由于你没有搜索其他区域,将它们的 SEP 设置为 0。

重复并调整前面的代码以适应搜索区域 2 和 3。使用 elif 语句,因为每次循环只有一个有效的菜单选择。这比使用额外的 if 语句更高效,因为所有在响应为真之后的 elif 语句都会被跳过。

如果玩家选择了 4、5 或 6,意味着他们希望将队伍分配到两个区域之间。在这种情况下,不需要重新计算 SEP ➌。

如果玩家找到了水手并且想要重新开始或仅仅想重新启动游戏,调用 main() 函数 ➍。这将重置游戏并清除地图。

如果玩家做出了无效选择,例如“Bob”,通过消息提示他们并使用 continue 跳回循环的起始位置,重新请求玩家的选择。

完成并调用 main()

列表 1-9,仍在 while 循环中,完成了 main() 函数并调用它来运行程序。

bayes.py, part 9 
        app.revise_target_probs()  # Use Bayes' rule to update target probs.

        print("\nSearch {} Results 1 = {}"
              .format(search_num, results_1), file=sys.stderr)
        print("Search {} Results 2 = {}\n"
              .format(search_num, results_2), file=sys.stderr)
        print("Search {} Effectiveness (E):".format(search_num))
        print("E1 = {:.3f}, E2 = {:.3f}, E3 = {:.3f}"
              .format(app.sep1, app.sep2, app.sep3))

     ➊ if results_1 == 'Not Found' and results_2 == 'Not Found':
            print("\nNew Target Probabilities (P) for Search {}:"
                  .format(search_num + 1))
            print("P1 = {:.3f}, P2 = {:.3f}, P3 = {:.3f}"
                  .format(app.p1, app.p2, app.p3))
        else:
            cv.circle(app.img, (sailor_x, sailor_y), 3, (255, 0, 0), -1)
         ➋ cv.imshow('Search Area', app.img)
            cv.waitKey(1500)
            main()
        search_num += 1

if __name__ == '__main__':
    main()

列表 1-9:完成并调用 main() 函数

调用 revise_target_probs() 方法来应用贝叶斯定理,并根据搜索结果重新计算水手位于每个搜索区域的概率。接下来,在命令行中显示搜索结果和搜索有效性概率。

如果两次搜索的结果都是负面,显示更新后的目标概率,玩家将用这些概率来指导下一次搜索 ➊。否则,显示水手在地图上的位置。使用 OpenCV 绘制圆圈,并将方法传入基础地图图像、水手的 (x, y) 元组作为圆心、半径(像素)、颜色和 –1 的厚度。负厚度值将填充圆圈并给圆圈上色。

通过使用类似于 列表 1-3 的代码来展示基础地图,完成 main() ➋。将 waitKey() 方法传入 1500,显示水手的实际位置 1.5 秒后,游戏会调用 main() 并自动重置。在循环结束时,将搜索次数变量加 1。你希望在循环后进行此操作,以便无效选择不被算作一次搜索。

在全局空间中,应用可以让程序作为模块导入或独立运行的代码。name 变量是一个内置变量,用于判断程序是自主运行还是被导入到另一个程序中。如果你直接运行这个程序,name 会被设置为 main,if 语句的条件得到满足,main() 会被自动调用。如果程序被导入,则直到显式调用,main() 函数才会运行。

游戏玩法

要玩这个游戏,请在文本编辑器中选择运行运行模块,或者直接按 F5。 图 1-7 和 1-8 显示了最终的游戏屏幕,其中展示了第一次搜索成功的结果。

图像

图 1-7:带有成功搜索结果的 Python 解释器窗口

图像

图 1-8:成功搜索结果的基础地图图像

在这个示例搜索中,玩家选择将两个搜索都提交到区域 2,该区域最初有 50% 的概率包含水手。第一次搜索没有成功,但第二次搜索找到了水手。请注意,搜索的效果仅比 50% 略好。这意味着第一次搜索找到水手的概率仅为四分之一(0.5 × 0.521 = 0.260)。尽管做出了明智的选择,玩家最终仍然需要依赖一点运气!

玩这个游戏时,尽量沉浸在情境中。你的决定将决定一个人的生死,而且你没有太多时间。如果水手漂浮在水面上,你只有三次机会来猜对。请明智地使用它们!

根据游戏开始时的目标概率,水手最有可能在区域 2,其次是区域 3。因此,一个好的初步策略是要么对区域 2 进行两次搜索(菜单选项 2),要么同时搜索区域 2 和区域 3(菜单选项 6)。你需要密切关注搜索效果输出。如果某个区域的效果得分很高,意味着该区域已被彻底搜索过,那么你可能想在游戏的剩余部分将精力集中在其他地方。

以下输出代表了作为决策者可能遇到的最糟糕的情况之一:

Search 2 Results 1 = Not Found
Search 2 Results 2 = Not Found

Search 2 Effectiveness (E):
E1 = 0.000, E2 = 0.234, E3 = 0.610

New Target Probabilities (P) for Search 3:
P1 = 0.382, P2 = 0.395, P3 = 0.223

在搜索 2 之后,只剩下最后一次搜索,目标概率如此相似,以至于几乎无法提供有用的指导,帮助你决定接下来该搜索哪里。在这种情况下,最好的做法是将搜索分配到两个区域之间,并寄希望于好运。

玩几次游戏,通过盲目地按初始概率的顺序搜索区域,先加倍搜索区域 2,再是区域 3,然后是区域 1。接下来,严格遵循贝叶斯结果,总是加倍搜索当前目标概率最高的区域。然后,尝试将搜索分配给具有两个最高概率的区域。之后,让自己的直觉发挥作用,在感觉合适时超越贝叶斯法则。正如你能想象的那样,随着搜索区域和搜索天数的增加,人类的直觉很快会感到不堪重负。

总结

本章介绍了贝叶斯法则,这是一条简单的统计定理,在我们现代世界中有着广泛的应用。你编写了一个程序,利用贝叶斯法则将新的信息——即搜索效果的估计——应用于更新在每个被搜索区域中找到失踪水手的概率。

你还加载并使用了多个科学包,如 NumPy 和 OpenCV,它们将在本书中贯穿实现。同时,你还应用了 Python 标准库中的有用模块,如 itertools、sys 和 random。

进一步阅读

《不会消逝的理论:贝叶斯法则如何破解密码、追踪俄罗斯潜艇,并从两百年的争议中脱颖而出(耶鲁大学出版社,2011 年),由 Sharon Bertsch McGrayne 编著,回顾了贝叶斯法则的发现与争议历史。附录中包括了贝叶斯法则的几个应用实例,其中一个启发了本章使用的失踪水手场景。

NumPy 的主要文档来源是 docs.scipy.org/doc/

挑战项目:更智能的搜索

当前的 bayes.py 程序将所有坐标放入搜索区域的列表中并进行随机打乱。之后对同一地区的再次搜索可能会重复之前的轨迹。从现实生活角度来看,这不一定是坏事,因为水手会一直漂移,但总体来说,最好尽可能覆盖整个区域而不重复。

复制并编辑程序,使其跟踪在一个区域内哪些坐标已被搜索过,并将这些坐标从未来的搜索中排除(直到再次调用 main(),无论是因为玩家找到水手,还是选择菜单选项 7 来重启)。测试这两个版本的游戏,看看你的修改是否明显影响了结果。

挑战项目:使用 MCS 找到最佳策略

蒙特卡洛模拟(MCS)使用重复的随机抽样来预测在指定条件范围下的不同结果。创建一个bayes.py版本,自动选择菜单项并跟踪成千上万的结果,使你能够确定最成功的搜索策略。例如,让程序根据最高的贝叶斯目标概率选择菜单项 1、2 或 3,然后记录发现水手时的搜索次数。重复此过程 10,000 次,并取所有搜索次数的平均值。然后再次循环,根据最高的组合目标概率选择菜单项 4、5 或 6。比较最终的平均值。是集中搜索一个区域更好,还是分配到两个区域之间更好?

挑战项目:计算检测概率

在实际的搜索和救援行动中,你需要在进行搜索前,估计每个区域的预期搜索有效性概率。这个预期或计划概率主要由天气报告提供。例如,雾气可能会侵入一个搜索区域,而其他两个区域则是晴空万里。

将目标概率乘以计划 SEP 可得出该区域的检测概率(PoD)。PoD 是给定所有已知误差和噪声源下,目标被检测到的概率。

编写一个bayes.py版本,其中每个搜索区域都包含一个随机生成的计划 SEP。将每个区域的目标概率(如 self.p1、self.p2 或 self.p3)乘以这些新变量,以产生该区域的 PoD。例如,如果区域 3 的贝叶斯目标概率是 0.90,但计划 SEP 仅为 0.1,则检测概率为 0.09。

在终端显示中,向玩家展示每个区域的目标概率、计划 SEP 和 PoD,如下所示。玩家可以利用这些信息来指导他们从搜索菜单中做出选择。

Actual Search 1 Effectiveness (E):
E1 = 0.190, E2 = 0.000, E3 = 0.000

New Planned Search Effectiveness and Target Probabilities (P) for Search 2:
E1 = 0.509, E2 = 0.826, E3 = 0.686
P1 = 0.168, P2 = 0.520, P3 = 0.312

Search 2

    Choose next areas to search:

    0 - Quit

    1 - Search Area 1 twice
      Probability of detection: 0.164

    2 - Search Area 2 twice
      Probability of detection: 0.674

    3 - Search Area 3 twice
      Probability of detection: 0.382

    4 - Search Areas 1 & 2
      Probability of detection: 0.515

    5 - Search Areas 1 & 3
      Probability of detection: 0.3

    6 - Search Areas 2 & 3
      Probability of detection: 0.643

    7 - Start Over

Choice:

在对同一区域进行两次搜索时,结合 PoD,请使用以下公式:

图片

否则,只需将概率加总即可。

计算一个区域的实际 SEP 时,应将其限制在预期值附近。这考虑了仅提前一天发布的天气报告的一般准确性。将 random.uniform()方法替换为基于计划 SEP 值构建的分布,例如三角分布。有关可用分布类型的列表,请参见docs.python.org/3/library/random.html#real-valued-distributions。当然,未搜索区域的实际 SEP 始终为零。

将计划好的 SEP(搜索执行计划)纳入游戏玩法中会产生什么影响?获胜变得更容易还是更难?理解贝叶斯规则的应用变得更难了吗?如果你负责一次真实的搜救行动,遇到一个目标概率很高但由于海况恶劣导致计划的 SEP 较低的区域,你会怎么处理?你会继续搜索,取消搜索,还是将搜索移到一个目标概率较低但天气更好的区域?

第二章:使用文体学归属作者身份

Image

文体学是通过计算机文本分析对文学风格进行定量研究的学科。它基于这样一个观点:我们每个人都有独特、一致且可识别的写作风格。这包括我们的词汇、标点使用、句子和单词的平均长度等等。

文体学的一个常见应用是作者身份归属分析。你是否曾经怀疑莎士比亚真的写了他所有的戏剧?或者约翰·列侬或保罗·麦卡特尼真的是《我的一生》这首歌的作者?《布谷鸟的呼唤》的作者罗伯特·加尔布雷思真的就是伪装成作者的 J.K. 罗琳吗?文体学能给出答案!

文体学曾被用于推翻谋杀定罪,甚至帮助识别并定罪“独行炸弹人”(Unabomber)。其他应用还包括检测抄袭以及确定文字背后的情感色调,如社交媒体帖子中的情感。文体学甚至可以用于检测抑郁症迹象和自杀倾向。

在本章中,你将使用多种文体学技术来确定是亚瑟·柯南·道尔还是赫伯特·乔治·威尔斯写了小说《失落的世界》

项目 #2:猎犬、战争与失落的世界

亚瑟·柯南·道尔爵士(1859–1930)最著名的作品是《福尔摩斯探案集》,被认为是犯罪小说领域的里程碑。赫伯特·乔治·威尔斯(1866–1946)以多部开创性的科幻小说而闻名,其中包括《世界大战》《时间机器》《隐形人》《莫罗博士岛》

1912 年,《海滨杂志》刊登了《失落的世界》,这是一本科幻小说的连载版本。故事讲述了一次由动物学教授乔治·爱德华·挑战者领导的亚马逊盆地探险队,探险队在此过程中遇到了活恐龙和一群凶猛的类人猿部落。

虽然小说的作者已经知道,但为了这个项目,我们假设其身份存在争议,而你将负责解开这个谜团。专家们已将候选作者缩小为两位——道尔和威尔斯。威尔斯略微占优,因为《失落的世界》是一本科幻小说,这是他的专长。书中还出现了粗暴的穴居人,类似于他在 1895 年作品《时间机器》中的莫洛克人。而道尔则以侦探小说和历史小说闻名。

目标

编写一个 Python 程序,利用文体学技术判断是亚瑟·柯南·道尔还是赫伯特·乔治·威尔斯写了小说《失落的世界》

策略

自然语言处理(NLP)的科学处理计算机精确、结构化的语言与人类使用的含有细微差别且常常模糊不清的“自然”语言之间的相互作用。NLP 的典型应用包括机器翻译、垃圾邮件检测、搜索引擎问题的理解,以及手机用户的预测文本识别。

最常见的自然语言处理(NLP)作者分析测试会分析以下文本特征:

单词长度 文档中单词长度的频率分布图

停用词 停用词的频率分布图(如thebutif等短小、无上下文的功能性词汇)

词性 根据词法功能(如名词、代词、动词、副词、形容词等)绘制的词频分布图

最常用的词 对文本中最常用词汇的比较

Jaccard 相似度 一种用于衡量样本集相似性和多样性的统计量

如果道尔和威尔斯有明显不同的写作风格,这五个测试应该足以区分它们。我们将在编码部分更详细地讨论每个测试。

为了捕捉并分析每个作者的风格,你需要一个代表性的语料库,即一篇文本。对于道尔,可以使用著名的《福尔摩斯探案集》中的小说《巴斯克维尔的猎犬》,该书于 1902 年出版。对于威尔斯,可以使用《世界大战》,该书于 1898 年出版。这两部小说都包含超过 50,000 个单词,足够用于进行可靠的统计抽样。接下来,你将把每位作者的样本与《失落的世界》进行比较,以确定他们的写作风格有多相似。

为了进行文体计量分析,你将使用自然语言工具包(NLTK),这是一个用于处理 Python 中的人类语言数据的流行程序和库套件。它是免费的,支持 Windows、macOS 和 Linux 系统。NLTK 最初于 2001 年作为宾夕法尼亚大学计算语言学课程的一部分创建,之后在众多贡献者的帮助下持续发展和扩展。要了解更多信息,可以访问官方的 NLTK 网站* www.nltk.org/*。

安装 NLTK

你可以在* www.nltk.org/install.html*找到 NLTK 的安装说明。在 Windows 上安装 NLTK,打开 PowerShell 并使用首选安装程序(pip)进行安装。

python -m pip install nltk

如果你安装了多个版本的 Python,需要指定版本。以下是针对 Python 3.7 的命令:

py -3.7 -m pip install nltk

要检查安装是否成功,打开 Python 交互式命令行,并输入以下内容:

>>> import nltk
>>>

如果没有出现错误,那就说明一切正常。否则,请按照* www.nltk.org/install.html*上的安装说明进行操作。

下载分词器

要运行文体测试,你需要将多个文本或语料库拆分成单独的单词,称为标记。在写作时,NLTK 中的 word_tokenize()方法会隐式调用 sent_tokenize(),该方法用于将语料库拆分为独立的句子。要处理 sent_tokenize(),你需要Punkt Tokenizer 模型。虽然这是 NLTK 的一部分,但你需要通过方便的 NLTK 下载器单独下载。要启动它,请在 Python 命令行中输入以下内容:

>>> import nltk
>>> nltk.download()

现在应该已经打开 NLTK Downloader 窗口 (图 2-1)。点击顶部的 ModelsAll Packages 标签,然后点击 Identifier 列中的 punkt。滚动到窗口底部并为你的平台设置下载目录(请参阅 www.nltk.org/data.html)。最后,点击 Download 按钮以下载 Punkt Tokenizer 模型。

Image

图 2-1:下载 Punkt Tokenizer 模型

请注意,你也可以直接在 shell 中下载 NLTK 包。以下是一个示例:

>>> import nltk
>>> nltk.download('punkt')

你还需要访问 Stopwords 语料库,可以通过类似的方式下载。

下载 Stopwords 语料库

点击 NLTK Downloader 窗口中的 Corpora 标签并下载 Stopwords 语料库,如 图 2-2 所示。

Image

图 2-2:下载 Stopwords 语料库

或者,你可以使用 shell。

>>> import nltk
>>> nltk.download('stopwords')

让我们再下载一个包,帮助你分析词性,如名词和动词。在 NLTK Downloader 窗口中点击 All Packages 标签并下载 Averaged Perceptron Tagger。

要使用 shell,请输入以下内容:

>>> import nltk
>>> nltk.download('averaged_perceptron_tagger')

当 NLTK 下载完成后,退出 NLTK Downloader 窗口,并在 Python 交互式命令行中输入以下内容:

>>> from nltk import punkt

然后输入以下内容:

>>> from nltk.corpus import stopwords

如果没有遇到错误,模型和语料库已经成功下载。

最后,你需要安装 matplotlib 来绘制图表。如果还没有安装,请参阅 第 6 页 上关于安装科学包的说明。

语料库

你可以从 nostarch.com/real-world-python/ 下载《巴斯克维尔的猎犬》(hound.txt), 《世界大战》(war.txt), 和《失落的世界》(lost.txt) 的文本文件,以及书中的代码。

这些文本来自 Project Gutenberg (www.gutenberg.org/),这是一个提供公有领域文学作品的极好资源。为了让你能立即使用这些文本,我已经去除了多余的内容,如目录、章节标题、版权信息等。

风格计量代码

你接下来编写的 stylometry.py 程序会将文本文件作为字符串加载,进行分词处理,并运行列出在 第 28 页–29 页 的五种风格计量分析。程序会输出包含图表和终端消息的组合,帮助你确定《失落的世界》的作者。

保持程序和三个文本文件在同一个文件夹中。如果你不想自己输入代码,只需跟随可下载的代码,网址为 nostarch.com/real-world-python/

导入模块并定义 main() 函数

清单 2-1 导入 NLTK 和 matplotlib,分配常量,并定义 main() 函数以运行程序。main() 中使用的函数将在本章后面详细描述。

stylometry.py, part 1
import nltk
from nltk.corpus import stopwords
import matplotlib.pyplot as plt

LINES = ['-', ':', '--']  # Line style for plots.

def main():
  ➊ strings_by_author = dict()
    strings_by_author['doyle'] = text_to_string('hound.txt')
    strings_by_author['wells'] = text_to_string('war.txt')
    strings_by_author['unknown'] = text_to_string('lost.txt')

    print(strings_by_author['doyle'][:300])

 ➋ words_by_author = make_word_dict(strings_by_author)
    len_shortest_corpus = find_shortest_corpus(words_by_author)
 ➌ word_length_test(words_by_author, len_shortest_corpus)
    stopwords_test(words_by_author, len_shortest_corpus)    
    parts_of_speech_test(words_by_author, len_shortest_corpus)
    vocab_test(words_by_author)
    jaccard_test(words_by_author, len_shortest_corpus)

清单 2-1:导入模块并定义 main() 函数

首先导入 NLTK 和 Stopwords 语料库。然后导入 matplotlib。

创建一个名为 LINES 的变量,并使用全大写字母命名约定来表示它应该被视为常量。默认情况下,matplotlib 会以颜色绘制图形,但你仍然需要为色盲人士和这本黑白书籍指定一组符号!

在程序开始时定义 main()。这个函数中的步骤几乎像伪代码一样可读,并且提供了一个关于程序将要做什么的良好概述。第一步将是初始化一个字典,用于存储每个作者的文本 ➊。text_to_string() 函数将把每个语料库加载到这个字典中作为字符串。每个作者的名字将作为字典的键(失落的世界用 unknown 表示),而他们小说中的文本字符串将作为值。例如,以下是键 Doyle,其对应的值文本字符串已大幅截断:

{'Doyle': 'Mr. Sherlock Holmes, who was usually very late in the mornings --snip--'}

在填充字典后,立即打印出 doyle 键的前 300 个条目,以确保一切按计划进行。此时应产生如下输出:

Mr. Sherlock Holmes, who was usually very late in the mornings, save
upon those not infrequent occasions when he was up all night, was seated
at the breakfast table. I stood upon the hearth-rug and picked up the
stick which our visitor had left behind him the night before. It was a
fine, thick piec

在语料库正确加载后,下一步是将字符串标记化为单词。目前,Python 不识别单词,而是处理 字符,如字母、数字和标点符号。为了解决这个问题,你将使用 make_word_dict() 函数,将 strings_by_author 字典作为参数,分离出字符串中的单词,并返回一个名为 words_by_author 的字典,其中作者为键,单词列表为值 ➋。

风格计量学依赖于单词计数,因此它在每个语料库长度相同的情况下效果最佳。有多种方法可以确保进行公平的比较。通过 分块处理,你可以将文本划分为若干块,例如每块 5,000 个单词,并对这些块进行比较。你还可以使用相对频率而非直接计数,或通过截断到最短的语料库来进行归一化。

让我们探讨一下截断选项。将单词字典传递给另一个函数 find_shortest_corpus(),该函数计算每个作者列表中的单词数并返回最短语料库的长度。表 2-1 显示了每个语料库的长度。

表 2-1: 每个语料库的长度(单词数)

语料库 长度
猎犬 (道尔) 58,387
战争 (威尔斯) 59,469
世界 (未知) 74,961

由于此处最短的语料库代表了一个强大的数据集,包含近 60,000 个单词,你将使用 len_shortest_corpus 变量将其他两个语料库截断为这个长度,然后再进行任何分析。当然,假设是截断后的文本在后端内容上与前端内容没有显著差异。

接下来的五行调用了执行样式计量分析的函数,如《策略》一章中 第 28 页 ➌ 所列。所有函数都将 words_by_author 字典作为参数,大多数函数还接受 len_shortest_corpus 作为参数。我们将在准备好文本进行分析后立即查看这些函数。

加载文本并构建词典

Listing 2-2 定义了两个函数。第一个将文本文件作为字符串读取。第二个构建一个字典,以每个作者的名字作为键,小说(现在已被标记为单独的单词,而不是一个连续的字符串)作为值。

stylometry.py, part 2
  def text_to_string(filename):
      """Read a text file and return a string."""
      with open(filename) as infile:
          return infile.read()

➊ def make_word_dict(strings_by_author):
      """Return dictionary of tokenized words by corpus by author."""
      words_by_author = dict()
      for author in strings_by_author:
          tokens = nltk.word_tokenize(strings_by_author[author])
       ➋ words_by_author[author] = ([token.lower() for token in tokens
                                      if token.isalpha()])
      return words_by_author

Listing 2-2:定义 text_to_string() 和 make_word_dict() 函数

首先,定义 text_to_string() 函数来加载文本文件。内置的 read() 函数将整个文件读取为一个单独的字符串,从而便于对文件进行广泛的操作。使用 with 打开文件,这样文件无论如何结束都会自动关闭。就像收拾玩具一样,关闭文件是一种良好的习惯。它可以防止发生一些不好的事情,比如文件描述符耗尽、文件被锁定无法访问、文件损坏,或者写入文件时丢失数据。

一些用户在加载文本时可能会遇到类似下面的 UnicodeDecodeError 错误:

UnicodeDecodeError: 'ascii' codec can't decode byte 0x93 in position 365:
ordinal not in range(128)

编码解码 指的是将存储为字节的字符转换为人类可读的字符串的过程。问题是,内置函数 open() 的默认编码是与平台相关的,并且依赖于 locale.getpreferredencoding() 的值。例如,如果你在 Windows 10 上运行该代码,你将得到以下编码:

>>> import locale
>>> locale.getpreferredencoding()
'cp1252'

CP-1252 是一种传统的 Windows 字符编码。如果你在 Mac 上运行相同的代码,可能会返回不同的编码,比如 'US-ASCII' 或 'UTF-8'。

UTF 代表 Unicode 转换格式,它是一种文本字符格式,旨在与 ASCII 向后兼容。尽管 UTF-8 能处理所有字符集,并且是全球网络上使用的主要编码形式,但它并不是许多文本编辑器的默认选项。

此外,Python 2 假设所有文本文件都使用 latin-1 编码,它适用于拉丁字母表。Python 3 更加智能,尽量尽早检测编码问题。然而,如果没有指定编码,它可能会抛出错误。

所以,第一步故障排除应该是传递编码参数给 open() 函数,并指定 UTF-8。

    with open(filename, encoding='utf-8') as infile:

如果你仍然遇到加载语料库文件的问题,可以尝试添加一个错误参数,如下所示:

    with open(filename, encoding='utf-8', errors='ignore') as infile:

你可以忽略错误,因为这些文本文件是作为 UTF-8 下载的,并且已经通过这种方法进行了测试。关于 UTF-8 的更多内容,请参见 docs.python.org/3/howto/unicode.html

接下来,定义 make_word_dict() 函数,该函数将接受按作者分类的字符串字典,并返回按作者分类的单词字典➊。首先,初始化一个名为 words_by_author 的空字典。然后,遍历 strings_by_author 字典中的键。使用 NLTK 的 word_tokenize() 方法,并传递字典的键。返回的结果将是一个标记的列表,这些标记将作为每个作者的字典值。标记就是语料库中被切割的小块,通常是句子或单词。

以下代码片段演示了该过程如何将一个连续的字符串转换为标记的列表(包括单词和标点):

>>> import nltk		  
>>> str1 = 'The rain in Spain falls mainly on the plain.'		  
>>> tokens = nltk.word_tokenize(str1)		  
>>> print(type(tokens))		  
<class 'list'>
>>> tokens		  
['The', 'rain', 'in', 'Spain', 'falls', 'mainly', 'on', 'the', 'plain', '.']

这与使用 Python 内置的 split() 函数类似,但 split() 从语言学的角度来说并不生成标记(请注意,句点没有被标记化)。

>>> my_tokens = str1.split()		  
>>> my_tokens		  
['The', 'rain', 'in', 'Spain', 'falls', 'mainly', 'on', 'the', 'plain.']

一旦你有了标记,使用列表推导式➋填充 words_by_author 字典。列表推导式是 Python 中执行循环的一种简写方式。你需要用方括号将代码括起来,以表示这是一个列表。将标记转换为小写,并使用内置的 isalpha() 方法,如果标记中的所有字符都是字母则返回 True,否则返回 False。这样可以过滤掉数字和标点符号,也会过滤掉带连字符的单词或名字。最后,返回 words_by_author 字典。

查找最短的语料库

在计算语言学中,频率指的是语料库中某个词出现的次数。因此,频率意味着计数,你稍后将使用的方法会返回一个包含单词及其计数的字典。为了以有意义的方式比较计数,所有语料库应该具有相同的单词数量。

由于这里使用的三个语料库都很大(见 表 2-1),你可以安全地通过将它们都截断为最短的长度来对语料库进行规范化。示例 2-3 定义了一个函数,用于在 words_by_author 字典中查找最短的语料库并返回其长度。

stylometry.py, part 3
def find_shortest_corpus(words_by_author):
    """Return length of shortest corpus."""
    word_count = []
    for author in words_by_author:
        word_count.append(len(words_by_author[author]))
        print('\nNumber of words for {} = {}\n'.
              format(author, len(words_by_author[author])))
    len_shortest_corpus = min(word_count)
    print('length shortest corpus = {}\n'.format(len_shortest_corpus))        
    return len_shortest_corpus

示例 2-3:定义 find_shortest_corpus() 函数

定义一个函数,接受 words_by_author 字典作为参数。立刻开始一个空列表,用于保存单词计数。

遍历字典中的作者(键)。获取每个键对应值的长度,该值是一个列表对象,并将长度追加到 word_count 列表中。这里的长度表示语料库中的单词数。每次循环时,打印作者的名字以及其标记化语料库的长度。

当循环结束时,使用内置的 min() 函数获取最小的计数,并将其赋值给 len_shortest_corpus 变量。打印答案,然后返回该变量。

比较单词长度

作者独特风格的一部分就是他们使用的单词。福克纳观察到,海明威从不让读者跑去查字典;海明威指责福克纳使用“10 美元的单词”。作者的风格通过单词的长度和词汇表达出来,这一点我们将在本章后面讨论。

示例 2-4 定义了一个函数,用于比较每个语料库中单词的长度,并将结果绘制为频率分布图。在频率分布中,单词的长度与每个长度的计数数目进行对比。例如,对于长度为六个字母的单词,一个作者可能有 4,000 次出现,而另一个作者可能有 5,500 次出现。频率分布允许比较不同单词长度的范围,而不仅仅是平均单词长度。

示例 2-4 中的函数使用列表切片将单词列表截断到最短语料库的长度,以避免小说大小对结果的影响。

stylometry.py, part 4
def word_length_test(words_by_author, len_shortest_corpus):
    """Plot word length freq by author, truncated to shortest corpus length."""
    by_author_length_freq_dist = dict()
    plt.figure(1)    
    plt.ion()

 ➊ for i, author in enumerate(words_by_author):
        word_lengths = [len(word) for word in words_by_author[author]
                        [:len_shortest_corpus]]
        by_author_length_freq_dist[author] = nltk.FreqDist(word_lengths)
     ➋ by_author_length_freq_dist[author].plot(15, 
                                                linestyle=LINES[i],                                                  
                                                label=author,   
                                                title='Word Length')
    plt.legend()
    #plt.show()  # Uncomment to see plot while coding.

示例 2-4:定义 word_length_test() 函数

所有的风格计量函数将使用标记的字典;几乎所有函数都将使用最短语料库的长度参数,以确保样本大小一致。使用这些变量名作为函数的参数。

启动一个空字典来保存按作者划分的单词长度的频率分布,然后开始制作图表。由于你将制作多个图表,首先实例化一个名为 1 的图形对象。为了确保所有图表在创建后都能保持显示,开启交互式绘图模式 plt.ion()

接下来,开始遍历分词后的字典中的作者 ➊。使用 enumerate() 函数为每个作者生成一个索引,供你选择绘图的线条样式。对于每个作者,使用列表推导获取每个单词在值列表中的长度,范围被截断到最短语料库的长度。结果将是一个列表,其中每个单词都被一个表示其长度的整数所替代。

现在,开始填充你的新按作者划分的词典,以保存频率分布。使用 nltk.FreqDist(),它接受一个单词长度的列表并创建一个包含单词频率信息的数据对象,该信息可以进行绘制。

你可以直接使用类方法 plot() 绘制词典,而无需通过 plt 引用 pyplot ➋。这将首先绘制出现频率最高的样本,接着是你指定的样本数量,在本例中为 15。这意味着你将看到长度从 1 到 15 个字母的单词频率分布。使用 iLINES 列表中选择,并通过提供标签和标题来完成。标签将用于图例,使用 plt.legend() 调用。

请注意,你可以通过 cumulative 参数更改频率数据图的显示方式。如果指定 cumulative=True,你将看到累计分布(图 2-3,左)。否则,plot() 默认为 cumulative=False,你将看到实际计数,从高到低排列(图 2-3,右)。在此项目中继续使用默认选项。

Image

图 2-3:NLTK 累计图(左)与默认频率图(右)

完成后,调用 plt.show() 方法来显示图表,但要将其注释掉。如果你希望在编写完此函数后立即看到图表,可以取消注释。还要注意,如果你通过 Windows PowerShell 启动该程序,图表可能会立即关闭,除非使用 block 标志:plt.show(block=True)。这会保持图表打开,但会暂停程序的执行,直到图表关闭为止。

单凭 图 2-3 中的词长频率图,Doyle 的风格与未知作者的风格更为相似,尽管也有一些段落是 Wells 相比之下相同或更优的。现在,让我们进行其他测试,看看是否能确认这一发现。

比较停用词

停用词 是一些常用的小词,如 thebybut。这些词在在线搜索等任务中被过滤掉,因为它们不提供上下文信息,曾被认为在识别作者方面价值不大。

但停用词由于频繁且随意使用,或许是最能体现作者风格的标志。而且,由于你比较的文本通常涉及不同的主题,这些停用词变得非常重要,因为它们与内容无关,并且在所有文本中都有出现。

清单 2-5 定义了一个函数,用于比较三种语料库中停用词的使用情况。

stylometry.py, part 5
def stopwords_test(words_by_author, len_shortest_corpus):
    """Plot stopwords freq by author, truncated to shortest corpus length."""
    stopwords_by_author_freq_dist = dict()
    plt.figure(2)
    stop_words = set(stopwords.words('english'))  # Use set for speed.
    #print('Number of stopwords = {}\n'.format(len(stop_words)))
    #print('Stopwords = {}\n'.format(stop_words))

    for i, author in enumerate(words_by_author):        
        stopwords_by_author = [word for word in words_by_author[author]
                               [:len_shortest_corpus] if word in stop_words]    
        stopwords_by_author_freq_dist[author] = nltk.FreqDist(stopwords_by_
        author)    
        stopwords_by_author_freq_dist[author].plot(50, 
                                                   label=author,
                                                   linestyle=LINES[i],
                                                   title=
                                                   '50 Most Common Stopwords')
    plt.legend()
##    plt.show()  # Uncomment to see plot while coding function.

清单 2-5:定义 stopwords_test() 函数

定义一个函数,接受单词字典和最短语料库长度作为参数。然后初始化一个字典,用于保存每个作者的停用词频率分布。你不想将所有图表都放在同一个图形中,因此从新图形 2 开始。

将一个本地变量 stop_words 赋值为 NLTK 的英语停用词语料库。集合比列表查找速度更快,因此将语料库设置为集合,以便以后更快地查找。接下来的两行代码目前被注释掉,它们会打印出停用词的数量(179)及停用词本身。

现在,开始遍历words_by_author字典中的作者。使用列表推导从每个作者的语料库中提取所有的停用词,并将这些作为一个新字典stopwords_by_author中的值。在下一行中,你将把这个字典传递给 NLTK 的FreqDist()方法,并使用输出填充stopwords_by_author_freq_dist字典。这个字典将包含生成每个作者频率分布图所需的数据。

重复你在列表 2-4 中用于绘制单词长度的代码,但将样本数设置为 50 并给予不同的标题。这将绘制使用的前 50 个停用词(参见图 2-4)。

Image

图 2-4:按作者划分的前 50 个停用词频率图

道尔和未知作者以类似的方式使用停用词。此时,两个分析结果都倾向于道尔是未知文本的最可能作者,但仍然需要做更多工作。

比较词性

现在让我们比较三个语料库中使用的词性。NLTK 使用一个叫做 PerceptronTagger 的词性标注器来识别词性。词性标注器处理标记化后的单词序列,并为每个单词附加一个词性标签(参见表 2-2)。

表 2-2: 词性与标签值

词性 标签 词性 标签
协调连词 CC 所有格代词 PRP$
基数词 CD 副词 RB
限定词 DT 副词,比较级 RBR
存在词 there EX 副词,最高级 RBS
外来词 FW 虚词 RP
介词或从属连词 IN 符号 SYM
形容词 JJ TO
形容词,比较级 JJR 感叹词 UH
形容词,最高级 JJS 动词,基本形式 VB
列表项标记 LS 动词,过去式 VBD
情态动词 MD 动词,动名词或现在分词 VBG
名词,单数或不可数名词 NN 动词,过去分词 VBN
名词,复数 NNS 动词,非第三人称单数现在时 VBP
名词,专有名词,单数 NNP 动词,第三人称单数现在时 VBZ
名词,专有名词,复数 NNPS 疑问限定词,哪个 WDT
前限定词 PDT 疑问代词,谁,什么 WP
所有格结尾 POS 所有格疑问代词,谁的 WP$
人称代词 PRP 疑问副词,哪里,什么时候 WRB

标注器通常在大型数据集上进行训练,如宾州树库布朗语料库,使它们非常准确,尽管并不完美。你也可以找到其他语言的训练数据和标注器。你不需要担心所有这些术语及其缩写。和之前的测试一样,你只需要比较图表中的各行。

列表 2-6 定义了一个函数,用于绘制三个语料库中词性频率分布。

stylometry.py, part 6
def parts_of_speech_test(words_by_author, len_shortest_corpus):
    """Plot author use of parts-of-speech such as nouns, verbs, adverbs."""
    by_author_pos_freq_dist = dict()
    plt.figure(3)
    for i, author in enumerate(words_by_author):
        pos_by_author = [pos[1] for pos in nltk.pos_tag(words_by_author[author]
                            [:len_shortest_corpus])] 
        by_author_pos_freq_dist[author] = nltk.FreqDist(pos_by_author)
        by_author_pos_freq_dist[author].plot(35, 
                                             label=author,
                                             linestyle=LINES[i],
                                             title='Part of Speech')
    plt.legend()
    plt.show()

列表 2-6:定义 parts_of_speech_test() 函数

定义一个函数,接收作为参数的——你猜对了——词典和最短语料库的长度。然后初始化一个字典来保存每个作者的词性频率分布,接着调用一个函数来生成第三张图。

开始遍历 words_by_author 字典中的作者,使用列表推导式和 NLTK pos_tag() 方法来构建一个名为 pos_by_author 的列表。对于每个作者,这会创建一个列表,将作者语料库中的每个词替换为其对应的词性标签,如下所示:

['NN', 'NNS', 'WP', 'VBD', 'RB', 'RB', 'RB', 'IN', 'DT', 'NNS', --snip--]

接下来,制作 POS 列表的频率分布,并在每次循环时绘制曲线,使用前 35 个样本。注意,POS 标签只有 36 个,其中一些标签,如 列表项标记,在小说中很少出现。

这是你绘制的最后一张图,所以调用 plt.show() 来将所有图表显示在屏幕上。如在列表 2-4 的讨论中所指出的,如果你使用 Windows PowerShell 启动程序,可能需要使用 plt.show(block=True) 来防止图表自动关闭。

前面的图表以及当前的图表(图 2-5)应该在大约 10 秒后显示。

Image

图 2-5:按作者排序的前 35 个词性频率图

再次出现的情况是,Doyle 和未知曲线的匹配明显优于未知与 Wells 的匹配。这表明 Doyle 是未知语料库的作者。

比较作者的词汇

为了比较三个语料库之间的词汇,你将使用 卡方随机变量(X²),也叫做 检验统计量,来衡量未知语料库与每个已知语料库所使用的词汇之间的“距离”。词汇之间的“距离”越小,越相似。公式如下:

Image

其中 O 是观察到的词数,E 是假设两个对比语料库来自同一作者时的预期词数。

如果 Doyle 写了这两本小说,它们的最常见词汇比例应该是相同的——或者相似的。检验统计量允许你通过衡量每个词的计数差异来量化它们的相似度。卡方检验统计量越低,两个分布之间的相似度越高。

列表 2-7 定义了一个函数,用于比较三个语料库之间的词汇。

stylometry.py, part 7
def vocab_test(words_by_author):
    """Compare author vocabularies using the chi-squared statistical test."""
    chisquared_by_author = dict()
    for author in words_by_author:
     ➊ if author != 'unknown': 
           combined_corpus = (words_by_author[author] +
                              words_by_author['unknown'])
           author_proportion = (len(words_by_author[author])/
                                len(combined_corpus))
           combined_freq_dist = nltk.FreqDist(combined_corpus)		
           most_common_words = list(combined_freq_dist.most_common(1000))
           chisquared = 0
        ➋ for word, combined_count in most_common_words:
              observed_count_author = words_by_author[author].count(word)
              expected_count_author = combined_count * author_proportion
              chisquared += ((observed_count_author -
                              expected_count_author)**2 /
                             expected_count_author)
           ➌ chisquared_by_author[author] = chisquared    
         print('Chi-squared for {} = {:.1f}'.format(author, chisquared))
 most_likely_author = min(chisquared_by_author, key=chisquared_by_author.get)
 print('Most-likely author by vocabulary is {}\n'.format(most_likely_author))

列表 2-7:定义 vocab_test() 函数

vocab_test() 函数需要词典,但不需要最短语料库的长度。和之前的函数一样,它通过创建一个新的字典来保存每个作者的卡方值,然后遍历词典。

要计算卡方分数,你需要将每个作者的语料库与未知语料库连接。你不想将未知与其自身合并,因此使用条件语句避免这种情况 ➊。对于当前的循环,将作者的语料库与未知语料库结合起来,然后通过将作者语料库的长度除以合并后语料库的长度来获取当前作者的比例。然后,通过调用nltk.FreqDist()获取合并语料库的频率分布。

现在,通过使用most_common()方法并传递 1000,来生成合并文本中 1000 个最常见的单词列表。在风格计量分析中,没有硬性规定需要考虑多少个单词。文献中的建议是考虑最常见的 100 到 1000 个单词。由于你正在处理大量文本,最好偏向较大的数值。

用 0 初始化chisquared变量;然后启动一个嵌套的 for 循环,遍历most_common_words列表 ➋。most_common()方法返回一个元组列表,每个元组包含一个单词及其计数。

[('the', 7778), ('of', 4112), ('and', 3713), ('i', 3203), ('a', 3195), --snip--]

接下来,从单词字典中获取每个作者的观察到的计数。对于道尔来说,这将是《巴斯克维尔的猎犬》语料库中最常见单词的计数。然后,获取预期计数,对于道尔来说,这将是如果他写了《巴斯克维尔的猎犬》和未知语料库,应该得到的计数。为此,将合并语料库中的计数数目乘以之前计算的作者比例。然后,应用卡方公式,并将结果添加到跟踪每个作者卡方分数的字典中 ➌。显示每个作者的结果。

要找到具有最低卡方分数的作者,调用内置的min()函数,并传递字典和字典键,你可以通过get()方法获取该键。这将返回对应最小。这很重要。如果你省略了最后一个参数,min()将基于名称的字母顺序返回最小的而不是它们的卡方分数!你可以在以下代码片段中看到这个错误:

>>> print(mydict)
{'doyle': 100, 'wells': 5}
>>> minimum = min(mydict)
>>> print(minimum)
'doyle'
>>> minimum = min(mydict, key=mydict.get)
>>> print(minimum)
'wells'

很容易假设min()函数返回最小的数值,但正如你所看到的,默认情况下它是查看字典的

完成函数,通过打印基于卡方分数最可能的作者。

Chi-squared for doyle = 4744.4
Chi-squared for wells = 6856.3
Most-likely author by vocabulary is doyle

另一个测试表明道尔是作者!

计算 Jaccard 相似度

为了确定由语料库创建的集合之间的相似度,你将使用Jaccard 相似系数。也叫做交集与并集比,它是两个集合的重叠区域面积除以两个集合的并集区域面积(图 2-6)。

Image

图 2-6:集合的交集与并集比是重叠区域的面积除以并集区域的面积。

两个文本创建的集合之间重叠越多,它们更有可能是由同一位作者写的。示例 2-8 定义了一个函数,用于衡量样本集合的相似度。

stylometry.py, part 8
def jaccard_test(words_by_author, len_shortest_corpus):
    """Calculate Jaccard similarity of each known corpus to unknown corpus."""
    jaccard_by_author = dict()
    unique_words_unknown = set(words_by_author['unknown']
                               [:len_shortest_corpus])
 ➊ authors = (author for author in words_by_author if author != 'unknown')
    for author in authors:
        unique_words_author = set(words_by_author[author][:len_shortest_corpus]) 
        shared_words = unique_words_author.intersection(unique_words_unknown)
     ➋ jaccard_sim = (float(len(shared_words))/ (len(unique_words_author) +
                                                  len(unique_words_unknown) -
                                                  len(shared_words)))
        jaccard_by_author[author] = jaccard_sim
        print('Jaccard Similarity for {} = {}'.format(author, jaccard_sim))
 ➌ most_likely_author = max(jaccard_by_author, key=jaccard_by_author.get)
    print('Most-likely author by similarity is {}'.format(most_likely_author))

if __name__ == '__main__':
    main()

示例 2-8:定义 jaccard_test() 函数

和之前的大多数测试一样,jaccard_test() 函数将词典和最短语料库的长度作为参数。你还需要一个字典来存储每个作者的 Jaccard 系数。

Jaccard 相似度基于唯一单词,因此你需要将语料库转换为集合,以便去除重复项。首先,你会从未知语料库中构建一个集合。然后,你会遍历已知语料库,将它们转换为集合,并与未知集合进行比较。在制作集合时,务必将所有语料库截断为最短语料库的长度。

在运行循环之前,使用生成器表达式从 words_by_author 字典中获取除未知作者外的作者名称 ➊。生成器表达式是一个返回可以逐个值进行迭代的对象的函数。它看起来很像列表推导式,但它是用圆括号包围的,而不是方括号。而且,它不会构建一个可能占用大量内存的项列表,而是实时生成这些项。当你需要使用大量的值,但只用一次时,生成器非常有用。我在这里使用它,作为展示这一过程的机会。

当你将生成器表达式赋值给变量时,你得到的只是一个称为生成器对象的迭代器类型。将此与创建列表进行对比,如下所示:

>>> mylist = [i for i in range(4)]
>>> mylist
[0, 1, 2, 3]
>>> mygen = (i for i in range(4))
>>> mygen
<generator object <genexpr> at 0x000002717F547390>

前面代码片段中的生成器表达式与这个生成器函数是一样的:

def generator(my_range):
    for i in range(my_range):
        yield i

与返回语句结束函数不同,yield 语句暂停函数的执行,并将一个值返回给调用者。稍后,函数可以从暂停的地方继续执行。当生成器到达末尾时,它“空”了,无法再次调用。

回到代码,使用作者生成器开始一个 for 循环。找到每个已知作者的唯一单词,就像你对未知作者所做的那样。然后使用内置的 intersection() 函数,找到当前作者单词集和未知单词集之间所有共享的单词。两个给定集合的交集是包含两个集合中所有共同元素的最大集合。通过这些信息,你可以计算 Jaccard 相似系数 ➋。

更新 jaccard_by_author 字典,并在解释器窗口中打印每个结果。然后,找到具有最大 Jaccard 值 ➌ 的作者并打印结果。

Jaccard Similarity for doyle = 0.34847801578354004
Jaccard Similarity for wells = 0.30786921307869214
Most-likely author by similarity is doyle

结果应该偏向道尔(Doyle)。

完成stylometry.py,编写代码以便在作为导入模块或独立模式下运行程序。

总结

《失落的世界》的真正作者是道尔,所以我们就此为止,宣布胜利。如果你想进一步探究,下一步可以是为道尔和威尔斯增加更多已知的文本,使它们的总长度接近《失落的世界》,这样就不必对其进行截断。你还可以测试句子长度和标点风格,或者使用更复杂的技术,如神经网络和遗传算法。

你还可以通过词干提取词形还原技术来优化现有的函数,如 vocab_test()和 jaccard_test(),这些技术将单词简化为它们的根形式,从而便于比较。目前程序的写法是,talktalkingtalked被视为完全不同的单词,尽管它们共享相同的词根。

到了最后,风格计量学无法绝对确定是阿瑟·柯南·道尔爵士写了《失落的世界》。它只能通过证据的权重,表明他比赫伯特·乔治·威尔斯更可能是作者。明确地提出问题非常重要,因为你不能评估所有可能的作者。正因如此,成功的作者鉴定始于传统的侦探工作,先将候选人名单缩小到一个可管理的范围。

进一步阅读

《Python 自然语言处理:使用自然语言工具包分析文本》(O'Reilly,2009),由 Steven Bird、Ewan Klein 和 Edward Loper 编写,是一本介绍使用 Python 进行自然语言处理(NLP)的易懂入门书,书中有很多练习,并且与 NLTK 网站有很好的集成。该书的新版已更新至 Python 3 和 NLTK 3,并可以在网上查看,网址是www.nltk.org/book/

1995 年,小说家库尔特·冯内古特提出了“故事有形状,可以在图纸上绘制”的观点,并建议“将它们输入计算机”。2018 年,研究人员基于这个想法,使用了超过 1700 本英文小说,并应用了一种叫做情感分析的 NLP 技术,能够揭示单词背后的情感基调。他们的研究结果有一个有趣的总结,“世界上每个故事都有六种基本情节之一”,可以在BBC.com网站上找到,链接为:www.bbc.com/culture/story/20180525-every-story-in-the-world-has-one-of-these-six-basic-plots/

实践项目:使用分布图追踪猎犬

NLTK 自带了一个有趣的小功能,叫做分布图,它可以展示一个词在文本中的位置。更具体地说,它绘制了一个词的出现位置与从语料库开始的词数之间的关系。

图 2-7 是《巴斯克维尔的猎犬》中主要人物的分布图。

图片

图 2-7:巴斯克维尔的猎犬中主要人物的分布图

如果你熟悉这个故事——如果你不熟悉,我就不剧透——那么你会注意到霍尔姆斯在中间部分的稀疏出现,几乎没有。

莫蒂默的双峰分布,以及巴里莫尔、塞尔登和猎犬的晚期故事重叠。

离散图可以有更多实际应用。例如,作为技术书籍的作者,我需要在术语首次出现时进行定义。这听起来很简单,但有时编辑过程会重新排列整章内容,这类问题可能被忽略。利用包含大量技术术语的离散图,可以使查找这些首次出现的术语变得更加容易。

另一种使用场景是,假设你是一名数据科学家,与法律助理合作处理一宗涉及内幕交易的刑事案件。为了找出被告是否在进行非法交易前与某个董事会成员进行了交谈,你可以将被告收到的传票邮件作为一个连续的字符串载入,并生成一个离散图。如果该董事会成员的名字按预期出现,案件就可以结案!

对于这个练习项目,编写一个 Python 程序,重现图 2-7 中显示的离散图。如果你在加载 hound.txt 语料库时遇到问题,请重新阅读第 35 页关于 Unicode 的讨论。你可以在附录和在线找到解决方案,practice_hound_dispersion.py

练习项目:标点符号热力图

热力图 是一种使用颜色来表示数据值的图表。热力图曾被用来可视化著名作家的标点符号使用习惯 (www.fastcompany.com/3057101/the-surprising-punctuation-habits-of-famous-authors-visualized/),并且可能有助于推测《失落的世界》的作者身份。

编写一个 Python 程序,仅根据标点符号对本章使用的三部小说进行分词。然后,重点分析分号的使用。对于每位作者,绘制一个热力图,将分号显示为蓝色,其他标点符号显示为黄色或红色。图 2-8 显示了威尔斯的《世界大战》和道尔的《巴斯克维尔的猎犬》的热力图示例。

图片

图 2-8:威尔斯(左)和道尔(右)分号使用的热力图(深色方块)

比较三个热力图。结果更倾向于道尔还是威尔斯作为《失落的世界》的作者?

你可以在附录和在线找到解决方案,practice_heatmap_semicolon.py

挑战项目:修复频率

如前所述,频率在自然语言处理中指的是计数,但它也可以表示单位时间内的出现次数。或者,它可以表示为比率或百分比。

定义一个新的版本的 nltk.FreqDist()方法,使用百分比而不是计数,并用它来生成stylometry.py程序中的图表。如需帮助,请参见 Clearly Erroneous 博客(martinapugliese.github.io/plotting-the-actual-frequencies-in-a-FreqDist-in-nltk/)。

第三章:使用自然语言处理总结演讲

Image

“四面水域皆是水,却无一滴可饮。”这句来自《古舟子之歌》的名句总结了数字信息的现状。根据国际数据公司(IDC)的预测,到 2025 年,我们将每年生成 175 万亿千兆字节的数字数据。但大部分数据——多达 95%——将是非结构化的,这意味着它并未被组织成有用的数据库。即使现在,癌症的治愈方法可能就在我们手边,却几乎无法触及。

为了让信息更容易被发现和消费,我们需要通过提取和重新包装关键点来减少数据量,形成易于理解的摘要。由于数据量庞大,人工处理几乎不可能。幸运的是,自然语言处理(NLP)可以帮助计算机理解单词和上下文。例如,NLP 应用程序可以总结新闻源、分析法律合同、研究专利、研究金融市场、捕捉企业知识并生成学习指南。

在本章中,你将使用 Python 的自然语言工具包(NLTK)生成历史上最著名演讲之一——马丁·路德·金的《我有一个梦想》的摘要。在理解基本概念后,你将使用一种简化的替代方法,称为 gensim,来总结威廉·H·麦克雷文海军上将的著名演讲《整理床铺》。最后,你将使用词云来制作一个有趣的可视化摘要,展示亚瑟·柯南·道尔的小说《巴斯克维尔的猎犬》中最常用的单词。

项目 #3:我有一个梦想……来总结演讲!

在机器学习和数据挖掘中,有两种总结文本的方法:提取抽象

基于提取的总结方法使用各种加权函数根据感知的重要性对句子进行排序。使用频率更高的词被认为更重要。因此,包含这些词的句子被认为更重要。总体行为就像是用黄色荧光笔手动选择关键词和句子,而不改变文本。尽管结果可能会显得支离破碎,但这种技术擅长提取重要的单词和短语。

抽象依赖于对文档更深层次的理解,以捕捉意图并生成更具人类化的改写。这包括创造全新的句子。与基于提取的方法相比,抽象方法的结果往往更加连贯且语法正确,但也有其代价。抽象算法需要先进且复杂的深度学习方法和精密的语言建模。

在这个项目中,你将对马丁·路德·金(Dr. Martin Luther King Jr.)1963 年 8 月 28 日在林肯纪念堂发表的“我有一个梦想”演讲使用提取式技术。与林肯 100 年前的“葛底斯堡演说”一样,这也是在完美的时刻发表的完美演讲。金博士巧妙地运用了重复手法,这使得这篇演讲特别适合使用提取技术,通过关联单词频率与重要性来提取关键信息。

目标

编写一个 Python 程序,使用自然语言处理(NLP)文本提取功能总结演讲内容。

策略

自然语言工具包(NLTK)包含了你需要用来总结马丁·路德·金(Dr. King)演讲的功能。如果你跳过了第二章,请查看第 29 页获取安装说明。

为了总结演讲内容,你需要一个数字化副本。在之前的章节中,你手动从互联网上下载了需要的文件。这一次,你将使用一种更高效的技术——网络爬取,它可以让你编程方式从网站中提取并保存大量数据。

一旦你将演讲加载为字符串,你可以使用 NLTK 来分割并统计每个单词。接着,你将通过将每个句子中的单词计数求和,来“评分”每个句子。你可以根据句子的得分来打印排名最高的句子,基于你希望在总结中包含多少句子。

网络爬取

网络爬取是指使用程序下载和处理内容。这是一个非常常见的任务,现成的爬虫程序可以自由获取。你将使用 requests 库来下载文件和网页,并使用 Beautiful Soup(bs4)包来解析 HTML。HTML 是超文本标记语言(Hypertext Markup Language)的缩写,是用于创建网页的标准格式。

要安装这两个模块,请在终端窗口或 Windows PowerShell 中使用 pip(有关使用和安装 pip 的说明,请参见第 8 页的第一章)。

pip install requests 
pip install beautifulsoup4

要检查安装情况,打开终端并按如下方式导入每个模块。如果没有报错,说明安装成功,可以开始使用了!

>>> import requests
>>> 
>>> import bs4
>>>

要了解更多关于 requests 的信息,请访问pypi.org/project/requests/。有关 Beautiful Soup 的更多信息,请查看www.crummy.com/software/BeautifulSoup/

“我有一个梦想”代码

dream_summary.py程序执行以下步骤:

  1. 打开包含“我有一个梦想”演讲的网页

  2. 将文本加载为字符串

  3. 将文本分解为单词和句子

  4. 移除没有上下文内容的停用词

  5. 计算剩余单词的数量

  6. 使用计数来对句子进行排名

  7. 显示排名最高的句子

如果你已经下载了书中的文件,请在Chapter_3文件夹中找到该程序。否则,访问nostarch.com/real-world-python/并从书籍的 GitHub 页面下载。

导入模块并定义 main()函数

清单 3-1 导入模块并定义了 main() 函数的第一部分,该部分用于抓取网页并将演讲内容作为字符串赋值给变量。

dream_summary.py, part 1
from collections import Counter
import re
import requests
import bs4
import nltk
from nltk.corpus import stopwords

def main():
 ➊ url = 'http://www.analytictech.com/mb021/mlk.htm'
    page = requests.get(url)
    page.raise_for_status()
 ➋ soup = bs4.BeautifulSoup(page.text, 'html.parser')
    p_elems = [element.text for element in soup.find_all('p')]

    speech = ''.join(p_elems)

清单 3-1:导入模块并定义 main() 函数

首先,从 collections 模块导入 Counter,用于帮助你跟踪句子的得分。collections 模块是 Python 标准库的一部分,包含了几种容器数据类型。Counter 是字典的子类,用于计数可哈希对象。元素作为字典的键存储,而它们的计数作为字典的值存储。

接下来,为了在总结内容之前清理演讲内容,导入 re 模块。re 代表 正则表达式,也称为 regex,它们是定义搜索模式的字符序列。这个模块将帮助你通过允许你有选择地删除不需要的部分来清理演讲内容。

用抓取网页和进行自然语言处理的模块完成导入。最后一个模块引入了功能性停用词的列表(例如 ifandbutfor),这些词不包含有用信息。在总结之前,你将从演讲中移除这些词。

接下来,定义一个 main() 函数来运行程序。为了从网上抓取演讲内容,提供 url 地址作为字符串 ➊。你可以从想要提取文本的网站复制并粘贴这个地址。

requests 库抽象了在 Python 中发起 HTTP 请求的复杂性。HTTP,即超文本传输协议,是通过超链接在万维网上进行数据通信的基础。使用 requests.get() 方法来获取 url,并将输出赋值给 page 变量,该变量引用了网页为请求返回的 Response 对象。该对象的 text 属性包含了网页内容,包括演讲内容,以字符串形式呈现。

为了检查下载是否成功,调用 Response 对象的 raise_for_status() 方法。如果一切正常,这个方法什么也不做;否则,它会引发异常并终止程序。

此时,数据是 HTML 格式,如下所示:

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<html>

<head>
<meta http-equiv="Content-Type"
content="text/html; charset=iso-8859-1">
<meta name="GENERATOR" content="Microsoft FrontPage 4.0">
<title>Martin Luther King Jr.'s 1962 Speech</title>
</head>
--snip--
<p>I am happy to join with you today in what will go down in
history as the greatest demonstration for freedom in the history
of our nation. </p>
--snip--

如你所见,HTML 中有许多 标签,例如 和

,它们告诉浏览器如何格式化网页。开始标签和结束标签之间的文本称为 元素。例如,“马丁·路德·金 Jr. 的 1962 演讲”是一个标题元素,它被夹在开始标签 和结束标签 之间。段落使用

标签进行格式化。

因为这些标签不是原始文本的一部分,所以在进行任何自然语言处理之前应该删除它们。为了移除这些标签,调用 bs4.BeautifulSoup() 方法,并将包含 HTML 的字符串传递给它 ➋。请注意,我已经明确指定了 html.parser。即使没有这个指定,程序仍然可以运行,但会在终端中发出警告。

soup 变量现在引用一个 BeautifulSoup 对象,这意味着你可以使用该对象的 find_all()方法来定位 HTML 文档中的演讲内容。在此案例中,通过列表推导和 find_all()来获取仅包含段落元素的列表,以查找段落标签(

)之间的文本。

最后,将演讲内容转化为连续的字符串。使用 join()方法将 p_elems 列表转化为字符串。设置“连接符”字符为空格,用''表示。

请注意,在 Python 中,通常有不止一种方式可以完成任务。列表中的最后两行也可以按以下方式编写:

    p_elems = soup.select('p')
    speech = ''.join(p_elems)

select()方法总体上比 find_all()方法功能更有限,但在这种情况下,它的作用与 find_all()相同,而且更简洁。在前面的代码中,select()方法查找

标签,结果在与演讲内容字符串连接时转换为文本。

完成 main()函数

接下来,你将准备演讲内容,修正拼写错误并去除标点符号、特殊字符和空格。然后,你将调用三个函数,分别去除停用词、计算词频并根据词频对句子进行评分。最后,你将对句子进行排序,并在命令行显示得分最高的句子。

列表 3-2 完成了执行这些任务的 main()函数定义。

dream_summary.py, part 2
   speech = speech.replace(')mowing', 'knowing')
   speech = re.sub('\s+', ' ', speech) 
   speech_edit = re.sub('[^a-zA-Z]', ' ', speech)
   speech_edit = re.sub('\s+', ' ', speech_edit)

➊ while True:
       max_words = input("Enter max words per sentence for summary: ")
       num_sents = input("Enter number of sentences for summary: ")
       if max_words.isdigit() and num_sents.isdigit():
           break
       else:
           print("\nInput must be in whole numbers.\n")

   speech_edit_no_stop = remove_stop_words(speech_edit)
   word_freq = get_word_freq(speech_edit_no_stop)
   sent_scores = score_sentences(speech, word_freq, max_words)

➋ counts = Counter(sent_scores)
   summary = counts.most_common(int(num_sents))
   print("\nSUMMARY:")
   for i in summary:
       print(i[0])

列表 3-2:完成 main()函数

原始文档包含一个拼写错误(mowing 应为 knowing),因此首先使用 string.replace()方法修正这个错误。接着,继续使用正则表达式清理演讲内容。许多非专业程序员对这个模块的复杂语法感到厌烦,但它是一个强大且有用的工具,每个人都应该了解基本的正则表达式语法。

使用 re.sub()函数去除多余的空格,该函数将子字符串替换为新的字符。使用简写字符类代码\s+来识别连续的空白符并将其替换为一个空格,表示为' '。最后,将字符串(speech)传递给 re.sub()方法。

接下来,使用[^a-zA-Z]模式删除任何不是字母的字符。开头的脱字符指示正则表达式“匹配任何不在括号中的字符”。因此,数字、标点符号等将被空格替换。

删除标点符号等字符会留下额外的空格。为了去除这些空格,再次调用 re.sub()方法。

接下来,要求用户输入要包括在摘要中的句子数量以及每个句子的最大字数。使用 while 循环和 Python 内置的 isdigit()函数确保用户输入一个整数➊。

注意

根据美国新闻研究所的研究,理解力最佳的句子长度为不超过 15 个单词。同样,《牛津简明英语指南》建议,整篇文档中的句子平均长度应为 15 到 20 个单词。

继续清理文本,调用 remove_stop_words()函数。然后调用 get_word_freq()和 score_sentences()函数,分别计算剩余单词的频率并为句子打分。你将在完成 main()函数后定义这些函数。

要对句子进行排名,调用集合模块的 Counter()方法 ➋。将 sent_scores 变量传入该方法。

要生成摘要,使用 Counter 对象的 most_common()方法。将用户输入的 num_sents 变量传递给该方法。结果摘要变量将包含一个元组列表。每个元组中的第一个元素是句子,第二个元素是其排名。

[('From every mountainside, let freedom ring.', 4.625), --snip-- ]

为了提高可读性,将摘要中的每个句子打印在单独的一行上。

去除停用词

记住在第二章中提到的,停用词是像ifbutforso这样的短小功能词。由于它们不包含重要的上下文信息,因此不应使用它们来排名句子。

示例 3-3 定义了一个名为 remove_stop_words()的函数,用来从语音中移除停用词。

dream_summary.py, part 3
def remove_stop_words(speech_edit):
    """Remove stop words from string and return string."""
    stop_words = set(stopwords.words('english'))
    speech_edit_no_stop = ''
    for word in nltk.word_tokenize(speech_edit):
        if word.lower() not in stop_words:
            speech_edit_no_stop += word + ' '
    return speech_edit_no_stop

示例 3-3:定义一个函数来移除语音中的停用词

定义函数时,接收 speech_edit 作为参数,该参数是编辑后的语音字符串。然后,创建一个包含 NLTK 中的英语停用词的集合。使用集合而不是列表,因为集合的查找速度更快。

为了保存去除停用词后的编辑语音,分配一个空字符串给该变量。speech_edit 变量目前是一个字符串,其中每个元素都是一个字母。

为了处理单词,调用 NLTK 的 word_tokenize()方法。注意,你可以在循环遍历单词时执行此操作。将每个单词转换为小写,并检查它是否在 stop_words 集合中。如果它不是停用词,就将其与一个空格一起拼接到新字符串中。函数返回该字符串并结束。

在这个程序中,字母的大小写处理非常重要。你希望摘要能同时打印出大写和小写字母,但在进行自然语言处理时,必须将所有字母转换为小写,以避免错误计数。为了理解原因,请看下面这段代码示例,它统计了一个包含大小写混合的字符串(s)中的单词:

>>> import nltk
>>> s = 'one One one'
>>> fd = nltk.FreqDist(nltk.word_tokenize(s))
>>> fd
FreqDist({'one': 2, 'One': 1})
>>> fd_lower = nltk.FreqDist(nltk.word_tokenize(s.lower()))
>>> fd_lower
FreqDist({'one': 3})

如果不将单词转换为小写,oneOne 会被认为是不同的元素。为了计数,每个one的实例,不论其大小写,应该被视为相同的单词。否则,one对文档的贡献将被稀释。

计算单词出现频率

为了统计每个单词在语音中的出现次数,你需要创建一个名为 get_word_freq()的函数,该函数返回一个字典,字典的键是单词,值是对应的出现次数。示例 3-4 定义了这个函数。

dream_summary.py, part 4 
def get_word_freq(speech_edit_no_stop):
    """Return a dictionary of word frequency in a string."""        
    word_freq = nltk.FreqDist(nltk.word_tokenize(speech_edit_no_stop.lower()))
    return word_freq

示例 3-4:定义一个函数来计算语音中的单词频率

get_word_freq() 函数以编辑过的没有停用词的语音字符串作为参数。NLTK 的 FreqDist 类类似于一个字典,单词作为键,计数作为值。作为过程的一部分,将输入字符串转换为小写,并将其分割成单词。结束函数时,返回 word_freq 字典。

评分句子

列表 3-5 定义了一个函数,该函数根据句子中单词的频率分布对句子进行评分。它返回一个字典,每个句子作为键,其得分作为值。

dream_summary.py, part 5
def score_sentences(speech, word_freq, max_words):
    """Return dictionary of sentence scores based on word frequency."""
    sent_scores = dict()
    sentences = nltk.sent_tokenize(speech)
 ➊ for sent in sentences:
        sent_scores[sent] = 0
        words = nltk.word_tokenize(sent.lower())
        sent_word_count = len(words)
     ➋ if sent_word_count <= int(max_words):
            for word in words:
                if word in word_freq.keys():
                    sent_scores[sent] += word_freq[word]
       ➌ sent_scores[sent] = sent_scores[sent] / sent_word_count
  return sent_scores

if __name__ == '__main__':
    main()

列表 3-5:定义一个根据单词频率为句子评分的函数

定义一个名为 score_sentences() 的函数,参数包括原始的语音字符串、word_freq 对象和用户输入的 max_words 变量。你希望摘要包含停用词和大写单词——因此使用语音字符串。

创建一个空字典,命名为 sent_scores,用于保存每个句子的得分。接下来,将语音字符串分割成句子。

现在,开始遍历句子 ➊。首先更新 sent_scores 字典,将句子作为键,并将其初始值(计数)设为 0。

要计算单词频率,首先需要将句子分割成单词。确保使用小写字母,以便与 word_freq 字典兼容。

在对每个句子的单词数进行求和以创建得分时,你需要小心,以免将结果偏向较长的句子。毕竟,较长的句子更可能包含更多重要单词。为了避免排除短但重要的句子,你需要通过将其除以句子的 长度 来对每个计数进行 标准化。将长度存储在一个名为 sent_word_count 的变量中。

接下来,使用一个条件语句,限制句子的长度为用户输入的最大值 ➋。如果句子通过测试,开始遍历其中的单词。如果一个单词出现在 word_freq 字典中,就将它添加到存储在 sent_scores 中的计数值中。

在每次循环结束时,将当前句子的得分除以句子中的单词数 ➌。这会将得分标准化,避免长句子占有不公平的优势。

通过返回 sent_scores 字典来结束函数。然后,在全局空间中,添加运行程序的代码,无论是作为模块还是独立模式。

运行程序

使用 dream_summary.py 程序,设置最大句子长度为 14 个单词。如前所述,良好、易读的句子通常包含 14 个单词或更少。然后将摘要截断为 15 个句子,约为演讲的三分之一。你应该得到以下结果。请注意,句子的顺序不一定与原文相同。

Enter max words per sentence for summary: 14
Enter number of sentences for summary: 15

SUMMARY:
From every mountainside, let freedom ring.
Let freedom ring from Lookout Mountain in Tennessee!
Let freedom ring from every hill and molehill in Mississippi.
Let freedom ring from the curvaceous slopes of California!
Let freedom ring from the snow capped Rockies of Colorado!
But one hundred years later the Negro is still not free.
From the mighty mountains of New York, let freedom ring.
From the prodigious hilltops of New Hampshire, let freedom ring.
And I say to you today my friends, let freedom ring.
I have a dream today.
It is a dream deeply rooted in the American dream.
Free at last!
Thank God almighty, we're free at last!"
We must not allow our creative protest to degenerate into physical violence.
This is the faith that I go back to the mount with.

摘要不仅捕捉了演讲的标题,还捕捉了主要内容。

但如果你再次运行程序,将每个句子的字数限制为 10,很多句子明显太长。由于整个演讲中只有 7 个句子包含 10 个或更少的字,因此程序无法遵循输入要求。它会默认从演讲的开头开始打印,直到句子数至少达到 num_sents 变量中指定的数目。

现在,重新运行程序并尝试将字数限制设置为 1,000。

Enter max words per sentence for summary: 1000
Enter number of sentences for summary: 15

SUMMARY:
From every mountainside, let freedom ring.
Let freedom ring from Lookout Mountain in Tennessee!
Let freedom ring from every hill and molehill in Mississippi.
Let freedom ring from the curvaceous slopes of California!
Let freedom ring from the snow capped Rockies of Colorado!
But one hundred years later the Negro is still not free.
From the mighty mountains of New York, let freedom ring.
From the prodigious hilltops of New Hampshire, let freedom ring.
And I say to you today my friends, let freedom ring.
I have a dream today.
But not only there; let freedom ring from the Stone Mountain of Georgia!
It is a dream deeply rooted in the American dream.
With this faith we will be able to work together, pray together; to struggle
together, to go to jail together, to stand up for freedom forever, knowing
that we will be free one day.
Free at last!
One hundred years later the life of the Negro is still sadly crippled by the
manacles of segregation and the chains of discrimination.

尽管较长的句子并未主导摘要,但仍有一些被遗漏,使得这个摘要比之前的版本少了些诗意。较低的字数限制迫使之前的版本更多依赖于短语,这些短语起到了类似副歌的作用。

项目 #4:使用 gensim 摘要演讲

在一集获得艾美奖的 辛普森一家(The Simpsons)中,霍默竞选卫生委员会委员,使用的竞选口号是:“难道不能让别人做吗?”这也正是许多 Python 应用程序的情况:经常在你需要编写脚本时,发现别人已经做过了!一个例子就是 gensim,这是一个用于自然语言处理的开源库,使用统计机器学习。

gensim 这个词代表“生成相似的(generate similar)”。它使用了一种基于图形的排名算法,称为 TextRank。这种算法受 PageRank 的启发,PageRank 是由拉里·佩奇(Larry Page)发明的,用于在 Google 搜索中对网页进行排名。使用 PageRank 时,网站的重要性是由指向该网站的其他页面的数量来决定的。要将这种方法应用于文本处理,算法会衡量每个句子与其他句子的相似度。与其他句子最相似的句子被认为是最重要的。

在这个项目中,你将使用 gensim 来总结威廉·H·麦克雷文(William H. McRaven)海军上将于 2014 年在德克萨斯大学奥斯汀分校所做的毕业演讲《整理床铺》。这场鼓舞人心的 20 分钟演讲在 YouTube 上已经观看超过 1,000 万次,并于 2017 年激发了一本 纽约时报 的畅销书。

目标

编写一个使用 gensim 模块来总结演讲的 Python 程序。

安装 gensim

gensim 模块可以在所有主要操作系统上运行,但依赖于 NumPy 和 SciPy。如果你没有安装它们,请返回到 第一章,并按照“安装 Python 库”中的说明操作,第 6 页。

在 Windows 上安装 gensim,使用 pip install -U gensim。在终端中安装,使用 pip install --upgrade gensim。对于 conda 环境,使用 conda install -c conda-forge gensim。有关 gensim 的更多信息,请访问 radimrehurek.com/gensim/

整理床铺代码

在项目 3 中,你通过 dream_summary.py 程序学习了文本提取的基本概念。既然你已经了解了一些细节,现在可以使用 gensim 作为 dream_summary.py 的简化替代方案。命名这个新程序为 bed_summary.py,或者从本书网站下载它。

导入模块、抓取网页并准备演讲字符串

清单 3-6 重复了在dream_summary.py中用于准备演讲的代码。要重新查看详细的代码解释,请参阅第 54 页。

bed_summary.py, part 1
   import requests
   import bs4
   from nltk.tokenize import sent_tokenize
➊ from gensim.summarization import summarize

➋ url = 'https://jamesclear.com/great-speeches/make-your-bed-by-admiral
          -william-h-mcraven'
   page = requests.get(url)
   page.raise_for_status()
   soup = bs4.BeautifulSoup(page.text, 'html.parser')
   p_elems = [element.text for element in soup.find_all('p')]

   speech = ''.join(p_elems)

清单 3-6:导入模块并将演讲加载为字符串

您将在从网页抓取的原始演讲文本上测试 gensim,因此不需要用于清理文本的模块。gensim 模块也会在内部进行任何计数,因此您不需要 Counter,但您需要 gensim 的 summarize() 函数来总结文本 ➊。唯一的其他变化是 url 地址 ➋。

总结演讲内容

清单 3-7 通过总结演讲并打印结果完成了程序。

bed_summary.py, part 2
print("\nSummary of Make Your Bed speech:")
summary = summarize(speech, word_count=225)
sentences = sent_tokenize(summary)
sents = set(sentences)
print(' '.join(sents))

清单 3-7:运行 gensim,删除重复行并打印摘要

首先打印摘要的标题。然后,调用 gensim 的 summarize() 函数来总结 225 个字的演讲。根据平均每句 15 个字的假设,这个字数将产生约 15 个句子的摘要。除了字数,您还可以传递一个比例给 summarize(),例如 ratio=0.01。这将生成一个长度为完整文档 1%的摘要。

理想情况下,您可以一步完成演讲的总结和打印摘要。

print(summarize(speech, word_count=225))

不幸的是,gensim 有时会在摘要中重复句子,这在这里发生了。

Summary of Make Your Bed speech:
Basic SEAL training is six months of long torturous runs in the soft sand,
midnight swims in the cold water off San Diego, obstacle courses, unending
calisthenics, days without sleep and always being cold, wet and miserable.
Basic SEAL training is six months of long torturous runs in the soft sand,
midnight swims in the cold water off San Diego, obstacle courses, unending
calisthenics, days without sleep and always being cold, wet and miserable.
--snip--

为了避免重复文本,您首先需要使用 NLTK 的 sent_tokenize() 函数将摘要变量中的句子分解。然后从这些句子中创建一个集合,这将删除重复项。最后打印结果。

因为集合是无序的,所以如果您多次运行程序,句子的排列可能会发生变化。

Summary of Make Your Bed speech:
If you can't do the little things right, you will never do the big things
right.And, if by chance you have a miserable day, you will come home to a
bed that is made — that you made — and a made bed gives you encouragement
that tomorrow will be better.If you want to change the world, start off
by making your bed.During SEAL training the students are broken down into
boat crews. It's just the way life is sometimes.If you want to change the
world get over being a sugar cookie and keep moving forward.Every day during
training you were challenged with multiple physical events — long runs, long
swims, obstacle courses, hours of calisthenics — something designed to test
your mettle. Basic SEAL training is six months of long torturous runs in the
soft sand, midnight swims in the cold water off San Diego, obstacle courses,
unending calisthenics, days without sleep and always being cold, wet and
miserable.
>>>
======= RESTART: C:\Python372\sequel\wordcloud\bed_summary.py =======

Summary of Make Your Bed speech:
It's just the way life is sometimes.If you want to change the world get over
being a sugar cookie and keep moving forward.Every day during training you
were challenged with multiple physical events — long runs, long swims,
obstacle courses, hours of calisthenics — something designed to test your
mettle. If you can't do the little things right, you will never do the big
things right.And, if by chance you have a miserable day, you will come home to
a bed that is made — that you made — and a made bed gives you encouragement
that tomorrow will be better.If you want to change the world, start off by
making your bed.During SEAL training the students are broken down into boat
crews. Basic SEAL training is six months of long torturous runs in the soft
sand, midnight swims in the cold water off San Diego, obstacle courses,
unending calisthenics, days without sleep and always being cold, wet and
miserable.

如果你花时间阅读全文,你可能会得出结论,gensim 生成了一个公平的摘要。尽管这两个结果不同,但两者都提取了演讲的要点,包括提到如何整理床铺。考虑到文档的大小,我觉得这很令人印象深刻。

接下来,我们将看看使用关键词和词云总结文本的另一种方法。

项目 #5:使用词云总结文本

词云是用于显示网站上关键词元数据的文本数据的视觉表示。在词云中,字体大小或颜色显示每个标签或单词的重要性。

词云对于突出文档中的关键词非常有用。例如,为每位美国总统的国情咨文生成词云可以快速概述该年国家面临的问题。在比尔·克林顿的首年,重点放在了像医疗保健、就业和税收等和平时期的关切事项上(图 3-1)。

图像

图 3-1:由比尔·克林顿 1993 年国情咨文制作的词云

不到 10 年后,乔治·W·布什的词云显示出对安全问题的关注(图 3-2)。

图像

图 3-2:由乔治·W·布什在 2002 年的国情咨文中演讲的词云。

词云的另一个用途是从客户反馈中提取关键词。如果像这样的词占主导地位,那么你就有问题了!作家还可以使用词云来比较书中的章节或剧本中的场景。如果作者在动作场景和浪漫插曲中使用了非常相似的语言,就需要进行一些编辑。如果你是文案写手,词云可以帮助你检查关键词密度,以优化搜索引擎优化(SEO)。

生成词云的方法有很多,包括一些免费的在线网站,如 www.wordclouds.com/www.jasondavies.com/wordcloud/。但是,如果你想完全自定义你的词云,或者将生成器嵌入到另一个程序中,就需要自己动手。在本项目中,你将使用词云为学校戏剧制作一张宣传单,基于《福尔摩斯探案集》中的故事 《巴斯克维尔的猎犬》

你将不再使用图 3-1 和 3-2 中显示的基本矩形,而是将单词嵌入到福尔摩斯头部的轮廓中(见图 3-3)。

Image

图 3-3:福尔摩斯的剪影

这将使得显示效果更加易于识别和引人注目。

目标

使用 wordcloud 模块为小说生成一个定形的词云。

词云和 PIL 模块

你将使用名为 wordcloud 的模块来生成词云。你可以通过 pip 安装它。

pip install wordcloud

或者,如果你使用的是 Anaconda,可以使用以下命令:

conda install -c conda-forge wordcloud

你可以在这里找到 wordcloud 的网页:amueller.github.io/word_cloud/

你还需要使用 Python Imaging Library (PIL) 来处理图像。可以再次使用 pip 安装它。

pip install pillow

或者,对于 Anaconda,请使用此命令:

conda install -c anaconda pillow

如果你在想,pillow 是 PIL 的继任项目,PIL 在 2011 年已停止更新。要了解更多信息,请访问 pillow.readthedocs.io/en/stable/

词云代码

要生成定形的词云,你需要一个图像文件和一个文本文件。图 3-3 中显示的图像来自 Getty Images 的 iStock(www.istockphoto.com/vector/detective-hat-gm698950970-129478957/)。这是大约 500×600 像素的“低”分辨率图像。

本书的下载文件中提供了一个相似但不受版权保护的图像(holmes.png)。你可以在 Chapter_3 文件夹中找到文本文件(hound.txt)、图像文件(holmes.png)和代码(wc_hound.py)。

导入模块、文本文件、图像文件和停用词

示例 3-8 导入模块,加载小说,加载福尔摩斯的轮廓图像,并创建一组你想从词云中排除的停用词。

wc_hound.py, part 1
   import numpy as np
   from PIL import Image
   import matplotlib.pyplot as plt
   from wordcloud import WordCloud, STOPWORDS

   # Load a text file as a string.
➊ with open('hound.txt') as infile:
      text = infile.read()

   # Load an image as a NumPy array.
   mask = np.array(Image.open('holmes.png'))

   # Get stop words as a set and add extra words.
   stopwords = STOPWORDS
➋ stopwords.update(['us', 'one', 'will', 'said', 'now', 'well', 'man', 'may',
                     'little', 'say', 'must', 'way', 'long', 'yet', 'mean',
                     'put', 'seem', 'asked', 'made', 'half', 'much',
                     'certainly', 'might', 'came'])

示例 3-8:导入模块并加载文本、图像和停用词

首先导入 NumPy 和 PIL。PIL 将打开图像,NumPy 将其转换为蒙版。你在第一章中开始使用 NumPy;如果你跳过了该部分,请参阅第一章的“安装 Python 库”部分。请注意,Pillow 模块继续使用 PIL 的缩写,以保证向后兼容。

你将需要 matplotlib,它在第一章的“安装 Python 库”部分中已经下载,用于显示词云。wordcloud 模块自带一套停用词列表,因此需要同时导入 STOPWORDS 和词云功能。

接下来,加载小说的文本文件并将其存储在名为 text ➊ 的变量中。如第二章中示例 2-2 的讨论所述,在加载文本时,你可能会遇到 UnicodeDecodeError 错误。

UnicodeDecodeError: 'ascii' codec can't decode byte 0x93 in position 365:
ordinal not in range(128)

在这种情况下,尝试通过添加编码和错误参数来修改 open() 函数。

    with open('hound.txt', encoding='utf-8', errors='ignore') as infile:

加载文本后,使用 PIL 的 Image.open() 方法打开福尔摩斯的图像,并使用 NumPy 将其转换为数组。如果你使用的是 iStock 上的福尔摩斯图像,请适当更改图像文件名。

将从 wordcloud 导入的 STOPWORDS 集合分配给 stopwords 变量。然后,使用你想排除的额外单词更新该集合 ➋。这些将是像 saidnow 这样的词,它们在词云中占主导地位,但并未增加有用内容。确定哪些是这样的词是一个迭代过程。你生成词云,移除你认为不贡献的词,并重复此过程。你可以注释掉这行代码以查看效果。

注意

要更新像 STOPWORDS 这样的容器,你需要知道它是列表、字典、集合等。Python 的内建函数 type() 会返回传入对象的类类型。在这种情况下,print(type(STOPWORDS)) 的输出为 <class 'set'>。

生成词云

示例 3-9 生成词云并使用轮廓图像作为 蒙版,即用来隐藏另一图像部分的图像。wordcloud 使用的处理过程足够复杂,可以将词语适应于蒙版中,而不仅仅是将它们截断在边缘。此外,有多个参数可以更改蒙版中单词的外观。

wc_hound.py, part 2
wc = WordCloud(max_words=500,
               relative_scaling=0.5,
               mask=mask,
               background_color='white',
               stopwords=stopwords,
               margin=2,
               random_state=7,
               contour_width=2,
               contour_color='brown',
               colormap='copper').generate(text)

colors = wc.to_array()

示例 3-9:生成词云

给变量命名为 wc 并调用 WordCloud()。有很多参数,因此我将每个参数单独放在一行以便清晰。有关所有可用参数的列表和描述,请访问 amueller.github.io/word_cloud/generated/wordcloud.WordCloud.html

首先传入你想要使用的最大单词数。你设置的数字将显示文本中最常见的 n 个单词。选择显示更多单词将更容易定义掩码的边缘并使其可识别。不幸的是,设置的最大数字过高也会导致许多微小的、难以辨认的单词。对于这个项目,从 500 开始。

接下来,为了控制字体大小和每个单词的相对重要性,将 relative_scaling 参数设置为 0.5。例如,值为 0 时,优先根据单词的排名来确定字体大小,而值为 1 时,出现频率是两倍的单词将显示为两倍大。0 到 0.5 之间的值通常能在排名和频率之间取得最佳平衡。

引用 mask 变量并将其背景颜色设置为白色。未指定颜色时,默认为黑色。然后引用你在前一个列表中编辑的 stopwords 集。

margin 参数将控制显示单词的间距。使用 0 会导致单词紧密排列。使用 2 会允许一些空白填充。

要将单词放置在词云周围,使用随机数生成器并将 random_state 设置为 7。这个值并没有特别的含义;我只是觉得它产生了一个吸引人的单词排列。

random_state 参数固定种子值,以便结果可重复,前提是没有更改其他参数。这意味着单词的排列将始终相同。只接受整数。

现在,将 contour_width 设置为 2。任何大于零的值都会在掩码周围创建一个轮廓。在这种情况下,由于图像的分辨率,轮廓是波浪形的(图 3-4)。

使用 contour_color 参数将轮廓颜色设置为棕色。通过将 colormap 设置为 copper,继续使用棕色调的色盘。在 matplotlib 中,colormap 是一个字典,它将数字映射到颜色。copper 色盘生成的文本颜色范围从浅肤色到黑色。你可以在 matplotlib.org/gallery/color/colormap_reference.html 查看它的色谱以及许多其他颜色选项。如果你没有指定 colormap,程序将使用默认颜色。

使用点符号调用 generate() 方法来构建词云。将文本字符串作为参数传递给它。通过命名一个 colors 变量并在 wc 对象上调用 to_array() 方法来结束这个列表。该方法将词云图像转换为 NumPy 数组,以便与 matplotlib 一起使用。

Image

图 3-4:带有轮廓的掩码词云示例(左)与不带轮廓的示例(右)

绘制词云

列表 3-10 为词云添加标题,并使用 matplotlib 显示它。它还将词云图像保存为文件。

wc_hound.py, part 3
plt.figure()
plt.title("Chamberlain Hunt Academy Senior Class Presents:\n",
          fontsize=15, color='brown')
plt.text(-10, 0, "The Hound of the Baskervilles",
         fontsize=20, fontweight='bold', color='brown')
plt.suptitle("7:00 pm May 10-12 McComb Auditorium",
             x=0.52, y=0.095, fontsize=15, color='brown')
plt.imshow(colors, interpolation="bilinear")
plt.axis('off')
plt.show()
##plt.savefig('hound_wordcloud.png')

列表 3-10:绘制和保存词云

从初始化 matplotlib 图形开始。然后调用 title()方法,传递学校的名称、字体大小和颜色。

您希望剧本的名称比其他标题更大更粗。由于无法在 matplotlib 中改变字符串的文本样式,请使用 text()方法定义一个新标题。传递给它(x, y)坐标(基于图形轴)、文本字符串和文本样式细节。通过反复试验坐标来优化文本位置。如果您使用的是 Holmes 的 iStock 图片,您可能需要将x坐标从-10 更改为其他值,以达到最佳的平衡效果,尤其是在不对称的轮廓下。

完成标题时,将剧本的时间和地点放在图形的底部。您可以再次使用 text()方法,但我们来看看另一种方法——pyplot 的 suptitle()方法。这个名字代表“超级标题”。传递给它文本、(x, y)图形坐标和样式细节。

要显示词云,请调用 imshow()——用于图像显示——并传递之前创建的颜色数组。指定 bilinear 进行颜色插值。

通过调用 show()关闭图形坐标轴并显示词云。如果您想保存图形,可以取消注释 savefig()方法。请注意,matplotlib 可以读取文件名中的扩展名,并以正确的格式保存图形。按原样,保存命令将在您手动关闭图形后才会执行。

微调词云

列表 3-10 将生成图 3-5 中的词云。由于算法是随机的,您可能会得到不同的单词排列。

Image

图 3-5:由 wc_hound.py 代码生成的传单

您可以通过在初始化图形时添加一个参数来更改显示的大小。示例如下:plt.figure(figsize=(50, 60))。

还有许多其他方法可以更改结果。例如,将 margin 参数设置为 10 会生成一个更稀疏的词云(图 3-6)。

Image

图 3-6:使用 margin=10 生成的词云

更改random_state参数也会重新排列遮罩中的单词(图 3-7)。

Image

图 3-7:使用 margin=10 和 random_state=6 生成的词云

调整 max_words 和 relative_scaling 参数也会改变词云的外观。根据您的细致程度,这一切可能是福是祸!

总结

在本章中,您使用了基于提取的摘要技术,生成了马丁·路德·金“我有一个梦想”演讲的概要。然后,您使用了一个免费的现成模块 gensim,以更少的代码总结了麦克雷文海军上将的“整理床铺”演讲。最后,您使用 wordcloud 模块创建了一个有趣的单词设计。

进一步阅读

用 Python 自动化枯燥的事情:完全初学者的实用编程(No Starch Press,2015 年),由阿尔·斯威加特编写,涵盖了第七章中的正则表达式和第十一章中的网页抓取,包括使用 requests 和 Beautiful Soup 模块。

整理床铺:那些能改变你生活的小事……也许还能改变世界,第二版(大中央出版社,2017 年),威廉·H·麦克雷文著,是一本基于这位海军上将于德克萨斯大学的毕业典礼演讲的自助书籍。你可以在 www.youtube.com/ 上找到这篇演讲的实际视频。

挑战项目:游戏之夜

使用 wordcloud 为游戏之夜发明一个新游戏。总结维基百科或 IMDb 的电影简介,看看你的朋友们能否猜出电影名称。图 3-8 展示了一些示例。

图片

图 3-8:2010 年发布的两部电影的词云:如何训练你的龙和波斯王子

如果你不喜欢电影,可以选择其他内容。替代品包括著名小说、星际迷航剧集以及歌词(见图 3-9)。

图片

图 3-9:从歌曲歌词中制作的词云(唐纳德·费根的“I.G.Y.”)

桌面游戏近年来有了复兴,所以你可以跟随这一趋势,将词云打印在卡纸上。或者,你可以保持数字化,给玩家提供每个词云的多项选择答案。游戏应跟踪正确答案的数量。

挑战项目:总结摘要

使用项目 3 中的程序对之前总结过的文本进行测试,比如维基百科页面。仅五个句子就能提供一个关于 gensim 的良好概述。

Enter max words per sentence for summary: 30
Enter number of sentences for summary: 5

SUMMARY:
Gensim is implemented in Python and Cython.
Gensim is an open-source library for unsupervised topic modeling and natural
language processing, using modern statistical machine learning.
[12] Gensim is commercially supported by the company rare-technologies.com,
who also provide student mentorships and academic thesis projects for Gensim
via their Student Incubator programme.
The software has been covered in several new articles, podcasts and
interviews.
Gensim is designed to handle large text collections using data streaming and
incremental online algorithms, which differentiates it from most other machine
learning software packages that target only in-memory processing.

接下来,尝试在那些没人愿意阅读的无聊服务协议上使用项目 4 中的 gensim 版本。一个微软协议的示例可在 www.microsoft.com/en-us/servicesagreement/default.aspx 获取。当然,要评估结果,你必须阅读完整的协议,而几乎没有人愿意这么做!享受这个进退两难的困境吧!

挑战项目:总结一部小说

编写一个程序,通过章节总结《巴斯克维尔的猎犬》。每个章节的总结保持简短,大约 75 个单词。

若要获取带章节标题的小说副本,可以通过以下代码从古腾堡计划网站抓取文本:url = 'http://www.gutenberg.org/files/2852/2852-h/2852-h.htm'。

若要分解章节元素,而非段落元素,使用此代码:

chapter_elems = soup.select('div[class="chapter"]')
chapters = chapter_elems[2:]

你还需要从每个章节中选择段落元素(p_elems),使用与 dream_summary.py 中相同的方法。

以下代码片段展示了使用每章 75 个单词的结果:

--snip--

Chapter 3:
"Besides, besides—" "Why do you hesitate?” "There is a realm in which the most
acute and most experienced of detectives is helpless." "You mean that the
thing is supernatural?" "I did not positively say so." "No, but you evidently
think it." "Since the tragedy, Mr. Holmes, there have come to my ears several
incidents which are hard to reconcile with the settled order of Nature." "For
example?" "I find that before the terrible event occurred several people had
seen a creature upon the moor which corresponds with this Baskerville demon,
and which could not possibly be any animal known to science.

--snip--

Chapter 6:
"Bear in mind, Sir Henry, one of the phrases in that queer old legend which
Dr. Mortimer has read to us, and avoid the moor in those hours of darkness
when the powers of evil are exalted." I looked back at the platform when we
had left it far behind and saw the tall, austere figure of Holmes standing
motionless and gazing after us.

Chapter 7:
I feared that some disaster might occur, for I was very fond of the old man,
and I knew that his heart was weak." "How did you know that?" "My friend
Mortimer told me." "You think, then, that some dog pursued Sir Charles, and
that he died of fright in consequence?" "Have you any better explanation?" "I
have not come to any conclusion." "Has Mr. Sherlock Holmes?" The words took
away my breath for an instant but a glance at the placid face and steadfast
eyes of my companion showed that no surprise was intended.

--snip--

Chapter 14:
"What’s the game now?" "A waiting game." "My word, it does not seem a very
cheerful place," said the detective with a shiver, glancing round him at the
gloomy slopes of the hill and at the huge lake of fog which lay over the
Grimpen Mire.

Far away on the path we saw Sir Henry looking back, his face white in the
moonlight, his hands raised in horror, glaring helplessly at the frightful
thing which was hunting him down.

--snip--

挑战项目:不仅仅是你说了什么,更重要的是你怎么说!

你迄今为止编写的文本摘要程序是严格按照句子的重要性顺序打印句子的。这意味着演讲(或任何文本)中的最后一句话可能会成为摘要中的第一句。摘要的目标是找到重要的句子,但你完全可以改变它们的展示方式。

编写一个文本摘要程序,显示最重要的句子,并保持它们原始的出现顺序。将结果与第 3 个项目中的程序生成的结果进行比较。这样做会对摘要产生明显的改进吗?

第四章:使用书籍密码发送超级机密信息

Image

Rebecca 的密码》是肯·福莱特(Ken Follett)创作的一本广受好评的畅销小说。故事背景设定在二战时期的开罗,基于真实事件,讲述了一名纳粹间谍和一名追捕他的英国情报官员的故事。书名指的是间谍使用的密码系统,它以达芙妮·杜穆里埃(Daphne du Maurier)所著的著名哥特式小说《Rebecca》为密钥。Rebecca被认为是 20 世纪最伟大的小说之一,德国人确实在战争期间将它作为密码书使用。

Rebecca 密码是一次性密钥的一种变体,一种无法破解的加密技术,它需要一个与所发送的信息大小至少相同的密钥。发送方和接收方各自持有一份密钥本,使用一次后,最上面的一张纸会被撕掉并丢弃。

一次性密钥提供绝对的、完美的安全性——即使是量子计算机也无法破解!尽管如此,密钥本仍有一些实际缺点,阻碍了它的广泛使用。其中最主要的是需要安全地传输和交付密钥本给发送方和接收方,存储它们的安全性,以及手动编码和解码消息的难度。

在《Rebecca 的密码》中,双方必须了解加密规则并且拥有相同版本的书籍才能使用该密码。在本章中,你将把书中描述的手动方法转化为一种更安全且更易于使用的数字技术。在这个过程中,你将使用到 Python 标准库、collections 模块和 random 模块中的一些有用函数。你还将稍微了解一下 Unicode 标准,这个标准确保了字母、数字等字符在所有平台、设备和应用中都能普遍兼容。

一次性密钥

一次性密钥本质上是一个有序的纸张堆,每张纸上印有真正随机的数字,通常以五个一组的方式排列(见图 4-1)。为了方便隐藏,这些密钥本通常很小,可能需要一只强力放大镜才能读取。尽管这种方法显得有些老派,但一次性密钥产生的密码是世界上最安全的,因为每个字母都使用一个唯一的密钥进行加密。因此,密码分析技术,如频率分析,根本无法奏效。

Image

图 4-1:一次性密钥纸张示例

要使用图 4-1 中的一次性密钥加密消息,首先为字母表中的每个字母分配一个两位数字。A等于 01,B等于 02,以此类推,如下表所示。

Image

接下来,将你的简短信息中的字母转换成数字:

Image

从一次性密码本页的左上角开始,按从左到右的顺序,为每个字母分配一个数字对(密钥),并将其加到该字母的数字值上。你将使用基数为 10 的数字对,因此,如果你的和大于 100,则使用模运算将结果截断为最后两位数字(103 变为 03)。下图中阴影单元格内的数字就是模运算的结果。

图片

此图中的最后一行表示密文。请注意,在明文中重复的“KITTY”在密文中并未重复。每次加密“KITTY”都是唯一的。

要将密文解密回明文,接收方使用与其相同的密码本页。他们将自己的数字对放在密文对下方,然后进行减法运算。当减法结果为负数时,使用模运算(在减法前将密文值加上 100)。最后,他们将结果数字对转换回字母。

图片

为了确保没有重复的密钥,消息中的字母数不能超过密码本上的密钥数。这就要求使用简短的消息,简短的消息有一个优点,就是更容易加密和解密,而且提供给密码分析员的破解机会更少。其他一些指导原则包括:

  • 将数字写出(例如,2 写作 TWO)。

  • X代替句号结束句子(例如,CALL AT NOONX)。

  • 将无法避免的其他标点符号写出来(例如,逗号)。

  • XX结束明文消息。

丽贝卡密码

在小说《丽贝卡的钥匙》中,纳粹间谍使用了一种变体的一次性密码本。葡萄牙购买了相同版本的小说《丽贝卡》。其中两本由间谍保留,另两本交给北非的隆美尔元帅的工作人员。加密消息通过预定频率的无线电发送。每天最多发送一条消息,并且总是在午夜。

为了使用密钥,间谍需要获取当前日期——比如 1942 年 5 月 28 日——然后将当天的日期与年份相加(28 + 42 = 70)。这将决定使用哪一页小说作为一次性密码本。因为 5 月是第五个月,因此每个句子中的每第五个单词将被忽略。由于《丽贝卡密码》是计划仅在 1942 年相对短时间内使用,间谍不必担心日历中的重复会导致密钥的重复。

间谍的第一条消息如下:HAVE ARRIVED. CHECKING IN. ACKNOWLEDGE。 从第 70 页开始,他沿着页面读,直到找到字母H。它是第 10 个字符,忽略每第五个字母。字母表中的第 10 个字母是J,因此他在密文中使用这个字母代表H。接下来的字母AH之后三个字母的位置找到,因此用字母表中的第三个字母C进行编码。这种方式一直持续,直到整个消息被加密。对于像XZ这样的稀有字母,作者 Ken Follett 表示应用了特殊规则,但没有详细描述这些规则。

以这种方式使用书籍相较于真正的一次性密钥有明显的优势。引用福尔特的话,“密钥本身显然是为了加密目的,但书籍看起来十分无辜。”然而,仍然有一个缺点:加密和解密的过程繁琐且可能出错。让我们看看能否使用 Python 解决这个问题!

项目 #6:Rebecca 的数字密钥

将《Rebecca》技巧转化为数字程序,相较于一次性密钥有几个优势:

  • 编码和解码过程变得快速且无误。

  • 可以发送更长的消息。

  • 句号、逗号甚至空格都可以直接加密。

  • z这样的稀有字母可以从书中的任何地方选择。

  • 密码书可以隐藏在硬盘或云端数千本电子书中。

最后一项是非常重要的。在小说中,英国情报官员在一个被俘的德军前哨中发现了《Rebecca》的副本。通过简单的推理,他认出了它是一次性密钥的替代品。然而,如果是数字方法,这将更加困难。事实上,这本小说可以保存在一个小而易于隐藏的设备中,比如 SD 卡。这使得它与一次性密钥相似,一次性密钥通常不大于邮票。

然而,数字方法确实有一个缺点:程序是可发现的。虽然间谍可以简单地记住一次性密钥的规则,但在数字方法中,规则必须嵌入到软件中。通过编写看似无辜——或者至少是神秘——的程序,并让它请求用户输入消息和密码书的名称,可以最大限度地减少这个弱点。

目标

编写一个 Python 程序,使用数字小说作为一次性密钥加密和解密信息。

策略

与间谍不同,你不需要小说中所有的规则,许多规则实际上根本无法使用。如果你曾使用过任何形式的电子书,你会知道页码是没有意义的。屏幕大小和文字大小的变化使得所有页码都不再唯一。而且因为你可以从书中的任何地方选择字母,你不一定需要为稀有字母或忽略计数中的数字设置特别的规则。

所以,你不需要完美地再现《Rebecca》密码。你只需生成类似的东西,最好是更好。

幸运的是,Python 的可迭代对象,比如列表和元组,使用数字索引来跟踪其中的每一项。通过将小说加载为列表,你可以利用这些索引作为每个字符的独特起始密钥。然后,你可以根据年份的天数来移动索引,模拟《Rebecca 的关键》中间谍的加密方法。

不幸的是,《Rebecca》目前还未进入公有领域。作为替代,我们将使用你在第二章中使用的亚瑟·柯南·道尔爵士的《失落的世界》文本文件。这本小说包含 51 个独特字符,出现了 421,545 次,因此你可以随机选择索引,几乎不必担心重复。这意味着你可以每次加密时都使用整本书作为一次性密钥,而不必局限于单张一次性密钥表上的少量数字。

注意

如果你愿意,你可以下载并使用《Rebecca》的数字版。我只是不能免费提供给你!

因为你会重复使用这本书,你需要考虑到消息对消息信息内的密钥重复问题。消息越长,密码分析员可以研究的材料越多,破解代码就越容易。如果每条消息都使用相同的加密密钥,所有拦截到的消息就可以视为一条大消息。

对于消息对消息的问题,你可以模仿间谍并根据年份的天数来移动索引数字,使用 1 到 366 的范围来考虑闰年。在这个方案中,2 月 1 日将是 32。这样,每次加密时,书籍就会变成一个全新的一次性密钥表,因为相同字符会使用不同的密钥。移动一个或多个增量会重置所有索引,实际上是“撕掉”了之前的密钥表。与一次性密钥不同,你不必担心丢弃一张纸!

对于信息内重复的问题,在传输消息之前,你可以先进行检查。虽然这种情况不太可能发生,但程序在加密过程中有可能选取相同的字母两次,从而使用相同的索引两次。重复的索引基本上是重复的密钥,这会帮助密码分析员破译你的代码。所以,如果发现有重复的索引,你可以重新运行程序或重新措辞消息。

你还需要类似于《Rebecca 的关键》中的规则。

  • 双方需要拥有相同的失落的世界数字副本。

  • 双方需要知道如何移动索引。

  • 尽量保持信息简洁。

  • 请写出数字。

加密代码

以下的rebecca.py代码会接收一条消息,并根据用户的要求返回加密或明文版本。消息可以直接输入,或从书籍网站下载。你还需要一个名为lost.txt的文本文件,放在与代码相同的文件夹中。

为了清晰起见,你将使用 ciphertextencryptmessage 等变量名。不过,如果你真是一个间谍,恐怕你会避免使用那些可能暴露身份的术语,免得敌人拿到你的笔记本。

导入模块并定义 main() 函数

示例 4-1 导入了模块并定义了 main() 函数,用于运行程序。该函数会请求用户输入,调用所需的加密或解密函数,检查是否有重复的键,并打印出密文或明文。

是否在程序的开始或结束定义 main() 函数取决于个人选择。有时候,它可以作为整个程序的一个简洁、易读的总结。其他时候,它可能显得不合时宜,就像“倒马车”一样。从 Python 的角度来看,只要在程序结束时调用该函数,放置的位置并不重要。

rebecca.py, part 1
import sys
import os
import random
from collections import defaultdict, Counter
def main():
    message = input("Enter plaintext or ciphertext: ")
    process = input("Enter 'encrypt' or 'decrypt': ")
    while process not in ('encrypt', 'decrypt'):
        process = input("Invalid process. Enter 'encrypt' or 'decrypt': ")
    shift = int(input("Shift value (1-366) = "))
    while not 1 <= shift <= 366:
        shift = int(input("Invalid value. Enter digit from 1 to 366: ")
 ➊ infile = input("Enter filename with extension: ")

    if not os.path.exists(infile):
        print("File {} not found. Terminating.".format(infile), file=sys.stderr)
        sys.exit(1)    
    text = load_file(infile)
    char_dict = make_dict(text, shift)

    if process == 'encrypt':
        ciphertext = encrypt(message, char_dict)
     ➋ if check_for_fail(ciphertext):
            print("\nProblem finding unique keys.", file=sys.stderr)
            print("Try again, change message, or change code book.\n",         
                  file=sys.stderr)
            sys.exit()
     ➌ print("\nCharacter and number of occurrences in char_dict: \n")
        print("{: >10}{: >10}{: >10}".format('Character', 'Unicode', 'Count'))
        for key in sorted(char_dict.keys()):
            print('{:>10}{:>10}{:>10}'.format(repr(key)[1:-1],
                                              str(ord(key)), 
                                              len(char_dict[key])))
        print('\nNumber of distinct characters: {}'.format(len(char_dict)))
        print("Total number of characters: {:,}\n".format(len(text)))

        print("encrypted ciphertext = \n {}\n".format(ciphertext))    
        print("decrypted plaintext = ")

     ➍ for i in ciphertext:
            print(text[i - shift], end='', flush=True)

   elif process == 'decrypt':
       plaintext = decrypt(message, text, shift)
       print("\ndecrypted plaintext = \n {}".format(plaintext))

示例 4-1:导入模块并定义 main() 函数

从导入 sys 和 os 开始,它们是两个让你能够与操作系统交互的模块;然后是 random 模块;接着是从 collections 模块导入 defaultdict 和 Counter。

collections 模块是 Python 标准库的一部分,包含了多个容器数据类型。你可以使用 defaultdict 来动态构建字典。如果 defaultdict 遇到缺失的键,它会提供默认值,而不是抛出错误。你将使用它来构建 《失落的世界》 中的字符字典及其对应的索引值。

Counter 是字典的子类,用于计数可哈希的对象。元素作为字典的键存储,其计数值作为字典的值存储。你将使用它来检查密文,并确保没有重复的索引。

在这一点,你开始定义 main() 函数。该函数首先会请求用户输入要加密或解密的消息。为了最大限度地提高安全性,用户应该手动输入此消息。接着,程序询问用户是要进行加密还是解密。一旦用户做出选择,程序会请求输入移位值。移位值代表一年中的日期,范围为 1 到 366(包括)。接下来,程序会要求输入 infile,这将是 lost.txt,即 《失落的世界》 的数字版 ➊。

在继续之前,程序会检查文件是否存在。它使用操作系统模块的 path.exists() 方法,并传入 infile 变量。如果文件不存在或路径和/或文件名不正确,程序会告知用户,使用 file=sys.stderr 选项在 Python Shell 中将“错误”消息显示为红色,并使用 sys.exit(1) 终止程序。1 用于标记程序因错误终止,而非正常终止。

接下来,你会调用一些稍后定义的函数。第一个函数将lost.txt文件加载为一个名为 text 的字符串,其中包括空格和标点等非字母字符。第二个函数构建一个字符及其对应索引的字典,并应用移位值。

现在,你开始使用条件判断来评估正在使用的处理过程。正如我之前提到的,为了清晰起见,我们使用了加密解密这样的术语。在真正的间谍工作中,你会希望掩盖这些术语。如果用户选择了加密,则调用该函数,使用字符字典加密消息。当该函数返回时,程序已成功加密消息。但不要认为它一定如预期般工作!你需要检查它是否能正确解密,并确保没有重复的键。为此,你将开始一系列质量控制步骤。

首先,你检查是否有重复的键 ➋。如果此函数返回 True,提示用户重试、更改消息,或将书籍换成其他书籍,而不是失落的世界。对于消息中的每个字符,你将使用 char_dict 并随机选择一个索引。即使每个字符有数百甚至数千个索引,你也可能会为某个字符多次选择相同的索引。

使用稍有不同参数重新运行程序,应该能够解决这个问题,除非你的消息很长且包含大量低频字符。处理这种罕见情况可能需要重新措辞消息,或者找一本比失落的世界更大的手稿。

注意

Python 的 random 模块并不会产生真正的随机数,而是产生可以预测的伪随机数。任何使用伪随机数的密码系统都有可能被密码分析师破解。为了在生成随机数时获得最大安全性,应该使用 Python 的 os.urandom()函数。

现在,打印字符字典的内容,这样你可以看到各种字符在小说中出现的次数 ➌。这将帮助你指导信息的编写,尽管失落的世界中包含了大量有用的字符。

Character and number of occurrences in char_dict: 

 Character   Unicode     Count
        \n        10      7865
                  32     72185
         !        33       282
         "        34      2205
         '        39       761
         (        40        62
         )        41        62
         ,        44      5158
         -        45      1409
         .        46      3910
         0        48         1
         1        49         7
         2        50         3
         3        51         2
         4        52         2
         5        53         2
         6        54         1
         7        55         4
         8        56         5
         9        57         2
         :        58        41
         ;        59       103
         ?        63       357
         a        97     26711
         b        98      4887
         c        99      8898
         d       100     14083
         e       101     41156
         f       102      7705
         g       103      6535
         h       104     20221
         i       105     21929
         j       106       431
         k       107      2480
         l       108     13718
         m       109      8438
         n       110     21737
         o       111     25050
         p       112      5827
         q       113       204
         r       114     19407
         s       115     19911
         t       116     28729
         u       117     10436
         v       118      3265
         w       119      8536
         x       120       573
         y       121      5951
         z       122       296
         {       123         1
         }       125         1

Number of distinct characters: 51
Total number of characters: 421,545

为了生成这个表格,你使用 Python 的格式规范迷你语言(docs.python.org/3/library/string.html#formatspec)来打印三个列的标题。大括号中的数字表示字符串中应包含的字符数,而大于号表示右对齐。

然后程序遍历字符字典中的键,并使用相同的列宽和对齐方式打印它们。它打印字符、字符的 Unicode 值,以及它在文本中出现的次数。

你可以使用 repr() 来打印键。这个内建函数返回一个字符串,包含对象的可打印表示形式。也就是说,它返回关于对象的所有信息,以便调试和开发使用的格式。这允许你显式地打印像换行符 (\n) 和空格这样的字符。索引范围 [1:-1] 排除了输出字符串两边的引号。

ord() 内建函数返回一个整数,表示字符的 Unicode 代码点。计算机只能处理数字,因此必须为每个可能的字符分配一个数字,例如 %、5、ImageAUnicode 标准 确保每个字符,不论平台、设备、应用程序或语言,都有一个唯一的数字,并且具有普遍兼容性。通过向用户展示 Unicode 值,程序使用户能够察觉文本文件中发生的任何异常情况,例如同一个字母以多个不同的字符形式出现。

对于第三列,你获取每个字典键的长度。它表示该字符在小说中出现的次数。程序然后打印出不同字符的数量以及文本中所有字符的总数。

最后,你通过打印密文和解密后的明文来完成加密过程,以便检查。为了破译消息,程序遍历密文中的每个项,并使用该项作为文本 ➍ 的索引,减去之前添加的移位值。当你打印结果时,程序使用 end='' 替代默认的换行符,因此每个字符不会显示在单独的一行。

main() 函数以一个条件语句结束,用于检查 process == 'decrypt'。如果用户选择解密消息,程序将调用 decrypt() 函数,然后打印解密后的明文。请注意,你可以在这里简单地使用 else,但我选择使用 elif 以提高清晰度和可读性。

加载文件并创建字典

清单 4-2 定义了用于加载文本文件并创建文件中字符及其对应索引的字典的函数。

rebecca.py, part 2
   def load_file(infile):
       """Read and return text file as a string of lowercase characters."""
       with open(infile) as f:
           loaded_string = f.read().lower()
       return loaded_string

➊ def make_dict(text, shift):
       """Return dictionary of characters and shifted indexes."""
       char_dict = defaultdict(list)
       for index, char in enumerate(text):
         ➋ char_dict[char].append(index + shift)
       return char_dict

清单 4-2:定义 load_file() 和 make_dict() 函数

这个清单开始时定义了一个函数,用于将文本文件加载为字符串。使用 with 打开文件确保文件在函数结束时自动关闭。

一些用户在加载文本文件时,可能会遇到类似以下的错误:

UnicodeDecodeError: 'charmap' codec can't decode byte 0x81 in position
27070:character maps to <undefined>

在这种情况下,尝试通过添加编码(encoding)和错误(errors)参数来修改 open 函数。

  with open(infile, encoding='utf-8', errors='ignore') as f:

关于这个问题的更多信息,请参见 第 35 页 中的 第二章。

打开文件后,将文件内容读取为一个字符串,并将所有文本转换为小写字母。然后返回该字符串。

下一步是将字符串转换为字典。定义一个函数,接受这个字符串和移位值作为参数 ➊。程序使用 defaultdict() 创建一个 char_dict 变量。这个变量将是一个字典。然后,程序将列表的类型构造函数传递给 defaultdict(),因为你希望字典的值是包含索引的列表。

使用 defaultdict() 时,每当一个操作遇到字典中尚不存在的项时,名为 default_factory() 的函数将被调用且不带参数,其输出将作为该项的值。任何不存在的键都会获得由 default_factory 返回的值,而且不会抛出 KeyError

如果你试图在没有方便的 collections 模块的情况下动态创建字典,你将得到 KeyError,如下例所示。

>>> mylist = ['a', 'b', 'c']
>>> d = dict()
>>> for index, char in enumerate(mylist):
 d[char].append(index)

Traceback (most recent call last):
 File "<pyshell#16>", line 2, in <module>
  d[char].append(index)
KeyError: 'a'

内置的 enumerate() 函数充当自动计数器,因此你可以轻松获取从 失落的世界 中提取的字符串中每个字符的索引。char_dict 中的键是字符,而这些字符在文本中可能出现成千上万次。因此,字典的值是保存所有这些字符出现位置的索引列表。当将移位值添加到索引并将其附加到值列表时,你可以确保每个消息的索引都是唯一的 ➋。

通过返回字符字典来结束函数。

加密消息

清单 4-3 定义了一个加密消息的函数。生成的密文将是一个索引列表。

rebecca.py, part 3
def encrypt(message, char_dict):
    """Return list of indexes representing characters in a message."""
    encrypted = []
    for char in message.lower():
     ➊ if len(char_dict[char]) > 1:
            index = random.choice(char_dict[char])
        elif len(char_dict[char]) == 1: # Random.choice fails if only 1 choice
            index = char_dict[char][0]
     ➋ elif len(char_dict[char]) == 0:
            print("\nCharacter {} not in dictionary.".format(char),
                  file=sys.stderr)
            continue
        encrypted.append(index)
    return encrypted

清单 4-3:定义一个加密明文消息的函数

encrypt() 函数将把消息和 char_dict 作为参数传入。首先,通过创建一个空列表来存储密文。接着,开始遍历消息中的字符并将它们转换为小写,以匹配 char_dict 中的字符。

如果与字符关联的索引数量大于 1,程序将使用 random.choice() 方法随机选择该字符的一个索引 ➊。

如果字符在 char_dict 中仅出现一次,random.choice() 会抛出一个错误。为了解决这个问题,程序使用条件语句,并硬编码选择索引的方式,索引将位于位置 [0]。

如果字符在 失落的世界 中不存在,它也不会出现在字典中,因此使用条件语句检查此情况 ➋。如果评估为 True,则打印警告信息,并使用 continue 返回循环的开始,而不选择索引。稍后,当质量控制步骤在密文上运行时,解密后的明文中将会出现一个空格,表示该字符的位置。

如果没有调用 continue,那么程序将把索引添加到加密列表中。当循环结束时,你会返回该列表以结束函数。

为了了解其工作原理,我们来看看纳粹间谍在 Rebecca 的钥匙 中发送的第一条消息,示例如下:

已到达。正在检查。确认。

使用这个消息和 70 的移位值生成了以下随机生成的密文:

[125711, 106950, 85184, 43194, 45021, 129218, 146951, 157084, 75611, 122047,
121257, 83946, 27657, 142387, 80255, 160165, 8634, 26620, 105915, 135897,
22902, 149113, 110365, 58787, 133792, 150938, 123319, 38236, 23859, 131058,
36637, 108445, 39877, 132085, 86608, 65750, 10733, 16934, 78282]

由于算法的随机性,你的结果可能会有所不同。

解密消息

示例 4-4 定义了一个函数来解密密文。用户会在 main() 函数提示输入时复制并粘贴密文。

rebecca.py, part 4
def decrypt(message, text, shift):
    """Decrypt ciphertext list and return plaintext string."""
    plaintext = ''
    indexes = [s.replace(',', '').replace('[', '').replace(']', '')
               for s in message.split()]
    for i in indexes:
        plaintext += text[int(i) - shift]
    return plaintext

示例 4-4:定义一个函数来解密明文消息

该示例首先定义了一个名为 decrypt() 的函数,参数包括消息、正文(文本)和位移值。当然,消息将是密文形式,由表示位移索引的数字列表组成。你立即创建一个空字符串来存储解密后的明文。

大多数人会在 main() 函数提示输入时复制并粘贴密文。此输入可能包含或不包含列表中的方括号。而且,由于用户是通过 input() 函数输入密文,因此结果是一个 字符串。为了将索引转换为可以进行位移的整数,首先需要删除非数字字符。可以使用字符串的 replace() 和 split() 方法,同时利用列表推导式返回一个列表。列表推导式是 Python 中执行循环的简洁方式。

要使用 replace(),你需要传入你想要替换的字符,后跟用于替换的字符。在这种情况下,使用空格进行替换。注意,你可以通过点符号将它们“串联”在一起,一次性处理逗号和方括号。是不是很酷?

接下来,开始遍历索引。程序将当前的索引从字符串转换为整数,以便你可以减去在加密时应用的位移值。你使用索引访问字符列表并获取相应的字符。然后将字符添加到明文字符串中,并在循环结束时返回明文。

检查失败并调用 main() 函数

示例 4-5 定义了一个函数来检查密文中的重复索引(密钥),并通过调用 main() 函数结束程序。如果该函数发现重复的索引,可能是加密已被破坏,main() 函数会告诉用户如何修复它,然后终止程序。

rebecca.py, part 5
def check_for_fail(ciphertext):
    """Return True if ciphertext contains any duplicate keys."""
    check = [k for k, v in Counter(ciphertext).items() if v > 1]
    if len(check) > 0:
        return True

if __name__ == '__main__':
    main()

示例 4-5:定义一个函数检查重复的索引并调用 main()

这个代码列出了一个名为 check_for_fail() 的函数,它接受密文作为参数。它会检查密文中的任何索引是否有重复。记住,一次性密码本的方法之所以有效,是因为每个密钥都是唯一的;因此,密文中的每个索引应该是唯一的。

为了查找重复项,程序再次使用了 Counter。它使用列表推导式构建一个包含所有重复索引的列表。这里,k 代表(字典)键,v 代表(字典)值。由于 Counter 生成每个键的计数字典,你可以理解为:对于从密文生成的字典中的每个键值对,创建一个包含所有出现超过一次的键的列表。如果有重复项,就将相应的键添加到检查列表中。

现在你需要做的就是获取检查的长度。如果它大于零,则表示加密已被破解,程序将返回 True。

程序以调用程序作为模块或独立模式的样板代码结束。

发送消息

以下消息摘自《Rebecca 的关键》。你可以在可下载的Chapter_4文件夹中找到它,文件名为allied_attack_plan.txt。作为测试,尝试用 70 的位移发送它。当请求输入时,使用操作系统的全选、复制和粘贴命令来传输文本。如果它未通过 check_for_fail()测试,重新运行一次!

Allies plan major attack for Five June. Begins at oh five twenty with
bombardment from Aslagh Ridge toward Rommel east flank. Followed by tenth
Indian Brigade infantry with tanks of twenty second Armored Brigade on Sidi
Muftah. At same time, thirty second Army Tank Brigade and infantry to charge
north flank at Sidra Ridge. Three hundred thirty tanks deployed to south and
seventy to north.

这个技术的好处在于你可以使用适当的标点符号,至少如果你将消息输入到解释器窗口中。通过外部复制的文本可能需要去除换行符(例如\r\n 或\n),并将其放置在回车符出现的地方。

当然,只有《失落的世界》中的字符可以被加密。程序会警告你出现异常的情况,然后用空格替代缺失的字符。

为了保持隐秘,你不想将明文或密文消息保存到文件中。从终端复制和粘贴是最好的方法。只需记得在完成后复制一些新的内容,以免在剪贴板上留下可疑证据!

如果你想更花哨一些,可以使用 Al Sweigart 编写的 pyperclip 库,直接从 Python 复制和粘贴文本到剪贴板。你可以在pypi.org/project/pyperclip/了解更多信息。

总结

在这一章中,你学会了使用 collections 模块中的 defaultdict 和 Counter;random 模块中的 choice();以及 Python 标准库中的 replace()、enumerate()、ord()和 repr()。最终,制作出了一个基于一次性密码本技术的加密程序,能够生成无法破解的密文。

进一步阅读

《Rebecca 的关键》(Penguin Random House, 1980),作者肯·福雷特,是一本令人兴奋的小说,以其深刻的历史细节、对二战期间开罗的准确描绘以及扣人心弦的间谍故事情节而著称。

《密码书:从古埃及到量子密码学的秘密科学》(Anchor, 2000),作者西蒙·辛格,是一本有趣的密码学历史回顾,包括对一次性密码本的讨论。

如果你喜欢破解密码,可以查看 Cracking Codes with Python(No Starch Press, 2018),作者 Al Sweigart。该书面向密码学和 Python 编程初学者,涵盖了许多密码类型,包括反向密码、凯撒密码、换位密码、替换密码、仿射密码和维吉尼亚密码。

Impractical Python Projects: Playful Programming Activities to Make You Smarter(No Starch Press, 2019),由 Lee Vaughan 著,书中包括额外的密码,例如联合路由密码、铁路栅栏密码和特雷瓦尼恩空密码,以及用隐形电子墨水写作的技巧。

实践项目:字符图表

如果你已经安装了 matplotlib(请参见第 6 页的“安装 Python 库”),你可以使用条形图将《失落的世界》中的可用字符及其出现频率可视化。这可以补充当前在 rebecca.py 程序中使用的每个字符及其计数的 shell 输出。

互联网上充斥着关于 matplotlib 图表的示例代码,所以只需搜索 制作简单的条形图 matplotlib。在绘制之前,您需要先按降序排序计数。

记住英语中最常见字母的助记符是“etaoin”。如果按降序绘图,你会发现 失落的世界 数据集也不例外(图 4-2)!

Image

图 4-2:在《失落的世界》数字版中字符出现的频率

请注意,最常见的字符是空格。这使得加密空格变得容易,进一步困扰任何密码分析!

你可以在附录和书籍网站上找到解决方案 practice_barchart.py

实践项目:第二次世界大战时期的秘密通信

根据维基百科上关于 Rebecca 的文章(*en.wikipedia.org/wiki/Rebecca_(novel),二战期间德国人确实尝试使用这本小说作为书码的关键。与其逐字编码消息,不如使用书中的单词来构成句子,按页码、行号和行内位置来引用。

复制并编辑 rebecca.py 程序,使其使用单词而不是字母。为了帮助你入门,这里有一种使用列表推导式将文本文件作为单词列表加载的方法,而不是字符:

with open('lost.txt') as f:
  words = [word.lower() for line in f for word in line.split()]
  words_no_punct = ["".join(char for char in word if char.isalpha())
                for word in words]

print(words_no_punct[:20])  # Print first 20 words as a QC check

输出应该像这样:

['i', 'have', 'wrought', 'my', 'simple', 'plan', 'if', 'i', 'give', 'one',
'hour', 'of', 'joy', 'to', 'the', 'boy', 'whos', 'half', 'a', 'man']

请注意,所有标点符号,包括撇号,已被去除。消息需要遵循这一惯例。

你还需要处理《失落的世界》中未出现的单词,比如专有名词和地名。一种方法是使用“首字母模式”,在这种模式下,接收者只使用标志之间每个单词的首字母。标志应该是常见的单词,比如athe,并且重复使用它们,以便更容易识别开始和结束的标志。在这种情况下,a a表示首字母模式的开始,the the表示结束。例如,要处理短语Sidi Muftah with ten tanks,首先将其直接运行以识别缺失的单词。

Enter plaintext or ciphertext: sidi muftah with ten tanks
Enter 'encrypt' or 'decrypt': encrypt
Shift value (1-365) = 5
Enter filename with extension: lost.txt

Character sidi not in dictionary.

Character muftah not in dictionary.

Character tanks not in dictionary.

encrypted ciphertext = 
 [23371, 7491]

decrypted plaintext = 
with ten

在识别出缺失的单词后,请重新措辞消息并使用首字母模式拼写它们。我在以下代码片段中将首字母标记为灰色:

Enter plaintext or ciphertext: a a so if do in my under for to all he the the
with ten a a tell all night kind so the the
Enter 'encrypt' or 'decrypt': encrypt
Shift value (1-365) = 5
Enter filename with extension: lost.txt

encrypted ciphertext = 
 [29910, 70641, 30556, 60850, 72292, 32501, 6507, 18593, 41777, 23831, 41833,
16667, 32749, 3350, 46088, 37995, 12535, 30609, 3766, 62585, 46971, 8984,
44083, 43414, 56950]

decrypted plaintext =
a a so if do in my under for to all he the the with ten a a tell all night
kind so the the

在《失落的世界》中,a出现了 1,864 次,the出现了 4,442 次。如果你坚持使用简短的消息,就不应该重复键。否则,你可能需要使用多个标志字符,或者禁用 check-for-fail()函数并接受一些重复项。

随意提出自己的方法来处理问题单词。作为精通规划的德国人,他们肯定有某种想法,否则他们根本不会考虑使用书籍密码!

你可以在附录中找到一个简单的首字母解决方案,practice_WWII_words.py,或者在线访问* nostarch.com/real-world-python/ *。

第五章:发现冥王星

Image

根据伍迪·艾伦的说法,成功的 80%仅仅是出现在场。这无疑描述了克莱德·汤博的成功,一个没有受过训练的堪萨斯农场男孩,在 1920 年代长大。由于对天文学充满热情却没有上大学的钱,他尝试了一下,邮寄了自己最好的天文素描给洛威尔天文台。令他大吃一惊的是,他被聘为助手。一年后,他发现了冥王星,并因此获得了永恒的荣耀!

费尔西瓦尔·洛威尔,这位著名的天文学家和洛威尔天文台的创始人,基于海王星轨道的扰动提出了冥王星的存在。他的计算是错误的,但纯粹巧合的是,他正确预测了冥王星的轨道路径。在 1906 年到他 1916 年去世之间,他曾两次拍摄冥王星。每次,他的团队都未能注意到它。而汤博则在 1930 年 1 月,经过仅一年寻找,拍摄并识别出了冥王星(图 5-1)。

Image

图 5-1:冥王星的发现底片,箭头指示的位置

汤博所取得的成就非凡。在没有计算机的情况下,他所遵循的方法论既不切实际,又繁琐且要求极高。他必须一夜又一夜地拍摄并重新拍摄天空的某些小部分,通常是在一个冰冷的圆顶下,圆顶被刺骨的寒风吹动。然后,他需要冲洗并筛选所有底片,寻找在星星密集区域中任何微弱的运动迹象。

虽然他没有计算机,但他确实拥有一台先进的设备,叫做闪烁比较器,这使他能够快速在连续几晚的底片之间切换。在通过闪烁比较器观察时,恒星保持静止,但冥王星作为一个移动物体,像信标一样闪烁。

在本章中,你将首先编写一个 Python 程序,复制 20 世纪初期的闪烁比较器。接着,你将进入 21 世纪,编写一个程序,利用现代计算机视觉技术自动检测运动物体。

注意

2006 年,国际天文学联合会将冥王星重新分类为矮行星。这是基于在柯伊伯带中发现了其他与冥王星相似的天体,包括一个——厄里斯——它的体积较小,但质量比冥王星大 27%。

项目#7:复制闪烁比较器

冥王星可能是用望远镜拍摄的,但它是用显微镜发现的。闪烁比较器(图 5-2),也叫做闪烁显微镜,允许用户安装两张照片板并快速切换查看其中一张到另一张。在这种“闪烁”过程中,任何在两张照片之间发生位置变化的物体都会显得来回跳动。

Image

图 5-2:闪烁比较器

为了使该技术有效,照片需要在相同的曝光条件下拍摄,并且具有相似的观看条件。最重要的是,两张图像中的星星必须完全对齐。在汤博(Tombaugh)时代,技术人员通过艰苦的手工劳动来完成这一过程;他们在长达一个小时的曝光过程中小心引导望远镜,显影胶片后,再通过闪烁比较器进行微调对齐。正因为这项精细的工作,汤博有时需要花费一周的时间来检查一对胶片。

在这个项目中,你将数字化复制对齐胶片并进行闪烁显示的过程。你将处理明亮和昏暗的物体,观察不同曝光下照片的影响,并比较使用正片与汤博所用的负片的效果。

目标

编写一个 Python 程序,将两张几乎相同的图像对齐,并在同一窗口中快速交替显示它们。

策略

该项目的照片已经拍摄完成,因此你只需要将它们对齐并进行闪烁显示。图像对齐通常被称为图像配准。这涉及对其中一张图像进行垂直、水平或旋转的变换。如果你曾用数码相机拍摄过全景照片,那么你一定见过配准技术的应用。

图像配准按照以下步骤进行:

  1. 在每张图像中定位出独特的特征。

  2. 用数字描述每个特征。

  3. 使用数字描述符匹配每张图像中相同的特征。

  4. 将一张图像进行扭曲,使得匹配的特征在两张图像中的像素位置相同。

为了获得良好的效果,图像应该具有相同的大小,并覆盖几乎相同的区域。

幸运的是,OpenCV Python 包附带了可以执行这些步骤的算法。如果你跳过了第一章,你可以在第 6 页阅读关于 OpenCV 的内容。

一旦图像完成配准,你需要将它们显示在同一个窗口中,使其精确重叠,然后循环显示若干次。再次强调,你可以借助 OpenCV 轻松实现这一点。

数据

你需要的图像位于书籍支持文件中的Chapter_5文件夹,可以从nostarch.com/real-world-python/下载。文件夹结构应如图 5-3 所示。下载文件夹后,请不要更改此组织结构或文件夹内容和名称。

Image

图 5-3:项目 7 的文件夹结构

night_1night_2 文件夹包含你将用来开始的输入图像。从理论上讲,这些图像应该是拍摄于不同夜晚的同一片区域的图像。这里使用的是相同的星空图像,且我添加了一个人工的 短暂天体。短暂天体,简称 短暂天文事件,是一个在相对较短的时间框架内可以检测到其运动的天体。彗星、小行星和行星都可以被视为短暂天体,因为它们的运动在更静态的银河背景下很容易被检测到。

表 5-1 简要描述了 night_1 文件夹的内容。该文件夹包含文件名中带有 left 的文件,表示它们应该放置在闪烁比较仪的左侧。night_2 文件夹中的图像文件名包含 right,应放置在另一侧。

表 5-1: night_1 文件夹中的文件

文件名 描述
1_bright_transient_left.png 包含一个明亮的短暂天体
2_dim_transient_left.png 包含一个直径为单个像素的暗短暂天体
3_diff_exposures_left.png 包含一个背景过曝的暗短暂天体
4_single_transient_left.png 仅包含左侧图像中的明亮短暂天体
5_no_transient_left.png 没有短暂天体的星空
6_bright_transient_neg_left.png 第一个文件的负片,显示汤博使用的图像类型

图 5-4 是其中一张图像的示例。箭头指向短暂天体(但不属于图像文件的一部分)。

图片

图 5-4: 1_bright_transient_left.png,箭头指示短暂天体

为了模拟完美对齐望远镜的困难,我稍微移动了 night_2 文件夹中的图像,使它们相对于 night_1 文件夹中的图像有所偏移。你需要遍历这两个文件夹中的内容,注册并比较每一对照片。因此,每个文件夹中的文件数应相同,且命名规则应确保照片正确配对。

闪烁比较仪代码

以下 blink_comparator.py 代码将数字化复制一个闪烁比较仪。可以在网站的 Chapter_5 文件夹中找到此程序。你还需要上一节描述的文件夹。将代码保存在 night_1night_2 文件夹之上的文件夹中。

导入模块并分配常量

列表 5-1 导入了运行程序所需的模块,并为接受的最小关键点匹配数分配了常量。关键点,也叫兴趣点,是图像中的有趣特征,可用于表征图像。它们通常与强烈的亮度变化相关,例如角点,或者在这个例子中是星体。

blink_comparator.py, part 1
import os
from pathlib import Path
import numpy as np
import cv2 as cv

MIN_NUM_KEYPOINT_MATCHES = 50

列表 5-1: 导入模块并为关键点匹配分配常量

首先导入操作系统模块,使用它来列出文件夹中的内容。接着导入 pathlib,一个简化处理文件和文件夹的有用模块。最后导入 NumPy 和 cv(OpenCV)用于处理图像。如果你跳过了第一章,你可以在第 8 页找到 NumPy 的安装说明。

为接受的最小关键点匹配数量分配一个常量变量。为了提高效率,你最好选择一个能够产生可接受配准结果的最小值。在这个项目中,算法运行非常快,因此可以增加这个值,而不会产生显著的成本。

定义 main()函数

清单 5-2 定义了 main()函数的第一部分,用于运行程序。这些初步步骤创建了列表和目录路径,用于访问各种图像文件。

blink_comparator.py, part 2 
def main():
    """Loop through 2 folders with paired images, register & blink images."""
    night1_files = sorted(os.listdir('night_1'))
    night2_files = sorted(os.listdir('night_2'))             
    path1 = Path.cwd() / 'night_1'
    path2 = Path.cwd() / 'night_2'
    path3 = Path.cwd() / 'night_1_registered'

清单 5-2:定义 main()的第一部分,用于操作文件和文件夹

首先定义 main()函数,然后使用 os 模块的 listdir()方法来创建night_1night_2文件夹中所有文件名的列表。对于night_1文件夹,listdir()返回以下内容:

['1_bright_transient_left.png', '2_dim_transient_left.png', '3_diff_exposures_
left.png', '4_no_transient_left.png', '5_bright_transient_neg_left.png']

请注意,os.listdir()在返回文件时并不对文件进行排序。底层操作系统决定了排序顺序,这意味着 macOS 返回的列表将与 Windows 不同!为了确保列表一致且文件正确配对,可以用内建的 sorted()函数包装 os.listdir()。该函数会基于文件名的第一个字符返回按数字顺序排列的文件。

接下来,使用 pathlib 的 Path 类将路径名分配给变量。前两个变量将指向两个输入文件夹,第三个变量将指向一个输出文件夹,用于存放配准后的图像。

pathlib 模块是 Python 3.4 引入的,它是 os.path 的替代方案,用于处理文件路径。os 模块将路径视为字符串,这可能会很麻烦,并且需要你使用标准库中的多个功能。相反,pathlib 模块将路径视为对象,并将所需的功能集中在一个地方。pathlib 的官方文档位于* docs.python.org/3/library/pathlib.html *。

对于目录路径的第一部分,使用 cwd()类方法获取当前工作目录。如果你至少有一个 Path 对象,你可以在路径指定中混合使用对象和字符串。你可以将表示文件夹名称的字符串与/符号连接起来。这与使用 os.path.join()类似,如果你熟悉 os 模块的话。

请注意,你需要在项目目录内执行程序。如果从文件系统的其他地方调用它,将会失败。

在 main()中的循环

示例 5-3,仍然在 main()函数中,通过一个大的 for 循环运行程序。这个循环会从两个“night”文件夹中的每一个提取一个文件,将它们作为灰度图像加载,找到每张图像中的匹配关键点,利用这些关键点将第一张图像变换(或配准)以匹配第二张图像,保存配准后的图像,然后比较(或闪烁)配准后的第一张图像和原始的第二张图像。我还包括了一些可选的质量控制步骤,在你确认结果满意后可以将它们注释掉。

blink_comparator.py, part 3
for i, _ in enumerate(night1_files):    
    img1 = cv.imread(str(path1 / night1_files[i]), cv.IMREAD_GRAYSCALE)
    img2 = cv.imread(str(path2 / night2_files[i]), cv.IMREAD_GRAYSCALE)
    print("Comparing {} to {}.\n".format(night1_files[i], night2_files[i]))
 ➊ kp1, kp2, best_matches = find_best_matches(img1, img2)
    img_match = cv.drawMatches(img1, kp1, img2, kp2, 
                               best_matches, outImg=None)
    height, width = img1.shape
    cv.line(img_match, (width, 0), (width, height), (255, 255, 255), 1)
 ➋ QC_best_matches(img_match)  # Comment out to ignore.
    img1_registered = register_image(img1, img2, kp1, kp2, best_matches)

 ➌ blink(img1, img1_registered, 'Check Registration', num_loops=5)  
    out_filename = '{}_registered.png'.format(night1_files[i][:-4])
    cv.imwrite(str(path3 / out_filename), img1_registered) # Will overwrite!
    cv.destroyAllWindows()
    blink(img1_registered, img2, 'Blink Comparator', num_loops=15)

示例 5-3:在 main()函数中运行程序循环

通过枚举 night1_files 列表开始循环。内置的 enumerate()函数为列表中的每个项目添加一个计数器,并返回该计数器以及项目。由于你只需要计数器,可以使用单个下划线(_)表示列表项。按惯例,单个下划线表示一个临时或不重要的变量。它还可以让代码检查程序(例如 Pylint)保持正常。如果你在这里使用一个变量名,例如 infile,Pylint 会抱怨一个未使用的变量

W: 17,11: Unused variable 'infile' (unused-variable)

接下来,使用 OpenCV 加载图像及其在 night2_files 列表中的配对图像。注意,你必须将路径转换为字符串,以便传递给 imread()方法。你还需要将图像转换为灰度图像。这样,你只需处理单通道图像,它表示强度。为了跟踪循环过程中发生的事情,可以在 shell 中打印一条消息,指示正在比较哪些文件。

现在,找到关键点及其最佳匹配 ➊。find_best_matches()函数(稍后你将定义)将返回这三个变量:kp1,表示第一个加载图像的关键点;kp2,表示第二个图像的关键点;以及 best_matches,表示匹配的关键点列表。

这样,你可以通过视觉检查匹配结果,使用 OpenCV 的 drawMatches()方法在 img1 和 img2 上绘制它们。作为参数,该方法接受每张图像及其关键点、最佳匹配关键点列表和输出图像。在此例中,输出图像参数设置为 None,因为你只是查看输出,而不是将其保存到文件中。

为了区分这两张图像,在 img1 的右侧画一条垂直的白线。首先,使用 shape 获取图像的高度和宽度。接下来,调用 OpenCV 的 line()方法,并传入你要绘制的图像、起始和结束坐标、线条颜色以及线条粗细。请注意,这是一张彩色图像,因此要表示白色,你需要使用完整的 BGR 元组(255, 255, 255),而不是在灰度图像中使用的单一强度值(255)。

现在,调用质量控制函数——稍后你将定义——来显示匹配结果 ➋。图 5-5 展示了一个示例输出。你可能希望在确认程序正常运行后注释掉这一行。

Image

图 5-5:QC_best_matches() 函数的示例输出

找到并检查了最佳关键点匹配后,是时候将第一张图像注册到第二张图像了。使用稍后编写的函数来执行此操作。将两张图像、关键点和最佳匹配列表传递给该函数。

Blink 比较器(名为 blink())是你稍后将编写的另一个函数。在这里调用它,查看注册过程对第一张图像的影响。传递给它原始图像和注册后的图像、显示窗口的名称以及你希望执行的闪烁次数 ➌。该函数将在两张图像之间闪烁。你看到的“摇晃”程度将取决于匹配 img2 所需的扭曲量。确认程序按预期运行后,可能想注释掉这一行。

接下来,将注册后的图像保存到名为 night_1_registered 的文件夹中,该文件夹路径由 path3 变量指向。首先,分配一个文件名变量,引用原始文件名,并在末尾附加 _registered.png。为了避免在名称中重复文件扩展名,使用索引切片([:-4])将其去除,然后再添加新的结尾。最后,使用 imwrite() 保存文件。请注意,这将覆盖具有相同名称的现有文件,而不会发出警告。

在开始寻找瞬时变化时,你需要一个干净的视图,因此调用方法销毁所有当前的 OpenCV 窗口。然后再次调用 blink() 函数,将注册后的图像、第二张图像、窗口名称以及循环图像的次数传递给它。第一张图像将并排显示在 图 5-6 中。你能找到瞬时变化吗?

图片

图 5-6:night_1_registered 和 night_2 文件夹中第一张图像的 Blink Comparator 窗口

查找最佳关键点匹配

现在是时候定义主函数中使用的函数了。列表 5-4 定义了一个函数,用于查找从 night_1night_2 文件夹中提取的每对图像之间的最佳关键点匹配。它应该定位、描述并匹配关键点,生成匹配列表,然后通过最小可接受关键点数的常量截断该列表。该函数返回每张图像的关键点列表和最佳匹配列表。

blink_comparator.py, part 4 
def find_best_matches(img1, img2):
    """Return list of keypoints and list of best matches for two images."""
    orb = cv.ORB_create(nfeatures=100)  #  Initiate ORB object.
 ➊ kp1, desc1 = orb.detectAndCompute(img1, mask=None)
    kp2, desc2 = orb.detectAndCompute(img2, mask=None)    
    bf = cv.BFMatcher(cv.NORM_HAMMING, crossCheck=True)
 ➋ matches = bf.match(desc1, desc2)
    matches = sorted(matches, key=lambda x: x.distance)
    best_matches = matches[:MIN_NUM_KEYPOINT_MATCHES]

    return kp1, kp2, best_matches

列表 5-4:定义用于查找最佳关键点匹配的函数

首先定义该函数,该函数以两张图像作为参数。主函数(main())将在每次运行 for 循环时从输入文件夹中选择这些图像。

接下来,使用 OpenCV 的 ORB_create() 方法创建一个 orb 对象。ORB 是嵌套首字母缩写词的缩写:Oriented FAST 和 Rotated BRIEF。

FAST,即Features from Accelerated Segment Test,是一种快速、高效且免费的算法,用于检测关键点。为了描述这些关键点,以便可以在不同图像之间进行比较,你需要使用 BRIEF。BRIEF 是Binary Robust Independent Elementary Features 的缩写,它同样快速、紧凑且是开源的。

ORB 将 FAST 和 BRIEF 结合成一个匹配算法,该算法通过首先检测图像中像素值剧烈变化的独特区域,然后记录这些独特区域的位置作为关键点。接下来,ORB 使用数值数组,或描述符,通过在关键点周围定义一个小区域,称为补丁,来描述在关键点处发现的特征。在图像补丁内,算法使用模式模板定期采样强度。然后,它将预选的样本对进行比较,并将它们转换为二进制字符串,称为特征向量(参见图 5-7)。

向量是一系列数字。矩阵是一个由行和列组成的矩形数字数组,它被视为一个整体并按照规则进行操作。特征向量是一个具有一行和多列的矩阵。为了构建一个特征向量,算法通过将 1 添加到向量的末尾(如果第一个样本具有最大强度)或将 0 添加到末尾(如果相反)来将样本对转换为二进制系列。

Image

图 5-7:ORB 如何生成关键点描述符的卡通示例

以下显示了一些示例特征向量。我缩短了向量列表,因为 ORB 通常会比较并记录 512 对样本!

V1 = [010010110100101101100--snip--]
V2 = [100111100110010101101--snip--]
V3 = [001101100011011101001--snip--]
--snip--

这些描述符作为特征的数字指纹。OpenCV 使用额外的代码来补偿旋转和尺度变化,这使得即使特征的大小和方向不同,它也能匹配相似的特征(参见图 5-8)。

Image

图 5-8:OpenCV 可以匹配关键点,尽管它们在尺度和方向上存在差异。

创建 ORB 对象时,你可以指定要检查的关键点数量。默认方法为 500,但对于本项目所需的图像配准,100 个就足够了。

接下来,使用 orb.detectAndCompute() 方法 ➊,找到关键点及其描述符。传入 img1,然后对 img2 重复相同的代码。

在定位并描述了关键点后,下一步是找到两幅图像中共同的关键点。通过创建一个包含距离度量的 BFMatcher 对象来开始此过程。暴力匹配器将第一幅图像中一个特征的描述符与第二幅图像中的所有特征进行比较,使用哈明距离。它返回最接近的特征。

对于两个相同长度的字符串,汉明距离是指对应位置或索引中值不同的数量。对于以下特征向量,不匹配的位置以粗体显示,汉明距离为 3:

1001011001010
1100111001010

bf 变量将是一个 BFMatcher 对象。调用 match() 方法并将两个图像的描述符传递给它 ➋。将返回的 DMatch 对象列表分配给名为 matches 的变量。

最佳匹配将具有最小的汉明距离,因此请按升序排序对象,将这些最佳匹配项移到列表的开头。请注意,您需要使用 lambda 函数以及对象的距离属性。lambda 函数是一个小型的、一次性的、没有名称的函数,通常是动态定义的。紧随 lambda 之后的词和字符是参数。表达式在冒号后面,返回值是自动的。

由于您只需要在程序开始时定义的最小关键点匹配数,因此通过切片匹配列表来创建一个新列表。最佳匹配项位于列表开头,因此从匹配列表的起始位置切片直到 MIN_NUM_KEYPOINT_MATCHES 指定的值。

此时,您仍在处理神秘的对象,如下所示:

best matches =  <DMatch 0000028BEBAFBFB0>, <DMatch 0000028BEBB21090>, --snip--

幸运的是,OpenCV 知道如何处理这些。通过返回两组关键点和最佳匹配对象列表,完成该函数。

检查最佳匹配

[Listing 5-5 定义了一个简短的函数,帮助您视觉检查关键点匹配。您已经在 Figure 5-5 中看到了这个函数的结果。通过将这些任务封装到一个函数中,您可以减少 main() 中的杂乱,并通过注释掉一行代码来让用户关闭此功能。

blink_comparator.py, part 5
def QC_best_matches(img_match):
    """Draw best keypoint matches connected by colored lines."""    
    cv.imshow('Best {} Matches'.format(MIN_NUM_KEYPOINT_MATCHES), img_match)
    cv.waitKey(2500)  # Keeps window active 2.5 seconds.

Listing 5-5: 定义一个函数来检查最佳关键点匹配

定义该函数时使用一个参数:匹配的图像。这个图像是通过 Listing 5-3 中的 main() 函数生成的。它由左图和右图组成,关键点以彩色圆圈表示,并且通过彩色线条连接对应的关键点。

接下来,调用 OpenCV 的 imshow() 方法显示窗口。您可以在命名窗口时使用 format() 方法。传递最小关键点匹配数的常量给它。

完成该功能后,给用户 2.5 秒的时间查看窗口。请注意,waitKey() 方法并不会销毁窗口;它只是暂停程序指定的时间。等待时间过后,程序恢复执行,新窗口将会出现。

注册图像

Listing 5-6 定义了一个函数,将第一张图像注册到第二张图像。

blink_comparator.py, part 6
def register_image(img1, img2, kp1, kp2, best_matches):
    """Return first image registered to second image."""
    if len(best_matches) >= MIN_NUM_KEYPOINT_MATCHES:
        src_pts = np.zeros((len(best_matches), 2), dtype=np.float32)
        dst_pts = np.zeros((len(best_matches), 2), dtype=np.float32)

     ➊ for i, match in enumerate(best_matches):
            src_pts[i, :] = kp1[match.queryIdx].pt
            dst_pts[i, :] = kp2[match.trainIdx].pt            
        h_array, mask = cv.findHomography(src_pts, dst_pts, cv.RANSAC)

     ➋ height, width = img2.shape  # Get dimensions of image 2.
        img1_warped = cv.warpPerspective(img1, h_array, (width, height))

        return img1_warped

    else:
        print("WARNING: Number of keypoint matches < {}\n".format
              (MIN_NUM_KEYPOINT_MATCHES))
        return img1

Listing 5-6: 定义一个函数将一张图像注册到另一张图像

定义一个函数,接收两张输入图像、它们的关键点列表以及由 find_best_matches() 函数返回的 DMatch 对象列表作为参数。接下来,将最佳匹配的位置加载到 NumPy 数组中。从一个条件判断开始,检查最佳匹配列表是否等于或超过 MIN_NUM_KEYPOINT_MATCHES 常量。如果是,则初始化两个 NumPy 数组,行数与最佳匹配的数量相等。

np.zeros() NumPy 方法返回一个具有给定形状和数据类型的新数组,填充为零。例如,下面的代码片段生成一个三行两列的零填充数组:

>>> import numpy as np
>>> ndarray = np.zeros((3, 2), dtype=np.float32)
>>> ndarray
array([[0., 0.],
       [0., 0.],
       [0., 0.]], dtype=float32)

在实际代码中,数组至少会是 50×2,因为你规定了至少要有 50 个匹配点。

现在,枚举匹配列表并开始用实际数据填充数组 ➊。对于源点,使用 queryIdx.pt 属性获取描述符在 kp1 描述符列表中的索引。对下一组点重复此操作,但使用 trainIdx.pt 属性。查询/训练术语有点混淆,但基本上分别指代第一张和第二张图像。

下一步是应用单应性。单应性是一种使用 3×3 矩阵的变换,将一张图像中的点映射到另一张图像中的对应点。如果两张图像分别从不同角度观察同一平面,或者两张图像是通过相同相机绕光轴旋转而无平移的,那么这两张图像可以通过单应性关联。为了正确运行,单应性需要至少四个在两张图像中对应的点。

单应性假设匹配点确实是对应点。但是,如果仔细查看图 5-5 和图 5-8,你会发现特征匹配并不完美。在图 5-8 中,约 30% 的匹配是错误的!

幸运的是,OpenCV 包括了一个带有离群点检测器的 findHomography() 方法,称为随机采样一致性(RANSAC)。RANSAC 随机采样匹配点,找到一个解释其分布的数学模型,并优先选择预测最多点的模型。然后它丢弃离群点。例如,考虑 图 5-9 中的“原始数据”框中的点。

图片

图 5-9:使用 RANSAC 进行示例线性拟合以忽略离群点

如你所见,你想通过真实数据点(称为内点)拟合一条线,并忽略较少数量的伪点(离群点)。使用 RANSAC,你随机采样一组原始数据点,拟合一条线,然后重复此过程若干次。每个线性拟合方程都会应用到所有点。通过最多点的那条线被用于最终的线性拟合。在图 5-9 中,这将是最右侧框中的线。

要运行 findHomography(),传入源点和目标点,并调用 RANSAC 方法。这将返回一个 NumPy 数组和一个掩码。掩码指定了内点和外点,或是好的匹配和坏的匹配。你可以使用它执行诸如仅绘制好匹配之类的任务。

最后的步骤是将第一张图像扭曲,使其与第二张图像完美对齐。你需要第二张图像的尺寸,因此使用 shape() 获取 img2 的高度和宽度 ➋。将这些信息与 img1 和单应性矩阵 h_array 一同传入 warpPerspective() 方法。返回已注册的图像,它将是一个 NumPy 数组。

如果关键点匹配数少于你在程序开始时设定的最小数目,图像可能没有正确对齐。因此,打印一个警告并返回原始的、未注册的图像。这将允许 main() 函数继续不间断地循环处理文件夹中的图像。如果配准效果不好,用户将能察觉到问题,因为问题图像对不会在闪烁比较器窗口中正确对齐。错误信息也会出现在命令行中。

Comparing 2_dim_transient_left.png to 2_dim_transient_right.png.
WARNING: Number of keypoint matches < 50
构建闪烁比较器

列表 5-7 定义了一个运行闪烁比较器的函数,并在程序以独立模式运行时调用 main()。blink() 函数会在指定范围内循环,先显示注册后的图像,再显示第二张图像,且都显示在同一个窗口中。每张图像只显示三分之一秒,这是 Clyde Tombaugh 使用闪烁比较器时偏好的频率。

blink_comparator.py, part 7 
def blink(image_1, image_2, window_name, num_loops):
    """Replicate blink comparator with two images."""
    for _ in range(num_loops):
        cv.imshow(window_name, image_1)
        cv.waitKey(330)
        cv.imshow(window_name, image_2)
        cv.waitKey(330)

if __name__ == '__main__':
    main()

列表 5-7:定义一个函数以实现图像闪烁效果

定义 blink() 函数,包含四个参数:两个图像文件、一个窗口名称和需要执行的闪烁次数。用一个范围值设置一个 for 循环,循环次数等于闪烁的次数。由于你不需要访问运行中的索引,因此可以使用一个下划线 (_) 来表示该变量是无关紧要的。正如本章前面提到的,这可以防止代码检查程序引发“未使用的变量”警告。

现在调用 OpenCV 的 imshow() 方法,并传入窗口名称和第一张图片。这将是注册的第一张图片。然后暂停程序 330 毫秒,这是 Clyde Tombaugh 自己推荐的时间。

对第二张图像重复前两行代码。由于两张图像已经对齐,窗口中唯一会变化的就是瞬态。如果只有一张图像包含瞬态,它将会出现闪烁的效果。如果两张图像都捕捉到瞬态,它将会表现出来回跳动的效果。

使用标准代码结束程序,以便它能够在独立模式下运行或作为模块导入。

使用闪烁比较器

在运行blink_comparator.py之前,先将房间的灯光调暗,模拟通过设备目镜观察的情况。然后启动程序。你应该首先看到图像中心附近闪烁的两个明显亮点。在下一对图像中,这些点会变得非常小——只有一个像素大小——但你应该仍然能检测到它们。

第三轮将显示相同的小型瞬态,只不过这次第二张图片的整体亮度会比第一张更亮。你应该仍然能够找到这个瞬态,但会更难以发现。这就是为什么汤博曾经需要小心地拍摄并冲洗图像,以确保曝光一致。

第四轮包含一个单一的瞬态,显示在左侧的图像中。它应该是闪烁的,而不是像之前的图像那样来回跳动。

第五对图像是控制图像,没有瞬态。这是天文学家几乎一直看到的:令人失望的静态星空。

最后一轮使用的是第一对图像的负片版本。亮的瞬态显示为闪烁的黑点。这是克莱德·汤博用过的图像类型,因为这样可以节省时间。由于黑点和白点一样容易被发现,他认为没有必要为每张负片打印正片。

如果你查看注册的负片图像的左侧,你会看到一条黑色条纹,表示对齐图像所需的平移量(图 5-10)。你在正片图像中不会注意到这一点,因为它与黑色背景融为一体。

Image

图 5-10:负片图像,6_bright_transient_neg_left_registered.png

在所有的轮次中,你可能会注意到每一对图像的左上角有一颗暗淡的星星在闪烁。这不是瞬态,而是由边缘伪影引起的假阳性。边缘伪影是图像错位导致的图像变化。经验丰富的天文学家会忽略这颗暗星,因为:它发生在图像的边缘附近,而且可能的瞬态在不同图像之间并没有移动,只是变暗了。

你可以在图 5-11 中看到这个假阳性的原因。因为在第一帧中只捕捉到了星星的一部分,相比第二张图中的同一颗星星,其亮度降低了。

Image

图 5-11:在图像 1 中注册一个截断的星星,导致星星的亮度明显比图像 2 中的暗。

人类能够直观地处理边缘效应,但计算机需要明确的规则。在下一个项目中,你将通过在寻找瞬态时排除图像的边缘来解决这个问题。

项目 #8:通过图像差异检测天文瞬态

闪光比较仪曾被认为与望远镜同样重要,但现在它们安静地摆放在博物馆里积满灰尘。天文学家们不再需要它们,因为现代的图像差异化技术比人眼更擅长检测移动物体。如今,克莱德·汤博的每一项工作都将由计算机完成。

在这个项目中,假设你是一个天文台的暑期实习生。你的任务是为一位仍在使用生锈的闪光比较仪的古老天文学家创建一个数字化工作流。

目标

编写一个 Python 程序,接受两张已注册的图像,并突出显示它们之间的任何差异。

策略

你现在需要的是一个自动寻找瞬态物体的算法,而不是一个简单的闪烁图像的算法。这个过程仍然需要已注册的图像,但为了方便起见,可以直接使用项目 7 中已经生成的图像。

检测图像之间的差异是一个常见的操作,OpenCV 自带了一个专门用于此目的的绝对差异方法 absdiff()。它计算两个数组之间的逐元素差异。但仅仅检测差异还不够。你的程序需要识别出差异的存在,并只显示包含瞬态物体的图像。毕竟,天文学家有更重要的事情要做,比如将行星降级!

因为你要找的物体位于黑色背景上,且匹配的亮物体已经被去除,所以在差异化过程中剩下的任何亮物体都值得注意。由于在星空中出现多个瞬态物体的几率极低,标记一到两个差异应该足以引起天文学家的注意。

瞬态检测器代码

以下的transient_detector.py 代码将自动化地检测天文图像中的瞬态物体。你可以在网站的Chapter_5 文件夹中找到它。为了避免代码重复,程序使用了blink_comparator.py 已经注册的图像,因此你需要在项目目录中包含night_1_registered_transientsnight_2 文件夹(见图 5-3)。和之前的项目一样,保持 Python 代码文件位于这两个文件夹的上层文件夹。

导入模块并分配常量

列表 5-8 导入了运行程序所需的模块,并分配了一个用于管理边缘伪影的常量(见图 5-11)。这个常量代表一个小的距离,垂直于图像的边缘测量,用来排除在分析中的区域。任何在图像边缘和这个常量之间检测到的物体将被忽略。

transient_detector.py, part 1
import os
from pathlib import Path
import cv2 as cv

PAD = 5  # Ignore pixels this distance from edge

列表 5-8:导入模块并分配常量以管理边缘效应

你需要导入上一个项目中使用的所有模块,除了 NumPy,因此在这里导入它们。将填充距离设置为 5 像素。这个值可能会因数据集的不同而略有变化。稍后,你将在图像的边缘区域画一个矩形,这样你就可以看到这个参数排除了多少区域。

检测并圈出暂态

列表 5-9 定义了一个函数,你将用它来找到并圈出每对图像中的最多两个暂态。它会忽略填充区域中的暂态。

transient_detector.py, part 2 
def find_transient(image, diff_image, pad):
    """Find and circle transients moving against a star field. """
    transient = False
    height, width = diff_image.shape
    cv.rectangle(image, (PAD, PAD), (width - PAD, height - PAD), 255, 1)
    minVal, maxVal, minLoc, maxLoc = cv.minMaxLoc(diff_image)
 ➊ if pad < maxLoc[0] < width - pad and pad < maxLoc[1] < height - pad:
        cv.circle(image, maxLoc, 10, 255, 0)
        transient = True
    return transient, maxLoc

列表 5-9:定义一个函数来检测并圈出暂态

find_transient() 函数有三个参数:输入图像、表示第一和第二个输入图像之间差异的图像(表示 差异图)以及 PAD 常量。该函数将找到差异图中最亮像素的位置,在其周围画一个圆,并返回该位置以及一个布尔值,指示是否找到了对象。

从设置一个名为 transient 的变量开始,初始值为 False。你将用这个变量来指示是否发现了暂态。由于现实生活中暂态比较少见,所以它的初始状态应为 False。

为了应用 PAD 常量并排除图像边缘附近的区域,你需要获取图像的边界。通过 shape 属性可以获取这些信息,它返回图像的高度和宽度的元组。

使用高度和宽度变量以及 PAD 常量,使用 OpenCV 的 rectangle() 方法在图像变量上绘制一个白色矩形。稍后,这将向用户显示图像中哪些部分被忽略了。

diff_image 变量是一个表示像素的 NumPy 数组。背景为黑色,两个输入图像之间位置发生变化(或突然出现)的任何“星星”将显示为灰色或白色(参见 图 5-12)。

Image

图 5-12:由“亮暂态”输入图像导出的差异图

要定位最亮的暂态,使用 OpenCV 的 minMaxLoc() 方法,它返回图像中的最小和最大像素值及其位置元组。请注意,我将变量命名为与 OpenCV 的大小写命名约定一致(例如 maxLoc)。如果你希望使用更符合 Python PEP8 风格指南的命名方式(* www.python.org/dev/peps/pep-0008/*),可以自由地使用像 max_loc 这样的名称代替 maxLoc。

你可能在图像的边缘附近找到了一个最大值,因此运行一个条件来排除这种情况,忽略在 PAD 常量 ➊ 限定的区域内找到的值。如果该位置通过,则在图像变量上圈出它。使用一个半径为 10 像素、线宽为 0 的白色圆圈。

如果你已经画出了一个圆圈,那么你就找到了一个暂态,因此将 transient 变量设置为 True。这将在程序稍后的部分触发额外的操作。

通过返回瞬时天体和 maxLoc 变量来结束函数。

注意

minMaxLoc() 方法容易受到噪声(例如假阳性)的影响,因为它作用于单个像素。通常,你会首先运行一个预处理步骤,比如模糊处理,以去除多余的像素。然而,这也可能导致你错过较暗的天体,这些天体在单张图像中可能无法与噪声区分开来。

准备文件和文件夹

列表 5-10 定义了 main() 函数,创建了输入文件夹中文件名的列表,并将文件夹路径分配给变量。

transient_detector.py, part 3
def main():
    night1_files = sorted(os.listdir('night_1_registered_transients'))
    night2_files = sorted(os.listdir('night_2'))             
    path1 = Path.cwd() / 'night_1_registered_transients'
    path2 = Path.cwd() / 'night_2'
    path3 = Path.cwd() / 'night_1_2_transients'

列表 5-10:定义 main() 函数,列出文件夹内容,并分配路径变量

定义 main() 函数。然后,就像你在 列表 5-2 中在 第 100 页 所做的那样,列出包含输入图像的文件夹内容,并将它们的路径分配给变量。你将使用现有文件夹来存放包含已识别瞬时天体的图像。

遍历图像并计算绝对差异

列表 5-11 启动了一个 for 循环,遍历图像对。该函数读取对应的图像对,作为灰度数组,计算图像之间的差异,并在一个窗口中显示结果。然后,它在差异图像上调用 find_transient() 函数。

transient_detector.py, part 4 
for i, _ in enumerate(night1_files[:-1]):  # Leave off negative image   
    img1 = cv.imread(str(path1 / night1_files[i]), cv.IMREAD_GRAYSCALE)
    img2 = cv.imread(str(path2 / night2_files[i]), cv.IMREAD_GRAYSCALE)

    diff_imgs1_2 = cv.absdiff(img1, img2)
    cv.imshow('Difference', diff_imgs1_2)
    cv.waitKey(2000)

    temp = diff_imgs1_2.copy()
    transient1, transient_loc1 = find_transient(img1, temp, PAD)
    cv.circle(temp, transient_loc1, 10, 0, -1)

    transient2, _ = find_transient(img1, temp, PAD)

列表 5-11:遍历图像并找到瞬时天体

启动一个 for 循环,遍历 night1_files 列表中的图像。该程序设计用于处理 正向 图像,因此使用图像切片([:-1])来排除负向图像。使用 enumerate() 获取一个计数器;命名为 i,而不是 _,因为稍后你将用它作为索引。

要查找图像之间的差异,只需调用 cv.absdiff() 方法,并传递两个图像的变量。显示结果两秒钟后再继续程序。

由于你将要遮盖最亮的瞬时天体,首先请复制 diff_imgs1_2。将此副本命名为 temp,表示临时文件。现在,调用你之前编写的 find_transient() 函数。传递给它第一个输入图像、差异图像以及 PAD 常量。使用结果更新瞬时天体变量,并创建一个新变量 transient_loc1,用来记录差异图像中最亮像素的位置。

瞬时天体可能已经或尚未在连续两个晚上的图像中都被捕获。为了确认它是否被捕获,遮盖你刚才找到的亮点,使用黑色圆圈覆盖它。通过在 temp 图像上使用黑色作为颜色,并将线宽设置为 -1,这会告诉 OpenCV 填充圆圈。继续使用半径 10,但如果你担心两个瞬时天体非常接近,也可以适当减小这个半径。

再次调用 find_transient()函数,但对于位置变量使用单个下划线,因为你不会再使用它。瞬态的数量不太可能超过两个,甚至找到一个就足够让图像接受进一步检查,所以无需再寻找更多。

揭示瞬态并保存图像

清单 5-12,仍然是在 main()函数的 for 循环中,显示第一张输入图像,并将任何瞬态用圆圈标出,发布涉及的图像文件名,并保存图像为新的文件名。你还将打印每对图像结果的日志到解释器窗口。

transient_detector.py, part 5
        if transient1 or transient2:
            print('\nTRANSIENT DETECTED between {} and {}\n'
                  .format(night1_files[i], night2_files[i]))
        ➊ font = cv.FONT_HERSHEY_COMPLEX_SMALL
           cv.putText(img1, night1_files[i], (10, 25),
                      font, 1, (255, 255, 255), 1, cv.LINE_AA)
           cv.putText(img1, night2_files[i], (10, 55),
                      font, 1, (255, 255, 255), 1, cv.LINE_AA)

           blended = cv.addWeighted(img1, 1, diff_imgs1_2, 1, 0)
           cv.imshow('Surveyed', blended)
           cv.waitKey(2500)  

         ➋ out_filename = '{}_DECTECTED.png'.format(night1_files[i][:-4])
            cv.imwrite(str(path3 / out_filename), blended)  # Will overwrite!

       else:
           print('\nNo transient detected between {} and {}\n'
                  .format(night1_files[i], night2_files[i]))

if __name__ == '__main__':
    main()

清单 5-12:显示标记了瞬态的图像,记录结果并保存结果

启动一个条件语句,检查是否发现瞬态。如果评估结果为 True,则在终端输出一条信息。对于 for 循环评估的四张图像,你应该得到以下结果:

TRANSIENT DETECTED between 1_bright_transient_left_registered.png and 1_bright_transient_right.png

TRANSIENT DETECTED between 2_dim_transient_left_registered.png and 2_dim_transient_right.png

TRANSIENT DETECTED between 3_diff_exposures_left_registered.png and 3_diff_exposures_right.png

TRANSIENT DETECTED between 4_single_transient_left_registered.png and 4_single_transient_right.png

No transient detected between 5_no_transient_left_registered.png and 5_no_transient_right.png

发布一个负结果显示程序按预期工作,并且不容置疑地证明图像已被比较。

接下来,发布两张在 img1 数组上有正响应的图像的文件名。首先为 OpenCV 分配一个字体变量➊。要查看可用字体的列表,可以在* docs.opencv.org/4.3.0/上搜索HersheyFonts*。

现在调用 OpenCV 的 putText()方法,传递第一张输入图像、图像的文件名、位置、字体变量、大小、颜色(白色)、厚度和线型。LINE_AA 属性创建一个抗锯齿线条。对第二张图像重复这段代码。

如果找到了两个瞬态,可以使用 OpenCV 的 addWeighted()方法将它们显示在同一张图像上。该方法计算两个数组的加权和。参数包括第一张图像及其权重,第二张图像及其权重,以及添加到每个和中的标量。使用第一张输入图像和差异图像,将权重设置为 1,以便每张图像都被充分使用,并将标量设置为 0。将结果赋值给名为 blended 的变量。

在一个名为 Surveyed 的窗口中显示合成图像。图 5-13 展示了“明亮”瞬态的示例结果。

图像

图 5-13:瞬态检测器(transient_detector.py)的示例输出窗口,箭头指示了垫片矩形

注意图像边缘附近的白色矩形。这代表 PAD 距离。矩形外的瞬态被程序忽略。

使用当前输入图像的文件名加上“DETECTED” ➋来保存合成图像。图 5-13 中的暗淡瞬态将被保存为1_bright_transient_left_registered_DECTECTED.png。将其写入night_1_2_transients文件夹,使用 path3 变量。

如果没有找到瞬态,请在终端窗口中记录结果。然后,用代码结束程序,作为模块或独立模式运行。

使用瞬态检测器

想象一下,克莱德·汤博(Clyde Tombaugh)如果使用你的瞬态检测器会有多高兴。它真的是“设置并忘记”型。即使是第三对图像之间变化的亮度,这在闪光比较器中是个大问题,但对于这个程序来说毫无挑战。

总结

在本章中,你复制了一个老式的闪光比较器设备,并使用现代计算机视觉技术更新了这一过程。在这个过程中,你使用了 pathLib 模块简化了目录路径的操作,并且对不重要的未使用的变量名使用了单个下划线。你还使用了 OpenCV 来寻找、描述和匹配图像中的有趣特征,使用单应性对齐这些特征,合成图像并将结果写入文件。

进一步阅读

走出黑暗:冥王星行星(Stackpole Books,2017 年),由克莱德·汤博和帕特里克·摩尔(Patrick Moore)编写,是关于冥王星发现的标准参考书,讲述了发现者自己的故事。

追逐新视野:冥王星首次史诗任务的内幕(Picador,2018 年),由艾伦·斯特恩(Alan Stern)和大卫·格林斯彭(David Grinspoon)编写,记录了将宇宙飞船(顺便说一下,里面装有克莱德·汤博的遗 ashes)送往冥王星的伟大努力。

实践项目:绘制轨道路径

编辑 transient_detector.py 程序,使其在两个输入图像对中都有瞬态时,OpenCV 会绘制一条连接两个瞬态的线。这样可以揭示瞬态在背景星体中的轨道路径。

这种信息对于冥王星的发现至关重要。克莱德·汤博通过冥王星在两张发现板之间的运动距离,以及曝光之间的时间,验证了这颗行星接近洛厄尔预测的轨道,而不仅仅是某颗靠近地球的小行星。

你可以在附录和 Chapter_5 文件夹中找到解决方案 practice_orbital_path.py

实践项目:找出差异

你在本章中进行的特征匹配不仅在天文学领域具有广泛应用。例如,海洋生物学家使用类似的技术通过鲸鲨的斑点来识别它们。这提高了科学家们对鲸鲨种群数量的准确估算。

在 图 5-14 中,左右照片之间有变化。你能找到吗?更棒的是,你能编写 Python 程序来对齐和比较这两张图像,并圈出变化的部分吗?

Image

图 5-14:找出左右图像之间的差异。

起始图像可以在 montages 文件夹中的 Chapter_5 文件夹找到,这些图像可以从书籍官网下载。这些是彩色图像,你需要将其转换为灰度图像并在进行物体检测之前进行对齐。你可以在附录和 montages 文件夹中找到解决方案 practice_montage_aligner.pypractice_montage_difference_finder.py

挑战项目:数星星

根据Sky and Telescope杂志,从两半球裸眼可见的星星有 9,096 颗(www.skyandtelescope.com/astronomy-resources/how-many-stars-night-sky-09172014/)。这个数字本身已经很多,但如果通过望远镜观察,星星的数量会呈指数级增长。

为了估算大量的星星,天文学家们会对天空的小区域进行调查,使用计算机程序计算星星数量,然后将结果外推到更大的区域。对于这个挑战项目,假设你是洛威尔天文台的助手,参与一个调查小组。编写一个 Python 程序,计算在项目 7 和 8 中使用的图像5_no_transient_left.png中星星的数量。

若需要提示,可以在线搜索如何使用 Python 和 OpenCV 计算图像中的点数。有关使用 Python 和 SciPy 的解决方案,参见prancer.physics.louisville.edu/astrowiki/index.php/Image_processing_with_Python_and_SciPy。如果将图像分成更小的部分,你可能会发现结果有所改善。

第六章:阿波罗 8 号赢得月球竞赛

Image

1968 年夏,美国在太空竞赛中处于下风。苏联的“宗德”号飞船看起来已经准备好登月,中央情报局拍摄到了停在发射台上的巨型苏联 N-1 火箭,而美国的阿波罗计划仍然需要三次测试飞行。但在 8 月,NASA 经理乔治·洛提出了一个大胆的想法。我们现在就去月球吧!与其在地球轨道上做更多测试,不如让我们在 12 月绕月飞行,让成为测试。就在那一刻,太空竞赛几乎已经结束。不到一年后,苏联投降了,尼尔·阿姆斯特朗为全人类迈出了伟大的一步。

将阿波罗 8 号宇宙飞船送上月球的决定绝非轻松之举。1967 年,三名宇航员在阿波罗 1 号舱内遇难,多次无人驾驶任务爆炸或以其他方式失败。在这种背景下,且事关重大,一切都取决于自由返回的概念。任务的设计是,如果服务舱的引擎未能点燃,飞船将直接绕月飞行,然后像回旋镖一样返回地球(图 6-1)。

Image

图 6-1:阿波罗 8 号徽标,其中绕月自由返回轨道作为任务编号

在这一章中,你将编写一个使用名为 turtle 的绘图板模块的 Python 程序,用来模拟阿波罗 8 号的自由返回轨道。你还将研究物理学中的一个经典难题:三体问题。

理解阿波罗 8 号任务

阿波罗 8 号任务的目标仅仅是绕月飞行,因此不需要携带月球着陆舱组件。宇航员们乘坐的是指令舱和服务舱,统称为CSM(图 6-2)。

Image

图 6-2:阿波罗指令舱与服务舱模块

1968 年秋,CSM 引擎仅在地球轨道上进行了测试,且存在关于其可靠性的正当担忧。为了绕月飞行,引擎需要两次点火:第一次是减速以进入月球轨道,第二次是离开轨道。使用自由返回轨道时,如果第一次操作失败,宇航员仍然可以滑行返回地球。事实证明,引擎两次点火都非常成功,阿波罗 8 号绕月飞行了 10 次。(然而,命运多舛的阿波罗 13 号则充分利用了其自由返回轨道!)

自由返回轨道

绘制自由返回轨道需要大量复杂的数学运算。毕竟,这就是火箭科学!幸运的是,你可以用几个简化的参数在二维图表中模拟该轨道(图 6-3)。

Image

图 6-3:自由返回轨道(非比例图)

这个自由返回的二维模拟使用了几个关键值:指令舱的起始位置(R[0])、指令舱的速度和方向(V[0])以及指令舱和月球之间的相位角(γ[0])。相位角,也叫做领先角度,是从起始位置到最终位置所需的指令舱轨道时间位置的变化。跨月注入速度(V[0])是一种推进操作,用来将指令舱设置为月球轨迹。它通过从绕地轨道(停车轨道)中实现,航天器在此轨道上进行内部检查,并等待与月球的相位角达到最优。这时,土星五号火箭的第三级发动机会点燃并脱落,指令舱则继续滑行向月球。

由于月球在移动,在执行跨月注入之前,你必须预测月球的未来位置,或者领先它,就像用霰丨弹丨枪打飞碟一样。然而,这个过程与射击霰丨弹丨枪略有不同,因为太空是弯曲的,你需要考虑地球和月球的引力。这两个天体对航天器的拉力会产生难以计算的扰动——这些扰动甚至难度之大,以至于这个计算问题在物理学领域有了一个专门的名字:三体问题。

三体问题

三体问题是预测三个相互作用的天体行为的挑战。艾萨克·牛顿的引力方程在预测两个天体(如地球和月球)之间的行为时非常有效,但如果再加上第三个天体,无论是航天器、彗星、月亮等,问题就变得复杂。牛顿从未能够将三体或更多天体的行为归纳成一个简单的方程式。275 年来——即便有国王提供奖金奖励解决方案——世界上最伟大的数学家们也一直未能解开这个问题。

问题在于,三体问题不能通过简单的代数表达式或积分来解决。计算多个引力场的影响需要进行数值迭代,这样的计算规模没有高速计算机(如你的笔记本电脑)是无法实现的。

1961 年,迈克尔·米诺维奇(Michael Minovitch),当时是喷气推进实验室的暑期实习生,使用当时世界上最快的计算机——IBM 7090 主机,找到了第一个数值解。他发现,数学家可以通过使用修正圆锥法,减少解决一个限制性三体问题(如我们地球-月球-指令舱问题)所需的计算量。

修正圆锥方法是一种分析近似方法,它假设在航天器位于地球的引力影响范围内时,你正在处理一个简单的双体问题,而在月球的引力影响范围内又是另一个问题。这是一种粗略的“便签纸”计算方法,能提供合理的出发和到达条件估算,减少初始速度和位置向量的选择范围。剩下的就是通过重复的计算机模拟来精细化飞行路径。

由于研究人员已经找到并记录了阿波罗 8 号任务的修正圆锥解法,你不需要重新计算它。我已经将其调整为你将在此处使用的二维场景。不过,之后你可以通过改变像 R[0]和 V[0]这样的参数并重新运行模拟来实验其他解法。

项目#9:与阿波罗 8 号一起飞往月球!

作为 NASA 的暑期实习生,你被要求为媒体和公众制作一个简单的阿波罗 8 号自由返回轨迹模拟。由于 NASA 资金紧张,你需要使用开源软件,并尽可能快速和低成本地完成这个项目。

目标

编写一个 Python 程序,图形化地模拟阿波罗 8 号任务提出的自由返回轨迹。

使用 turtle 模块

为了模拟阿波罗 8 号的飞行,你需要一种在屏幕上绘制和移动图像的方法。虽然有很多第三方模块可以帮助你完成这项工作,但我们会通过使用预安装的 turtle 模块来保持简单。尽管 turtle 最初是为帮助孩子们学习编程而发明的,但它可以轻松地适应更复杂的用途。

turtle 模块允许你使用 Python 命令在屏幕上移动一个小图像,称为turtle。该图像可以是不可见的、实际的图像、自定义形状或图 6-4 中显示的预定义形状之一。

Image

图 6-4:turtle 模块提供的标准 turtle 形状

随着 turtle 的移动,你可以选择在它的后面画一条线来追踪它的运动(图 6-5)。

Image

图 6-5:在 Turtle 图形窗口中移动 turtle

这个简单的图形是通过以下脚本制作的:

>>> import turtle
>>> steve = turtle.Turtle('turtle') # Creates a turtle object with turtle shape.
>>> steve.fd(50) # Moves turtle forward 50 pixels.
>>> steve.left(90) # Rotates turtle left 90 degrees.
>>> steve.fd(50)
>>> steve.left(90)
>>> steve.fd(50)

你可以使用 Python 的 turtle 功能编写更简洁的代码。例如,你可以使用 for 循环来创建相同的图案。

>>> for i in range(3):
       steve.fd(50)
       steve.left(90)

在这里,steve 前进 50 个像素,然后向左转 90 度。这些步骤通过 for 循环重复三次。

其他 turtle 方法允许你更改 turtle 的形状、改变其颜色、抬起笔不绘制路径、在屏幕上“印章”当前的位置、设置 turtle 的朝向,并获取它在屏幕上的位置。图 6-6 展示了这些功能,并在接下来的脚本中进行了描述。

Image

图 6-6:更多海龟行为的示例。数字指代脚本注释。


   >>> import turtle
   >>> steve = turtle.Turtle('turtle')
➊ >>> a_stamp = steve.stamp()
➋ >>> steve.position()
   ➌ (0.00,0.00)
   >>> steve.fd(150)
➍ >>> steve.color('gray')
   >>> a_stamp = steve.stamp()
   >>> steve.left(45)
➎ >>> steve.bk(75)
   >>> a_stamp = steve.stamp()
➏ >>> steve.penup()
   >>> steve.bk(75)
   >>> steve.color('black')
➐ >>> steve.setheading(180)
   >>> a_stamp = steve.stamp()
➑ >>> steve.pendown()
   >>> steve.fd(50)
➒ >>> steve.shape('triangle')

导入海龟模块并实例化一个名为 steve 的海龟对象后,使用 stamp()方法 ➊留下 steve 的图像。然后使用 position()方法 ➋获取海龟当前的(x, y)坐标,并将其作为元组 ➌。这个方法在计算物体之间的距离时会很有用,尤其是在重力方程中。

将海龟向前移动 150 个单位,并将其颜色更改为灰色 ➍。然后留下印章,旋转海龟 45 度,再使用 bk()(向后)方法将其向后移动 75 个单位 ➎。

先留下另一个印章,然后使用 penup()方法停止绘制海龟的路径 ➏。将 steve 向后移动 75 个单位,并将其颜色改为黑色。现在,使用 rotate()方法的替代方法,直接设置海龟的朝向 ➐。朝向就是海龟当前移动的方向。请注意,默认的“标准模式”方向是以东为参考,而非北方(表 6-1)。

表 6-1: 海龟模块在标准模式下的常见方向(角度)

角度 方向
0
90
180 西
270

先留下另一个印章,然后放下画笔再次绘制海龟后面的路径 ➑。将 steve 向前移动 50 个单位,然后将其形状改为三角形 ➒。这完成了绘图。

不要被我们目前所做的简单操作迷惑。通过正确的命令,你可以绘制复杂的设计,例如图 6-7 中的 Penrose 镶嵌。

图片

图 6-7:海龟模块演示生成的 Penrose 镶嵌,penrose.py

海龟模块是 Python 标准库的一部分,官方文档可以在docs.python.org/3/library/turtle.html?highlight=turtle#module-turtle/找到。对于快速教程,可以在线搜索 Al Sweigart 的Simple Turtle Tutorial for Python

策略

我们已经做出战略决策,使用海龟绘制模拟图,但模拟图应该是什么样子呢?为了方便起见,我建议基于图 6-3 来设计。你将从地球附近的相同停泊轨道位置(R[0])和月球的相同大致相位角(γ[0])开始。你可以使用图像表示地球和月球,并使用自定义的海龟形状构建 CSM。

另一个重要的决策是是否使用过程式编程或面向对象编程(OOP)。当你计划生成多个行为相似且相互作用的对象时,OOP 是一个不错的选择。你可以使用 OOP 类作为地球、月球和 CSM 对象的蓝图,并在模拟运行时自动更新对象属性。

你可以使用时间步长来运行模拟。基本上,每个程序循环代表一个无量纲的时间单位。每次循环,你都需要计算每个物体的位置并更新(重绘)它在屏幕上的位置。这需要解决三体问题。幸运的是,已经有人做过这个工作,而且他们是用 turtle 来实现的。

Python 模块通常会包括示例脚本,向你展示如何使用该产品。例如,matplotlib 画廊包括用于制作大量图表和绘图的代码片段和教程。同样,turtle 模块附带了turtle-example-suite,其中包含 turtle 应用的演示。

示例之一,planet_and_moon.py,提供了一个处理三体问题的好“配方”,使用了 turtle(参见图 6-8)。要查看这些示例,打开 PowerShell 或终端窗口并输入python -m turtledemo。根据你的平台和安装的 Python 版本数量,你可能需要使用python3 -m turtledemo

Image

图 6-8:planet_and_moon.py 海龟示例的屏幕截图

此示例处理的是太阳-地球-月球三体问题,但它可以很容易地适应处理地球-月球-CSM 问题。同样,对于特定的阿波罗 8 号情况,你将使用图 6-3 来指导程序的开发。

阿波罗 8 号自由回归代码

apollo_8_free_return.py程序使用 turtle 图形生成阿波罗 8 号 CSM 离开地球轨道、环绕月球并返回地球的俯视图。程序的核心基于上一节讨论的planet_and_moon.py示例。

你可以在Chapter_6文件夹中找到该程序,文件可以从书籍的官网上下载,网址为nostarch.com/real-world-python/。你还需要从该网站获取地球和月球图像(参见图 6-9)。请确保将它们与代码放在同一文件夹中,并且不要更改文件名。

Image

图 6-9:在模拟中使用的 earth_100x100.gif 和 moon_27x27.gif 图像

导入 turtle 并赋值常量

列表 6-1 导入了 turtle 模块并赋值了表示关键参数的常量:引力常数、主循环运行的次数,以及 R[0]和 V[0]的xy值(参见图 6-3)。将这些值列在程序的顶部使得它们易于查找和修改。

apollo_8_free_return.py, part 1 
from turtle import Shape, Screen, Turtle, Vec2D as Vec

# User input:
G = 8 
NUM_LOOPS = 4100 
Ro_X = 0 
Ro_Y = -85 
Vo_X = 485 
Vo_Y = 0

列表 6-1:导入 turtle 并赋值常量

你需要从 turtle 导入四个辅助类。你将使用 Shape 类来制作一个看起来像 CSM 的自定义海龟。Screen 子类用于创建屏幕,turtle 术语中称之为绘图板。Turtle 子类用于创建海龟对象。Vec2D 导入的是一个二维向量类,它将帮助你定义速度,作为一个具有大小和方向的向量。

接下来,分配一些变量,用户以后可能希望调整它们。首先是引力常数,它用于牛顿引力方程中,以确保单位的正确性。将其设置为 8,这是 turtle 演示中使用的值。可以将其视为一个 缩放过 的引力常数。你不能使用真实的常数,因为模拟并不使用现实世界的单位。

你将在一个循环中运行模拟,每次迭代代表一个时间步长。每个步骤,程序都会重新计算 CSM 的位置,随着它穿越地球和月球的引力场。通过反复试验得出的 4100 的值,将在宇宙飞船返回地球后停止模拟。

1968 年,往返月球大约需要六天。由于你每次循环增加 0.001 的时间单位,并运行 4,100 次循环,这意味着模拟中的一个时间步长大约代表现实世界中的两分钟。时间步长越长,模拟运行得越快,但结果的准确性越差,因为小错误会随着时间的推移积累。在实际的飞行路径模拟中,你可以通过先运行一个小的时间步长(以获得最大的准确性),然后使用结果来找到一个能产生相似结果的最大时间步长,从而优化时间步长。

接下来的两个变量 Ro_X 和 Ro_Y 表示 CSM 在月球转移注入(translunar injection)时的(xy)坐标(见图 6-3)。同样,Vo_X 和 Vo_Y 表示月球转移注入速度的 xy 分量,这个速度由土星五号火箭的第三阶段提供。这些值最初是通过猜测得出的,然后通过反复模拟进行优化。

创建引力系统

由于地球、月球和 CSM 形成了一个持续互动的引力系统,你需要一种方便的方式来表示它们及其相应的力。为此,你需要两个类,一个用于创建引力系统,另一个用于创建其中的天体。清单 6-2 定义了 GravSys 类,帮助你创建一个迷你太阳系。这个类将使用一个列表来跟踪所有在运动中的天体,并将它们通过一系列时间步长循环。它基于 turtle 库中的 planet_and_moon.py 演示。

apollo_8_free_return.py, part 2
class GravSys():
    """Runs a gravity simulation on n-bodies."""

    def __init__(self):
        self.bodies = []
        self.t = 0
        self.dt = 0.001    

 ➊ def sim_loop(self):
        """Loop bodies in a list through time steps."""
        for _ in range(NUM_LOOPS):
            self.t += self.dt
            for body in self.bodies:
                body.step()

清单 6-2:定义一个类来管理引力系统中的天体

GravSys 类定义了模拟运行的时间、时间步长(循环之间的时间间隔)以及参与的天体。它还调用了你将在清单 6-3 中定义的 Body 类的 step() 方法。这个方法将根据引力加速度更新每个天体的位置。

定义初始化方法,并按照惯例将 self 作为参数传递。self 参数表示你将在 main() 函数中创建的 GravSys 对象。

创建一个空列表,命名为 bodies,用于存放地球、月球和 CSM 对象。接着,设置模拟开始时的属性以及每次循环时递增的时间量,这个量被称为 delta time 或 dt。将起始时间设置为 0,并将 dt 时间步长设置为 0.001。如前一节所讨论的,这个时间步长大约对应现实世界中的两分钟,并且会产生一个平滑、准确且快速的模拟。

最后一个方法控制模拟中的时间步长 ➊。它使用一个 for 循环,范围设置为 NUM_LOOPS 变量。使用单个下划线 (_) 而不是 i 来表示使用一个不重要的变量(有关详细信息,请参见 列表 5-3 以及 第五章)。

在每次循环中,增加重力系统的时间变量 dt。然后,通过遍历物体列表并调用 body.step() 方法(稍后会在 Body 类中定义)来对每个物体应用时间偏移。此方法更新物体由于引力作用而改变的位置和速度。

创建天体

列表 6-3 定义了 Body 类,用于构建地球、月球和 CSM Body 对象。尽管没有人会把行星误认为是小型航天器,但从引力角度来看,它们差别并不大,因此你可以将它们都用相同的模板创建出来。

apollo_8_free_return.py, part 3
class Body(Turtle): 
    """Celestial object that orbits and projects gravity field.""" 
    def __init__(self, mass, start_loc, vel, gravsys, shape):
        super().__init__(shape=shape)
        self.gravsys = gravsys
        self.penup()
        self.mass = mass
        self.setpos(start_loc)
        self.vel = vel
        gravsys.bodies.append(self)
        #self.resizemode("user")
        #self.pendown()  # Uncomment to draw path behind object.

列表 6-3:定义一个类来创建地球、月球和 CSM 的对象

通过使用 Turtle 类作为 祖先 来定义一个新类。这意味着 Body 类将方便地继承 Turtle 类的所有方法和属性。

接下来,为 body 对象定义一个初始化方法。你将用它在模拟中创建新的 Body 对象,这个过程在面向对象编程中称为 实例化。作为参数,初始化方法接收自身、质量属性、起始位置、起始速度、重力系统对象和形状。

super() 函数允许你调用父类的方法,以访问从祖先类继承的方法。这使得你的 Body 对象能够使用预构建的 Turtle 类的属性。将形状属性传递给它,这样你在 main() 函数中创建物体时,就能为物体传递自定义的形状或图像。

接下来,为 gravsys 对象分配一个实例属性。这将允许重力系统和物体进行交互。请注意,最好通过 init() 方法初始化属性,就像我们在这里做的那样,因为它是创建对象后第一个被调用的方法。这样,这些属性将立即对类中的其他方法可用,并且其他开发者可以在一个地方看到所有属性的列表。

以下是 Turtle 类的 penup() 方法,它将移除绘图笔,使得物体在移动时不会留下路径。这样,你就可以选择运行带有或不带有可视轨道路径的模拟。

为物体初始化一个质量属性。你需要这个属性来计算重力。接下来,使用 Turtle 类的 setpos()方法为物体分配起始位置。每个物体的起始位置将是一个(x, y)元组。原点(0, 0)将在屏幕中央,x坐标向右增加,y坐标向上增加。

为速度分配一个初始化属性。它将保存每个物体的起始速度。对于 CSM,这个值将在模拟过程中随着飞船穿越地球和月球的引力场而变化。

在每个物体实例化时,使用点符号将其附加到重力系统中的物体列表中。你将在 main()函数中从 GravSys()类创建 gravsys 对象。

最后的两行,已注释掉,允许用户更改模拟窗口的大小,并选择在每个物体后面绘制路径。先从全屏显示开始,并保持笔的位置在上方,以便快速运行模拟。

计算重力加速度

阿波罗 8 号模拟将在月际注入后立即开始。此时,土星 V的第三级已经点燃并脱落,CSM 开始向月球行进。所有的速度或方向变化将完全由于重力变化。

清单 6-4 中的方法遍历物体列表中的物体,计算每个物体的重力加速度,并返回一个表示物体在xy方向上的加速度的向量。

apollo_8_free_return.py, part 4
    def acc(self):
        """Calculate combined force on body and return vector components."""
        a = Vec(0, 0)
        for body in self.gravsys.bodies:
            if body != self:
                r = body.pos() - self.pos()
                a += (G * body.mass / abs(r)**3) * r
        return a

清单 6-4:计算重力加速度

仍然在 Body 类内,定义加速度方法,命名为 acc(),并传入 self。在方法内,定义一个局部变量 a,再次代表加速度,并将其赋值为一个使用 Vec2D 辅助类的向量元组。2D 向量是一个由实数(a, b)组成的对,这里分别表示xy分量。Vec2D 辅助类强制执行规则,允许使用向量进行简便的数学运算,如下所示:

  • (a, b) + (c, d) = (a + c, b + d)

  • (a, b) – (c, d) = (ac, bd)

  • (a, b) × (c, d) = ac + bd

接下来,开始遍历物体列表中的项,该列表包含地球、月球和指令服务舱(CSM)。你将使用每个物体的重力来确定你正在调用 acc()方法的物体的加速度。物体不应自我加速,因此如果物体与自身相同,则排除它。

要计算空间中某一点的重力加速度(存储在 g 变量中),你需要使用以下公式:

Image

其中,M 是吸引物体的质量,r 是物体之间的距离(半径),G 是你之前定义的引力常数,r 是从吸引物体的质心到被加速物体质心的单位向量。单位向量,也称为方向向量归一化向量,可以表示为 r/|r|,或者:

Image

单位向量可以帮助你捕捉加速度的方向,方向可以是正值或负值。要计算单位向量,你需要通过使用海龟的 pos() 方法获取每个物体当前的位置,并将其表示为一个 Vec2D 向量来计算物体之间的距离。如前所述,这个向量是一个由 (x, y) 坐标组成的元组。

然后,你将把这个元组输入到加速度方程中。每次循环遍历一个新的物体时,你将根据正在检查物体的引力来更改 a 变量。例如,虽然地球的引力可能会减缓 CSM 的速度,月球的引力可能会朝相反方向拉动并使其加速。a 变量将捕捉到循环结束时的净效应。通过返回 a 来完成方法。

逐步模拟

Listing 6-5,仍然在 Body 类中,定义了一个方法来解决三体问题。它会在每个时间步长中更新引力系统中物体的位置、方向和速度。时间步长越短,解的准确性越高,但也会牺牲计算效率。

apollo_8_free_return.py, part 5
def step(self):
    """Calculate position, orientation, and velocity of a body."""
    dt = self.gravsys.dt
    a = self.acc()
    self.vel = self.vel + dt * a
    self.setpos(self.pos() + dt * self.vel)
 ➊ if self.gravsys.bodies.index(self) == 2:  # Index 2 = CSM.
        rotate_factor = 0.0006
        self.setheading((self.heading() - rotate_factor * self.xcor()))
     ➋ if self.xcor() < -20: 
            self.shape('arrow')
            self.shapesize(0.5)
            self.setheading(105)

Listing 6-5: 应用时间步长并旋转 CSM

定义一个 step() 方法来计算物体的位置、方向和速度。将 self 作为参数传递给它。

在方法定义中,设置一个本地变量 dt,赋值为同名的 gravsys 对象。这个变量与任何实时系统没有关联;它只是一个浮动的数字,你将用它来在每个时间步长中增加速度。dt 变量的值越大,模拟的运行速度就越快。

现在,调用 self.acc() 方法来计算当前物体由于其他物体的引力场的合力所经历的加速度。该方法返回一个 (x, y) 坐标的向量元组。将其乘以 dt 并将结果加到 self.vel() 上,后者也是一个向量,从而更新当前时间步长的物体速度。回想一下,背后 Vec2D 类将负责处理向量运算。

要更新海龟图形窗口中物体的位置,将物体的速度乘以时间步长,并将结果加到物体的位置信息上。现在,每个物体将根据其他物体的引力来移动。你刚刚解决了三体问题!

接下来,添加一些代码来精细化 CSM 的行为。推力从 CSM 后部喷射,因此在实际任务中,航天器的后部朝向目标。这样,发动机就能启动并减速,足以进入月球轨道或地球大气层。对于自由返回轨迹来说,航天器不需要以这种方式朝向目标,但由于阿波罗 8 号计划发射发动机并进入月球轨道(并成功完成),你应该在整个旅程中正确地调整航天器的朝向。

从物体列表 ➊ 中选择 CSM。你将在 main() 函数中按大小顺序创建物体,因此 CSM 将是列表中的第三个项目,索引为 2。

为了让 CSM 在太空中滑行时旋转,给一个名为rotate_factor的局部变量赋值一个小的数值。我是通过反复试验得出这个数字的。接下来,使用 CSM 海龟对象的self.heading属性设置其航向。你无需传入 (x, y) 坐标,而是调用self.heading()方法,它会返回对象当前的航向角度,然后从中减去rotate_factor变量乘以当前的 x 位置,该位置通过调用self.xcor()方法获取。这样,随着 CSM 靠近月球,它的旋转速度会加快,以保持尾部指向运动方向。

在航天器进入地球大气层之前,你需要将服务舱分离。为了在一个与实际阿波罗任务相似的位置进行分离,使用另一个条件来检查航天器的 x 坐标 ➋。模拟中假定地球位于屏幕中心,坐标为 (0, 0)。在海龟中,x 坐标会随着你向左移动而减小,向右移动则增大。如果 CSM 的 x 坐标小于 –20 像素,你可以假设它正在返回地球,并且是时候与服务舱分离了。

你将通过改变代表 CSM(指令舱)的海龟形状来模拟这个事件。由于海龟包含一个标准形状——叫做箭头——它与指令舱相似,因此你现在只需调用self.shape()方法,并传入该形状的名称。然后调用self.shapesize()方法,将箭头的大小减半,使其匹配后续自定义的指令舱形状。当 CSM 通过 –20 x 位置时,服务舱将神奇地消失,留下指令舱完成回程任务。

最后,你需要将指令舱的底部朝向地球,确保热防护罩朝向地球。通过将箭头形状的航向设置为 105 度来实现这一点。

定义 main(),设置屏幕,并实例化引力系统

你使用面向对象编程构建了引力系统和其中的天体。为了运行仿真,你将回到过程式编程并使用 main()函数。此函数设置海龟图形屏幕,为引力系统和三个天体实例化对象,构建指令舱的自定义形状,并调用引力系统的 sim_loop()方法以逐步进行时间步长。

示例 6-6 定义了 main()并设置了屏幕。它还创建了一个引力系统对象来管理你的迷你太阳系。

apollo_8_free_return.py, part 6
def main():  
    screen = Screen()
    screen.setup(width=1.0, height=1.0) # For fullscreen.
    screen.bgcolor('black')
    screen.title("Apollo 8 Free Return Simulation")

    gravsys = GravSys()

示例 6-6:在 main()中设置屏幕并创建 gravsys 对象

定义 main()函数,然后基于 TurtleScreen 子类实例化一个屏幕对象(绘图窗口)。接着调用该屏幕对象的 setup()方法,将屏幕大小设置为全屏。通过传递宽度和高度参数为 1 来实现这一点。

如果你不希望绘图窗口占满整个屏幕,可以将以下代码片段中的像素参数传递给 setup():

screen.setup(width=800, height=900, startx=100, starty=0)

注意,负值的 startx 表示右对齐,负值的 starty 表示底部对齐,默认设置会创建一个居中的窗口。可以随意尝试这些参数,以找到最适合你显示器的设置。

完成屏幕设置后,将背景颜色设置为黑色并为其设置标题。接下来,使用 GravSys 类实例化一个引力系统对象 gravsys。这个对象将使你能够访问 GravSys 类中的属性和方法。稍后在实例化每个天体时,你将把它传递给每个天体。

创建地球和月球

示例 6-7 仍然在 main()函数中,使用你之前定义的 Body 类创建地球和月球的海龟对象。地球将保持在屏幕中心不动,而月球将绕着地球旋转。

当你创建这些对象时,你需要设置它们的起始坐标。地球的起始位置接近屏幕中心,稍微偏向下方一点,以便为月球和指令舱留出空间,使它们可以在窗口的顶部互动。

月球和指令舱的起始位置应该与图 6-3 中的内容一致,指令舱位于地球中心正下方。这样,你只需在x方向施加推力,而不需要计算一个包含x方向和y方向运动的向量分量速度。

apollo_8_free_return.py, part 7
   image_earth = 'earth_100x100.gif'
   screen.register_shape(image_earth)
   earth = Body(1000000, (0, -25), Vec(0, -2.5), gravsys, image_earth)
   earth.pencolor('white')
   earth.getscreen().tracer(n=0, delay=0) 

➊ image_moon = 'moon_27x27.gif'
   screen.register_shape(image_moon)
   moon = Body(32000, (344, 42), Vec(-27, 147), gravsys, image_moon)
   moon.pencolor('gray')

示例 6-7:实例化地球和月球的海龟对象

首先将地球的图像赋值给一个变量,该图像包含在此项目的文件夹中。注意,图像应该是gif文件,并且不能旋转以显示海龟的朝向。为了让海龟识别新的形状,使用 screen.register_shape()方法将其添加到 TurtleScreen 的 shapelist 中,并将引用地球图像的变量传给它。

现在是时候为地球实例化 turtle 对象了。你调用 Body 类并传入质量、起始位置、起始速度、引力系统和 turtle 形状(在这种情况下是图像)的参数。接下来,我们将更详细地讨论每个参数。

这里并没有使用真实世界的单位,因此质量是一个任意数字。我从 turtle 示例程序 planet_and_moon.py 中使用的太阳质量值开始,这个程序是本程序的基础。

起始位置是一个 (x, y) 元组,将地球放置在屏幕的中心附近。然而,它向下偏移了 25 像素,因为大部分动作会发生在屏幕的上半部分。这个位置将为该区域提供更多空间。

起始速度是一个简单的 (x, y) 元组,作为参数传递给 Vec2D 辅助类。如前所述,这将允许后续的方法通过向量运算来改变速度属性。需要注意的是,地球的速度不是 (0, 0),而是 (0, -2.5)。在现实世界中以及模拟中,月球足够大,会影响地球,使得地球和月球之间的重心不在地球的中心,而是在更远的地方。这会导致地球 turtle 在模拟过程中出现摆动,并且位置发生干扰性的变化。由于月球将在模拟过程中出现在屏幕的上半部分,每个时间步长地球向下移动少许将减缓这种摆动。

最后两个参数是你在之前列表中实例化的 gravsys 对象和地球的图像变量。传递 gravsys 意味着地球 turtle 将被添加到物体列表中,并包含在 sim_loop() 类方法中。

请注意,如果你不想在实例化对象时使用大量参数,可以在创建对象后更改其属性。例如,在定义 Body 类时,你本可以设置 self.mass = 0,而不是为质量使用一个参数。然后,在实例化地球对象后,你可以通过 earth.mass = 1000000 来重设质量值。

因为地球略有摆动,它的轨道路径会在行星的顶部形成一个紧密的圆圈。为了将其隐藏在极地帽中,可以使用 turtle 的 pencolor() 方法,并将线条颜色设置为白色。

使用代码使地球的海龟在模拟开始前延迟启动,并防止各种海龟在程序第一次绘制和调整大小时在屏幕上闪烁。getscreen() 方法返回海龟正在绘制的 TurtleScreen 对象。然后,可以对该对象调用 TurtleScreen 方法。在同一行中,调用 tracer() 方法,该方法用于打开或关闭海龟动画并设置绘图更新的延迟。n 参数决定了屏幕更新的次数。值为 0 表示每次循环屏幕都会更新;较大的值会逐渐抑制更新。这可以加速复杂图形的绘制,但会以图像质量为代价。第二个参数设置屏幕更新之间的延迟时间,单位为毫秒。增加延迟会减慢动画速度。

你将以类似地球海龟的方式构建月球海龟。首先分配一个新变量来保存月球的图像 ➊。月球的质量只有地球质量的几分之一,因此要为月球使用一个较小的值。我开始时使用了大约 16,000 的质量,并调整该值,直到 CSM 的飞行路径在月球周围产生了一个视觉上令人满意的循环。

月球的起始位置由图 6-3 中显示的相位角控制。像这个图一样,你在这里创建的模拟并非按比例显示。尽管地球和月球的图像将具有正确的相对大小,但它们之间的距离小于实际距离,因此需要相应地调整相位角。我在模型中减少了距离,因为太空非常广阔,真的非常大。如果你想要按比例显示模拟并将其完全适应你的计算机显示器,那么你只能接受一个极其小的地球和月球(图 6-10)。

Image

图 6-10:地球与月球系统在最近接近时,或称近地点,按比例显示

为了让这两个天体仍然易于识别,你将使用更大的、经过适当缩放的图像,但减少它们之间的距离(图 6-11)。这种配置对观众来说更加直观,同时仍然能够复制自由返回轨道。

由于在模拟中地球和月球之间的距离较近,根据开普勒的第二定律,月球的轨道速度会比实际情况快。为了解决这个问题,月球的起始位置设计得比图 6-3 所示的相位角要小。

Image

图 6-11:模拟中的地球与月球系统,仅有正确比例的天体大小

最后,你需要一个选项来在月球后面画一条线,以追踪它的轨道。使用海龟的 pencolor() 方法,并将线条颜色设置为灰色。

注意

例如质量、初始位置和初始速度等参数是全局常量的理想候选项。尽管如此,我选择将它们作为方法参数输入,以避免在程序开始时让用户承受过多的输入变量。

为 CSM 构建自定义形状

现在是时候实例化一个 turtle 对象来表示 CSM 了。这比创建最后两个对象需要更多的工作。

首先,无法将 CSM 显示为与地球和月球相同的比例。要做到这一点,你需要小于一个像素,但这是不可能的。而且,这样做还有什么乐趣呢?所以,再次,你将不拘一格地调整比例,使 CSM 足够大,以便能被识别为阿波罗飞船。

其次,你不会像处理其他两个物体时那样使用图像来表示 CSM。因为图像形状在 turtle 转动时不会自动旋转,而你希望在大部分旅程中使 CSM 尾部朝前,所以必须自定义自己的形状。

列表 6-8,仍然在 main() 中,通过绘制基本形状(如矩形和三角形)来构建 CSM 的表示。然后,你将这些单独的基本图形组合成一个最终的复合形状。

apollo_8_free_return.py, part 8
csm = Shape('compound')
cm = ((0, 30), (0, -30), (30, 0))
csm.addcomponent(cm, 'white', 'white')
sm = ((-60, 30), (0, 30), (0, -30), (-60, -30))
csm.addcomponent(sm, 'white', 'black')  
nozzle = ((-55, 0), (-90, 20), (-90, -20))
csm.addcomponent(nozzle, 'white', 'white')
screen.register_shape('csm', csm)

列表 6-8:为 CSM turtle 构建自定义形状

命名一个变量 csm 并调用 turtle Shape 类。传递参数 'compound',表示你希望使用多个组件构建形状。

第一个组件将是指令模块。命名一个变量 cm,并将其赋值为坐标对的元组,这些坐标在 turtle 中称为多边形类型。这些坐标构建一个三角形,如图 6-12 所示。

图像

图 6-12:包含喷嘴、服务模块和指令模块坐标的 CSM 复合形状

使用 addcomponent() 方法将这个三角形组件添加到 csm 形状中,方法通过点号符号调用。传递 cm 变量、填充颜色和轮廓颜色。合适的填充颜色包括白色、银色、灰色或红色。

对服务模块矩形重复此一般过程。在将组件添加到服务和指令模块时,设置轮廓颜色为黑色,以便区分服务模块和指令模块(参见图 6-12)。

使用另一个三角形表示喷嘴,也称为发动机喇叭。添加组件后,将新的 csm 复合形状注册到屏幕上。传递方法时需要提供形状的名称以及引用该形状的变量。

创建 CSM,启动仿真并调用 main()

列表 6-9 完成 main() 函数,通过实例化一个 CSM 的 turtle 并调用运行时间步骤的仿真循环。然后,如果程序在独立模式下运行,它将调用 main()。

apollo_8_free_return.py, part 9
    ship = Body(1, (Ro_X, Ro_Y), Vec(Vo_X, Vo_Y), gravsys, 'csm')
    ship.shapesize(0.2)
    ship.color('white')
    ship.getscreen().tracer(1, 0)
    ship.setheading(90)

    gravsys.sim_loop()

if __name__ == '__main__':
    main()

列表 6-9:实例化 CSM turtle,调用仿真循环和 main()

创建一个名为 ship 的海龟来表示指令模块。起始位置是一个(x, y)元组,将指令模块放置在屏幕上地球正下方的停泊轨道。我首先大致估算了停泊轨道的合适高度(图 6-3 中的 R[0]),然后通过反复运行仿真进行微调。注意,你使用的是程序开始时分配的常量,而非实际值。这是为了方便你以后实验这些值。

速度参数(Vo_X, Vo_Y)表示指令模块在土星第三阶段停止点火时的速度,即在向月球注入过程中。所有的推力都朝X方向,但地球的引力将使飞行轨迹立刻向上弯曲。像 R[0]参数一样,一个最佳猜测的速度被输入并通过仿真进行优化。注意,速度是一个使用 Vec2D 辅助类输入的元组,这使得后续方法能够通过向量运算改变速度。

接下来,使用 shapesize()方法设置飞船海龟的大小。然后将其路径颜色设置为白色,这样它就能与飞船的颜色匹配。其他吸引人的颜色有银色、灰色和红色。

使用 getscreen()和 tracer()方法控制屏幕更新,然后将飞船的航向设置为 90 度,使其在屏幕上指向正东方。

这完成了天体对象的定义。现在剩下的就是启动仿真循环,使用 gravsys 对象的 sim_loop()方法。在全局空间中,使用代码运行程序,可以作为导入模块或独立模式运行。

按照当前的程序,你必须手动关闭 Turtle Graphics 窗口。如果你希望窗口自动关闭,可以在 main()函数的最后一行添加以下命令:

screen.bye()

运行仿真

当你第一次运行仿真时,笔尖会抬起,且所有天体的轨道路径不会绘制出来(图 6-13)。指令模块会平滑地旋转并重新定位自己,接近月球然后是地球。

图像

图 6-13:仿真运行时,笔尖抬起,指令模块接近月球

要追踪指令模块的轨迹,去到 Body 类的定义并取消注释这行代码:

    self.pendown() # uncomment to draw path behind object

你现在应该能看到自由返回轨迹的“8 字形”轨迹(图 6-14)。

图像

图 6-14:仿真运行时,笔尖下压,指令舱在太平洋迫降

你还可以模拟引力推进—也叫做弹弓机动—通过将 Vo_X 速度变量设置为 520 到 540 之间的某个值,然后重新运行仿真。这将导致指令模块绕月球飞行,并窃取一些月球的动量,从而增加飞船的速度并偏转飞行路径(图 6-15)。拜拜,阿波罗 8 号!

图像

图 6-15:通过 Vo_X = 520 实现的引力弹弓机动

这个项目应该教会你,太空旅行是与秒与厘米赛跑的游戏。如果你继续尝试不同的 Vo_X 变量值,你会发现即便是微小的变化也可能让任务失败。如果你没有撞向月球,你可能会过于陡峭地重新进入地球大气层,或者完全错过月球!

模拟的好处在于,如果失败了,你仍然可以继续尝试。NASA 为其所有提出的任务进行无数次模拟。模拟结果帮助 NASA 在多个飞行计划中做出选择,找到最有效的航线,决定在出现问题时该怎么做,以及更多内容。

模拟对于外太阳系探索尤为重要,因为巨大的距离使得实时通信变得不可能。关键事件的时间安排,如喷射推进器、拍摄照片或释放探测器,都是基于精确模拟进行预编程的。

总结

在本章中,你学习了如何使用 turtle 绘图程序,包括如何制作自定义的 turtle 形状。你还学习了如何使用 Python 模拟引力并解决著名的三体问题。

进一步阅读

《阿波罗 8 号:第一次登月任务的惊心动魄故事》(亨利·霍尔特出版社,2017 年),作者杰弗里·克鲁格,讲述了历史性的阿波罗 8 号任务,从其不太可能的起点到其“难以想象的胜利”。

在线搜索 PBS Nova How Apollo 8 Left Earth Orbit 应该能返回一个简短的视频片段,讲解阿波罗 8 号的月际转移机动,标志着人类首次离开地球轨道并前往另一个天体。

《NASA 旅行者 1 号与 2 号用户手册》(海恩斯出版社,2015 年),作者克里斯托弗·赖利、理查德·科菲尔德和菲利普·多林,提供了关于三体问题以及迈克尔·米诺维奇对太空旅行贡献的有趣背景。

维基百科上的 引力辅助 页面包含了许多有趣的引力辅助机动动画和历史性的行星飞掠,你可以在阿波罗 8 号模拟中重现这些内容。

《追寻新视野:深入探秘第一次冯向冥王星的史诗任务》(皮卡多出版社,2018 年),作者艾伦·斯特恩和大卫·格林斯普恩,记录了模拟在 NASA 任务中的重要性和普及性。

实践项目:模拟搜索模式

在第一章中,你使用贝叶斯定理帮助海岸警卫队寻找失踪的海员。现在,使用 turtle 设计一个直升机搜索模式,以寻找失踪的海员。假设观察员能够看到 20 像素远的区域,并且长航迹之间的间隔为 40 像素(见图 6-16)。

图片

图 6-16:来自 practice_search_pattern.py 的两个截图

为了好玩,添加一个直升机 turtle,并在每次飞行时正确调整它的方向。还可以添加一个随机位置的海员 turtle,在找到海员时停止模拟,并在屏幕上显示喜讯(见图 6-17)。

Image

图 6-17:海员在 practice_search_pattern.py 中被发现。

你可以在附录中找到解决方案,practice_search_pattern.py。我已将数字版本和直升机、海员图像一并包含在 Chapter_6 文件夹中,可以从书籍网站下载。

练习项目:启动我!

重写 apollo_8_free_return.py 使得移动的月球接近静止的指令舱,导致指令舱开始移动,然后将其甩向远方。为了增加趣味,调整指令舱的方向,使其始终指向前进的方向,仿佛在自身的推动下行进(见 图 6-18)。

Image

图 6-18:月球接近静止的指令舱(左),然后将其抛向星空(右)。

如需解决方案,请参见附录中的 practice_grav_assist_stationary.py,或从 nostarch.com/real-world-python/ 下载。

练习项目:关闭我!

重写 apollo_8_free_return.py 使得指令舱和月球的轨道交叉,指令舱在月球之前通过,而月球的引力减缓指令舱的速度,并使其改变方向约 90 度。与之前的练习项目一样,让指令舱始终指向行进的方向(见 图 6-19)。

Image

图 6-19:月球与指令舱交叉轨道,月球减慢并转向指令舱。

如需解决方案,请参见附录中的 practice_grav_assist_intersecting.py,或从 nostarch.com/real-world-python/ 下载。

挑战项目:真实比例模拟

重写 apollo_8_free_return.py 使得地球、月球及其之间的距离都能准确缩放,如 图 6-10 所示。使用彩色圆圈代替图像表示地球和月球,且使指令舱不可见(只绘制其后方的线条)。使用 表 6-2 来帮助确定相对的大小和距离。

表 6-2:地月系统的长度参数

地球半径 6,371 公里
月球半径 1,737 公里
地月距离 356,700 公里*

*阿波罗 8 号任务在 1968 年 12 月的最接近接触

挑战项目:真实的阿波罗 8 号

重写 apollo_8_free_return.py 使其模拟整个阿波罗 8 号任务,而不仅仅是自由返回组件。指令舱应绕月球轨道飞行 10 圈后再返回地球。

第七章:选择火星着陆点

Image

在火星上着陆是一项极其困难且充满风险的任务。没有人希望失去一颗价值十亿的探测器,因此工程师必须强调操作安全。他们可能会花费数年时间通过卫星图像寻找最安全的着陆点,以满足任务目标。而且他们需要覆盖的区域非常广阔,火星的干旱土地几乎与地球一样多!

分析如此大范围的区域需要计算机的帮助。在本章中,你将使用 Python 语言和喷气推进实验室(Jet Propulsion Laboratory)骄傲的成果——火星轨道激光测高仪(MOLA)地图,来选择和排名火星着陆器的候选着陆点。为了加载和提取 MOLA 地图中的有用信息,你将使用 Python Imaging Library、OpenCV、tkinter 和 NumPy。

如何在火星上着陆

将探测器着陆到火星表面的方法有很多,包括降落伞、气球、反向火箭和喷气背包。无论采用哪种方法,大多数着陆操作都遵循相同的基本安全规则。

第一个规则是目标选择低洼地区。探测器可能以每小时 27,000 公里的速度进入火星大气层。为了实现软着陆,必须通过厚厚的大气层减速。然而,火星的大气层非常稀薄——其密度大约是地球大气的 1%。为了找到足够的密度以产生影响,你需要瞄准最低的海拔区域,在那里空气更稠密,飞行所需的时间也最长。

除非你有专门的探测器,例如为极地冰帽设计的探测器,否则你会希望选择靠近赤道的地区着陆。这里,你将能获得充足的阳光来为探测器的太阳能面板提供能量,并且温度足够温暖,可以保护探测器的精密机械设备。

你需要避免选择那些被巨石覆盖的地方,因为巨石可能会摧毁探测器,阻止其面板展开,挡住其机械臂,或让探测器倾斜,无法面向太阳。出于类似的原因,你还应该避开那些陡坡区域,比如火山口边缘的地方。从安全的角度来看,越平坦越好,而无聊的地方则是美丽的。

另一个着陆火星的挑战是精确度有限。要飞行超过 5000 万公里,穿越大气层,并精确地在预定位置着陆是非常困难的。星际导航的误差以及火星大气特性的变化,使得在小范围内命中目标变得非常不确定。

因此,NASA 会对每个着陆坐标进行大量计算机模拟。每次模拟运行都会生成一个坐标,成千上万次运行的散点会形成一个椭圆形状,其长轴与探测器的飞行路径平行。这些着陆椭圆可能非常大(见图 7-1),尽管随着每次新任务的推进,准确性会有所提高。

Image

图 7-1:1997 年火星探路者着陆点(左)与南加州(右)的对比图

2018 年的 InSight 着陆器的着陆椭圆仅为 130 km × 27 km。探测器在该椭圆范围内着陆的概率约为 99%。

MOLA 地图

为了识别合适的着陆点,你需要一张火星地图。在 1997 年至 2001 年之间,Mars Global Surveyor (MGS) 太空船上的一台工具向火星发射激光,并反射计时 6 亿次。通过这些测量,由玛丽亚·祖伯(Maria Zuber)和大卫·史密斯(David Smith)领导的研究人员制作了一张详细的全球地形图,被称为 MOLA (图 7-2)。

Image

图 7-2:火星的 MOLA 阴影地形图

要查看 MOLA 的精彩彩色版本以及图例,请访问 Mars Global Surveyor 的维基百科页面。地图中的蓝色区域对应于数十亿年前火星上可能存在的海洋和海域。这些区域的分布是基于海拔和诊断性地表特征的组合,比如古老的海岸线。

MOLA 的激光测量垂直定位精度约为 3 至 13 米,水平定位精度约为 100 米。像素分辨率为每像素 463 米。单独看,MOLA 地图缺乏选择最终着陆椭圆所需的详细信息,但它非常适合进行你将要执行的范围划定工作。

项目 #10:选择火星着陆点

假设你是 NASA 夏季实习生,正在参与 Orpheus 项目,这个任务旨在探测火星震动并研究火星内部,就像 2018 年的火星 InSight 任务一样。因为 Orpheus 的目的是研究火星的内部,所以火星表面的有趣特征并不那么重要。安全性是首要关注点,这使得这个任务成为工程师梦寐以求的机会。

你的任务是找到至少十二个区域,供 NASA 的工作人员选择较小的候选着陆椭圆。根据你的主管的要求,这些区域应为长 670 km(东西方向)和宽 335 km(南北方向)的矩形。为了应对安全问题,这些区域应横跨赤道,位于 30°N 和 30°S 纬度之间,处于低海拔,并且尽可能平坦光滑。

目标

编写一个 Python 程序,利用 MOLA 地图中的图像选择靠近火星赤道的 20 个最安全的 670 km × 335 km 区域,从中选择 Orpheus 着陆器的着陆椭圆。

策略

首先,您需要一种方法将 MOLA 数字地图划分为矩形区域,并提取高程和表面粗糙度的统计数据。这意味着您将处理像素,因此需要图像处理工具。由于 NASA 总是注重成本控制,您将希望使用免费且开源的库,如 OpenCV、Python 图像库(PIL)、tkinter 和 NumPy。有关概述和安装说明,请参阅 OpenCV 和 NumPy 在第 6 页的“安装 Python 库”,以及 PIL 在第 65 页的“文字云和 PIL 模块”。tkinter 模块随 Python 一起预安装。

为了尊重高程约束,您可以简单地计算每个区域的平均高程。对于在给定尺度下测量表面平滑度,您有很多选择,其中一些相当复杂。除了基于高程数据来衡量平滑度,您还可以通过立体图像中的差异阴影、雷达、激光和微波反射中的散射量、红外图像中的热变化等来进行测量。许多粗糙度估算涉及沿横断面进行繁琐的分析,横断面是绘制在地球表面上的线,沿着这些线测量并检查高度变化。由于您并不是一个有三个月时间的暑期实习生,因此您将保持简单,使用两种常见的度量方法,并将其应用于每个矩形区域:标准差和峰谷值。

标准差,也被物理科学家称为均方根,是衡量一组数字分散程度的指标。低标准差表明一组中的值接近平均值;高标准差则表明它们分布在更广泛的范围内。具有低标准差的高程地图区域意味着该区域相对平坦,高程值与平均值的差异很小。

从技术上讲,样本群体的标准差是均值的平方偏差的平均值的平方根,表示如下公式:

Image

其中,σ 是标准差,N 是样本数量,h[i] 是当前的高度样本,h[0] 是所有高度的平均值。

峰谷值统计是指表面上最高点与最低点之间的高度差。它捕捉了表面上的最大高度变化。这一点很重要,因为一个表面可能具有相对较低的标准差——暗示着平滑——但仍然可能包含显著的危险,如图 7-3 中的横截面所示。

Image

图 7-3:表面剖面(黑线)与标准差(StD)和峰谷值(PV)统计数据

你可以使用标准差和峰谷统计数据作为比较指标。对于每个矩形区域,你需要寻找每个统计数据的最低值。由于每个统计数据记录的是略有不同的内容,你将基于每个统计数据找到最佳的 20 个矩形区域,然后只选择那些重叠的矩形,最终找到最佳的矩形区域。

站点选择器代码

site_selector.py 程序使用 MOLA 地图的灰度图像(图 7-4)来选择着陆点矩形,并使用阴影颜色图(图 7-2)显示它们。高程通过灰度图像中的单通道表示,因此比三通道(RGB)彩色图像更容易使用。

图片

图 7-4:火星 MGS MOLA 数字高程模型 463m v2 (mola_1024x501.png)

你可以在 Chapter_7 文件夹中找到程序、灰度图像 (mola_1024x501.png) 和彩色图像 (mola_color_1024x506.png),该文件夹可以从 nostarch.com/real-world-python/ 下载。请将这些文件保存在同一文件夹中,并且不要重命名它们。

注意

MOLA 地图有多种文件大小和分辨率。这里使用的是最小的文件尺寸,以加快下载和运行时间。

导入模块并分配用户输入常量

清单 7-1 导入模块并分配代表用户输入参数的常量。这些包括图像文件名、矩形区域的尺寸、最大高程限制以及考虑的候选矩形数量。

site_selector.py, part 1
import tkinter as tk
from PIL import Image, ImageTk
import numpy as np
import cv2 as cv

# CONSTANTS: User Input:
IMG_GRAY = cv.imread('mola_1024x501.png', cv.IMREAD_GRAYSCALE)
IMG_COLOR = cv.imread('mola_color_1024x506.png')
RECT_WIDTH_KM = 670  
RECT_HT_KM = 335  
MAX_ELEV_LIMIT = 55  
NUM_CANDIDATES = 20  
MARS_CIRCUM = 21344

清单 7-1:导入模块并分配用户输入常量

首先导入 tkinter 模块。这是 Python 默认的 GUI 库,用于开发桌面应用程序。你将使用它来制作最终的显示窗口:顶部是彩色 MOLA 地图,底部是已发布矩形的文本描述。大多数 Windows、macOS 和 Linux 机器都已安装 tkinter。如果你没有安装,或者需要最新版本,可以从 www.activestate.com/ 下载并安装。该模块的在线文档可以在 docs.python.org/3/library/tk.html 找到。

接下来,从 Python 图像库中导入 Image 和 ImageTK 模块。Image 模块提供了一个表示 PIL 图像的类,并且提供了工厂函数,包括从文件加载图像和创建新图像的函数。ImageTK 模块包含用于从 PIL 图像创建和修改 tkinter 的 BitmapImage 和 PhotoImage 对象的支持。再次强调,你将在程序结束时使用这些模块将彩色地图和一些描述性文本放入总结窗口中。最后,导入 NumPy 和 OpenCV。

现在,分配一些常量来表示程序运行过程中不会改变的用户输入。首先,使用 OpenCV 的 imread() 方法加载灰度的 MOLA 图像。请注意,必须使用 cv.IMREAD_GRAYSCALE 标志,因为该方法默认加载彩色图像。没有使用该标志的代码将加载彩色图像。然后,添加矩形大小的常量。在接下来的列表中,你将把这些尺寸转换为像素,以便在地图图像中使用。

接下来,为了确保矩形定位在低海拔的平坦区域,你应该将搜索限制在轻微坑洼的平坦地形上。这些区域被认为代表古老的海洋底部。因此,你将设置最大海拔限制为灰度值 55,这与被认为是古代海岸线遗迹的区域非常接近(参见图 7-5)。

图像

图 7-5:MOLA 地图,像素值 ≤ 55 的区域被涂黑,以表示古老的火星海洋

现在,指定要显示的矩形数量,由 NUM_CANDIDATES 变量表示。稍后,你将从一个排序后的矩形统计数据列表中选择这些矩形。通过为火星周长(以公里为单位)分配一个常量,来完成用户输入常量的设置。你稍后将使用这个常量来确定每公里的像素数。

分配派生常量并创建屏幕对象

列表 7-2 分配了从其他常量派生的常量。这些值会在用户更改前述常量时自动更新,例如测试不同的矩形大小或海拔限制。该列表最终通过创建 tkinter 屏幕和画布对象来完成最终显示。

site_selector.py, part 2
   # CONSTANTS: Derived:
   IMG_HT, IMG_WIDTH = IMG_GRAY.shape
   PIXELS_PER_KM = IMG_WIDTH / MARS_CIRCUM
   RECT_WIDTH = int(PIXELS_PER_KM * RECT_WIDTH_KM)
   RECT_HT = int(PIXELS_PER_KM * RECT_HT_KM)
➊ LAT_30_N = int(IMG_HT / 3)
   LAT_30_S = LAT_30_N * 2
   STEP_X = int(RECT_WIDTH / 2)
   STEP_Y = int(RECT_HT / 2)

➋ screen = tk.Tk()
   canvas = tk.Canvas(screen, width=IMG_WIDTH, height=IMG_HT + 130)

列表 7-2:分配派生常量并设置 tkinter 屏幕

首先,使用 shape 属性解包图像的高度和宽度。OpenCV 将图像存储为 NumPy 的 ndarray,这些是n维数组——也就是同类元素的表格。对于图像数组,shape 是一个包含行数、列数和通道数的元组。高度表示图像中的像素行数,宽度表示图像中的像素列数。通道表示用来表示每个像素的组件数量(例如红、绿、蓝)。对于只有一个通道的灰度图像,shape 只是表示区域的高度和宽度的元组。

为了将矩形的尺寸从公里转换为像素,你需要知道每公里的像素数。所以,将图像的宽度除以周长,以获得赤道上的每公里像素数。然后将宽度和高度转换为像素。你将在后续的索引切片中使用这些值,因此请确保它们是整数,可以通过使用 int() 来确保。现在,这些常量的值应该分别是 32 和 16。

你想将搜索范围限制在最温暖和最阳光明媚的区域,这些区域位于赤道之间,纬度在 30°北和 30°南之间(参见图 7-6)。就气候标准而言,这个区域对应于地球的热带。

Image

图 7-6:火星的纬度(y 轴)和经度(x 轴)

纬度值从赤道的 0°开始,到极地的 90°结束。要找到 30°北纬,你只需要将图像的高度除以 3 ➊。要到达 30°南纬,只需将到达 30°北纬所需的像素数翻倍。

将搜索范围限制在火星的赤道区域有一个有益的副作用。你正在使用的 MOLA 地图基于圆柱投影,该投影用于将地球表面转移到平面上。这导致经线收敛并变得平行,从而严重扭曲了靠近极地的地物。你可能在地球的墙面地图上注意到这种情况,格林兰岛看起来像一个大陆,南极洲巨大得不可思议(参见图 7-7)。

幸运的是,这种扭曲在赤道附近最小化,因此你不必将其考虑在矩形的维度中。你可以通过检查 MOLA 地图上的火山口形状来验证这一点。只要它们是圆形的——而不是椭圆形的——就可以忽略与投影相关的效应。

Image

图 7-7:强迫经线平行会扭曲靠近极地的地物大小。

接下来,你需要将地图划分成矩形区域。一个合逻辑的起点是左上角,位于 30°北纬线下方(参见图 7-8)。

Image

图 7-8:第一个编号矩形的位置

程序将绘制第一个矩形,编号并计算其中的高度统计数据。然后它会将矩形向东移动,并重复这一过程。每次移动矩形的距离由 STEP_X 和 STEP_Y 常量定义,并且与称为别名效应的现象有关。

别名效应是一个分辨率问题。当你没有足够多的样本来识别某一地区所有重要的地表特征时,就会发生这种情况。这可能导致你“跳过”某个特征,比如火山口,从而未能识别出来。例如,在图 7-9A 中,在两个大火山口之间有一个适当平滑的着陆椭圆。然而,如图 7-9B 所示,任何一个矩形区域都没有对应这个椭圆;该区域内的两个矩形都部分采样了一个火山口的边缘。因此,虽然在附近存在一个适合的着陆椭圆,但绘制的矩形中没有一个包含该椭圆。在这种矩形布局下,图 7-9A 中的椭圆产生了别名效应。但是,如果将每个矩形移动其宽度的一半,如图 7-9C 所示,平滑区域将被正确采样并识别出来。

Image

图 7-9:由于矩形位置引起的别名效应示例

避免别名效应的经验法则是将步长设置为小于或等于你想要识别的最小特征的宽度的一半。对于这个项目,使用矩形宽度的一半,这样显示不会显得过于拥挤。

现在是时候展望最终显示的效果了。创建一个 tkinter Tk() 类的屏幕实例 ➋。tkinter 应用程序是 Python 对 GUI 工具包 Tk 的封装,Tk 最初是用一种叫做 TCL 的计算机语言编写的。它需要一个屏幕窗口来连接到底层的 tcl/tk 解释器,将 tkinter 命令翻译成 tcl/tk 命令。

接下来,创建一个 tkinter 画布对象。这是一个矩形绘图区域,旨在支持复杂的图形、文本、控件和框架布局。将屏幕对象传递给它,将其宽度设置为 MOLA 图像的宽度,并将其高度设置为 MOLA 图像的高度加上 130。图像下方的额外填充区域将用来显示总结矩形统计数据的文本。

通常来说,应该将刚才描述的 tkinter 代码放在程序的最后,而不是放在开始部分。我选择将其放在接近顶部的位置,以便更容易理解代码的解释。你也可以将这段代码嵌入到生成最终显示的函数中。然而,这可能会给 macOS 用户带来问题。对于 macOS 10.6 或更高版本,Apple 提供的 Tcl/Tk 8.5 存在严重的 bug,可能导致应用程序崩溃(详见 www.python.org/download/mac/tcltk/)。

定义和初始化 Search 类

清单 7-3 定义了一个类,你将用它来搜索合适的矩形区域。接着定义了类的 init() 初始化方法,用于实例化新对象。有关面向对象编程(OOP)的快速概述,请参见 第 10 页的“定义 Search 类”部分,在那里你也将定义一个搜索类。

site_selector.py, part 3
class Search():
    """Read image and identify landing rectangles based on input criteria.""" 

   def __init__(self, name):
       self.name = name
    ➊ self.rect_coords = {}
       self.rect_means = {}
       self.rect_ptps = {}
       self.rect_stds = {}
    ➋ self.ptp_filtered = []
       self.std_filtered = []
       self.high_graded_rects = []

清单 7-3:定义 Search 类和 init() 方法

定义一个名为 Search 的类。然后定义 init() 方法,用于创建新对象。name 参数将允许你在稍后的 main() 函数中创建对象时为每个对象赋予个性化的名称。

现在你准备好开始分配属性了。首先将对象的名称与创建对象时提供的参数关联。然后,分配四个空字典来保存每个矩形的重要统计数据 ➊。这些包括矩形的角点坐标及其平均海拔、峰谷高度差和标准差统计数据。作为键,这些字典将使用连续的数字,从 1 开始。你将需要过滤这些统计数据以找到最低值,因此设置两个空列表来存储这些 ➋。注意,我使用 ptp 这个术语,而不是 ptv,来表示峰谷高度差统计数据。这样做是为了与 NumPy 内置方法中用于此计算的峰值到峰值一致。

在程序结束时,您将把同时出现在排序后的标准差和峰值到谷值列表中的矩形放入一个名为 high_graded_rects 的新列表中。这个列表将包含得分最低的矩形编号。这些矩形将是寻找着陆椭圆的最佳地点。

计算矩形统计数据

仍然在 Search 类中,列表 7-4 定义了一个方法,该方法计算矩形中的统计数据,将统计数据添加到相应的字典中,然后移动到下一个矩形并重复该过程。该方法通过仅使用低洼地区的矩形来填充字典,从而遵守了海拔限制。

site_selector.py, part 4
def run_rect_stats(self):
    """Define rectangular search areas and calculate internal stats."""
    ul_x, ul_y = 0, LAT_30_N
    lr_x, lr_y = RECT_WIDTH, LAT_30_N + RECT_HT
    rect_num = 1

    while True:
     ➊ rect_img = IMG_GRAY[ul_y : lr_y, ul_x : lr_x]
        self.rect_coords[rect_num] = [ul_x, ul_y, lr_x, lr_y]
        if np.mean(rect_img) <= MAX_ELEV_LIMIT:
            self.rect_means[rect_num] = np.mean(rect_img)
            self.rect_ptps[rect_num] = np.ptp(rect_img)
            self.rect_stds[rect_num] = np.std(rect_img)
        rect_num += 1

        ul_x += STEP_X
        lr_x = ul_x + RECT_WIDTH
      ➋ if lr_x > IMG_WIDTH:
            ul_x = 0
            ul_y += STEP_Y
            lr_x = RECT_WIDTH
            lr_y += STEP_Y
      ➌ if lr_y > LAT_30_S + STEP_Y:
            break

列表 7-4:计算矩形统计数据并移动矩形

定义 run_rect_stats() 方法,该方法将 self 作为参数。然后为每个矩形的左上角和右下角分配局部变量。通过将坐标和常数结合来初始化这些变量。这样可以将第一个矩形放置在图像的左侧,且其上边界位于 30° 北纬。

通过为矩形编号来跟踪这些矩形,编号从 1 开始。这些编号将作为字典的键,用于记录坐标和统计数据。您还将用它们来标识地图上的矩形,正如之前在 图 7-8 中演示的那样。

现在,启动一个 while 循环,该循环将自动执行移动矩形和记录统计数据的过程。该循环会一直运行,直到矩形的超过一半部分延伸到 30° 南纬以下,届时循环将终止。

如前所述,OpenCV 将图像存储为 NumPy 数组。为了计算活动矩形中的统计数据,而不是整张图像,可以使用常规切片 ➊ 创建一个子数组。将此子数组命名为 rect_img,即“矩形图像”。然后,将矩形编号和这些坐标添加到 rect_coords 字典中。您需要为 NASA 的工作人员保留这些坐标记录,他们将使用您的矩形作为后续更详细调查的起点。

接下来,开始一个条件判断,检查当前矩形是否在指定的项目最大海拔限制以下或等于该限制。作为此语句的一部分,使用 NumPy 来计算 rect_img 子数组的平均海拔。

如果矩形通过了海拔测试,按照需要填充三个字典,分别包含坐标、峰值到谷值的差值和标准差统计数据。请注意,您可以将计算过程作为步骤的一部分进行,使用 np.ptp 来计算峰值到谷值的差值,使用 np.std 来计算标准差。

接下来,将 rect_num 变量增加 1 并移动矩形。将左上角的x坐标按步长移动,然后将右下角的x坐标按矩形的宽度进行平移。您不希望矩形越过图像的右侧,因此需要检查 lr_x 是否大于图像的宽度➋。如果是,设置左上角的x坐标为 0,将矩形移回屏幕左侧的起始位置。然后将其y坐标向下移动,以便新矩形沿着新的一行移动。如果这一新行的底部距离南纬 30°超过半个矩形的高度,您就已经完全采样了搜索区域,可以结束循环➌。

在南北纬 30°之间,图像两侧被相对较高、坑洼的地形所限制,这些区域不适合作为着陆点(见图 7-6)。因此,您可以忽略最后一步,即将矩形向其宽度的一半方向平移。否则,您将需要添加代码,将矩形从图像的一侧移动到另一侧,并计算每个部分的统计数据。我们将在本章末的最终挑战项目中更详细地了解这种情况。

注意

当您在图像上绘制某些东西时,例如矩形,该绘制将成为图像的一部分。改变后的像素将在您运行的任何 NumPy 分析中包含,因此请确保在注释图像之前先计算任何统计数据。

检查矩形位置

在搜索类下仍然缩进,列表 7-5 定义了一个执行质量控制的方法。它打印出所有矩形的坐标,然后在 MOLA 地图上绘制它们。这将帮助您验证搜索区域是否已完全评估,并且矩形的大小是否符合预期。

site_selector.py, part 5
def draw_qc_rects(self):
        """Draw overlapping search rectangles on image as a check."""
        img_copy = IMG_GRAY.copy()
        rects_sorted = sorted(self.rect_coords.items(), key=lambda x: x[0])
        print("\nRect Number and Corner Coordinates (ul_x, ul_y, lr_x, lr_y):")
        for k, v in rects_sorted:
            print("rect: {}, coords: {}".format(k, v))
            cv.rectangle(img_copy,
                         (self.rect_coords[k][0], self.rect_coords[k][1]),
                         (self.rect_coords[k][2], self.rect_coords[k][3]),
                         (255, 0, 0), 1)
        cv.imshow('QC Rects {}'.format(self.name), img_copy)
        cv.waitKey(3000)
        cv.destroyAllWindows()

列表 7-5:作为质量控制步骤在 MOLA 地图上绘制所有矩形

首先定义一个方法,在图像上绘制矩形。在 OpenCV 中,您在图像上绘制的任何东西都会成为图像的一部分,因此首先需要在本地空间中复制图像。

您需要向 NASA 提供每个矩形的标识号码和坐标的打印输出。为了按数字顺序打印这些信息,请使用 lambda 函数对 rect_coords 字典中的项进行排序。如果您之前没有使用过 lambda 函数,可以在第 107 页的第五章中找到简短的说明。

为列表打印一个标题,然后开始通过新排序的字典中的键和值进行循环。键是矩形编号,值是坐标列表,如下所示的输出:

Rect Number and Corner Coordinates (ul_x, ul_y, lr_x, lr_y):
rect: 1, coords: [0, 167, 32, 183]
rect: 2, coords: [16, 167, 48, 183]

--snip--

rect: 1259, coords: [976, 319, 1008, 335]
rect: 1260, coords: [992, 319, 1024, 335]

使用 OpenCV 的 rectangle()方法在图像上绘制矩形。传入绘制的图像、矩形坐标、颜色和线宽。通过直接从 rect_coords 字典中使用键和列表索引(0 = 左上角x,1 = 左上角y,2 = 右下角x,3 = 右下角y)来访问坐标。

为了显示图像,调用 OpenCV 的 imshow()方法,并传入窗口的名称和图像变量。矩形应覆盖在以赤道为中心的带状区域内的火星图像上(图 7-10)。保持窗口显示三秒钟,然后销毁它。

图片

图 7-10:draw_qc_rects()方法绘制的所有 1,260 个矩形

如果你将图 7-10 与图 7-8 进行比较,你可能会注意到矩形看起来比预期的小。这是因为你使用矩形的宽度和高度的一半步进地移动矩形,在图像上水平和垂直排列,导致它们相互重叠。

排序统计数据并高评分矩形

继续定义 Search 类,列表 7-6 定义了一个方法,用于寻找具有最佳潜在着陆点的矩形。该方法对包含矩形统计数据的字典进行排序,根据峰谷值和标准差统计数据制作矩形的前几个列表,然后制作这两个列表之间共享的矩形列表。共享的矩形将是着陆点的最佳候选者,因为它们将具有最小的峰谷值和标准差。

site_selector.py, part 6
def sort_stats(self): 
    """Sort dictionaries by values and create lists of top N keys."""
    ptp_sorted = (sorted(self.rect_ptps.items(), key=lambda x: x[1]))
    self.ptp_filtered = [x[0] for x in ptp_sorted[:NUM_CANDIDATES]]
    std_sorted = (sorted(self.rect_stds.items(), key=lambda x: x[1]))
    self.std_filtered = [x[0] for x in std_sorted[:NUM_CANDIDATES]]    
    for rect in self.std_filtered:
        if rect in self.ptp_filtered:
            self.high_graded_rects.append(rect)

列表 7-6:基于统计数据对矩形进行排序和高评分

定义一个名为 sort_stats()的方法。使用一个 lambda 函数对 rect_ptps 字典进行排序,该函数根据值进行排序,而不是键。字典中的值是峰谷测量值。这将创建一个包含元组的列表,元组的第 0 个索引是矩形编号,第 1 个索引是峰谷值。

接下来,使用列表推导式将矩形编号填充到 self.ptp_filtered 属性中,矩形编号来自 ptp_sorted 列表。使用索引切片选择前 20 个值,如 NUM_CANDIDATES 常量所规定。你现在得到了 20 个具有最低峰谷值的矩形。对标准差进行相同的基本代码处理,生成具有最低标准差的 20 个矩形的列表。

通过遍历 std_filtered 列表中的矩形编号,并将其与 ptp_filtered 列表中的编号进行比较来完成该方法。将匹配的编号附加到你之前使用 init()方法创建的 high_graded_rects 实例属性中。

在地图上绘制过滤后的矩形

列表 7-7,仍然位于 Search 类下,定义了一个方法,用于在灰度 MOLA 地图上绘制 20 个最佳矩形。你将在 main()函数中调用此方法。

site_selector.py, part 7 
def draw_filtered_rects(self, image, filtered_rect_list):
    """Draw rectangles in list on image and return image."""
    img_copy = image.copy()
    for k in filtered_rect_list: 
        cv.rectangle(img_copy,
                     (self.rect_coords[k][0], self.rect_coords[k][1]),
                     (self.rect_coords[k][2], self.rect_coords[k][3]),
                     (255, 0, 0), 1)
        cv.putText(img_copy, str(k),
                   (self.rect_coords[k][0] + 1, self.rect_coords[k][3]- 1),
                   cv.FONT_HERSHEY_PLAIN, 0.65, (255, 0, 0), 1)

 ➊ cv.putText(img_copy, '30 N', (10, LAT_30_N - 7),
               cv.FONT_HERSHEY_PLAIN, 1, 255)
    cv.line(img_copy, (0, LAT_30_N), (IMG_WIDTH, LAT_30_N),
            (255, 0, 0), 1)
    cv.line(img_copy, (0, LAT_30_S), (IMG_WIDTH, LAT_30_S),
            (255, 0, 0), 1)
    cv.putText(img_copy, '30 S', (10, LAT_30_S + 16),
               cv.FONT_HERSHEY_PLAIN, 1, 255)

    return img_copy

列表 7-7:在 MOLA 地图上绘制过滤后的矩形和纬度线

首先定义方法,该方法需要多个参数。除了 self,方法还需要加载的图像和一个矩形编号的列表。使用一个局部变量复制图像,然后开始循环遍历 filtered_rect_list 中的矩形编号。每次循环时,通过使用矩形编号访问 rect_coords 字典中的角落坐标来绘制矩形。

为了区分一个矩形和另一个矩形,使用 OpenCV 的 putText() 方法在每个矩形的左下角显示矩形编号。该方法需要图像、文本(字符串形式)、左上角 x 和右下角 x 的坐标、字体、线宽和颜色。

接下来,绘制标注的纬度限制,从 30° 北纬的文本开始 ➊。然后使用 OpenCV 的 line() 方法绘制该线。该方法需要传入一个图像、一对 (x, y) 坐标表示线段的起点和终点,一个颜色和一个厚度。对 30° 南纬的纬线执行相同的操作。

通过返回标注后的图像来结束该方法。基于峰谷得分和标准差统计数据,最佳矩形分别显示在 图 7-11 和 7-12 中。

这两幅图展示了每个统计数据的前 20 个矩形。这并不意味着它们总是相同的。由于单个小陨石坑的存在,标准差最低的矩形可能不会出现在峰谷图中。为了找到最平坦、最平滑的矩形,你需要识别出同时出现在这两幅图中的矩形,并在自己的显示区域中展示它们。

图片

图 7-11:标准差最低的 20 个峰谷得分矩形

图片

图 7-12:标准差最低的 20 个矩形

制作最终的彩色显示

列表 7-8 通过定义一个方法总结最佳矩形,完成了 Search 类的实现。它使用 tkinter 创建一个总结窗口,并将矩形显示在彩色 MOLA 图像上。它还在图像下方打印矩形的统计数据作为文本对象。虽然这增加了一些工作量,但比直接在图像上用 OpenCV 显示总结的统计数据要干净得多。

site_selector.py, part 8
def make_final_display(self):
    """Use Tk to show map of final rects & printout of their statistics."""
    screen.title('Sites by MOLA Gray STD & PTP {} Rect'.format(self.name))

    img_color_rects = self.draw_filtered_rects(IMG_COLOR,
                                               self.high_graded_rects)

 ➊ img_converted = cv.cvtColor(img_color_rects, cv.COLOR_BGR2RGB)
    img_converted = ImageTk.PhotoImage(Image.fromarray(img_converted)) 
    canvas.create_image(0, 0, image=img_converted, anchor=tk.NW)

 ➋ txt_x = 5
    txt_y = IMG_HT + 20
    for k in self.high_graded_rects:
        canvas.create_text(txt_x, txt_y, anchor='w', font=None,
                           text="rect={} mean elev={:.1f} std={:.2f} ptp={}"
                           .format(k, self.rect_means[k], self.rect_stds[k],
                                   self.rect_ptps[k]))
        txt_y += 15
     ➌ if txt_y >= int(canvas.cget('height')) - 10:
            txt_x += 300
            txt_y = IMG_HT + 20        
    canvas.pack()
    screen.mainloop()

列表 7-8:使用彩色 MOLA 地图制作最终显示

定义方法后,为 tkinter 屏幕窗口设置一个标题,标题链接到你的搜索对象名称。

然后,为了制作最终的彩色图像用于显示,命名一个局部变量 img_color_rects 并调用 draw_filtered_rects() 方法。将彩色 MOLA 图像和评分较高的矩形列表作为参数传入。这将返回包含最终矩形和纬度限制的彩色图像。

在您将这个新彩色图像显示到 tkinter 画布之前,您需要将图像的颜色从 OpenCV 的蓝绿红(BGR)格式转换为 tkinter 使用的红绿蓝(RGB)格式。可以使用 OpenCV 的 cvtColor()方法来完成此转换。将图像变量和 COLOR_BGR2RGB 标志➊传递给该方法。将结果命名为 img_converted。

此时,图像仍然是 NumPy 数组。要转换为 tkinter 兼容的照片图像,您需要使用 PIL ImageTk 模块的 PhotoImage 类和 Image 模块的 fromarray()方法。将您在前一步中创建的 RGB 图像变量传递给该方法。

现在图像已准备好用于 tkinter,使用 create_image()方法将其放置在画布中。将画布的左上角坐标(0, 0)、转换后的图像和西北锚点方向传递给该方法。

现在剩下的就是添加总结文本。首先为第一个文本对象➋的左下角分配坐标。然后开始遍历高等级矩形列表中的矩形编号。使用 create_text()方法将文本放入画布中。传递给它一对坐标、左对齐的锚点方向、默认字体和文本字符串。通过使用矩形编号(用 k 表示“key”)访问不同的字典来获取统计信息。

在绘制每个文本对象后,将文本框的y坐标增加 15。然后写一个条件语句,检查文本是否大于或位于画布底部 10 像素以内➌。您可以使用 cget()方法获取画布的高度。

如果文本离画布底部太近,您需要开始新的一列。将 txt_x 变量向右移动 300,并将 txt_y 重置为图像的高度加 20。

完成方法定义后,通过打包画布并调用屏幕对象的 mainloop()来结束。打包优化了画布中对象的排列。mainloop()是一个无限循环,运行 tkinter,等待事件发生并处理该事件,直到窗口关闭。

注意

彩色图像的高度(506 像素)略大于灰度图像的高度(501 像素)。我选择忽略这一点,但如果你对准确性有严格要求,可以使用 OpenCV 通过IMG_COLOR = cv.resize(IMG_COLOR, (1024, 501), interpolation = cv.INTER_AREA)来缩小彩色图像的高度。

使用 main()运行程序

列表 7-9 定义了一个 main()函数来运行程序。

site_selector.py, part 9
   def main():
       app = Search('670x335 km')
       app.run_rect_stats()
       app.draw_qc_rects()
       app.sort_stats()
       ptp_img = app.draw_filtered_rects(IMG_GRAY, app.ptp_filtered)
       std_img = app.draw_filtered_rects(IMG_GRAY, app.std_filtered)

    ➊ cv.imshow('Sorted by ptp for {} rect'.format(app.name), ptp_img)
       cv.waitKey(3000)
       cv.imshow('Sorted by std for {} rect'.format(app.name), std_img)
       cv.waitKey(3000)

       app.make_final_display()  # Includes call to mainloop().

➋ if __name__ == '__main__':
      main()

列表 7-9:定义并调用用于运行程序的 main()函数

从 Search 类实例化一个 app 对象开始。命名为 670x335 km,以记录所研究的矩形区域的大小。接下来,按顺序调用 Search 方法。运行矩形的统计信息并绘制质量控制矩形。将统计信息从小到大排序,然后绘制具有最佳峰谷差和标准差统计信息的矩形。显示结果 ➊,并通过生成最终的汇总显示来完成函数。

返回全局空间,添加代码让程序能够以导入模块或独立模式运行 ➋。

图 7-13 显示了最终的显示结果。它包括按标准差排序的高评分矩形和汇总统计信息。

图片

图 7-13: 最终显示,包含按标准差排序的高评分矩形和汇总统计信息

结果

完成最终显示后,首先要做的是进行合理性检查。确保矩形位于允许的纬度和海拔范围内,并且看起来位于平滑的地形上。同样,基于峰谷差和标准差统计信息的矩形,如在 图 7-11 和 图 7-12 中所示,应符合约束条件,并且大多数情况下选择相同的矩形。

如前所述,图 7-11 和 图 7-12 中的矩形并没有完全重叠。这是因为你使用了两种不同的平滑度度量。虽然如此,你可以确定的是,重叠的矩形将是所有矩形中最平滑的。

虽然最终显示中的所有矩形位置看起来都合理,但在地图的远西侧,矩形的集中分布尤为令人鼓舞。这是搜索区域中最平滑的地形(图 7-14),你的程序显然识别了这一点。

这个项目重点关注安全问题,但科学目标在大多数任务的站点选择中起主导作用。在本章结尾的实践项目中,你将有机会将额外的约束条件——地质——纳入站点选择的方程中。

图片

图 7-14: 奥林帕斯山熔岩区以西的非常平滑地形

摘要

在本章中,你使用了 Python、OpenCV、Python Imaging Library、NumPy 和 tkinter 来加载、分析和显示图像。因为 OpenCV 将图像视为 NumPy 数组,你可以轻松地从图像的某些部分提取信息,并使用 Python 的众多科学库对其进行评估。

你使用的数据集下载迅速且运行快速。虽然一个真正的实习生会使用一个更大、更严格的数据集,例如由数百万个实际海拔测量值组成的数据集,但你能够在较少的工作量下看到这个过程如何运行,并获得合理的结果。

进一步阅读

喷气推进实验室有几个简短而有趣的视频,讲述火星着陆的过程。通过在线搜索 Mars in a Minute: How Do You Choose a Landing Site?Mars in a Minute: How Do You Get to Mars?Mars in a Minute: How Do You Land on Mars? 可以找到这些视频。

Mapping Mars: Science, Imagination, and the Birth of a World(《火星制图:科学、想象与一个世界的诞生》,Picador,2002),由奥利弗·莫顿(Oliver Morton)著作,讲述了当代火星探索的故事,包括 MOLA 地图的制作。

The Atlas of Mars: Mapping Its Geography and Geology(《火星地图集:制图其地理与地质》,剑桥大学出版社,2019),由肯尼斯·科尔斯(Kenneth Coles)、肯尼斯·田中(Kenneth Tanaka)和菲利普·克里斯滕森(Philip Christensen)编著,是一本精彩的火星通用参考地图集,包含了地形学、地质学、矿物学、热学性质、近地表水冰等多种地图。

用于项目 10 的 MOLA 地图的数据页面位于 astrogeology.usgs.gov/search/map/Mars/GlobalSurveyor/MOLA/Mars_MGS_MOLA_DEM_mosaic_global_463m/

详细的火星数据集可以在由圣路易斯华盛顿大学 PDS 地质学节点提供的火星轨道数据浏览器网站上找到 (ode.rsl.wustl.edu/mars/index.aspx).

实践项目:确认绘图成为图像的一部分

编写一个 Python 程序,验证添加到图像中的绘图内容(如文本、线条、矩形等)是否成为该图像的一部分。使用 NumPy 计算 MOLA 灰度图像中矩形区域的均值、标准差和峰谷统计数据,但不要绘制矩形的边框。然后在该区域周围绘制一条白色线条,并重新运行统计数据。这两次运行的结果一致吗?

你可以在附录或 Chapter_7 文件夹中找到解决方案 practice_confirm_drawing_part_of_image.py,该文件可以从 nostarch.com/real-world-python/ 下载。

实践项目:提取海拔剖面

海拔剖面是景观的二维横截面视图。它提供了地图上两个位置之间绘制的线条上的地形起伏的侧面视图。地质学家可以使用剖面来研究表面的平滑度,并可视化其地形。在本练习项目中,绘制一个从西到东的剖面,经过太阳系最大火山奥林匹斯山的火山口(图 7-15)。

Image

图 7-15:经过奥林匹斯山的垂直夸张的东西向剖面

使用图 7-15 中显示的 Mars MGS MOLA - MEX HRSC Blended DEM Global 200m v2 地图。这个版本比你在项目 10 中使用的地图具有更好的横向分辨率。它还使用了 MOLA 数据中的全部高程范围。你可以在 Chapter_7 文件夹中找到一个副本,mola_1024x512_200mp.jpg,并可以从本书网站下载。解决方案 practice_profile_olympus.py 可在同一文件夹和附录中找到。

实践项目:3D 绘图

火星是一个不对称的行星,南半球以古老的陨石坑高原为主,北半球则是平滑、平坦的低地。为了更好地展示这一点,可以使用 matplotlib 的 3D 绘图功能,显示你在前一个实践项目中使用的 mola_1024x512_200mp.jpg 图像(图 7-16)。

Image

图 7-16:火星的 3D 等高线图,朝向西方

使用 matplotlib,你可以通过点、线、等高线、网格框和表面来制作 3D 地形图。虽然这些图形有些粗糙,但你可以快速生成它们。你还可以使用鼠标交互式地抓取图形并改变视角。它们对于那些难以从 2D 地图中想象地形的人特别有用。

在图 7-16 中,夸大的垂直比例使得从南到北的高程差异容易观察到。你还可以轻松识别出最高的山(奥林帕斯山)和最深的陨石坑(赫拉斯平原)。

你可以使用附录中的 practice_3d_plotting.py 程序,或 Chapter_7 文件夹中的相同程序(可以从本书网站下载)来重现图 7-16 中的图表—不带注释。地图图像可以在同一文件夹中找到。

实践项目:混合地图

创建一个新项目,将一些科学元素添加到选址过程中。将 MOLA 地图与彩色地质地图结合,找到塔尔西斯山脉火山沉积物中最平坦的矩形区域(见图 7-17 中的箭头)。

Image

图 7-17:火星的地质地图。箭头指向塔尔西斯火山沉积物。

由于塔尔西斯山脉区域位于较高的海拔,重点应放在寻找火山沉积物中最平坦、最光滑的部分,而不是最低的海拔。为了隔离火山沉积物,可以考虑对地图进行灰度阈值处理。阈值处理是一种分割技术,用于将图像分成前景和背景。

使用阈值处理,你可以将灰度图像转换为二值图像,其中高于或在指定阈值之间的像素设置为 1,其他所有像素设置为 0。你可以使用这个二值图像来过滤 MOLA 地图,如图 7-18 所示。

Image

图 7-18:塔尔西斯山脉区域的 MOLA 滤波地图,左侧为 ptp 矩形,右侧为 std 矩形

你可以在Chapter_7文件夹中找到地质图Mars_Global_Geology_Mariner9_1024.jpg,该文件可以从书籍的网站上下载。火山沉积物的颜色将是浅粉色的。对于高度图,请使用“提取高度剖面”练习项目中的mola_1024x512_200mp.jpg,可以在第 172 页找到。

解决方案包含在practice_geo_map_step_1of2.pypractice_geo_map_step_2of2.py文件中,可以在同一文件夹中找到,也可以在附录中查看。首先运行practice_geo_map_step_1of2.py程序来生成第 2 步的滤镜。

挑战项目:三连击

编辑“提取高度剖面”项目,使得剖面通过位于塔尔西斯山脉的三座火山,如图 7-19 所示。

图片

图 7-19:穿过塔尔西斯山脉三座火山的对角线剖面

其他有趣的地貌特征包括瓦莱斯·马里内里斯,这是一个长度是大峡谷的九倍、深度是大峡谷的四倍的峡谷,以及赫拉斯平原,它被认为是太阳系中第三或第四大撞击坑(图 7-19)。

挑战项目:包裹矩形

编辑site_selector.py代码,使其适应不能整除 MOLA 图像宽度的矩形尺寸。实现这一点的一种方法是添加代码,将矩形分割成两部分(一个沿地图的右边缘,另一个沿左边缘),分别计算每个部分的统计数据,然后将它们重新组合成一个完整的矩形。另一种方法是复制图像并将其“拼接”到原始图像上,如图 7-20 所示。这样,你就不需要拆分矩形;只需决定何时停止将它们移动 across 地图即可。

图片

图 7-20:重复的灰度 MOLA 图像

当然,为了提高效率,你不需要复制整个地图。你只需要沿着东侧边缘保留一条宽度足够容纳最终重叠矩形的条带。

第八章:探测遥远系外行星

图片

系外行星,简称外行星,是围绕外星太阳公转的行星。到 2019 年底,已发现超过 4000 颗系外行星。这意味着自 1992 年首次确认发现系外行星以来,平均每年发现 150 颗!如今,发现一颗遥远的行星似乎像感冒一样容易,但人类几乎花费了整个历史——直到 1930 年——才发现了构成我们太阳系的八颗行星以及冥王星。

天文学家通过观察恒星运动中的引力诱发摆动,首次探测到系外行星。如今,他们主要依赖于当系外行星从恒星与地球之间经过时,恒星光线的微弱变暗。借助强大的下一代设备,如詹姆斯·韦伯太空望远镜,他们将能够直接拍摄系外行星的图像,并了解其旋转、季节、天气、植被等更多信息。

在本章中,你将使用 OpenCV 和 matplotlib 模拟一个系外行星在其太阳前经过的过程。你将记录下由此产生的光曲线,然后利用它来探测行星并估算其直径。接着,你将模拟系外行星如何呈现给詹姆斯·韦伯太空望远镜。在“实践项目”部分,你将研究一些异常的光曲线,这些曲线可能代表巨大的外星巨型结构,这些结构是为了利用恒星的能量而设计的。

凌星光度法

在天文学中,凌星是指相对较小的天体直接穿过较大天体的盘面与观察者之间。当小天体移动到较大天体的面前时,较大天体会略微变暗。最著名的凌星现象是水星和金星凌日(参见图 8-1)。

图片

图 8-1:2012 年 6 月,云层和金星(黑点)在太阳前经过

通过今天的技术,天文学家可以在凌星事件中探测到遥远恒星光线的微弱变暗。这一技术称为凌星光度法,其结果是绘制出恒星亮度随时间变化的图表(参见图 8-2)。

图片

图 8-2:用凌星光度法探测系外行星的技术

在图 8-2 中,光度曲线图上的点表示恒星发出的光的测量值。当行星不位于恒星上方 ➊ 时,测得的亮度是最大的。(我们将忽略系外行星在经历其不同相位时反射的光,这会非常轻微地增加恒星的表观亮度)。随着行星的前缘开始进入恒星盘面 ➋,发出的光逐渐变暗,形成光度曲线中的上升段。当整个行星出现在盘面上 ➌ 时,光度曲线变平,并保持平稳,直到行星开始从盘面的远端退出。这时形成另一段上升段 ➍,直到行星完全离开盘面 ➎。此时,光度曲线恢复到最大值,因为恒星不再被遮挡。

由于过境期间阻挡的光量与行星盘面的大小成正比,因此你可以使用以下公式来计算行星的半径:

Image

其中 R[p] 是行星的半径,R[s] 是恒星的半径。天文学家通过恒星的距离、亮度和颜色(与恒星的温度相关)来确定恒星的半径。深度指的是在过境期间亮度的总变化量(见图 8-3)。

Image

图 8-3:深度代表在光度曲线中观察到的亮度变化总量。

当然,这些计算假设整个系外行星,而不是它的一部分,都会经过恒星的表面。如果系外行星只掠过恒星的上方或下方(从我们的视角来看),则可能会出现后者的情况。我们将在“过境光度测量实验”中探讨这种情况,见第 182 页。

项目 #11:模拟系外行星过境

在我飞往爱达荷州拍摄 2017 年“大美洲日全食”之前,我做了充分的准备。全食事件,即月亮完全遮住太阳的那段时间,仅持续了 2 分钟 10 秒。这几乎没有时间进行实验、测试或临时处理。为了成功捕捉到半影、影子、太阳耀斑和钻石戒指效应(见图 8-4),我必须精确知道带什么设备、使用什么相机设置,以及这些事件的发生时间。

Image

图 8-4:2017 年日全食末期的钻石戒指效应

类似地,计算机模拟帮助你为观察自然世界做准备。它们帮助你理解预期的内容、何时发生,以及如何校准你的仪器。在本项目中,你将创建一个系外行星过境事件的模拟。你可以通过不同的行星大小来运行此模拟,从而理解过境大小对光度曲线的影响。稍后,你将使用该模拟来评估与小行星带和可能的外星超级结构相关的光度曲线。

目标

编写一个 Python 程序来模拟系外行星凌星,绘制结果的光变曲线,并计算系外行星的半径。

策略

要生成光变曲线,您需要能够测量亮度的变化。您可以通过对像素进行数学运算来实现这一点,比如计算均值、最小值和最大值,使用 OpenCV 进行处理。

您将不使用真实的凌星和恒星图像,而是会在黑色矩形上绘制圆形,正如您在上一章中绘制火星地图时所做的那样。为了绘制光变曲线,您可以使用 matplotlib,Python 的主要绘图库。您已经在“使用 pip 安装 NumPy 及其他科学包”中安装了 matplotlib,并在第二章开始使用它绘制图形。

凌星代码

transit.py程序使用 OpenCV 生成系外行星凌过恒星的视觉模拟,使用 matplotlib 绘制结果的光变曲线,并使用来自第 179 页的行星半径方程来估算行星的大小。您可以自己输入代码或从* nostarch.com/real-world-python/* 下载它。

导入模块和赋值常量

清单 8-1 导入模块并赋值常量,表示用户输入的值。

transit.py, part 1
import math
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt

IMG_HT = 400
IMG_WIDTH = 500
BLACK_IMG = np.zeros((IMG_HT, IMG_WIDTH, 1), dtype='uint8') 
STAR_RADIUS = 165
EXO_RADIUS = 7
EXO_DX = 3
EXO_START_X = 40
EXO_START_Y = 230
NUM_FRAMES = 145

清单 8-1:导入模块和赋值常量

导入 math 模块来计算行星半径方程,导入 NumPy 来计算图像的亮度,导入 OpenCV 来绘制模拟图像,导入 matplotlib 来绘制光变曲线。然后开始赋值常量,表示用户输入的值。

从模拟窗口的高度和宽度开始。窗口将是一个黑色的矩形图像,通过使用 np.zeros()方法创建,该方法返回一个形状和类型指定的、填充为零的数组。

回想一下,OpenCV 图像是 NumPy 数组,数组中的项必须具有相同的类型。uint8 数据类型表示一个无符号整数,范围从 0 到 255。您可以在* numpy.org/devdocs/user/basics.types.html* 上找到其他数据类型及其描述的有用列表。

接下来,赋值恒星和系外行星的半径值(以像素为单位)。OpenCV 将在绘制它们的圆形时使用这些常量。

系外行星将穿过恒星的面,因此您需要定义它的移动速度。EXO_DX 常量将在每次编程循环中将系外行星的* x *位置增加三像素,使其从左到右移动。

赋值两个常量来设置系外行星的起始位置。然后赋值一个 NUM_FRAMES 常量来控制模拟更新的次数。尽管您可以通过计算该数字(IMG_WIDTH/EXO_DX)来得到,但赋值可以让您微调模拟的持续时间。

定义 main() 函数

列表 8-2 定义了用于运行程序的 main()函数。虽然你可以在任何位置定义 main(),但将其放在开始部分使其成为整个程序的总结,从而为后面定义的函数提供上下文。作为 main()的一部分,你将计算外行星的半径,将方程嵌套在 print()函数的调用中。

transit.py, part 2
def main():
    intensity_samples = record_transit(EXO_START_X, EXO_START_Y)  
    relative_brightness = calc_rel_brightness(intensity_samples)
    print('\nestimated exoplanet radius = {:.2f}\n'
          .format(STAR_RADIUS * math.sqrt(max(relative_brightness)
                                          - min(relative_brightness))))
    plot_light_curve(relative_brightness)

列表 8-2:定义 main()函数

在定义 main()函数后,命名一个变量 intensity_samples 并调用 record_transit()函数。强度指的是光的强度,用像素的数值表示。record_transit()函数将模拟画面绘制到屏幕上,测量其强度,将测量值附加到名为 intensity_samples 的列表中,并返回该列表。它需要外行星的起始点(xy)坐标。传递起始常量 EXO_START_X 和 EXO_START_Y,它们将把行星放置在类似于图 8-2 中➊的位置。注意,如果显著增加外行星的半径,可能需要将起始点向左移动(负值是可以接受的)。

接下来,命名一个变量 relative_brightness 并调用 calc_rel_brightness()函数。顾名思义,该函数计算相对亮度,即测量的强度除以最大记录强度。它接受强度测量列表作为参数,将测量值转换为相对亮度,并返回新的列表。

你将使用相对亮度值的列表,通过第 179 页的方程计算外行星的半径(以像素为单位)。你可以在 print()函数中执行此计算。使用{:.2f}格式将答案保留两位小数。

通过调用绘制光曲线的函数结束 main()函数。传递相对亮度列表。

记录穿越事件

列表 8-3 定义了一个函数,用于模拟并记录穿越事件。它在黑色矩形图像上绘制恒星和外行星,然后移动外行星。它还计算并显示每次移动时图像的平均强度,将强度附加到一个列表中,并在最后返回该列表。

transit.py, part 3
def record_transit(exo_x, exo_y):
    """Draw planet transiting star and return list of intensity changes."""
    intensity_samples = []
    for _ in range(NUM_FRAMES):
        temp_img = BLACK_IMG.copy()
        cv.circle(temp_img, (int(IMG_WIDTH / 2), int(IMG_HT / 2)),
                  STAR_RADIUS, 255, -1)
     ➊ cv.circle(temp_img, (exo_x, exo_y), EXO_RADIUS, 0, -1)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity), (5, 390),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        cv.waitKey(30)
     ➋ intensity_samples.append(intensity)
        exo_x += EXO_DX
    return intensity_samples

列表 8-3:绘制模拟图像,计算图像强度,并将其作为列表返回

record_transit()函数接受一对(xy)坐标作为参数。这些坐标代表外行星的起始点,或者更具体地说,是用于作为模拟中绘制的第一个圆心的像素。它不应与图像中心的恒星圆重叠。

接下来,创建一个空列表来保存强度测量值。然后,启动一个 for 循环,使用 NUM_FRAMES 常量重复模拟一定次数。模拟的持续时间应该稍微超过系外行星离开恒星面所需的时间。这样,你就可以获得包含掩星后测量的完整光变曲线。

使用 OpenCV 在图像上绘制的图形和文本将成为该图像的一部分。因此,你需要通过将原始 BLACK_IMG 复制到名为 temp_img 的本地变量中,替换每次循环中的前一个图像。

现在,你可以使用 OpenCV 的 circle()方法绘制恒星。传递给它临时图像、对应于图像中心的圆心坐标(xy)、STAR_RADIUS 常量、白色填充颜色和线条厚度。使用负数作为厚度值可以将圆填充为颜色。

接下来,绘制系外行星圆圈。使用 exo_x 和 exo_y 坐标作为起点,EXO_RADIUS 常量作为大小,黑色填充颜色 ➊。

这时,你应该记录图像的强度。由于像素已经代表强度,因此你需要做的就是计算图像的平均值。你所采集的测量次数取决于 EXO_DX 常量。这个值越大,系外行星移动越快,你记录平均强度的次数就越少。

使用 OpenCV 的 putText()方法在图像上显示强度读数。传递给它临时图像、包含测量值的文本字符串、文本字符串的左下角坐标(xy)、字体、文本大小和颜色。

现在,命名窗口为 Transit,并使用 OpenCV 的 imshow()方法显示它。图 8-5 展示了一个循环迭代过程。

Image

图 8-5:系外行星掩过恒星

显示图像后,使用 OpenCV 的 waitKey()方法每 30 毫秒更新一次图像。传递给 waitKey()的数字越小,系外行星穿越恒星的速度就越快。

将强度测量值追加到 intensity_samples 列表中,然后通过将 exo_x 值增加 EXO_DX 常量来推进系外行星圆圈 ➋。最后,通过返回平均强度测量值的列表来结束该函数。

计算相对亮度并绘制光变曲线

列表 8-4 定义了计算每个强度样本相对亮度并显示光变曲线图的函数。如果该程序不是作为模块在其他程序中使用,还会调用 main()函数。

transit.py, part 4 
   def calc_rel_brightness(intensity_samples):
       """Return list of relative brightness from list of intensity values."""
       rel_brightness = []
       max_brightness = max(intensity_samples)
       for intensity in intensity_samples:
          rel_brightness.append(intensity / max_brightness)
       return rel_brightness

➊ def plot_light_curve(rel_brightness):
       """Plot changes in relative brightness vs. time."""
       plt.plot(rel_brightness, color='red', linestyle='dashed',
               linewidth=2, label='Relative Brightness')
       plt.legend(loc='upper center')
       plt.title('Relative Brightness vs. Time')
       plt.show()

➋ if __name__ == '__main__':
      main()

列表 8-4:计算相对亮度、绘制光变曲线并调用 main()

光变曲线显示了随时间变化的相对亮度,未被遮挡的恒星的亮度为 1.0,而完全被掩盖的恒星亮度为 0.0。为了将平均强度测量转换为相对值,定义一个 calc_rel_brightness()函数,该函数接受一个平均强度测量值的列表作为参数。

在函数中,首先创建一个空列表来保存转换后的值,然后使用 Python 的内置 max()函数来查找 intensity_samples 列表中的最大值。为了获得相对亮度,遍历列表中的每个元素,并将其除以最大值。然后将结果添加到 rel_brightness 列表中。最后,返回新列表,结束函数。

定义第二个函数来绘制光变曲线,并将 rel_brightness 列表传递给它➊。使用 matplotlib 的 plot()方法,并传递列表、线条颜色、线条样式、线宽和图例标签。添加图例和图表标题,然后显示图表。你应该会看到图 8-6 中的图表。

Image

图 8-6:来自 transit.py 的示例光变曲线图

图表上的亮度变化乍一看可能会显得非常剧烈,但如果仔细观察y轴,你会发现外行星仅将恒星的亮度降低了 0.175%!为了查看恒星的绝对亮度图(图 8-7)上的效果,可以在 plt.show()之前添加以下行:

plt.ylim(0, 1.2)

光变曲线因过境而发生的偏折微妙但可检测。不过,你不希望盯着光变曲线看得眼睛花,所以继续让 matplotlib 像图 8-6 那样自动调整y轴。

通过调用 main()函数来完成程序➋。除了光变曲线,你应该在命令行中看到估算的外行星半径。

estimated exoplanet radius = 6.89

就是这样。不到 50 行 Python 代码,你已经开发出了发现外行星的方法!

Image

图 8-7:从图 8-6 中重新缩放 y 轴的光变曲线

实验过境光度测量

现在你有了一个可运行的模拟,你可以使用它来模拟过境的行为,从而更好地分析将来在实际观察中获得的数据。一种方法是运行大量可能的情况,并生成预期外行星反应的“图谱”。研究人员可以使用这个图谱来帮助解读实际的光变曲线。

例如,如果外行星的轨道平面相对于地球倾斜,使得外行星在过境过程中只部分穿过恒星?研究人员能否通过它的光变曲线特征来检测到它的位置,还是它只会像一个较小的外行星进行完整的过境?

如果你使用半径为 7 的外行星并让它掠过恒星的底部运行模拟,你应该会得到一个 U 形曲线(图 8-8)。

Image

图 8-8:一个半遮挡其恒星的外星行星的光变曲线,半径为 7

如果你重新运行模拟,设置外星行星的半径为 5,并让外星行星完全经过恒星的面前,你将得到图 8-9 中的图表。

Image

图 8-9:一个完全遮挡其恒星的外星行星的光变曲线,半径为 5

当外星行星掠过恒星的一侧,未完全遮挡它时,重叠区域会不断变化,产生图 8-8 中的 U 型曲线。如果整个外星行星完全经过恒星的面前,曲线的底部则会较平坦,如图 8-9 所示。而由于在部分掩星过程中你无法看到行星的完整盘面,你也无法测量其真实大小。因此,如果光变曲线底部没有平坦部分,大小的估计就应当谨慎对待。

如果你运行一系列不同大小的外星行星,你会看到光变曲线以可预测的方式变化。随着行星大小的增加,曲线会加深,曲线两侧的坡度变长,因为恒星亮度的较大部分被遮挡了(参见图 8-10 和图 8-11)。

Image

图 8-10:EXO_RADIUS = 28 的光变曲线

Image

图 8-11:EXO_RADIUS = 145 的光变曲线

由于外星行星是圆形的、边缘光滑的物体,它们应当产生平滑的光变曲线,曲线的变化应持续增加或减少。这是非常重要的知识,因为天文学家在寻找外星行星时,记录到了显著的起伏曲线。在本章末的“实践项目”部分,你将利用你的程序探索那些形状奇怪的光变曲线,它们可能是外星工程的结果!

项目 #12:成像外星行星

到 2025 年,三台强大的望远镜——两台在地球上,一台在太空中——将使用红外线和可见光直接成像地球大小的外星行星。在最佳情况下,外星行星将以一个饱和的像素显示出来,周围的像素会有所溢出,但这足以判断行星是否自转,是否有大陆和海洋,是否有气候和季节变化,以及是否能够支持我们所知道的生命!

在这个项目中,你将模拟分析来自这些望远镜拍摄的图像的过程。你将以地球作为一个遥远外星行星的代替,这样你可以轻松地将已知的特征(如大陆和海洋)与单个像素中看到的内容联系起来。你将关注反射光的颜色成分和强度,并推测外星行星的大气层、地表特征以及自转情况。

目标

编写一个 Python 程序,对地球的图像进行像素化处理,并绘制红色、绿色和蓝色通道的强度。

策略

为了演示你可以通过单一的饱和像素捕获不同的表面特征和云层,你只需要两张图像:一张是西半球的图像,另一张是东半球的图像。方便的是,NASA 已经从太空拍摄了地球的东西半球图像(图 8-12)。

Image

图 8-12:东西半球的图像

这些图像的大小为 474×474 像素,分辨率对于未来的系外行星图像来说过高,因为系外行星预计只会占据 9 个像素,其中只有中心像素完全被行星覆盖(图 8-13)。

Image

图 8-13:覆盖有 9 像素网格的 earth_west.png 和 earth_east.png 图像

你需要通过将地球图像映射到 3×3 数组来降解图像。由于 OpenCV 使用 NumPy,这将非常容易实现。为了检测系外行星表面的变化,你需要提取主要的颜色(蓝色、绿色和红色)。OpenCV 允许你对这些颜色通道进行平均。然后,你可以使用 matplotlib 显示结果。

像素化器代码

pixelator.py 程序加载这两张地球图像,将它们调整为 3×3 像素,然后再将它们调整为 300×300 像素。这些最终图像仅用于可视化,它们拥有与 3×3 图像相同的颜色信息。程序随后对两张调整过的图像的颜色通道进行平均,并将结果以饼图形式绘制出来,你可以进行比较。你可以从本书的网站下载代码和两张图像(earth_west.pngearth_east.png)。将它们保存在同一文件夹中,并且不要重命名图像。

导入模块和降采样图像

清单 8-5 导入用于绘图和图像处理的模块,然后加载并降解两张地球图像。它首先将每张图像缩小为 9 像素的 3×3 数组。接着,它将降采样后的图像放大为 300×300 像素,以便足够大可以查看,并将它们显示在屏幕上。

pixelator.py, part 1
import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt

files = ['earth_west.png', 'earth_east.png']

for file in files:
    img_ini = cv.imread(file)
    pixelated = cv.resize(img_ini, (3, 3), interpolation=cv.INTER_AREA)
    img = cv.resize(pixelated, (300, 300), interpolation=cv.INTER_AREA)
    cv.imshow('Pixelated {}'.format(file), img)
    cv.waitKey(2000)

清单 8-5:导入模块并加载、降解和显示图像

导入 NumPy 和 OpenCV 来处理图像,并使用 matplotlib 将它们的颜色组件绘制为饼图。然后,开始列出包含两张地球图像的文件名。

现在开始循环遍历列表中的文件,使用 OpenCV 将它们加载为 NumPy 数组。回想一下,OpenCV 默认加载彩色图像,因此你不需要为此添加任何参数。

你的目标是将地球图像缩小为一个单一的饱和像素,周围是部分饱和的像素。要将图像从原始的 474×474 大小降到 3×3,使用 OpenCV 的 resize()方法。首先,将新图像命名为pixelated,并传递给方法当前图像、新的宽度和高度(像素单位)以及插值方法。插值发生在调整图像大小时,使用已知数据来估算未知点的值。OpenCV 文档建议在缩小图像时使用 INTER_AREA 插值方法(参见几何图像转换:docs.opencv.org/4.3.0/da/d54/group__imgproc__transform.html)。

此时,你有一个太小的图像,无法直观查看,因此将其重新调整为 300×300,以便检查结果。使用 INTER_NEAREST 或 INTER_AREA 作为插值方法,这些方法将保留像素边界。

显示图像 (图 8-14),并使用 waitKey()延迟程序两秒。

Image

图 8-14:像素化颜色图像的灰度视图

请注意,你不能通过将图像调整回 474×474 来恢复其原始状态。一旦将像素值平均至 3×3 矩阵,所有细节信息就永远丢失了。

平均颜色通道并制作饼图

仍然在 for 循环中,清单 8-6 制作并显示每个像素化图像的蓝色、绿色和红色分量的饼图。你可以将这些图表进行比较,从中推测行星的天气、陆地、旋转等信息。

pixelator.py, part 2 
   b, g, r = cv.split(pixelated)
   color_aves = []
   for array in (b, g, r):
       color_aves.append(np.average(array))

   labels = 'Blue', 'Green', 'Red'
   colors = ['blue', 'green', 'red']    
   fig, ax = plt.subplots(figsize=(3.5, 3.3))  # size in inches
➊ _, _, autotexts = ax.pie(color_aves,
                            labels=labels,
                            autopct='%1.1f%%',
                            colors=colors)
   for autotext in autotexts:
       autotext.set_color('white')
   plt.title('{}\n'.format(file))

plt.show()

清单 8-6:拆分和平均颜色通道并制作颜色的饼图

使用 OpenCV 的 split()方法分离像素化图像中的蓝色、绿色和红色颜色通道,并将结果解包到 b、g、r 变量中。这些是数组,如果你调用 print(b),你应该会看到如下输出:

[[ 49  93  22]
 [124 108  65]
 [ 52 118  41]]

每个数字代表一个像素——具体来说,是该像素的蓝色值——在 3×3 像素化图像中。要计算数组的平均值,首先创建一个空列表来存储平均值,然后遍历数组,调用 NumPy 的平均方法,并将结果附加到列表中。

现在你已经准备好制作每个像素化图像的颜色平均值饼图了。首先,将颜色名称分配给一个名为 labels 的变量,用于标注饼图的扇区。接着,指定你希望在饼图中使用的颜色,这些颜色将覆盖 matplotlib 的默认选择。要制作图表,使用 fig 和 ax 命名约定来代表图形和坐标轴,调用 subplots()方法,并传入图形的尺寸(单位:英寸)。

由于图像之间的颜色差异只有很小的变化,你希望在每个颜色的饼图楔形中显示该颜色的百分比,这样你可以轻松查看它们之间是否存在差异。不幸的是,matplotlib 的默认设置是使用黑色文本,这在深色背景下可能难以看清。为了解决这个问题,调用 ax.pie()方法生成饼图,并使用它的 autotexts 列表 ➊。该方法返回三个列表,一个与饼图楔形相关,一个与标签相关,一个与数字标签相关,称为autotexts。你只需要最后一个列表,因此将前两个列表当作未使用的变量,赋值为下划线符号。

将色彩平均值列表和标签列表传递给 ax.pie(),并设置其 autopct 参数以显示小数点后一位的数字。如果该参数设置为 None,则不会返回 autotexts 列表。最后,传递用于饼图楔形的颜色列表,完成参数设置。

第一幅图像的 autotexts 列表如下:

Text(0.1832684031431146, 0.5713253822554821, '40.1%'), Text(-0.5646237442340427,
-0.20297789891298565, '30.7%'), Text(0.36574010704848686, -0.47564080364930983, '29.1%')

每个 Text 对象都有(x, y)坐标和百分比值作为文本字符串。由于这些仍然会以黑色显示,你需要遍历这些对象并使用它们的 set_color()方法将颜色更改为白色。现在,你只需要将图表标题设置为文件名并显示图表([图 8-15)。

Image

图 8-15:pixelator.py 生成的饼图

尽管饼图相似,但其差异是有意义的。如果你比较原始的彩色图像,你会看到earth_west.png照片包含更多的海洋,因此应该产生更大的蓝色楔形。

绘制单个像素

图 8-15 中的图表是针对整个图像的,包括对黑色区域的采样。为了获取一个未被污染的样本,你可以使用每个图像中心的单个饱和像素,如清单 8-7 所示。此代码代表了编辑后的pixelator.py副本,标注了更改的行。你可以在Chapter_8文件夹中找到数字副本,名为pixelator_saturated_only.py

pixelator_saturated_only.py
import cv2 as cv
from matplotlib import pyplot as plt

files = ['earth_west.png', 'earth_east.png']

# Downscale image to 3x3 pixels.
for file in files:
    img_ini = cv.imread(file)
    pixelated = cv.resize(img_ini, (3, 3), interpolation=cv.INTER_AREA)
    img = cv.resize(pixelated, (300, 300), interpolation=cv.INTER_NEAREST)
    cv.imshow('Pixelated {}'.format(file), img)
    cv.waitKey(2000)

 ➊ color_values = pixelated[1, 1]  # Selects center pixel.

    # Make pie charts.
    labels = 'Blue', 'Green', 'Red'
    colors = ['blue', 'green', 'red']    
    fig, ax = plt.subplots(figsize=(3.5, 3.3))  # Size in inches.

 ➋ _, _, autotexts = ax.pie(color_values,
                             labels=labels,
                             autopct='%1.1f%%',
                             colors=colors)
    for autotext in autotexts:
        autotext.set_color('white')
 ➌ plt.title('{} Saturated Center Pixel \n'.format(file))

plt.show()

清单 8-7:绘制像素化图像中心像素的颜色饼图

清单 8-6 中的四行代码,用于分割图像并计算颜色通道的平均值,可以替换为一行代码 ➊。pixelated 变量是一个 NumPy 数组,[1, 1]表示数组中的第 1 行第 1 列。记住,Python 的计数从 0 开始,因此这些值对应于一个 3×3 数组的中心。如果你打印 color_values 变量,你将看到另一个数组。

[108 109 109]

这些是中心像素的蓝色、绿色和红色通道值,你可以直接将它们传递给 matplotlib ➋。为了清晰起见,更改图表标题,使其表明你仅分析中心像素 ➌。图 8-16 显示了生成的图表。

Image

图 8-16:pixelator_saturated_only.py 生成的单像素饼图

图 8-15 和 8-16 中西半球的颜色差异很微妙,但你知道它们是真实的,因为你前向建模了反应。也就是说,你通过实际观测得出了结果,所以你知道这个结果是有意义的、可重复的,并且是独一无二的。

在实际的系外行星调查中,你会希望拍摄尽可能多的图像。如果相似的强度和颜色模式在时间上持续存在,那么你可以排除诸如天气等随机效应。如果颜色模式在较长时间内发生可预测的变化,你可能在观察季节效应,比如冬季出现白色极地冰盖,春夏季节绿植扩展的现象。

如果测量周期性地重复,并且时间跨度相对较短,你可以推测行星正在自转。在本章末尾的“实践项目”部分,你将有机会计算系外行星的一天长度。

总结

在本章中,你使用了 OpenCV、NumPy 和 matplotlib 创建图像并测量它们的属性。你还将图像调整为不同的分辨率,并绘制了图像强度和颜色通道信息。通过简短而简单的 Python 程序,你模拟了天文学家用来发现和研究遥远系外行星的重要方法。

进一步阅读

如何搜索系外行星,由行星学会(Planetary Society)出版,(www.planetary.org/),这是一本关于搜索系外行星技术的好概述,涵盖了每种方法的优缺点。

“过境光变曲线教程”,由安德鲁·范德堡(Andrew Vanderburg)编写,解释了过境光度法的基础,并提供了开普勒太空望远镜的过境数据链接。你可以在 www.cfa.harvard.edu/~avanderb/tutorial/tutorial.html 上找到它。

“NASA 想要拍摄系外行星表面”(Wired,2020),由丹尼尔·奥伯豪斯(Daniel Oberhaus)编写,描述了将太阳变成一个巨大的相机镜头来研究系外行星的努力。

“戴森球:高级外星文明如何征服银河系”(Space.com,2014),由卡尔·塔特(Karl Tate)编写,是一张信息图,展示了一个高级文明如何利用巨大的太阳能面板阵列捕获恒星的能量。

环世界(Ringworld),由拉里·尼文(Larry Niven)编写,出版于 1970 年,是科幻小说的经典之作。故事讲述了一个任务,目的地是一个巨大的废弃外星建筑——环世界,它环绕着一颗外星星球。

实践项目:探测外星巨型结构

2015 年,正在处理开普勒太空望远镜数据的业余天文学家注意到天鹅座中的塔比星(Tabby’s Star)出现了异常现象。这颗星星的光变曲线记录于 2013 年,显示出亮度变化不规则,这种变化远远大于行星所能引起的变化(图 8-17)。

Image

图 8-17:由开普勒太空天文台测量的 Tabby 星的光变曲线

除了亮度的剧烈下降外,光变曲线还表现出不对称性,并且包含一些奇怪的凸起,这在典型的行星凌日中并未见过。提出的解释认为,这个光变曲线可能是由于星体吞噬行星、由解体彗星云凌日、一个被小行星群包围的大型环状行星,或者是外星巨型结构所致。

科学家推测,这样大小的人造结构很可能是外星文明试图从其太阳收集能量的尝试。科学文献和科幻小说中都描述了这些惊人的大型太阳能电池板项目。例如包括戴森群、戴森球、环世界和波克罗夫斯基壳(图 8-18)。

图片

图 8-18:围绕恒星的波克罗夫斯基壳系统,旨在拦截恒星的辐射

在这个实践项目中,使用transit.py程序来逼近 Tabby 星光变曲线的形状和深度。用其他简单的几何形状替换程序中使用的圆形外星行星。你不需要完全匹配曲线,只需捕捉到关键特征,如不对称性、2 月 28 日左右看到的“凸起”,以及亮度的大幅下降。

你可以在Chapter_8文件夹中找到我尝试编写的程序practice_tabbys_star.py,该文件可以从本书网站下载,网址为nostarch.com/real-world-python/,并且可以在附录中找到。它会生成图 8-19 所示的光变曲线。

图片

图 8-19:由 practice_tabbys_star.py 产生的光变曲线

我们现在知道,无论是什么环绕 Tabby 星,都会允许某些波长的光通过,所以它不可能是一个实心物体。基于这种行为和它吸收的波长,科学家们认为尘埃是导致这颗星光变曲线形状怪异的原因。然而,像天秤座中的 HD 139139 等其他恒星,至今仍然没有解释它们奇异的光变曲线。

实践项目:检测小行星凌日

小行星带可能是一些曲折和不对称光变曲线的罪魁祸首。这些碎片带通常来自行星碰撞或太阳系的形成,就像木星轨道中的特洛伊小行星(图 8-20)。你可以在网页“Lucy:首次任务探索特洛伊小行星”中找到一段有趣的特洛伊小行星动画,网址为www.nasa.gov/

图片

图 8-20:超过一百万颗特洛伊小行星共享木星的轨道。

修改 transit.py 程序,使其随机生成半径在 1 到 3 之间的小行星,且小行星的生成概率偏向于 1。允许用户输入小行星的数量。无需计算外行星半径,因为该计算假设你处理的是单个球形物体,而你不是。试验小行星的数量、大小和分布范围(小行星存在的 x-范围和 y-范围),观察其对光变曲线的影响。图 8-21 展示了一个这样的例子。

Image

图 8-21:由随机生成的 asteroids(小行星)场产生的不规则、非对称光变曲线

你可以在附录和本书网站上找到解决方案 practice_asteroids.py。该程序使用面向对象编程(OOP)来简化多个小行星的管理。

实践项目:加入光球暗化

光球 是恒星的发光外层,辐射光和热。由于光球的温度随着离恒星中心的距离增加而降低,恒星盘的边缘比中心更冷,因此看起来比中心更暗(图 8-22)。这一现象被称为 光球暗化

Image

图 8-22:太阳的光球暗化和太阳黑子

重写 transit.py 程序,使其能够处理光球暗化。不要再绘制恒星,而是使用 Chapter_8 文件夹中的图片 limb_darkening.png,该文件可以从本书网站下载。

光球暗化会影响行星过境产生的光变曲线。与项目 11 中你产生的理论曲线相比,这些曲线将显得不那么方形,边缘更加圆润柔和,底部呈弯曲状(图 8-23)。

Image

图 8-23:光球暗化对光变曲线的影响

使用你修改后的程序重新访问“实验:过境光度法”中的第 186 页,在那里你分析了部分过境产生的光变曲线。你应该会看到,相比部分过境,完全过境仍然产生较宽的波谷,并且波谷底部较为平坦(图 8-24)。

Image

图 8-24:完全与部分过境的光变曲线(R = 外行星半径)

如果一个小行星的完全过境发生在恒星的边缘,光球暗化可能使其与一个大行星的部分过境难以区分。你可以在图 8-25 中看到这一点,箭头表示行星的位置。

Image

图 8-25:半径为 8 像素的行星的部分过境与半径为 5 像素的行星的完全过境

天文学家有许多工具可以提取光变曲线中的信息。通过记录多个过境事件,他们可以确定系外行星的轨道参数,比如行星与恒星之间的距离。他们可以利用光变曲线中的细微变化来推算行星完全遮盖恒星表面的时间。他们还可以估计理论上的边缘变暗程度,并可以使用建模方法,正如你在这里所做的那样,将所有信息结合起来,并用实际观测数据验证他们的假设。

你可以在附录和 Chapter_8 文件夹中找到一个解决方案,practice_limb_darkening.py,并从书籍网站下载。

实践项目:检测恒星斑点

太阳黑子——在外星太阳上被称为 星斑——是由恒星磁场变化引起的表面温度降低区域。星斑会使恒星的表面变暗,并对光变曲线产生有趣的影响。在 图 8-26 中,一颗系外行星经过星斑,造成了光变曲线中的一个“突起”。

图片

图 8-26:一颗系外行星(箭头,左图)经过恒星斑点时,会在光变曲线中产生一个“突起”。

为了实验星斑,使用前一个实践项目中的 practice_limb_darkening.py 代码,并编辑它,使一颗大致与星斑大小相同的系外行星在横越过程中经过这些星斑。为了重现 图 8-26,设置 EXO_RADIUS = 4,EXO_DX = 3,EXO_START_Y = 205。

实践项目:检测外星舰队

系外行星 BR549 上的超级进化的海狸们忙得不可开交,就像海狸一样。它们已经集结了一支庞大的殖民舰队,这些舰船已经装载完毕,准备离开轨道。由于自己也能进行系外行星探测,它们决定抛弃已经啃噬殆尽的家园,前往地球那片郁郁葱葱的绿树成荫的森林!

编写一个 Python 程序,模拟多艘太空飞船横越一颗恒星。给飞船设置不同的大小、形状和速度(如图 8-27 所示)。

图片

图 8-27:一支外星殖民舰队准备入侵地球

将得到的光变曲线与 Tabby 星的光变曲线(图 8-17)以及小行星实践项目的光变曲线进行比较。这些飞船会产生独特的曲线吗,还是你能从小行星群、恒星斑点或其他自然现象中得到相似的模式?

你可以在附录和 Chapter_8 文件夹中找到一个解决方案,practice_alien_armada.py,并从书籍网站下载。

实践项目:检测具有卫星的行星

一个拥有卫星的系外行星会产生怎样的光变曲线?编写一个 Python 程序,模拟一个小型外卫星绕着更大的系外行星轨道运行,并计算出由此产生的光变曲线。你可以在附录和书籍网站上找到一个解决方案,practice_planet_moon.py

实践项目:测量系外行星的一天长度

你的天文学家老板给了你 34 张外行星 BR549 的图像。图像拍摄时间间隔为一小时。编写一个 Python 程序,按顺序加载这些图像,测量每张图像的强度,并将这些测量值绘制成单一的光曲线(图 8-28)。使用该曲线来确定 BR549 的白昼长度。

Image

图 8-28:外行星 BR549 的 34 张图像合成光曲线

你可以在附录中找到一个解决方案,practice_length_of_day.py。代码的数字版本以及图像文件夹(br549_pixelated)都位于从本书网站可下载的Chapter_8文件夹中。

挑战项目:生成动态光曲线

重新编写transit.py,使得光曲线在模拟运行时动态更新,而不是仅在结束时显示。

第九章:识别朋友或敌人

Image

人脸检测是一种机器学习技术,用于在数字图像中定位人脸。这是面部识别过程的第一步,面部识别是通过代码识别特定个体人脸的技术。人脸检测和识别方法有广泛的应用,例如社交媒体上的照片标记、数字相机的自动对焦、手机解锁、寻找失踪儿童、追踪恐怖分子、促进安全支付等。

在本章中,你将使用 OpenCV 中的机器学习算法编程一个机器人岗哨枪。因为你需要区分人类和外星突变体,所以你只需要检测人脸的存在,而不是识别特定的个体。在第十章中,你将迈出下一步,通过人脸来识别具体的人。

检测照片中的人脸

人脸检测之所以可行,是因为人脸具有相似的模式。一些常见的面部模式包括眼睛比脸颊暗,鼻梁比眼睛亮,正如在图 9-1 的左侧图像中所看到的。

Image

图 9-1:面部中一些一致的明亮和暗淡区域的示例

你可以使用像图 9-2 中的模板来提取这些模式。它们产生了Haar 特征,这是用于物体识别的数字图像属性的 fancy 名称。要计算 Haar 特征,将其中一个模板放置在灰度图像上,将与白色部分重叠的灰度像素相加,然后从与黑色部分重叠的像素之和中减去它们。因此,每个特征由一个单一的强度值组成。我们可以使用不同大小的模板来采样图像上的所有可能位置,使得系统具有尺度不变性。

Image

图 9-2:一些示例 Haar 特征模板

在图 9-1 中的中间图像中,一个“边缘特征”模板提取了黑暗眼睛和明亮脸颊之间的关系。在图 9-1 中的最右侧图像中,一个“线条特征”模板提取了黑暗眼睛和明亮鼻子之间的关系。

通过计算成千上万张已知的人脸和非人脸图像的 Haar 特征,我们可以确定哪种 Haar 特征组合在识别面部时最有效。这个训练过程比较慢,但它为后续的快速检测提供了便利。最终的算法被称为人脸分类器,它通过输出 1 或 0 来预测图像是否包含人脸。OpenCV 提供了基于这一技术的预训练人脸检测分类器。

为了应用分类器,算法使用滑动窗口方法。一个小的矩形区域会逐步在图像上移动,并通过一个包含多级过滤器的级联分类器进行评估。每个阶段的过滤器是 Haar 特征的组合。如果窗口区域未通过某一阶段的阈值,它将被拒绝,窗口滑动到下一个位置。快速拒绝非人脸区域,例如图 9-3 右侧插图中的区域,有助于加速整个过程。

Image

图 9-3:使用矩形滑动窗口搜索人脸。

如果一个区域通过了某个阶段的阈值,算法会处理另一组 Haar 特征并与阈值进行比较,依此类推,直到它要么拒绝,要么确定一个面部。这会导致滑动窗口在图像上移动时加速或减速。你可以在vimeo.com/12774628/上找到一个精彩的视频示例。

对于每个检测到的人脸,算法返回一个围绕人脸的矩形坐标。你可以使用这些矩形作为进一步分析的基础,例如识别眼睛。

项目#13:编程机器人守卫炮

想象一下,你是联盟海军的技术员,隶属于太空部队的一支分队。你的小队被派遣到由威克汉-尤塔萨基公司在 LV-666 星球上运营的一个秘密研究基地。在研究一个神秘的外星设备时,研究人员不小心打开了通往地狱般异次元的门户。任何靠近门户的人,包括数十名平民和你的几位战友,都会变异成嗜血的无脑怪物!你甚至捕捉到了监控视频(见图 9-4)。

Image

图 9-4:变异科学家的监控视频(左)和海军(右)

根据剩余科学家的说法,变异不仅仅影响有机物质。受害者佩戴的任何装备,例如头盔和护目镜,也会被转化并与肉体融合。眼组织特别脆弱。迄今为止形成的所有变异体都是没有眼睛的盲者,尽管这似乎并不影响它们的行动能力。它们依然凶猛、致命且不可阻挡,除非有军用级武器。

这就是你的任务。你的工作是设置一个自动开火站点,以守卫被攻陷设施中的关键通道 5。如果没有它,你的小队就有可能被变异怪物大军包围并压倒。

火控站由一台 UAC 549-B 自动哨兵枪组成,步兵们称其为机器人哨兵(图 9-5)。它配备了四门 M30 自动炮,拥有 1,000 发弹药以及多个传感器,包括运动探测器、激光测距单元和光学相机。该枪还通过敌友识别(IFF)应答器对目标进行识别。所有联军海军陆战队员都携带这些应答器,从而使他们能够安全地通过激活的哨兵枪。

图片

图 9-5:UAC 549-B 自动哨兵枪

不幸的是,小队的哨兵枪在着陆时受损,因此应答器不再起作用。更糟糕的是,物资上士忘记下载用于视觉识别目标的软件。由于应答器传感器失效,现在无法正面识别海军陆战队员和平民。你需要尽快修复这个问题,因为你的战友人数严重不足,而变异体已经开始行动!

幸运的是,LV-666 星球没有本土生命形式,因此你只需要区分人类和变异体。由于变异体基本上没有面孔,人脸检测算法是最合适的解决方案。

目标

编写一个 Python 程序,当哨兵枪检测到人脸时,禁用其开火机制。

策略

在这种情况下,最好保持简单,并利用现有的资源。这意味着依赖 OpenCV 的人脸检测功能,而不是编写自定义代码来识别基地上的人类。但是你无法确定这些现成的程序是否能很好地工作,因此你需要引导你的目标人类,使任务尽可能简便。

哨兵枪的运动探测器将负责触发光学识别过程。为了允许人类安全通过,你需要提醒他们停下并面对相机。他们需要几秒钟的时间来完成这一动作,之后在被确认清除后,他们才能继续通过哨兵枪。

你还需要运行一些测试,确保 OpenCV 的训练集足够,并且没有生成任何假阳性,以免让变异体悄悄溜过。你不想因为误伤友军而杀死任何人,但你也不能过于谨慎。如果一个变异体溜过,所有人都可能丧命。

注意

在现实生活中,哨兵枪将使用视频馈送。由于我没有自己的电影制作工作室以及特效和化妆部门,你将使用静态照片代替。你可以把这些照片当作独立的视频帧来看。稍后的章节中,你将有机会使用电脑的摄像头来检测自己的面孔。

代码

sentry.py 代码将循环遍历一个图像文件夹,识别图像中的人脸,并显示带有勾画出人脸的图像。然后,根据结果,它会触发或禁用枪支。你将使用 Chapter_9 文件夹中的 corridor_5 文件夹中的图像,该文件夹可以从 nostarch.com/real-world-python/ 下载。像往常一样,在下载后不要移动或重命名任何文件,并从存储该文件的文件夹中启动 sentry.py

你还需要安装两个模块,playsound 和 pyttsx3。第一个是一个跨平台模块,用于播放 WAV 和 MP3 格式的音频文件。你将使用它来产生声音效果,例如机枪射击声和“全部清除”音调。第二个是一个跨平台封装器,支持 Windows 和基于 Linux 的系统(包括 macOS)上的本地文本转语音库。哨兵枪将使用它来发出音频警告和指令。与其他文本转语音库不同,pyttsx3 直接从程序读取文本,而不是先将其保存到音频文件中。它也可以离线工作,使其成为语音项目的可靠选择。

你可以在 PowerShell 或终端窗口中使用 pip 安装这两个模块。

pip install playsound
pip install pyttsx3

如果你在 Windows 上安装 pyttsx3 时遇到错误,比如 No module named win32.com.client、No module named win32 或 No module named win32api,那么请安装 pypiwin32。

pip install pypiwin32

安装后,你可能需要重新启动 Python shell 和编辑器。

有关 playsound 的更多信息,请参阅 pypi.org/project/playsound/。pyttsx3 的文档可以在 pyttsx3.readthedocs.io/en/latest/pypi.org/project/pyttsx3/ 找到。

如果你尚未安装 OpenCV,请参阅 第 6 页中的“安装 Python 库”部分。

导入模块、设置音频并引用分类器文件和走廊图像

清单 9-1 导入模块,初始化并设置音频引擎,将分类器文件分配给变量,并将目录更改为包含走廊图像的文件夹。

sentry.py, part 1
   import os
   import time
➊ from datetime import datetime
   from playsound import playsound
   import pyttsx3
   import cv2 as cv

➋ engine = pyttsx3.init()
   engine.setProperty('rate', 145)  
   engine.setProperty('volume', 1.0) 

   root_dir = os.path.abspath('.')
   gunfire_path = os.path.join(root_dir, 'gunfire.wav')
   tone_path = os.path.join(root_dir, 'tone.wav')

➌ path= "C:/Python372/Lib/site-packages/cv2/data/"
   face_cascade = cv.CascadeClassifier(path + 
                                       'haarcascade_frontalface_default.xml')
   eye_cascade = cv.CascadeClassifier(path + 'haarcascade_eye.xml')

➍ os.chdir('corridor_5')
   contents = sorted(os.listdir())

清单 9-1:导入模块、设置音频,并定位分类器文件和走廊图像

除了 datetime、playsound 和 pyttsx3 模块,如果你已经完成前面的章节 ➊,你应该对这些导入的模块很熟悉。你将使用 datetime 来记录在走廊中检测到入侵者的准确时间。

要使用 pytts3,请初始化一个 pyttsx3 对象并将其分配给一个变量,通常命名为 engine ➋。根据 pyttsx3 文档,应用程序使用 engine 对象来注册和注销事件回调,生成和停止语音,获取和设置语音引擎属性,以及启动和停止事件循环。

在接下来的两行中,设置语音速率和音量属性。这里使用的语音速率值是通过反复试验得到的,应该快速但仍然清晰易懂。音量应设置为最大值(1.0),这样任何走进走廊的人都能清楚听到警告指令。

Windows 系统上的默认语音为男性,但也可以选择其他语音。例如,在 Windows 10 机器上,您可以使用以下语音 ID 切换到女性语音:

engine.setProperty('voice',
'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Speech\Voices\Tokens\TTS_MS_EN-US_ZIRA_11.0')

要查看平台上可用的语音列表,请参考“更改语音”pyttsx3.readthedocs.io/en/latest/

接下来,设置枪声的音频录制,当在走廊中检测到变异体时播放。通过生成一个适用于所有平台的目录路径字符串来指定音频文件的位置,方法是使用 os.path.join() 方法将绝对路径与文件名结合起来。对于tone.wav 文件,使用相同的路径,当程序识别到人类时,它将作为“解除警报”信号使用。

在安装 OpenCV 时,预训练的 Haar cascade 分类器应该作为 .xml 文件下载。将包含分类器的文件夹路径分配给变量 ➌。显示的路径是我 Windows 机器上的路径;您的路径可能会不同。例如,在 macOS 上,您可能会在 opencv/data/haarcascades 中找到它们。您还可以在网上找到它们,网址是*github.com/opencv/opencv/tree/master/data/haarcascades/

查找 cascade 分类器路径的另一种方法是使用预安装的 sysconfig 模块,如下片段所示:

>>> import sysconfig
>>> path = sysconfig.get_paths()['purelib'] + '/cv2/data'
>>> path
'C:\\Python372\\Lib\\site-packages/cv2/data'

这在 Windows 系统中无论是在虚拟环境内外都应该有效。但在 Ubuntu 上,这仅在虚拟环境内有效。

要加载分类器,请使用 OpenCV 的 CascadeClassifier() 方法。使用字符串拼接将路径变量添加到分类器的文件名字符串中,并将结果分配给一个变量。

请注意,我只使用了两个分类器,一个用于正面人脸,一个用于眼睛,以保持简单。还可以使用其他分类器来检测侧面、微笑、眼镜、上半身等。

最后,指向您正在守卫的走廊中拍摄的图像。将目录更改为正确的文件夹 ➍;然后列出文件夹内容并将结果分配给一个内容变量。因为您没有提供文件夹的完整路径,所以需要从包含文件夹的文件夹中启动程序,该文件夹应该位于图像文件夹的上一层。

发出警告、加载图像并检测人脸

清单 9-2 开始一个 for 循环,遍历包含走廊图像的文件夹。在现实生活中,守卫枪的运动探测器会在有人进入走廊时启动你的程序。由于我们没有运动探测器,我们假设每个循环代表一个新的入侵者的到来。

循环立即激活枪支并准备开火。然后,它口头要求入侵者停止并正对摄像头。这会在离枪支一定距离处发生,由运动传感器决定。因此,你知道这些面孔的大小大致相同,这使得测试程序变得容易。

给入侵者几秒钟时间遵守命令。之后,调用级联分类器并用它来搜索面部。

sentry.py, part 2 
for image in contents:
 ➊ print(f"\nMotion detected...{datetime.now()}")
    discharge_weapon = True
 ➋ engine.say("You have entered an active fire zone. \
                Stop and face the gun immediately. \
                When you hear the tone, you have 5 seconds to pass.")
    engine.runAndWait()
    time.sleep(3)

 ➌ img_gray = cv.imread(image, cv.IMREAD_GRAYSCALE)
    height, width = img_gray.shape
    cv.imshow(f'Motion detected {image}', img_gray)
    cv.waitKey(2000)
    cv.destroyWindow(f'Motion detected {image}')

 ➍ face_rect_list = []  
    face_rect_list.append(face_cascade.detectMultiScale(image=img_gray,
                                                        scaleFactor=1.1,
                                                        minNeighbors=5))

清单 9-2:遍历图像、发出口头警告并搜索面部

开始遍历文件夹中的图像。每张新图像代表走廊中的一个新入侵者。打印事件日志和发生的时间 ➊。注意字符串前面的 f。这是 Python 3.6 引入的f-string格式(* www.python.org/dev/peps/pep-0498/*)。f-string 是一个字面量字符串,它包含表达式,例如变量、字符串、数学运算,甚至函数调用,位于大括号内。当程序打印该字符串时,它会用表达式的值替换这些表达式。这些是 Python 中最快、最有效的字符串格式,我们当然希望这个程序运行得很快!

假设每个入侵者都是变种人,并准备开火。然后,口头警告入侵者停止并进行扫描。

使用 pyttsx3 引擎对象的 say()方法来发声 ➋。它接受一个字符串作为参数。然后调用 runAndWait()方法。它会暂停程序执行,清空 say()队列并播放音频。

注意

对于一些 macOS 用户,程序可能在第二次调用 runAndWait()时退出。如果发生这种情况,请从书籍的网站下载 sentry_for_Mac_bug.py 代码。该程序使用操作系统的文本到语音功能替代 pyttsx3。你仍然需要更新程序中的 Haar 级联路径变量,正如你在➌处所做的那样,在清单 9-1 中。

接下来,使用 time 模块暂停程序三秒钟。这样,入侵者有时间正对着枪的摄像头。

在这一点上,你将进行视频捕捉,尽管我们不使用视频。相反,加载coridor_5文件夹中的图像。使用带有 IMREAD_GRAYSCALE 标志的 cv.imread()方法 ➌。

使用图像的 shape 属性获取其高度和宽度(以像素为单位)。这将在稍后你在图像上添加文本时派上用场。

人脸检测仅适用于灰度图像,但在应用 Haar 级联时,OpenCV 会在后台自动将彩色图像转换为灰度图像。我从一开始就选择使用灰度图像,因为图像显示时效果更恐怖。如果你想查看彩色图像,只需将前两行代码修改如下:

    img_gray = cv.imread(image)
    height, width = img_gray.shape[:2]

接下来,显示人脸检测前的图像,保持显示两秒钟(以毫秒为单位),然后销毁窗口。这是质量控制的一部分,确保所有图像都已被检查。你可以在确认一切按计划正常工作后,将这些步骤注释掉。

创建一个空列表,用于存储当前图像中找到的人脸➍。OpenCV 将图像视为 NumPy 数组,因此列表中的项目是框住人脸的矩形的角点坐标(xy,宽度,高度),如以下输出片段所示:

[array([[383, 169,  54,  54]], dtype=int32)]

现在是时候使用 Haar 级联来检测人脸了。通过调用 face_cascade 变量的 detectMultiscale()方法来执行此操作。将图像以及 scaleFactor 和 minNeighbors 的值传递给该方法。这些值可以用于调整结果,以应对误检或未能识别人脸的情况。

为了获得良好的结果,图像中的人脸应与训练分类器时使用的人脸大小相同。为了确保这一点,scaleFactor 参数使用一种叫做尺度金字塔的技术,将原始图像缩放到正确的大小(见图 9-6)。

Image

图 9-6:“尺度金字塔”示例

尺度金字塔将图像按设定的次数向下缩放。例如,scaleFactor 为 1.2 意味着图像将按 20%的增量缩小。滑动窗口将继续在缩小后的图像上滑动,并再次检查 Haar 特征。这种缩小和滑动将持续进行,直到缩放后的图像达到训练时使用的图像大小。对于 Haar 级联分类器来说,这个大小是 20×20 像素(你可以通过打开一个.xml文件来确认这一点)。小于这个大小的窗口无法被检测到,因此缩放会在此结束。请注意,尺度金字塔仅会缩小图像,因为放大图像可能会引入失真。

每次缩放时,算法会计算大量新的 Haar 特征,从而产生大量误检。为了筛除这些误检,可以使用 minNeighbors 参数。

要查看这个过程是如何工作的,参见图 9-7。此图中的矩形框代表通过 haarcascade_frontalface_alt2.xml 分类器检测到的人脸,scaleFactor 参数设置为 1.05,minNeighbors 设置为 0。矩形框的大小取决于使用的缩放图像——由 scaleFactor 参数确定——当人脸被检测到时使用的图像大小。尽管会有很多误检,但矩形框通常会聚集在真实人脸周围。

Image

图 9-7:检测到的人脸矩形框,minNeighbors=0

增加 minNeighbors 参数的值将提高检测的质量,但会减少检测到的数量。如果你指定值为 1,则只有具有一个或多个紧邻矩形的矩形会被保留,其他的都被丢弃 (图 9-8)。

Image

图 9-8:使用 minNeighbors=1 检测到的面部矩形

将最小邻居数增加到大约五通常可以消除误报 (图 9-9)。这对于大多数应用来说可能足够了,但应对可怕的跨维怪物需要更严谨的处理。

Image

图 9-9:使用 minNeighbors=5 检测到的面部矩形

为了查看原因,查看 图 9-10。尽管使用了 minNeighbor 值为 5,但变种人的脚趾区域仍被错误地识别为面部。凭借一点想象力,你可以看到矩形顶部有两个黑色的眼睛和一个明亮的鼻子,底部有一个黑色、直线状的嘴巴。这可能让变种人毫发无损地通过,最好的结果是你被开除,最坏的结果则是经历一场极其痛苦的死亡。

Image

图 9-10:变种人的右脚趾区域被错误地识别为面部

幸运的是,这个问题可以很容易地解决。解决方案是搜索不仅仅是面部。

检测眼睛并禁用武器

仍然在遍历走廊图像的 for 循环中,列表 9-3 使用 OpenCV 内置的眼睛级联分类器在检测到的面部矩形列表中搜索眼睛。通过增加第二步验证,搜索眼睛可以减少误报。而且因为变种人没有眼睛,如果至少找到一只眼睛,你可以假定是人类存在,并禁用哨兵枪的射击机制,让他们通过。

sentry.py, part 3 
    print(f"Searching {image} for eyes.")
    for rect in face_rect_list:
        for (x, y, w, h) in rect:
         ➊ rect_4_eyes = img_gray[y:y+h, x:x+w]
            eyes = eye_cascade.detectMultiScale(image=rect_4_eyes, 
                                                scaleFactor=1.05,
                                                minNeighbors=2)
         ➋ for (xe, ye, we, he) in eyes:
                print("Eyes detected.")
                center = (int(xe + 0.5 * we), int(ye + 0.5 * he))
                radius = int((we + he) / 3)
                cv.circle(rect_4_eyes, center, radius, 255, 2)
                cv.rectangle(img_gray, (x, y), (x+w, y+h), (255, 255, 255), 2)
             ➌ discharge_weapon = False
                break

列表 9-3:在面部矩形中检测眼睛并禁用武器

打印正在搜索的图像名称,并开始遍历 face_rect_list 中的矩形。如果存在矩形,开始遍历坐标元组。使用这些坐标从图像中制作一个子数组,在其中搜索眼睛 ➊。

在子数组上调用眼睛级联分类器。由于现在搜索的是一个更小的区域,你可以减少 minNeighbors 参数的值。

像面部级联分类器一样,眼睛级联也会返回一个矩形的坐标。从这些坐标开始循环,并用 e 作为结尾命名它们,代表“眼睛”,以便与面部矩形的坐标 ➋ 区分开来。

接下来,在你找到的第一个眼睛周围画一个圆圈。这只是为了你自己的视觉确认;就算法而言,眼睛已经被找到。计算矩形的中心,然后计算一个略大于眼睛的半径值。使用 OpenCV 的 circle() 方法在 rect_4_eyes 子数组上画一个白色圆圈。

现在,通过调用 OpenCV 的rectangle()方法并传入img_gray数组,绘制一个矩形框围绕人脸。显示图像两秒钟后销毁窗口。因为rect_4_eyes子数组是img_gray的一部分,即使没有显式将该子数组传递给im_show()方法,圆形仍然会显示出来(见图 9-11)。

Image

图 9-11:人脸矩形框和眼睛圆形

识别到一个人类后,禁用武器➌并跳出 for 循环。你只需识别一个眼睛,就能确认有一个人脸,因此是时候继续下一个人脸矩形框了。

通过入侵者或开火武器

仍然在遍历走廊图像的 for 循环中,清单 9-4 决定了如果禁用武器或允许开火时会发生什么。在禁用的情况下,它显示检测到人脸的图像并播放“解除警报”音调。否则,显示图像并播放枪火音频文件。

sentry.py, part 4
    if discharge_weapon == False:
        playsound(tone_path, block=False)    
        cv.imshow('Detected Faces', img_gray)
        cv.waitKey(2000)
        cv.destroyWindow('Detected Faces')
        time.sleep(5)

    else:
        print(f"No face in {image}. Discharging weapon!")
        cv.putText(img_gray, 'FIRE!', (int(width / 2) - 20, int(height / 2)),
                                       cv.FONT_HERSHEY_PLAIN, 3, 255, 3)
        playsound(gunfire_path, block=False)
        cv.imshow('Mutant', img_gray)
        cv.waitKey(2000)
        cv.destroyWindow('Mutant')
        time.sleep(3)

engine.stop()

清单 9-4:确定枪支禁用或启用时的行动方案

使用条件语句检查武器是否被禁用。当你从corridor_5文件夹中选择当前图像时,你将discharge_weapon变量设置为 True(见清单 9-2)。如果之前的清单在人脸矩形框中找到一只眼睛,它会将状态更改为 False。

如果武器被禁用,显示正面检测图像(例如在图 9-11 中),并播放音调。首先,调用playsound,传入tone_path字符串,并将block参数设置为 False。通过将block设置为 False,你允许playsound在 OpenCV 显示图像的同时运行。如果将block=True,那么在音频完成之前,你不会看到图像。显示图像两秒钟后销毁它,并使用time.sleep()暂停程序五秒钟。

如果discharge_weapon仍然为 True,打印一条信息到命令行,表示枪正在开火。使用 OpenCV 的putText()方法将此信息显示在图像中心,然后显示图像(见图 9-12)。

Image

图 9-12:示例突变窗口

现在播放枪火音频。使用playsound,传入gunfire_path字符串并将block参数设置为 False。注意,如果你在调用playsound时提供完整路径,你可以选择删除清单 9-1 中的root_dirgunfire_path代码行。例如,在我的 Windows 机器上,我会使用如下代码:

playsound('C:/Python372/book/mutants/gunfire.wav', block=False)

显示窗口两秒钟后销毁它。暂停程序三秒钟,以便在显示突变图像和显示corridor_5文件夹中的下一个图像之间暂停。当循环完成时,停止pyttsx3引擎。

结果

你的sentry.py程序修复了哨兵枪的损坏,并使其在无需转发器的情况下正常工作。然而,它有偏向保护人类生命的倾向,这可能导致灾难性的后果:如果一个变种人与一个人类在同一时刻进入走廊,变种人可能会悄悄绕过防御(见图 9-13)。

Image

图 9-13:最坏的情况。说“茄子”!

如果走廊里有人的话,变种人也可能触发开火机制,前提是这些人在错误的时刻背离了镜头(见图 9-14)。

Image

图 9-14:你只有一个任务!

我看过足够多的科幻和恐怖电影,知道在真实的情况下,我会将枪械编程为射击任何移动的物体。幸运的是,这是一个我永远不必面对的道德困境!

从视频流中检测人脸

你也可以使用视频摄像头实时检测人脸。这很简单,因此我们不会将其作为一个专门的项目。输入清单 9-5 中的代码,或者使用书籍网站上可下载的Chapter_9文件夹中的数字版本video_face_detect.py。你将需要使用你计算机的摄像头或通过计算机连接的外部摄像头。

video_face_detect.py
   import cv2 as cv

   path = "C:/Python372/Lib/site-packages/cv2/data/"
   face_cascade = cv.CascadeClassifier(path + 'haarcascade_frontalface_alt.xml')

➊ cap = cv.VideoCapture(0)

   while True:
       _, frame = cap.read()
       face_rects = face_cascade.detectMultiScale(frame, scaleFactor=1.2,
                                                  minNeighbors=3)    

       for (x, y, w, h) in face_rects:
           cv.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)

       cv.imshow('frame', frame)
    ➋ if cv.waitKey(1) & 0xFF == ord('q'):
          break

 cap.release()
 cv.destroyAllWindows()

清单 9-5:在视频流中检测人脸

在导入 OpenCV 之后,像在清单 9-1 的➌步骤中一样设置你的 Haar 级联分类器路径。我在这里使用haarcascade_frontalface_alt.xml文件,因为它比你在之前项目中使用的haarcascade_frontalface_default.xml文件精度更高(假阳性较少)。接下来,实例化一个名为 cap 的 VideoCapture 类对象,“cap”代表“捕获”。将视频设备的索引传递给构造函数➊。如果你只有一个摄像头,比如你笔记本电脑的内置摄像头,那么该设备的索引应该是 0。

为了保持相机和人脸检测过程的运行,使用一个 while 循环。在循环内,你将捕捉每一帧视频,并分析其中的人脸,就像你在之前的项目中对静态图像所做的那样。尽管算法需要完成许多工作,但它足够快,可以跟上连续的视频流!

要加载视频帧,调用 cap 对象的 read()方法。它返回一个元组,包含一个布尔值返回码和一个表示当前帧的 NumPy ndarray 对象。返回码用于检查在从文件读取时是否已读取完所有帧。由于我们这里没有从文件读取,将其赋值为下划线表示它是一个不重要的变量。

接下来,重用之前项目中的代码,找到人脸矩形并在帧上绘制矩形。使用 OpenCV 的 imshow()方法显示帧。如果检测到人脸,程序应该在该帧上绘制一个矩形。

要结束循环,你需要按下 Q 键以退出 ➋。首先调用 OpenCV 的 waitKey() 方法,并传递一个短暂的、一毫秒的时间间隔。此方法会暂停程序,等待按键事件,但我们不希望中断视频流太长时间。

Python 内置的 ord() 函数接受一个字符串作为参数,并返回传入参数的 Unicode 码点表示,在这种情况下是小写的 q。你可以在这里看到字符与数字的映射:www.asciitable.com/。为了使这个查找兼容所有操作系统,你必须将按位与运算符 & 与十六进制数 FF(0xFF)结合使用,FF 的整数值为 255。使用 & 0xFF 确保只读取变量的最后 8 位。

循环结束时,调用 cap 对象的 release() 方法。这会释放相机资源,供其他应用程序使用。通过销毁显示窗口来完成程序。

你可以通过在面部检测中添加更多级联来提高其准确性,就像你在之前的项目中做的那样。如果这使得检测速度变慢,可以尝试缩小视频图像。在调用 cap.read() 之后,添加以下代码片段:

    frame = cv.resize(frame, None, fx=0.5, fy=0.5,
                      interpolation=cv.INTER_AREA)

fx 和 fy 参数是屏幕 xy 维度的缩放因子。使用 0.5 将使窗口的默认大小减半。

除非你做出一些疯狂的动作,比如稍微侧头,否则程序应该不会在追踪你的面部时遇到麻烦。只要稍微转动头部,检测就会中断,矩形框就会消失(图 9-15)。

Image

图 9-15:使用视频帧进行面部检测

Haar 级联分类器设计用于识别直立的面部,包括正面和侧面视角,并且表现出色。它们甚至可以处理眼镜和胡须。但如果你转动头部,它们可能会很快失败。

一种低效但简单的管理倾斜头部的方式是使用一个循环,在将图像传递给面部检测之前稍微旋转图像。Haar 级联分类器可以处理一定程度的倾斜(图 9-16),因此你可以在每次传递时旋转图像大约 5 度,并且有很大概率获得一个积极的结果。

Image

图 9-16:旋转图像有助于面部检测。

Haar 特征法用于面部检测非常受欢迎,因为它足够快速,可以在有限的计算资源下实时运行。然而,正如你可能猜到的那样,仍然有更准确、复杂且资源密集型的技术可用。

例如,OpenCV 提供了一个基于 Caffe 深度学习框架的准确且强大的面部检测器。欲了解更多有关此检测器的信息,请参见教程“使用 OpenCV 和深度学习进行面部检测”,访问 www.pyimagesearch.com/

另一种选择是使用 OpenCV 的 LBP 级联分类器进行人脸检测。这种技术将人脸划分为多个块,并从中提取局部二值模式直方图(LBPH)。这种直方图在检测无约束的人脸(即未对齐且姿势各异的面孔)时非常有效。我们将在下一章中详细讨论 LBPH,重点介绍人脸识别,而不仅仅是人脸检测。

总结

在这一章中,你使用了 OpenCV 的 Haar 级联分类器来检测人脸;使用了 playsound 库来播放音频文件;还使用了 pyttsx3 库来进行文本转语音。得益于这些有用的库,你能够快速编写一个不仅能检测人脸,还能发出语音警告和指令的程序。

进一步阅读

“使用提升级级联简单特征的快速物体检测”(计算机视觉与模式识别会议,2001 年),由 Paul Viola 和 Michael Jones 提出,是第一个提供实用实时物体检测率的物体检测框架。它构成了本章用于人脸检测的基础。

Adrian Rosebrock 的www.pyimagesearch.com/ 网站是构建图像搜索引擎和寻找大量有趣计算机视觉项目的绝佳资源,比如能够检测火灾和烟雾、在无人机视频流中寻找目标、区分真人面孔和打印的面孔、自动识别车牌等等。

实践项目:模糊面孔

你是否曾经看到过某个纪录片或新闻报道,其中一个人的面部被模糊处理以保护其匿名性,就像在图 9-17 中看到的那样?嗯,这个酷炫的效果通过 OpenCV 很容易实现。你只需要从一帧图像中提取出人脸矩形框,对其进行模糊处理,然后再将其写回到原图中,并可以选择在面部周围绘制一个矩形框。

Image

图 9-17:使用 OpenCV 进行面部模糊的示例

模糊操作是通过在一个称为的局部矩阵内对像素进行平均来实现的。可以将核想象成一个你放置在图像上的框。框内的所有像素都会被平均成一个值。框越大,平均的像素越多,因此图像看起来越平滑。因此,可以将模糊视为一种低通滤波器,它会阻挡高频内容,如锐利的边缘。

模糊是这个过程中的唯一一个你之前没有做过的步骤。要模糊图像,可以使用 OpenCV 的 blur()方法,并传入图像和一个表示像素大小的元组。

blurred_image = cv.blur(image, (20, 20))

在这个示例中,你将图像中给定像素的值替换为以该像素为中心的 20×20 正方形区域内所有像素的平均值。这个操作会对图像中的每个像素重复执行。

你可以在附录中和从书籍网站下载的Chapter_9文件夹中找到解决方案,名为practice_blur.py

挑战项目:检测猫脸

结果发现,LV-666 星球上有三种动物生命形态:人类、变种人和猫。基地的吉祥物,小猫先生,拥有自由出入的权限,并且经常会在走廊 5 中闲逛。

编辑并校准sentry.py,使得小猫先生可以自由通行。这将是一个挑战,因为猫并不以听从口头命令而闻名。为了至少让它看向摄像头,你可以在 pyttsx3 的语音命令中添加“Here kitty, kitty”或“Puss, puss, puss”。或者更好,使用 playsound 添加开罐头的声音!

你可以在与项目 13 中使用的分类器相同的 OpenCV 文件夹中找到用于猫脸的 Haar 分类器,并且在书籍可下载的Chapter_9文件夹中可以找到一张空的走廊图片,empty_corridor.png。从互联网上或你个人的收藏中选择几张猫的图片,并将它们粘贴在空走廊的不同地方。使用其他图片中的人类来估计猫的适当比例。

第十章:使用人脸识别限制访问权限

图片

在前一章中,你是太空军的一名联军海军技术员。在这一章中,你依然是那个技术员,只是你的工作变得更加复杂。你现在的角色是识别人脸,而不仅仅是检测人脸。你的指挥官,德明上尉,发现了一个包含突变体生产的跨维度传送门的实验室,他希望仅限自己访问该实验室。

如同前一章所述,你需要快速行动,因此你将依赖 Python 和 OpenCV 来提高速度和效率。具体来说,你将使用 OpenCV 的局部二值模式直方图(LBPH)算法,这是一种最古老且最易使用的人脸识别算法,来帮助锁定实验室。如果你之前没有安装和使用过 OpenCV,可以查看 第 6 页中的“安装 Python 库”。

使用局部二值模式直方图识别人脸

LBPH 算法依赖特征向量来识别人脸。回想一下 第五章中提到的,特征向量基本上是按特定顺序排列的一组数字。在 LBPH 中,这些数字代表了面部的一些特性。例如,假设你可以通过一些简单的测量来区分不同的面部,比如眼睛之间的距离、嘴巴的宽度、鼻子的长度和脸部的宽度。按照这个顺序,且以厘米为单位,这四个测量值可能组成如下的特征向量:(5.1,7.4,5.3,11.8)。将数据库中的人脸简化为这些向量,可以实现快速搜索,并且我们可以通过两个向量之间的数值差异,或者说距离,来表达它们之间的差异。

识别人脸在计算上需要多个特征,当然,许多可用的算法依赖于不同的特征。在这些算法中有特征脸(Eigenfaces)、LBPH、Fisherfaces、尺度不变特征变换(SIFT)、加速稳健特征(SURF)以及各种神经网络方法。当面部图像在受控条件下采集时,这些算法可以达到与人类相似的高准确率。

对于面部图像的受控条件,可能涉及每个面部的正面视图,表情自然、放松,并且为了让所有算法都能使用,还需要保持一致的光照条件和分辨率。面部应避免被胡须和眼镜遮挡,前提是算法已经学习了如何在这些条件下识别面部。

人脸识别流程图

在深入了解 LBPH 算法的细节之前,让我们先来看看人脸识别的一般工作原理。这个过程包括三个主要步骤:捕获、训练和预测。

在捕获阶段,你需要收集将用于训练人脸识别器的图像(见 图 10-1)。对于每个你想要识别的面孔,你应拍摄十张或更多张不同表情的图像。

Image

图 10-1:捕捉人脸图像以训练人脸识别器

捕捉过程的下一步是检测图像中的人脸,在人脸周围绘制一个矩形,裁剪图像到矩形区域,调整裁剪后的图像大小以匹配相同的尺寸(取决于算法),并将其转换为灰度图像。算法通常使用整数来跟踪人脸,因此每个对象需要一个唯一的 ID 号。处理后,所有人脸将存储在一个文件夹中,我们称之为数据库。

下一步是训练人脸识别器(见图 10-2)。该算法——在我们的案例中是 LBPH——分析每个训练图像,然后将结果写入 YAML(.yml)文件,YAML 是一种可读性强的数据序列化语言,用于数据存储。YAML 最初代表“Yet Another Markup Language”,但现在表示“YAML Ain’t Markup Language”,以强调它不仅仅是一个文档标记工具。

Image

图 10-2:训练人脸识别器并将结果写入文件

训练好人脸识别器后,最后一步是加载一个新的、未经训练的人脸并预测其身份(见图 10-3)。这些未知的人脸与训练图像的处理方式相同——即,裁剪、调整大小并转换为灰度图像。然后,识别器会对其进行分析,将结果与 YAML 文件中的人脸进行比对,并预测最匹配的人脸。

Image

图 10-3:使用训练过的识别器预测未知的人脸

请注意,识别器将对每个面孔的身份进行预测。如果 YAML 文件中只有一个已训练的人脸,识别器将为每个人脸分配该训练人脸的 ID 号。它还会输出一个置信度因素,实际上是新的人脸与训练人脸之间的距离度量。数字越大,匹配越差。我们稍后会更详细地讨论这个问题,但现在要知道,你将使用一个阈值来决定预测的人脸是否正确。如果置信度超过接受的阈值,程序将丢弃该匹配,并将人脸分类为“未知”(见图 10-3)。

提取局部二值模式直方图

你将使用的 OpenCV 人脸识别器是基于局部二值模式(LBP)的。这些纹理描述符最早于 1994 年左右被用来描述和分类表面纹理,例如区分混凝土和地毯。人脸也是由纹理组成的,因此该技术也适用于人脸识别。

在提取直方图之前,你需要先生成二值模式。LBP 算法通过将每个像素与其周围的邻居进行比较,计算出纹理的局部表示。第一个计算步骤是将一个小窗口滑动到人脸图像上并捕捉像素信息。图 10-4 显示了一个示例窗口。

图片

图 10-4:示例 3×3 像素滑动窗口,用于捕获局部二值模式

下一步是将像素转换为二进制数,使用中心值(在此情况下为 90)作为阈值。你通过将八个相邻值与阈值进行比较来实现这一点。如果某个相邻值等于或高于阈值,则赋值为 1;如果低于阈值,则赋值为 0。接下来,忽略中心值,将二进制值逐行连接(某些方法使用顺时针旋转)以形成新的二进制值(11010111)。最后,将该二进制数转换为十进制数(215),并将其存储在中心像素位置。

继续滑动窗口,直到所有像素都转换为 LBP 值。除了使用方形窗口捕获相邻像素外,该算法还可以使用半径,这一过程称为圆形 LBP

现在是时候从上一步产生的 LBP 图像中提取直方图了。为此,你使用一个网格将 LBP 图像划分为矩形区域(见图 10-5)。在每个区域内,构建 LBP 值的直方图(在图 10-5 中标记为“局部区域直方图”)。

图片

图 10-5:提取 LBP 直方图

构建局部区域直方图后,按照预定顺序对它们进行归一化并将它们连接成一个长直方图(在图 10-5 中显示为截断形式)。由于你使用的是灰度图像,其强度值在 0 和 255 之间,因此每个直方图中有 256 个位置。如果你使用的是 10×10 网格,如在图 10-5 中所示,那么最终直方图中将有 10×10×256 = 25,600 个位置。假设这个复合直方图包含了识别面部所需的诊断特征。因此,它们是面部图像的表示,而面部识别是通过比较这些表示,而不是图像本身来实现的。

要预测一个新的未知面孔的身份,你需要提取其连接直方图,并将其与训练数据库中的现有直方图进行比较。比较的方式是衡量直方图之间的距离。这个计算可以使用各种方法,包括欧几里得距离、绝对距离、卡方距离等。该算法返回与最接近的直方图匹配的训练图像的 ID 号码,并附带置信度测量。然后,你可以对置信度值应用阈值,如在图 10-3 中所示。如果新图像的置信度低于阈值,则认为匹配为正。

由于 OpenCV 封装了所有这些步骤,LBPH 算法易于实现。它还在受控环境中产生了出色的结果,并且不受光照条件变化的影响(见图 10-6)。

图片

图 10-6:LBP 对光照变化具有鲁棒性

LBPH 算法能够很好地应对光照条件变化,因为它依赖于像素强度之间的比较。即使一张图像的照明比另一张图像要亮得多,面部的相对反射率依然保持不变,LBPH 能够捕捉到这一点。

项目 #14:限制外星文物的访问

你的队伍已经打进了包含产生传送门的外星文物的实验室。德明上校命令立即锁定该实验室,仅他自己可以访问。另一个技术员将用军用笔记本电脑覆盖当前系统。德明上校将通过这台笔记本电脑使用两级安全验证:一个是输入密码,另一个是面部识别。考虑到你在 OpenCV 方面的技能,他命令负责面部验证部分。

目标

编写一个 Python 程序,能够识别德明上校的面孔。

策略

由于时间紧迫并且工作环境不佳,你希望使用一个快速且易用的工具,且具有良好的性能记录,比如 OpenCV 的 LBPH 人脸识别器。你知道 LBPH 在受控环境下表现最佳,因此你将使用相同的笔记本电脑摄像头来捕捉训练图像和任何试图访问实验室的人的面孔。

除了德明上校的面部照片外,你还需要捕捉一些不属于德明上校的面孔。你将使用这些面孔来确保所有的正面匹配确实属于上校。无需担心设置密码、隔离程序与用户的操作,或者破解现有系统;另一个技术员会处理这些任务,而你则负责外出打击一些变异体。

支持模块和文件

在这个项目中,你将主要使用 OpenCV 和 NumPy。如果你还没有安装它们,请参考第 6 页的“安装 Python 库”部分。你还需要 playsound 模块来播放声音,以及 pyttsx3 模块来实现文本转语音功能。你可以在第 207 页的“代码”部分找到这些模块的更多信息,包括安装说明。

代码和支持文件位于书籍网站上的Chapter_10文件夹,nostarch.com/real-world-python/。下载后请保持文件夹结构和文件名不变(见图 10-7)。注意,testertrainer文件夹稍后会创建,并不会包含在下载内容中。

图片

图 10-7:项目 14 的文件结构

demming_trainerdemming_tester文件夹包含德明上校及其他人的图片,你可以用来完成这个项目。当前代码已引用这些文件夹。

如果你想提供自己的图像——例如,使用你自己的面部来代表 Captain Demming——那么你将使用名为 trainertester 的文件夹。接下来的代码将为你创建 trainer 文件夹。你需要手动创建 tester 文件夹并添加一些你的照片,稍后会有详细说明。当然,你需要编辑代码,使其指向这些新的文件夹。

视频捕获代码

第一步(由 1_capture.py 代码执行)是捕捉你将用于训练识别器的面部图像。如果你打算使用 demming_trainer 文件夹中提供的图像,可以跳过此步骤。

要使用你自己的面部来表示 Captain Demming,使用计算机的摄像头录制大约十几张不同表情的面部照片,且不戴眼镜。如果你没有摄像头,可以跳过此步骤,使用手机自拍,并将其保存到名为 trainer 的文件夹中,如 图 10-7 所示。

导入模块,设置音频、摄像头、说明和文件路径

清单 10-1 导入模块,初始化并设置音频引擎和 Haar 级联分类器,初始化摄像头,并提供用户说明。你需要 Haar 级联分类器,因为在识别面部之前,必须先检测到它。关于 Haar 级联和面部检测的复习内容,请参阅 第 204 页 中的“照片中的面部检测”。

1_capture.py, part 1
   import os
   import pyttsx3
   import cv2 as cv
   from playsound import playsound

   engine = pyttsx3.init()
➊ engine.setProperty('rate', 145)
   engine.setProperty('volume', 1.0)

   root_dir = os.path.abspath('.')
   tone_path = os.path.join(root_dir, 'tone.wav')

➋ path = "C:/Python372/Lib/site-packages/cv2/data/"
   face_detector = cv.CascadeClassifier(path + 
                                        'haarcascade_frontalface_default.xml')
   cap = cv.VideoCapture(0)
   if not cap.isOpened(): 
       print("Could not open video device.")
➌ cap.set(3, 640)  # Frame width.
   cap.set(4, 480)  # Frame height.

   engine.say("Enter your information when prompted on screen. \
              Then remove glasses and look directly at webcam. \
              Make multiple faces including normal, happy, sad, sleepy. \
              Continue until you hear the tone.")
   engine.runAndWait()

➍ name = input("\nEnter last name: ")
   user_id = input("Enter assigned ID Number: ")
   print("\nCapturing face. Look at the camera now!")

清单 10-1:导入模块并设置音频和检测器文件、摄像头以及说明

导入的模块与上一章中用于检测面部的模块相同。你将使用操作系统(通过 os 模块)来操作文件路径,使用 pyttsx3 播放文本转语音音频说明,使用 cv 处理图像并运行面部检测器和识别器,使用 playsound 播放音效,提示用户程序何时完成捕获其图像。

接下来,设置文本转语音引擎。你将使用它告诉用户如何运行程序。默认的语音取决于你所使用的操作系统。该引擎的速率参数目前已针对 Windows 系统上的美国“David”语音进行了优化➊。如果你发现语速太快或太慢,可以编辑该参数。如果你想更改语音,请参阅 清单 9-1 及 第 209 页 上的说明。

你将使用音效提醒用户视频捕获过程已结束。设置 tone.wav 音频文件的路径,方法与 第九章 中一样。

现在,提供 Haar 级联文件的路径 ➋,并将分类器分配给名为 face_detector 的变量。这里显示的路径是我的 Windows 计算机的路径;你的路径可能会有所不同。例如,在 macOS 上,你可以在 opencv/data/haarcascades 文件夹下找到这些文件。你也可以在网上找到它们,网址为 github.com/opencv/opencv/tree/master/data/haarcascades/

在 第九章 中,你学习了如何使用计算机的网络摄像头捕获你的面部图像。你将在本程序中使用类似的代码,从调用 cv.VideoCapture(0) 开始。0 参数指的是活动摄像头。如果你有多个摄像头,可能需要使用其他数字,例如 1,这可以通过尝试不同的数字来确定。使用条件语句检查摄像头是否成功打开,如果成功,则分别设置帧的宽度和高度 ➌。这两个方法中的第一个参数指的是宽度或高度参数在参数列表中的位置。

出于安全考虑,你将在视频捕获过程中进行监督。然而,你可以使用 pyttsx3 引擎来向用户解释该过程(这样你就不需要记住这些步骤了)。为了控制获取条件并确保后续的识别准确,用户需要摘掉眼镜或面部遮挡物,并展示多种表情。其中应包括他们每次进入实验室时计划使用的表情。

最后,他们需要按照屏幕上打印的指示操作。首先,他们将输入自己的姓氏 ➍。目前不需要担心重复问题,因为 Demming 阁下将是唯一的用户。此外,你将为用户分配一个唯一的 ID 号。OpenCV 将使用这个变量 user_id 来跟踪训练和预测过程中的所有面部图像。以后,你将创建一个字典,以便能够跟踪每个用户 ID 对应的是哪个人,假设未来会有更多人获得访问权限。

一旦用户输入他们的 ID 号并按下回车键,摄像头将开启并开始捕获图像,因此通过另一个调用 print() 来通知用户。记住在前一章中提到的 Haar 级联人脸检测器对头部姿态非常敏感。为了让它正常工作,用户必须直接面对网络摄像头并尽量保持头部直立。

捕获训练图像

列表 10-2 使用网络摄像头和一个 while 循环来捕获指定数量的面部图像。代码将图像保存到一个文件夹中,并在操作完成时发出音调。

1_capture.py, part 2
if not os.path.isdir('trainer'):
    os.mkdir('trainer')
os.chdir('trainer')

frame_count = 0

while True:
    # Capture frame-by-frame for total of 30 frames.
    _, frame = cap.read()
    gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
 ➊ face_rects = face_detector.detectMultiScale(gray, scaleFactor=1.2,
                                                minNeighbors=5)     
    for (x, y, w, h) in face_rects:
        frame_count += 1
        cv.imwrite(str(name) + '.' + str(user_id) + '.'
                   + str(frame_count) + '.jpg', gray[y:y+h, x:x+w])
        cv.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
        cv.imshow('image', frame)
        cv.waitKey(400)
 ➋ if frame_count >= 30:
        break

print("\nImage collection complete. Exiting...")
playsound(tone_path, block=False)
cap.release()
cv.destroyAllWindows()

列表 10-2:使用循环捕获视频图像

首先检查是否有名为 trainer 的目录。如果没有,使用操作系统模块的 mkdir() 方法创建该目录。然后,将当前工作目录更改为 trainer 文件夹。

现在,初始化 frame_count 变量为 0。只有当检测到人脸时,代码才会捕捉并保存视频帧。为了知道何时结束程序,你需要跟踪已捕捉帧的数量。

接下来,启动一个设置为 True 的 while 循环。然后调用 cap 对象的 read() 方法。如前一章所述,该方法返回一个元组,包含一个布尔值返回码和一个 numpy ndarray 对象,表示当前帧。返回码通常用于检查在从文件读取时是否已读完所有帧。由于我们这里不是从文件读取,所以将其赋值给一个下划线,以表示这是一个未使用的变量。

人脸检测和人脸识别都在灰度图像上工作,因此将帧转换为灰度图像,并将结果数组命名为 gray。接着,调用 detectMultiscale() 方法检测图像中的人脸 ➊。有关该方法如何工作的详细信息,请参考 Listing 9-2 中的讨论,见 第 212 页。由于你通过让用户注视笔记本的摄像头来控制条件,你可以放心地认为算法会表现良好,尽管你当然需要检查结果。

前面的方法应该输出围绕人脸的矩形框坐标。接着,开始一个 for 循环,遍历每一组坐标,并立即将 frame_count 变量加 1。

使用 OpenCV 的 imwrite() 方法将图像保存到 trainer 文件夹中。文件夹采用以下命名规则:name.user_id.frame_count.jpg(例如 demming.1.9.jpg)。只保存人脸矩形区域内的图像部分,这将有助于确保你不会训练算法识别背景特征。

接下来的两行代码会在原始帧上绘制一个人脸矩形,并显示它。这是为了让用户——Demming 阁下——能够检查自己的头部是否直立,表情是否适当。waitKey() 方法延迟了捕捉过程,给用户足够时间切换不同的表情。

即使 Demming 阁下在验证身份时总是采用放松的中性表情,对软件进行一系列表情的训练将导致更为健壮的结果。从这个角度来看,如果用户在捕捉阶段稍微左右摇动头部,也会有帮助。

接下来,检查目标帧数是否已达到,如果已经达到,则跳出循环 ➋。请注意,如果没有人对着摄像头,循环将永远运行下去。只有当级联分类器检测到人脸并返回人脸矩形时,才会计数帧数。

通过打印消息并发出提示音,让用户知道相机已经关闭。然后,释放相机并销毁所有图像窗口,从而结束程序。

此时,trainer 文件夹中应该包含 30 张用户的紧密裁剪人脸图像。在接下来的部分中,你将使用这些图像——或 demming_trainer 文件夹中提供的图像集——来训练 OpenCV 的人脸识别器。

面部训练器代码

下一步是使用 OpenCV 创建一个基于 LBPH 的面部识别器,使用训练图像对其进行训练,并将结果保存为可重复使用的文件。如果你使用自己的面部图像来代表 Captain Demming 的面部,你需要将程序指向trainer文件夹。否则,你将需要使用demming_trainer文件夹,和包含代码的2_train.py文件,这些都位于可下载的Chapter_10文件夹中。

列表 10-3 设置了用于面部检测的 Haar 级联路径和前一个程序捕捉的训练图像。OpenCV 通过标签整数跟踪面部,而不是使用名称字符串,列表还初始化了用于存储标签及其相关图像的列表。接着,它遍历训练图像,加载它们,从文件名中提取用户 ID 号,并进行面部检测。最后,它训练识别器并将结果保存到文件中。

2_train.py
   import os
   import numpy as np
   import cv2 as cv

   cascade_path = "C:/Python372/Lib/site-packages/cv2/data/"
   face_detector = cv.CascadeClassifier(cascade_path +
                                       'haarcascade_frontalface_default.xml')

➊ train_path = './demming_trainer'  # Use for provided Demming face.   
   #train_path = './trainer'  # Uncomment to use your face.
   image_paths = [os.path.join(train_path, f) for f in os.listdir(train_path)]
   images, labels = [], []

   for image in image_paths:
       train_image = cv.imread(image, cv.IMREAD_GRAYSCALE)
    ➋ label = int(os.path.split(image)[-1].split('.')[1])
       name = os.path.split(image)[-1].split('.')[0]
       frame_num = os.path.split(image)[-1].split('.')[2]
    ➌ faces = face_detector.detectMultiScale(train_image)
       for (x, y, w, h) in faces:
           images.append(train_image[y:y + h, x:x + w])
           labels.append(label)
           print(f"Preparing training images for {name}.{label}.{frame_num}")
           cv.imshow("Training Image", train_image[y:y + h, x:x + w])
           cv.waitKey(50) 

   cv.destroyAllWindows()

➍ recognizer = cv.face.LBPHFaceRecognizer_create()
   recognizer.train(images, np.array(labels))
   recognizer.write('lbph_trainer.yml')
   print("Training complete. Exiting...")

列表 10-3:训练并保存 LBPH 面部识别器

你之前已经看到过导入和面部检测器的代码。虽然你已经在1_capture.py中将训练图像裁剪成面部矩形,但重复这个过程并不会有坏处。由于2_train.py是一个独立程序,最好不要对任何事情掉以轻心。

接下来,你必须选择使用哪一组训练图像:你自己在trainer文件夹中捕捉的图像,还是demming_trainer文件夹中提供的图像集 ➊。注释掉或删除不使用的那一行。记住,由于你没有提供文件夹的完整路径,你需要从包含该文件夹的上一级文件夹启动程序,即trainerdemming_trainer文件夹的父文件夹。

使用列表推导创建一个名为 image_paths 的列表。它将保存训练文件夹中每个图像的目录路径和文件名。然后创建空列表来存储图像及其标签。

开始一个 for 循环,遍历图像路径。以灰度模式读取图像;然后从文件名中提取其数字标签,并将其转换为整数➋。记住,标签对应的是通过1_capture.py输入的用户 ID,恰好是在它捕捉视频帧之前。

让我们花一点时间来解析一下这个提取和转换过程发生了什么。os.path.split()方法接受一个目录路径并返回一个包含目录路径和文件名的元组,如下所示:

>>> import os
>>> path = 'C:\demming_trainer\demming.1.5.jpg'
>>> os.path.split(path)
('C:\\demming_trainer', 'demming.1.5.jpg')

然后,你可以使用索引-1 选择元组中的最后一个项目,并根据点分割它。这将产生一个包含四个项目的列表(用户的姓名、用户 ID、帧编号和文件扩展名)。

>>> os.path.split(path)[-1].split('.')
['demming', '1', '5', 'jpg']

为了提取标签值,你可以通过索引 1 选择列表中的第二个项目。

>>> os.path.split(path)[-1].split('.')[1]
'1'

重复此过程以提取每个图像的名称和 frame_num。这些目前都是字符串,因此你需要将用户 ID 转换为整数,以便作为标签使用。

现在,调用人脸检测器处理每一张训练图像 ➌。这将返回一个numpy.ndarray,我们称之为faces。开始循环遍历数组,它包含了检测到的人脸矩形的坐标。将矩形区域内的图像部分添加到之前创建的图像列表中。同时,将图像的用户 ID 添加到标签列表中。

通过在终端打印信息来让用户了解当前的进展。然后,作为检查,显示每张训练图像 50 毫秒。如果你曾看过彼得·加布里尔 1986 年流行的音乐视频《Sledgehammer》,你会喜欢这种展示方式。

现在是训练人脸识别器的时候了。就像使用 OpenCV 的人脸检测器一样,你首先要实例化一个识别器对象 ➍。接着,调用train()方法,将图像列表和标签列表传递给它,并实时将其转换为 NumPy 数组。

你不希望每次有人验证他们的面孔时都重新训练识别器,所以将训练过程的结果写入名为lbph_trainer.yml的文件中。然后让用户知道程序已结束。

人脸预测代码

现在是时候开始识别人脸了,这个过程我们称之为预测,因为它归结为概率问题。3_predict.py中的程序首先会计算每张人脸的连接 LBP 直方图。然后,它会计算该直方图与训练集中所有直方图之间的距离。接着,如果该距离低于你指定的阈值,它将为新的面孔分配与训练面孔最接近的标签和名称。

导入模块并准备人脸识别器

清单 10-4 导入了模块,准备了一个字典来保存用户 ID 号和姓名,设置了人脸检测器和识别器,并建立了测试数据的路径。测试数据包括德明队长的图像,以及其他几张人脸图像。为了测试结果,还包含了训练文件夹中的一张德明队长的图像。如果一切正常,算法应该能准确识别这张图像,并给出较低的距离度量。

3_predict.py, part 1
   import os
   from datetime import datetime
   import cv2 as cv

   names = {1: "Demming"}
   cascade_path = "C:/Python372/Lib/site-packages/cv2/data/"
   face_detector = cv.CascadeClassifier(cascade_path +
                                        'haarcascade_frontalface_default.xml')

➊ recognizer = cv.face.LBPHFaceRecognizer_create()
   recognizer.read('lbph_trainer.yml')

   #test_path = './tester'
➋ test_path = './demming_tester'
   image_paths = [os.path.join(test_path, f) for f in os.listdir(test_path)]

清单 10-4:导入模块并为人脸检测与识别做准备

在导入一些常见的模块后,创建一个字典,将用户 ID 号与用户名关联起来。尽管目前只有一个条目,但这个名称字典使得将来可以方便地添加更多条目。如果你使用的是自己的照片,可以随意更改最后一个名字,但 ID 号要保持为 1。

接下来,重复设置face_detector对象的代码。你需要输入自己的cascade_path(参见清单 10-1 在第 233 页)。

如同在2_train.py代码中一样创建一个识别器对象 ➊。然后使用read()方法加载包含训练信息的.yml文件。

你需要使用文件夹中的人脸图像来测试识别器。如果你使用的是提供的 Demming 图像,请设置指向demming_tester文件夹的路径 ➋。否则,使用你之前创建的tester文件夹。你可以将自己的图像添加到这个空白文件夹中。如果你用自己的脸来代表 Demming 队长的脸,你不应该在这里重复使用训练图像,尽管你可以考虑使用其中一张作为对照。相反,使用1_capture.py程序生成一些新的图像。如果你戴眼镜,可以包括一些戴眼镜和不戴眼镜的照片。你还需要包括一些来自demming_tester文件夹中的陌生人的图像。

人脸识别与访问日志更新

清单 10-5 循环遍历测试文件夹中的图像,检测任何存在的人脸,将人脸的直方图与训练文件中的进行比较,命名人脸,分配置信度值,然后将姓名和访问时间记录到一个持久化的文本文件中。在此过程中,程序理论上会在 ID 为正时解锁实验室,但由于我们没有实验室,所以我们会跳过这部分。

3_predict.py, part 2
for image in image_paths:
    predict_image = cv.imread(image, cv.IMREAD_GRAYSCALE)
    faces = face_detector.detectMultiScale(predict_image,
                                          scaleFactor=1.05,
                                          minNeighbors=5)
    for (x, y, w, h) in faces:
        print(f"\nAccess requested at {datetime.now()}.")
 ➊ face = cv.resize(predict_image[y:y + h, x:x + w], (100, 100))   
    predicted_id, dist = recognizer.predict(face)
 ➋ if predicted_id == 1 and dist <= 95:
        name = names[predicted_id]
        print("{} identified as {} with distance={}"
              .format(image, name, round(dist, 1)))
     ➌ print(f"Access granted to {name} at {datetime.now()}.",
              file=open('lab_access_log.txt', 'a'))
    else:
        name = 'unknown'
        print(f"{image} is {name}.")

    cv.rectangle(predict_image, (x, y), (x + w, y + h), 255, 2)
    cv.putText(predict_image, name, (x + 1, y + h - 5),
               cv.FONT_HERSHEY_SIMPLEX, 0.5, 255, 1)        
    cv.imshow('ID', predict_image)        
    cv.waitKey(2000)
    cv.destroyAllWindows()

清单 10-5:运行人脸识别并更新访问日志文件

从测试文件夹中的图像开始循环。这将是demming_tester文件夹或tester文件夹。将每张图像读取为灰度图像,并将结果数组赋给名为 predict_image 的变量。然后对其运行人脸检测器。

现在像之前一样循环遍历人脸矩形。打印出一个关于访问请求的消息;然后使用 OpenCV 将人脸子数组调整为 100×100 像素 ➊。这接近demming_trainer文件夹中训练图像的尺寸。同步图像的大小严格来说不是必需的,但根据我的经验,有助于提高结果。如果你使用自己的图像代表 Demming 队长的脸,你应该检查训练图像和测试图像的尺寸是否相似。

现在是时候预测人脸的身份了。这样做只需要一行代码。只需在识别器对象上调用 predict()方法,并传入人脸子数组。该方法将返回一个 ID 号和一个距离值。

距离值越低,预测的人脸被正确识别的可能性就越大。你可以使用距离值作为阈值:所有被预测为 Demming 队长并且得分等于或低于阈值的图像将被确定为 Demming 队长。其他所有图像将被分配为“未知”。

要应用阈值,使用 if 语句➋。如果你使用的是自己的训练和测试图像,第一次运行程序时,将距离值设置为 1000。查看测试文件夹中所有图像的距离值,包括已知和未知图像。找到一个阈值,在该阈值以下,所有面孔都能正确识别为 Demming 队长。这将成为你接下来使用的区分值。对于demming_trainerdemming_tester文件夹中的图像,阈值距离应该是 95。

接下来,使用预测的 _id 值作为名称字典的键来获取图像的名称。 在命令行中打印一条消息,表明图像已被识别,并包括图像文件名、字典中的名称和距离值。

对于日志,打印一条消息,表明某个名字(在本例中是 Demming 队长)已获得实验室访问权限,并包括使用 datetime 模块的时间➌。

你会想保留一份人员进出记录的持久文件。这里有一个很巧妙的技巧:只需使用 print()函数写入文件。打开lab_access_log.txt文件,并包含一个“附加”参数。这样,程序每次处理新图像时,不会覆盖文件,而是在文件底部添加一行。以下是文件内容的示例:

Access granted to Demming at 2020-01-20 09:31:17.415802.
Access granted to Demming at 2020-01-20 09:31:19.556307.
Access granted to Demming at 2020-01-20 09:31:21.644038.
Access granted to Demming at 2020-01-20 09:31:23.691760.
--snip--

如果条件不满足,将 name 设置为'unknown'并打印相应的消息。然后在面部周围绘制矩形,并使用 OpenCV 的 putText()方法显示用户的名字。显示图像两秒钟后销毁它。

结果

你可以在图 10-8 中查看一些来自demming_tester文件夹的 20 张图片的示例结果。预测器代码正确识别了 8 张 Demming 队长的照片,没有出现误识别。

Image

图 10-8:Demming 队长和非 Demming 队长

为了使 LBPH 算法具有高准确性,你需要在受控条件下使用它。记住,通过强制用户通过笔记本电脑获取访问权限,你控制了他们的姿势、面部大小、图像分辨率和光照条件。

总结

在本章中,你将使用 OpenCV 的局部二值模式直方图算法来识别人脸。通过仅几行代码,你就创建了一个强大的面部识别器,能够轻松应对不同的光照条件。你还使用了标准库的 os.path.split()方法来分割目录路径和文件名,从而生成自定义的变量名。

进一步阅读

“应用局部二值模式于人脸检测与识别”(加泰罗尼亚理工大学,2010 年),由 Laura María Sánchez López 编写,是对 LBPH 方法的全面回顾。PDF 可以在像* www.semanticscholar.org/*这样的网站上找到。

“查看 LBP 直方图”,在 AURlabCVsimulator 网站上 (aurlabcvsimulator.readthedocs.io/en/latest/),包含了可以让你可视化 LBPH 图像的 Python 代码。

如果你是 macOS 或 Linux 用户,一定要查看 Adam Geitgey 的 face_recognition 库,这是一个简单易用且高精度的人脸识别系统,利用深度学习技术。你可以在 Python 软件基金会网站上找到安装说明和概述:pypi.org/project/face_recognition/

“机器学习很有趣!第四部分:使用深度学习进行现代人脸识别”(Medium,2016),作者 Adam Geitgey,是一篇简短且有趣的概述,介绍了如何使用 Python、OpenFace 和 dlib 进行现代人脸识别。

“使用 OpenCV 进行活体检测”(PyImageSearch,2019),作者 Adrian Rosebrock,是一篇在线教程,教你如何保护人脸识别系统免受伪造面孔的欺骗,例如将 Captain Demming 的照片举到网络摄像头前。

世界各地的城市和大学已开始禁止使用人脸识别系统。发明家们也纷纷行动起来,设计出能够迷惑系统、保护身份的衣物。“这些衣服使用离奇的设计来欺骗面部识别软件,让它认为你不是人类”(《商业内幕》,2020),作者 Aaron Holmes,以及“如何黑掉你的面孔,躲避人脸识别技术的崛起”(《连线》,2019),作者 Elise Thomas,回顾了这一问题的一些近期实用——也有不太实用的——解决方案。

“使用 OpenCV 进行深度学习年龄检测”(PyImageSearch,2020),作者 Adrian Rosebrock,是一篇在线教程,介绍如何使用 OpenCV 从照片中预测一个人的年龄。

挑战项目:添加密码和视频捕捉

你在项目 14 中编写的 3_predict.py 程序,循环处理一个文件夹中的照片进行人脸识别。现在,重写该程序,使其能够动态识别网络摄像头视频流中的人脸。人脸矩形框和名称应该与文件夹中的图像一样出现在视频帧中。

要启动程序,让用户输入一个密码并进行验证。如果密码正确,则添加音频指示,告诉用户看向摄像头。如果程序成功识别出 Captain Demming,则用音频宣布授权访问。否则,播放一条音频消息,表示访问被拒绝。

如果你需要帮助识别视频流中的人脸,请参见附录中的 challenge_video_recognize.py 程序。注意,你可能需要为视频帧使用比静态照片更高的置信度值。

为了方便你跟踪谁尝试进入实验室,将一帧图像保存到与lab_access_log.txt文件相同的文件夹中。使用从 datetime.now()获取的日志结果作为文件名,这样你就可以将面部与访问尝试进行匹配。注意,你需要重新格式化 datetime.now()返回的字符串,以使其仅包含操作系统允许用作文件名的字符。

挑战项目:相似面孔和双胞胎

使用第 14 项目中的代码比较名人相似面孔和双胞胎。用互联网上的图像进行训练,看看你能否欺骗 LBPH 算法。可以考虑的一些搭配包括:斯嘉丽·约翰逊与安柏·赫德、艾玛·沃森与基尔南·希普卡、利亚姆·海姆斯沃斯与卡伦·哈恰诺夫、罗布·洛与伊恩·萨默霍尔德、希拉里·达夫与维多利亚·佩德雷蒂、布莱斯·达拉斯·霍华德与杰西卡·查斯坦、威尔·法瑞尔与查德·史密斯。

对于著名的双胞胎,可以看看宇航员双胞胎马克·凯利和斯科特·凯利,或者名人双胞胎玛丽-凯特·奥尔森和艾希丽·奥尔森。

挑战项目:时光机

如果你曾经观看过老节目重播,你会看到一些著名演员年轻时——有时是非常年轻时的模样。尽管人类在面部识别方面表现出色,我们仍然可能很难认出年轻时的伊恩·麦凯伦或帕特里克·斯图尔特。这就是为什么有时需要某种语调的变化或奇特的举止,才能促使我们赶紧去 Google 查找演员表。

面部识别算法在跨时间识别面孔时也容易出错。为了查看 LBPH 算法在这些条件下的表现,可以使用第 14 项目中的代码,并用你自己(或你亲戚)某个年龄阶段的面部图像进行训练。然后,使用不同年龄段的图像进行测试。

第十一章:创建一个互动丧尸逃生地图

Image

2010 年,行尸走肉在 AMC 电视台首播。该剧设定在丧尸末日的初期,讲述了一群幸存者在乔治亚州亚特兰大地区的故事。这部广受好评的剧集迅速成为现象级作品,成为有线电视历史上收视最多的系列剧,衍生出了名为恐惧行尸走肉的外传,并开创了一个全新的电视类型——剧集后讨论节目,行尸走肉后谈

在本章中,你将扮演一位机智的数据科学家,预见到文明即将崩溃。你将准备一张地图,帮助行尸走肉的幸存者逃离拥挤的亚特兰大大都市区,前往密西西比河以西人口稀少的地区。在这个过程中,你将使用 pandas 库加载、分析和清理数据,并使用 bokeh 和 holoviews 模块绘制地图。

项目 #15:使用区域专题地图可视化人口密度

根据科学家的研究(是的,他们研究过这个问题),生存下来的关键是尽量远离城市。在美国,这意味着要住在图 11-1 中显示的大面积黑色区域里。灯光越亮,人口越多,因此如果你想避免人群,就不要“走向光明”。

Image

图 11-1:2012 年美国城市夜间灯光图

对于我们在亚特兰大的行尸走肉幸存者来说,不幸的是,他们距离美国西部相对安全的地区还有很长一段路。他们需要穿越一系列城市和小镇,理想情况下尽量经过人口稀少的区域。服务站地图并没有提供人口信息,但美国人口普查提供了。文明崩溃、互联网失效之前,你可以将人口密度数据下载到你的笔记本电脑上,稍后使用 Python 整理数据。

展示此类数据的最佳方式是使用区域专题地图,这是一种利用颜色或图案来表示预定地理区域统计数据的可视化工具。你可能熟悉美国总统选举结果的区域专题地图,地图上县域会被涂上红色代表共和党获胜,蓝色代表民主党获胜(图 11-2)。

如果幸存者们手中有一张显示每个县每平方英里人口数量的区域专题地图,他们就能找到离开亚特兰大并穿越美国南部的最短、理论上最安全的路线。尽管你可以从人口普查中获得更高分辨率的数据,但使用其县级数据应该足够了。行尸走肉中的丧尸群体会随着饥饿而迁移,这很快就会让详细的统计数据变得过时。

Image

图 11-2:2016 年美国总统选举结果的区域专题地图(浅灰色 = 民主党,深灰色 = 共和党)

为了确定通过各县的最佳路线,幸存者可以使用像加油站和欢迎中心提供的州际公路地图。这些纸质地图包括县和教区的轮廓,便于将它们的城市和道路网络与页尺寸的分层地图打印图对照。

目标

创建一个交互式地图,展示美国 48 个相邻州(即大陆 48 州)的县级人口密度。

策略

和所有数据可视化任务一样,本任务包含以下基本步骤:查找并清理数据、选择绘图类型和展示数据的工具、准备数据以进行绘图,以及绘制数据。

在这种情况下,找到数据很容易,因为美国人口普查数据是公开的。但你仍然需要清理这些数据,处理虚假的数据点、空值和格式问题。理想情况下,你还应该验证数据的准确性,这是一项困难的工作,数据科学家可能经常忽略。数据至少应该通过一个理智检查,这个工作可能要等到数据绘制出来后才能完成。例如,纽约市的人口密度应该大于蒙大拿州比灵斯市。

接下来,你必须决定如何呈现数据。你将使用地图,但其他选项可能包括条形图或表格。更重要的是选择工具——在这种情况下是 Python 库——来生成图表。工具的选择会对数据准备方式以及最终展示的内容产生重大影响。

多年前,一家快餐公司曾做过一个广告,其中一个顾客声称喜欢“有多样性,但不要太多的多样性。”对于 Python 中的可视化工具,你可以说选择太多,且很难区分它们:matplotlib、seaborn、plotly、bokeh、folium、altair、pygal、ggplot、holoviews、cartopy、geoplotlib,以及 pandas 中的内置函数。

这些不同的可视化库各有优缺点,但由于本项目需要快速完成,你将重点使用易于操作的 holoviews 模块,后端使用 bokeh 绘图。这种组合可以让你仅用几行代码就生成一个交互式的分层地图,而且 bokeh 方便地在其示例数据中包含了美国州和县的多边形。

一旦选择了可视化工具,你就需要将数据转换成工具所期望的格式。你需要弄清楚如何将一个文件中的县级边界形状,和另一个文件中的人口数据结合起来。这将涉及一些逆向工程,借助 holoviews 画廊中的示例代码。完成之后,你将使用 bokeh 绘制地图。

幸运的是,使用 Python 进行数据分析几乎总是依赖于 Python 数据分析库(pandas)。该模块将帮助你加载人口普查数据、分析数据并将其重新格式化,以便与 holoviews 和 bokeh 一起使用。

Python 数据分析库

开源的 pandas 库是 Python 中最流行的数据提取、处理和操作库。它包含了针对常见数据源(如 SQL 关系型数据库和 Excel 电子表格)设计的数据结构。如果你打算从事数据科学工作,你一定会在某个时刻遇到 pandas。

pandas 库包含两种主要的数据结构:序列和 DataFrame。序列 是一个一维的带标签数组,可以保存任何类型的数据,如整数、浮点数、字符串等。因为 pandas 基于 NumPy,序列对象实际上是两个关联的数组(如果你对数组不熟悉,可以参考第一章中第 12 页的数组介绍)。一个数组包含数据点的值,数据类型可以是任何 NumPy 类型。另一个数组包含每个数据点的标签,称为索引(见表 11-1)。

表 11-1: 序列对象

索引
0 25
1 432
2 –112
3 99

与 Python 列表项的索引不同,序列中的索引不必是整数。在表 11-2 中,索引是人名,值是他们的年龄。

表 11-2: 带有有意义标签的序列对象

索引
Javier 25
Carol 32
Lora 19
Sarah 29

与列表或 NumPy 数组一样,你可以通过指定索引来切片序列或选择单个元素。你可以以多种方式操作序列,如对其进行筛选、执行数学运算或与其他序列合并。

DataFrame 是一个更复杂的结构,包含两个维度。它具有类似电子表格的表格结构,包括列、行和数据(见表 11-3)。你可以把它看作是一个有序的列集合,配有两个索引数组。

表 11-3: DataFrame 对象

索引 国家 人口
--- --- --- --- ---
0 美国 阿拉巴马州 奥托戈县 54,571
1 美国 阿拉巴马州 鲍德温县 182,265
2 美国 阿拉巴马州 巴伯县 27,457
3 美国 阿拉巴马州 比布县 22,915

第一个索引用于行,类似于序列中的索引数组。第二个索引跟踪标签序列,每个标签代表一个列标题。DataFrame 也类似于字典;列名作为键,每列中的数据序列作为值。这个结构使你可以轻松地操作 DataFrame。

pandas 的所有功能如果要全部覆盖需要一本书,你可以在网上找到很多资源!我们将在代码部分进一步讨论,届时会通过具体示例来讲解。

bokeh 和 holoviews 库

bokeh 模块 (bokeh.org/ )是一个开源的交互式可视化库,适用于现代网页浏览器。你可以使用它在大规模或流式数据集上构建优雅的交互式图形。它使用 HTML 和 JavaScript 渲染图形,这两种是创建交互式网页的主要编程语言。

开源的 holoviews 库 (holoviews.org/ )旨在简化数据分析和可视化。使用 holoviews,你首先创建一个描述数据的对象,而不是直接调用绘图库(如 bokeh 或 matplotlib)来构建图表,然后图表会自动成为该对象的可视化表现。

holoviews 示例画廊包括多个使用 bokeh 可视化的区域图(例如 holoviews.org/gallery/demos/bokeh/texas_choropleth_example.html)。稍后,我们将使用这个画廊中的失业率示例,了解如何以类似的方式呈现我们的人口密度数据。

安装 pandas、bokeh 和 holoviews

如果你完成了第一章的项目,你已经安装了 pandas 和 NumPy。如果没有,请参考“安装 Python 库”中的说明,查看第 6 页。

安装 holoviews 的一种方式是使用 Anaconda,它会同时安装推荐的所有包的最新版本,适用于 Linux、Windows 或 macOS。

conda install -c pyviz holoviews bokeh

该安装方法包括默认的 matplotlib 绘图库后端、更加互动的 bokeh 绘图库后端以及 Jupyter/IPython Notebook。

你可以使用 pip 安装一套类似的包。

pip install 'holoviews[recommended]'

如果你已经安装了 bokeh,通过 pip 可以获得其他最小安装选项。你可以在 holoviews.org/install.htmlholoviews.org/user_guide/Installing_and_Configuring.html 上找到这些及其他安装说明。

访问县、州、失业和人口数据

bokeh 库附带了州和县的轮廓数据文件以及 2009 年美国各县的失业数据。如前所述,你将使用失业数据来确定如何格式化来自 2010 年人口普查的人口数据。

要下载 bokeh 示例数据,请连接互联网,打开 Python shell,并输入以下命令:

>>> import bokeh
>>> import bokeh.sampledata
>>> bokeh.sampledata.download()
Creating C:\Users\lee_v\.bokeh directory
Creating C:\Users\lee_v\.bokeh\data directory
Using data directory: C:\Users\lee_v\.bokeh\data

如你所见,程序会告诉你数据存放的位置,以便 bokeh 可以自动找到它。你的路径会与我的不同。有关下载示例数据的更多信息,请参见 docs.bokeh.org/en/latest/docs/reference/sampledata.html

在下载的文件夹中查找 US_Counties.csvunemployment09.csv 文件。这些纯文本文件使用流行的 逗号分隔值(CSV)格式,每一行表示一个数据记录,包含多个由逗号分隔的字段。(如果你经常去 CVS 药店购物,试着正确说出“CSV”,祝你好运!)

失业文件展示了数据科学家的困境。如果你打开它,你会发现没有描述数据的列名(图 11-3),虽然大多数字段的含义可以猜测出来。我们稍后会处理这个问题。

Image

图 11-3:unemployment09.csv 文件的前几行

如果你打开美国县文件,你会看到很多列,但至少它们有标题(图 11-4)。你的挑战是将图 11-3 中的失业数据与图 11-4 中的地理数据关联起来,这样你就可以在处理人口普查数据时做同样的事情。

Image

图 11-4:US_Counties.csv 文件的前几行

你可以在 Chapter_11 文件夹中找到人口数据 census_data_popl_2010.csv,该文件可以从书籍网站下载。这个文件原名为 DEC_10_SF1_GCTPH1.US05PR_with_ann.csv,来自美国数据查询网站 American FactFinder。到本书出版时,美国政府将把人口普查数据迁移到一个新的站点 data.census.gov(见 www.census.gov/data/what-is-data-census-gov.html)。

如果你查看人口普查文件的顶部,你会看到很多列和两行标题(图 11-5)。你需要关注的是 M 列,标题为 每平方英里土地面积密度 - 人口

Image

图 11-5:census_data_popl_2010.csv 文件的前几行

到目前为止,你已经具备了生成人口密度专题地图所需的所有 Python 库和数据文件 理论上。然而,在你编写代码之前,你需要知道如何将人口数据与地理数据关联起来,以便将正确的县数据放入正确的县形状中。

黑客入门:Holoviews

学会根据自己的需求调整现有代码是数据科学家的宝贵技能。这可能需要一些逆向工程的技巧。因为开源软件是免费的,有时文档不全,所以你必须自己弄清楚它是如何工作的。让我们花点时间,将这种技能应用到当前的问题上。

在前几章中,我们利用了像 turtle 和 matplotlib 这样的开源模块提供的图库示例。holoviews 库也有一个图库(* holoviews.org/gallery/index.html *),其中包括德克萨斯州分层图示例,这是一张 2009 年德克萨斯州失业率的分层地图(见图 11-6)。

图片

图 11-6:来自 holoviews 图库的 2009 年德克萨斯州失业率的分层地图

清单 11-1 包含了 holoviews 为该地图提供的代码。你将基于这个示例构建你的项目,但要做到这一点,你需要解决两个主要的差异。首先,你计划绘制人口密度,而不是失业率。其次,你想要一张包括美国本土的地图,而不仅仅是德克萨斯州。

texas_choropleth_example.html
   import holoviews as hv
   from holoviews import opts
   hv.extension('bokeh')
➊ from bokeh.sampledata.us_counties import data as counties
   from bokeh.sampledata.unemployment import data as unemployment

   counties = [dict(county, ➋Unemployment=unemployment[cid])
               for cid, county in counties.items()
            ➌ if county["state"] == "tx"]

   choropleth = hv.Polygons(counties, ['lons', 'lats'],
                            [('detailed name', 'County'), 'Unemployment'])

   choropleth.opts(opts.Polygons(logz=True,
                                 tools=['hover'],
                                 xaxis=None, yaxis=None,
                                 show_grid=False,
                                 show_frame=False,
                                 width=500, height=500,
                                 color_index='Unemployment',
                                 colorbar=True, toolbar='above',
                                 line_color='white'))

清单 11-1:生成德克萨斯州分层图的 holoviews 图库代码

这段代码从 bokeh 示例数据 ➊ 中导入数据。你需要知道失业率和县数据这两个变量的格式和内容。失业率稍后将通过失业率变量和 cid 索引或键来访问,cid 可能代表“县 ID” ➋。该程序通过使用州代码来选择德克萨斯州,而不是整个美国 ➌。

让我们在 Python shell 中探讨一下。

   >>> from bokeh.sampledata.unemployment import data as unemployment
➊ >>> type(unemployment)
   <class 'dict'>
➋ >>> first_2 = {k: unemployment[k] for k in list(unemployment)[:2]}
   >>> for k in first_2:
          print(f"{k} : {first_2[k]}")
➌ (1, 1) : 9.7
   (1, 3) : 9.1
   >>>
   >>> for k in first_2:
          for item in k:
             print(f"{item}: {type(item)}")
➍ 1: <class 'int'>
   1: <class 'int'>
   1: <class 'int'>
   3: <class 'int'>

首先,使用图库示例中的语法导入 bokeh 示例数据。接着,使用内置函数 type()检查失业率变量 ➊ 的数据类型。你会看到它是一个字典。

现在,使用字典推导式生成一个新的字典,包含失业率中前两行的数据 ➋。打印结果,你会看到键是元组,值是数字,推测是以百分比表示的失业率 ➌。检查键中数字的数据类型。它们是整数,而不是字符串 ➍。

将 ➌ 处的输出与图 11-3 中 CSV 文件的前两行进行对比。键元组中的第一个数字,推测为州代码,来自第 B 列。元组中的第二个数字,推测为县代码,来自第 C 列。失业率显然存储在第 I 列。

现在将失业率的数据与图 11-4 中的县数据进行对比。STATE num(第 J 列)和COUNTY num(第 K 列)显然包含了键元组的组成部分。

到目前为止都很好,但如果你查看图 11-5 中的人口数据文件,你会发现没有州或县的代码可以直接转换为元组。然而,第 E 列中的数字与县数据的最后一列匹配,那个列被标注为FIPS 公式(见图 11-4)。这些 FIPS 号码似乎与州和县的代码有关。

事实证明,联邦信息处理系列(FIPS) 代码基本上是县区的邮政编码。FIPS 代码是由国家标准与技术研究院分配给每个县区的五位数字编码。前两位数字代表该县所在的州,后三位数字代表该县(见 表 11-4)。

表 11-4: 使用 FIPS 代码识别美国县区

美国县区 州代码 县区代码 FIPS
鲍德温县,AL 01 003 1003
约翰逊县,IA 19 103 19103

恭喜你,现在你知道如何将美国人口普查数据与 Bokeh 样本数据中的县区形状关联起来了。现在是时候编写最终代码了!

Choropleth 代码

choropleth.py 程序包括清理数据和绘制 choropleth 地图的代码。你可以在书籍网站的 Chapter_11 文件夹中下载该代码以及人口数据的副本。

导入模块和数据并构建数据框

列表 11-2 导入了模块和包含所有美国县区多边形坐标的 Bokeh 县区样本数据。它还加载并创建了一个数据框对象,用于表示人口数据。然后,它开始清理和准备数据,以便与县区数据一起使用。

choropleth.py, part 1
   from os.path import abspath
   import webbrowser
   import pandas as pd
   import holoviews as hv
   from holoviews import opts
➊ hv.extension('bokeh')
   from bokeh.sampledata.us_counties import data as counties

➋ df = pd.read_csv('census_data_popl_2010.csv', encoding="ISO-8859-1")

   df = pd.DataFrame(df,
                     columns=
                     ['Target Geo Id2',
                     'Geographic area.1',
                     'Density per square mile of land area - Population'])

   df.rename(columns =
             {'Target Geo Id2':'fips',
              'Geographic area.1': 'County',
              'Density per square mile of land area - Population':'Density'},
             inplace = True)

   print(f"\nInitial popl data:\n {df.head()}")
   print(f"Shape of df = {df.shape}\n")

列表 11-2:导入模块和数据,创建数据框,并重命名列

首先从操作系统库导入 abspath。你将使用它找到创建后的 choropleth 地图 HTML 文件的绝对路径。然后导入 webbrowser 模块,这样你就可以启动 HTML 文件。你需要这个,因为 holoviews 库是为 Jupyter Notebook 设计的,没有一些帮助,它不会自动显示地图。

接下来,导入 pandas 并重复 列表 11-1 中的 holoviews 导入。注意,你必须指定 bokeh 作为 holoviews 的扩展,或称为 backend ➊。这是因为 holoviews 可以与其他绘图库(如 matplotlib)一起使用,因此需要知道使用哪一个。

你通过导入操作获得了地理数据。现在,使用 pandas 加载人口数据。这个模块包括一组输入/输出 API 函数,方便数据的读取和写入。这些 读取器写入器 处理常见格式,如逗号分隔值(read_csv, to_csv)、Excel(read_excel, to_excel)、结构化查询语言(read_sql, to_sql)、超文本标记语言(read_html, to_html)等。在这个项目中,你将使用 CSV 格式。

在大多数情况下,你可以在不指定字符编码的情况下读取 CSV 文件。

df = pd.read_csv('census_data_popl_2010.csv')

然而,在这种情况下,你会遇到以下错误:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 31: 
invalid continuation byte

这是因为文件包含使用 Latin-1 编码(也称为 ISO-8859-1)而非默认的 UTF-8 编码的字符。添加编码参数将解决这个问题 ➋。

现在,通过调用 DataFrame() 构造函数将人口数据文件转化为一个表格数据框。你不需要原始文件中的所有列,因此将你想保留的列名称传递给构造函数。这些列分别代表图 11-5 中的 E 列、G 列和 M 列,即 FIPS 代码、县名(不包含州名)和人口密度。

接下来,使用 rename() 数据框方法将列标签改得更短且更有意义。将它们命名为 fipsCountyDensity

完成列表后,使用 head() 方法打印数据框的前几行,并使用 shape 属性打印数据框的形状。默认情况下,head() 方法打印前五行。如果你想查看更多行,可以传递行数作为参数,比如 head(20)。你应该会在 shell 中看到以下输出:

Initial popl data:
      fips          County  Density
0     NaN   United States     87.4
1     1.0         Alabama     94.4
2  1001.0  Autauga County     91.8
3  1003.0  Baldwin County    114.6
4  1005.0  Barbour County     31.0
Shape of df = (3274, 3)

注意前两行(第 0 行和第 1 行)并不有用。事实上,从输出中可以看出,每个州会有一行用于显示州名,而这些行你需要删除。从 shape 属性中你还可以看到,数据框中一共有 3,274 行。

删除多余的州名行并准备州和县代码

列表 11-3 删除了所有 FIPS 代码小于或等于 100 的行。这些是指示新州开始的头部行。然后,它为州和县代码创建了新的列,这些列是从现有的 FIPS 代码列派生出来的。你稍后会用这些列从 bokeh 示例数据中选择适当的县轮廓。

   choropleth.py, part 2
   df = df[df['fips'] > 100]
   print(f"Popl data with non-county rows removed:\n {df.head()}")
   print(f"Shape of df = {df.shape}\n")

➊ df['state_id'] = (df['fips'] // 1000).astype('int64')
   df['cid'] = (df['fips'] % 1000).astype('int64') 
   print(f"Popl data with new ID columns:\n {df.head()}")
   print(f"Shape of df = {df.shape}\n")
   print("df info:")
➋ print(df.info())

   print("\nPopl data at row 500:")
➌ print(df.loc[500])

列表 11-3:删除多余的行并准备州和县代码

为了在县的多边形中显示人口密度数据,你需要将其转化为一个字典,其中键是由州代码和县代码组成的元组,值是密度数据。但正如你之前所看到的,人口数据中并没有单独的州和县代码列;它只有 FIPS 代码。因此,你需要拆分出州和县的部分。

首先,去除所有不包含县的数据行。如果查看之前的 shell 输出(或图 11-5 中的第 3 和第 4 行),你会发现这些行并没有包含四位或五位的 FIPS 代码。因此,你可以使用 fips 列创建一个新的数据框,仍命名为 df,只保留 FIPS 值大于 100 的行。为了检查这一操作是否有效,可以重复前面列出的打印输出,如下所示:

 Popl data with non-county rows removed:
      fips          County  Density
2  1001.0  Autauga County     91.8
3  1003.0  Baldwin County    114.6
4  1005.0  Barbour County     31.0
5  1007.0     Bibb County     36.8
6  1009.0   Blount County     88.9
Shape of df = (3221, 3)

数据框顶部的两行“坏”数据现在已经消失,根据 shape 属性,你已经丢失了总计 53 行。这些行代表了 50 个州、美国、哥伦比亚特区(DC)和波多黎各的表头行。注意,DC 的 FIPS 代码为 11001,波多黎各使用 72 作为州代码,与其 78 个市的三位县代码配合使用。你将保留 DC,但稍后会删除波多黎各。

接下来,为州和县代码数字创建列。将第一列命名为 state_id ➊。使用地板除法(//)除以 1000 返回商,去除小数点后的数字。由于 FIPS 代码的最后三位保留给县代码,因此这将给你留下州代码。

虽然//返回整数,但新的数据框列默认使用浮点数据类型。但我们对 bokeh 示例数据的分析表明,它在关键元组中使用的是整数类型的这些代码。使用 pandas 的 astype()方法将该列转换为整数数据类型,并传递'int64'。

现在,创建一个新的列用于县代码。将其命名为 cid,以便与 holoviews choropleth 示例中使用的术语一致。由于你需要的是 FIPS 代码的最后三位数字,因此使用取余运算符(%)。它返回第一个参数除以第二个参数的余数。像前一行一样,将此列转换为整数数据类型。

再次打印输出,但这次调用数据框的 info()方法 ➋。此方法返回数据框的简洁摘要,包括数据类型和内存使用情况。

Popl data with new ID columns:
      fips          County  Density  state_id  cid
2  1001.0  Autauga County     91.8         1    1
3  1003.0  Baldwin County    114.6         1    3
4  1005.0  Barbour County     31.0         1    5
5  1007.0     Bibb County     36.8         1    7
6  1009.0   Blount County     88.9         1    9
Shape of df = (3221, 5)

df info:
<class 'pandas.core.frame.DataFrame'>
Int64Index: 3221 entries, 2 to 3273
Data columns (total 5 columns):
fips        3221 non-null float64
County      3221 non-null object
Density     3221 non-null float64
state_id    3221 non-null int64
cid         3221 non-null int64
dtypes: float64(2), int64(2), object(1)
memory usage: 151.0+ KB
None

如你从列和信息摘要中看到的那样,state_id 和 cid 数字是整数值。

前五行中的州代码都是单一数字,但州代码也可能是两位数。花些时间检查后续行的州代码。你可以通过在数据框上调用 loc()方法并传递一个较大的行号 ➌ 来做到这一点。这将让你检查双位数的州代码。

Popl data at row 500:
fips                13207
County      Monroe County
Density              66.8
state_id               13
cid                   207
Name: 500, dtype: object

fips、state_id 和 cid 看起来都很合理。这完成了数据的准备工作。下一步是将这些数据转换成 holoviews 可以用来制作 choropleth 地图的字典。

准备数据以供显示

列表 11-4 将州和县 ID 以及密度数据转换为单独的列表。然后,它将这些数据重新组合成一个字典,格式与 holoviews 画廊示例中使用的失业字典相同。它还列出了要从地图中排除的州和领土,并列出了绘制 choropleth 地图所需的数据。

choropleth.py, part 3
state_ids = df.state_id.tolist()
cids = df.cid.tolist()
den = df.Density.tolist()

tuple_list = tuple(zip(state_ids, cids))
popl_dens_dict = dict(zip(tuple_list, den))

EXCLUDED = ('ak', 'hi', 'pr', 'gu', 'vi', 'mp', 'as')

counties = [dict(county, Density=popl_dens_dict[cid])
            for cid, county in counties.items()
            if county["state"] not in EXCLUDED]

列表 11-4:为绘制准备人口数据

之前,我们查看了 holoviews 画廊示例中的失业变量,并发现它是一个字典。州和县代码的元组作为键,失业率作为值,如下所示:

(1, 1) : 9.7
(1, 3) : 9.1
--snip--

为了创建类似的字典来存储人口数据,首先使用 pandas 的 tolist() 方法将数据框的 state_id、cid 和 Density 列分别创建成单独的列表。然后,使用内置的 zip() 函数将州和县代码列表合并成元组对。通过将这个新的元组列表与密度列表打包,创建最终的字典 popl_dens_dict。(tuple_list 这个名字有些误导,严格来说它是一个 tuple_tuple。)数据准备工作就完成了。

行尸走肉中的幸存者要是能从亚特兰大活着出去就已经幸运了。我们还是忘了他们能到达阿拉斯加吧。创建一个名为 EXCLUDED 的元组,包含在 bokeh 县数据中但不属于美国本土的州和地区。这些包括阿拉斯加、夏威夷、波多黎各、关岛、维尔京群岛、北马里亚纳群岛和美属萨摩亚。为了减少打字量,你可以使用县数据集中提供的州缩写(参见图 11-4)。

接下来,像 holoviews 示例一样,创建一个字典并将其放入一个名为 counties 的列表中。在这里,你需要添加人口密度数据。使用 cid 县标识符号将其链接到正确的县。使用条件语句来应用 EXCLUDED 元组。

如果你打印出该列表的第一个索引,你将得到以下(被截断的)输出:

[{'name': 'Autauga', 'detailed name': 'Autauga County, Alabama', 'state': 
'al', 'lats': [32.4757, 32.46599, 32.45054, 32.44245, 32.43993, 32.42573, 
32.42417, --snip-- -86.41231, -86.41234, -86.4122, -86.41212, -86.41197, 
-86.41197, -86.41187], 'Density': 91.8}]

现在,Density 键值对替换了在 holoviews 示例中使用的失业率键值对。接下来,开始绘制地图!

绘制 choropleth 地图

列表 11-5 创建 choropleth 地图,将其保存为 .html 文件,并通过 webbrowser 打开。

choropleth.py, part 4
   choropleth = hv.Polygons(counties,
                            ['lons', 'lats'],
                            [('detailed name', 'County'), 'Density'])

➊ choropleth.opts(opts.Polygons(logz=True,
                     tools=['hover'],
                     xaxis=None, yaxis=None,
                     show_grid=False, show_frame=False,
                     width=1100, height=700,
                     colorbar=True, toolbar='above',
                     color_index='Density', cmap='Greys', line_color=None,
                     title='2010 Population Density per Square Mile of Land Area'
                     ))

➋ hv.save(choropleth, 'choropleth.html', backend='bokeh')
   url = abspath('choropleth.html')
   webbrowser.open(url)

列表 11-5:创建并绘制 choropleth 地图

根据 holoviews 文档,Polygons() 类在 2D 空间中创建一个连通的填充区域,作为多边形几何体的列表。定义一个变量 choropleth 并将 counties 变量和字典键(包括用于绘制县多边形的 lons 和 lats)传递给 Polygons()。同时,传递县名和人口密度键。holoviews 的悬停工具将使用这个元组('详细名称','County'),在你移动鼠标时显示完整的县名,比如 "County: Claiborne County, Mississippi"(见图 11-7)。

图片

图 11-7:启用悬停功能的 choropleth 地图

接下来,设置地图的选项 ➊。首先,通过将 logz 参数设置为 True,允许使用对数色条。

holoviews 窗口将带有一组默认工具,如平移、缩放、保存、刷新等(参见图 11-7 的右上角)。使用 tools 参数将悬停功能添加到此列表中。这允许你查询地图,并获取县名和人口密度的详细信息。

你并不是在创建一个标准的带有注释的 x 轴和 y 轴的图形,所以将它们设置为 None。同样,不要显示地图周围的网格或框架。

设置地图的宽度和高度(以像素为单位)。你可能需要根据你的显示器调整这些设置。接下来,将 colorbar 设置为 True,并将工具栏放置在显示屏的顶部。

由于你希望根据人口密度为县区着色,因此将 color_index 参数设置为 Density,它代表 popl_dens_dict 中的值。对于填充颜色,使用 Greys 色图。如果你希望使用更亮的颜色,可以在build.holoviews.org/user_guide/Colormaps.html找到可用色图的列表。确保选择一个名称中包含“bokeh”的色图。完成颜色方案后,选择一个线条颜色用于县区轮廓。对于灰色色图,好的选择包括 None、'white'或'black'。

通过添加标题来完成选项。现在,分区地图已经准备好绘制。

要将你的地图保存在当前目录中,使用 holoviews 的 save()方法,并传递给它 choropleth 变量、一个带有.html扩展名的文件名以及正在使用的绘图后端名称 ➋。如前所述,holoviews 是为 Jupyter Notebook 设计的。如果你希望地图自动弹出在浏览器中,首先将保存的地图的完整路径赋值给一个 url 变量。然后,使用 webbrowser 模块打开 url 并显示地图(图 11-8)。

图片

图 11-8:2010 年人口密度分区地图。较浅的颜色表示较低的人口密度

你可以使用地图顶部的工具栏来平移、缩放(使用框选或套索)、保存、刷新或悬停。悬停工具,如图 11-7 所示,将帮助你在地图阴影使差异难以视觉区分的地方找到人口最少的县区。

注意

Box Zoom 工具允许快速查看矩形区域,但可能会拉伸或压缩地图坐标轴。为了在缩放时保持地图的纵横比,可以结合使用 Wheel Zoom 和 Pan 工具。

逃生计划

奇索斯山脉(Chisos Mountains)是大弯国家公园(Big Bend National Park)中的一座灭绝超级火山,可能是地球上度过僵尸末日的最佳地点之一。它孤立且外形像堡垒(图 11-9),山脉比周围的沙漠平原高出 4,000 英尺,最高海拔接近 8,000 英尺。山脉的中心是一个天然盆地,里面有公园设施,包括旅馆、小屋、商店和餐厅。该地区鱼类和野生动物丰富,沙漠泉水提供淡水,里奥格兰德河的河岸适合农业种植。

图片

图 11-9:德克萨斯州西部的奇索斯山脉(左)及其 3D 地形图(右)

使用你的地理信息图,你可以迅速规划一条通往远处自然堡垒的路线。但首先,你需要逃离亚特兰大。离开大都市区的最短路线是位于阿拉巴马州伯明翰和蒙哥马利之间的一条狭窄通道(见图 11-10)。你可以选择向北或向南绕过下一个大城市杰克逊,密西西比州。不过,为了选择最佳路线,你需要往前看得更远。

图片

图 11-10:逃离亚特兰大

绕过杰克逊的南方路线较短,但迫使幸存者必须经过高度发达的 I-35 走廊,该走廊以南部的圣安东尼奥和北部的达拉斯–沃斯堡(DFW)为中心(见图 11-11)。这在德克萨斯州希尔县(图 11-11 中圈出的地方)形成了一个潜在的危险瓶颈。

图片

图 11-11:西行路线

或者,通过俄克拉荷马州和德克萨斯州之间的红河谷的北方路线,虽然更长,但更安全,尤其是如果你利用了可通航的河流。一旦穿过沃斯堡以西,幸存者可以过河并向南行进,找到救赎之路。

如果 holoviews 提供一个允许你交互式地改变颜色条的滑动工具,这种类型的规划将变得更加简单。例如,你可以通过简单地上下拖动光标来过滤或改变县的阴影。这将使得找到通过人口最少的县的连接路线变得更加容易。

不幸的是,滑动工具并不是 holoviews 窗口选项之一。然而,由于你了解 pandas,这并不会阻止你。只需在打印位置 500 的信息行之后添加以下代码片段:

df.loc[df.Density >= 65, ['Density']] = 1000

这将改变数据框中的人口密度值,将那些大于或等于 65 的值设置为常量 1000。重新运行程序后,你将看到图 11-12 中的图表。通过这些新值,圣安东尼奥–奥斯汀–达拉斯的屏障更加明显,红河谷(它形成了东德克萨斯州的北部边界)的相对安全性也更加明显。

你可能会想知道,电视节目中的幸存者去了哪里?他们什么也没去!他们在亚特兰大附近度过了前四季,最初露营在石山,然后藏身于虚构小镇伍德伯里的监狱附近(见图 11-13)。

图片

图 11-12:每平方英里超过 65 人的县以黑色阴影显示

图片

图 11-13:石山和虚构小镇伍德伯里的位置

斯通山距离亚特兰大市中心不到 20 英里,位于德卡尔布县,每平方英里有 2,586 人。伍德伯里(即实际的塞诺伊镇)距离亚特兰大市中心仅 35 英里,位于科韦塔县和费耶特县的边界,每平方英里有 289 人和 549 人。难怪这些家伙会遇到这么多麻烦。如果当时小组里有一位数据科学家就好了。

总结

在这一章中,你将学习如何使用 Python 数据分析库(pandas)以及 bokeh 和 holoviews 可视化模块。在这个过程中,你进行了实际的数据清洗和整理,将来自不同来源的数据进行连接。

进一步阅读

“如果僵尸 apocalypse 发生,科学家建议你应该跑到山里去”(《商业内幕》,2017 年),作者凯文·洛里亚,描述了将标准疾病模型应用于假设僵尸疫情中的感染率。

“制作分层地图时需要考虑的因素”(Chartable,2018 年),作者丽莎·夏洛特·罗斯特,提供了制作分层地图的实用指南。你可以在* blog.datawrapper.de/choroplethmaps/ *找到它。

“浑浊的美国:选举地图的颜色平衡——信息图表”(STEM Lounge,2019 年),作者拉里·韦鲁,展示了如何增加分层地图中有用细节的方法,并以具有标志性的红蓝美国选举地图为例。

Python 数据科学手册:数据工作必备工具(O'Reilly 出版社,2016 年),作者杰克·范德普拉斯,是一本详细的参考书,涵盖了 Python 数据科学工具的相关内容,包括 pandas。

窗下:大弯国家早期牧场生活(《铁山出版社》,2003 年),作者帕特里夏·威尔逊·克洛瑟,是一本生动的回忆录,讲述了她在 20 世纪初在德克萨斯州大弯国家的一个广阔牧场上长大,那个地方在成为国家公园之前的样子。它提供了对末日幸存者如何应对严酷环境的见解。

博弈论:在僵尸末日中生存的真正技巧(7 天生存挑战)(《游戏理论家》,2016 年)是一部关于世界上最佳逃生地的视频。与行尸走肉不同,这段视频假设僵尸病毒可以通过蚊子和蜱虫传播,因此选择了考虑到这一点的地点。该视频可以在线观看。

挑战项目:美国人口变化地图

美国政府将在 2021 年发布 2020 年人口普查数据。然而,2019 年的跨普查人口估计数据虽然不那么精确,但已经可以使用。请利用这些数据,结合 2010 年的《项目 15》数据,生成一个新的分层地图,展示该时间段内按县划分的人口变化。

提示:你可以在 pandas 数据框中相减列以生成差异数据,如下面的玩具示例所示。2020 年的人口值代表的是虚拟数据。

>>> import pandas as pd
>>>
>>> # Generate example population data by county:
>>> pop_2010 = {'county': ['Autauga', 'Baldwin', 'Barbour', 'Bibb'],
 'popl': [54571, 182265, 27457, 22915]}
>>> pop_2020 = {'county': ['Autauga', 'Baldwin', 'Barbour', 'Bibb'],
 'popl': [52910, 258321, 29073, 29881]}
>>>
>>> df_2010 = pd.DataFrame(pop_2010)
>>> df_2020 = pd.DataFrame(pop_2020)
>>> df_diff = df_2020.copy()  # Copy the 2020 dataframe to a new df
>>> df_diff['diff'] = df_diff['popl'].sub(df_2010['popl'])  # Subtract popl columns
>>> print(df_diff.loc[:4, ['county', 'diff']])
    county   diff
0  Autauga  -1661
1  Baldwin  76056
2  Barbour   1616
3     Bibb   6966

第十二章:我们是否生活在计算机模拟中?

图片

2003 年,哲学家尼克·博斯特罗姆假设我们生活在由我们先进的、可能是后人类的后代运行的计算机模拟中。如今,包括尼尔·德格拉斯·泰森和埃隆·马斯克在内的许多科学家和思想家认为,模拟假设可能是真的。这确实解释了为什么数学如此优雅地描述了自然,为什么观察者似乎能影响量子事件,为什么我们看起来在宇宙中是孤独的。

更奇怪的是, 可能是这个模拟中唯一真实的存在。也许你只是一个浸泡在历史模拟中的大脑。为了计算效率,模拟可能只呈现出你当前互动的那些事物。当你进入屋里并关上门时,外面的世界可能就像冰箱灯一样关闭。你如何才能知道这究竟是怎么回事呢?

科学家们严肃看待这个假设,进行辩论并发表论文讨论如何设计测试来证明它。在这一章中,你将尝试使用物理学家提出的方法来回答这个问题:你将构建一个简单的模拟世界,然后分析其中的线索,看看是否有任何迹象能揭示这是一个模拟。通过这样做,你将以反向的方式完成这个项目,即先编写代码,再制定解决问题的策略。你会发现,即使是最简单的模型,也能提供关于我们存在本质的深刻洞察。

项目 #16:生命、宇宙与 Yertle 的池塘

模拟现实的能力并非遥不可及的梦想。物理学家们已经使用世界上最强大的超级计算机完成了这一壮举,模拟了亚原子粒子的行为,规模达到几飞米(10^(-15) 米)。尽管这项模拟仅代表宇宙的一小部分,但它与我们所理解的现实无法区分。

但别担心,你不需要超级计算机或物理学学位来解决这个问题。你只需要使用 turtle 模块,这是一个为孩子设计的绘图程序。你在第六章中使用过 turtle 来模拟阿波罗 8 号任务。在这里,你将利用它来理解计算机模型的一个基本特征。然后,你将应用这些知识来制定物理学家计划用于模拟假设的相同基本策略。

目标

确定计算机模拟中的一个特征,可能会被被模拟者发现。

池塘模拟代码

pond_sim.py 代码创建了一个基于海龟图形的池塘模拟,其中包括一个泥岛、一个漂浮的木头和一只名叫 Yertle 的 snapping turtle(咬龟)。Yertle 会游到木头那里,游回来,然后再游出去。你可以从本书的网站下载该代码,网址为 nostarch.com/real-world-python/

turtle 模块随 Python 一起提供,因此你不需要安装任何东西。有关该模块的概述,请参见第 127 页的《使用 turtle 模块》。

导入 turtle,设置屏幕并绘制岛屿

示例 12-1 导入 turtle,设置一个屏幕对象作为池塘,并为 Yertle 绘制一个泥岛,让他巡视自己的领地。

pond_sim.py, part 1
import turtle

pond = turtle.Screen()
pond.setup(600, 400)
pond.bgcolor('light blue')
pond.title("Yertle's Pond")

mud = turtle.Turtle('circle')
mud.shapesize(stretch_wid=5, stretch_len=5, outline=None)
mud.pencolor('tan')
mud.fillcolor('tan')

示例 12-1:导入 turtle 模块并绘制池塘和泥岛

导入 turtle 模块后,将一个屏幕对象赋值给名为 pond 的变量。使用 turtle 的 setup()方法设置屏幕的大小(以像素为单位),然后将背景颜色设置为浅蓝色。你可以在多个网站上找到海龟颜色及其名称的表格,例如 trinket.io/docs/colors。通过为屏幕提供标题来完成 pond 的设置。

接下来,绘制一个圆形的泥岛,让 Yertle 在上面晒太阳。使用 Turtle()类实例化一个名为 mud 的海龟对象。尽管 turtle 有一个绘制圆形的自带方法,但在这里通过直接传递'circle'参数给构造函数更简单,它会生成一个圆形的海龟对象。然而,这个圆形的形状太小,无法成为一个真正的岛屿,因此使用 shapesize()方法将其拉伸。最后,将岛屿的轮廓和填充颜色设置为淡棕色。

绘制原木、节疤和 Yertle

示例 12-2 通过绘制原木(包括节疤)和海龟 Yertle 来完成程序。然后,它移动 Yertle,使他可以离开他的岛屿去查看原木。

pond_sim.py, part 2
   SIDE = 80
   ANGLE = 90
   log = turtle.Turtle()
   log.hideturtle()
   log.pencolor('peru')
   log.fillcolor('peru')
   log.speed(0)
➊ log.penup()
   log.setpos(215, -30)
   log.lt(45)
   log.begin_fill()
➋ for _ in range(2):
       log.fd(SIDE)
       log.lt(ANGLE)
       log.fd(SIDE / 4)
       log.lt(ANGLE)
   log.end_fill()

   knot = turtle.Turtle()
   knot.hideturtle()
   knot.speed(0)
   knot.penup()
   knot.setpos(245, 5)
   knot.begin_fill()
   knot.circle(5)
   knot.end_fill()

   yertle = turtle.Turtle('turtle')
   yertle.color('green')
   yertle.speed(1)  # Slowest.
   yertle.fd(200)
   yertle.lt(180)
   yertle.fd(200)
➌ yertle.rt(176)
   yertle.fd(200)

示例 12-2:绘制原木和海龟,然后让海龟四处移动

你将绘制一个矩形来表示原木,所以首先定义两个常量:SIDE 和 ANGLE。第一个表示原木的长度(以像素为单位);第二个是你将在矩形的每个角落让海龟转动的角度(以度为单位)。

默认情况下,所有海龟最初会出现在屏幕中央,坐标为(0, 0)。由于你会将原木放到一边,因此在实例化原木对象后,使用hideturtle()方法使其不可见。这样,你就不必看着它在屏幕上飞奔到达最终位置。

将原木涂成棕色,使用 peru 作为原木的颜色。然后将对象的速度设置为最快(奇怪的是,速度为 0)。这样,你就不必看着它慢慢地在屏幕上绘制。而且,为了避免看到它从屏幕中心到边缘的路径,使用 penup()方法抬起画笔 ➊。

使用 setpos()方法——用于设置位置——将原木放置在屏幕的右边缘附近。然后将对象向左旋转 45 度,并调用 begin_fill()方法。

你可以通过使用 for 循环 ➋来绘制矩形,从而节省一些代码行数。你将循环两次,每次绘制矩形的两条边。将原木的宽度设置为 20 像素,即将 SIDE 除以 4。循环结束后,调用 end_fill()方法将原木填充为棕色。

通过添加一个树节孔(由一个树节乌龟表示)给木桩增添一些特色。要画出树节孔,调用 circle()方法并传入 5,表示半径为五个像素。注意,你不需要指定填充颜色,因为默认就是黑色。

最后,通过画出耶尔特尔,所有他所观察的事物的国王,来结束程序。耶尔特尔是一只老乌龟,所以把他的绘图速度设置为最慢的 1。让他游出去检查木桩,然后转身游回来。耶尔特尔有点健忘,他忘记了自己刚才做的事情。所以,让他再游出去——不过这次,改变他的游动方向,使他不再朝正东游去 ➌。运行程序,你应该看到图 12-1 中所示的结果。

仔细看看这个图形。尽管这个模拟很简单,但它包含了关于我们是否像耶尔特尔一样,生活在一个计算机模拟中的强大洞察。

图片

图 12-1:已完成模拟的截图

池塘模拟的含义

由于计算资源有限,所有计算机模拟都需要某种框架来“悬挂”其现实模型。无论它被称为网格、晶格、网状结构、矩阵或其他什么,它提供了一种方法,可以在二维或三维空间中分布物体,并赋予它们某些属性,比如质量、温度、颜色或其他东西。

乌龟模块使用你显示器中的像素作为其坐标系统,同时用于存储属性。像素位置定义了形状,例如木桩的轮廓,像素的颜色属性帮助区分不同的形状。

像素形成了一个正交图案,意味着像素的行和列在直角处交叉。尽管单个像素是方形的,且太小无法轻易看见,但你可以使用乌龟模块的 dot()方法生成一个类似的图案,如下代码片段所示:

>>> import turtle
>>> 
>>> t = turtle.Turtle()
>>> t.hideturtle()
>>> t.penup()
>>> 
>>> def dotfunc(x, y):
       t.setpos(x, y)
       for _ in range(10):
              t.dot()
              t.fd(10)

>>> for i in range(0, 100, 10):
       dotfunc(0, -i)

这会生成图 12-2 中的图案。

图片

图 12-2:代表方形像素中心的正交网格的黑色点

在乌龟世界中,像素是真正的原子:不可分割。一条线不能比一个像素短。移动只能按像素的整数进行(虽然你可以输入浮动值而不报错)。最小的物体就是一个像素大小。

这意味着,模拟中的网格决定了你能观察到的最小特征。由于我们能够观察到极其微小的亚原子粒子,假设我们是一个模拟,那么我们的网格必须非常细密。这让许多科学家严肃怀疑模拟假设,因为这将需要惊人的计算机内存。然而,谁知道我们的远方后代,或外星人,能做什么呢?

除了限制物体的大小外,仿真网格可能会在宇宙的结构上强制施加一个优先方向,或称为各向异性。各向异性是指材料的方向性依赖性,比如木材沿着木纹方向比横向更容易劈开。如果你仔细观察在海龟仿真中 Yertle 的路径(图 12-3),你会看到各向异性的证据。他的上方路径略微倾斜并且呈之字形,而下方的东西向路径则完全是直线。

Image

图 12-3:角度路径与直线路径

在正交网格上绘制非正交线并不美观。但这不仅仅是审美问题。沿* x y 方向移动仅需要整数的加法或减法(图 12-4,左)。而沿角度移动则需要三角学来计算 x y *方向的分量运动(图 12-4,右)。

对于计算机来说,数学运算等同于工作,因此我们可以推测,在一个角度上移动需要更多的能量。通过在图 12-4 中计时这两种计算方式,我们可以得到能量差异的相对衡量。

Image

图 12-4:沿行或列的移动(左)需要比跨越行列(右)更简单的算术运算

测量穿越网格的代价

要计算沿对角线绘制线条与沿水平线绘制线条之间的时间差,你需要绘制两条等长的线。但是记住,海龟只能处理整数。你需要找到一个角度,使得三角形的所有边——图 12-4 中的对边、邻边和斜边——都是整数。这样,你就可以确保你的斜线与直线长度相同。

要找到这些角度,你可以使用毕达哥拉斯三元组,这是由一组符合直角三角形规则的正整数abc组成的集合,其中a² + b² = c²。最著名的三元组是 3-4-5,但你会希望找到一条更长的线,以确保绘制函数的运行时间不会低于计算机时钟的测量精度。幸运的是,你可以在网上找到其他更大的三元组。62-960-962 这个三元组是一个不错的选择,因为它很长,但仍然适合海龟屏幕。

线条比较代码

为了比较绘制对角线与绘制直线的代价,列表 12-3 使用海龟绘制这两条线。第一条线平行于* x 轴(即东西方向),第二条线则以浅角度与 x *轴成角。你可以通过三角学计算出这个角度的正确度数;在这种情况下,它是 3.695220532 度。该代码通过 for 循环多次绘制这些线,并使用内置的时间模块记录绘制每条线所需的时间。最终的比较是通过这些运行的平均值得出的。

你需要使用平均值,因为中央处理单元(CPU)不断运行多个进程。操作系统在后台调度这些进程,执行一个进程并推迟另一个,直到某个资源(例如输入/输出)变得可用。因此,记录一个给定函数的 绝对 运行时间是困难的。计算多次运行的平均时间可以弥补这一点。

你可以从书籍的网站下载代码,line_compare.py

line_compare.py
from time import perf_counter
import statistics
import turtle

turtle.setup(1200, 600)
screen = turtle.Screen()

ANGLES = (0, 3.695220532)  # In degrees.
NUM_RUNS = 20
SPEED = 0
for angle in ANGLES:
 ➊ times = []
    for _ in range(NUM_RUNS):
        line = turtle.Turtle()
        line.speed(SPEED)  
        line.hideturtle()
        line.penup()
        line.lt(angle)
        line.setpos(-470, 0)
        line.pendown()
        line.showturtle()
     ➋ start_time = perf_counter()
        line.fd(962)
        end_time = perf_counter()
        times.append(end_time - start_time)

    line_ave = statistics.mean(times)
    print("Angle {} degrees: average time for {} runs at speed {} = {:.5f}"
          .format(angle, NUM_RUNS, SPEED, line_ave))

清单 12-3:绘制直线和角度线并记录每条线的运行时间

从 time 模块中导入 perf_counter——即 性能计数器——开始。此函数返回秒为单位的浮动时间值。它比 time.clock() 更精确,而后者从 Python 3.8 起已被淘汰。

接下来,导入 statistics 模块来帮助你计算多次模拟运行的平均值。然后导入 turtle 并设置 turtle 屏幕。你可以根据自己的显示器自定义屏幕,但记住,你需要能够看到一条 962 像素长的线。

现在,为模拟指定一些关键值。将直线和对角线的角度放入一个名为 ANGLES 的元组中,然后为循环运行次数和绘制线条的速度分配一个变量。

开始循环遍历 ANGLES 元组中的角度。创建一个空列表来存储时间测量值 ➊,然后像之前一样设置海龟对象。将海龟对象左转指定的角度,然后使用 setpos() 将其移动到屏幕的最左侧。

让海龟前进 962 像素,将此命令夹在两次 perf_counter() 调用之间,以便计时 ➋。用结束时间减去开始时间,并将结果附加到 times 列表中。

最后,使用 statistics.mean() 函数来计算每条线的平均运行时间。将结果打印到小数点后五位。程序运行后,海龟屏幕应显示如图 12-5 所示。

图片

图 12-5:完成的 turtle 屏幕,来自 line_compare.py

由于你使用了毕达哥拉斯三元组,角度线确实会在一个像素上结束,而不是简单地跳到最近的像素。因此,你可以确信直线和角度线的长度相同,并且在计时测量时,你是在进行真正的比较。

结果

如果你绘制每条线 500 次并比较结果,你应该会看到绘制角度线所需的时间大约是绘制直线的 2.4 倍。

Angle 0 degrees: average time for 500 runs at speed 0 = 0.06492
Angle 3.695220532 degrees: average time for 500 runs at speed 0 = 0.15691

你的时间可能会略有不同,因为它们会受到你计算机上可能同时运行的其他程序的影响。正如之前提到的,CPU 调度将管理所有这些进程,以确保你的系统快速、高效且公平。

如果你重复进行 1000 次实验,你应该会得到类似的结果。(如果你决定这么做,你可能需要泡一杯咖啡,来点好吃的派。)这条角度线的绘制时间大约是正常速度的 2.7 倍。

Angle 0 degrees: average time for 1000 runs at speed 0 = 0.10911
Angle 3.695220532 degrees: average time for 1000 runs at speed 0 = 0.29681

你正在以高速运行一个简单的函数。如果你担心海龟(turtle)为了提高速度而牺牲精度,你可以减慢速度并重新运行程序。当绘制速度设置为正常(speed = 6)时,角度线的绘制时间大约是最快速度的 2.6 倍,接近最快速度的结果。

Angle 0 degrees: average time for 500 runs at speed 6 = 1.12522
Angle 3.695220532 degrees: average time for 500 runs at speed 6 = 2.90180

很明显,穿越像素网格比沿着网格移动需要更多的工作。

策略

本项目的目标是寻找一种方法,让模拟的存在,也许是我们自己,能够找到模拟的证据。到目前为止,我们至少知道两件事。首先,如果我们生活在一个模拟中,那么网格非常小,因为我们能够观察到亚原子粒子。其次,如果这些小粒子以某个角度穿过模拟的网格,我们应该能发现计算上的阻力,这种阻力可以转化为某种可测量的现象。这个阻力可能表现为能量损失、粒子散射、速度减缓或类似的情况。

2012 年,来自波恩大学的物理学家西拉斯·R·比恩(Silas R. Beane)和来自华盛顿大学的佐赫雷·达沃迪(Zohreh Davoudi)及马丁·J·萨维奇(Martin J. Savage)发表了一篇论文,正是论证了这一点。根据作者的观点,如果看似连续的物理定律被叠加到一个离散的网格上,那么网格的间距可能会对物理过程产生限制。

他们提议通过观察超高能宇宙射线(UHECRs)来进行调查。UHECRs 是宇宙中最快的粒子,随着它们能量的增加,它们会受到越来越小的特征影响。但这些粒子能量有一个限制。这个限制被称为 GZK 截断,并且在 2007 年的实验中得到了证实,这个限制与模拟网格可能产生的边界一致。这样的边界也应该导致 UHECRs 沿网格轴线优先移动,并散射试图穿越网格的粒子。

毫不奇怪,这种方法存在许多潜在的障碍。极高能宇宙射线(UHECRs)非常稀有,而且异常行为可能不易察觉。如果网格的间距小于 10^(-12)飞米,我们可能无法检测到它。甚至可能根本没有网格,至少就我们理解的方式而言,因为使用的技术可能远远超出我们的认知。而且,正如哲学家普雷斯顿·格林(Preston Greene)在 2019 年所指出的,整个项目可能会面临道德上的障碍。如果我们生活在一个模拟中,我们发现它的存在可能会触发模拟的终结!

总结

从编码的角度来看,构建耶特尔的模拟世界其实很简单。但编码的一个重要部分是解决问题,你所做的少量工作却产生了重大影响。我们没有跨越到宇宙射线,但我们启动了正确的对话。计算机模拟需要一个网格来在宇宙中留下可观察的痕迹这一基本前提,超越了琐碎的细节。

在《哈利·波特与死亡圣器》一书中,哈利问巫师邓布利多:“告诉我最后一件事。这是真的吗?还是这只是发生在我的脑海里?”邓布利多回答:“当然,它发生在你的脑袋里,哈利,但为什么这就意味着它不真实呢?”

即便我们的世界不像尼克·博斯特罗姆所假设的那样位于“现实的基本层面”,你仍然可以从解决此类问题的能力中获得乐趣。正如笛卡尔如果今天活着可能会说的:“我编码,因此我存在。”继续前进!

进一步阅读

“我们生活在一个模拟的宇宙中吗?科学家怎么说”(NBC 新闻,2019),作者:丹·福克,文章概述了模拟假说。

“尼尔·德格拉斯·泰森表示‘宇宙很可能是一个模拟’”(ExtremeTech, 2016),作者:格雷厄姆·坦普尔顿,文章中嵌入了由天体物理学家尼尔·德格拉斯·泰森主持的艾萨克·阿西莫夫纪念辩论会的视频,探讨了我们是否生活在模拟中的可能性。

“我们生活在计算机模拟中吗?我们不必揭晓真相”(纽约时报,2019),作者:普雷斯顿·格林,文章提出了反对研究模拟假说的哲学论点。

“我们并没有生活在一个模拟中。可能是吧。”(Fast Company, 2018),作者:格伦·麦克唐纳,文章论证了宇宙太庞大且细节太复杂,无法通过计算机进行模拟。

继续前进

生活中永远没有足够的时间去做我们想做的所有事情,而写书更是如此。接下来的挑战项目代表了那些尚未写就的章节的幽灵。没有时间完成这些(或者在某些情况下,甚至没有时间开始),但也许你会有更好的运气。和往常一样,本书并没有为挑战项目提供解决方案——毕竟,你可能不需要它们。

这是现实世界,宝贝,你已经准备好迎接它了。

挑战项目:寻找一个安全空间

这部获得奖项的 1970 年小说《环世界》向世人介绍了皮尔森的操纵者,一种有感知能力且高度进化的外星草食性生物。作为群居动物,操纵者极其胆小和谨慎。当它们意识到银河系的核心已经爆炸,辐射将在 20,000 年后到达它们时,它们立即开始逃离银河!

在这个项目中,你是 29 世纪外交团队的一员,负责木偶大使的任务。你的工作是选择美国大陆内的一个州,该州对木偶大使馆来说足够安全。你需要筛查每个州的自然灾害,如地震、火山、龙卷风和飓风,并为大使呈现一张总结结果的地图。不要担心你将使用的数据已经过时数百年;只需假装它是 2850 年当前的数据。

你可以在* earthquake.usgs.gov/earthquakes/feed/v1.0/csv.php/* 查找地震数据。使用点来绘制 6.0 或更大规模地震的震中位置。

你可以按每年每个州的平均龙卷风数量发布龙卷风数据(参见 www.ncdc.noaa.gov/climate-information/extreme-events/us-tornado-climatology)。使用像在第十一章中一样的色块格式。

你可以在 2018 年更新的美国地质调查局《国家火山威胁评估》(* pubs.usgs.gov/sir/2018/5140/sir20185140.pdf*)的表 2 中找到危险火山的列表。将这些火山表示为地图上的点,但赋予它们不同于地震数据的颜色或形状。此外,忽略黄石的火山灰降落。假设监控这个超级火山的专家可以预测到火山爆发,足够让大使安全逃离地球。

要查找飓风路径,访问国家海洋和大气管理局网站(* coast.noaa.gov/digitalcoast/data/*)并搜索“历史飓风路径”。下载并在地图上标出 4 级及以上的风暴段。

尝试像木偶师一样思考,并使用最终的合成地图来选择一个适合使馆的候选州。你可能需要忽略一两个龙卷风。美国是一个危险的地方!

挑战项目:太阳来了

2018 年,来自加利福尼亚州伍德赛德的 13 岁少女乔治亚·哈钦森在布罗德康姆大师全国科学、技术、工程和数学(STEM)竞赛中赢得了 25,000 美元。她的项目“设计一个数据驱动的双轴太阳能跟踪器”将通过消除昂贵的光传感器,使太阳能电池板更便宜、更高效。

这个新的太阳跟踪器基于这样一个前提:我们已经知道地球上任意一个点在任何时刻的太阳位置。它利用来自国家海洋和大气管理局的公共数据,持续确定太阳的位置,并调节太阳能电池板的角度以实现最大功率输出。

写一个 Python 程序,计算出基于你选择的位置的太阳位置。要开始,可以查看维基百科页面“太阳的位置” (en.wikipedia.org/wiki/Position_of_the_Sun)。

挑战项目:透过狗的眼睛看世界

运用你对计算机视觉的了解,编写一个 Python 程序,输入一张图片,模拟狗狗会看到的图像。要开始,可以查看 www.akc.org/expert-advice/health/are-dogs-color-blind/dog-vision.andraspeter.com/

挑战项目:定制化单词搜索

哇,奶奶超爱做单词搜索!为了她的生日,使用 Python 设计并打印定制化的单词搜索,包含家庭成员的名字、经典的电视节目比如 MatlockColumbo,或她常用药物的名字。让这些单词横向、纵向以及对角线方式打印出来。

挑战项目:简化庆祝晚宴幻灯片

你的配偶、兄弟姐妹、父母、最好的朋友,或是其他某个庆祝晚宴的主办方,而你负责制作幻灯片。你有很多云端的照片,其中许多照片都是主角的,但文件名仅列出了拍摄的日期和时间,完全无法看出照片的内容。看起来你将花费整个星期六翻找这些照片。

等等,难道你不是在《Real-World Python》这本书中学过人脸识别吗?其实你只需要找到一些训练图像,做点儿编程就行了。

首先,从你个人的数字照片库中挑选一个人作为主角。接下来,编写一个 Python 程序,搜索你的文件夹,找到包含此人的照片,并将这些照片复制到一个专门的文件夹中以供查看。在训练时,确保包括侧面和正面视图,并且在人脸检测时加入侧面 Haar cascade。

挑战项目:我们编织的复杂网络

使用 Python 和 turtle 模块模拟蜘蛛织网。要了解一些织网的指导,可以参考 www.brisbaneinsects.com/brisbane_weavers/index.htmrecursiveprocess.com/mathprojects/index.php/2015/06/09/spider-webs-creepy-or-cool/

挑战项目:山顶上的故事

“休斯顿,德克萨斯州,最近的山是哪座?”这个看似简单的问题,在 Quora 上被提问,却并不容易回答。一方面,你需要考虑墨西哥的山脉,还有美国的山脉。另一方面,山脉的定义并没有一个全球公认的标准。

为了让这稍微容易一些,可以使用联合国环境规划署对山区地形的定义。找到海拔至少为 2,500 米(8,200 英尺)的高地,并将其视为山脉。计算它们到休斯顿市中心的距离,以找到最近的山脉。

第十三章:附录

实践项目解决方案**

图像

本附录包含每章实践项目的解决方案。数字版本可以在本书网站上获取,网址为 nostarch.com/real-world-python/

使用风格学归属作者**

使用色散追踪猎犬

practice_hound_dispersion.py
"""Use NLP (nltk) to make dispersion plot."""
import nltk
import file_loader

corpus = file_loader.text_to_string('hound.txt')
tokens = nltk.word_tokenize(corpus)
tokens = nltk.Text(tokens)  # NLTK wrapper for automatic text analysis.
dispersion = tokens.dispersion_plot(['Holmes',
                                     'Watson',
                                     'Mortimer',
                                     'Henry',
                                     'Barrymore', 
                                     'Stapleton',
                                     'Selden',
                                     'hound'])

标点热图

practice_heatmap_semicolon.py
"""Make a heatmap of punctuation."""
import math
from string import punctuation
import nltk
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
import seaborn as sns

# Install seaborn using: pip install seaborn.

PUNCT_SET = set(punctuation)

def main():  
    # Load text files into dictionary by author.
    strings_by_author = dict()
    strings_by_author['doyle'] = text_to_string('hound.txt')
    strings_by_author['wells'] = text_to_string('war.txt')
    strings_by_author['unknown'] = text_to_string('lost.txt')

    # Tokenize text strings preserving only punctuation marks.
    punct_by_author = make_punct_dict(strings_by_author)

    # Convert punctuation marks to numerical values and plot heatmaps.
    plt.ion()
    for author in punct_by_author:
        heat = convert_punct_to_number(punct_by_author, author)
        arr = np.array((heat[:6561])) # trim to largest size for square array
        arr_reshaped = arr.reshape(int(math.sqrt(len(arr))),
                                   int(math.sqrt(len(arr))))
        fig, ax = plt.subplots(figsize=(7, 7))
        sns.heatmap(arr_reshaped,
                    cmap=ListedColormap(['blue', 'yellow']),
                    square=True,
                    ax=ax)
        ax.set_title('Heatmap Semicolons {}'.format(author))
    plt.show()    

def text_to_string(filename):
    """Read a text file and return a string."""
    with open(filename) as infile:
        return infile.read()

def make_punct_dict(strings_by_author):
    """Return dictionary of tokenized punctuation by corpus by author."""
    punct_by_author = dict()
    for author in strings_by_author:
        tokens = nltk.word_tokenize(strings_by_author[author])
        punct_by_author[author] = ([token for token in tokens
                                    if token in PUNCT_SET])
        print("Number punctuation marks in {} = {}"
              .format(author, len(punct_by_author[author])))
    return punct_by_author  

def convert_punct_to_number(punct_by_author, author):
    """Return list of punctuation marks converted to numerical values."""
    heat_vals = []
    for char in punct_by_author[author]:
        if char == ';':
            value = 1
        else:
            value = 2
        heat_vals.append(value)
    return heat_vals

if __name__ == '__main__':
    main()

使用书籍密码发送超级机密消息**

绘制字符图

practice_barchart.py
"""Plot barchart of characters in text file."""
import sys
import os
import operator
from collections import Counter
import matplotlib.pyplot as plt

def load_file(infile):
    """Read and return text file as string of lowercase characters."""
    with open(infile) as f:
        text = f.read().lower()
    return text
def main():
    infile = 'lost.txt'
    if not os.path.exists(infile):
        print("File {} not found. Terminating.".format(infile),
              file=sys.stderr)
        sys.exit(1)

    text = load_file(infile)

    # Make bar chart of characters in text and their frequency.
    char_freq = Counter(text)
    char_freq_sorted = sorted(char_freq.items(),
                              key=operator.itemgetter(1), reverse=True)
    x, y = zip(*char_freq_sorted)  # * unpacks iterable.
    fig, ax = plt.subplots()
    ax.bar(x, y)
    fig.show()

if __name__ == '__main__':
    main()

以二战方式发送秘密

practice_WWII_words.py
"""Book code using the novel The Lost World

For words not in book, spell-out with first letter of words.
Flag 'first letter mode' by bracketing between alternating
'a a' and 'the the'.

credit: Eric T. Mortenson
"""
import sys
import os
import random
import string
from collections import defaultdict, Counter

def main():
    message = input("Enter plaintext or ciphertext: ") 
    process = input("Enter 'encrypt' or 'decrypt': ")  
    shift = int(input("Shift value (1-365) = "))
    infile = input("Enter filename with extension: ")

    if not os.path.exists(infile):
        print("File {} not found. Terminating.".format(infile), file=sys.stderr)
        sys.exit(1)        
    word_list = load_file(infile)
    word_dict = make_dict(word_list, shift)
    letter_dict = make_letter_dict(word_list)

    if process == 'encrypt':
        ciphertext = encrypt(message, word_dict, letter_dict)          
        count = Counter(ciphertext)        
        encryptedWordList = []
        for number in ciphertext:
            encryptedWordList.append(word_list[number - shift])

        print("\nencrypted word list = \n {} \n"
              .format(' '.join(encryptedWordList)))           
        print("encrypted ciphertext = \n {}\n".format(ciphertext))

        # Check the encryption by decrypting the ciphertext.
        print("decrypted plaintext = ")
        singleFirstCheck = False
        for cnt, i in enumerate(ciphertext):
            if word_list[ciphertext[cnt]-shift] == 'a' and \
               word_list[ciphertext[cnt+1]-shift] == 'a':
                continue
            if word_list[ciphertext[cnt]-shift] == 'a' and \
               word_list[ciphertext[cnt-1]-shift] == 'a':
                singleFirstCheck = True
                continue
            if singleFirstCheck == True and cnt<len(ciphertext)-1 and \
               word_list[ciphertext[cnt]-shift] == 'the' and \
                             word_list[ciphertext[cnt+1]-shift] == 'the':
                continue
            if singleFirstCheck == True and \
               word_list[ciphertext[cnt]-shift] == 'the' and \
                             word_list[ciphertext[cnt-1]-shift] == 'the':
                singleFirstCheck = False
                print(' ', end='', flush=True)
                continue
            if singleFirstCheck == True:
                print(word_list[i - shift][0], end = '', flush=True)
            if singleFirstCheck == False:
                print(word_list[i - shift], end=' ', flush=True)

    elif process == 'decrypt':
        plaintext = decrypt(message, word_list, shift)
        print("\ndecrypted plaintext = \n {}".format(plaintext))

def load_file(infile):
    """Read and return text file as a list of lowercase words."""
    with open(infile, encoding='utf-8') as file:
        words = [word.lower() for line in file for word in line.split()]
        words_no_punct = ["".join(char for char in word if char not in \
                                 string.punctuation) for word in words]
    return words_no_punct

def make_dict(word_list, shift):
    """Return dictionary of characters as keys and shifted indexes as values."""
    word_dict = defaultdict(list)
    for index, word in enumerate(word_list):
        word_dict[word].append(index + shift)
    return word_dict

def make_letter_dict(word_list):
    firstLetterDict = defaultdict(list)
    for word in word_list:
        if len(word) > 0:
            if word[0].isalpha():
                firstLetterDict[word[0]].append(word)
    return firstLetterDict

def encrypt(message, word_dict, letter_dict):
    """Return list of indexes representing characters in a message."""
    encrypted = []
    # remove punctuation from message words
    messageWords = message.lower().split()
    messageWordsNoPunct = ["".join(char for char in word if char not in \
                                 string.punctuation) for word in messageWords]    
    for word in messageWordsNoPunct:
        if len(word_dict[word]) > 1:
            index = random.choice(word_dict[word])
        elif len(word_dict[word]) == 1:  # Random.choice fails if only 1 choice.
            index = word_dict[word][0]
        elif len(word_dict[word]) == 0:  # Word not in word_dict.
            encrypted.append(random.choice(word_dict['a']))
            encrypted.append(random.choice(word_dict['a']))

            for letter in word:
                if letter not in letter_dict.keys():
                    print('\nLetter {} not in letter-to-word dictionary.'
                          .format(letter), file=sys.stderr)
                    continue
                if len(letter_dict[letter])>1:
                    newWord =random.choice(letter_dict[letter])
                else:
                    newWord = letter_dict[letter][0]
                if len(word_dict[newWord])>1:
                    index = random.choice(word_dict[newWord])
                else:
                    index = word_dict[newWord][0]
                encrypted.append(index)

            encrypted.append(random.choice(word_dict['the']))
            encrypted.append(random.choice(word_dict['the']))
            continue
        encrypted.append(index)
    return encrypted

def decrypt(message, word_list, shift):
    """Decrypt ciphertext string and return plaintext word string.
    This shows how plaintext looks before extracting first letters.
    """
    plaintextList = []
    indexes = [s.replace(',', '').replace('[', '').replace(']', '')
               for s in message.split()]
    for count, i in enumerate(indexes):
        plaintextList.append(word_list[int(i) - shift])
    return ' '.join(plaintextList)

def check_for_fail(ciphertext):
    """Return True if ciphertext contains any duplicate keys."""
    check = [k for k, v in Counter(ciphertext).items() if v > 1]
    if len(check) > 0:
        print(check)
        return True

if __name__ == '__main__':
    main()

寻找冥王星**

绘制轨道路径

practice_orbital_path.py
import os
from pathlib import Path
import cv2 as cv

PAD = 5  # Ignore pixels this distance from edge

def find_transient(image, diff_image, pad):
    """Takes image, difference image, and pad value in pixels and returns
       boolean and location of maxVal in difference image excluding an edge
       rind. Draws circle around maxVal on image."""
    transient = False
    height, width = diff_image.shape
    cv.rectangle(image, (PAD, PAD), (width - PAD, height - PAD), 255, 1)
    minVal, maxVal, minLoc, maxLoc = cv.minMaxLoc(diff_image)
    if pad < maxLoc[0] < width - pad and pad < maxLoc[1] < height - pad:
        cv.circle(image, maxLoc, 10, 255, 0)
        transient = True
    return transient, maxLoc

def main():
    night1_files = sorted(os.listdir('night_1_registered_transients'))
    night2_files = sorted(os.listdir('night_2'))             
    path1 = Path.cwd() / 'night_1_registered_transients'
    path2 = Path.cwd() / 'night_2'
    path3 = Path.cwd() / 'night_1_2_transients'

    # Images should all be the same size and similar exposures.    
    for i, _ in enumerate(night1_files[:-1]):  # Leave off negative image   
        img1 = cv.imread(str(path1 / night1_files[i]), cv.IMREAD_GRAYSCALE)
        img2 = cv.imread(str(path2 / night2_files[i]), cv.IMREAD_GRAYSCALE)
        # Get absolute difference between images.
        diff_imgs1_2 = cv.absdiff(img1, img2)
        cv.imshow('Difference', diff_imgs1_2)
        cv.waitKey(2000)        

        # Copy difference image and find and circle brightest pixel.
        temp = diff_imgs1_2.copy()
        transient1, transient_loc1 = find_transient(img1, temp, PAD)

        # Draw black circle on temporary image to obliterate brightest spot.
        cv.circle(temp, transient_loc1, 10, 0, -1)

        # Get location of new brightest pixel and circle it on input image.        
        transient2, transient_loc2 = find_transient(img1, temp, PAD)

        if transient1 or transient2:
            print('\nTRANSIENT DETECTED between {} and {}\n'
                  .format(night1_files[i], night2_files[i]))
            font = cv.FONT_HERSHEY_COMPLEX_SMALL
            cv.putText(img1, night1_files[i], (10, 25),
                       font, 1, (255, 255, 255), 1, cv.LINE_AA)
            cv.putText(img1, night2_files[i], (10, 55),
                       font, 1, (255, 255, 255), 1, cv.LINE_AA)
            if transient1 and transient2:
                cv.line(img1, transient_loc1, transient_loc2, (255, 255, 255),
                        1, lineType=cv.LINE_AA)

            blended = cv.addWeighted(img1, 1, diff_imgs1_2, 1, 0)
            cv.imshow('Surveyed', blended)
            cv.waitKey(2500)  # Keeps window open 2.5 seconds.
            out_filename = '{}_DECTECTED.png'.format(night1_files[i][:-4])
            cv.imwrite(str(path3 / out_filename), blended)  # Will overwrite!
        else:
            print('\nNo transient detected between {} and {}\n'
                  .format(night1_files[i], night2_files[i]))

if __name__ == '__main__':
    main()

有什么不同?

本实践项目使用两个程序,practice_montage_aligner.pypractice_montage_difference_finder.py,程序应按顺序运行。

practice_montage_aligner.py
practice_montage_aligner.py
import numpy as np
import cv2 as cv

MIN_NUM_KEYPOINT_MATCHES = 150

img1 = cv.imread('montage_left.JPG', cv.IMREAD_COLOR)  # queryImage
img2 = cv.imread('montage_right.JPG', cv.IMREAD_COLOR) # trainImage
img1 = cv.cvtColor(img1, cv.COLOR_BGR2GRAY)  # Convert to grayscale.
img2 = cv.cvtColor(img2, cv.COLOR_BGR2GRAY)

orb = cv.ORB_create(nfeatures=700) 

# Find the keypoints and descriptions with ORB.
kp1, desc1 = orb.detectAndCompute(img1, None)
kp2, desc2 = orb.detectAndCompute(img2, None)

# Find keypoint matches using Brute Force Matcher.
bf = cv.BFMatcher(cv.NORM_HAMMING, crossCheck=True)
matches = bf.match(desc1, desc2, None)

# Sort matches in ascending order of distance.
matches = sorted(matches, key=lambda x: x.distance)

# Draw best matches.
img3 = cv.drawMatches(img1, kp1, img2, kp2, 
                       matches[:MIN_NUM_KEYPOINT_MATCHES],
                       None)

cv.namedWindow('Matches', cv.WINDOW_NORMAL)
img3_resize = cv.resize(img3, (699, 700))
cv.imshow('Matches', img3_resize)
cv.waitKey(7000)  # Keeps window open 7 seconds.
cv.destroyWindow('Matches')

# Keep only best matches.
best_matches = matches[:MIN_NUM_KEYPOINT_MATCHES]

if len(best_matches) >= MIN_NUM_KEYPOINT_MATCHES:
    src_pts = np.zeros((len(best_matches), 2), dtype=np.float32)
    dst_pts = np.zeros((len(best_matches), 2), dtype=np.float32)

    for i, match in enumerate(best_matches):
        src_pts[i, :] = kp1[match.queryIdx].pt
        dst_pts[i, :] = kp2[match.trainIdx].pt

    M, mask = cv.findHomography(src_pts, dst_pts, cv.RANSAC)

    # Get dimensions of image 2.
    height, width = img2.shape
    img1_warped = cv.warpPerspective(img1, M, (width, height))

    cv.imwrite('montage_left_registered.JPG', img1_warped)
    cv.imwrite('montage_right_gray.JPG', img2)

else:
    print("\n{}\n".format('WARNING: Number of keypoint matches < 10!'))
practice_montage_difference_finder.py
practice_montage_difference_finder.py
import cv2 as cv

filename1 = 'montage_left.JPG'
filename2 = 'montage_right_gray.JPG'

img1 = cv.imread(filename1, cv.IMREAD_GRAYSCALE)
img2 = cv.imread(filename2, cv.IMREAD_GRAYSCALE)

# Absolute difference between image 2 & 3:
diff_imgs1_2 = cv.absdiff(img1, img2)

cv.namedWindow('Difference', cv.WINDOW_NORMAL)
diff_imgs1_2_resize = cv.resize(diff_imgs1_2, (699, 700))
cv.imshow('Difference', diff_imgs1_2_resize)

crop_diff = diff_imgs1_2[10:2795, 10:2445]  # x, y, w, h = 10, 10, 2790, 2440

# Blur to remove extraneous noise.
blurred = cv.GaussianBlur(crop_diff, (5, 5), 0)

(minVal, maxVal, minLoc, maxLoc2) = cv.minMaxLoc(blurred)
cv.circle(img2, maxLoc2, 100, 0, 3)
x, y = int(img2.shape[1]/4), int(img2.shape[0]/4)
img2_resize = cv.resize(img2, (x, y))
cv.imshow('Change', img2_resize)

通过阿波罗 8 号赢得登月竞赛**

模拟搜索模式

practice_search_pattern.py
import time
import random
import turtle

SA_X = 600  # Search area width.
SA_Y = 480  # Search area height.
TRACK_SPACING = 40  # Distance between search tracks.

# Setup screen. 
screen = turtle.Screen()
screen.setup(width=SA_X, height=SA_Y)
turtle.resizemode('user')
screen.title("Search Pattern")
rand_x = random.randint(0, int(SA_X / 2)) * random.choice([-1, 1])
rand_y = random.randint(0, int(SA_Y / 2)) * random.choice([-1, 1])

# Set up turtle images.
seaman_image = 'seaman.gif'
screen.addshape(seaman_image)
copter_image_left = 'helicopter_left.gif'
copter_image_right = 'helicopter_right.gif'
screen.addshape(copter_image_left)
screen.addshape(copter_image_right)

# Instantiate seaman turtle.
seaman = turtle.Turtle(seaman_image)
seaman.hideturtle()
seaman.penup()
seaman.setpos(rand_x, rand_y)
seaman.showturtle()

# Instantiate copter turtle.
turtle.shape(copter_image_right)
turtle.hideturtle()
turtle.pencolor('black')
turtle.penup()
turtle.setpos(-(int(SA_X / 2) - TRACK_SPACING), int(SA_Y / 2) - TRACK_SPACING)
turtle.showturtle()
turtle.pendown()

# Run search pattern and announce discovery of seaman.
for i in range(int(SA_Y / TRACK_SPACING)):     
    turtle.fd(SA_X - TRACK_SPACING * 2)
    turtle.rt(90)
    turtle.fd(TRACK_SPACING / 2)
    turtle.rt(90)
    turtle.shape(copter_image_left)
    turtle.fd(SA_X - TRACK_SPACING * 2)
    turtle.lt(90)
    turtle.fd(TRACK_SPACING / 2)
    turtle.lt(90)
    turtle.shape(copter_image_right)
    if turtle.ycor() - seaman.ycor() <= 10:
        turtle.write("      Seaman found!",
                     align='left',
                     font=("Arial", 15, 'normal', 'bold', 'italic'))
        time.sleep(3)

        break

启动我!

practice_grav _assist_stationary.py
"""gravity_assist_stationary.py

Moon approaches stationary ship, which is swung around and flung away.

Credit: Eric T. Mortenson
"""
from turtle import Shape, Screen, Turtle, Vec2D as Vec
import turtle
import math

# User input:
G = 8  # Gravitational constant used for the simulation.
NUM_LOOPS = 4100  # Number of time steps in simulation.
Ro_X = 0  # Ship starting position x coordinate.
Ro_Y = -50  # Ship starting position y coordinate.
Vo_X = 0  # Ship velocity x component.
Vo_Y = 0  # Ship velocity y component.

MOON_MASS = 1_250_000

class GravSys():
    """Runs a gravity simulation on n-bodies."""

    def __init__(self):
        self.bodies = []
        self.t = 0
        self.dt = 0.001

    def sim_loop(self):
        """Loop bodies in a list through time steps."""
        for _ in range(NUM_LOOPS):
            self.t += self.dt
            for body in self.bodies:
                body.step()

class Body(Turtle):
    """Celestial object that orbits and projects gravity field."""
    def __init__(self, mass, start_loc, vel, gravsys, shape):
        super().__init__(shape=shape)
        self.gravsys = gravsys
        self.penup()
        self.mass=mass
        self.setpos(start_loc)
        self.vel = vel
        gravsys.bodies.append(self)
        self.pendown()  # uncomment to draw path behind object

    def acc(self):
        """Calculate combined force on body and return vector components."""
        a = Vec(0,0)
        for body in self.gravsys.bodies:
            if body != self:
                r = body.pos() - self.pos()
                a += (G * body.mass / abs(r)**3) * r  # units dist/time²
        return a
    def step(self):
        """Calculate position, orientation, and velocity of a body."""
        dt = self.gravsys.dt
        a = self.acc()
        self.vel = self.vel + dt * a
        xOld, yOld = self.pos()  # for orienting ship
        self.setpos(self.pos() + dt * self.vel)
        xNew, yNew = self.pos()  # for orienting ship
        if self.gravsys.bodies.index(self) == 1: # the CSM
            dir_radians = math.atan2(yNew-yOld,xNew-xOld)  # for orienting ship
            dir_degrees = dir_radians * 180 / math.pi  # for orienting ship
            self.setheading(dir_degrees+90)  # for orienting ship

def main():
    # Setup screen
    screen = Screen()
    screen.setup(width=1.0, height=1.0)  # for fullscreen
    screen.bgcolor('black')
    screen.title("Gravity Assist Example")

    # Instantiate gravitational system
    gravsys = GravSys()

    # Instantiate Planet
    image_moon = 'moon_27x27.gif'
    screen.register_shape(image_moon)
    moon = Body(MOON_MASS, (500, 0), Vec(-500, 0), gravsys, image_moon)
    moon.pencolor('gray')

    # Build command-service-module (csm) shape
    csm = Shape('compound')
    cm = ((0, 30), (0, -30), (30, 0))
    csm.addcomponent(cm, 'red', 'red')
    sm = ((-60,30), (0, 30), (0, -30), (-60, -30))
    csm.addcomponent(sm, 'red', 'black')
    nozzle = ((-55, 0), (-90, 20), (-90, -20))
    csm.addcomponent(nozzle, 'red', 'red')
    screen.register_shape('csm', csm)

    # Instantiate Apollo 8 CSM turtle
    ship = Body(1, (Ro_X, Ro_Y), Vec(Vo_X, Vo_Y), gravsys, "csm")
    ship.shapesize(0.2)
    ship.color('red') # path color
    ship.getscreen().tracer(1, 0)
    ship.setheading(90)

    gravsys.sim_loop()

if __name__=='__main__':
    main()

关闭我!

practice_grav_assist_intersecting.py
"""gravity_assist_intersecting.py

Moon and ship cross orbits and moon slows and turns ship.

Credit: Eric T. Mortenson
"""

from turtle import Shape, Screen, Turtle, Vec2D as Vec
import turtle
import math
import sys

# User input:
G = 8  # Gravitational constant used for the simulation.
NUM_LOOPS = 7000  # Number of time steps in simulation.
Ro_X = -152.18  # Ship starting position x coordinate.
Ro_Y = 329.87  # Ship starting position y coordinate.
Vo_X = 423.10  # Ship translunar injection velocity x component.
Vo_Y = -512.26  # Ship translunar injection velocity y component.

MOON_MASS = 1_250_000

class GravSys():
    """Runs a gravity simulation on n-bodies."""

    def __init__(self):
        self.bodies = []
        self.t = 0
        self.dt = 0.001

    def sim_loop(self):
        """Loop bodies in a list through time steps."""
        for index in range(NUM_LOOPS): # stops simulation after while 
            self.t += self.dt
            for body in self.bodies:
                body.step()

class Body(Turtle):
    """Celestial object that orbits and projects gravity field."""
    def __init__(self, mass, start_loc, vel, gravsys, shape):
        super().__init__(shape=shape)
        self.gravsys = gravsys
        self.penup()
        self.mass=mass
        self.setpos(start_loc)
        self.vel = vel
        gravsys.bodies.append(self)
        self.pendown()  # uncomment to draw path behind object

    def acc(self):
        """Calculate combined force on body and return vector components."""
        a = Vec(0,0)
        for body in self.gravsys.bodies:
            if body != self:
                r = body.pos() - self.pos()
                a += (G * body.mass / abs(r)**3) * r  # units dist/time²
        return a

    def step(self):
        """Calculate position, orientation, and velocity of a body."""
        dt = self.gravsys.dt
        a = self.acc()
        self.vel = self.vel + dt * a
        xOld, yOld = self.pos()  # for orienting ship
        self.setpos(self.pos() + dt * self.vel)
        xNew, yNew = self.pos()  # for orienting ship
        if self.gravsys.bodies.index(self) == 1:  # the CSM
            dir_radians = math.atan2(yNew-yOld,xNew-xOld)  # for orienting ship
            dir_degrees = dir_radians * 180 / math.pi  # for orienting ship
            self.setheading(dir_degrees+90)  # for orienting ship

def main():
    # Setup screen
    screen = Screen()
    screen.setup(width=1.0, height=1.0)  # for fullscreen
    screen.bgcolor('black')
    screen.title("Gravity Assist Example")
    # Instantiate gravitational system
    gravsys = GravSys()

    # Instantiate Planet
    image_moon = 'moon_27x27.gif'
    screen.register_shape(image_moon)
    moon = Body(MOON_MASS, (-250, 0), Vec(500, 0), gravsys, image_moon)
    moon.pencolor('gray')

    # Build command-service-module (csm) shape
    csm = Shape('compound')
    cm = ((0, 30), (0, -30), (30, 0))
    csm.addcomponent(cm, 'red', 'red')
    sm = ((-60,30), (0, 30), (0, -30), (-60, -30))
    csm.addcomponent(sm, 'red', 'black')
    nozzle = ((-55, 0), (-90, 20), (-90, -20))
    csm.addcomponent(nozzle, 'red', 'red')
    screen.register_shape('csm', csm)

    # Instantiate Apollo 8 CSM turtle
    ship = Body(1, (Ro_X, Ro_Y), Vec(Vo_X, Vo_Y), gravsys, "csm")
    ship.shapesize(0.2)
    ship.color('red')  # path color
    ship.getscreen().tracer(1, 0)
    ship.setheading(90)
    gravsys.sim_loop()

if __name__=='__main__':
    main()

选择火星着陆地点**

确认绘图已成为图像的一部分

practice_confirm_drawing_part_of_image.py
"""Test that drawings become part of an image in OpenCV."""
import numpy as np
import cv2 as cv

IMG = cv.imread('mola_1024x501.png', cv.IMREAD_GRAYSCALE)

ul_x, ul_y = 0, 167
lr_x, lr_y = 32, 183
rect_img = IMG[ul_y : lr_y, ul_x : lr_x]

def run_stats(image):
    """Run stats on a numpy array made from an image."""
    print('mean = {}'.format(np.mean(image)))
    print('std = {}'.format(np.std(image)))
    print('ptp = {}'.format(np.ptp(image)))
    print()
    cv.imshow('img', IMG)
    cv.waitKey(1000)    

# Stats with no drawing on screen:
print("No drawing")
run_stats(rect_img)

# Stats with white rectangle outline:
print("White outlined rectangle")
cv.rectangle(IMG, (ul_x, ul_y), (lr_x, lr_y), (255, 0, 0), 1)
run_stats(rect_img)

# Stats with rectangle filled with white:
print("White-filled rectangle")
cv.rectangle(IMG, (ul_x, ul_y), (lr_x, lr_y), (255, 0, 0), -1)
run_stats(rect_img)

提取高程剖面

practice_profile_olympus.py
"""West-East elevation profile through Olympus Mons."""
from PIL import Image, ImageDraw
from matplotlib import pyplot as plt

# Load image and get x and z values along horiz profile parallel to y _coord.
y_coord = 202
im = Image.open('mola_1024x512_200mp.jpg').convert('L')
width, height = im.size
x_vals = [x for x in range(width)]
z_vals = [im.getpixel((x, y_coord)) for x in x_vals]

# Draw profile on MOLA image.
draw = ImageDraw.Draw(im)
draw.line((0, y_coord, width, y_coord), fill=255, width=3)
draw.text((100, 165), 'Olympus Mons', fill=255)
im.show()    

# Make profile plot.
fig, ax = plt.subplots(figsize=(9, 4))
axes = plt.gca()
axes.set_ylim(0, 400)
ax.plot(x_vals, z_vals, color='black')
ax.set(xlabel='x-coordinate',
       ylabel='Intensity (height)',
       title="Mars Elevation Profile (y = 202)")
ratio = 0.15  # Reduces vertical exaggeration in profile.
xleft, xright = ax.get_xlim()
ybase, ytop = ax.get_ylim()
ax.set_aspect(abs((xright-xleft)/(ybase-ytop)) * ratio)
plt.text(0, 310, 'WEST', fontsize=10)
plt.text(980, 310, 'EAST', fontsize=10)
plt.text(100, 280, 'Olympus Mons', fontsize=8)
##ax.grid()
plt.show()

绘制 3D 图

practice_3d_plotting.py
"""Plot Mars MOLA map image in 3D.  Credit Eric T. Mortenson."""
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt
from mpl_toolkits import mplot3d

IMG_GRAY = cv.imread('mola_1024x512_200mp.jpg', cv.IMREAD_GRAYSCALE)

x = np.linspace(1023, 0, 1024)
y = np.linspace(0, 511, 512)

X, Y = np.meshgrid(x, y)
Z = IMG_GRAY[0:512, 0:1024]

fig = plt.figure()
ax = plt.axes(projection='3d')
ax.contour3D(X, Y, Z, 150, cmap='gist_earth')  # 150=number of contours
ax.auto_scale_xyz([1023, 0], [0, 511], [0, 500])
plt.show()

混合地图

本实践项目使用两个程序,practice_geo_map_step_1of2.pypractice_geo_map_step_2of2.py,必须按顺序运行。

practice_geo_map_step_1of2.py
practice_geo_map_step_1of2.py
"""Threshold a grayscale image using pixel values and save to file."""
import cv2 as cv

IMG_GEO = cv.imread('Mars_Global_Geology_Mariner9_1024.jpg', 
                     cv.IMREAD_GRAYSCALE)
cv.imshow('map', IMG_GEO)
cv.waitKey(1000)
img_copy = IMG_GEO.copy()
lower_limit = 170  # Lowest grayscale value for volcanic deposits
upper_limit = 185  # Highest grayscale value for volcanic deposits

# Using 1024 x 512 image
for x in range(1024):
    for y in range(512):
        if lower_limit <= img_copy[y, x] <= upper_limit:
            img_copy[y, x] = 1  # Set to 255 to visualize results.
        else:
            img_copy[y, x] = 0

cv.imwrite('geo_thresh.jpg', img_copy)
cv.imshow('thresh', img_copy)
cv.waitKey(0)
practice_geo_map_step_2of2.py
practice_geo_map_step_2of2.py
"""Select Martian landing sites based on surface smoothness and geology."""
import tkinter as tk
from PIL import Image, ImageTk
import numpy as np
import cv2 as cv

# CONSTANTS: User Input:
IMG_GRAY = cv.imread('mola_1024x512_200mp.jpg', cv.IMREAD_GRAYSCALE)
IMG_GEO = cv.imread('geo_thresh.jpg', cv.IMREAD_GRAYSCALE)
IMG_COLOR = cv.imread('mola_color_1024x506.png')
RECT_WIDTH_KM = 670  # Site rectangle width in kilometers.
RECT_HT_KM = 335  # Site rectangle height in kilometers.
MIN_ELEV_LIMIT = 60  # Intensity values (0-255).
MAX_ELEV_LIMIT = 255
NUM_CANDIDATES = 20  # Number of candidate landing sites to display.

#------------------------------------------------------------------------------
# CONSTANTS: Derived and fixed:
IMG_GRAY_GEO = IMG_GRAY * IMG_GEO
IMG_HT, IMG_WIDTH = IMG_GRAY.shape
MARS_CIRCUM = 21344  # Circumference in kilometers.
PIXELS_PER_KM = IMG_WIDTH / MARS_CIRCUM
RECT_WIDTH = int(PIXELS_PER_KM * RECT_WIDTH_KM)
RECT_HT = int(PIXELS_PER_KM * RECT_HT_KM)
LAT_30_N = int(IMG_HT / 3)
LAT_30_S = LAT_30_N * 2
STEP_X = int(RECT_WIDTH / 2)  # Dividing by 4 yields more rect choices
STEP_Y = int(RECT_HT / 2)  # Dividing by 4 yields more rect choices

# Create tkinter screen and drawing canvas
screen = tk.Tk()
canvas = tk.Canvas(screen, width=IMG_WIDTH, height=IMG_HT + 130)

class Search():
    """Read image and identify landing sites based on input criteria."""   

    def __init__(self, name):
        self.name = name
        self.rect_coords = {}
        self.rect_means = {}
        self.rect_ptps = {}
        self.rect_stds = {}
        self.ptp_filtered = []
        self.std_filtered = []
        self.high_graded_rects = []

    def run_rect_stats(self):
        """Define rectangular search areas and calculate internal stats."""
        ul_x, ul_y = 0, LAT_30_N 
        lr_x, lr_y = RECT_WIDTH, LAT_30_N + RECT_HT
        rect_num = 1

        while True:
            rect_img = IMG_GRAY_GEO[ul_y : lr_y, ul_x : lr_x]
            self.rect_coords[rect_num] = [ul_x, ul_y, lr_x, lr_y]
            if MAX_ELEV_LIMIT >= np.mean(rect_img) >= MIN_ELEV_LIMIT:
                self.rect_means[rect_num] = np.mean(rect_img)
                self.rect_ptps[rect_num] = np.ptp(rect_img)
                self.rect_stds[rect_num] = np.std(rect_img)
            rect_num += 1

           # Move the rectangle.
            ul_x += STEP_X
            lr_x = ul_x + RECT_WIDTH
            if lr_x > IMG_WIDTH:
                ul_x = 0
                ul_y += STEP_Y
                lr_x = RECT_WIDTH
                lr_y += STEP_Y
            if lr_y > LAT_30_S + STEP_Y:
                break

    def draw_qc_rects(self):
        """Draw overlapping search rectangles on image as a check."""
        img_copy = IMG_GRAY_GEO.copy()
        rects_sorted = sorted(self.rect_coords.items(), key=lambda x: x[0])
        print("\nRect Number and Corner Coordinates (ul_x, ul_y, lr_x, lr_y):")
        for k, v in rects_sorted:
            print("rect: {}, coords: {}".format(k, v))
            cv.rectangle(img_copy,
                         (self.rect_coords[k][0], self.rect_coords[k][1]),
                         (self.rect_coords[k][2], self.rect_coords[k][3]),
                         (255, 0, 0), 1)
        cv.imshow('QC Rects {}'.format(self.name), img_copy)
        cv.waitKey(3000)
        cv.destroyAllWindows()        

    def sort_stats(self):  
        """Sort dictionaries by values and create lists of top N keys."""
        ptp_sorted = (sorted(self.rect_ptps.items(), key=lambda x: x[1]))
        self.ptp_filtered = [x[0] for x in ptp_sorted[:NUM_CANDIDATES]]
        std_sorted = (sorted(self.rect_stds.items(), key=lambda x: x[1]))
        self.std_filtered = [x[0] for x in std_sorted[:NUM_CANDIDATES]]

        # Make list of rects where filtered std & ptp coincide.
        for rect in self.std_filtered:
            if rect in self.ptp_filtered:
                self.high_graded_rects.append(rect)   

    def draw_filtered_rects(self, image, filtered_rect_list):
        """Draw rectangles in list on image and return image."""
        img_copy = image.copy()
        for k in filtered_rect_list: 
            cv.rectangle(img_copy,
                         (self.rect_coords[k][0], self.rect_coords[k][1]),
                         (self.rect_coords[k][2], self.rect_coords[k][3]),
                         (255, 0, 0), 1)
            cv.putText(img_copy, str(k),
                       (self.rect_coords[k][0] + 1, self.rect_coords[k][3]- 1),
                       cv.FONT_HERSHEY_PLAIN, 0.65, (255, 0, 0), 1)

        # Draw latitude limits.
        cv.putText(img_copy, '30 N', (10, LAT_30_N - 7),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.line(img_copy, (0, LAT_30_N), (IMG_WIDTH, LAT_30_N),
                (255, 0, 0), 1)
        cv.line(img_copy, (0, LAT_30_S), (IMG_WIDTH, LAT_30_S),
                (255, 0, 0), 1)
        cv.putText(img_copy, '30 S', (10, LAT_30_S + 16),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)

        return img_copy

    def make_final_display(self):
        """Use Tk to show map of final rects & printout of their statistics."""
        screen.title('Sites by MOLA Gray STD & PTP {} Rect'.format(self.name))
        # Draw the high-graded rects on the colored elevation map.        
        img_color_rects = self.draw_filtered_rects(IMG_COLOR,
                                                   self.high_graded_rects)
        # Convert image from CV BGR to RGB for use with Tkinter.
        img_converted = cv.cvtColor(img_color_rects, cv.COLOR_BGR2RGB)
        img_converted = ImageTk.PhotoImage(Image.fromarray(img_converted))    
        canvas.create_image(0, 0, image=img_converted, anchor=tk.NW)
        # Add stats for each rectangle at bottom of canvas.
        txt_x = 5
        txt_y = IMG_HT + 15
        for k in self.high_graded_rects:
            canvas.create_text(txt_x, txt_y, anchor='w', font=None,
                               text=
                               "rect={}  mean elev={:.1f}  std={:.2f}  ptp={}"
                               .format(k, self.rect_means[k], 
                                       self.rect_stds[k],
                                       self.rect_ptps[k]))
            txt_y += 15
            if txt_y >= int(canvas.cget('height')) - 10:
                txt_x += 300
                txt_y = IMG_HT + 15                
        canvas.pack()
        screen.mainloop()

def main():
    app = Search('670x335 km')
    app.run_rect_stats()
    app.draw_qc_rects()
    app.sort_stats()
    ptp_img = app.draw_filtered_rects(IMG_GRAY_GEO, app.ptp_filtered)
    std_img = app.draw_filtered_rects(IMG_GRAY_GEO, app.std_filtered)
    # Display filtered rects on grayscale map.
    cv.imshow('Sorted by ptp for {} rect'.format(app.name), ptp_img)
    cv.waitKey(3000)
    cv.imshow('Sorted by std for {} rect'.format(app.name), std_img)
    cv.waitKey(3000)

    app.make_final_display()  # includes call to mainloop()

if __name__ == '__main__':
    main()

探测遥远的外行星**

探测外星巨型结构

practice_tabbys_star.py
"""Simulate transit of alien array and plot light curve."""
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt

IMG_HT = 400
IMG_WIDTH = 500
BLACK_IMG = np.zeros((IMG_HT, IMG_WIDTH), dtype='uint8')
STAR_RADIUS = 165
EXO_START_X = -250
EXO_START_Y = 150 
EXO_DX = 3
NUM_FRAMES = 500

def main():
    intensity_samples = record_transit(EXO_START_X, EXO_START_Y)
    rel_brightness = calc_rel_brightness(intensity_samples)
    plot_light_curve(rel_brightness)

def record_transit(exo_x, exo_y):
    """Draw array transiting star and return list of intensity changes."""
    intensity_samples = []
    for _ in range(NUM_FRAMES):
        temp_img = BLACK_IMG.copy()
        # Draw star:
        cv.circle(temp_img, (int(IMG_WIDTH / 2), int(IMG_HT / 2)),
                  STAR_RADIUS, 255, -1)
        # Draw alien array:
        cv.rectangle(temp_img, (exo_x, exo_y),
                     (exo_x + 20, exo_y + 140), 0, -1)
        cv.rectangle(temp_img, (exo_x - 360, exo_y),
                     (exo_x + 10, exo_y + 140), 0, 5)
        cv.rectangle(temp_img, (exo_x - 380, exo_y),
                     (exo_x - 310, exo_y + 140), 0, -1)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity), (5, 390),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        cv.waitKey(10)
        intensity_samples.append(intensity)
        exo_x += EXO_DX
    return intensity_samples

def calc_rel_brightness(intensity_samples):
    """Return list of relative brightness from list of intensity values."""
    rel_brightness = []
    max_brightness = max(intensity_samples)
    for intensity in intensity_samples:
        rel_brightness.append(intensity / max_brightness)
    return rel_brightness

def plot_light_curve(rel_brightness):
    """Plot changes in relative brightness vs. time."""
    plt.plot(rel_brightness, color='red', linestyle='dashed',
             linewidth=2)
    plt.title('Relative Brightness vs. Time')
    plt.xlim(-150, 500)
    plt.show()

if __name__ == '__main__':
    main()

探测小行星掠过

practice _asteroids.py
"""Simulate transit of asteroids and plot light curve."""
import random
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt

STAR_RADIUS = 165
BLACK_IMG = np.zeros((400, 500, 1), dtype="uint8")
NUM_ASTEROIDS = 15
NUM_LOOPS = 170

class Asteroid():
    """Draws a circle on an image that represents an asteroid."""

    def __init__(self, number):
        self.radius = random.choice((1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 3))
        self.x = random.randint(-30, 60)
        self.y = random.randint(220, 230)
        self.dx = 3  

    def move_asteroid(self, image):
        """Draw and move asteroid object."""
        cv.circle(image, (self.x, self.y), self.radius, 0, -1)
        self.x += self.dx

def record_transit(start_image):
    """Simulate transit of asteroids over star and return intensity list."""
    asteroid_list = []
    intensity_samples = []

    for i in range(NUM_ASTEROIDS):
        asteroid_list.append(Asteroid(i))

    for _ in range(NUM_LOOPS):
        temp_img = start_image.copy()
        # Draw star.  
        cv.circle(temp_img, (250, 200), STAR_RADIUS, 255, -1)
        for ast in asteroid_list:
            ast.move_asteroid(temp_img)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity),
                   (5, 390), cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        intensity_samples.append(intensity)
        cv.waitKey(50)
    cv.destroyAllWindows()
    return intensity_samples

def calc_rel_brightness(image):
    """Calculate and return list of relative brightness samples."""
    rel_brightness = record_transit(image)
    max_brightness = max(rel_brightness)
    for i, j in enumerate(rel_brightness):
        rel_brightness[i] = j / max_brightness
    return rel_brightness

def plot_light_curve(rel_brightness):
    "Plot light curve from relative brightness list."""
    plt.plot(rel_brightness, color='red', linestyle='dashed',
             linewidth=2, label='Relative Brightness')
    plt.legend(loc='upper center')
    plt.title('Relative Brightness vs. Time')
    plt.show()

relative_brightness = calc_rel_brightness(BLACK_IMG)
plot_light_curve(relative_brightness)

加入天顶暗化效应

practice_limb_darkening.py
"""Simulate transit of exoplanet, plot light curve, estimate planet radius."""
import cv2 as cv
import matplotlib.pyplot as plt

IMG_HT = 400
IMG_WIDTH = 500
BLACK_IMG = cv.imread('limb_darkening.png', cv.IMREAD_GRAYSCALE)
EXO_RADIUS = 7
EXO_START_X = 40
EXO_START_Y = 230 
EXO_DX = 3
NUM_FRAMES = 145

def main():
    intensity_samples = record_transit(EXO_START_X, EXO_START_Y)
    relative_brightness = calc_rel_brightness(intensity_samples)
    plot_light_curve(relative_brightness)

def record_transit(exo_x, exo_y):
    """Draw planet transiting star and return list of intensity changes."""
    intensity_samples = []
    for _ in range(NUM_FRAMES):
        temp_img = BLACK_IMG.copy()
        # Draw exoplanet:
        cv.circle(temp_img, (exo_x, exo_y), EXO_RADIUS, 0, -1)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity), (5, 390),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        cv.waitKey(30)
        intensity_samples.append(intensity)
        exo_x += EXO_DX
    return intensity_samples

def calc_rel_brightness(intensity_samples):
    """Return list of relative brightness from list of intensity values."""
    rel_brightness = []
    max_brightness = max(intensity_samples)
    for intensity in intensity_samples:
        rel_brightness.append(intensity / max_brightness)
    return rel_brightness

def plot_light_curve(rel_brightness):
    """Plot changes in relative brightness vs. time."""
    plt.plot(rel_brightness, color='red', linestyle='dashed',
             linewidth=2, label='Relative Brightness')
    plt.legend(loc='upper center')
    plt.title('Relative Brightness vs. Time')
##    plt.ylim(0.995, 1.001)
    plt.show()

if __name__ == '__main__':
    main()

探测外星舰队

practice_alien_armada.py
"""Simulate transit of alien armada with light curve."""
import random
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt

STAR_RADIUS = 165
BLACK_IMG = np.zeros((400, 500, 1), dtype="uint8")
NUM_SHIPS = 5
NUM_LOOPS = 300  # Number of simulation frames to run

class Ship():
    """Draws and moves a ship object on an image."""
    def __init__(self, number):
        self.number = number
        self.shape = random.choice(['>>>|==H[X)',
                                    '>>|==H[XX}=))-',
                                    '>>|==H[XX]=(-'])
        self.size = random.choice([0.7, 0.8, 1])
        self.x = random.randint(-180, -80)
        self.y = random.randint(80, 350)
        self.dx = random.randint(2, 4)

    def move_ship(self, image):
        """Draws and moves ship object."""
        font = cv.FONT_HERSHEY_PLAIN
        cv.putText(img=image, 
                   text=self.shape,
                   org=(self.x, self.y),
                   fontFace=font,
                   fontScale=self.size,
                   color=0,
                   thickness=5) 
        self.x += self.dx

def record_transit(start_image):
    """Runs simulation and returns list of intensity measurements per frame."""
    ship_list = []
    intensity_samples = []

    for i in range(NUM_SHIPS):
        ship_list.append(Ship(i))

    for _ in range(NUM_LOOPS):
        temp_img = start_image.copy()
        cv.circle(temp_img, (250, 200), STAR_RADIUS, 255, -1)  # The star.
        for ship in ship_list:
            ship.move_ship(temp_img)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity),
                   (5, 390), cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        intensity_samples.append(intensity)
        cv.waitKey(50)
    cv.destroyAllWindows()
    return intensity_samples

def calc_rel_brightness(image):
    """Return list of relative brightness measurments for planetary transit."""
    rel_brightness = record_transit(image)
    max_brightness = max(rel_brightness)
    for i, j in enumerate(rel_brightness):
        rel_brightness[i] = j / max_brightness
    return rel_brightness

def plot_light_curve(rel_brightness):
    """Plots curve of relative brightness vs. time."""
    plt.plot(rel_brightness, color='red', linestyle='dashed',
             linewidth=2, label='Relative Brightness')
    plt.legend(loc='upper center')
    plt.title('Relative Brightness vs. Time')
    plt.show()

relative_brightness = calc_rel_brightness(BLACK_IMG)
plot_light_curve(relative_brightness)

探测带月球的行星

practice_planet_moon.py
"""Moon animation credit Eric T. Mortenson."""
import math
import numpy as np
import cv2 as cv
import matplotlib.pyplot as plt

IMG_HT = 500
IMG_WIDTH = 500
BLACK_IMG = np.zeros((IMG_HT, IMG_WIDTH, 1), dtype='uint8')
STAR_RADIUS = 200
EXO_RADIUS = 20
EXO_START_X = 20
EXO_START_Y = 250
MOON_RADIUS = 5
NUM_DAYS = 200  # number days in year

def main():
    intensity_samples = record_transit(EXO_START_X, EXO_START_Y)
    relative_brightness = calc_rel_brightness(intensity_samples)
    print('\nestimated exoplanet radius = {:.2f}\n'
          .format(STAR_RADIUS * math.sqrt(max(relative_brightness)
                                          -min(relative_brightness))))
    plot_light_curve(relative_brightness)

def record_transit(exo_x, exo_y):
    """Draw planet transiting star and return list of intensity changes."""
    intensity_samples = []
    for dt in range(NUM_DAYS):
        temp_img = BLACK_IMG.copy()
        # Draw star:
        cv.circle(temp_img, (int(IMG_WIDTH / 2), int(IMG_HT/2)),
                  STAR_RADIUS, 255, -1)
        # Draw exoplanet
        cv.circle(temp_img, (int(exo_x), int(exo_y)), EXO_RADIUS, 0, -1)
        # Draw moon
        if dt != 0:
            cv.circle(temp_img, (int(moon_x), int(moon_y)), MOON_RADIUS, 0, -1)
        intensity = temp_img.mean()
        cv.putText(temp_img, 'Mean Intensity = {}'.format(intensity), (5, 10),
                   cv.FONT_HERSHEY_PLAIN, 1, 255)
        cv.imshow('Transit', temp_img)
        cv.waitKey(10)        
        intensity_samples.append(intensity)
        exo_x = IMG_WIDTH / 2 - (IMG_WIDTH / 2 - 20) * \
                math.cos(2 * math.pi * dt / (NUM_DAYS)*(1 / 2))
        moon_x = exo_x + \
                 3 * EXO_RADIUS * math.sin(2 * math.pi * dt / NUM_DAYS *(5))
        moon_y = IMG_HT / 2 - \
                 0.25 * EXO_RADIUS * \
                 math.sin(2 * math.pi * dt / NUM_DAYS * (5))
    cv.destroyAllWindows()

    return intensity_samples

def calc_rel_brightness(intensity_samples):
    """Return list of relative brightness from list of intensity values."""
    rel_brightness = []
    max_brightness = max(intensity_samples)
    for intensity in intensity_samples:
        rel_brightness.append(intensity / max_brightness)
    return rel_brightness

def plot_light_curve(rel_brightness):
    """Plot changes in relative brightness vs. time."""
    plt.plot(rel_brightness, color='red', linestyle='dashed',
             linewidth=2, label='Relative Brightness')
    plt.legend(loc='upper center')
    plt.title('Relative Brightness vs. Time')
    plt.show()

if __name__ == '__main__':
    main()

图 A-1 总结了 practice_planet_moon.py 程序的输出结果。

图像

图 A-1:行星和月球的光曲线,其中月球从行星后面经过

测量外行星的一天长度

practice_length _of_day.py
"""Read-in images, calculate mean intensity, plot relative intensity vs time."""
import os
from statistics import mean
import cv2 as cv
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal  # See Chap. 1 to install scipy.

# Switch to the folder containing images.
os.chdir('br549_pixelated')
images = sorted(os.listdir())
intensity_samples = []

# Convert images to grayscale and make a list of mean intensity values.
for image in images:
    img = cv.imread(image, cv.IMREAD_GRAYSCALE)    
    intensity = img.mean()
    intensity_samples.append(intensity)

# Generate a list of relative intensity values.
rel_intensity = intensity_samples[:]
max_intensity = max(rel_intensity)
for i, j in enumerate(rel_intensity):
    rel_intensity[i] = j / max_intensity

# Plot relative intensity values vs frame number (time proxy).
plt.plot(rel_intensity, color='red', marker='o', linestyle='solid',
         linewidth=2, markersize=0, label='Relative Intensity')
plt.legend(loc='upper center')
plt.title('Exoplanet BR549 Relative Intensity vs. Time')
plt.ylim(0.8, 1.1)
plt.xticks(np.arange(0, 50, 5))
plt.grid()
print("\nManually close plot window after examining to continue program.")
plt.show()

# Find period / length of day.
# Estimate peak height and separation (distance) limits from plot.
# height and distance parameters represent >= limits.
peaks = signal.find_peaks(rel_intensity, height=0.95, distance=5)
print(f"peaks = {peaks}")
print("Period = {}".format(mean(np.diff(peaks[0]))))

识别朋友或敌人**

模糊面部

practice_blur.py
import cv2 as cv

path = "C:/Python372/Lib/site-packages/cv2/data/"
face_cascade = cv.CascadeClassifier(path + 'haarcascade_frontalface_alt.xml')

cap = cv.VideoCapture(0)

while True:
    _, frame = cap.read()
    face_rects = face_cascade.detectMultiScale(frame, scaleFactor=1.2,
                                               minNeighbors=3)    
    for (x, y, w, h) in face_rects:
        face = cv.blur(frame[y:y + h, x:x + w], (25, 25))
        frame[y:y + h, x: x + w] = face
        cv.rectangle(frame, (x,y), (x+w, y+h), (0, 255, 0), 2)

    cv.imshow('frame', frame)
    if cv.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv.destroyAllWindows()

通过人脸识别限制访问**

挑战项目:添加密码和视频捕捉功能

以下代码片段解决了挑战项目中与视频流中的人脸识别相关的部分。

challenge_video_recognize.py
"""Recognize Capt. Demming's face in video frame."""
import cv2 as cv

names = {1: "Demming"}

# Set up path to OpenCV's Haar Cascades
path = "C:/Python372/Lib/site-packages/cv2/data/"
detector = cv.CascadeClassifier(path + 'haarcascade_frontalface_default.xml')

# Set up face recognizer and load trained data.
recognizer = cv.face.LBPHFaceRecognizer_create()
recognizer.read('lbph_trainer.yml')

# Prepare webcam.
cap = cv.VideoCapture(0)
if not cap.isOpened(): 
    print("Could not open video device.")
##cap.set(3, 320)  # Frame width.
##cap.set(4, 240)  # Frame height.

while True:
    _, frame = cap.read()
    gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
    face_rects = detector.detectMultiScale(gray, 
                                           scaleFactor=1.2,
                                           minNeighbors=5)

    for (x, y, w, h) in face_rects:
        # Resize input so it's closer to training image size.
        gray_resize = cv.resize(gray[y:y + h, x:x + w],
                                (100, 100),
                                cv.INTER_LINEAR)
        predicted_id, dist = recognizer.predict(gray_resize)
        if predicted_id == 1 and dist <= 110:
            name = names[predicted_id]
        else:
            name = 'unknown'    
        cv.rectangle(frame, (x, y), (x + w, y + h), (255, 255, 0), 2)
        cv.putText(frame, name, (x + 1, y + h -5),
                   cv.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1)
        cv.imshow('frame', frame)

    if cv.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv.destroyAllWindows()
posted @ 2025-11-27 09:18  绝不原创的飞龙  阅读(25)  评论(0)    收藏  举报