你自己的机器人构建指南-全-

你自己的机器人构建指南(全)

原文:Build Your Own Robot

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我喜欢学习如何使用计算机创建事物,无论是视频游戏还是网络应用。当我成为父亲后,我也开始与我的女儿们一起创建有趣的制作项目。随着她们的成长,我们的项目变得越来越雄心勃勃,直到我们开始一起建造机器人。其中一个有趣的机器人项目是创建一个可以四处行驶并从女儿够不到的高书架上取书的机器人。书籍会以高速从书架上飞出,她必须要么接住要么躲避。

我在不同的 Python 会议上展示了这些项目的代码和创建过程,这让观众感到非常有趣。这激发了我为像我这样没有机器人背景的人写一本从头开始创建机器人的书的想法。你所需要的只是熟悉 Python,并对学习新的、有趣的方式来使用该语言构建有趣的项目充满热情。

我希望这本书具备一些特定的品质。这些品质基于我在学习新主题时认为最有效的方法,我还想解决一些现有学习材料中的一些不足:

  • 尽早提供结果。从第二章开始,你将能够编写并运行 Python 代码,这些代码可以控制电机并与传感器交互。

  • 动手实践。本书以非常实用的方法从头开始构建机器人。

  • 易于接触。创建本书中的项目不需要特殊的机器人、焊接或电子知识。任何 Python 开发者都可以开始构建这些机器人。

  • 提供许多项目。本书涵盖了九个不同的项目,并在入门章节之后,每个章节都以一个新完成的项目结束。

在这些指导原则的指导下,本书的结构和内容得以形成。我希望你阅读和运行这些项目时能像我创建它们时一样享受。

致谢

我要感谢 Manning 出版社的编辑 Karen Miller。这是我第一次与 Manning 合作出版书籍,在整个过程中你都非常耐心和支持。感谢你所有的支持和宝贵的反馈!我还想对技术编辑 Alexander Ryker 表示感谢,他为本书中的代码提供了出色的反馈和测试。特别感谢那些参与本书推广和生产的团队。你们帮助我深入理解为什么我对这本书如此热情,并让我与读者分享这份热情。

我还想感谢那些提供了如此详细和出色反馈的审稿人。感谢 Alain Couniot、Alena Coons、Alex Lucas、Amitabh Cheekoth、Ben McNamara、Chad Yantorno、Cosimo Attanasi、Darrin Bishop、Erico Lendzian、Fernando Bernardino、James Black、James Matlock、Jeremy Chen、Jesús Juárez、Jon Choate、Jonathan Reeves、Julien Pohie、Keith Kim、Marc Taylor、Marcus Geselle、Martin Dehnert、Mohana Krishna BG、Patrice Maldague、Patrick Regan 和 Richard Tobias,你们的评论各不相同,帮助我从另一个角度看待这本书。我们做出的更改和改进如果没有你们宝贵的观察是不可能的。

关于本书

自己动手做机器人》是一本将你的第一个基于 Python 的机器人带入生活的 DIY 指南。从基础知识开始,你将教会你的新朋友如何旋转、移动和找到自己的路。然后,你将快速进步到使用手机、电脑或操纵杆远程控制你的机器人。你甚至可以设置一个摄像头,将机器人看到的直接广播到你的电脑屏幕上。巧妙的计算机视觉技巧将使你的机器人能够追踪人脸、寻找二维码,甚至可能去拿一些零食。

谁应该阅读这本书?

本书面向软件开发者,读者应该熟悉 Python。不需要机器人或电气工程方面的先验知识或经验。书中所有的硬件组装都可以使用简单的工具,如螺丝刀来完成。对于任何的布线或机器人组装,都不需要特殊的工具或焊接等技能。这本书非常适合

  • Python 开发者

  • 机器人爱好者

  • 大学学生

本书是如何组织的:路线图

本书共有 11 章。无论是初学者还是有经验的 Python 开发者都可以从本书中使用的软件技术中学习,以使机器人项目栩栩如生:

  • 第一章解释了为什么机器人如此神奇以及它们为什么具有如此大的潜力。它还讨论了机器人的构建模块以及本书中构建机器人的方法。

  • 第二章涵盖了设置机器人并开始机器人项目的初步步骤。还将介绍创建软件来控制直流电机并根据触摸传感器更改 Neopixel 颜色的内容。

  • 第三章探讨了让机器人移动的主题。你将学习如何控制直流电机使机器人前进和后退,以及左转和右转。所有这些不同的运动功能都将被放入库中,以便你可以在后续章节中重用代码。

  • 第四章涵盖了在 Python 中创建交互式自定义外壳的基础知识,以便你可以创建机器人外壳。该外壳将支持执行不同机器人运动的命令,并具有命令历史记录和执行自定义外壳脚本来使机器人执行一系列运动的能力。

  • 第五章讨论了创建远程控制机器人软件的主题。将分别使用 SSH 和 HTTP 网络协议作为流行的远程控制选项。

  • 第六章讨论了如何创建可以用于通过手机或计算机网络浏览器控制机器人的机器人网络应用程序。还将涵盖使用网络浏览器工具测量网络应用程序性能等主题。

  • 第七章涵盖了使用操纵杆控制机器人的主题。将检查在 Python 中读取和响应操纵杆事件的不同方法。然后,将创建一个应用程序,通过网络响应操纵杆事件并执行不同的机器人运动。

  • 第八章讨论了如何使用键盘控制一组伺服电机在水平和倾斜方向上执行运动。然后可以控制安装到机器人伺服电机上的摄像头以显示实时视频流并拍摄快照。

  • 第九章涵盖了构建一个可以移动摄像头以跟随检测到的面部方向的机器人的主题。将使用机器学习来执行面部检测并根据捕获的视频帧中检测到的面部位置移动摄像头。

  • 第十章旨在构建一个可以在其环境中移动以寻找匹配 QR 码的机器人。涵盖了在 Python 中生成和检测 QR 码的技术,以及如何构建一个将驱动机器人四处移动直到找到匹配 QR 码的应用程序。

  • 第十一章讨论了如何构建一个读取 CSV 文件中零食列表的推送机器人。然后可以从一个网络应用程序中选择一个想要的零食,使机器人采取行动并驶向所选的零食并推动它。

关于代码

本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在这两种情况下,源代码都使用fixed-width 字体如这样`来格式化,以将其与普通文本区分开来。

您可以从本书的 liveBook(在线)版本中获取可执行代码片段,网址为livebook.manning.com/book/build-your-own-robot。本书中示例的完整代码可以从 Manning 网站www.manning.com和 GitHubgithub.com/marwano/robo下载。

liveBook 讨论论坛

购买《自己动手制作机器人》包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/build-your-own-robot/discussion。您还可以在livebook.manning.com/discussion了解更多关于曼宁论坛和行为准则的信息。

曼宁对读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣偏离!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

软件硬件要求

附录 A 中的硬件购买指南涵盖了本书项目的硬件要求。它显示了不同章节所需的硬件。它还提供了一些关于可选购买的推荐,这些购买可以帮助改进机器人项目。附录 B 提供了 Raspberry Pi 的安装和配置的详细说明以及相关的软件要求。有关组装和配置机器人硬件的详细信息,请查看附录 C 中找到的机器人组装指南。最后,附录 D 提供了一个模拟机器人硬件的机制,可以在任何笔记本电脑或台式计算机上运行本书中的所有代码。

关于作者

作者照片

Marwan Alsabbagh 是一位经验丰富的软件开发者。他在麦吉尔大学学习数学和计算机科学,热衷于通过使用 Python 构建项目来教学和学习,重点关注微控制器和机器人。

关于技术编辑

Alex Ryker 是一位在工业自动化领域工作的咨询系统工程师。他在普渡大学主修计算机科学,并在计算机安全和机器人领域共同撰写了研究论文。

关于封面插图

《自己动手制作机器人》封面上的插图“L’Etalagiste”取自路易·库默尔(Louis Curmer)于 1841 年出版的一本书。每一幅插图都是手工精心绘制和着色的。

在那些日子里,仅凭人们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面,庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片的收藏得以重现。

1 什么是机器人?

本章涵盖

  • 机器人由什么组成

  • 为什么机器人有如此大的潜力

  • 机器人使用的硬件和软件组件

最近,机器人领域取得了巨大的进步,消费级机器人正越来越多地被工业应用机器人所取代。这些机器人的硬件和软件也变得更加易于获取,因此,现在是学习机器人技术的激动人心时期。本书利用 Python 编程语言的力量,通过采用开源理念的软硬件,将各种机器人项目生动地呈现出来。到本书结束时,你将学会如何构建九个不同的机器人项目。

1.1 为什么机器人令人惊叹

计算机以它们创造者几十年前甚至无法想象的方式改变了地球上每一个人的生活。机器人技术赋予那些

计算机是机器的“手臂”和“车轮”,使它们能够移动并完成超出我们想象的事情。在许多方面,机器人是未来。这本书让你从头开始构建机器人,并亲眼看到它们在现实世界中栩栩如生。你编写的代码将使你的电脑四处移动并撞倒你选择的物品。图 1.1 展示了我们可以利用机器人力量的不同方式。

图片

图 1.1 机器人的力量:机器人使用不同的硬件来移动并观察它们周围的世界。

机器人可以做很多事情:

  • 机器人移动。 它们拥有轮子,并且不畏惧使用它们。这种移动性使得它们能够执行各种任务,从在仓库中运输包裹到在自动驾驶汽车中运输人员。

  • 机器人可以看见。机器人配备了摄像头和强大的计算机视觉软件,可以看见它们周围的这个世界。它们可以看到我们的脸,检测到它们,并通过使用电机移动摄像头朝向我们进行反应。

  • 机器人可以找到. 通过在现实世界中的物体上使用二维码贴纸,我们可以派遣我们的机器人去寻找并交互我们选择的物体。机器人使用它们的摄像头和二维码检测软件来找到它们正在寻找的东西,一旦到达目的地,就会执行手头的任务。

  • 机器人可以被控制。 人类操控的机器人使我们能够完成以前不可能完成的各类工作(例如,在人类难以进入的危险区域操控机器人或进行人类双手难以精确操作的医疗程序)。在这本书中,我们将使用键盘、鼠标和操纵杆控制器,通过电脑屏幕和手机来控制机器人。每种设备都有其优势和权衡,这些将在每种实现中探讨。

  • 机器人可以远程控制。 我们这个时代最强大的计算机创新之一是 TCP/IP 互联网通信协议。通过使用建立在 TCP 之上的 HTTP 和 SSH,我们将实现与机器人远程通信和控制的不同方式。无论人类操作员是在同一个房间还是数英里之外,这些机器人都将能够被控制。

1.2 我们制造机器人的方法

这本书采用了一种非常实用的方法来学习如何构建机器人以及用 Python 编写软件来控制和与之交互。到下一章结束时,你将组装并连接足够的机器人组件,以便编写和运行你的第一个 Python 脚本。这个脚本将读取机载触摸传感器的状态作为输入,并在检测到触摸事件时打开直流电机。每个剩余的章节都以一个完全功能的项目结束,该项目要么直接与机器人交互,要么是本书后面添加更多功能的基石。每个基于项目的章节都将介绍当前的项目,然后引导读者一步一步地构建解决方案。

1.2.1 从失败中学习

最直接的方法往往因为处理能力或某种技术的固有局限性而失败。这些情况将被用作学习机会,以了解如何通过使用更复杂的方法或优化实现来克服这些限制,从而在受限的硬件要求下获得主要性能提升。读者将被带入诊断、精确测量和基准测试这些问题和它们相关解决方案的旅程中。读者将能够使用这些技能来预测、诊断和调试他们自己项目中的类似问题。许多这些性能问题和解决方案不仅限于在机器人上工作,还适用于许多不同的计算机视觉、网络和计算项目。

1.2.2 你将获得什么?

通过阅读这本书,你将接触到各种硬件和软件挑战。本书中提到的许多 Python 库被广泛使用,并且适用于涉及机器人及其他项目的开发。书中还涵盖了处理硬件和软件问题的多种问题解决技巧,这将有益于读者。

机器人领域是一个非常广泛的领域,具有多种多样的应用。就本书所涵盖的软件和硬件而言,将使用一组特定的硬件和软件。

1.2.3 原型设计

使用本书涵盖的硬件和软件,可以完成各种各样的原型设计项目。由于使用的硬件既经济又强大,许多想法可以通过构建原型来测试,以在投入更复杂或昂贵的生产硬件之前测试设计或方法。

1.2.4 教学

书中的项目可用于在课堂环境中教授学生,甚至可以用于自学,以获得构建机器人的真实生活经验。无论您是创建一个用于工业用途的小型机器人还是大型机器人,机器人的底层技术都是相同的。通过构建这些机器人,您还将获得与底盘设计、电机和计算机功率需求、电池功率容量、重量以及便携性相关的知识。在小规模上获得的经验对于更大规模或生产型机器人仍然非常适用。

1.2.5 生产就绪

关于软件,书中使用的许多软件都可直接适用于大规模生产。例如,Linux、Python 和 Tornado 网络框架等软件被广泛应用于许多关键任务应用中。至于硬件,树莓派被用于许多终端用户产品中,而树莓派计算模块在工业应用中得到了大量使用。

1.2.6 局限性

本书所使用的电机在输出功率和容量方面存在固有的硬件限制,这将直接限制可以使用该硬件执行的项目类型。Raspberry Pi 的计算能力也固有限制,因为它被设计成体积小、重量轻且功耗低。

1.3 机器人由什么组成?

机器人通常拥有像任何笔记本电脑或台式电脑一样的处理器、内存和存储。但将它们区分开来的是它们的电机和传感器,这使得它们能够做任何普通电脑都无法做到的事情。再加上一些强大的软件,它们将能够完成各种壮举。

1.3.1 机器人构建模块

构建本书中使用的硬件和软件的心理模型是非常有价值的。这将帮助你理解我们在操作软件和硬件堆栈的哪个部分。Linux 和 Python 是一个由极其强大的软件模块和库组成的生态系统,这些模块和库经过良好测试,功能多样,并且文档齐全。我们将利用这一点,并结合许多不同的模块来实现我们所需的功能。

1.3.2 Raspberry Pi 上的伺服电机和直流电机

直流电机使机器人的轮子转动,并帮助它们四处行驶。无论是沿着不同的方向行驶,还是沿着固定的路径或轨道行驶,都是直流电机使这一切成为可能。伺服电机是更复杂的电机,内置传感器允许精确的运动。它们可以被控制转动到特定的角度。机器人的机械臂通常由几个伺服电机组成。书中的项目将深入探讨伺服电机和直流电机,并使用 Python 脚本控制它们执行各种任务。书中的机器人将配备一个小型但功能强大的单板计算机(SBC),称为树莓派,它是它们操作的核心。

1.3.3 硬件堆栈

图 1.2 展示了本书中不同项目中将使用的不同硬件组件。

图片

图 1.2 硬件堆栈:树莓派是主计算机,而 CRICKIT 负责处理电机。

每个硬件组件的描述如下:

  • 树莓派—一款集成了 CPU、RAM 以及多个不同输入/输出接口的单板计算机。

  • CRICKIT—Adafruit CRICKIT HAT 是为 Raspberry Pi 设计的附加板,它连接到 Pi 的通用输入/输出 (GPIO) 接口。它为伺服电机和直流电机供电和控制。

  • Neopixel—CRICKIT HAT 带有一个 RGB LED。其颜色和亮度可以在软件中更改。

  • 直流电机—多个直流电机可以连接到 CRICKIT HAT。它们将为左右轮提供动力,以驱动机器人。

  • 伺服—多个伺服电机可以连接到 CRICKIT HAT。它们可以用于伺服电机的相机平移和倾斜。

  • 相机—树莓派摄像头模块由树莓派基金会创建,用于计算机视觉。

  • 以太网—千兆以太网端口集成在 Pi 板上,提供高速、低延迟的可靠网络通信。

  • Wi-Fi—提供双频段 2.4 GHz 和 5 GHz 无线局域网。

  • 蓝牙—蓝牙 5.0, BLE

  • USB—键盘、鼠标和游戏手柄可以直接使用 USB 连接。

不同的项目将需要不同的硬件组件集合。每一章都将介绍用于特定项目的硬件组件。附录 A 中的硬件购买指南提供了关于项目所需特定产品型号的良好建议,以及在线零售商的详细信息。

1.3.4 Python 和 Linux

机器人将在 Linux 操作系统之上运行其软件。Linux 是一个功能丰富且多才多艺的操作系统,它为机器人和超级计算机提供动力。这为书中将使用的一系列成熟且经过充分测试的软件和功能打开了大门——从来自摄像头流的实时视频处理到使用蓝牙协议,通过高度敏感的模拟摇杆控制器无线控制机器人的动作,一切应有尽有。

Python 是一种非常表达性的语言,拥有丰富的成熟且强大的软件库。我们将使用这些库将丰富的功能融入类似机器人的计算机视觉,以及检测和解码二维码的能力,以便机器人能够在其周围环境中搜索并找到特定的二维码标记物体。同时,我们还将使用创建和消费 Web 应用的库,以便机器人可以通过本地或远程网络进行控制。

1.3.5 软件栈

图 1.3 展示了本书项目中将使用到的不同软件。

图片

图 1.3 软件栈:Linux 管理硬件并运行 Python 解释器。

机器人软件组件如下:

  • Linux—将使用 Raspberry Pi OS Linux 发行版作为操作系统。

  • Python—Python 解释器将在 Linux 上作为可执行文件运行并执行机器人 Python 应用程序。

  • 图书馆—这些是将在机器人项目中使用和集成的各种 Python 库,所有这些库都在 Python 解释器中运行。将使用 Tornado 网络框架来构建控制机器人的网络应用。将使用 OpenCV 计算机视觉库进行人脸检测和二维码解码。将使用 Adafruit CRICKIT 库来控制伺服电机和直流电机。

  • 应用—本书中驱动机器人项目的代码将在这一级别运行。

摘要

  • 配备摄像头和强大的计算机视觉软件,机器人能够看到它们周围的这个世界。

  • 通过扫描现实世界物体上的二维码贴纸,机器人可以找到并与其环境中的物体进行交互。

  • 人类操控的机器人使我们能够完成各种以前不可能完成的任务,无论是这些任务意味着在人类无法进入的危险区域控制机器人,还是进行人类双手无法精确操作的医疗程序。

  • 使用诸如 HTTP 和 SSH 这样的互联网协议,我们将实现与控制我们的机器人进行远程通信的不同方式。

  • 许多不同类型的原型设计项目可以使用本书涵盖的硬件和软件来执行。

  • 本书中的许多 Python 库被广泛使用,并且可以用于涉及机器人及其他项目的开发。

  • 机器人通常拥有像任何笔记本电脑或台式电脑一样的处理器、内存和存储器。

  • 机器人也经常运行 Linux,这是一个功能丰富且多才多艺的操作系统,它为机器人和超级计算机提供动力。

  • 直流电机是使机器人车轮转动并帮助它们行驶的工具。

2 开始

本章涵盖

  • 组装和配置机器人的核心硬件和软件

  • 控制 Neopixel 的颜色和亮度

  • 从四个板载触摸传感器读取传感器数据

  • 使用 Python 控制 DC 电机

  • 创建第一个与传感器和电机交互的 Python 机器人程序

在本章中,你将学习如何连接和配置本书中机器人使用的主要硬件和软件组件。一旦硬件和软件设置完成,我们将通过从板载触摸传感器读取传感器数据,直接使用 Python 与硬件交互。然后,你将学习如何控制 Neopixel 灯和 DC 电机。最后,所有这些不同的硬件组件和 Python 脚本将结合在一起,创建一个基于触摸传感器输入控制 Neopixel 和 DC 电机的人工智能程序。

2.1 介绍我们的机器人硬件

图 2.1 展示了前一章讨论的硬件堆栈,本章中使用的特定组件以较暗的文本框突出显示。带有灰色文本的组件将在后续章节中使用。

图片

图 2.1 硬件堆栈:Raspberry Pi 将使用以太网和 Wi-Fi 处理网络通信。

Linux 操作系统将安装在 Raspberry Pi 上。以太网和 Wi-Fi 硬件组件将被用于将机器连接到网络,并允许网络上的其他计算机连接到它。然后,CRICKIT HAT 将连接到 Raspberry Pi 并用于控制 Neopixel 和连接的电机。

在购买本章所需的硬件之前,请务必检查附录 A 中的硬件购买指南。附录显示了不同章节所需的硬件,还有一些可选购买的推荐,这些推荐可以改善机器人项目。附录 B 提供了安装和配置 Raspberry Pi 和 Adafruit CRICKIT HAT 的详细说明。还值得注意的是,附录 D 提供了一种模拟机器人硬件的机制,可以在任何笔记本电脑或台式计算机上运行本书中的任何代码。

2.1.1 Raspberry Pi

Raspberry Pi 是由 Raspberry Pi 基金会创建的小型单板计算机 (raspberrypi.org)。建议使用具有 2 GB 或更多 RAM 的 Raspberry Pi 4 模型。图 2.2 展示了 Raspberry Pi 4 的照片以供参考。这些计算机的以下属性使它们成为机器人项目的理想选择:

  • 它们的小尺寸和轻质结构对移动机器人很重要。

  • 运行 Linux 和 Python 为在机器人项目上构建强大的软件打开了大门。

  • 与 Raspberry Pi 兼容的多功能机器人底盘套件允许不同的板、电机和电池配置。

  • 强大的 CPU 和内存使得实时计算机视觉和机器学习等密集型应用成为可能。

  • 良好的相机支架使机器人能够看到其环境。

  • 它们具有内置和灵活的连接选项,如以太网、Wi-Fi、蓝牙和 USB。

  • 通用输入/输出(GPIO)连接器为 Adafruit CRICKIT 所使用的电路板提供了强大的机制,以添加硬件功能。

图 2.2 树莓派:电路板上的主要硬件接口已标注。

2.1.2 Adafruit CRICKIT HAT

Adafruit CRICKIT HAT 是 Adafruit Industries 为树莓派创建的硬件扩展板(adafruit.com;图 2.3)。CRICKIT HAT 连接到树莓 Pi 的 GPIO 连接器,并为本书中的项目提供以下功能:

  • 可连接多达两个双向直流电机,供电并控制。

  • 可连接多达四个伺服电机,供电并控制。

  • 芯片板上集成了四个电容式触摸输入传感器。

  • 芯片板上集成了 Neopixel RGB LED。

  • Adafruit Python 库提供 Python 支持,用于控制与电机、电容式触摸和 Neopixel 的交互。

图 2.3 Adafruit CRICKIT HAT:直流电机和伺服电机连接到该电路板。

2.2 为我们的机器人配置软件

图 2.4 展示了前一章中提到的软件堆栈。本章中使用的特定软件的详细信息将在下文中描述。

图 2.4 软件堆栈:本章将涵盖 Linux 和 Python 的安装和配置。

安装 Linux 后,Python 将被配置为拥有一个专用的虚拟环境,其中可以安装 Python 库。将安装 Adafruit CRICKIT 库,然后使用它来运行 Python 代码,以与 CRICKIT 硬件组件(如 Neopixel LED 和触摸输入传感器)交互。将使用 Python 的time模块来控制不同动作的时间持续时间。在继续本章之前,请确保遵循附录 B 中关于树莓派和 Adafruit CRICKIT HAT 的安装和配置说明。

2.3 更改 Neopixel 颜色

CRICKIT 库提供了几种与 Neopixel LED 交互的不同方式。我们可以在 REPL(读取-评估-打印循环)会话中探索这些选项。有关激活 Python 虚拟环境和打开 REPL 会话的帮助,请参阅附录 B。Neopixel 可能非常明亮,因此我们将亮度降低到 1%,然后将颜色设置为蓝色:

>>> from adafruit_crickit import crickit
>>> crickit.onboard_pixel.brightness = 0.01
>>> crickit.onboard_pixel.fill(0x0000FF)

到目前为止,我们使用 RGB 十六进制颜色代码设置颜色。如果能使用人类可读的颜色名称来设置颜色那就更好了。这种功能在 CRICKIT 库中不是直接可用的,但我们可以创建一个简单的字典来存储和查找常见的颜色名称:

>>> RGB = dict(red=0xFF0000, green=0x00FF00, blue=0x0000FF)
>>> crickit.onboard_pixel.fill(RGB['red'])

现在我们可以创建一个简单的脚本,不断循环遍历每个颜色名称并设置颜色。此代码将创建一个多色闪烁效果,使用 LED。在每次循环中,脚本将打印出颜色名称,设置颜色,然后在设置下一个颜色之前暂停 0.1 秒。将以下脚本保存到名为blink.py的文件中,存放在 Pi 上。

列表 2.1 blink.py:使用 LED 创建多色闪烁效果

#!/usr/bin/env python3
import time
from adafruit_crickit import crickit

RGB = dict(red=0xFF0000, green=0x00FF00, blue=0x0000FF)

crickit.onboard_pixel.brightness = 0.01
while True:
    for name in RGB:
        print(name)
        crickit.onboard_pixel.fill(RGB[name])
        time.sleep(0.1)

可以通过运行命令来赋予文件执行权限

$ chmod a+x blink.py

然后运行 Python 脚本:

$ ./blink.py

脚本可以直接执行的原因是第一行使用了 Unix 的一个特性,称为 shebang,它告诉 Linux 该脚本应该通过 Python 解释器python3来执行。确保在运行脚本之前激活 Python 虚拟环境,如附录 B 所示。我们可以通过按 Ctrl+C 退出脚本,这将强制脚本退出。在 Pi 上保存脚本时,将其放置在/home/robo/bin/目录中,也可以作为~/bin访问。这是 Linux 系统上放置用户脚本等的标准位置,本书将遵循这一惯例。《blink.py》文件以及本书中所有项目的代码都可以在 GitHub 上找到(github.com/marwano/robo)。

深入了解:I2C 通信协议

CRICKIT HAT 在板上有自己的微控制器,并使用 I2C 通信协议来启用 Raspberry Pi 与其微控制器之间的通信。所有这些都由 Python Adafruit CRICKIT 库处理。I2C 协议非常强大且灵活,是集成电路之间通信的流行选择。SparkFun 网站有一个关于 I2C 的出色指南(learn.sparkfun.com/tutorials/i2c)。了解这些底层硬件协议的内部工作原理既有趣又实用。

Adafruit 网站有一个关于在 Python 中使用 I2C 设备进行交互的良好实践指南(mng.bz/g7mV)。我们可以使用这个指南在 Pi 上使用 CRICKIT HAT 进行一些基本的 I2C 交互。让我们首先打开一个 REPL 并导入board模块:

>>> import board

现在我们可以创建一个I2C对象来扫描 CRICKIT HAT:

>>> i2c = board.I2C()

现在我们可以扫描 I2C 设备并将结果保存在devices中。我们可以从结果中看到找到了一个设备:

>>> devices = i2c.scan()
>>> devices
[73]

从附录 B 中,我们知道 CRICKIT HAT 的 I2C 地址预期为0x49。我们可以通过以下行计算其十六进制值来确认我们找到的设备是 CRICKIT HAT:

>>> hex(devices[0])
'0x49'

I2C 协议是一种强大的协议,可以在仅两条线上支持多达 1,008 个外围设备。

2.4 检查触摸传感器状态

CRICKIT 上有四个电容式触摸输入传感器。图 2.5 显示了它们的特写视图。从 Python 中,每个传感器都可以单独检查,以查看它是否正在检测触摸事件。在不触摸触摸传感器的情况下,在 REPL 会话中运行以下代码:

>>> from adafruit_crickit import crickit
>>> crickit.touch_1.value
False

图 2.5 电容式触摸传感器:这些传感器可以检测触摸事件。

现在触摸第一个触摸传感器的同时再次运行最后一行:

>>> crickit.touch_1.value
True

当访问value属性时,CRICKIT 库会检查触摸传感器状态,并返回一个布尔值TrueFalse,这取决于传感器数据。

2.5 控制直流电机

将直流电机的两根线连接到直流电机连接器端口 1。图 2.6 显示了这些电机连接在 CRICKIT 上的位置。这两根线可以任意方式连接到 CRICKIT 电机端口;这不会引起任何问题。请确保使用附录 A 中提到的 M/M 扩展跳线,因为这将确保公母端匹配。一旦连接,请在 REPL 会话中运行以下行:

>>> from adafruit_crickit import crickit
>>> crickit.dc_motor_1.throttle = 1

图 2.6 直流电机连接:直流电机的连接点被拧紧固定。

直流电机现在将以全速运行。要停止电机运行,请使用

>>> crickit.dc_motor_1.throttle = 0

2.6 使用触摸传感器控制电机

我们可以将到目前为止学到的知识结合起来,将这些 CRICKIT 库的不同部分组合起来,制作一个不断检查触摸传感器并根据传感器是否被触摸来启动或停止电机的应用程序。我们还将根据电机是否启动或停止改变 LED 颜色。让我们一步步构建这个应用程序。

首先,我们将导入 CRICKIT 库来控制电机和time库来在检查触摸事件之间暂停:

import time
from adafruit_crickit import crickit

接下来,我们将定义RGB,这样我们就可以通过名称设置颜色,并在名为POLL_DELAY的设置中保存检查触摸事件之间的等待时间。轮询延迟值设置为 0.1 秒,这足以使触摸传感器和启动电机的体验响应迅速:

RGB = dict(red=0xFF0000, green=0x00FF00, blue=0x0000FF)
POLL_DELAY = 0.1

在开始程序的主循环之前,我们设置 LED 的亮度:

crickit.onboard_pixel.brightness = 0.01

程序的其余部分在这个无限循环中运行:

while True:

在循环的第一行,我们检查传感器是否被触摸,并相应地设置throttle变量。在 Python 中,这种语法称为条件表达式

throttle = 1 if crickit.touch_1.value else 0

我们采用相同的方法,使用color变量在电机开启时将 LED 设置为红色,关闭时设置为蓝色:

color = RGB['red'] if crickit.touch_1.value else RGB['blue']

在计算了throttlecolor值之后,我们将它们应用到电机和 LED 上:

crickit.onboard_pixel.fill(color)
crickit.dc_motor_1.throttle = throttle

最后,我们在开始下一个循环迭代之前,睡眠POLL_DELAY秒:

time.sleep(POLL_DELAY)

完整的应用程序可以保存为 Pi 上的touch.py,然后执行。

列表 2.2 touch.py:当触摸传感器被按下时启动电机

#!/usr/bin/env python3
import time
from adafruit_crickit import crickit

RGB = dict(red=0xFF0000, green=0x00FF00, blue=0x0000FF)
POLL_DELAY = 0.1

crickit.onboard_pixel.brightness = 0.01
while True:
    throttle = 1 if crickit.touch_1.value else 0
    color = RGB['red'] if crickit.touch_1.value else RGB['blue']
    crickit.onboard_pixel.fill(color)
    crickit.dc_motor_1.throttle = throttle
    time.sleep(POLL_DELAY)

当你运行脚本时,你会看到电机最初是不动的,LED 的颜色将是蓝色。如果你按下触摸传感器,LED 的颜色将变为红色,电机将以全速开始移动。如果你停止触摸传感器,LED 将恢复到最初的蓝色,电机将完全停止。图 2.7 将触摸传感器的状态映射到 LED 的颜色和电机的运行状态。

图片

图 2.7 触摸状态图:LED 和电机状态根据触摸事件发生变化。

摘要

  • 树莓派是由树莓派基金会创建的单板计算机。

  • Adafruit CRICKIT HAT 是 Adafruit Industries 为树莓派创建的硬件附加组件。

  • 一旦连接到树莓派,CRICKIT HAT 就可以用来控制 Neopixel 和连接的电机。

  • Adafruit Python CRICKIT 库可以用来运行与 CRICKIT 硬件组件交互的 Python 代码。

  • 树莓派 Imager 是一种软件,可以用来准备带有树莓派 OS 镜像的安装介质(microSD 卡/USB 闪存驱动器)。

  • CRICKIT HAT 通过 GPIO 连接器连接到树莓派。

  • 在 Python 中可以使用 RGB 十六进制颜色代码更改 Neopixel 颜色。

  • 当检查触摸传感器状态时,CRICKIT 库返回一个布尔值,要么是True,要么是False,这取决于触摸传感器的状态。

  • 通过设置节流属性为10,可以开启和关闭直流电机。

  • 可以通过使用不断轮询触摸事件并相应地设置电机节流的循环,根据触摸事件开启和关闭连接的电机。

3 驱动机器人

本章涵盖

  • 控制直流电机使机器人前进和后退

  • 实现软件配置的电机功率调整

  • 控制机器人左右转动

  • 在原地旋转机器人

  • 使用functools库重构代码

本章将教会你如何使用 Python 代码控制直流电机的功率来使机器人向不同方向移动。机器人的左右轮各有一个专用的电机连接。控制电机可以使一系列动作成为可能。将为每个主要动作操作生成 Python 函数,以创建一组易于使用和阅读的方法来控制机器人的动作。一旦所有这些函数都已实现,将应用一系列重构技术来简化并巩固代码库。

3.1 什么是机器人底盘套件?

机器人底盘套件是构建移动机器人的好方法。在前一章中,我们看到了树莓派如何为机器人提供计算能力,以及 CRICKIT HAT 附加组件如何控制连接的电机。底盘套件提供了身体、电机和轮子,使你的机器人能够移动。有许多不同的机器人底盘套件可以与树莓派一起使用。本书推荐的一个选择是价格低廉且灵活,让你能够构建许多不同的机器人配置。套件包括以下主要部件:

  • 两个直流电机

  • 两个轮子

  • 一个用作第三轮的万向球

  • 使用三个铝制框架和安装硬件构建三层机器人

图 3.1 展示了套件中的主要部件。与双层布局相比,三层提供了更大的空间和灵活性,可以在不同层上安装树莓派和电源硬件。直流电机保持在底层。机器底盘中的这些层可以在附录 C 中找到的机器人组装指南中的图像中看到。有关推荐的底盘套件的更多详细信息,请参阅附录 A 中的硬件购买指南。

图 3.1 底盘套件:套件包含三个铝制框架,用于支撑三层机器人。

3.2 硬件堆栈

图 3.2 展示了所讨论的硬件堆栈,以及本章中使用的具体组件被突出显示。随着本书的进展,更多的硬件组件将被纳入机器人项目中。

图 3.2 硬件堆栈:机器人将使用两个直流电机移动。

如前节所述,本章将使用两个直流电机。一个将连接到左轮,另一个将连接到右轮。中心轮将使用一个可以平滑转向任何方向的万向球,且没有连接电机。直流电机将连接到 CRICKIT HAT,它将为它们提供动力和控制。可以使用以太网连接到机器人,但 Wi-Fi 连接提供了机器人无束缚移动的能力。

有关组装和配置机器人硬件的详细信息,请参阅附录 C 中的机器人组装指南。它展示了如何组装本章和其他章节中使用的机器人。

3.3 软件栈

本章所使用的特定软件的详细信息如图 3.3 所示,并在随后的文本中进行描述。应用层使用了底层的库和 Python 解释器。Python 解释器在 Linux 上运行,而 Linux 又运行在硬件上。

图 3.3 软件栈:本章将介绍如何使用 Python 控制直流电机。

建立在第二章的知识基础上,我们将继续使用 Python Adafruit CRICKIT 库与直流电机交互。在本章中,我们将使用相同的脚本控制多个直流电机。我们还将学习如何控制电机的方向和速度。在 Linux 级别,我们将使用环境变量,如ROBO_DC_ADJUST_R,将配置值传递给我们的 Python 脚本以设置电机功率调整。这样,我们就不必直接将配置值硬编码到我们的 Python 代码中。Python 的time模块将用于控制不同运动操作中电机运行的时间。time模块是 Python 标准库的一部分,它提供了一个通过sleep函数暂停脚本执行的标准机制。最后,我们将把这些部分组合起来,在章节末尾创建motor.py脚本和库。

3.4 编写向前移动函数

在本节中,我们将学习如何创建一个 Python 函数,当调用该函数时,机器人将向前移动。我们通过同时打开左轮和右轮的直流电机,让它们运行特定的时间,然后停止它们来实现这一点。这将使机器人向前移动并停止。

在处理机器人时,一个重要的安全方面是拥有一个执行紧急停止的机制。CRICKIT HAT 有一个硬件开关可以打开和关闭板子。我们可以使用这个开关作为我们的紧急停止,因为它将切断所有连接电机的电源。它还有一个额外的优点,即我们可以立即重新打开板子并再次启动我们的应用程序,而无需重新启动 Raspberry Pi。

首先,我们将导入 CRICKIT 库来控制电机,以及time库来控制移动前进的时间:

from adafruit_crickit import crickit
import time

接下来,我们将定义 MOTOR_RMOTOR_L,它们将映射到右侧和左侧电机。在布线机器人时,请确保将右侧直流电机连接到电机连接 1,将左侧直流电机连接到电机连接 2。书中所有的代码都将遵循此约定:

MOTOR_R = crickit.dc_motor_1
MOTOR_L = crickit.dc_motor_2

然后,我们定义一个名为 set_throttle 的辅助函数,它接受两个参数,并将指定电机的节流设置到指定的值:

def set_throttle(motor, value):
    motor.throttle = value

现在,我们可以定义 forward 函数本身,该函数将使机器人向前移动。当被调用时,它首先通过在两个电机上调用 set_throttle 将两个电机设置为以 90%的速度向前运行。然后,通过调用 sleep,它等待 0.2 秒。最后,再次在两个电机上调用 set_throttle 结束移动。这样,调用 forward 函数将使机器人向前移动 0.2 秒,然后使其完全停止:

def forward():
    set_throttle(MOTOR_R, 0.9)
    set_throttle(MOTOR_L, 0.9)
    time.sleep(0.2)
    set_throttle(MOTOR_R, 0)
    set_throttle(MOTOR_L, 0)

完整的脚本可以保存为 forward.py 在 Pi 上,然后执行。

列表 3.1 forward.py:使用函数使电机向前移动

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time

MOTOR_R = crickit.dc_motor_1
MOTOR_L = crickit.dc_motor_2

def set_throttle(motor, value):
    motor.throttle = value

def forward():
    set_throttle(MOTOR_R, 0.9)
    set_throttle(MOTOR_L, 0.9)
    time.sleep(0.2)
    set_throttle(MOTOR_R, 0)
    set_throttle(MOTOR_L, 0)

forward()

在运行脚本时,两个电机应向前移动,使整个机器人向前移动。如果其中一个轮子向后移动而不是向前移动,只需切换该直流电机的连接线。直流电机线可以任意连接,翻转连接线也会翻转正节流的方向。本书遵循的约定是正节流导致向前移动,负节流导致向后移动。如果尚未确认车轮方向,请注意不要将您的机器人放在桌子的边缘,因为它可能会向错误的方向行驶并从桌子上掉下来。

警告:电机速度被设置为 90%的强度而不是 100%的全强度。这是出于电力安全原因。使用某些 USB 移动电源为 CRICKIT HAT 供电并快速切换直流电机方向会导致电力中断,并切断 Raspberry Pi 和 CRICKIT HAT 之间的 I2C 连接。90%的强度提供了高水平的节流和良好的安全水平,以防止这些问题。您可以使用更高的值,但提到的值已经过测试,并在实践中是可靠的。

3.5 使用环境变量进行配置

经常,某些特定于特定机器或硬件设备的配置值需要由 Python 应用程序设置和读取。一些例子是安全凭证或不应直接硬编码到 Python 脚本中的可配置设置。

在我们的情况下,我们的机器人需要为两个直流电机中的每一个设置一个电源配置。在物理世界设置中,两个电机上的相同油门通常不会使它们以完全相同的速度移动。这是由于物理电机之间微小的差异造成的。因为一个电机通常会旋转得比另一个电机快一点,这会使机器人稍微向左或向右偏移,而不是以完美的直线移动。解决方案是调整两个电机之间的油门设置,使它们以相似的速度旋转。这样,当机器人向前移动时,它将更多地沿着直线行驶。我们将创建两个配置值来调整每个电机的功率。这是一个实用且简单的解决方案,将满足本书中项目的需求。一个更高级的解决方案将需要我们在软件中添加硬件传感器和逻辑,通过考虑传感器数据来不断调整两个电机的功率,以保持机器人直线行驶。

为了解决这个问题,我们实现了一个在 Linux 中使用环境变量设置我们的配置值,然后在 Python 中读取和使用这些值的常用技术。解决方案应满足以下要求:

  • 配置值应在 Python 中从一组特定的命名环境变量中读取。

  • 配置值应该是可选的,如果环境变量未设置,它们应回退到特定的默认值。

  • 所有环境变量都是字符串值。应该执行类型转换,将它们设置为适当的数据类型,例如浮点值。

环境变量可以在终端会话中设置和查看。这可以是一个本地终端会话或通过 SSH 的远程会话。Raspberry Pi OS 上的默认终端或 shell 软件称为 Bash。有关终端使用和软件的更多详细信息,请参阅 Raspberry Pi 文档(raspberrypi.com/documentation/usage/terminal)。

首先,我们将定义环境变量的命名以及它们如何被设置。通常,让所有环境变量以相同的前缀开始是一个好主意。这样,当列出所有现有环境变量时,将很容易找到我们自己的变量。我们将使用前缀 ROBO_ 为所有变量命名。运行命令 $ env

执行此命令以设置一个新的环境变量,该变量将调整分配给右侧直流电机的功率。0.8 的值将使右侧直流电机的油门降低,并达到正常油门的 80%,以减慢右侧电机的速度。这可能是在你发现你的右侧电机比左侧电机移动得更快时进行的调整,你想减慢它的速度,以便两个电机具有相似的速度:

$ export ROBO_DC_ADJUST_R="0.8"

再次执行env命令时,你应该在输出中看到我们新的变量。我们可以将此命令的输出用作 Bash |功能的输入,将输出管道传输到另一个命令,该命令将过滤输出。grep命令过滤输出,只显示包含ROBO_文本的行。我们可以运行以下命令来过滤env命令的输出,并仅列出我们的变量:

$ env | grep ROBO_

这些值仅在当前的 Bash 会话中可用。如果你打开一个新的会话或重新启动机器,这些值将会丢失。要使环境变量永久,它应该放在你的.bashrc文件中。编辑此文件并添加export行。现在打开一个新的 Bash 会话并确认值已被设置。

我们现在可以深入到 Python REPL(读取-评估-打印循环)并开始从这些环境变量中读取值。我们将导入os模块,然后开始访问这些值:

>>> import os
>>> os.environ['ROBO_DC_ADJUST_R']
'0.8'

当我们访问一个未设置的值时,将引发KeyError异常:

>>> os.environ['ROBO_DC_ADJUST_L']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.9/os.py", line 679, in __getitem__
    raise KeyError(key) from None
KeyError: 'ROBO_DC_ADJUST_L'

处理可选值的方法是使用get方法,当环境变量不存在时提供默认值:

>>> os.environ.get('ROBO_DC_ADJUST_L', '1')
'1'
>>> os.environ.get('ROBO_DC_ADJUST_R', '1')
'0.8'

现在,我们可以将我们的变量类型转换为float

>>> float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
0.8

现在我们已经一切就绪,我们可以使用这些新更改升级我们之前实现的forward函数。需要注意的是,我们不必设置两个环境变量,因为它们都是可选的。我们将把我们的两个配置值保存到变量中:

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))

我们将把我们的功率调整值保存在一个名为ADJUST的字典中,以便更容易地访问:

ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)

以类似的方式,我们将通过一个名为MOTOR的字典来访问我们的直流电机对象:

MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)

set_throttle函数的实现现在可以更新为接收电机名称作为字符串,并应用基于ADJUST中值的调整后的节流值:

def set_throttle(name, value):
    MOTOR[name].throttle = value * ADJUST[name]

最后,我们可以更新我们的forward函数,使其使用值'R''L'来引用电机:

def forward():
    set_throttle('R', 0.9)
    set_throttle('L', 0.9)
    time.sleep(0.2)
    set_throttle('R', 0)
    set_throttle('L', 0)

完整脚本可以保存为envforward.py在 Pi 上,然后执行。

列表 3.2 envforward.py:从环境变量中读取配置值

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)

def set_throttle(name, value):
    MOTOR[name].throttle = value * ADJUST[name]

def forward():
    set_throttle('R', 0.9)
    set_throttle('L', 0.9)
    time.sleep(0.2)
    set_throttle('R', 0)
    set_throttle('L', 0)

forward()

当脚本运行时,机器人将根据环境配置值中为每个电机定义的特定功率调整来前进。这将允许对每个轮子的功率进行调整,并使其能够以更直的线路行驶。

深入探讨:机器人运动物理学

随着你的机器人项目在具有挑战性的环境中处理更复杂的任务,机器人运动物理学的主题变得越来越重要。例如,如果你的机器人需要在可能有时会滑动的各种表面上行驶,那么可以将牵引控制系统集成到机器人中,以处理这些不同的表面。

另一种场景可能是让机器人驱动一个不平坦但有斜坡的表面。斜坡可能是向上的或向下的。如果斜坡是向上的,我们可能需要给直流电机提供更多的功率,以实现我们在水平表面上相同的速度。另一方面,如果机器人正在向下行驶,那么我们希望减少提供给直流电机的功率,以免速度过快。这种控制是一种标准功能,是许多汽车巡航控制系统中的一部分。同样的系统也可以应用于我们的机器人。我们需要添加传感器来测量我们的速度并相应地调整功率。以下图提供了当机器人在上坡时必须提供给电机的额外功率和力的说明。

在斜坡上驾驶:当向上坡行驶时,需要给电机提供更多的功率。

Allied Motion 提供的关于机器人车辆电动牵引和转向的指南(mng.bz/ZRoZ)是关于机器人转向和牵引控制主题的优秀参考资料。涵盖了诸如车轮和操作表面的特性等内容。它还包括了关于如何创建牵引解决方案及其相关权衡的比较。

3.6 控制运动的速度和持续时间

在下一个升级中,我们将添加控制机器人前进速度和持续时间的功能。目前,持续时间在forward函数中是硬编码的,设置为 0.2 秒。我们将向函数添加一个可选参数,这样它仍然默认为 0.2 秒,但调用函数的代码可以提供其他值。机器人前进的速度可以通过改变提供给电机的油门级别来控制。我们将定义三个速度设置——低、中、高——然后在调用运动函数时指定这些级别之一。

我们将添加可选的duration参数来控制电机运行的时间:

def forward(duration=0.2):
    set_throttle('R', 0.9)
    set_throttle('L', 0.9)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

THROTTLE_SPEED字典将把三个速度级别映射到它们相关的油门级别。速度级别 0 用于停止电机:

THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

我们现在可以更新我们的forward函数来设置期望的速度:

def forward(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

set_throttle函数现在也将使用新的THROTTLE_SPEED字典:

def set_throttle(name, speed):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name]

完整的脚本可以保存为speedforward.py在 Pi 上,然后执行。

列表 3.3 speedforward.py:控制向前移动的电机速度

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name]

def forward(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

print('move forward for 0.5 seconds')
forward(duration=0.5)

for speed in [1, 2, 3]:
    print('move forward at speed:', speed)
    forward(speed=speed)

脚本对forward函数进行了一些函数调用,以展示新的功能。它将使机器人以自定义的半秒持续时间移动。然后它将以三个速度级别中的每一个将机器人向前移动。一旦对forward函数的最后一个调用完成,机器人将停止移动,因为函数通过停止两个电机来结束。

3.7 向后移动

现在我们已经实现了一个向前移动的函数,接下来我们将继续实现使机器人向后移动的函数。这些是我们需要实现的主要移动函数,以实现完整的运动范围。

首先,我们将通过添加一个factor参数来增强set_throttle函数。这个参数将用于控制油门是具有正值以驱动电机向前移动还是具有负值以驱动电机向后移动:

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

接下来,我们需要实现新的backward函数。它与forward函数非常相似,主要区别在于factor参数的值:

def backward(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

完整的脚本可以保存为backward.py在 Pi 上,然后执行。

列表 3.4 backward.py:使电机向后移动

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

def forward(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def backward(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

for i in range(3):
    forward()
    time.sleep(1)
    backward()
    time.sleep(1)

脚本通过使机器人向前和向后移动三次来演示新函数的使用。

3.8 向右转

向右转需要向左右电机提供不同的油门级别。为了更好地理解作用中的力,我们可以查看图 3.4 中的轮子布局。左右轮子上有连接的电机,可以施加不同级别的油门。中心轮子是一个万向球,可以在任何方向上自由移动。该图显示,为了向右转,我们应该向左轮电机施加更强的油门。这将使左轮转得更快,从而将机器人向右转。

图 3.4 轮子布局:左轮更强的油门会使机器人向右转。

现在我们已经拥有了实现使机器人向右移动的函数所需的一切。

新的right函数与之前的一些函数有相似之处。本质上,我们是在向前移动的同时向右转。这是通过给左轮提供两倍的油门来实现的:

def right(duration=0.2, speed=3):
    set_throttle('R', speed, factor=0.5)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

完整的脚本可以保存为right.py在 Pi 上,然后执行。

列表 3.5 right.py:使机器人向右转

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

def forward(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def backward(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def right(duration=0.2, speed=3):
    set_throttle('R', speed, factor=0.5)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

right(1)

脚本调用right函数使机器人向右转 1 秒。

3.9 向左移动和任意方向旋转

我们现在可以实施一套完整的函数,以执行机器人所需的所有移动。以下是我们的函数集需要满足的要求:

  • 创建一组 Python 函数,使机器人能够向前、向后、向右和向左移动,以及向右和向左旋转。

  • 这些函数中的每一个都应该允许我们设置移动操作的时间和速度。

我们已经编写了完成我们的函数集所需的大部分内容。现在我们可以实现剩余的三个移动函数。图 3.5 显示了为了使机器人向右旋转,需要在每个电机上施加的油门方向。

图 3.5 旋转:需要在左右电机上施加油门,使机器人旋转。

新的 left 函数基本上与 right 函数相同,只是更强的油门被放在右轮上,这样机器人就会向左转:

def left(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed, factor=0.5)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

spin_right 函数将通过让轮子以全速相反方向转动,使机器人原地旋转。右轮将向后旋转,左轮将向前旋转,使机器人顺时针旋转:

def spin_right(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

spin_left 函数将使机器人旋转,但这次是逆时针方向:

def spin_left(duration=0.2, speed=3):
    set_throttle('R', speed, factor=1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

完整脚本可以保存为 Pi 上的 fullmotor.py 并执行。

列表 3.6 fullmotor.py: 创建函数以执行所有支持的机器人运动

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

def forward(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def backward(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def right(duration=0.2, speed=3):
    set_throttle('R', speed, factor=0.5)
    set_throttle('L', speed)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def left(duration=0.2, speed=3):
    set_throttle('R', speed)
    set_throttle('L', speed, factor=0.5)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def spin_right(duration=0.2, speed=3):
    set_throttle('R', speed, factor=-1)
    set_throttle('L', speed, factor=1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def spin_left(duration=0.2, speed=3):
    set_throttle('R', speed, factor=1)
    set_throttle('L', speed, factor=-1)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

if __name__ == "__main__":
    left(1)
    spin_right(0.5)
    spin_left(0.5)

脚本现在已实现了所有运动函数。甚至可以将其作为 Python 模块导入并用于其他 Python 脚本。当脚本直接运行时,它将使机器人向左转然后向右和左旋转。然而,如果模块被导入作为库使用,则不会执行这些演示运动。这是通过检查 __name__ 变量的值来实现的,以检测 Python 代码是直接运行还是作为库导入。

3.10 通过查找共同逻辑进行重构

代码重构是改变或简化应用程序内部实现的过程,而不改变其外部行为。在我们的情况下,我们想要简化 motor 库的实现,而不改变任何函数的名称或它们接收的参数。我们将通过创建一个更简单、更易读、更易于维护的库版本来实现这一点。

代码重构的一种方法是在不同的函数之间寻找相似或共享的逻辑,然后将这种逻辑集中在一个函数中,以避免重复。我们可以看到我们的运动函数都共享一个非常相似的结构,即设置左右电机的油门,暂停一段时间,然后停止两个电机的油门。让我们创建一个集中化的函数来实现这个功能,然后让其他函数调用这个主要集中化的函数。

集中化的 movement 函数必须像其他函数一样接受 durationspeed 参数,但它还接收左右电机的系数值,因为这些值在不同的函数中会有所不同。使用这种实现,它应该满足我们已实现的所有函数的需求:

def movement(duration=0.2, speed=3, factor_r=1, factor_l=1):
    set_throttle('R', speed, factor_r)
    set_throttle('L', speed, factor_l)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

我们现在可以从新的 forward 函数中调用 movement 函数。实现非常简单,基本上是原样传递所有参数。我们可以实施一系列类似的更改,将所有旧函数迁移到使用新方法:

def forward(duration=0.2, speed=3):
    movement(duration, speed)

完整脚本可以保存为 Pi 上的 commonmotor.py 并导入。

列表 3.7 commonmotor.py: 将共同逻辑合并到公共函数中

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

def movement(duration=0.2, speed=3, factor_r=1, factor_l=1):
    set_throttle('R', speed, factor_r)
    set_throttle('L', speed, factor_l)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

def forward(duration=0.2, speed=3):
    movement(duration, speed)

def backward(duration=0.2, speed=3):
    movement(duration, speed, factor_r=-1, factor_l=-1)

def right(duration=0.2, speed=3):
    movement(duration, speed, factor_r=0.5)

def left(duration=0.2, speed=3):
    movement(duration, speed, factor_l=0.5)

def spin_right(duration=0.2, speed=3):
    movement(duration, speed, factor_r=-1, factor_l=1)

def spin_left(duration=0.2, speed=3):
    movement(duration, speed, factor_r=1, factor_l=-1)

每个函数的实现现在都变得更加简单。这将使代码的可维护性大大提高,因为所有实际的工作都是由set_throttlemovement函数完成的。它也更容易阅读,因为每个调用本质上只是更改movement函数调用的一些参数。

深入探讨:代码重构

代码重构的过程是软件开发的重要部分。敏捷联盟(www.agilealliance.org/glossary/refactoring)对重构是什么以及其一些好处有一个很好的定义。当我们首次实现一个软件组件时,我们的目标通常只是让事情工作。一旦它开始工作,代码库将随着我们向软件添加更多功能而自然增长。重构是我们花时间后退一步,不添加任何新功能,而是考虑清理或简化我们的代码库的方法。

清洁的代码库的一个好处是提高了可维护性。从长远来看,拥有一个更清洁的代码库将为我们节省大量时间,因为代码变得更加易于管理。它可以通过使查找和修复错误变得更加容易来使软件更加可靠。

与重构相关的一个重要概念是“不要重复自己”(DRY)的设计原则。当我们将 DRY 原则应用于我们的代码时,我们希望避免代码和逻辑中的重复。在第 3.10 节中,我们发现了重复的逻辑,并通过将其集成到一个公共函数中减少了这种重复。许多高质量的框架和库在他们的软件设计中应用了 DRY 哲学,以支持创建避免重复的项目。Python Django Web 框架是这一点的良好例子,其文档(docs.djangoproject.com)有一个专门介绍其设计哲学的页面。它提到了 DRY 和其他创建更清洁代码库的设计原则。

3.11 使用 functools 进行重构

functools模块是 Python 标准库的一部分,它提供了关于可调用对象和函数的几个不同功能。具体来说,我们将使用partial来简化我们定义函数的方式,这对于一个函数本质上非常类似于对另一个函数的调用的场景来说是一个完美的工具,正如我们的情况。

partial的工作方式是,它接受一些现有的函数作为其第一个参数,以及一组位置参数和关键字参数。然后它返回一个新的函数,该函数将使用提供的参数调用原始函数。我们可以用它来简化我们的函数定义。有关partial和使用partial创建的示例函数的更多详细信息,请参阅 Python 文档(docs.python.org/3/library/functools.html)中的functools模块。

我们首先从functools模块导入partial

from functools import partial

forward 函数的新定义现在基本上是一个直接调用 movement,因为它具有直接映射到 forward 函数默认值的默认值:

forward = partial(movement)

backward 的情况下,对 movement 参数的唯一更改是将左右电机都设置为反向转动:

backward = partial(movement, factor_r=-1, factor_l=-1)

我们继续对 rightleft 进行处理,它们使用所有默认值,除了将左右电机速度降低以使机器人转向:

right = partial(movement, factor_r=0.5)
left = partial(movement, factor_l=0.5)

spin_rightspin_left 是通过类似的方法创建的:

spin_right = partial(movement, factor_r=-1, factor_l=1)
spin_left = partial(movement, factor_r=1, factor_l=-1)

我们还添加了一个 noop 函数,它将帮助我们在后面的章节中进行性能测试:

noop = lambda: None

完整的脚本可以保存为 motor.py 并在 Pi 上导入。

列表 3.8 motor.py: 简化函数定义的方式

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time
import os
from functools import partial

DC_ADJUST_R = float(os.environ.get('ROBO_DC_ADJUST_R', '1'))
DC_ADJUST_L = float(os.environ.get('ROBO_DC_ADJUST_L', '1'))
ADJUST = dict(R=DC_ADJUST_R, L=DC_ADJUST_L)
MOTOR = dict(R=crickit.dc_motor_1, L=crickit.dc_motor_2)
THROTTLE_SPEED = {0: 0, 1: 0.5, 2: 0.7, 3: 0.9}

def set_throttle(name, speed, factor=1):
    MOTOR[name].throttle = THROTTLE_SPEED[speed] * ADJUST[name] * factor

def movement(duration=0.2, speed=3, factor_r=1, factor_l=1):
    set_throttle('R', speed, factor_r)
    set_throttle('L', speed, factor_l)
    time.sleep(duration)
    set_throttle('R', 0)
    set_throttle('L', 0)

forward = partial(movement)
backward = partial(movement, factor_r=-1, factor_l=-1)
right = partial(movement, factor_r=0.5)
left = partial(movement, factor_l=0.5)
spin_right = partial(movement, factor_r=-1, factor_l=1)
spin_left = partial(movement, factor_r=1, factor_l=-1)
noop = lambda: None

我们可以通过在相同路径中启动 Python REPL 会话来测试我们的 motor 库的最终版本。在以下会话中,机器人向前和向后移动。然后它向右转半秒,然后以最低速度向左转。接下来,它向右旋转一秒,向左旋转两秒。最后的调用将不会执行任何操作,但应该成功执行且无错误:

>>> import motor
>>> motor.forward()
>>> motor.backward()
>>> motor.right(0.5)
>>> motor.left(speed=1)
>>> motor.spin_right(1)
>>> motor.spin_left(2)
>>> motor.noop()

您可以将此库导入各种应用程序并调用不同的运动函数来创建许多不同的项目。您可以编写一个程序,该程序响应用户输入以使机器人向不同方向移动。或者,您可以编程机器人以正确的向前和转向动作在障碍赛中驾驶,以免撞到任何物体。

深入探讨:库的力量

编写一个独立的程序和编写一个为许多不同软件提供功能的库是两回事。本章早期脚本是一些独立的脚本,可以执行以移动机器人。它们在可执行的操作方面有限,但是一个很好的起点。随着我们构建功能,我们能够将它们全部打包成一个库。随着我们在书中创建不同的项目,我们通常会导入本章的 motor 库并使用其功能。

设计良好的库可以提供一系列可重用的功能,我们可以轻松地将它们集成到自己的程序中。我们可以通过构建在他人伟大作品的基础上,或者,正如艾萨克·牛顿所说,“站在巨人的肩膀上”,将它们结合起来创建各种强大而复杂的应用程序。在这本书中,我们将使用我们创建的库以及一套丰富的开源 Python 库,这些库我们可以轻松安装并集成到我们的机器人项目中。

摘要

  • 机器人底盘套件是构建移动机器人的灵活且经济的方式。

  • 右轮和左轮各自配备有专用的直流电机。

  • 在 Linux 中,可以使用环境变量将配置值传递给 Python 脚本,然后可以使用这些值来配置电机功率调整。

  • 可以通过检测缺失的环境变量并设置默认值以备不时之需,使配置值成为可选的。

  • 通过同时开启左、右直流电机,可以实现机器人的前进运动。

  • 机器人的速度可以通过改变直流电机的油门级别来控制。

  • 通过反转两个直流电机的油门方向,可以实现后退运动。

  • 要使机器人向右转,我们需要对左轮电机施加比右轮电机更强的油门。

  • 要使机器人旋转,我们需要在相反方向上对两个电机施加油门。

  • 代码重构可以简化应用程序的实现方式,而不会改变库中函数的调用方式。

  • 使用 partial 可以使创建函数变得更快、更简单。

4 创建机器人外壳

本章涵盖了

  • 在 Python 中创建交互式自定义外壳的基本知识

  • 创建用于使机器人前后移动的命令循环

  • 在外壳命令中处理命令参数

  • 在代码外壳中集中处理参数逻辑

  • 在 Python 中执行自定义外壳脚本

本章将向您介绍如何创建一个自定义的交互式 REPL(读取-评估-打印循环)机器人外壳。外壳提供了一种强大的交互式界面,允许直接与软件或在此情况下,与机器人硬件进行交互。它们是用户交互的一种经过验证和有效的方法,Python 标准库提供了创建自定义外壳的内置功能。本章从简单的机器人外壳开始,然后逐步添加更多带有更多定制选项的运动功能。本章最后展示了如何在外壳中一次性保存并运行一组命令,就像在许多其他外壳中做的那样。

4.1 什么是 REPL 或外壳?

REPL 或命令行外壳是一个无限循环的程序,等待接收用户输入,然后执行输入,并根据需要打印输出。它们也被称为面向行的命令解释器,因为它们将命令作为用户输入的一行并解释或执行该行。图 4.1 说明了 REPL 经历的三种状态。

图 4.1 读取-评估-打印循环:REPL 在读取-评估-打印状态之间无限循环。

4.2 REPL 的好处

在本书中到目前为止,我们已经与两个提供 REPL 界面的非常流行的程序进行了交互:Python 和 Bash。以下是本章创建的外壳的一些功能和好处:

  • 快速直接执行命令。

  • 简单的逐行执行。

  • 列出可用命令的实时帮助命令。

  • 命令的 Tab 自动完成。

  • 可以通过上下键访问的命令历史记录。

  • 每个命令的可选参数。

  • 在脚本文件中执行命令。

这些功能将用于本章创建的机器人外壳。有了我们的机器人外壳,我们能够从终端快速轻松地发出运动命令给我们的机器人。我们还可以使用命令历史记录功能来重放我们之前应用到机器人上的运动。

4.3 硬件栈

图 4.2 显示了讨论的硬件栈,本章使用的特定组件被突出显示。REPL 通过使用 Tab 自动完成和箭头键来访问命令历史记录进行键盘交互。

图 4.2 硬件栈:机器人外壳通过 CRICKIT 板控制直流电机。

机器人外壳将在 Raspberry Pi 硬件上运行。通过外壳执行的命令将与 CRICKIT 板通信,然后 CRICKIT 板将发送信号到直流电机以执行所需的电机运动。

4.4 软件栈

本章中使用的特定软件的详细信息在图 4.3 中展示,并在随后的文本中进行描述。随着每个新应用的加入,我们将向我们的 shell 实现添加更多功能和增强。

图 4.3 软件堆栈:机器人 shell 将在 Python 解释器上运行。

Python 和 Bash 都有 REPL 功能,并且都直接在 Linux 上使用readline库运行。上一章中的motor模块将被用来控制直流电机。本章中的机器人 shell 将使用 Python 实现,因此它将在 Python 之上运行。用户不需要担心它的实现方式,可以像与计算机上的任何其他 shell 程序一样与之交互。

4.5 创建机器人 shell

在本节中,我们将使用 Python 编写一个程序,实现一个自定义的 REPL 机器人 shell 以获取基本的机器人前进和后退动作。此脚本需要两个模块,我们按照以下代码所示导入它们。cmd模块是 Python 标准库的一部分,它为创建 REPL 应用程序(如机器人 shell)提供了一个框架。cmd模块(docs.python.org/3/library/cmd.html)的文档是学习更多关于该库的绝佳资源。motor模块是上一章中创建的用于控制机器人的库:

import cmd
import motor

接下来,我们定义我们的RobotShell类,它是cmd.Cmd的子类。intro方法在 shell 首次启动时提供欢迎信息。当用户被提示时,prompt文本会显示,表明他们现在处于机器人 shell 中。以do_开头的每个方法都会自动调用以处理其相关命令。这样,do_forward方法就会被调用以处理forward命令。每次调用时,它都会通过调用motor.forward将机器人向前移动:

class RobotShell(cmd.Cmd):
    intro = 'Welcome to the robot shell. Type help or ? to list commands.'
    prompt = '(robot) '

    def do_forward(self, line):
        motor.forward()

接下来,我们定义do_backward方法来处理backward命令:

    def do_backward(self, line):
        motor.backward()

最后一行代码将运行事件循环,启动 shell,从用户输入中读取命令,并执行相应的命令方法:

RobotShell().cmdloop()

完整的脚本可以保存为basic_shell.py在 Pi 上,然后执行。

列表 4.1 basic_shell.py:在 shell 中提供基本的机器人运动

#!/usr/bin/env python3
import cmd
import motor

class RobotShell(cmd.Cmd):
    intro = 'Welcome to the robot shell. Type help or ? to list commands.'
    prompt = '(robot) '

    def do_forward(self, line):
        motor.forward()

    def do_backward(self, line):
        motor.backward()

RobotShell().cmdloop()

在执行脚本时,请确保motor.pybasic_shell.py在同一目录下,以便它可以被basic_shell.py导入。以下代码显示了在机器人 shell 中执行help和运动命令的一个会话示例:

$ basic_shell.py
Welcome to the robot shell. Type help or ? to list commands.
(robot) help

Documented commands (type help <topic>):
========================================
help

Undocumented commands:
======================
backward  forward

(robot) forward
(robot) backward
(robot) backward
(robot) forward

我们可以像其他 Python 脚本一样通过 SSH 连接远程运行 shell。在运行 shell 时,可以按 Tab 键使用自动完成功能,上箭头和下箭头可以用来访问命令历史记录功能。按 F 键然后按 Tab 键,可以使命令forward自动完成。当您完成使用机器人外壳后,可以按 CTRL+C 来退出,就像在其他程序中一样。

深入探讨:导入库

在本章中,我们通过导入motor库来构建前一章的代码。我们可以将创建的所有脚本和库保存在/home/robo/bin/目录中,以简化导入模块的过程。但其他我们一直在使用的库在哪里,Python 解释器在导入模块时是如何找到它们的?

我们可以进入 REPL(交互式 Python 解释器)来获取这些问题的答案。我们导入sys模块,然后检查sys.path的内容:

>>> import sys
>>> sys.path
['', '/usr/lib/python39.zip', '/usr/lib/python3.9',
 '/usr/lib/python3.9/lib-dynload',
 '/home/robo/pyenv/lib/python3.9/site-packages']

sys.path中,有一个字符串列表,这些路径是在导入模块时需要搜索的路径。如果我们检查这些目录,我们将找到我们在书中导入的模块。例如,标准库中cmd模块的位置可以使用以下方式列出:

$ ls /usr/lib/python3.9/cmd.py 
/usr/lib/python3.9/cmd.py

我们可以打开这个文件,像其他 Python 脚本一样调查其源代码。我们还可以使用pip命令找到我们在虚拟环境中安装的第三方库的位置。以下是用于控制直流电机的 Adafruit CRICKIT 库的位置:

$ ls /home/robo/pyenv/lib/python3.9/site-packages/adafruit_crickit.py 
/home/robo/pyenv/lib/python3.9/site-packages/adafruit_crickit.py

我们可以看到,Python 标准库中的库位于所有虚拟环境共享的系统位置,而我们在为虚拟环境创建的/home/robo/pyenv位置安装的第三方包都位于那里。有关 Python 中导入包的更多详细信息,importlib模块的文档(docs.python.org/3/library/importlib.html)是一个很好的资源。

4.6 处理命令参数

我们实现了一个基本的机器人外壳,包含两个运动命令,forward(前进)和backward(后退)。然而,它们无法处理运动命令之后提供的任何参数。我们将添加对每个运动命令提供duration(持续时间)参数的支持。我们还将改进退出外壳的方式,通过正确处理输入的文件结束符(EOF)。

现在,我们增强do_forward方法,以检查是否提供了持续时间。forward命令之后的文本将通过line参数提供,我们可以解析它以获取持续时间。如果找到持续时间,它将被转换为float值,并在调用motor.forward函数时使用:

    def do_forward(self, line):
        if line:
            duration = float(line)
            motor.forward(duration)
        else:
            motor.forward()

然后将相同的流程应用于do_backward方法:

    def do_backward(self, line):
        if line:
            duration = float(line)
            motor.backward(duration)
        else:
            motor.backward()

在类中添加了 do_EOF 方法来处理在输入数据中遇到 EOF 条件的情况。该方法返回 True 值以向事件循环发出信号,外壳应退出:

    def do_EOF(self, line):
        return True

完整的脚本可以保存为 Pi 上的 arg_shell.py 并执行。

列表 4.2 arg_shell.py:在外壳中支持命令参数

#!/usr/bin/env python3
import cmd
import motor

class RobotShell(cmd.Cmd):
    intro = 'Welcome to the robot shell. Type help or ? to list commands.'
    prompt = '(robot) '

    def do_EOF(self, line):
        return True

    def do_forward(self, line):
        if line:
            duration = float(line)
            motor.forward(duration)
        else:
            motor.forward()

    def do_backward(self, line):
        if line:
            duration = float(line)
            motor.backward(duration)
        else:
            motor.backward()

RobotShell().cmdloop()

接下来是在机器人外壳中调用不同持续时间移动命令的会话示例:

$ arg_shell.py
Welcome to the robot shell. Type help or ? to list commands.
(robot) forward 0.2
(robot) forward 1
(robot) backward 0.5
(robot) backward
(robot) forward

在示例会话中,移动命令使用整数和浮点数表示的持续时间调用。也可以不提供任何持续时间,将使用默认的持续时间值。当你想要退出机器人外壳时,按下 CTRL+D 而不是 CTRL+C。这将以一种更干净的方式退出外壳,因为 CTRL+D 会发送 EOF,而按下 CTRL+C 将导致外壳输出 Traceback 错误。使用 CTRL+D 退出外壳是一个标准程序,与 Bash 和 Python REPL 的相同过程将起作用。

4.7 添加速度参数

为了支持多个可选参数,我们不得不做一些额外的工作。由于我们需要升级我们的参数处理行为,最好是在多个地方不需要更改它。因此,第一步将是将所有移动命令的参数处理代码集中在一个函数中,然后升级该函数。

我们创建了一个新的函数 get_kwargs,它将接受 line 值并返回一个包含所有必需键值对的 dict 对象。以下定义将覆盖现有的行为,即取第一个可选参数作为 duration 的值:

def get_kwargs(line):
    if line:
        return dict(duration=float(line))
    else:
        return dict()

然后,我们更新 do_forwarddo_backward 的定义以使用 get_kwargs。它们调用 get_kwargs 并直接使用返回值作为函数调用到相关移动函数的关键字参数:

    def do_forward(self, line):
        motor.forward(**get_kwargs(line))

    def do_backward(self, line):
        motor.backward(**get_kwargs(line))

在这个阶段,我们可以运行外壳,它将使用之前的行为。现在我们可以升级 get_kwargs 函数并添加处理第二个关键字参数 speed 的功能。这个参数预期是 int 类型,因此将其转换为该数据类型:

def get_kwargs(line):
    kwargs = dict()
    items = line.split()
    if len(items) > 0:
        kwargs['duration'] = float(items[0])
    if len(items) > 1:
        kwargs['speed'] = int(items[1])
    return kwargs

完整的脚本可以保存为 Pi 上的 speed_shell.py 并执行。

列表 4.3 speed_shell.py:在外壳中控制移动速度

#!/usr/bin/env python3
import cmd
import motor

def get_kwargs(line):
    kwargs = dict()
    items = line.split()
    if len(items) > 0:
        kwargs['duration'] = float(items[0])
    if len(items) > 1:
        kwargs['speed'] = int(items[1])
    return kwargs

class RobotShell(cmd.Cmd):
    intro = 'Welcome to the robot shell. Type help or ? to list commands.'
    prompt = '(robot) '

    def do_EOF(self, line):
        return True

    def do_forward(self, line):
        motor.forward(**get_kwargs(line))

    def do_backward(self, line):
        motor.backward(**get_kwargs(line))

RobotShell().cmdloop()

下面是在升级后的机器人外壳中的一个会话示例:

$ speed_shell.py 
Welcome to the robot shell. Type help or ? to list commands.
(robot) forward 0.2 1
(robot) forward 0.2 3
(robot) backward 0.5 1
(robot) backward 1 2
(robot) backward 0.5
(robot) forward

在示例会话中,现在可以指定持续时间并使用默认速度调用移动命令,也可以指定特定的持续时间或速度,或者使用默认的持续时间或速度设置。

4.8 运行机器人外壳脚本

在本节中,挑战是使机器人外壳能够执行命令脚本并添加剩余的移动命令。我们添加了 do_rightdo_leftdo_spin_rightdo_spin_left 方法。它们遵循我们之前的移动方法的相同风格:

    def do_right(self, line):
        motor.right(**get_kwargs(line))

    def do_left(self, line):
        motor.left(**get_kwargs(line))

    def do_spin_right(self, line):
        motor.spin_right(**get_kwargs(line))

    def do_spin_left(self, line):
        motor.spin_left(**get_kwargs(line))

当我们在 shell 中执行脚本文件中的命令时,能够得到一些关于正在执行哪个命令的视觉反馈将会很棒。我们可以通过添加一个precmd方法来实现这一点。这个方法在执行任何命令之前被调用。这是cmd.Cmd对象的一部分功能。我们将使用它来打印即将执行的命令。为了使事件循环处理命令,它必须返回line的值:

    def precmd(self, line):
        print('executing', repr(line))
        return line

完整的脚本可以保存为 Pi 上的shell.py,然后执行。

列表 4.4 shell.py:创建支持所有机器人动作的 shell

#!/usr/bin/env python3
import cmd
import motor

def get_kwargs(line):
    kwargs = dict()
    items = line.split()
    if len(items) > 0:
        kwargs['duration'] = float(items[0])
    if len(items) > 1:
        kwargs['speed'] = int(items[1])
    return kwargs

class RobotShell(cmd.Cmd):
    intro = 'Welcome to the robot shell. Type help or ? to list commands.'
    prompt = '(robot) '

    def do_EOF(self, line):
        return True

    def precmd(self, line):
        print('executing', repr(line))
        return line

    def do_forward(self, line):
        motor.forward(**get_kwargs(line))

    def do_backward(self, line):
        motor.backward(**get_kwargs(line))

    def do_right(self, line):
        motor.right(**get_kwargs(line))

    def do_left(self, line):
        motor.left(**get_kwargs(line))

    def do_spin_right(self, line):
        motor.spin_right(**get_kwargs(line))

    def do_spin_left(self, line):
        motor.spin_left(**get_kwargs(line))

RobotShell().cmdloop()

以下命令文本文件应保存为 Pi 上的move.txt

spin_right
spin_left
right
left
forward 0.2 1
forward 0.2 3
backward 0.5 1
backward 0.5

作为初始测试,我们可以使用echo将单个命令输入到机器人 shell 中:

$ echo forward | shell.py 
Welcome to the robot shell. Type help or ? to list commands.
(robot) executing 'forward'
(robot) executing 'EOF'

我们也可以使用cat将一组保存的命令输入到机器人 shell 中:

$ cat move.txt | shell.py 
Welcome to the robot shell. Type help or ? to list commands.
(robot) executing 'spin_right'
(robot) executing 'spin_left'
(robot) executing 'right'
(robot) executing 'left'
(robot) executing 'forward 0.2 1'
(robot) executing 'forward 0.2 3'
(robot) executing 'backward 0.5 1'
(robot) executing 'backward 0.5'
(robot) executing 'EOF'

以这种方式,我们可以使用一组最适合我们需求的命令来设计自己的 shell。一组机器人 shell 命令可以交互式运行,或者保存到一个单独的文件中,然后直接由机器人 shell 执行。

深入探讨:增强 shell

我们可以将我们的 shell 进一步扩展并添加一些更强大的功能。在cmd的文档(docs.python.org/3/library/cmd.html)中,有一个对我们机器人的用例非常有用的功能。文档展示了如何通过创建记录和回放会话的命令来记录并稍后回放 REPL 会话。假设我们正在使用机器人 shell 通过一组有效的动作来移动机器人绕行一个物理课程。而不是每次都需要重新输入它们,我们可以随时记录和回放机器人的动作。

另一个常见且强大的功能是通过命令行参数执行机器人命令。Python Module of the Week 网站是一个深入了解 Python 标准模块不同部分的绝佳资源,它关于cmd模块的文档([pymotw.com/3/cmd/](https://pymotw.com/3/cmd/))展示了使用该模块的许多不同方式,包括如何从命令行参数解析命令。Python 解释器本身就有这个功能。我们在书中之前已经使用过 REPL,但我们也可以通过将代码作为命令行参数直接传递给解释器来评估 Python 代码。以下是一个简单示例,演示如何使用此功能:

$ python -c 'print(1+1)'
2

我们可以添加到外壳的第三个有用功能是能够在外壳会话之间保留我们的命令历史。目前,当我们使用我们的外壳时,我们可以使用上箭头和下箭头来回溯我们发出的命令。但一旦我们退出外壳,我们就失去了这个历史。其他外壳,如 Python REPL,在 REPL 会话之间保留历史。这是通过我们在退出外壳时保存历史文件并在启动新外壳时重新加载它来实现的。我们可以通过以下命令查看这一功能:打开一个 REPL 并评估一些 Python 表达式。现在退出 REPL 并打开一个新的。如果你按上箭头,你将找到你的历史命令。我们可以找到存储这个历史的文件,并使用以下命令输出其内容:

$ ls ~/.python_history 
/home/robo/.python_history
$ cat ~/.python_history 

要在我们的机器人外壳中实现此功能,我们将使用readline模块(docs.python.org/3/library/readline.html),这是处理我们外壳命令历史功能的东西。它有一组函数,将允许我们将历史保存到历史文件中。readline模块的 Python 模块每周页面有一个实现此功能的优秀示例(pymotw.com/3/readline/)。我们只需要在我们的外壳启动时添加几行代码来加载历史文件,然后在退出外壳时添加一些代码来保存历史文件。

摘要

  • REPL 是一个无限循环的程序,等待接收用户输入。

  • 通过机器人外壳执行的命令将与 CRICKIT 板通信,然后 CRICKIT 板将发送信号到直流电机以执行所需的电机运动。

  • Python 和 Bash 都有 REPL 功能,并且两者都直接在 Linux 上运行。

  • cmd模块是 Python 标准库的一部分,它提供了一个创建 REPL 应用程序(如机器人外壳)的框架。

  • do_EOF方法用于处理输入数据中遇到的 EOF 条件。

  • 可选参数使得可以以指定持续时间和默认速度、特定持续时间和速度,或默认持续时间和速度设置来调用移动命令。

  • 可以使用cat命令将一组保存的命令输入到机器人外壳中。

5 远程控制机器人

本章涵盖

  • 通过 SSH 在网络中执行机器人命令

  • 创建用于控制机器人的 Web 服务

  • 从 Python 调用机器人网络服务

  • 创建基于 Python 的远程执行函数

  • 测量本地和远程 HTTP 命令的执行时间

  • 构建具有低延迟调用的高性能 Python 客户端

本章将教会你如何通过网络共享你的机器人,以便远程 Python 客户端可以发出运动命令来控制它。将使用 SSH 和 HTTP 协议,这意味着从协议的角度将提供两种解决方案,每种方案都有其自身的优点和权衡。对于 HTTP 解决方案,首先将创建一个简单的 Python 客户端,然后是一个更复杂、性能更高、延迟更低的客户端。此外,还将涵盖不同的测量执行时间的技术。这将提供一个定量基础来比较不同协议和客户端的性能。

远程控制机器人是许多项目的基本部分,例如使用手机和笔记本电脑上的应用程序控制机器人,以及使用中央机器人服务器控制机器人编队。本章中的项目使得可以在同一房间或数英里之外控制机器人。与蓝牙等短距离协议不同,SSH 和 HTTP 都支持短距离和长距离连接。

5.1 硬件堆栈

图 5.1 展示了硬件堆栈,本章中使用的特定组件被突出显示。机器人将通过以太网端口连接到有线网络,通过 Wi-Fi 硬件连接到无线网络。Wi-Fi 连接为机器人提供了最大的移动自由度,允许它在没有任何连接线的情况下移动。然而,有时有线以太网连接可以提供更好的性能。本章将展示如何进行网络性能测量,以便比较这两种选项。

图 5.1

图 5.1 硬件堆栈:远程客户端将使用以太网或 Wi-Fi 硬件进行连接。

5.2 软件堆栈

本章中使用的特定软件的详细信息在图 5.2 中展示,并在随后的文本中进行描述。本章将实现三个主要应用:通过 SSH 协议(ssh_client.py)实现远程客户端、提供机器人网络服务(robows.py)以及使用持久连接的 Web 服务客户端(client_persist.py)。将使用 Tornado Web 框架来创建 HTTP 解决方案。在构建 SSH 解决方案时将使用subprocessargparse Python 模块。第一个 HTTP 客户端将使用urllib模块,然后是一个更高级的版本,使用http.client模块。

图 5.2

图 5.2 软件堆栈:Tornado Web 框架将通过 HTTP 协议公开机器人命令。

深入探讨:Web 框架

在 Python 中创建网络应用时,几乎总是使用网络框架是一个好主意。创建网络应用时需要处理的细节非常多,而网络框架在这方面做得非常好。在 Python 中,我们有众多优秀的选择,例如 Django(www.djangoproject.com)和 Flask(flask.palletsprojects.com)网络框架。

在我们的案例中,我们将使用 Tornado 网络框架(www.tornadoweb.org),因为它具有一个特殊功能,使其非常适合我们的需求。大多数网络框架,如 Django 和 Flask,都不附带一个生产就绪的网络应用服务器,无法安全地控制像我们的机器人电机这样的硬件。然而,Tornado 提供了这样的选项。它允许我们的网络应用在整个网络服务器生命周期内运行在一个单一的长运行进程中。此进程也获得了对我们机器人电机的独家访问权限,因为它一次只允许一个网络请求移动机器人电机。这样,我们可以避免竞态条件,并保持我们的网络应用安全且易于实现。

5.3 机器人测试技巧

有关组装和配置机器人硬件的详细信息,请参阅附录 C 中的机器人组装指南。有两个技巧可以帮助您在处理机器人时使用。

本章中组装的机器人。第一个技巧是在最初测试代码库时将机器人放置在支架上。图 5.3 显示了机器人放置在支架上,使其轮子可以自由移动而机器人不会移动。这样,您可以在测试期间安全地将机器人放在桌子上,而不用担心不小心将其从桌子上开走并损坏它。这对于您有新代码且尚未测试,可能会进入启动电机而不停止的状态特别有用,这可能导致机器人向某个方向移动,最终撞到墙壁或其他物体。

图片

图 5.3 机器人支架:为了安全起见,可以将机器人放置在支架上。

第二个技巧是使用 SlimRun 以太网电缆而不是标准电缆。这些电缆比标准网络电缆轻且薄,当机器人通过有线网络连接驱动时,这为机器人提供了更多的灵活性。图 5.4 显示了连接到机器人的 SlimRun 网络电缆。

图片

图 5.4 机器人网络电缆:网络电缆连接到机器人上的以太网端口。

使用这两个技巧可以保护您的机器人免受不必要的损坏,并使其在有线连接中更加灵活。请确保您的机器人安全,因为一个损坏的机器人并不好玩。

5.4 通过 SSH 控制机器人

我们将使用 SSH 协议作为我们第一个解决方案来通过网络控制机器人。SSH 更容易上手,因为我们已经设置了 SSH 服务器,并且我们将在整个书中使用 SSH 客户端和连接来连接到我们的机器人。我们需要在 SSH 之上创建一些 Python 代码以满足以下要求:

  • 创建一个将在 SSH 服务器上执行的 Python 脚本,接收动作和可选的动作参数,并执行机器人动作。

  • 为 SSH 客户端创建一个 Python 函数,该函数接收动作名称并连接到机器人,然后远程执行相同的动作。

5.4.1 创建服务器端脚本

第一步是导入所有必要的模块。我们从 Python 标准库中的 argparse 模块导入 ArgumentParser,该模块将执行所有繁重的解析命令行参数的工作。motor 模块是第三章中创建的用于控制机器人的库:

from argparse import ArgumentParser
import motor

然后定义了 parse_args 函数,并负责处理所有命令行参数解析。它首先创建一个 ArgumentParser 对象,然后配置解析器。一个名为 name 的必需参数将捕获动作名称。然后配置可选的 --duration--speed 参数。它们都配置了正确的数据类型和帮助信息。函数的最后一条语句将执行实际的解析,并使用 vars 函数将结果作为 dict 对象返回:

def parse_args():
    parser = ArgumentParser(description='robot cli')
    parser.add_argument('name', help='name of movement')
    parser.add_argument('--duration', type=float, help='movement duration')
    parser.add_argument('--speed', type=int, help='movement speed')
    return vars(parser.parse_args())

main 函数首先调用 parse_args 并将结果保存到 args 变量中。要调用的动作函数名称然后从 args 中移除并保存到 name 变量中。现在可以使用 getattrmotor 模块中查找动作函数。下一步是收集所有指定的可选参数并将它们保存到一个名为 kwargs 的字典中。最后,打印并调用要调用的函数:

def main():
    args = parse_args()
    name = args.pop('name')
    func = getattr(motor, name)
    kwargs = {k: v for k, v in args.items() if v}
    print(f'calling {name} with kwargs {kwargs}')
    func(**kwargs)

完整的脚本可以保存为 Pi 上的 cli.py 文件,然后执行。

列表 5.1 cli.py:创建用于执行机器人动作的命令行界面

#!/usr/bin/env python3
from argparse import ArgumentParser
import motor

def parse_args():
    parser = ArgumentParser(description='robot cli')
    parser.add_argument('name', help='name of movement')
    parser.add_argument('--duration', type=float, help='movement duration')
    parser.add_argument('--speed', type=int, help='movement speed')
    return vars(parser.parse_args())

def main():
    args = parse_args()
    name = args.pop('name')
    func = getattr(motor, name)
    kwargs = {k: v for k, v in args.items() if v}
    print(f'calling {name} with kwargs {kwargs}')
    func(**kwargs)

main()

以下代码是终端中演示对脚本的不同调用的会话:

$ cli.py 
usage: cli.py [-h] [--duration DURATION] [--speed SPEED] name
cli.py: error: the following arguments are required: name
$ cli.py --help
usage: cli.py [-h] [--duration DURATION] [--speed SPEED] name

robot cli

positional arguments:
  name                 name of movement

optional arguments:
  -h, --help           show this help message and exit
  --duration DURATION  movement duration
  --speed SPEED        movement speed
$ cli.py forward
calling forward with kwargs {}
$ cli.py forward --duration=0.5
calling forward with kwargs {'duration': 0.5}
$ cli.py forward --speed=1
calling forward with kwargs {'speed': 1}
$ cli.py forward --duration=0.5 --speed=1
calling forward with kwargs {'duration': 0.5, 'speed': 1}

脚本首先不带任何参数调用,这表明必需参数验证正在工作。接下来,脚本带帮助选项调用,这显示了自动生成的帮助和用法信息。然后请求机器人以默认和自定义的持续时间和速度选项向前移动。

深入了解:Python 中的函数作为一等对象

在本节中,我们能够使用getattr查找一个函数并将其保存到变量中。这并不是所有编程语言都支持的,但在 Python 中完全支持,因为函数被视为一等对象。这意味着它们可以被分配给变量、放置在列表中或作为参数传递给其他函数,就像任何其他值一样。Python 语言的创造者 Guido van Rossum 的帖子“First-class Everything”(mng.bz/g7Bl)是了解 Python 中此功能的好文章。实际上,这个特性适用于 Python 中的所有对象,而不仅仅是函数。这使得语言在如何与函数交互方面非常灵活。

5.4.2 远程运行脚本

现在我们已经放置了脚本,我们将开始在 SSH 客户端中调用它。SSH 客户端可以是与机器人处于同一网络上的任何计算机。在客户端机器上,运行以下命令以生成 SSH 密钥并将它们传输到机器人:

ssh-keygen -t rsa
ssh-copy-id robo@robopi

根据附录 B 中记录的安装过程,Pi 的主机名被设置为robopi。现在您可以在客户端机器的 hosts 文件中添加一行,包含robopi主机名及其关联的 IP 地址。这样,您就可以按照示例使用robopi名称而不是机器人的 IP 地址来连接机器人。How-To Geek 网站提供了一个关于如何在 Windows、Mac 和 Linux 上编辑 hosts 文件的优秀指南(mng.bz/5owz)。

在这个阶段,您将能够从客户端机器上执行机器人命令,而无需输入密码,并且无需交互式会话。以下是从客户端机器上运行的终端会话:

$ ssh robo@robopi whoami
robo
$ ssh robo@robopi '~/pyenv/bin/python --version'
Python 3.9.2

在这个会话中,执行了不同的远程命令来获取当前用户名和 Python 解释器的版本。这里使用的 Python 虚拟环境与附录 B 中安装过程中创建的虚拟环境相同。接下来,我们可以尝试执行机器人脚本。我们将从第二章中描述的标准脚本位置~/bin运行脚本:

$ ssh robo@robopi '~/pyenv/bin/python ~/bin/cli.py --help'
usage: cli.py [-h] [--duration DURATION] [--speed SPEED] name

robot cli

positional arguments:
  name                 name of movement

optional arguments:
  -h, --help           show this help message and exit
  --duration DURATION  movement duration
  --speed SPEED        movement speed
$ ssh robo@robopi '~/pyenv/bin/python ~/bin/cli.py forward'
calling forward with kwargs {}

我们已经生成了脚本的帮助信息,并请求机器人通过 SSH 连接远程前进。现在我们可以使用time命令来测量在本地运行命令和通过 SSH 远程运行命令时的执行时间。time命令在 Mac 和 Linux 上都是可用的。如果您使用的是 Windows,可以使用 PowerShell 的Measure-Command来测量执行时间。输出首先显示在机器人本地运行命令所需的时间,然后是建立 SSH 连接并执行命令所需的时间。我们关注的时间列在标签real下。在这个会话中,本地执行耗时 10 毫秒,而通过 SSH 执行相同的命令耗时 314 毫秒:

$ time whoami
robo
real    0m0.010s
user    0m0.001s
sys    0m0.010s

$ time ssh robo@robopi whoami
robo
real    0m0.314s
user    0m0.084s
sys    0m0.004s

这种额外时间的原因是,在这种方法中,每次要执行新命令时,都必须建立一个新的 SSH 连接。了解这种方法的开销是有用的。motor 模块有一个 noop 函数,它执行无操作,非常适合测量模块中纯函数调用的执行时间:

$ time cli.py noop
calling noop with kwargs {}
real    0m0.713s
user    0m0.128s
sys    0m0.066s

$ time ssh robo@robopi '~/pyenv/bin/python ~/bin/cli.py noop'
calling noop with kwargs {}
real    0m1.036s
user    0m0.083s
sys    0m0.005s

从输出中,我们可以看到本地调用耗时 713 毫秒,而远程调用耗时 1,036 毫秒。差异为 323 毫秒,这与我们之前对 SSH 过程开销的采样相符。time 命令是进行快速性能测量的好方法。在章节的后面部分,当我们改进这些性能数字时,我们将探讨在 Python 内部更精确地测量性能的方法。

5.4.3 创建客户端脚本

下一步是实现运行在客户端机器上的 Python 函数,连接到机器人 SSH 服务器并执行机器人命令。check_output 函数是从 subprocess 模块导入的,它是 Python 标准库的一部分。我们可以使用 check_output 来执行所需的 SSH 客户端命令:

from subprocess import check_output

然后定义了三个常量。SSH_USERSSH_HOST 分别指定用于 SSH 连接的用户和主机。SSH_CLI_CMD 包含要远程在机器人上执行的 Python 解释器和机器人脚本的路径:

SSH_USER = 'robo'
SSH_HOST = 'robopi'
SSH_CLI_CMD = '~/pyenv/bin/python ~/bin/cli.py'

接下来,我们定义了 call_ssh,它将以用户 user 的身份连接到主机 host 并在远程服务器上执行提供的 remote_cmd

def call_ssh(user, host, remote_cmd):
    cmd = ['ssh', f'{user}@{host}', remote_cmd]
    check_output(cmd)

remote_robot 接收要执行的机器人移动命令的名称,并在远程机器人上执行该移动:

def remote_robot(robot_cmd):
    call_ssh(SSH_USER, SSH_HOST, SSH_CLI_CMD + ' ' + robot_cmd)

最后,main 函数遍历一系列移动,并对每个移动调用 remote_robot 以进行演示:

def main():
    commands = ['forward', 'backward', 'spin_right', 'spin_left']
    for command in commands:
        print('remote robot command:', command)
        remote_robot(command)

完整脚本可以保存为客户端机器上的 ssh_client.py 并执行。

列表 5.2 ssh_client.py:通过 SSH 连接执行远程脚本

#!/usr/bin/env python3
from subprocess import check_output

SSH_USER = 'robo'
SSH_HOST = 'robopi'
SSH_CLI_CMD = '~/pyenv/bin/python ~/bin/cli.py'

def call_ssh(user, host, remote_cmd):
    cmd = ['ssh', f'{user}@{host}', remote_cmd]
    check_output(cmd)

def remote_robot(robot_cmd):
    call_ssh(SSH_USER, SSH_HOST, SSH_CLI_CMD + ' ' + robot_cmd)

def main():
    commands = ['forward', 'backward', 'spin_right', 'spin_left']
    for command in commands:
        print('remote robot command:', command)
        remote_robot(command)

main()

此脚本向机器人发出远程命令,使其向前、向后移动,然后向右和向左旋转。

5.5 创建机器人 Web 服务

我们现在将使用 HTTP 协议创建用于远程控制机器人的 Web 服务。Web 服务允许机器在网络中相互调用。我们需要在 Python 中实现一组满足以下要求的 Web 服务:

  • 应该在 Python 中创建一组可以调用以执行移动动作的 Web 服务,并可以提供可选的速度和持续时间参数。

  • Web 服务应正确使用 HTTP 方法。具体来说,使用 GET 方法的调用不应改变机器人的状态,并且所有移动请求都应使用 POST 方法处理。

  • 所有 Web 服务调用都应返回其数据为 JSON 格式,并且任何预期的 Web 服务输入数据也应编码为 JSON 格式。

5.5.1 创建我们的第一个 Web 服务

在 Python 中创建网络应用程序时,通常使用网络框架是一个好主意。有许多流行的选项可供选择。我们将使用 Tornado 网络框架,因为它具有许多功能,并且可以安全地与机器人硬件交互。在 Pi 上运行以下行以安装 Tornado Python 包:

$ ~/pyenv/bin/pip install tornado

我们可以开始创建我们的第一个网络应用程序。这个网络应用程序将公开一个网络服务,该服务返回我们机器人服务器上的当前时间。首先,我们导入所需的模块。datetime 模块将允许我们获取服务器上的当前时间。Tornado 的 IOLoop 是运行网络服务器所需的。RequestHandlerApplication 对象将帮助我们定义网络应用程序的行为:

from datetime import datetime
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application

下一步是定义将处理 incoming 请求的 MainHandler 对象。我们定义了一个名为 get 的方法来处理 incoming HTTP GET 请求。每次调用它时,它将当前时间作为字符串保存,然后调用字典中的 write 方法。在 Tornado 框架中,每次您向 write 方法提供字典对象时,它将自动将输出转换为 JSON 格式,并设置适当的 HTTP 响应头以指示内容类型为 JSON:

class MainHandler(RequestHandler):
    def get(self):
        stamp = datetime.now().isoformat()
        self.write(dict(stamp=stamp))

我们随后创建了一个 Tornado 应用程序,该程序将根路径的 incoming 请求路由到 MainHandler。之后,我们将网络服务器设置为监听端口 8888 并启动主事件循环,这将启动网络服务器并处理 incoming 网络请求:

app = Application([('/', MainHandler)])
app.listen(8888)
IOLoop.current().start()

完整的脚本可以保存为 datews.py 在 Pi 上,然后执行。

列表 5.3 datews.py:创建用于报告机器人服务器时间的网络服务

#!/usr/bin/env python3
from datetime import datetime
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application

class MainHandler(RequestHandler):
    def get(self):
        stamp = datetime.now().isoformat()
        self.write(dict(stamp=stamp))

app = Application([('/', MainHandler)])
app.listen(8888)
IOLoop.current().start()

在一个终端中运行脚本,然后从另一个连接到 Pi 的终端运行以下命令来测试新的网络服务:

$ curl http://localhost:8888/
{"stamp": "2022-11-27T16:52:36.248068"}

终端会话正在使用 curl 命令,这是一个在终端中制作 HTTP 请求并查看其响应的出色工具。第一次调用显示了返回的 JSON 数据,其中包含一个时间戳,显示了机器人服务器上的当前时间。我们现在可以运行以下命令来获取更多关于响应头部的详细信息:

$ curl -i http://localhost:8888/
HTTP/1.1 200 OK
Server: TornadoServer/6.2
Content-Type: application/json; charset=UTF-8
Date: Sun, 27 Nov 2022 16:52:49 GMT
Etag: "d00b59ccd574e3dc8f86dcadb1d349f53e7711ec"
Content-Length: 39

{"stamp": "2022-11-27T16:52:49.683872"}

这个调用显示了响应头,我们可以看到响应内容类型已正确设置为 JSON 输出。您可以通过将 localhost 替换为网络上机器人的 IP 地址,从您的网络上的任何网络浏览器进行这些网络请求。

5.5.2 创建执行机器人移动的网络服务

我们实现了一个简单的网络服务。现在我们可以升级代码以添加网络服务,使机器人能够移动。我们必须导入两个额外的模块。我们将使用 json 模块来解析 JSON 请求数据。motor 模块将用于控制机器人电机,正如前几章所述:

import json
import motor

我们将 URL 模式更改为接受任何字符串,然后在调用处理请求的方法时传递该字符串作为参数。我们这样做是为了能够将执行动作的名称作为 URL 提供:

app = Application([('/(.*)', MainHandler)])

这意味着我们还需要更新之前的方法,使其能够接受一个 name 参数:

    def get(self, name):

移动 Web 服务将改变机器人的状态,因此我们将在接收到 POST 请求时执行它们。post 方法将通过首先读取请求数据并将其解析为 JSON 数据来处理这些请求。如果 Web 服务请求没有输入数据,它将默认值为空字典。下一步是获取动作函数的名称,并使用 getattrmotor 模块中检索该函数。现在我们可以使用 Web 服务请求中提供的参数调用该函数。代码的最后一行返回一个成功状态消息:

    def post(self, name):
        args = json.loads(self.request.body or '{}')
        func = getattr(motor, name)
        func(**args)
        self.write(dict(status='success'))

完整的脚本可以保存为 robows.py 在 Pi 上,然后执行。

列表 5.4 robows.py:创建执行机器人动作命令的 Web 服务

#!/usr/bin/env python3
from datetime import datetime
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
import json
import motor

class MainHandler(RequestHandler):
    def get(self, name):
        stamp = datetime.now().isoformat()
        self.write(dict(stamp=stamp))

    def post(self, name):
        args = json.loads(self.request.body or '{}')
        func = getattr(motor, name)
        func(**args)
        self.write(dict(status='success'))

app = Application([('/(.*)', MainHandler)])
app.listen(8888)
IOLoop.current().start()

我们可以将脚本在一个终端中运行,然后从另一个终端运行以下命令来测试 Web 服务:

$ curl http://localhost:8888/
{"stamp": "2022-11-27T17:54:30.658154"}

$ curl localhost:8888/
{"stamp": "2022-11-27T17:54:30.658154"}

$ curl -X POST localhost:8888/forward
{"status": "success"}

$ curl -X POST localhost:8888/backward
{"status": "success"}

$ curl -X POST localhost:8888/forward -d '{"speed": 1}'
{"status": "success"}

$ curl -X POST localhost:8888/forward -d '{"duration": 0.5, "speed": 1}'
{"status": "success"}

在终端会话中,我们首先检查我们的时间 Web 服务是否仍然在运行。第二个调用演示了在不指定协议的情况下引用 URL 的更短方式。然后我们调用 Web 服务来使机器人向前和向后移动。最后两个调用展示了我们如何为我们的动作提供自定义的速度和持续时间设置。

5.6 从 Python 调用 Web 服务

现在我们已经设置了这些强大的 Web 服务,我们可以继续创建代码,从网络中的任何位置调用它们,使机器人移动。我们需要实现一个满足以下要求的 Python Web 服务客户端:

  • 我们应该在 Python 中实现一个函数,该函数将接收动作名称和一组可选的动作参数,然后向机器人 Web 服务发出必要的 HTTP 调用来执行此动作。

  • 实现应使用 HTTP 持久连接,通过在发出多个动作调用时具有更低的延迟来提高网络性能。

5.6.1 使用读取-评估-打印循环调用 Web 服务

作为第一步,使用 Python REPL(读取-评估-打印循环)开始向 Web 服务器发起调用将会很有帮助。这样,我们可以探索不同的调用 Web 服务的方式以及它们返回的结果和数据结构。在客户端机器上打开一个 REPL 会话。我们 REPL 冒险的第一部分将是导入所需的模块。urlopen 命令将被用来调用 Web 服务器,而 json 将被用来解析 JSON 响应:

>>> from urllib.request import urlopen
>>> import json

我们执行的下一行将连接到 Web 服务器,并消费返回机器人服务器当前时间的 Web 服务。原始 JSON 响应以字节形式返回:

>>> urlopen('http://robopi:8888/').read()
b'{"stamp": "2022-11-28T14:30:41.314300"}'

我们可以将这个响应保存到一个变量中,然后解析它,以便我们可以访问时间戳值本身:

>>> response = urlopen('http://robopi:8888/').read()
>>> response
b'{"stamp": "2022-11-28T14:31:08.859478"}'
>>> json.loads(response)
{'stamp': '2022-11-28T14:31:08.859478'}
>>> result = json.loads(response)
>>> result
{'stamp': '2022-11-28T14:31:08.859478'}
>>> result['stamp']
'2022-11-28T14:31:08.859478'

现在,让我们继续调用一些网络服务来使机器人移动。当我们为 data 参数提供一个值时,urlopen 将自动将 HTTP 方法设置为 POST 方法。以下调用将使机器人向前移动:

>>> urlopen('http://robopi:8888/forward', data=b'').read()
b'{"status": "success"}'

我们可以设置自定义的运动选项,例如速度等

>>> urlopen('http://robopi:8888/forward', data=b'{"speed": 1}').read()
b'{"status": "success"}'

我们现在已经探索得足够多了,可以拼凑出我们网络服务客户端的第一个实现。

5.6.2 创建基本的网络服务客户端

客户端的初始版本将包含我们所需的一切,除了持久连接。我们导入与 REPL 会话中相同的模块来向 Web 服务器发送请求并处理 JSON 数据:

from urllib.request import urlopen
import json

接下来,我们定义 ROBO_URL 常量,提供我们将用于调用的基础 URL 部分:

ROBO_URL = 'http://robopi:8888/'

call_api 将实际调用网络服务 API。它接收完整的 URL 和作为字典的请求数据。它将接收到的数据转换为 JSON 格式,然后调用 encode 方法将其转换为 urlopen 所期望的字节数据类型。然后,使用相关的 URL 和请求数据调用 urlopen

def call_api(url, data):
    data = json.dumps(data).encode()
    urlopen(url, data).read()

call_robot 函数接收运动名称和任何可选的运动参数。生成相关运动的 URL,然后调用 call_api

def call_robot(func, **args):
    call_api(ROBO_URL + func, args)

剩余的脚本演示了通过向 call_robot 进行不同的调用来使用客户端:

call_robot('forward')
call_robot('backward')
call_robot('forward', duration=0.5, speed=1)
call_robot('backward', duration=0.5, speed=1)
call_robot('spin_right')
call_robot('spin_left')

完整的脚本可以保存为 client_basic.py 在 Pi 上,然后执行。

列表 5.5 client_basic.py:从客户端调用机器人的远程网络服务

#!/usr/bin/env python3
from urllib.request import urlopen
import json

ROBO_URL = 'http://robopi:8888/'

def call_api(url, data):
    data = json.dumps(data).encode()
    urlopen(url, data).read()

def call_robot(func, **args):
    call_api(ROBO_URL + func, args)

call_robot('forward')
call_robot('backward')
call_robot('forward', duration=0.5, speed=1)
call_robot('backward', duration=0.5, speed=1)
call_robot('spin_right')
call_robot('spin_left')

当脚本运行时,它将使机器人向前和向后移动,使用默认的持续时间和速度设置。接下来,将再次使用自定义设置调用向前和向后。最后,将使机器人向右和向左旋转。

现实世界的机器人:机器人群体

在我们的软件中与机器人通信的能力是许多机器人应用中强大且基本的功能。群体机器人学是当你有了让群体中的机器人相互通信的机制后变得可能的一个领域。通过使用群体智能或群体机器人的集体行为,我们开始获得智能全局行为的出现。这种群体智能在自然界中存在,其复杂程度体现在蚂蚁群体和蜂巢的设计中。

这些机器人群体的实际应用范围从搜索和救援任务到不同的医疗应用。关于这个主题的 Big Think (mng.bz/6nDy) 文章展示了机器人群体的一个很好的例子,并对该技术的不同实际应用进行了良好的讨论。

5.6.3 创建具有持久连接的网络服务客户端

现在我们有一个基本的客户端正在运行,我们可以将其升级为具有持久连接以改善我们请求的性能。这个客户端的方法将与之前的一个非常相似,但将使用一组不同的库。第一步将是导入提供持久连接功能的 HTTPConnection 对象:

from http.client import HTTPConnection
import json

call_api 函数需要修改为接受一个连接对象。在将请求数据编码为 JSON 格式后,我们使用提供的连接对象将请求发送到 Web 服务器。该请求将使用 POST 方法,并调用提供的 URL 以及生成的请求数据。然后,我们可以使用 getresponse 方法来读取响应:

def call_api(conn, url, data):
    body = json.dumps(data).encode()
    conn. request('POST', url, body)
    with conn.getresponse() as resp:
        resp.read()

call_robot 函数接收连接对象作为参数,并将移动名称作为请求 URL,将移动参数作为请求数据:

def call_robot(conn, func, **args):
    return call_api(conn, '/' + func, args)

我们创建一个带有机器人主机名和 Web 服务器端口号的 HTTPConnection 对象。然后对 call_robot 进行多次调用以演示其功能:

conn = HTTPConnection('robopi:8888')
for speed in [1, 2, 3]:
    call_robot(conn, 'spin_right', speed=speed)
    call_robot(conn, 'spin_left', speed=speed)

完整的脚本可以保存为 client_persist.py 在 Pi 上,然后执行。

列表 5.6 client_persist.py:使用持久连接调用 Web 服务

#!/usr/bin/env python3
from http.client import HTTPConnection
import json

def call_api(conn, url, data):
    body = json.dumps(data).encode()
    conn.request('POST', url, body)
    with conn.getresponse() as resp:
        resp.read()

def call_robot(conn, func, **args):
    return call_api(conn, '/' + func, args)

conn = HTTPConnection('robopi:8888')
for speed in [1, 2, 3]:
    call_robot(conn, 'spin_right', speed=speed)
    call_robot(conn, 'spin_left', speed=speed)

当脚本运行时,它将经过三个不同的速度设置,并在每个设置下使机器人向右和向左旋转。

深入了解:持久连接

在底层,HTTP 请求是通过 TCP 连接传输的。在过去,每个 HTTP 请求都需要通过一个新的 TCP 连接。HTTP 协议随后得到了增强,允许持久连接或在一个 TCP 连接上发送多个请求。这提高了像网页浏览器这样的 Web 客户端的网络性能,因为它减少了为额外的 HTTP 请求打开新的 TCP 连接的开销。Mozilla 基金会的 HTTP 协议文档(developer.mozilla.org/Web/HTTP)对该主题进行了很好的覆盖,是获取更多关于该主题的低级别细节的绝佳参考。

使用持久连接的性能优势使其值得付出努力。它是所有现代网络浏览器的一个标准功能,并将帮助我们构建本书后面章节中时间敏感的实时机器人应用。

5.6.4 测量客户端性能

我们费尽周折添加了持久连接。创建一个脚本来测量这个客户端的性能是值得的。我们可以使用这个脚本来比较新连接与重用持久连接的时间。这些时间也可以与我们在本章早期获得的 SSH 客户端的结果进行比较。最后,我们可以比较本地 Web 服务调用和通过 Wi-Fi 和有线以太网连接的远程调用。

我们将导入mean来计算性能时间测量的平均值或平均数,以及stdev来计算它们的样本标准差。time模块中的perf_counter函数用于记录函数调用的开始和结束时间以测量性能。perf_counter的文档(docs.python.org/3/library/time.html)提供了在性能测量中使用它的指导:

from statistics import mean, stdev
import time

get_noop_timing函数首先使用perf_counter函数保存当前时间。然后,将对机器人服务器上的noop移动函数进行调用。这是一个无操作调用,我们可以用它来测量客户端和服务器之间的性能。然后,我们计算经过的时间并将其乘以一千,以便返回值以毫秒表示:

def get_noop_timing(conn):
    start = time.perf_counter()
    call_robot(conn, 'noop')
    return (time.perf_counter() - start) * 1000

我们创建一个HTTPConnection对象并调用网络服务器。我们这样做是为了使后续的调用结果更加一致。接下来,我们创建一个连接对象,我们将使用它来进行所有测量。第一次网络服务调用的测量结果保存在变量init中,这样我们就可以跟踪初始连接建立和第一次调用所花费的时间。然后,我们进行一百次时间样本的测量并将它们保存在stats中。现在我们可以输出样本的初始值、最大值、平均值、最小值和标准差:

conn_initial = HTTPConnection('robopi:8888')
get_noop_timing(conn_initial)
conn = HTTPConnection('robopi:8888')
init = get_noop_timing(conn)
stats = [get_noop_timing(conn) for i in range(100)]
print(' init:', init)
print('  max:', max(stats))
print('  avg:', mean(stats))
print('  min:', min(stats))
print('stdev:', stdev(stats))

完整的脚本可以保存为client_measure.py在 Pi 上,然后执行。

列表 5.7 client_measure.py:调用网络服务时的性能测量

#!/usr/bin/env python3
from http.client import HTTPConnection
from statistics import mean, stdev
import time
import json

def call_api(conn, url, data):
    body = json.dumps(data).encode()
    conn.request('POST', url, body)
    with conn.getresponse() as resp:
        resp.read()

def call_robot(conn, func, **args):
    return call_api(conn, '/' + func, args)

def get_noop_timing(conn):
    start = time.perf_counter()
    call_robot(conn, 'noop')
    return (time.perf_counter() - start) * 1000

conn_initial = HTTPConnection('robopi:8888')
get_noop_timing(conn_initial)
conn = HTTPConnection('robopi:8888')
init = get_noop_timing(conn)
stats = [get_noop_timing(conn) for i in range(100)]
print(' init:', init)
print('  max:', max(stats))
print('  avg:', mean(stats))
print('  min:', min(stats))
print('stdev:', stdev(stats))

当我们运行脚本时,它将收集所有性能测量时间并将它们输出到终端。以下脚本是在机器人服务器本地运行的:

$ client_measure.py
 init: 2.5157280000485116
  max: 1.9314019999683296
  avg: 1.8538593599976139
  min: 1.812051000001702
stdev: 0.028557077821141714

这些数字让我们对在完成端到端网络请求时的开销有了概念,甚至在任何网络数据包从机器人传输到网络之前。让我们看看当我们从网络上的有线以太网连接到机器人服务器时我们得到的数字:

$ client_measure.py 
 init: 4.3936739675700665
  max: 3.5557260271161795
  avg: 2.244193991064094
  min: 1.503808016423136
stdev: 0.5216725173049904

与 1,036 毫秒的 SSH 时间相比,这些数字显示了两种方法在性能和开销方面的巨大差异。我们还可以看到,标准差有所增加,这在转移到物理网络时是预期的。接下来,我们测量通过无线 Wi-Fi 网络的时间:

$ client_measure.py 
 init: 8.047391020227224
  max: 8.70389404008165
  avg: 4.211111041367985
  min: 3.290054970420897
stdev: 0.8859955886558311

这些数字表明,有线网络连接可以提供比无线网络更好的性能。具体来说,初始连接时间、平均值和标准差在有线连接下都更好。标准差衡量我们在测量中的变化程度。我们可以从标准差数字中看到,与有线网络相比,无线网络上的性能变化更大。通过比较建立初始连接的时间(8.05 毫秒)与持久连接的平均时间(4.21 毫秒),我们可以看到使用持久连接时我们获得了近两倍的性能提升。

现实世界中的机器人:实时计算

与我们的机器人进行低延迟通信的能力使得实时计算等时间敏感型应用成为可能。这些应用类型的一个例子是使用模拟摇杆来控制机器人运动,我们将在本书的后续章节中这样做。这是一个非常时间敏感的应用,如果在摇杆交互和机器人运动之间存在显著的延迟,整个应用将无法正确运行。

另一个例子是汽车制造,其中多个机器人在生产线上一同工作来组装汽车。不同的机器人将进行焊接、钻孔并将部件传递给彼此。确保这些不同的任务在设定的时间框架内完成至关重要,否则装配线上的流程将被打乱。这篇文章关于实时系统(mng.bz/or1M)在机器人学和计算机视觉的背景下很好地涵盖了这一主题。

摘要

  • Wi-Fi 连接为机器人提供了最大的运动自由度。

  • Tornado 是一个功能丰富的 Web 框架,可以安全地与机器人上的硬件进行交互。

  • argparse 模块是 Python 标准库的一部分,可用于解析命令行参数。

  • time 命令可用于测量在本地运行命令以及通过 SSH 远程运行命令时的执行时间。

  • json 模块用于解析 JSON 请求数据。

  • urlopen 模块可用于向 Web 服务器发起调用。

  • 使用持久连接可以提供显著的性能提升。

6 创建机器人 Web 应用

本章涵盖

  • 创建桌面和移动友好的 Web 应用以控制机器人

  • 使用 Web 浏览器工具测量 Web 应用性能

  • 使用 Tornado 模板创建动态页面

  • 启用增强型 Web 日志记录以检测 Web 请求失败

本章将教会你如何构建一个用于控制你的机器人的 Web 应用。该应用在桌面计算机和手机上都能正常工作。应用将提供完整的机器人运动范围,以及可以用来测量应用网络性能的命令,从端到端。在我们构建应用的过程中,你将学习到测量应用性能的有用技术,以及检测和修复某些类型的 Web 请求失败。

Web 应用提供了一个强大的平台,用于构建由人类操作员控制机器人的机制。Web 应用既可以从桌面应用程序访问,也可以从移动设备访问。它们在主要的桌面操作系统(即 Windows、Mac 和 Linux)上保持一致的工作。

6.1 硬件堆栈

图 6.1 显示了硬件堆栈,本章中使用的特定组件被突出显示。在本章中,我们可以使用鼠标作为人机交互设备,通过我们的 Web 应用与机器人交互。

图 6.1 硬件堆栈:将通过 Web 界面在网络中控制直流电机。

Web 应用可以通过有线网络使用以太网端口或无线网络使用 Wi-Fi 硬件访问。通过 Wi-Fi 连接进行移动访问的用户体验最佳,因为它提供了完全的便携性。当从桌面访问 Web 界面时,可以使用鼠标作为人机交互设备,通过点击所需的机器人移动按钮来控制机器人。在后面的章节中,我们将使用键盘和操纵杆来控制机器人。

6.2 软件堆栈

本章中使用的特定软件的详细信息在图 6.2 中描述。这里将创建三个主要的应用程序。第一个是一个基本的 Web 应用,显示机器人服务器上的当前时间(basic_web)。然后,我们将创建一个使机器人前后移动的应用程序(forward_web)。最后,将创建一个具有完整机器人移动命令的移动友好型应用(full_web)。这些 Web 应用将使用 Tornado Web 框架创建。框架的内置模板功能将用于创建动态内容。将使用datetimeos Python 模块来计算服务器上的时间并从环境变量中读取值。

图 6.2 软件堆栈:网络上的 Web 浏览器将通过 Tornado Web 应用连接。

6.3 通过 Web 移动机器人前后

我们创建的第一个网络应用程序将执行基本的机器人前进和后退运动。我们需要创建一个满足以下要求的网络应用程序:

  • 应创建一个 Python 网络应用程序,允许用户控制机器人前进和后退。

  • 网络应用程序应使用 HTML5 标准。

  • 用户界面必须是桌面和移动友好的。

HTML5 是互联网上使用的标记语言最新版本,与旧版本相比,它提供了更丰富的功能。因此,我们将它作为应用程序的要求之一。

6.3.1 创建基本网络应用程序

让我们采取一些简单的第一步,创建一个显示机器人网络服务器时间的网络应用程序。第一步是导入所有必需的模块。从 Tornado 中,我们导入 IOLoopRequestHandlerApplication,就像我们在前面的章节中做的那样,以设置和运行我们的网络应用程序。然后,我们导入 enable_pretty_logging 以启用日志输出。将使用 datetime 对象来获取当前时间。dirname 函数将获取路径的目录名。我们将使用 os 模块来访问环境变量:

from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging
from datetime import datetime
from os.path import dirname
import os

Tornado 网络框架具有强大的调试模式,提供自动重新加载和生成错误页面等特性,这些特性在开发调试网络应用程序时非常有用。下一行代码设置全局变量 DEBUG 为真或假,这取决于环境变量 ROBO_DEBUG 是否已定义。这样,相同的代码可以用于开发或生产使用,并且其调试行为可以通过环境变量在代码库外部定义:

DEBUG = bool(os.environ.get('ROBO_DEBUG'))

下一步是将 TEMPLATE_PATH 设置为模板目录的路径。此目录将包含用于生成 HTML 内容的 Tornado 模板文件。此路径自动计算为与 Python 代码相同的目录下的一个名为 templates 的子目录。将所有 HTML 模板文件放置在此目录中:

TEMPLATE_PATH = (dirname(__file__) + '/templates')

我们现在可以定义一个 MainHandler 对象来处理传入的请求。它将计算当前时间并将该值作为字符串保存在名为 stamp 的变量中。然后,使用 stamp 变量渲染 basic.html 模板并将其发送到网络浏览器:

class MainHandler(RequestHandler):
    def get(self):
        stamp = datetime.now().isoformat()
        self.render('basic.html', stamp=stamp)

最后一块代码调用 enable_pretty_logging 以启用日志输出,并使用应用程序设置定义 settings。然后,这些设置提供给 Application,并启动应用程序服务器:

enable_pretty_logging()
settings = dict(debug=DEBUG, template_path=TEMPLATE_PATH)
app = Application([('/', MainHandler)], **settings)
app.listen(8888)
IOLoop.current().start()

整个脚本可以保存为 basic_web.py 在 Pi 上。

图 6.1 basic_web.py:显示机器人时间的网络应用程序

#!/usr/bin/env python3
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging
from datetime import datetime
from os.path import dirname
import os

DEBUG = bool(os.environ.get('ROBO_DEBUG'))
TEMPLATE_PATH = (dirname(__file__) + '/templates')

class MainHandler(RequestHandler):
    def get(self):
        stamp = datetime.now().isoformat()
        self.render('basic.html', stamp=stamp)

enable_pretty_logging()
settings = dict(debug=DEBUG, template_path=TEMPLATE_PATH)
app = Application([('/', MainHandler)], **settings)
app.listen(8888)
IOLoop.current().start()

在我们可以执行脚本之前,我们应该创建 basic.html 模板。我们将逐部分运行此模板文件。文件的第一行是必需的,用于通知网络浏览器该文件使用 HTML5。然后,我们有我们的 html 开启标签,它定义了文档语言为英语:

<!DOCTYPE HTML>
<html lang="en">

下一个部分是 HTML 文档的head部分。网站标题已提供,然后使用meta标签设置viewport元数据,以便 Web 应用程序可以在桌面和移动浏览器上正确显示。接下来,使用style标签将页面的字体设置为Verdana

<head>
  <title>Robot Web</title>
  <meta name="viewport" content="width=device-width">
<style>
body {
  font-family: Verdana, sans-serif;
}
</style>
</head>

模板的最后部分包含文档的body部分。使用h1标签提供标题内容,最后,将stamp模板变量放置在这个标题下以显示当前时间:

<body>
<h1>Robot Web</h1>
{{ stamp }}
</body>
</html>

模板可以保存为basic.html在 Pi 的模板目录中。

图 6.2 basic.html:显示机器人时间的 HTML 用户界面

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Robot Web</title>
  <meta name="viewport" content="width=device-width">
<style>
body {
  font-family: Verdana, sans-serif;
}
</style>
</head>
<body>
<h1>Robot Web</h1>
{{ stamp }}
</body>
</html>

我们现在可以执行basic_web.py来运行 Web 服务器。您可以通过访问网络上的计算机上的地址 http://robopi:8888 来访问 Web 应用程序。请确保更新计算机上的 hosts 文件,以便有一个robopi条目,如第五章所述。您也可以通过将 URL 中的robopi替换为机器人的 IP 地址来访问 Web 应用程序。当从移动设备访问 Web 应用程序时,使用 IP 地址将是一个更方便的选择。

当你访问 Web 应用程序时,它将显示机器人服务器上的当前时间。刷新页面以查看更新时间并确认服务器正在响应多个请求。图 6.3 显示了应用程序的外观。

图 6.3 basic_web:它显示在机器人服务器上的当前时间。

6.3.2 检测失败的请求

由于我们调用了enable_pretty_logging,我们得到了将服务器日志输出到终端的好处。我们可以检查这个输出以查看所有传入 Web 服务器的请求和给定的响应。尝试从多个浏览器或计算机再次访问 Web 应用程序。以下是从多个浏览器和计算机访问应用程序后的日志输出:

$ basic_web.py 
[I 221213 17:20:27 web:2271] 200 GET / (10.0.0.30) 7.99ms
[W 221213 17:20:27 web:2271] 404 GET /favicon.ico (10.0.0.30) 1.27ms
[I 221213 17:20:33 web:2271] 200 GET / (10.0.0.30) 2.21ms
[W 221213 17:20:34 web:2271] 404 GET /favicon.ico (10.0.0.30) 1.84ms
[I 221213 17:20:35 web:2271] 200 GET / (10.0.0.30) 1.98ms
[I 221213 17:20:35 web:2271] 200 GET / (10.0.0.30) 2.23ms
[W 221213 17:23:51 web:2271] 404 GET /favicon.ico (10.0.0.15) 1.82ms
[I 221213 17:23:53 web:2271] 200 GET / (10.0.0.15) 2.36ms
[I 221213 17:23:54 web:2271] 200 GET / (10.0.0.15) 2.32ms
[I 221213 17:23:55 web:2271] 200 GET / (10.0.0.15) 2.23ms

我们可以看到,在页面加载后,浏览器试图获取一个名为favicon.ico的文件,并因“404 not found”的 HTTP 错误而失败。这是因为我们还没有定义一个适当的方式来处理这些请求,所以它们失败了。在下一个升级中,我们可以解决这个问题,并在更改后检查服务器日志以确认问题已解决。这个日志输出也是查看 Tornado 提供响应所需时间的一个很好的方式,因为响应时间也会出现在日志输出中。

6.3.3 使用 Web 应用向前移动机器人

现在我们将向我们的应用程序添加前进和后退动作。我们将再次导入motor模块来控制机器人的动作:

import motor

Application对象将被增强以处理不同的请求 URL 并解析请求路径中的动作。正则表达式/([a-z_]*)用于匹配由小写字母和下划线字符组成的路径。此模式将匹配所有可用的移动命令:

app = Application([('/([a-z_]*)', MainHandler)], **settings)

我们现在更新get方法以接收name参数并渲染forward.html模板:

def get(self, name):
    stamp = datetime.now().isoformat()
    self.render('forward.html', stamp=stamp)

如前几章所述,我们只有在运动命令作为post请求传入时才会处理运动命令。然后post方法将检查name变量的值并调用相关的运动函数。之后,它将使用redirect方法将浏览器重定向到 Web 应用程序主页:

def post(self, name):
    if name == 'forward':
        motor.forward()
    if name == 'backward':
        motor.backward()
    self.redirect('/')

完整脚本可以保存为forward_web.py在 Pi 上。

图 6.3 forward_web.py:用于移动机器人前后移动的 Web 应用程序

#!/usr/bin/env python3
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging
from datetime import datetime
from os.path import dirname
import os
import motor

DEBUG = bool(os.environ.get('ROBO_DEBUG'))
TEMPLATE_PATH = (dirname(__file__) + '/templates')

class MainHandler(RequestHandler):
    def get(self, name):
        stamp = datetime.now().isoformat()
        self.render('forward.html', stamp=stamp)

    def post(self, name):
        if name == 'forward':
            motor.forward()
        if name == 'backward':
            motor.backward()
        self.redirect('/')

enable_pretty_logging()
settings = dict(debug=DEBUG, template_path=TEMPLATE_PATH)
app = Application([('/([a-z_]*)', MainHandler)], **settings)
app.listen(8888)
IOLoop.current().start()

我们现在可以升级 HTML 模板。为了解决favicon问题,我们在模板的head部分使用以下 HTML。这为页面设置无图标,从而指示网络浏览器不要从网络服务器获取favicon文件:

<link rel="icon" href="data:,">

在文档的主体中,我们添加了两个表单。每个表单将使用post方法提交其数据,到forward路径或backward路径。每个表单的提交按钮都有一个标签,与该表单的运动动作相匹配:

<form method="post" action="forward">
  <input type="submit" value="Forward">
</form>
<form method="post" action="backward">
  <input type="submit" value="Backward">
</form>

模板可以保存为forward.html在 Pi 的模板目录中。

图 6.4 forward.html:用于移动机器人的前后移动的 HTML

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Robot Web</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
<style>
body {
  font-family: Verdana, sans-serif;
}
</style>
</head>
<body>
<h1>Robot Web</h1>
{{ stamp }}
<form method="post" action="forward">
  <input type="submit" value="Forward">
</form>
<form method="post" action="backward">
  <input type="submit" value="Backward">
</form>

</body>
</html>

我们现在可以执行forward_web.py来运行网络服务器。当你访问网络应用程序时,按下前后按钮来移动机器人前后。图 6.4 显示了现在带有这些新按钮的应用程序的外观。我们可以检查应用程序会话的日志输出:

$ forward_web.py 
[I 221213 17:37:29 web:2271] 200 GET / (10.0.0.30) 7.99ms
[I 221213 17:37:34 web:2271] 302 POST /forward (10.0.0.30) 222.82ms
[I 221213 17:37:34 web:2271] 200 GET / (10.0.0.30) 2.28ms
[I 221213 17:37:35 web:2271] 302 POST /backward (10.0.0.30) 223.56ms
[I 221213 17:37:35 web:2271] 200 GET / (10.0.0.30) 2.25ms
[I 221213 17:37:36 web:2271] 302 POST /backward (10.0.0.30) 224.18ms
[I 221213 17:37:36 web:2271] 200 GET / (10.0.0.30) 2.22ms

图 6.4 forward_web:它提供了移动机器人前后按钮。

在日志输出中,我们可以看到机器人向前移动了一次,然后向后移动了两次。从日志中,我们可以看到渲染主页面通常需要 2 毫秒。执行机器人移动大约需要 224 毫秒。motor模块中为机器人移动设置的默认持续时间是 200 毫秒。因此,这些数字是我们预期的。最后,我们可以看到“favicon 未找到错误”也已解决,因为它们不再出现在请求日志中。

深入了解:HTML5

HTML5 是用于网络的标记语言的最新版本。该标准由 Web 超文本应用技术工作组(WHATWG)维护,它是一个由主要浏览器供应商(苹果、谷歌、Mozilla 和微软)组成的联盟。HTML Living Standard (w3.org/TR/html5)提供了关于 HTML 元素和语法的全部细节。它是标准的全面参考。

HTML 表单在本章中被大量使用,用于将所需的运动动作提交给机器人服务器。Mozilla 关于网页表单的指南(developer.mozilla.org/Learn/Forms)是一个优秀的资源,可以探索如何创建具有不同提交选项的表单,以及表单本身包含的不同输入元素。

6.4. 创建一个完整的运动 Web 应用程序

我们现在可以继续创建一个可以调用所有机器人运动函数的 Web 应用程序。我们需要创建一个满足以下要求的 Web 应用程序:

  • 应该创建一个 Python Web 应用程序,允许用户将机器人向前、向后、向右、向左移动,以及向两个方向旋转。

  • 应该创建一个调用无操作noop函数的按钮,以便在网页浏览器内进行性能测量。

  • 用户界面的按钮应使用一个舒适地支持移动触摸和桌面鼠标交互的布局。

6.4.1 创建完整的运动应用程序

在 Python 方面,我们几乎完成了。我们可以对我们的先前应用程序进行一些小的修改,以启用所有运动函数。我们将对get方法进行小的修改,使其使用我们新的full.html模板:

    def get(self, name):
        stamp = datetime.now().isoformat()
        self.render('full.html', stamp=stamp)

现在将增强post方法,从motor模块查找所需的运动函数,然后调用该函数。之后,我们将被重定向到应用程序主屏幕:

    def post(self, name):
        func = getattr(motor, name)
        func()
        self.redirect('/')

完整的脚本可以保存为full_web.py在 Pi 上。

图 6.5 full_web.py:支持所有机器人运动动作的 Web 应用程序

#!/usr/bin/env python3
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging
from datetime import datetime
from os.path import dirname
import os
import motor

DEBUG = bool(os.environ.get('ROBO_DEBUG'))
TEMPLATE_PATH = (dirname(__file__) + '/templates')

class MainHandler(RequestHandler):
    def get(self, name):
        stamp = datetime.now().isoformat()
        self.render('full.html', stamp=stamp)

    def post(self, name):
        func = getattr(motor, name)
        func()
        self.redirect('/')

enable_pretty_logging()
settings = dict(debug=DEBUG, template_path=TEMPLATE_PATH)
app = Application([('/([a-z_]*)', MainHandler)], **settings)
app.listen(8888)
IOLoop.current().start()

下一步将是升级 HTML 模板。body中的内容将进行多项增强。页面标题中的文本将被赋予一个链接,以便用户可以点击页面标题来重新加载页面。每个运动函数都有一个按钮放置在屏幕上。屏幕的布局被设计成将类似动作分组在同一行。第一行有前进和后退按钮。第二行有左转和右转按钮。第三行显示左转和右转的按钮。最后一行展示无操作按钮。按钮使用 HTML5 命名的字符引用,以便按钮有图形指示它们的功能:

<body>
<h1><a href='/'>Robot Web</a></h1>
{{ stamp }}<br><br>
<form method="post">
  <button formaction="forward">&blacktriangle;</button>
  <button formaction="backward">&blacktriangledown;</button>
  <br><br>
  <button formaction="left">&blacktriangleleft;</button>
  <button formaction="right">&blacktriangleright;</button>
  <br><br>
  <button formaction="spin_left">&circlearrowleft;</button>
  <button formaction="spin_right">&circlearrowright;</button>
  <br><br>
  <button formaction="noop">X</button>
</form>
</body>

现在我们可以通过更新样式标签来对页面上的内容进行样式设计。我们将内容居中在页面中,并从页面上的链接中移除默认的下划线文本装饰样式。接下来,我们继续对页面上的按钮进行样式设计。它们的字体大小被放大了三倍,并添加了足够的边距,以提供健康的按钮间距。这种布局和间距使得在触摸界面上用手指按下按钮更容易,因为按钮没有紧挨在一起。最后,所有按钮都被赋予了相同的高度和宽度为 60 px,以创建统一的样式:

<style>
body, a {
  font-family: Verdana, Arial, sans-serif;
  text-align: center;
  text-decoration: none;
}
button {
  font-size: 300%;
  margin: 0px 10px;
  height: 60px;
  width: 60px;
}
</style>

模板可以保存为full.html在 Pi 的模板目录中。

图 6.6 full.html:支持所有机器人移动动作的 HTML 用户界面

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Robot Web</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
<style>
body, a {
  font-family: Verdana, Arial, sans-serif;
  text-align: center;
  text-decoration: none;
}
button {
  font-size: 300%;
  margin: 0px 10px;
  height: 60px;
  width: 60px;
}
</style>
</head>
<body>
<h1><a href='/'>Robot Web</a></h1>
{{ stamp }}<br><br>
<form method="post">
  <button formaction="forward">&blacktriangle;</button>
  <button formaction="backward">&blacktriangledown;</button>
  <br><br>
  <button formaction="left">&blacktriangleleft;</button>
  <button formaction="right">&blacktriangleright;</button>
  <br><br>
  <button formaction="spin_left">&circlearrowleft;</button>
  <button formaction="spin_right">&circlearrowright;</button>
  <br><br>
  <button formaction="noop">X</button>
</form>
</body>
</html>

我们现在可以执行full_web.py来运行网页服务器。当你访问网页应用程序时,按下不同的移动按钮,使机器人执行每个可用的移动。以下是从使用应用程序的会话中输出的日志:

$ full_web.py 
[I 221214 15:37:32 web:2271] 200 GET / (10.0.0.30) 4.75ms
[I 221214 15:37:34 web:2271] 302 POST /forward (10.0.0.30) 223.77ms
[I 221214 15:37:34 web:2271] 200 GET / (10.0.0.30) 5.26ms
[I 221214 15:37:35 web:2271] 302 POST /backward (10.0.0.30) 223.29ms
[I 221214 15:37:35 web:2271] 200 GET / (10.0.0.30) 4.77ms
[I 221214 15:37:35 web:2271] 302 POST /left (10.0.0.30) 222.85ms
[I 221214 15:37:35 web:2271] 200 GET / (10.0.0.30) 4.78ms
[I 221214 15:37:36 web:2271] 302 POST /right (10.0.0.30) 222.96ms
[I 221214 15:37:36 web:2271] 200 GET / (10.0.0.30) 4.81ms
[I 221214 15:37:40 web:2271] 302 POST /spin_left (10.0.0.30) 223.67ms
[I 221214 15:37:40 web:2271] 200 GET / (10.0.0.30) 4.80ms
[I 221214 15:37:41 web:2271] 302 POST /spin_right (10.0.0.30) 223.42ms
[I 221214 15:37:41 web:2271] 200 GET / (10.0.0.30) 4.84ms
[I 221214 15:37:41 web:2271] 302 POST /noop (10.0.0.30) 1.83ms
[I 221214 15:37:41 web:2271] 200 GET / (10.0.0.30) 4.87ms

从日志中,我们可以看到每个不同的移动功能都成功执行,然后在每个功能调用后加载主页面。值得注意的是,对noop页面的调用大约在 2 毫秒内完成,这表明性能良好。

6.4.2 网页界面设计

图 6.5 展示了网页应用程序在桌面和笔记本电脑用户面前的外观。每个按钮使用的图标反映了将要执行的动作。因此,指向前后方向的箭头表示机器人的前后移动。右转、左转和旋转功能有类似的图形表示。无操作动作用 X 表示,表示它不会导致移动。

图片

图 6.5 桌面界面:每个机器人移动都可以通过其相关按钮调用。

图 6.6 展示了应用程序在移动设备上的外观。按钮使用较大的字体,并赋予了足够的宽度和高度,以便在触摸屏上舒适地按下。按钮之间宽敞的间距也防止了它们的拥挤。整个应用程序都适合在一个屏幕上,这样就可以访问所有功能,而无需滚动。

图片

图 6.6 移动界面:按钮之间的间距使得触摸交互更加容易。

现实世界中的机器人:使用网页应用程序控制机器人

使用网页应用程序控制机器人比使用桌面应用程序更方便,因为它们可以通过移动设备访问。通过智能手机访问和控制工厂车间的机器人,比携带笔记本电脑给操作员带来更大的行动自由度。

当需要时,它们还支持从具有更大屏幕的设备访问,例如平板电脑或笔记本电脑。可以设计不同的用户界面,以更好地支持具有更大屏幕的设备。这样,用户就能享受到两者的最佳之处。

现代网页应用程序也非常易于扩展。我们将在后面的章节中看到如何将机器人摄像头的视频流添加到我们的网页应用程序中。这使机器人操作员能够看到机器人所看到的一切。

6.4.3 在浏览器中测量应用程序性能

图 6.7 展示了如何在浏览器中测量网络应用程序的性能。Firefox 和 Google Chrome 网页浏览器都内置了一个名为开发者工具的功能,它提供了许多丰富的功能,例如测量应用程序性能。一旦在浏览器中访问了该工具,点击网络标签。图 6.7 显示了当请求机器人向右旋转时的一个样本测量。从测量结果中,我们可以看到调用向右旋转动作花费了 229 毫秒,然后 14 毫秒用于重定向和加载主页。这些数字与我们在终端日志输出中从服务器上看到的数字相匹配。当尝试诊断网络应用程序的性能问题时,这个工具非常有用。

图片

图 6.7 测量性能:页面的加载时间可以在浏览器本身中进行测量。

6.4.4 网络硬件设备

图 6.8 展示了在运行 iOS 操作系统的智能手机上,网络应用程序将如何显示。智能手机提供最高的便携性,因为它们足够小,可以放入口袋。使用它们的缺点是屏幕较小,我们只能使用触摸屏界面与应用程序进行交互。

图片

图 6.8 智能手机设备:可以使用智能手机来控制机器人。

图 6.9 显示了在平板电脑上运行的应用程序。这类硬件设备提供了更大的屏幕,这使得我们可以在用户界面中放置更多的控件。

在多种不同屏幕尺寸和网页浏览器上尝试您的网络应用程序通常是一个好主意,这样您可以发现可能在特定设备或浏览器上出现的问题。

图片

图 6.9 平板电脑:平板电脑的屏幕比智能手机大,但仍然非常便携。

摘要

  • 通过 Wi-Fi 访问机器人可以提供最佳的用户体验,因为机器人和移动电话都具有完全的便携性。

  • Tornado 网络框架的内置模板功能用于在应用程序中创建动态内容。

  • 输出服务器日志的一个好处是我们可以看到所有发送到网络服务器的请求和给出的响应。

  • 按钮使用较大的字体,并且被赋予了良好的宽度和高度,这样它们就足够大,可以在触摸屏上舒适地按下。

  • Firefox 和 Google Chrome 网页浏览器都内置了一个名为开发者工具的功能,它提供了许多丰富的功能,例如测量应用程序性能。

7 摇杆控制机器人

本章涵盖

  • 使用 Pygame 读取摇杆数据

  • 读取和解析原始摇杆事件数据

  • 测量摇杆事件速率

  • 创建远程摇杆机器人控制器

摇杆是最强大的输入设备之一。当涉及到控制机器人电机时,它们相比键盘和鼠标提供了更优越的控制。本章涵盖的场景将帮助您创建一个功能齐全的摇杆控制机器人。本章将教授您多种从摇杆硬件读取事件的方法。然后我们可以创建自己的事件处理器,根据特定的摇杆动作执行不同的机器人运动。在这个过程中,我们还将学习如何测量每秒触发的摇杆事件数量,并优化我们的代码,以防止机器人电机被运动请求淹没。最后,我们通过创建一个通过网络使用摇杆移动机器人的应用程序来结束本章。

摇杆控制的机器人具有广泛的应用,从在工厂车间远程操作重型车辆到使用机械臂进行精细的医学程序。在机器人辅助手术的情况下,通过控制非常小的机器人臂,医生可以执行其他情况下不可能进行的手术程序。

7.1 硬件堆栈

图 7.1 展示了硬件堆栈,其中本章使用的特定组件被突出显示。摇杆硬件可以通过有线 USB 或无线蓝牙连接连接到 Raspberry Pi。摇杆还可以连接到远程计算机,并且机器人运动请求将通过网络使用 Wi-Fi 或以太网进行传输。

图片

图 7.1 硬件堆栈:摇杆将被用来控制机器人运动。

本章所使用的摇杆硬件可以是索尼 PlayStation 4/5 控制器或 Xbox 控制器。图 7.2 展示了一款 PlayStation 4 控制器的照片,图 7.3 展示了一款 Xbox 控制器的照片。在购买本章所需的硬件之前,请务必查阅附录 A 中的硬件购买指南。

图片

图 7.2 PlayStation 4 控制器:这款控制器广泛可用,并且对 Linux 有良好的支持。

图片

图 7.3 Xbox 控制器:这款控制器与 PlayStation 控制器类似,但它有两个模拟摇杆。

当通过 USB 将控制器连接到树莓派时,您只需将 USB 线连接到控制器和树莓派之间。不需要额外的软件或配置。索尼 PlayStation 控制器支持蓝牙连接。要使用它,您必须首先按照控制器的说明将其置于配对模式。然后,您可以使用随树莓派操作系统一起提供的图形蓝牙应用程序搜索并配对控制器,就像配对任何其他蓝牙设备一样。本章的最后一个应用程序也支持将控制器连接到同一网络上的远程 Linux 计算机。在该计算机上,可以使用相同的 USB 和蓝牙选项。

7.2 软件栈

本章中使用的特定软件的详细信息提供在图 7.4 中。前几个应用程序将使用 Pygame 库,因为它是与游戏杆设备一起工作的绝佳起点。然后,我们将使用struct Python 模块直接从 Linux 输入子系统读取和解析游戏杆事件。当创建joystick_levels应用程序来测量游戏杆事件生成的速率时,将使用systime模块。蓝牙硬件及其相关的蓝牙 Linux 驱动程序将被用于为控制器创建无线连接。本章以joystick_remote应用程序结束,该应用程序使用游戏杆硬件控制机器人电机。

图 7.4

图 7.4 软件栈:将使用 Linux 输入子系统来读取游戏杆事件。

7.3 游戏杆事件

图 7.5 展示了我们最感兴趣的特定摇杆事件。控制器上有很多按钮和摇杆,每个按钮和摇杆在被按下或移动时都可以向连接的计算机发送事件。对于本章中的应用程序,我们最感兴趣的是与控制器上两个模拟摇杆相关的事件。有一个摇杆用于左手,另一个用于右手。我们将通过根据摇杆的位置设置每个电机的节流来控制机器人的移动。如果右摇杆完全向前推,右电机将在前进方向上获得全功率。如果右摇杆完全向后拉,右电机将在后退方向上获得全功率。对于左摇杆和左电机,也将执行同样的操作。我们还将根据每个摇杆向前或向后推的程度来设置节流速度或级别。这样,你可以使用摇杆来控制前进、后退、转向和旋转动作。你还可以根据摇杆推的程度以较慢或较快的速度执行这些动作。当摇杆移动时,摇杆轴和位置将作为事件提供。每个摇杆都有 yx 轴。摇杆垂直位置的变化与 y 轴事件相关,摇杆水平位置的变化与 x 轴事件相关。

图片

图 7.5 摇杆事件:摇杆在上下移动时产生 y 轴事件。

7.4 使用 Pygame 读取摇杆事件

Pygame 是一个非常流行的 Python 模块,用于编写视频游戏。它内置了对读取摇杆事件的支持,是使用 Python 处理摇杆的绝佳起点。我们需要创建一个满足以下要求的应用程序:

  • 有必要创建一个使用 Pygame 库读取摇杆事件的 Python 应用程序。

  • 我们应该在应用程序中创建一个事件处理函数,每次有摇杆移动或按钮按下事件时都会调用该函数。

7.4.1 在 Pygame 中检测事件

这个程序将有一个事件循环,读取所有检测到的事件并将它们打印出来。一旦我们设置好,我们就可以继续下一节,该节将专注于摇杆事件。运行以下行以在我们的虚拟环境中安装 Pygame Python 包:

$ ~/pyenv/bin/pip install pygame

我们应用程序的第一部分将导入 pygame 模块:

import pygame

当我们运行主事件循环时,我们需要设置循环检查事件的频率。这个频率被称为帧率,我们将其设置在一个名为 FRAME_RATE 的变量中。它被设置为每秒 60 帧,这是创建响应性应用程序的常见值。如果这个值太小,应用程序将不会非常响应,如果太高,则会在不提供改进用户体验的情况下给计算机带来不必要的负担。人类无法感知每秒超过 60 帧的帧率。我们将窗口的高度和宽度保存在名为 WINDOW_SIZE 的变量中。在我们的应用程序中,窗口的大小并不重要,因为我们不会在窗口中绘制任何内容:

FRAME_RATE = 60
WINDOW_SIZE = [100, 100]

我们现在定义位于程序核心的 main 函数。我们调用 pygame.init 函数来初始化 Pygame 模块。然后,我们创建一个名为 screen 的窗口。接着,我们创建一个 Clock 对象,该对象将在事件循环中使用,以在期望的帧率处理事件。下一个块是主事件循环,它将持续运行,直到应用程序退出。通过调用 pygame.event.get 获取后续可用的事件。然后打印出该事件的详细信息。检查事件类型以确定它是否是 pygame.QUIT 类型的事件。如果是,通过调用 pygame.quit 退出应用程序,然后从主函数返回。最后,循环的最后一行使用配置的帧率调用 clock.tick

def main():
    pygame.init()
    screen = pygame.display.set_mode(WINDOW_SIZE)
    clock = pygame.time.Clock()
    while True:
        for event in pygame.event.get():
            print('event detected:', event)
            if event.type == pygame.QUIT:
                pygame.quit()
                return
        clock.tick(FRAME_RATE)

应用程序的最后一行调用 main 函数:

main()

完整脚本可以保存为 pygame_events.py 在 Pi 上,然后执行。

列表 7.1 pygame_events.py: 使用 Pygame 库打印摇杆事件

#!/usr/bin/env python3
import pygame

FRAME_RATE = 60
WINDOW_SIZE = [100, 100]

def main():
    pygame.init()
    screen = pygame.display.set_mode(WINDOW_SIZE)
    clock = pygame.time.Clock()
    while True:
        for event in pygame.event.get():
            print('event detected:', event)
            if event.type == pygame.QUIT:
                pygame.quit()
                return
        clock.tick(FRAME_RATE)

main()

该应用程序需要一个图形环境来运行,因为它会创建窗口。您可以直接在 Pi 的桌面环境中运行它,或者通过 VNC 会话远程运行。输出是从运行命令的会话中获取的。在下面的会话中,启动了应用程序,并在键盘上按下并释放了字母 A。鼠标在应用程序周围移动,然后关闭了窗口。键盘、鼠标和窗口关闭事件可以在输出中看到,它们被检测到:

$ pygame_events.py 
pygame 2.1.2 (SDL 2.0.14, Python 3.9.2)
Hello from the pygame community. https://www.pygame.org/contribute.html
event detected: <Event(32774-WindowShown {'window': None})>
event detected: <Event(32777-WindowMoved {'x': 464, 'y': 364, 'window': ...
event detected: <Event(32770-VideoExpose {})>
event detected: <Event(32776-WindowExposed {'window': None})>
event detected: <Event(32768-ActiveEvent {'gain': 1, 'state': 1})>
event detected: <Event(32785-WindowFocusGained {'window': None})>
event detected: <Event(32788-WindowTakeFocus {'window': None})>
event detected: <Event(768-KeyDown {'unicode': 'a', 'key': 97, 'mod': ...
event detected: <Event(771-TextInput {'text': 'a', 'window': None})>
event detected: <Event(769-KeyUp {'unicode': 'a', 'key': 97, 'mod': 0, ...
event detected: <Event(32768-ActiveEvent {'gain': 1, 'state': 0})>
event detected: <Event(32783-WindowEnter {'window': None})>
event detected: <Event(1024-MouseMotion {'pos': (99, 2), 'rel': (0, 0), ...
event detected: <Event(32768-ActiveEvent {'gain': 0, 'state': 0})>
event detected: <Event(32784-WindowLeave {'window': None})>
event detected: <Event(32788-WindowTakeFocus {'window': None})>
event detected: <Event(32787-WindowClose {'window': None})>
event detected: <Event(256-Quit {})>

深入了解:帧率

帧率通常以每秒帧数来衡量。它是人类与计算机交互的一个重要方面。在本章中,我们专注于创建读取摇杆事件并能快速响应以创建实时应用的应用程序。如果我们的帧率下降到一个非常低的水平,我们的机器人操作者会注意到。我们的应用程序在响应我们的动作时会有明显的延迟。

尽管我们在初始应用程序中将帧率设置为每秒 60 帧,但每秒 30 帧的较低帧率对于许多应用程序来说仍然很受欢迎且舒适。在接下来的章节中,Pi 相机的默认帧捕获率为每秒 30 帧。以这个速率显示的图像将看起来像流畅的视频流。当我们执行像人脸检测这样的要求较高的任务,帧率降低到非常低的水平时,这将会非常明显且具有破坏性。因此,我们将通过软件优化在遇到问题时解决这些问题。

无论我们处理视频播放、摇杆事件,还是任何其他高度交互式的用户应用程序,通常都会归结为测量帧率并确保软件设计保持目标帧率,以免用户体验受到影响。

7.4.2 检测摇杆事件

现在,我们可以将检测和处理摇杆事件的能力添加到我们的应用程序中。以下两行代码被添加到 main 函数中。第一行调用 Joystick 来设置控制器设备对象,并将其保存在 joystick 变量中。然后我们输出摇杆控制器设备的名称:

joystick = pygame.joystick.Joystick(0)
print('joystick name:', joystick.get_name())

在我们之前的事件循环中添加了一行代码,以便每次检测到新事件时调用 handle_event 函数:

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            return
        handle_event(event)
    clock.tick(FRAME_RATE)

现在可以定义事件处理函数。它将仅关注摇杆事件,并在按钮被按下或控制器上的某个摇杆被移动时打印不同的消息:

def handle_event(event):
    if event.type == pygame.JOYBUTTONDOWN:
        print('button pressed', event.button)
    if event.type == pygame.JOYAXISMOTION:
        print('axis motion', event.axis, event.value)

完整的脚本可以保存为 Pi 上的 pygame_joystick.py 并执行。

列表 7.2 pygame_joystick.py:使用 Pygame 检测特定的摇杆事件

#!/usr/bin/env python3
import pygame

FRAME_RATE = 60
WINDOW_SIZE = [100, 100]

def handle_event(event):
    if event.type == pygame.JOYBUTTONDOWN:
        print('button pressed', event.button)
    if event.type == pygame.JOYAXISMOTION:
        print('axis motion', event.axis, event.value)

def main():
    pygame.init()
    screen = pygame.display.set_mode(WINDOW_SIZE)
    clock = pygame.time.Clock()
    joystick = pygame.joystick.Joystick(0)
    print('joystick name:', joystick.get_name())
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                return
            handle_event(event)
        clock.tick(FRAME_RATE)

main()

在接下来的会话中,应用程序被启动,按下了四个不同的按钮,摇杆被移动到不同的位置。我们可以看到每个按钮都有一个唯一的标识符,摇杆的移动有详细信息来识别移动的轴和摇杆移动到的位置:

$ pygame_events.py 
pygame 2.1.2 (SDL 2.0.14, Python 3.9.2)
pygame 2.1.2 (SDL 2.0.14, Python 3.9.2)
Hello from the pygame community. https://www.pygame.org/contribute.html
joystick name: Sony Interactive Entertainment Wireless Controller
button pressed 0
button pressed 3
button pressed 2
button pressed 1
axis motion 0 -0.003936887722403638
axis motion 0 -0.003936887722403638
axis motion 4 0.003906369212927641
axis motion 4 -0.027466658528397473

在接下来的部分中,我们将学习如何更详细地读取事件数据。

7.5 读取 Linux 摇杆事件

使用 Pygame 让我们了解了如何在 Python 中与摇杆事件交互以及创建图形应用程序,我们将在接下来的章节中做更多这方面的内容。然而,这个库更专注于创建视频游戏,所以它并不完全适合我们的需求。幸运的是,在 Linux 上,对摇杆设备有非常好的支持,Python 应用程序可以直接读取它们的事件数据。这样做的好处是我们可以避免运行整个视频游戏引擎的开销和复杂性,而可以专注于当前的任务,即读取摇杆事件。我们将解析摇杆事件数据,并专注于处理 y-轴上的摇杆事件,这是我们机器人应用程序中最感兴趣的事件。我们需要创建一个满足以下要求的应用程序:

  • Python 应用程序应直接读取 Linux 摇杆事件。

  • 应该区分事件是按钮按下还是摇杆移动事件。

  • 应该过滤轴事件,只处理两个摇杆上的 y- 轴事件。

  • 应该能够计算 y-轴上的移动方向和百分比。

7.5.1 探索 Linux 输入子系统

要读取摇杆事件数据,我们首先需要探索 Linux 输入子系统。文档(www.kernel.org/doc/html/latest/input/)非常全面,并将成为我们探索和实施的基础。我们感兴趣的所有内容都在“Linux 摇杆支持”章节中。从文档中,我们可以看到每个连接的摇杆在文件系统中都暴露为一个设备文件。这是 Unix 系统中的一种常见方法。当摇杆连接时,摇杆设备会自动创建,并遵循一个常见的命名约定。这使得列出它们变得容易。以下终端会话显示了如何列出系统上的摇杆设备:

$ ls /dev/input/js*
/dev/input/js0

从输出中,我们可以看到连接了一个摇杆。如果有额外的摇杆连接,它们将被命名为 /dev/input/js1/dev/input/js2。文档还涵盖了 jstest 命令,该命令可用于在终端连接到摇杆并查看生成的摇杆事件详情。运行以下行来安装该命令:

$ sudo apt install joystick

现在,我们可以运行 jstest 来在我们的终端中获得摇杆事件的实时视图:

$ jstest /dev/input/js0
Driver version is 2.1.0.
Joystick (Wireless Controller)
  has 8 axes (X, Y, Z, Rx, Ry, Rz, Hat0X, Hat0Y)
and 13 buttons (BtnA, BtnB, BtnX, BtnY, BtnTL, BtnTR, BtnTL2, BtnTR2,
  BtnSelect, BtnStart, BtnMode, BtnThumbL, BtnThumbR).
Testing ... (interrupt to exit)
Axes:  0:0  1:0  2:-32767  3:0  4:0  5:-32767  6:0  7:0
Buttons:  0:off  1:off  2:off  3:off  4:off  5:off  6:off
          7:off  8:off  9:off 10:off 11:off 12:off

从输出中,我们可以看到检测到的不同按钮和轴。我们可以看到所有按钮都是关闭的,因为它们都没有被按下。每个按钮都有一个特定的编号用于识别它。每个按钮的值要么是开启要么是关闭。如果我们按住 PlayStation 控制器的十字按钮,我们将得到以下输出:

Buttons:  0:on   1:off  2:off  3:off  4:off  5:off  6:off
          7:off  8:off  9:off 10:off 11:off 12:off

我们可以看到按钮编号 0 是开启的,这意味着十字按钮被映射到这个按钮。如果我们现在按下圆形按钮,我们将得到以下输出:

Buttons:  0:off  1:on   2:off  3:off  4:off  5:off  6:off
          7:off  8:off  9:off 10:off 11:off 12:off

输出指示圆形按钮被映射到按钮编号 1。如果我们继续这个过程,它将显示三角形按钮被映射到按钮编号 2,正方形按钮被映射到按钮编号 3。我们可以记录这些映射并在我们的应用程序中使用它们将按钮编号映射到按钮标签。如果你使用的是 Xbox 控制器,你可以遵循相同的程序来处理 A、B、X 和 Y 按钮。

接下来,让我们探索轴值。当没有任何按键被按下时,我们得到以下值:

Axes:  0:0  1:0  2:-32767  3:0  4:0  5:-32767  6:0  7:0

从文档中,我们可以看到当摇杆处于中心位置时,轴的值为 0。当摇杆沿特定轴推到最远方向时,值为 32767,而当它放在相反方向时,它变为 -32767。摇杆的其他位置将表示在这些值之间,具体取决于摇杆离中心有多远。我们可以从输出中看到,有两个轴的值为 -32767,即使我们没有移动摇杆。原因是这两个轴被映射到控制器上的触发按钮,这些按钮具有可以检测按钮被按下程度的硬件,而摇杆上的其他按钮则没有。对于我们的机器人应用程序,我们不需要使用触发器,因此我们可以忽略它们。如果我们把右侧摇杆推到最前面的位置,我们会得到以下输出:

Axes:  0:0  1:0  2:-32767  3:0  4:-32767  5:-32767  6:0  7:0

我们可以看到右侧摇杆的 y 轴映射到轴号 4,其值为 -32767。这意味着 y 轴的前进位置映射到 -32767 的值。现在,让我们将右侧摇杆移动到最远位置向后,并查看结果:

Axes:  0:0  1:0  2:-32767  3:0  4:32767  5:-32767  6:0  7:0

现在相同的轴具有 32767 的值。向后位置映射到 32767 的值。通过为右侧摇杆的 x 轴执行相同的处理过程,我们看到它映射到轴号 3。同样,我们可以发现左侧摇杆的 x 轴映射到轴号 0,而 y 轴映射到轴号 1

文档有一个名为“编程接口”的优秀部分,它将为我们提供编写应用程序所需的一切。一般方法将是以二进制模式打开摇杆设备文件,并从设备文件中读取固定长度的字节。我们读取的每个数据块都是一个单独的摇杆事件。我们正在读取的二元数据的结构如下:

struct js_event {
    __u32 time;     /* event timestamp in milliseconds */
    __s16 value;    /* value */
    __u8 type;      /* event type */
    __u8 number;    /* axis/button number */
};

Python 内置了一个模块,用于以 C 语言 struct 格式读取二进制数据并将其转换为相关的 Python 值。在这个阶段,我们只需要注意数据类型及其含义。第一个值是一个时间戳,我们不需要它。然后,value 将具有与使用 jstest 命令看到的相同的按钮和轴值。通过检查 type,我们可以判断事件是按钮事件还是轴事件。文档中说明,对于 type 变量,按钮事件将具有 1 的值,而轴事件将具有 2 的值。最后,number 表示事件针对的是哪个按钮或轴,这与我们在 jstest 输出中看到的方式相同。现在,我们已经拥有了所有必需的信息来组合我们的实现。

7.5.2 解包摇杆事件

此脚本将包含从 Linux 输入子系统读取摇杆事件并将事件数据转换为 Python 的核心逻辑。我们将导入 Python 标准库中的 struct 模块。此模块提供了将 C 结构数据转换为 Python 值的功能:

from struct import Struct

然后我们将摇杆设备文件的路径保存在一个名为DEVICE的变量中:

DEVICE = '/dev/input/js0'

现在我们定义函数main,它首先创建一个Struct对象,并将其保存在event_struct变量中。Python 文档中的struct模块展示了如何映射不同的 C 数据类型。第一个值是u32,它是一个 32 位长度的无符号整数,因此映射到I。下一个值是__s16,它是一个 16 位整数,因此映射到h。最后,最后两个值都是u8,它是一个 8 位整数,因此映射到B。这使得创建的Struct对象格式为'I h B B.'。我们现在以模式'rb'打开设备文件,以便以二进制模式打开文件进行读取。然后我们进入一个while循环,该循环持续从设备文件中读取事件数据。接下来,我们从文件中读取event_struct.size个字节,并将其保存到bytes变量中。此值是 C structs数据的确切字节数。通过读取这个确切的大小,我们在每个循环中读取一个摇杆事件。接下来,我们使用unpack方法将字节数据转换为 Python 值集合,并将其保存到data中。然后,我们将事件数据的每个部分保存到单独的变量中。最后,当检测到按钮按下事件时,我们打印出valuenumber,这对应于type1

def main():
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            data = event_struct.unpack(bytes)
            time, value, type, number = data
            if type == 1:
                print(f'value:{value} number:{number}')

应用程序的最后一行调用main函数:

main()

完整的脚本可以保存为joystick_unpack.py在 Pi 上,然后执行。

列表 7.3 joystick_unpack.py:在 Linux 上解包原始摇杆事件

#!/usr/bin/env python3
from struct import Struct

DEVICE = '/dev/input/js0'

def main():
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            data = event_struct.unpack(bytes)
            time, value, type, number = data
            if type == 1:
                print(f'value:{value} number:{number}')

main()

输出显示了脚本运行会话,然后按下了十字和圆形按钮。输出的前两行与十字按钮相关,其按钮编号为number:0。我们可以看到,按钮的value值在按下和释放时从1变为0。最后两行显示相同的情况,但按钮编号为number:1,这表明圆形按钮被按下和释放:

$ joystick_unpack.py 
value:1 number:0
value:0 number:0
value:1 number:1
value:0 number:1

7.5.3 映射摇杆事件

下一步是将事件中的值映射到更易读的按钮和轴的名称以及事件类型。这将使我们的代码更易读,并为我们提供在终端中生成更易读输出的能力。我们还将创建一个专用函数,该函数将在接收摇杆事件时被调用。

我们将导入collections模块中的namedtuple对象,它是 Python 标准库的一部分。此对象提供了一种将 Python tuple对象转换为更易读的namedtuple的绝佳方式:

from collections import namedtuple

我们在 TYPE_BUTTONTYPE_AXIS 中保存按钮和轴类型事件的值。我们使用字典将 BUTTON 变量中每个按钮的名称进行映射。BUTTON 的第一个版本是对 PlayStation 控制器的映射,而第二个注释掉的版本是对 Xbox 控制器的映射。根据需要,您可以使用任何一个。然后,我们创建一个名为 AXIS 的字典,以获取轴事件的轴名称:

TYPE_BUTTON = 1
TYPE_AXIS = 2
BUTTON = {0: 'cross', 1: 'circle', 2: 'triangle', 3: 'square'}
# BUTTON = {0: 'A', 1: 'B', 2: 'X', 3: 'Y'}
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}

创建了一个名为 Eventnamedtuple,它将被用来以更可读的数据结构保存事件数据:

Event = namedtuple('Event', 'time, value, type, number')

main 函数基本上与上一个脚本相同。主要区别是,为每个新事件创建一个 Event 对象,然后使用此对象调用 handle_event 函数:

def main():
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event)

handle_event 函数遇到按钮事件时,它将使用 BUTTON 字典查找按钮的名称。我们使用 get 方法,这样如果按下了我们未定义的按钮,就不会导致错误;相反,返回 None 值。这样,我们可以定义我们最关心的按钮名称。然后,我们将输出到终端,表示遇到了按钮事件,并提供按钮名称和 event 变量的转储。当检测到轴事件时,会检索轴的名称,并输出轴名称和 event 变量的类似详细信息:

def handle_event(event):
    if event.type == TYPE_BUTTON:
        name = BUTTON.get(event.number)
        print('button -', name, event)
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        print('axis -', name, event)

完整脚本可以保存为 joystick_map.py 在 Pi 上,然后执行。

列表 7.4 joystick_map.py:将摇杆事件映射到按钮和轴名称

#!/usr/bin/env python3
from struct import Struct
from collections import namedtuple

DEVICE = '/dev/input/js0'
TYPE_BUTTON = 1
TYPE_AXIS = 2
BUTTON = {0: 'cross', 1: 'circle', 2: 'triangle', 3: 'square'}
# BUTTON = {0: 'A', 1: 'B', 2: 'X', 3: 'Y'}
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}

Event = namedtuple('Event', 'time, value, type, number')

def handle_event(event):
    if event.type == TYPE_BUTTON:
        name = BUTTON.get(event.number)
        print('button -', name, event)
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        print('axis -', name, event)

def main():
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event)

main()

以下会话显示了按下十字和圆形按钮时的输出。然后,右摇杆沿 y 轴和 x 轴移动。最后,左摇杆在两个轴上再次移动:

$ joystick_map.py 
button - cross Event(time=16675880, value=1, type=1, number=0)
button - cross Event(time=16676010, value=0, type=1, number=0)
button - circle Event(time=16676400, value=1, type=1, number=1)
button - circle Event(time=16676500, value=0, type=1, number=1)
axis - right_x Event(time=16677540, value=-1014, type=2, number=3)
axis - right_y Event(time=16677540, value=-1014, type=2, number=4)
axis - right_x Event(time=16677540, value=-2365, type=2, number=3)
axis - right_y Event(time=16677540, value=-2027, type=2, number=4)
axis - right_x Event(time=16677550, value=-3041, type=2, number=3)
axis - right_y Event(time=16677550, value=-2703, type=2, number=4)
axis - left_x Event(time=16681520, value=7769, type=2, number=0)
axis - left_y Event(time=16681520, value=-19932, type=2, number=1)
axis - left_x Event(time=16681520, value=5067, type=2, number=0)
axis - left_y Event(time=16681520, value=-12500, type=2, number=1)
axis - left_x Event(time=16681530, value=0, type=2, number=0)
axis - left_y Event(time=16681530, value=-2365, type=2, number=1)

7.5.4 与轴事件一起工作

我们现在可以更深入地研究轴事件,并计算方向以及摇杆移动的距离。对于控制机器人,我们只关心摇杆在 y 轴上的移动,因此我们只会关注该轴上的事件。可以使用 MAX_VAL 变量,这样我们就可以将摇杆位置与最大可能值进行比较,以计算移动的百分比:

MAX_VAL = 32767

handle_event 函数已被修改,仅关注轴事件。一旦获得轴的名称,就会检查它是否为 left_yright_y。这样,只处理 y 轴事件。direction 变量将跟踪摇杆是向前推还是向后推。此值基于 event.value 是正数还是负数来计算。取 event.value 的绝对值,并将其除以 MAX_VAL 来计算摇杆远离中心的分数位置。此值乘以一百并四舍五入到两位小数,以百分比的形式表示。然后,这三个变量被输出到终端:

def handle_event(event):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name in ['left_y', 'right_y']:
            direction = 'backward' if event.value > 0 else 'forward'
            percent = round((abs(event.value) / MAX_VAL) * 100, 2)
            print(name, direction, percent)

完整脚本可以保存为 joystick_axis.py 在 Pi 上,然后执行。

列表 7.5 joystick_axis.py:控制方向和移动百分比

#!/usr/bin/env python3
from struct import Struct
from collections import namedtuple

DEVICE = '/dev/input/js0'
TYPE_AXIS = 2
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}
MAX_VAL = 32767

Event = namedtuple('Event', 'time, value, type, number')

def handle_event(event):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name in ['left_y', 'right_y']:
            direction = 'backward' if event.value > 0 else 'forward'
            percent = round((abs(event.value) / MAX_VAL) * 100, 2)
            print(name, direction, percent)

def main():
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event)

main()

以下会话显示了当右摇杆向前移动到最满位置然后回到中心,然后再向后移动一点并回到中心时,在脚本执行过程中生成的输出。最后,左摇杆也被向前和向后移动:

$ joystick_axis.py 
right_y forward 13.4
right_y forward 40.21
right_y forward 79.38
right_y forward 100.0
right_y forward 95.88
right_y forward 35.05
right_y forward 0.0
right_y backward 18.56
right_y backward 48.45
right_y backward 22.68
right_y backward 9.28
right_y forward 0.0
left_y forward 29.9
left_y forward 56.7
left_y forward 83.51
left_y forward 100.0
left_y forward 97.94
left_y forward 86.6
left_y forward 28.87
left_y forward 0.0
left_y backward 16.5
left_y backward 28.86
left_y backward 41.24
left_y backward 19.59
left_y forward 0.0

7.6 测量摇杆事件率

模拟摇杆上的传感器极其灵敏,可以检测到数百种不同的位置。这种灵敏度会产生每秒非常高的摇杆事件率。我们需要注意这一点,因为它可能会让我们的机器人接收到大量请求来改变电机节流,即使摇杆位置只有微小的变化。在motor模块中,我们包含了三个速度等级。我们可以采用类似的方法来解决我们的摇杆问题,通过计算每个方向的三个摇杆位置等级,并且只有在这些等级中的一个发生变化时才请求机器人移动。每个等级将直接与一个速度等级相关联。

与摇杆一起使用的另一种常见解决方案是创建一个可配置的摇杆死区。这个死区是指摇杆需要从中性位置移动多远,应用程序才会将其视为运动。

我们将编写一个脚本来测量和报告每秒生成的轴事件数量,以便我们可以量化这个问题。然后,我们将增强脚本以计算三个定义好的等级的变化以及等级变化的速率。有了这些测量数据,我们将有数据来得出这个解决方案是否解决问题的结论。脚本将仅关注单个摇杆的y轴,以简化实现和测量。应用程序需要满足以下要求:

  • Python 应用程序应读取 100 个轴事件并记录所花费的时间。

  • 事件率应计算为新事件每秒的数量。

  • 等级率应计算为新等级每秒的数量。

  • 应仅记录右摇杆y轴上的轴运动。

7.6.1 计算事件率

当前手头的首要任务将是计算每秒新轴事件的数量。脚本将基于上一节中的脚本进行构建。我们将导入sys模块,以便在所有测量完成后退出脚本。导入time模块以测量时间。从types模块导入SimpleNamespace对象,并将用于跟踪我们的统计数据:

import sys
import time
from types import SimpleNamespace

main函数与我们之前看到的脚本非常相似,有一些小的添加。data变量将跟踪迄今为止遇到的事件数量以及第一个事件的开始时间:

def main():
    data = SimpleNamespace(events=0, start=None)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data)

handle_event将检查右摇杆上的任何y轴事件。每次检测到事件时,它将调用update_counter函数,以便更新计数器统计信息:

def handle_event(event, data):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name == 'right_y':
            update_counter(data)

update_counter 函数将 data.events 计数器变量增加以记录新事件。然后输出计数事件的数目。如果这是遇到的第一事件,则将样本的起始时间保存在 data.start 变量中。如果收集了一百个样本,则调用 stop_counter 函数结束测量并报告结果:

def update_counter(data):
    data.events += 1
    print('events:', data.events)
    if data.events == 1:
        data.start = time.perf_counter()
    if data.events == 100:
        stop_counter(data)

stop_counter 函数首先计算在计数新事件并保存结果到 duration 中的时间。然后,计算每秒新事件的数量并保存在 event_rate 中。最后,打印所用时间、总事件数和事件速率,并退出脚本:

def stop_counter(data):
    duration = time.perf_counter() - data.start
    event_rate = data.events / duration
    print('---------- STATS ----------')
    print(f'      time: {duration:0.3f}s')
    print(f'    events: {data.events}')
    print(f'event rate: {event_rate:0.1f}')
    sys.exit()

完整的脚本可以保存为 joystick_stats.py 在 Pi 上,然后执行。

列表 7.6 joystick_stats.py: 收集和报告摇杆统计数据

#!/usr/bin/env python3
import sys
import time
from struct import Struct
from collections import namedtuple
from types import SimpleNamespace

DEVICE = '/dev/input/js0'
TYPE_AXIS = 2
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}
MAX_VAL = 32767

Event = namedtuple('Event', 'time, value, type, number')

def stop_counter(data):
    duration = time.perf_counter() - data.start
    event_rate = data.events / duration
    print('---------- STATS ----------')
    print(f'      time: {duration:0.3f}s')
    print(f'    events: {data.events}')
    print(f'event rate: {event_rate:0.1f}')
    sys.exit()

def update_counter(data):
    data.events += 1
    print('events:', data.events)
    if data.events == 1:
        data.start = time.perf_counter()
    if data.events == 100:
        stop_counter(data)

def handle_event(event, data):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name == 'right_y':
            update_counter(data)

def main():
    data = SimpleNamespace(events=0, start=None)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data)

main()

当你执行脚本时,用右摇杆不断向前和向后推,直到检测到 100 个事件。然后,脚本将退出并打印测量结果。会话显示了所进行的轴测量结果。正如预期的那样,每秒事件数对我们来说相当高,如果我们为每次摇杆运动发送一个油门请求,可能会对我们的机器人电机服务器构成挑战:

$ joystick_stats.py 
events: 1
events: 2
events: 3
...
events: 98
events: 99
events: 100
---------- STATS ----------
      time: 0.722s
    events: 100
event rate: 138.5

7.6.2 计算层级速率

现在我们可以增强我们的脚本,以计算沿 y 轴每个运动方向的摇杆位置的不同层级。然后,我们可以计算每秒新层级的数量。

data 变量有一些额外的属性来跟踪新层级的数量和遇到的最后一个层级。函数的其余部分保持不变:

def main():
    data = SimpleNamespace(events=0, levels=0, last_level=0, start=None)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data)

handle_event 函数将通过将摇杆从中心移动的距离除以三来计算层级。然后将此值保存在 level 变量中,并在调用时将其传递给 update_counter 函数:

def handle_event(event, data):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name == 'right_y':
            level = round((event.value / MAX_VAL) * 3)
            update_counter(data, level)

update_counter 函数现在每次遇到新层级时也会增加 data.levels。在每次调用中,打印事件和层级计数。函数中的其余逻辑保持不变:

def update_counter(data, level):
    data.events += 1
    if data.last_level != level:
        data.last_level = level
        data.levels += 1
    print('events:', data.events, 'level:', level)
    if data.events == 1:
        data.start = time.perf_counter()
    if data.events == 100:
        stop_counter(data)

stop_counter 函数现在也会计算层级速率并输出速率和遇到的新层级总数:

def stop_counter(data):
    duration = time.perf_counter() - data.start
    event_rate = data.events / duration
    level_rate = data.levels / duration
    print('---------- STATS ----------')
    print(f'      time: {duration:0.3f}s')
    print(f'    events: {data.events}')
    print(f'event rate: {event_rate:0.1f}')
    print(f'    levels: {data.levels}')
    print(f'level rate: {level_rate:0.1f}')
    sys.exit()

完整的脚本可以保存为 joystick_levels.py 在 Pi 上,然后执行。

列表 7.7 joystick_levels.py: 将层级应用于摇杆运动

#!/usr/bin/env python3
#!/usr/bin/env python3
import sys
import time
from struct import Struct
from collections import namedtuple
from types import SimpleNamespace

DEVICE = '/dev/input/js0'
TYPE_AXIS = 2
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}
MAX_VAL = 32767

Event = namedtuple('Event', 'time, value, type, number')

def stop_counter(data):
    duration = time.perf_counter() - data.start
    event_rate = data.events / duration
    level_rate = data.levels / duration
    print('---------- STATS ----------')
    print(f'      time: {duration:0.3f}s')
    print(f'    events: {data.events}')
    print(f'event rate: {event_rate:0.1f}')
    print(f'    levels: {data.levels}')
    print(f'level rate: {level_rate:0.1f}')
    sys.exit()

def update_counter(data, level):
    data.events += 1
    if data.last_level != level:
        data.last_level = level
        data.levels += 1
    print('events:', data.events, 'level:', level)
    if data.events == 1:
        data.start = time.perf_counter()
    if data.events == 100:
        stop_counter(data)

def handle_event(event, data):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name == 'right_y':
            level = round((event.value / MAX_VAL) * 3)
            update_counter(data, level)

def main():
    data = SimpleNamespace(events=0, levels=0, last_level=0, start=None)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data)

main()

我们现在可以执行新的脚本,再次移动右侧操纵杆,直到检测到 100 个事件。会话显示了再次进行事件测量和这次计算水平测量的结果。我们可以从结果中看到,10.3 的水平速率是 146.6 事件速率的 14 倍低。通过仅在水平发生变化时而不是在每次事件上发送机器人移动请求,我们可以在机器人电机服务器上的请求负载上做出非常显著的改进。通过这种性能提升,我们仍然支持所有三个电机速度级别,并将能够通过网络提供对操纵杆的响应式体验来移动机器人:

$ joystick_levels.py 
events: 1 level: 0
events: 2 level: 0
events: 3 level: 0
...
events: 98 level: -3
events: 99 level: -3
events: 100 level: -3
---------- STATS ----------
      time: 0.682s
    events: 100
event rate: 146.6
    levels: 7
level rate: 10.3

7.7 使用操纵杆移动机器人

我们现在拥有了将操纵杆连接到机器人的所需工具。当前的任务是创建一个满足以下要求的应用程序:

  • 我们应该创建一个 Python 应用程序,当右侧操纵杆向前和向后移动时,使右侧电机向前和向后移动。

  • 左侧电机应根据左侧操纵杆的移动进行相应的移动。

  • 操纵杆应用程序应通过 HTTP 将移动请求发送到运行robows服务器的机器人服务器。

7.7.1 创建操纵杆客户端

将发送移动请求到robows服务器的所有逻辑都是从列表 5.6 中的client_persist.py中获取的。导入jsonhttp.client模块以进行所需的 HTTP 请求。导入Structnamedtuple对象以帮助读取操纵杆事件数据:

import json
from http.client import HTTPConnection
from struct import Struct
from collections import namedtuple

使用MOTOR_AXIS字典将操纵杆轴映射到机器人上相关的电机。左侧操纵杆y轴映射到左侧电机,右侧操纵杆y轴映射到右侧电机:

MOTOR_AXIS = dict(left_y='L', right_y='R')

main函数与本章中之前看到的函数非常相似。建立到机器人服务器的 HTTP 连接并将其保存在HTTPConnection中。然后创建一个名为data的字典来跟踪左右操纵杆y轴上的位置水平。函数的其余部分保持不变:

def main():
    conn = HTTPConnection('robopi:8888')
    data = dict(left_y=0, right_y=0)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data, conn)

每当检测到新事件时,都会调用handle_event函数。它将检查事件是否是操纵杆或右侧操纵杆的y轴上的轴事件。如果是,它将计算操纵杆从中心位置的水平。将前一个水平进行比较,如果水平发生变化,它将准备对机器人电机的油门进行更改。新的水平将保存在data中。使用MOTOR_AXIS查找要移动的电机名称。根据水平是正还是负计算factor的值。这将决定电机是向前还是向后转动。电机移动的速度将基于level的绝对值。这些参数将保存在名为args的字典中。然后调用call_robot函数,以便向机器人服务器发出调用set_throttle的请求:

def handle_event(event, data, conn):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name in ['left_y', 'right_y']:
            level = round((event.value / MAX_VAL) * 3)
            if data[name] != level:
                print('level change:', name, level)
                data[name] = level
                motor = MOTOR_AXIS[name]
                factor = 1 if level <= 0 else -1
                args = dict(name=motor, speed=abs(level), factor=factor)
                call_robot(conn, 'set_throttle', **args)

call_apicall_robot函数在第五章中已有介绍。它们将向机器人服务器发送 HTTP 请求,请求改变电机油门:

def call_api(conn, url, data):
    body = json.dumps(data).encode()
    conn.request('POST', url, body)
    with conn.getresponse() as resp:
        resp.read()

def call_robot(conn, func, **args):
    return call_api(conn, '/' + func, args)

完整的脚本可以保存为joystick_remote.py在 Pi 上,然后执行。

列表 7.8 joystick_remote.py:远程控制机器人电机

#!/usr/bin/env python3
import json
from http.client import HTTPConnection
from struct import Struct
from collections import namedtuple

DEVICE = '/dev/input/js0'
TYPE_AXIS = 2
AXIS = {0: 'left_x', 1: 'left_y', 3: 'right_x', 4: 'right_y'}
MOTOR_AXIS = dict(left_y='L', right_y='R')
MAX_VAL = 32767

Event = namedtuple('Event', 'time, value, type, number')

def call_api(conn, url, data):
    body = json.dumps(data).encode()
    conn.request('POST', url, body)
    with conn.getresponse() as resp:
        resp.read()

def call_robot(conn, func, **args):
    return call_api(conn, '/' + func, args)

def handle_event(event, data, conn):
    if event.type == TYPE_AXIS:
        name = AXIS.get(event.number)
        if name in ['left_y', 'right_y']:
            level = round((event.value / MAX_VAL) * 3)
            if data[name] != level:
                print('level change:', name, level)
                data[name] = level
                motor = MOTOR_AXIS[name]
                factor = 1 if level <= 0 else -1
                args = dict(name=motor, speed=abs(level), factor=factor)
                call_robot(conn, 'set_throttle', **args)

def main():
    conn = HTTPConnection('robopi:8888')
    data = dict(left_y=0, right_y=0)
    event_struct = Struct('I h B B')
    with open(DEVICE, 'rb') as js_device:
        while True:
            bytes = js_device.read(event_struct.size)
            event = Event(*event_struct.unpack(bytes))
            handle_event(event, data, conn)

main()

如第五章所述,请确保在一个终端中保持robows服务器运行,然后在另一个终端中运行此脚本。在以下会话中,我们可以看到脚本的输出。右侧操纵杆被推到最前面,然后回到中心,使右侧电机向前移动。然后,右侧操纵杆被拉回并回到中心,使右侧电机向后转动。最后,左右两侧操纵杆一起向前推,使两个电机都向前驱动,然后都回到中心,使机器人完全停止:

$ joystick_remote.py 
level change: right_y -1
level change: right_y -2
level change: right_y -3
level change: right_y -2
level change: right_y -1
level change: right_y 0
level change: right_y 1
level change: right_y 2
level change: right_y 3
level change: right_y 2
level change: right_y 1
level change: right_y 0
level change: left_y -1
level change: left_y -2
level change: left_y -3
level change: right_y -1
level change: right_y -2
level change: right_y -3
level change: right_y 0
level change: left_y -1
level change: left_y 1
level change: left_y 0

现实世界中的机器人:远程叉车操作员

许多机器人初创公司已经开始为叉车等车辆提供远程操作服务。远程驾驶员可以使用多个摄像头查看车辆周围区域,并使用操纵杆控制器驾驶叉车。这为将来自许多远程位置的驾驶员与不同地点相匹配提供了可能性,这对于解决重型车辆驾驶员短缺问题已成为一项重要技术。关于这一主题的 BBC 文章(www.bbc.com/news/business-54431056)提到了这种方法提供的机会以及风险。

安全功能,如车辆上安装麦克风,以便远程驾驶员可以听到周围是否有人呼叫他们停车,是必不可少的。在软件和网络层面上也采取了安全措施,以防止恶意方未经授权访问车辆。

摘要

  • 当用于控制机器人电机时,操纵杆比键盘和鼠标提供了更优越的控制。

  • 操纵杆硬件可以通过有线 USB 或无线蓝牙连接连接到 Raspberry Pi。

  • 可以使用 Python 的struct模块直接从 Linux 输入子系统中读取和解析操纵杆事件。

  • Pygame 是一个非常流行的 Python 模块,用于编写视频游戏。

  • Linux 为操纵杆设备提供了非常好的支持,Python 应用程序可以直接读取其事件数据。

  • 控制器的模拟杆上的传感器非常敏感,可以检测到数百种不同的位置。

  • 要远程控制机器人电机,操纵杆应用程序必须通过 HTTP 向运行robows服务器的机器人服务器发送移动请求。

8 键盘控制相机

本章涵盖

  • 从相机捕获图像和流式传输实时视频

  • 使用 OpenCV 库绘制形状和写入文本

  • 将伺服电机移动到特定的角度和位置

  • 使用 OpenCV 中的键盘事件来控制伺服电机运动

在本章中,我们将构建一个带有两个伺服电机连接相机的机器人。一个伺服电机将允许我们平移相机,另一个将应用倾斜运动。这样,我们可以将相机指向许多不同的方向。此外,我们将检测和使用键盘事件来在不同方向上移动电机,并从实时相机流中捕获和保存照片。

我们已经探讨了在移动设备上使用触摸屏和操纵杆作为人机交互设备。现在,我们将使用键盘来控制我们的机器人运动和相机。键盘是最古老和最成熟的输入设备之一。与操纵杆相比,它们随每台台式计算机和笔记本电脑一起提供,并且在大多数软件中都有出色的内置支持,不需要额外的工作。这使得它们在不想添加额外的硬件要求或只想简化设备处理时成为操纵杆的绝佳替代品。

8.1 硬件堆栈

图 8.1 显示了硬件堆栈,本章中使用的特定组件被突出显示。相机可以直接通过相机连接器连接到 Raspberry Pi。本章中使用的相机是 Raspberry Pi Camera Module 2。

图片

图 8.1 硬件堆栈:相机和伺服电机将通过键盘进行控制。

伺服电机是 Pan-Tilt 套件的一部分,该套件已完全组装。相机将安装在此套件上。有关机器人组装的更多详细信息,请参阅附录 C 中的机器人组装指南。它展示了如何组装本章中使用的机器人。此外,在购买本章所需的硬件之前,请务必查阅附录 A 中的硬件购买指南。任何键盘都可以用于机器人;无论是 USB 键盘还是蓝牙键盘,都没有特殊要求。

8.2 软件堆栈

本章中使用的特定软件的详细信息在图 8.2 中描述。本章中的drawsnapshot应用程序将使用 OpenCV 和 NumPy 库从相机捕获图像,并在图像上绘制形状和写入文本。我们还将学习如何使用 OpenCV 库读取键盘事件,并在检测到特定键盘事件时执行拍照等操作。OpenCV 库将用于与 Linux 内核视频子系统交互,以从相机硬件捕获图像。sweeppan应用程序将在本章的后面部分介绍,并将允许我们控制伺服电机硬件,以及使用相机拍照。

图片

图 8.2 软件堆栈:OpenCV 库将捕获相机图像。

8.3 使用 OpenCV 捕获图像

OpenCV 是一个非常流行且功能强大的计算机视觉库。我们将使用它来与相机交互,显示实时视频流并保存快照。在后面的章节中,我们将扩展我们的使用范围,以执行人脸检测和二维码检测与解码。我们需要创建一个满足以下要求的应用程序:

  • 应该创建一个使用 OpenCV 库显示来自相机的实时视频流的 Python 应用程序。

  • 当用户按下空格键时,应该能够保存带有时间戳的快照图像。

  • 每次保存图像时,应显示一条文本消息。

  • 应用程序应该在按下 Esc 键或字母 Q 时终止。

我们创建这个应用程序是为了放置一些基本的组件,我们可以使用这些组件来构建更复杂的应用程序。与来自相机的实时视频交互将使我们接触到相机帧率,并为从实时视频流中进行人脸和二维码检测做准备。使用 OpenCV 执行所有这些操作是战略性的,因为它是 Python 中计算机视觉的首选库。

8.3.1 探索 OpenCV 库

要在 OpenCV 中使用相机,我们首先必须在 Raspberry Pi 上启用旧版相机支持。在终端中使用raspi-config并启用 Legacy

在界面选项菜单中的相机选项。图 8.3 显示了一台放置在 Adafruit 相机外壳中的 Raspberry Pi 相机的照片,以保护它并使其更容易连接到机器人。

图片

图 8.3 Raspberry Pi 相机:相机被封装在 Adafruit 相机外壳中。

OpenCV 库大量使用 Python NumPy 库。NumPy 用户指南(numpy.org/doc/stable/user/)是一个优秀的资源,提供了关于安装程序的详细信息。根据指南,在安装 NumPy 之前需要安装libatlas-base-dev包。OpenCV 需要libgtk2.0-dev包来支持其图形界面功能。运行以下命令来安装这两个包:

$ sudo apt install libatlas-base-dev libgtk2.0-dev

我们现在可以使用以下命令安装 OpenCV:

$ ~/pyenv/bin/pip install opencv-python

同一个命令也会自动安装 NumPy,因为它是 OpenCV 的辅助工具。OpenCV 有一个庞大的代码库,它在 Raspberry Pi 上的安装可能需要长达 70 分钟才能完成;在执行安装时请记住这一点。

我们现在可以直接进入 REPL 会话并开始与 OpenCV 和相机交互。我们首先导入cv2,并使用0作为参数调用VideoCapture以打开默认相机。我们将VideoCapture对象保存在名为cap的变量中:

>>> import cv2
>>> cap = cv2.VideoCapture(0)

我们可以通过调用isOpened方法来检查相机是否正确初始化并且是打开的。如果相机正确初始化,它将返回True

>>> cap.isOpened()
True

我们还可以查询与视频捕获设备相关的不同属性。以下会话显示,要捕获的图像大小设置为宽度640和高度480。我们还可以检查视频的帧率,在我们的情况下设置为每秒 30 帧:

>>> cap.get(cv2.CAP_PROP_FRAME_WIDTH)
640.0
>>> cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
480.0
>>> cap.get(cv2.CAP_PROP_FPS)
30.0

接下来,我们调用read方法来抓取和解码下一个视频帧。它将返回两个值。第一个是一个布尔值,表示调用是否成功。第二个值是图像本身。通过检查ret的值,我们可以看到调用是成功的。如果我们检查frame的数据类型,我们会看到它报告为numpy .ndarray。这种数据结构是 NumPy 的核心,它提供了一个执行非常高效的 n 维数组数据类型:

>>> ret, frame = cap.read()
>>> ret
True
>>> type(frame)
<class 'numpy.ndarray'>

无论何时我们在 OpenCV 中处理图像,我们都会使用ndarray对象。有一些有用的属性我们可以检查,以获取有关我们刚刚捕获的图像的更多详细信息。shape属性显示,正如预期的那样,我们的图像高度为480,宽度为640。显示3的最后部分表示它是一个彩色图像,并具有三个颜色组件:红色、绿色和蓝色。dtype表示数组中每个项的数据类型。uint8数据类型表明每个值是一个 8 位整数,这与每个颜色组件的值范围从 0 到 255 相关:

>>> frame.shape
(480, 640, 3)
>>> frame.dtype
dtype('uint8')

现在,我们可以通过调用imwrite方法并传入我们的文件名来将图像数据保存到磁盘。此方法将使用文件扩展名来编码图像数据为预期的图像格式。接下来,我们将在当前目录中找到一个名为photo.jpg的图像,这是从相机捕获的快照:

>>> cv2.imwrite('photo.jpg', frame)
True

当我们完成与摄像头的操作后,通过调用release方法以平稳的方式关闭视频流总是一个好主意。当我们关闭捕获设备后调用isOpened时,我们可以看到视频流状态已关闭:

>>> cap.release()
>>> cap.isOpened()
False

这使我们接触到了 OpenCV 和 NumPy。我们能够熟悉使用 OpenCV 捕获图像所需的核心对象和操作。

8.3.2 使用 OpenCV 绘制形状和显示文本

在执行如人脸检测等计算机视觉活动时,能够绘制形状,如矩形,覆盖已检测图像的精确部分是非常有用的。在图像上放置文本是另一种常见的绘图操作,用于显示消息。我们将编写一个脚本,调用 OpenCV 的主要绘图函数,并演示它们的使用。首先,我们从 Python 标准库中导入string模块,我们将使用它来显示小写字母。然后我们导入cv2numpy模块:

import string
import numpy as np
import cv2

接下来,我们定义三个常量颜色,用于绘制我们的形状。在大多数系统中,颜色由它们的红色、绿色和蓝色元素表示。大多数系统使用 RGB 编码,并且需要记住,OpenCV 中的默认颜色编码是 BGR:

BLUE = (255, 0, 0)
GREEN = (0, 255, 0)
RED = (0, 0, 255)

要创建一个新的图像,我们调用 np.zeros,这将创建一个新的 ndarray 对象,填充为零值。当图像的所有颜色分量都为零值时,颜色将是黑色。数组的 shape 与我们从摄像头捕获的图像所使用的相同。这个数组是一个宽度为 640 和高度为 480 的黑色 BGR 图像:

img = np.zeros(shape=(480, 640, 3), dtype=np.uint8)

我们现在可以开始在图像上绘制形状。下一行将绘制一个半径为 100 px 的红色圆圈,圆心位于 (x, y) 坐标 (200, 110)

cv2.circle(img, center=(200, 110), radius=100, color=RED)

接下来,我们从图像的左上角 (0, 0) 开始绘制一条绿色线条到圆的中心 (200, 110)

cv2.line(img, pt1=(0, 0), pt2=(200, 110), color=GREEN)

然后我们调用 rectangle 在圆下方绘制一个蓝色矩形框,一个角在 (50, 250),另一个角在 (350, 350)。这创建了一个宽度为 300 和高度为 100 的矩形框:

cv2.rectangle(img, pt1=(50, 250), pt2=(350, 350), color=BLUE)

最后,我们将通过调用 putText 函数在图像上放置一些文本。我们将使用 FONT_HERSHEY_SIMPLEX 字体在位置 (10, 380) 以正常比例和红色显示小写字母的字母表:

text = string.ascii_lowercase
font = cv2.FONT_HERSHEY_SIMPLEX
pos = (10, 380)
cv2.putText(img, text, org=pos, fontFace=font, fontScale=1, color=RED)

脚本的最后两行将显示图像,并在退出应用程序之前等待按键:

cv2.imshow('preview', img)
cv2.waitKey()

完整的脚本可以保存为 draw.py 在 Pi 上,然后执行。

图 8.1 draw.py:使用 OpenCV 绘制不同大小和位置的形状

#!/usr/bin/env python3
import string
import numpy as np
import cv2

BLUE = (255, 0, 0)
GREEN = (0, 255, 0)
RED = (0, 0, 255)

img = np.zeros(shape=(480, 640, 3), dtype=np.uint8)

cv2.circle(img, center=(200, 110), radius=100, color=RED)
cv2.line(img, pt1=(0, 0), pt2=(200, 110), color=GREEN)
cv2.rectangle(img, pt1=(50, 250), pt2=(350, 350), color=BLUE)

text = string.ascii_lowercase
font = cv2.FONT_HERSHEY_SIMPLEX
pos = (10, 380)
cv2.putText(img, text, org=pos, fontFace=font, fontScale=1, color=RED)

cv2.imshow('preview', img)
cv2.waitKey()

此脚本将创建一个窗口,因此需要在支持创建图形窗口的环境中运行。OpenCV 支持三种选项:

  • 在连接了键盘、鼠标和显示器的桌面环境中直接在 Raspberry Pi 上运行脚本。

  • 使用 VNC 观察器远程通过 VNC 运行脚本。

  • 使用 SSH X11 转发远程运行脚本,命令为 ssh robo@robopi -X

有多种方式运行这些图形脚本非常方便,因为你可以选择最适合你手头硬件和软件的方法。图 8.4 显示了脚本生成的窗口和图像的外观。

图 8.4 绘制形状:OpenCV 支持在图像上绘制形状和文本。

现在我们已经掌握了使用 OpenCV 绘制形状的基础,我们可以继续进行下一个任务。在接下来的几节中,我们将使用其中一些函数来帮助我们显示在应用程序中的文本,当我们进行快照时。

8.3.3 使用 OpenCV 拍照

我们现在已经探索得足够多,可以创建我们的相机应用程序,它将允许我们从实时摄像头视频流中拍摄照片快照。我们导入 datetime 来帮助我们生成带时间戳的文件名。我们还导入了 OpenCV 模块:

from datetime import datetime
import cv2

为了提高可读性,我们将 Esc 键的键码保存到一个名为ESC_KEY的常量中。我们还保存了蓝色的颜色码,并将我们将要使用的字体保存到FONT中。当我们在应用程序中显示文本消息时,我们将它们定位在左上角使用TEXT_POS。我们将以每秒 30 帧的默认帧率从摄像头捕获和显示图像。我们希望显示特定的帧数文本消息。我们将此设置保存到一个名为MSG_FRAME_COUNT的常量中。它设置为 10 帧,因此将显示消息三分之一秒:

ESC_KEY = 27
BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
TEXT_POS = (10, 30)
MSG_FRAME_COUNT = 10

函数main是我们应用程序的核心。它以VideoCapture对象的形式打开默认摄像头,并将其保存在变量cap中。然后我们使用assert语句来确保摄像头被正确初始化;否则,我们以'Cannot open camera'消息退出。assert语句是确保程序中不同点满足某些条件的一种很好的方式:

def main():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'

在同一个函数中,我们首先将messages变量初始化为一个空列表,该列表将存储要在应用程序中显示的任何文本消息。接下来,我们进入主事件循环,在每次循环中调用waitKey。在上一节中,我们不带参数调用它,因此它会无限期地等待直到按键。这里,我们调用它为waitKey(1),这将使其等待 1 毫秒并返回。如果按下按键,它将返回按下的键的代码;否则,它将返回值-1。调用waitKey还起到了在窗口上获取和处理 GUI 事件的重要作用。我们使用 Python 的 walrus 操作符将返回值保存到变量key中。然后我们检查按下的键是否是 Esc 键或字母 Q。然后退出循环:

messages = []
while (key := cv2.waitKey(1)) not in [ord('q'), ESC_KEY]:

一旦进入事件循环,我们从摄像头捕获图像并将其保存到frame中。像之前一样,我们使用assert来确保cap.read的调用是成功的。如果按下空格键,我们调用save_photo函数来保存图像。然后我们调用set_message将应用程序中的文本消息设置为'saving photo...'。循环结束时,我们调用show_image来显示图像。当退出循环时,调用cap.release来关闭视频捕获流:

        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        if key == ord(' '):
            save_photo(frame)
            set_message(messages, 'saving photo...')
        show_image(frame, messages)

    cap.release()

接下来,我们定义一个名为save_photo的函数,该函数接收一个名为frame的参数,其中包含图像数据。我们使用datetime.now().isoformat()生成一个时间戳,并将所有':'替换为'.'。这样做是为了避免在图像文件名中放置:字符。某些软件与包含冒号的文件名不兼容。然后我们调用imwrite将图像数据保存到当前目录,并使用带时间戳的文件名:

def save_photo(frame):
    stamp = datetime.now().isoformat().replace(':', '.')
    cv2.imwrite(f'photo_{stamp}.jpg', frame)

现在我们定义 show_image 以显示在 messages 中给定文本的消息。如果 messages 中有任何文本项,它们将通过调用 pop 从列表中删除,并使用 putText 在图像上显示。然后,通过调用 imshow 显示图像:

def show_image(frame, messages):
    if messages:
        cv2.putText(frame, messages.pop(), TEXT_POS, FONT, 1, BLUE)
    cv2.imshow('preview', frame)

函数 set_message 将在下面定义。它将接受参数 text 并将 messages 列表的内容设置为重复 10 次的 text 值列表,正如在 MSG_FRAME_COUNT 变量中定义的那样。这将显示该消息文本 10 帧。

def set_message(messages, text):
    messages[:] = [text] * MSG_FRAME_COUNT

完整的脚本可以保存为 Pi 上的 snapshot.py 并执行。

图 8.2 snapshot.py:在按下空格键时拍摄快照

#!/usr/bin/env python3
from datetime import datetime
import cv2

ESC_KEY = 27
BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
TEXT_POS = (10, 30)
MSG_FRAME_COUNT = 10

def save_photo(frame):
    stamp = datetime.now().isoformat().replace(':', '.')
    cv2.imwrite(f'photo_{stamp}.jpg', frame)

def show_image(frame, messages):
    if messages:
        cv2.putText(frame, messages.pop(), TEXT_POS, FONT, 1, BLUE)
    cv2.imshow('preview', frame)

def set_message(messages, text):
    messages[:] = [text] * MSG_FRAME_COUNT

def main():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'

    messages = []
    while (key := cv2.waitKey(1)) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        if key == ord(' '):
            save_photo(frame)
            set_message(messages, 'saving photo...')
        show_image(frame, messages)

    cap.release()

if __name__ == "__main__":
    main()

当您运行此应用程序时,它将显示一个窗口,以每秒 30 帧的速度捕获并显示图像,这将创建一个实时视频流。您可以

在相机前挥动手臂来测试捕获和显示图像的应用程序的延迟和响应。然后,摆出不同的姿势并按下空格键来拍摄每个姿势的快照。您会发现每张照片都保存在您的当前目录中。然后,您可以通过按下键盘上的 Esc 键或 Q 键来退出应用程序。图 8.5 显示了在保存快照时快照应用程序的外观。

图 8.5 拍摄快照:在快照应用程序中显示照片预览。

8.4 使用伺服电机移动相机

我们现在将通过 OpenCV 从相机捕获图像,并通过 CRICKIT 库向伺服电机发出运动指令来结合 OpenCV 和 CRICKIT 库。我们需要创建一个满足以下要求的应用程序:

  • 按下左键和右键将使用伺服电机在该方向平移相机。

  • 按下上键和下键将使用伺服电机在该方向倾斜相机。

  • 按下空格键将保存来自相机的快照图像。

  • 应用程序中的移动和快照动作应显示为文本消息。

创建此应用程序将使我们能够将迄今为止使用的相机和电机库结合起来,创建一个应用程序,使我们能够通过键盘和电机控制相机指向的位置。我们可以实时预览相机所看到的内容,然后可以使用键盘保存来自相机的快照图像。

8.4.1 使用 CRICKIT 库探索伺服电机

伺服电机内部有一个直流电机。然而,它们还内置了一个传感器,可以检测它们的精确位置。因此,与我们在前几章中使用的直流电机不同,我们可以将伺服电机移动到精确的位置。这使得它们非常适合像机械臂这样的应用,你希望将机械臂移动到特定的位置。在本章中我们将使用的 Pan-Tilt Kit 就是这样的设备。

配有两个伺服电机。图 8.6 显示了 Pan-Tilt Kit 的照片。将相机连接到套件后,我们将能够将相机移动到许多不同的方向。底部的伺服电机将使相机左右移动,而顶部的伺服电机将使相机上下倾斜。通过这种方式,我们可以使用电机将相机指向许多不同的方向。当相机连接时,移动伺服电机在其完整运动范围内可能会很困难,因为相机有一条带子,它不一定总是足够长,以适应套件可能到达的所有位置。因此,在第一次尝试套件提供的完整运动范围时,移除相机是一个好主意。

图 8.6 Adafruit Pan-Tilt Kit:套件包含两个伺服电机。

我们可以使用读取-评估-打印循环(REPL)直接进入 CRICKIT 库。我们首先导入crickit模块:

>>> from adafruit_crickit import crickit

将用于平移套件的伺服电机连接到第一个伺服连接,并使用crickit.servo_1来访问。我们通过设置我们想要定位的角度来移动伺服电机。默认情况下,CRICKIT 库将伺服电机的最低角度或位置设置为0度,最高设置为180度。我们可以通过设置angle的值来设置伺服电机的位置。在 REPL 中运行以下代码来将伺服电机移动到最低角度:

>>> crickit.servo_1.angle = 0

现在我们可以通过将值设置为90来将电机移动到中间位置:

>>> crickit.servo_1.angle = 90

如果我们将值设置为180,伺服电机将移动到其最高位置:

>>> crickit.servo_1.angle = 180

如果我们测量物理伺服电机实际移动的距离,我们会看到它不是180度。我们使用的平移伺服电机的实际范围是从0度到142度。CRICKIT 库有一个功能,我们可以设置软件层面的实际真实世界值。一旦我们这样做,我们在软件中设置的角值将匹配真实世界的角值。我们现在将伺服电机移动回最低位置,然后使用以下行设置伺服电机的动作范围:

>>> crickit.servo_1.angle = 0
>>> crickit.servo_1.actuation_range = 142

我们可以将伺服电机再次移动到最低、中间和最高角度:

>>> crickit.servo_1.angle = 0
>>> crickit.servo_1.angle = 142
>>> 142/2
71.0
>>> crickit.servo_1.angle = 71

然而,如果我们尝试设置超出动作范围的值,库将引发一个ValueError异常来阻止我们:

>>> crickit.servo_1.angle = 180
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/robo/pyenv/lib/python3.9/site-packages/adafruit_motor/servo.py", line 136, in angle
    raise ValueError("Angle out of range")
ValueError: Angle out of range
>>> 

我们也可以移动另一个伺服电机来控制相机的倾斜位置。最低的倾斜位置与180的最高角度值相关。运行以下行来设置此位置:

>>> crickit.servo_2.angle = 180

将相机向上指的最高倾斜位置与角度值90相关联:

>>> crickit.servo_2.angle = 90

与平移不同,倾斜的范围更有限,我们不需要为它设置一个动作范围。我们现在有足够的信息来编写一个脚本,使我们的伺服电机在不同方向上移动。

深入了解:伺服电机

CRICKIT 板支持许多不同的伺服电机。它可以同时连接多达四个不同的伺服电机。它在连接伺服电机的类型上非常灵活,因为它支持任何 5V 供电的伺服电机。Adafruit 在线商店在其商店的伺服电机部分提供了一系列伺服电机(adafruit.com/category/232)。书中使用的 Pan-Tilt 套件中包含的两个伺服电机被称为微型伺服电机。它们比其他伺服电机更小、功率更低。伺服电机的强度由伺服扭矩等级指定。一些标准伺服电机可能比微型伺服电机多出超过一倍的功率。根据伺服电机必须携带或推动的重量,你可能需要获得更大、更强大的伺服电机。

Adafruit 网站上的《电机选择指南》(mng.bz/YRRB)是了解伺服电机工作原理的绝佳资源。它还提供了关于不同伺服电机尺寸、扭矩和速度等级的详细信息。

8.4.2 执行俯仰和倾斜扫描

最终,我们希望用键盘的方向键来控制伺服电机的俯仰和倾斜运动。为了朝那个方向移动,我们需要将上下移动请求转换为倾斜伺服电机的相关角度变化。同样,为了在俯仰伺服电机的左右移动上做同样的事情,我们将创建一个脚本,它发出四个方向的大量移动命令,然后将它们转换为所需的伺服角度变化。首先,我们导入crickit模块来控制伺服电机和time模块来在移动之间暂停:

from adafruit_crickit import crickit
import time

我们使用ANGLE_STEP来控制每次移动将引起的角度变化量。然后我们定义PANTILT作为每个伺服电机的数据结构。每个都是一个dict,使用servo来引用其相关的伺服电机,min来控制允许的最小角度,max来设置允许的最大电机。在dict中,start的值将在应用程序启动时将伺服电机放置在该角度,range将用于设置该伺服电机的驱动范围。MOVE将每个四个运动映射到相关的伺服电机和该伺服电机的移动方向:

ANGLE_STEP = 2
PAN = dict(servo=crickit.servo_1, min=30, max=110, start=70, range=142)
TILT = dict(servo=crickit.servo_2, min=90, max=180, start=180, range=180)
MOVE = dict(left=(PAN, -1), right=(PAN, 1), up=(TILT, -1), down=(TILT, 1))

当脚本开始时,我们调用init_motors来设置每个电机的驱动范围并将电机定位在指定的起始角度:

def init_motors():
    for motor in [PAN, TILT]:
        motor['servo'].actuation_range = motor['range']
        motor['servo'].angle = motor['start']

我们现在定义了move_motor,它将使用四种接受的运动之一被调用。然后,它将使用MOVE字典来查找相关的伺服电机和factor来设置角度变化的方向。接下来,我们计算new_angle,它表示新的角度。然后我们检查新角度是否在定义的minmax范围内。如果新角度是允许的,我们将通过设置相关伺服电机的angle值来应用它:

def move_motor(direction):
    motor, factor = MOVE[direction]
    new_angle = motor['servo'].angle + (ANGLE_STEP * factor)
    if motor['min'] <= new_angle <= motor['max']:
        motor['servo'].angle = new_angle

main函数首先调用init_motors来初始化每个电机的驱动范围和起始位置。然后我们进入一个循环,进行 20 次迭代,每次使相机向左和向上移动。我们在每个循环中打印出我们动作的详细信息,然后在执行下一次动作迭代前暂停 0.1 秒。同样的循环风格再次执行,但这次是向右和向下移动:

def main():
    init_motors()

    for i in range(20):
        print('moving left and up')
        move_motor('left')
        move_motor('up')
        time.sleep(0.1)

    for i in range(20):
        print('moving right and down')
        move_motor('right')
        move_motor('down')
        time.sleep(0.1)

完整的脚本可以保存为pan.py在 Pi 上,然后执行。

图 8.3 pan.py:使用伺服电机执行水平和倾斜运动

#!/usr/bin/env python3
from adafruit_crickit import crickit
import time

ANGLE_STEP = 2
PAN = dict(servo=crickit.servo_1, min=30, max=110, start=70, range=142)
TILT = dict(servo=crickit.servo_2, min=90, max=180, start=180, range=180)
MOVE = dict(left=(PAN, -1), right=(PAN, 1), up=(TILT, -1), down=(TILT, 1))

def move_motor(direction):
    motor, factor = MOVE[direction]
    new_angle = motor['servo'].angle + (ANGLE_STEP * factor)
    if motor['min'] <= new_angle <= motor['max']:
        motor['servo'].angle = new_angle

def init_motors():
    for motor in [PAN, TILT]:
        motor['servo'].actuation_range = motor['range']
        motor['servo'].angle = motor['start']

def main():
    init_motors()

    for i in range(20):
        print('moving left and up')
        move_motor('left')
        move_motor('up')
        time.sleep(0.1)

    for i in range(20):
        print('moving right and down')
        move_motor('right')
        move_motor('down')
        time.sleep(0.1)

if __name__ == "__main__":
    main()

当我们执行这个脚本时,它将运行伺服运动的演示。第一组动作将使相机向左和向上移动 20 次,直到达到我们为水平移动设定的最大位置。然后,我们将向右和向下移动 20 次,回到起始位置。以下会话显示了脚本生成的输出:

$ pan.py 
moving left and up
moving left and up
moving left and up
...
moving right and down
moving right and down
moving right and down

现实世界中的机器人:机械臂

伺服电机是机械臂的核心。机械臂由多个关节组成,这赋予了机械臂完全的自由运动。本章中的伺服电机使相机能够向任何方向进行水平和倾斜。在机械臂的情况下,不同关节上的四个伺服电机就足以使机械臂具有全范围的移动能力。

英特尔关于机械臂的文章(mng.bz/G99v)涵盖了使用机械臂在不同行业中的许多好处,从制造业到农业。由于软件中缺乏计算机视觉,旧的机械臂在应用上受到了限制。这意味着机械臂只能拾取放置在精确位置且具有特定方向的物品。将机器人技术与强大的计算机视觉软件相结合,使机器人能够检测物体的位置并适应不同的方向。机器人和计算机视觉的应用是相辅相成的,以创造更多样化的机器人解决方案。

8.4.3 同时控制伺服电机和相机

我们现在可以将所有工作整合在一起,创建一个最终的应用程序,使我们能够使用键盘移动相机并在命令下拍照。我们首先导入所需的模块。我们使用datetime来处理时间戳,就像之前一样,使用cv2与相机和键盘一起工作,以及crickit模块来控制伺服电机。我们将能够通过从我们的snapshotpan模块中导入所需函数来重用本章中编写的功能:

from datetime import datetime
import cv2
from adafruit_crickit import crickit
from snapshot import save_photo, show_image, set_message
from pan import move_motor, init_motors

ESC_KEY值是 Esc 键的键码,就像我们之前看到的那样。ARROW_KEYS字典将用于将箭头键的键码映射到它们相关的键名。键的名称也直接与我们的四个支持的动作相对应:

ESC_KEY = 27
ARROW_KEYS = {81: 'left', 82: 'up', 83: 'right', 84: 'down'}

handle_key 函数将处理发生的任何按键事件。如果按下的键是空格键,则将调用 save_photo 函数以保存快照。如果按下了箭头键之一,则将调用 move_motor 函数,并带有按下的相关键名。处理完这些按键事件后,将调用 set_message 函数来更新应用程序中显示的文本消息:

def handle_key(key, frame, messages):
    if key == ord(' '):
        save_photo(frame)
        set_message(messages, 'saving photo...')
    elif key in ARROW_KEYS.keys():
        move_motor(ARROW_KEYS[key])
        set_message(messages, f'moving {ARROW_KEYS[key]}...')

最后,main 函数将首先调用 init_motors 来初始化伺服电机。然后,将创建视频捕获设备,并初始化 messages 列表。此时,我们进入主事件循环,该循环将一直持续到按下 Esc 或 Q 键。在每次循环中,将从相机捕获一帧,并调用 handle_key 来处理任何键盘事件。循环的最后部分是调用 show_image 来显示最新捕获的图像和任何文本消息。当退出此循环时,将调用 cap.release 来释放视频捕获设备:

def main():
    init_motors()
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'

    messages = []
    while (key := cv2.waitKey(1)) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        handle_key(key, frame, messages)
        show_image(frame, messages)

    cap.release()

完整的脚本可以保存为 Pi 上的 servocam.py 并执行。

图 8.4 servocam.py:使用键盘控制相机位置

#!/usr/bin/env python3
from datetime import datetime
import cv2
from adafruit_crickit import crickit
from snapshot import save_photo, show_image, set_message
from pan import move_motor, init_motors

ESC_KEY = 27
ARROW_KEYS = {81: 'left', 82: 'up', 83: 'right', 84: 'down'}

def handle_key(key, frame, messages):
    if key == ord(' '):
        save_photo(frame)
        set_message(messages, 'saving photo...')
    elif key in ARROW_KEYS.keys():
        move_motor(ARROW_KEYS[key])
        set_message(messages, f'moving {ARROW_KEYS[key]}...')

def main():
    init_motors()
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'

    messages = []
    while (key := cv2.waitKey(1)) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        handle_key(key, frame, messages)
        show_image(frame, messages)

    cap.release()

main()

当我们运行此脚本时,云台伺服将移动到起始位置。现在您可以按左右箭头键来平移相机。按上下键将相机上下倾斜。尝试将相机平移和倾斜到最远可接受的位置。脚本将检测并安全地到达这些极限,而不会超出允许的伺服角度。在不同的角度按空格键以在不同相机位置拍摄快照。图 8.7 显示了当相机倾斜向上移动时显示的消息。

图 8.7 移动相机:当执行相机移动操作时显示文本消息。

通过创建此应用程序,我们学习了如何响应键盘事件来控制我们的机器人电机和相机硬件,执行电机运动,并使用不同的键盘控制键从相机捕获图像。

深入了解:机器人运动学

随着您创建包含许多作为机器人臂关节的伺服电机的机器人项目,机器人运动学的领域变得越来越重要。通过创建一个包含机器人臂中每个连杆的长度以及每个关节位置的模式,我们可以计算出将每个伺服设置到什么角度,以将机器人臂移动到不同的位置。

前向运动学通过获取每个伺服关节的角度,然后计算伺服臂将放置的位置来工作。逆运动学通过取机器人臂的期望末端位置并计算将手臂移动到该位置所需的关节运动来反向工作。

伊利诺伊大学香槟分校的《机器人系统指南》(motion.cs.illinois.edu/RoboticSystems)提供了关于机器人运动学的优秀参考资料。第五章和第六章专门讨论了正向和逆向运动学。这些计算背后的数学方程式通过详细的机器人臂视觉图进行展示。

运动学的一个有趣应用是创建一种名为 SCARA 机器人的机器人,它只能在 X-Y 方向上移动。这种限制使得运动学计算更简单,并且需要更少的伺服电机来创建一个功能性的机器人。Instructables 网站上的 SCARA 机器人项目(mng.bz/z00B)使用本书中介绍过的伺服电机来创建这个机器人。仅需要两个伺服电机来在 X-Y 方向上移动机械臂。一个额外的伺服电机用于降低和提升笔进行绘图操作。

摘要

  • 在相机上执行倾斜运动的专用伺服电机。

  • OpenCV 库用于与 Linux 内核视频子系统交互。

  • NumPy 库中的数据结构在 OpenCV 库中被广泛使用。

  • 在图像上放置文本是 OpenCV 应用程序中常见的绘图操作之一,用于显示消息。

  • Raspberry Pi 相机将以默认的每秒 30 帧的帧率捕获视频图像。

  • 与直流电机不同,由于它们的硬件传感器,我们可以将伺服电机移动到精确的位置。

9 面部跟随相机

本章涵盖

  • 使用 OpenCV 库在图像中检测面部

  • 测量和优化面部检测性能

  • 在实时视频中执行面部检测

  • 使用伺服电机制作面部跟随相机

本章将首先展示如何使用 OpenCV 库在图像中检测面部。然后,我们将扩展这一功能以检测实时视频流中的面部,并测量和优化我们的面部检测过程。一旦我们建立了快速的面部检测机制,我们将创建一个应用程序在实时视频流中执行面部检测。本章的最后部分包括创建一个可以检测面部运动并使用电机将相机移动到检测到的面部方向的应用程序。面部检测是一个要求较高的计算机视觉活动,使用机器学习来检测面部。

机器学习在人工智能领域发挥着重要作用,并在机器人学中有许多应用。在本章中,我们将创建一个机器人,它根据从相机接收到的图像输入数据中的面部来使用电机移动其相机。这是一种强大的技术,可以扩展到许多可以自动对环境中的事件做出反应并采取行动的机器人。有许多自主机器人系统是通过获取机器人接收的传感器输入来创建的,它们使用机器学习来决定机器人应该采取哪些行动来实现其目标。这些包括从食物配送到建筑,机器人执行诸如砌砖等复杂任务。

9.1 硬件栈

图 9.1 显示了硬件栈,本章中使用的特定组件被突出显示。机器人将使用伺服电机将连接的相机移动到检测到的面部方向。根据检测到的面部是在相机的左侧还是右侧,伺服电机将移动电机以面向面部。本章的初始应用将专注于使用相机硬件执行面部检测,稍后,相关的伺服运动将被添加到机器人功能中。

图片

图 9.1 硬件栈:将使用伺服电机将相机移动到检测到的面部。

9.2 软件栈

本章中使用的特定软件的详细信息在图 9.2 中描述。我们通过创建 detect_face 应用程序开始本章,该应用程序将使用 OpenCV 库在单个图像上执行人脸检测。然后,我们使用 measure_face 脚本和 statistics 以及 time 模块来测量人脸检测过程的性能。一旦我们应用了一些性能提升,我们将创建 face 库,它可以执行快速的人脸检测,从而使 live_face 应用程序成为可能。live_face 应用程序在实时视频流中执行人脸检测。本章以 follow 应用程序结束,该应用程序通过移动伺服电机来跟踪人脸运动。我们将使用 Linux 视频子系统和摄像头硬件进行人脸检测。crickit 库将用于控制伺服电机。

图 9.2 软件堆栈:OpenCV 库将用于执行人脸检测。

9.3 在图像中检测人脸

第一步是在单个图像上执行人脸检测。我们需要创建一个满足以下要求的 Python 应用程序:

  • OpenCV 计算机视觉库应用于检测图像中的人脸位置。

  • 应用程序应能够围绕检测到的人脸绘制矩形并在其中心放置标记。

  • 应计算并返回人脸中心的 x,y 坐标。

计算人脸中心的最终要求将在本章的后续部分非常有用,因为我们将会用它来决定伺服电机移动的方向。

9.3.1 探索人脸检测

OpenCV 文档([docs.opencv.org/4.x/](https://docs.opencv.org/4.x/))是一个极好的资源,提供了关于人脸检测等常见主题的良好教程。在 Python 教程部分,它提到人脸检测由 objdetect 模块涵盖。具体来说,关于 objdetect 级联分类器的教程详细解释了理论以及 OpenCV 中的人脸检测应用。

OpenCV 使用基于 Haar 特征的级联分类器进行人脸检测。这种方法使用机器学习从大量正负人脸图像中训练级联函数。正图像包含人脸,而负图像中没有人脸。一旦函数被训练,我们就可以使用它来检测我们提供的任何图像中的人脸。

预训练模型作为 OpenCV 库的一部分提供,可以直接使用。这些模型是可以在 OpenCV 安装的数据目录中找到的 XML 文件。我们可以开始使用这些模型,并在读取-评估-打印循环(REPL)中执行人脸检测。第一步是导入 cv2 包:

>>> import cv2

要定位 OpenCV 安装路径,我们可以检查 __path__ 属性:

>>> cv2.__path__
['/home/robo/pyenv/lib/python3.9/site-packages/cv2']

__path__属性提供了cv2包的位置列表。列表中的第一个项目是我们感兴趣的。我们可以将其保存到CV2_DIR变量中,以供进一步使用:

>>> CV2_DIR = cv2.__path__[0]
>>> CV2_DIR
'/home/robo/pyenv/lib/python3.9/site-packages/cv2'

现在我们可以计算用于人脸检测的模型 XML 文件的路径,并将其保存到名为CLASSIFIER_PATH的变量中:

>>> CLASSIFIER_PATH = f'{CV2_DIR}/data/haarcascade_frontalface_default.xml'

我们现在可以使用CascadeClassifier函数从模型文件创建一个分类器。创建后,我们将分类器保存到名为face_classifier的变量中:

>>> face_classifier = cv2.CascadeClassifier(CLASSIFIER_PATH)

这个分类器可以用于在图像中检测人脸。让我们来试一试这个分类器,开始检测人脸。用相机拍摄一张人脸照片,并将图像保存为photo.jpg,与 REPL 会话所在的目录相同。我们可以使用imread函数打开这张图像:

>>> frame = cv2.imread('photo.jpg')

如预期,当我们检查图像的shape属性时,我们可以看到图像的分辨率为 640 x 480 像素,每个像素有三个颜色分量。图 9.3 显示了我们在本 REPL 会话中使用的图像:

>>> frame.shape
(480, 640, 3)

图 9.3 人脸图像:使用树莓派相机拍摄的人脸图像。

我们的分类器将检查图像不同区域中的像素强度。为此,你希望图像以灰度图像的形式表示,而不是彩色。我们可以通过调用cvtColor将我们的彩色图像转换为灰度图像:

>>> gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

如果我们检查新gray图像的shape属性,我们可以看到它不再具有每个像素的三个颜色分量。相反,它有一个表示像素强度的单一值,范围从0255,表示从0的黑色,然后是灰度的更高值,一直到255的白色。

>>> gray.shape
(480, 640)

在人脸检测之前,我们将在图像上执行的第二个操作是直方图均衡化。这个操作提高了图像的对比度,从而提高了人脸检测的准确性。我们将准备好的图像保存到名为clean的变量中。图 9.4 显示了应用直方图均衡化后结果的图像。

>>> clean = cv2.equalizeHist(gray)

图 9.4 均衡直方图:直方图均衡化后图像的对比度得到改善。

我们现在可以在我们的分类器上调用detectMultiScale方法,该方法将在我们的图像上执行人脸检测,并将检测结果作为检测到的人脸列表返回:

>>> faces = face_classifier.detectMultiScale(clean)

当我们检查faces的长度时,我们可以看到图像中成功检测到了一个单独的人脸:

>>> len(faces)
1

检查faces显示,对于每个检测到的人脸,都提供了一组与该检测到的人脸相关的值。每组值与一个匹配的矩形相关:

>>> faces
array([[215, 105, 268, 268]])

我们可以将第一个检测到的人脸的矩形值保存到变量中,表示矩形的左上角坐标x, y,以及表示矩形宽度和高度的变量w, h

>>> x, y, w, h = faces[0]

我们可以看到匹配人脸的左上角位于坐标(215, 105)

>>> x, y
(215, 105)

我们现在已经有了足够的东西来构建我们的第一个面部检测应用程序。让我们将所学的一切整合到一个脚本中,以便在图像中检测面部。

深入学习:使用 OpenCV 进行机器学习

OpenCV 文档有一个全面的机器学习概述(docs.opencv.org/4.x/dc/dd6/ml_intro.html),这是一个深入了解 OpenCV 中机器学习主题的绝佳起点。

机器学习的核心是使用训练数据构建和训练模型,这些模型可以基于该数据做出预测。一旦这些训练模型就位,我们可以向它们提供算法之前未见过的新的数据,它们可以根据数据做出预测。在本章中,我们使用了一个在一系列面部图像上训练的模型来检测新图像中面部存在和位置。

另一个计算机视觉应用是对手写数字执行 OCR(光学字符识别)。OpenCV 项目有 5,000 个手写数字的样本,可以用作训练数据来训练我们的模型。k 最近邻算法可以用来训练我们的模型,然后使用它们来识别图像中的数字。在 OpenCV 文档的 Python 教程的机器学习部分有一个关于此的极好示例。

9.3.2 标记检测到的面部

我们将创建一个脚本,用于在图像上执行面部检测,并在匹配的面部周围绘制矩形。我们还将计算匹配矩形的中心,并在中心点放置一个标记。一旦我们完成检测和形状绘制,我们将在我们的图形应用程序中显示最终图像。第一步将是导入cv2库:

import cv2

蓝色的值保存在变量BLUE中,cv2库的位置保存在CV2_DIR中。现在我们可以使用CV2_DIR设置我们的CLASSIFIER_PATH。然后我们的面部分类器在face_classifier中创建并保存:

BLUE = (255, 0, 0)
CV2_DIR = cv2.__path__[0]
CLASSIFIER_PATH = f'{CV2_DIR}/data/haarcascade_frontalface_default.xml'
face_classifier = cv2.CascadeClassifier(CLASSIFIER_PATH)

prep_face函数将通过将其转换为灰度并应用直方图均衡化来准备图像以进行面部检测。然后返回准备好的图像:

def prep_face(frame):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    return cv2.equalizeHist(gray)

我们将定义get_center来计算矩形的中心坐标。我们可以用它来计算检测到的面部的中心。该函数接收与矩形相关的标准值,然后返回一个x,y坐标对作为中心点:

def get_center(x, y, w, h):
    return int(x + (w / 2)), int(y + (h / 2))

detect_face 函数接收一个图像并返回匹配面部的中心坐标。它首先调用 prep_face 函数来准备图像以便面部检测,然后调用 detectMultiScale 函数在图像中检测面部。如果找到面部,我们将第一个匹配面部的矩形值保存到变量 xywh 中。然后,我们计算面部的中心并保存此值到 center。使用 rectangle 函数在面部周围绘制矩形,并使用 drawMarker 函数在面部中心放置标记。最后,返回面部中心的坐标:

def detect_face(frame):
    clean = prep_face(frame)
    faces = face_classifier.detectMultiScale(clean)
    if len(faces) > 0:
        x, y, w, h = faces[0]
        center = get_center(x, y, w, h)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BLUE, 2)
        cv2.drawMarker(frame, center, BLUE)
        return center

main 函数将我们的面部图像加载到名为 frame 的变量中。然后调用 detect_face 函数执行面部检测,并将面部中心保存到 center 变量中。这些坐标被打印出来,并使用 imshow 显示面部图像。调用 waitKey 函数显示图像,直到在应用程序中按下键:

def main():
    frame = cv2.imread('photo.jpg')
    center = detect_face(frame)
    print('face center:', center)
    cv2.imshow('preview', frame)
    cv2.waitKey()

完整脚本可以保存为 detect_face.py 在 Pi 上,然后执行。

列表 9.1 detect_face.py:检测面部并标记匹配面部

#!/usr/bin/env python3
import cv2

BLUE = (255, 0, 0)
CV2_DIR = cv2.__path__[0]
CLASSIFIER_PATH = f'{CV2_DIR}/data/haarcascade_frontalface_default.xml'
face_classifier = cv2.CascadeClassifier(CLASSIFIER_PATH)

def get_center(x, y, w, h):
    return int(x + (w / 2)), int(y + (h / 2))

def prep_face(frame):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    return cv2.equalizeHist(gray)

def detect_face(frame):
    clean = prep_face(frame)
    faces = face_classifier.detectMultiScale(clean)
    if len(faces) > 0:
        x, y, w, h = faces[0]
        center = get_center(x, y, w, h)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BLUE, 2)
        cv2.drawMarker(frame, center, BLUE)
        return center

def main():
    frame = cv2.imread('photo.jpg')
    center = detect_face(frame)
    print('face center:', center)
    cv2.imshow('preview', frame)
    cv2.waitKey()

main()

当此脚本运行时,它将在 photo.jpg 图像上执行面部检测,并在检测到的面部周围绘制匹配的矩形和标记。图 9.5 显示了应用程序完成面部检测并在匹配面部周围绘制形状后的样子。

图片

图 9.5 面部检测:矩形和标记显示了检测到的面部位置。

现在我们已经为图像中的面部检测打下了基础,我们可以继续进行在实时视频流中进行面部检测的激动人心的任务。

9.4 在实时视频中检测面部

在实时视频中检测面部的方法与在单个图像中检测面部的方法类似。主要区别在于,为了跟上实时视频流的速率,面部检测的性能要求更高。我们需要创建一个满足以下要求的 Python 应用程序:

  • 应在从相机视频流捕获的每一帧上执行面部检测。

  • 面部检测的速度应足够快,以跟上相机的帧率。

  • 在应用程序中,实时视频流应显示任何检测到的面部,使用匹配的矩形和标记显示。

当前的首要任务是衡量我们的面部检测性能,看看它是否执行得足够快,能够跟上我们从视频流中接收到的图像速率。

9.4.1 测量面部检测性能

从前一章我们知道,我们的相机将以每秒 30 帧的速度捕获图像。我们需要面部检测过程运行得比这个帧率快,以便它能跟上视频流的速率。我们将创建一个脚本来执行多次面部检测,然后报告面部检测达到的平均帧率。

导入cv2库以执行人脸检测。导入mean函数以计算平均帧率。将使用time模块来测量人脸检测操作的执行时间:

import cv2
from statistics import mean
import time

人脸检测的功能和过程与detect_face.py脚本中使用的相同。我们将使用get_detect_timing函数来测量人脸检测的执行时间。这个函数记录开始时间,然后调用detect_face函数。最后,它计算经过的时间(以秒为单位)并返回该值:

def get_detect_timing(frame):
    start = time.perf_counter()
    center = detect_face(frame)
    return time.perf_counter() - start

我们的main函数将像以前一样打开photo.jpg图像,并将其保存到frame中。然后我们调用detect_face并打印出匹配人脸中心的坐标。接下来,我们重复调用get_detect_timing以捕获 10 次执行时间的样本。我们取这个样本的平均值,计算并报告平均每秒帧数。在每次人脸检测中,我们使用frame.copy()来提供每次人脸检测的干净副本:

def main():
    frame = cv2.imread('photo.jpg')
    center = detect_face(frame.copy())
    print('face center:', center)
    stats = [get_detect_timing(frame.copy()) for i in range(10)]
    print('avg fps:', 1 / mean(stats))

完整脚本可以保存为measure_face.py在 Pi 上,然后执行。

列表 9.2 measure_face.py:测量人脸检测性能

#!/usr/bin/env python3
import cv2
from statistics import mean
import time

BLUE = (255, 0, 0)
CV2_DIR = cv2.__path__[0]
CLASSIFIER_PATH = f'{CV2_DIR}/data/haarcascade_frontalface_default.xml'
face_classifier = cv2.CascadeClassifier(CLASSIFIER_PATH)

def get_center(x, y, w, h):
    return int(x + (w / 2)), int(y + (h / 2))

def prep_face(frame):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    return cv2.equalizeHist(gray)

def detect_face(frame):
    clean = prep_face(frame)
    faces = face_classifier.detectMultiScale(clean)
    if len(faces) > 0:
        x, y, w, h = faces[0]
        center = get_center(x, y, w, h)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BLUE, 2)
        cv2.drawMarker(frame, center, BLUE)
        return center

def get_detect_timing(frame):
    start = time.perf_counter()
    center = detect_face(frame)
    return time.perf_counter() - start

def main():
    frame = cv2.imread('photo.jpg')
    center = detect_face(frame.copy())
    print('face center:', center)
    stats = [get_detect_timing(frame.copy()) for i in range(10)]
    print('avg fps:', 1 / mean(stats))

main()

当运行此脚本时,它将在photo.jpg图像上执行人脸检测。检测到的脸中心的坐标将在终端中打印出来。然后,我们测量检测人脸所需时间的 10 个样本。基于这些样本的平均值,计算并报告帧率。我们可以看到报告的帧率为每秒 10.1 帧,远低于我们需要的每秒 30 帧:

$ measure_face.py 
face center: (349, 239)
avg fps: 10.104761447758944

现在我们已经量化了我们的人脸检测性能,我们可以看到存在性能问题,我们可以着手提高我们的人脸检测过程的性能,以便我们可以满足,并希望超过每秒 30 帧的要求。

9.4.2 减少处理像素的数量

我们的人脸检测操作不需要大图像就能准确检测人脸。如果我们用较小的图像调用我们的面部分类器,它将处理更少的像素,并更快地返回结果。因此,我们将采取的策略是在一个被调整得小得多的图像上执行人脸检测,这样处理速度就会更快。

我们将图像调整到原始图像大小的 20%。通过实验我们可以发现,如果这个值显著小于 10%,它将影响检测精度。我们将看到将值设置为 20%符合我们的性能需求,并且处于安全范围内。

我们可以打开一个 REPL 会话并做一些计算,以了解通过这种缩放我们减少了图像中像素数量的多少。我们的 20%缩放相当于将图像的宽度和高度减少了 5 倍。我们可以通过以下计算轻松地看到这一点:

>>> 1/5
0.2

捕获的图像宽度为640,高度为480。我们可以通过以下计算来计算缩小后的图像的高度和宽度:

>>> 640/5
128.0
>>> 480/5
96.0

我们可以看到,调整大小后的图像宽度将为128,高度为96。现在我们可以计算原始图像和调整大小后的图像中的像素总数:

>>> 640*480
307200
>>> 128*96
12288

现在,我们可以将这两个像素计数相除,以找出我们减少了多少像素总数:

>>> 307200/12288
25.0

我们通过 25 倍的因素减少了需要处理的像素总数。这大大减少了需要处理的数据量,应该会显著提高处理速度。图 9.6 显示了将两个图像并排放置时图像尺寸的显著差异。我们可以通过将宽度和高度的缩放因子平方来交叉检查此图:

>>> 5*5
25
25.0

如预期,它产生了 25 倍的缩放因子。

图 9.6 图像缩小:将原始图像和缩小后的图像并排放置以进行比较。

我们可以将较小的图像通过我们的面部检测脚本运行来检查结果。图 9.7 显示该图像明显像素化,但这并不影响面部检测过程。

图 9.7 在较小图像上的检测:对于分辨率较低的图像,面部检测是成功的。

现在我们已经完成了初始计算,我们可以继续实现我们应用程序的新快速版本。

9.4.3 优化面部检测性能

此实现将在前一个基础上构建,并主要添加图像缩小步骤以提高性能。首先,我们将导入cv2库以执行面部检测:

import cv2

缩放因子保存在DETECT_SCALE变量中:

DETECT_SCALE = 0.2

resize函数接收图像和期望的缩放比例,以调整图像大小并返回新的较小图像。图像的新宽度和高度基于提供的scale计算,并保存在size中。然后对图像调用cv2.resize函数。OpenCV 文档(docs.opencv.org/4.x)中关于resize函数的说明提供了在缩小图像时使用INTER_AREA插值以及在放大图像时使用INTER_CUBIC插值的指导。我们正在缩小图像,因此我们使用INTER_AREA

def resize(img, scale):
    size = (int(img.shape[1] * scale), int(img.shape[0] * scale))
    return cv2.resize(img, size, interpolation=cv2.INTER_AREA)

detect_face函数现在有了性能提升。在调用prep_face之后,在面部检测之前调用resize以创建一个较小的图像。然后使用small调用detectMultiScale。当返回矩形值时,我们将它们除以DETECT_SCALE,以便可以将它们再次映射到原始全分辨率图像上。这样,我们可以在全尺寸原始图像上显示检测到的面部细节,同时通过在较小的图像上进行面部检测来获得性能提升。其余的代码保持不变:

def detect_face(frame):
    clean = prep_face(frame)
    small = resize(clean, DETECT_SCALE)
    faces = face_classifier.detectMultiScale(small)
    if len(faces) > 0:
        x, y, w, h = [int(i / DETECT_SCALE) for i in faces[0]]
        center = get_center(x, y, w, h)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BLUE, 2)
        cv2.drawMarker(frame, center, BLUE)
        return center

该库可以保存为face.py在 Pi 上,以便其他应用程序导入。

列表 9.3 face.py:提供快速面部检测库

import cv2

BLUE = (255, 0, 0)
CV2_DIR = cv2.__path__[0]
CLASSIFIER_PATH = f'{CV2_DIR}/data/haarcascade_frontalface_default.xml'
face_classifier = cv2.CascadeClassifier(CLASSIFIER_PATH)
DETECT_SCALE = 0.2

def resize(img, scale):
    size = (int(img.shape[1] * scale), int(img.shape[0] * scale))
    return cv2.resize(img, size, interpolation=cv2.INTER_AREA)

def get_center(x, y, w, h):
    return int(x + (w / 2)), int(y + (h / 2))

def prep_face(frame):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    return cv2.equalizeHist(gray)

def detect_face(frame):
    clean = prep_face(frame)
    small = resize(clean, DETECT_SCALE)
    faces = face_classifier.detectMultiScale(small)
    if len(faces) > 0:
        x, y, w, h = [int(i / DETECT_SCALE) for i in faces[0]]
        center = get_center(x, y, w, h)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BLUE, 2)
        cv2.drawMarker(frame, center, BLUE)
        return center

要查看此库的实际效果,我们将创建一个新的脚本,该脚本将导入库并多次调用面部检测函数并测量其性能。我们首先导入cv2meantime,就像我们之前打开图像、计算平均值和测量执行时间一样。然后从我们的新face库中导入detect_face函数:

import cv2
from face import detect_face
from statistics import mean
import time

应用程序的其余部分具有与在measure_face.py脚本中创建的相同的功能,用于测量执行时间并报告达到的平均帧率。

完整脚本可以保存为fast_face.py在 Pi 上,然后执行。

列表 9.4 fast_face.py:报告快速面部检测函数的性能

#!/usr/bin/env python3
import cv2
from face import detect_face
from statistics import mean
import time

def get_detect_timing(frame):
    start = time.perf_counter()
    center = detect_face(frame)
    return time.perf_counter() - start

def main():
    frame = cv2.imread('photo.jpg')
    center = detect_face(frame.copy())
    print('face center:', center)
    stats = [get_detect_timing(frame.copy()) for i in range(10)]
    print('avg fps:', 1 / mean(stats))

main()

当运行此脚本时,它将调用我们新的更快面部检测实现。我们可以从结果中看到,我们已经实现了很大的性能提升,现在我们已经达到了每秒 75.6 帧的帧率。这给我们带来了比之前面部检测方法快七倍以上的性能提升:

$ fast_face.py 
face center: (347, 242)
avg fps: 75.63245789951259

这个帧率也远远超过了我们希望达到的每秒 30 帧的目标。现在我们可以继续使用这种新的改进方法在实时视频流中进行面部检测。

9.4.4 在实时视频中显示检测到的面部

在以下脚本中,我们将从摄像头视频流中捕获图像,然后在一个应用程序窗口中显示检测到的面部。导入cv2库以从摄像头视频流中捕获图像。导入detect_face函数以执行面部检测:

import cv2
from face import detect_face

如我们之前所做的那样,Esc 键的关键代码保存在ESC_KEY中。它将通过按下 Esc 键来退出图形应用程序:

ESC_KEY = 27

main函数将视频捕获对象保存到变量cap中。然后我们检查捕获设备是否正确打开。我们进入一个事件循环,直到按下 Esc 或 Q 键才退出循环。在每次循环迭代中,我们从视频流中捕获一帧,并在捕获的图像上调用detect_face函数。然后我们调用imshow来显示带有任何检测到的面部标记的捕获图像。当退出此循环时,通过调用cap.release函数释放视频捕获设备:

def main():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        detect_face(frame)
        cv2.imshow('preview', frame)
    cap.release()

完整脚本可以保存为live_face.py在 Pi 上,然后执行。

列表 9.5 live_face.py:在实时视频流中显示检测到的面部

#!/usr/bin/env python3
import cv2
from face import detect_face

ESC_KEY = 27

def main():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        detect_face(frame)
        cv2.imshow('preview', frame)
    cap.release()

main()

当运行此脚本时,它将不断从视频流中捕获图像。每个图像都通过我们的面部检测函数。如果检测到面部,则在检测到的面部周围绘制一个矩形,并在其中心放置一个标记。带有面部检测的视频流在应用程序窗口中显示,直到按下 Esc 键或 Q 键退出应用程序。

图 9.8 Pan-Tilt Kit 上的相机:相机安装在该套件上,以实现相机移动。

图 9.8 显示了相机安装到 Pan-Tilt Kit 上的样子。两个伺服电机使相机能够向不同方向移动。

在下一节中,我们将使用伺服电机将相机移动到检测到的人脸方向。

9.5 创建跟随人脸的机器人

现在我们能够快速检测人脸,足以处理实时视频,我们可以将我们的代码提升到下一个层次,并让机器人对你的面部位置做出反应。机器人将移动相机以跟随你的面部。我们需要创建一个满足以下要求的 Python 应用程序:

  • 它应该能够识别人脸是否在画面左侧、中心或右侧被检测到。

  • 当在左侧或右侧检测到人脸时,相机应该朝人脸移动。

  • 在应用程序中,应该显示带有检测到的人脸标记的实时视频流以及显示三个区域(左、中、右)的网格。

图 9.9 相机区域:相机区域被分成三个检测区域。

在应用程序中显示这三个区域将使应用程序更具交互性,因为人们将能够告诉他们的面部被检测在哪个区域,以及相机将移动到哪个位置。

9.5.1 人脸检测分区

我们可以将机器人看到的区域分成三个区域或区域。当在中心区域检测到人脸时,我们不需要做任何事情,因为相机正对着人。如果人脸在左区检测到,那么我们将移动伺服器,使相机将人脸放置在中心区域。如果人脸在右区检测到,我们将再次移动伺服器,但方向相反。我们将只关注使用伺服电机的摆动运动来左右移动相机。图 9.9 显示了人脸检测的三个区域。

现在,让我们进入一个 REPL 会话,看看我们如何将相机观看区域分成这三个区域。首先,我们将导入cv2用于在图像上绘制和numpy用于创建一个新的空白图像:

>>> import cv2
>>> import numpy as np

我们将把相机图像的宽度和高度保存在变量IMG_WIDTHIMG_HEIGHT中。这将使我们的代码更易于阅读:

>>> IMG_WIDTH = 640
>>> IMG_HEIGHT = 480

我们可以通过将IMG_WIDTH除以二来得到宽度的中心或中点:

>>> (IMG_WIDTH / 2)
320.0

现在,让我们将这个中心位置向左移动 50 像素,以获得左区和中心区之间的线条位置。我们将把这个值保存在一个名为LEFT_X的变量中:

>>> LEFT_X = int((IMG_WIDTH / 2) - 50)
>>> LEFT_X
270

从中心向右移动 50 像素,我们得到中心区和右区之间的线条位置。我们将这个值保存在RIGHT_X中:

>>> RIGHT_X = int((IMG_WIDTH / 2) + 50)
>>> RIGHT_X
370

我们可以将绿色的值保存在一个名为GREEN的变量中:

>>> GREEN = (0, 255, 0)

接下来,让我们创建一个具有我们所需尺寸的空白彩色图像:

>>> img = np.zeros(shape=(480, 640, 3), dtype=np.uint8)

我们可以通过围绕中心区绘制一个矩形来绘制显示三个区域的网格:

>>> cv2.rectangle(img, (LEFT_X, -1), (RIGHT_X, IMG_HEIGHT), GREEN)

最后一步是将我们创建的内容保存下来,以便我们可以看到图像。我们将使用 imwrite 将图像保存为文件名 zones.jpg

>>> cv2.imwrite('zones.jpg', img)

图 9.10 显示了在绘制区域网格后图像将呈现的样子。中心区域被设置为比左右区域窄。这样,当面部在框架周围移动时,我们可以使相机对左右移动更加敏感。

图片

图 9.10 区域网格:区域网格是通过矩形方法绘制的。

9.5.2 将电机移动以跟踪面部

现在,我们可以尝试编写脚本以跟踪人在相机视场内查看不同区域时的面部。我们可以基于上一节所做的实验进行构建。

我们导入 cv2 库以从相机捕获图像。导入 detect_face 函数,它将执行与之前所见相同的面部检测。最后,我们使用 crickit 模块来控制连接相机的伺服电机:

import cv2
from face import detect_face
from adafruit_crickit import crickit

接下来,我们定义 ESC_KEYGREEN 以存储 Esc 键的键码和绿色值。图像的高度和宽度在 IMG_WIDTHIMG_HEIGHT 中定义。然后我们计算 LEFT_XRIGHT_X 的值,以帮助跟踪检测到的面部所在的区域:

ESC_KEY = 27
GREEN = (0, 255, 0)
IMG_WIDTH = 640
IMG_HEIGHT = 480
LEFT_X = int((IMG_WIDTH / 2) - 50)
RIGHT_X = int((IMG_WIDTH / 2) + 50)

正如我们在第八章中所做的那样,我们创建了一个名为 PAN 的变量来跟踪与执行水平移动的伺服电机相关的值。具体来说,我们保存了伺服电机的最小、最大和起始角度的引用。我们还在 range 中保存了激活范围设置。正如前一章所做的那样,我们在 ANGLE_STEP 中存储了每一步的角度变化值。我们使用 MOVE 将左侧、中央和右侧区域映射到它们相关的伺服电机运动:

PAN = dict(servo=crickit.servo_1, min=30, max=110, start=70, range=142)
ANGLE_STEP = 2
MOVE = dict(L=ANGLE_STEP, C=0, R=-ANGLE_STEP)

get_zone 函数将根据 LEFT_XRIGHT_X 的值返回检测到的面部的区域:

def get_zone(face_x):
    if face_x <= LEFT_X:
        return 'L'
    elif face_x <= RIGHT_X:
        return 'C'
    else:
        return 'R'

init_motors 函数用于初始化伺服电机的起始位置和激活范围:

def init_motors():
    PAN['servo'].actuation_range = PAN['range']
    PAN['servo'].angle = PAN['start']

我们将使用 move_motor 函数根据检测到的面部位置移动伺服电机。我们首先通过调用 get_zone 计算区域。然后,我们查找角度变化并将其保存到 change 中。接下来,如果检测到变化且新角度在最小和最大角度范围内,我们应用新的角度:

def move_motor(face_x):
    zone = get_zone(face_x)
    change = MOVE[zone]
    if change and PAN['min'] <= PAN['servo'].angle + change <= PAN['max']:
        PAN['servo'].angle += change

当我们创建一个新的视频捕获对象时,我们调用 check_capture_device 来检查设备。我们检查它是否成功打开,以及捕获的图像的宽度和高度是否与我们的 IMG_WIDTHIMG_HEIGHT 值匹配:

def check_capture_device(cap):
    assert cap.isOpened(), 'Cannot open camera'
    assert cap.get(cv2.CAP_PROP_FRAME_WIDTH) == IMG_WIDTH, 'wrong width'
    assert cap.get(cv2.CAP_PROP_FRAME_HEIGHT) == IMG_HEIGHT, 'wrong height'

main 函数首先调用 init_motors 来初始化伺服电机。然后我们创建一个视频捕获设备,并通过调用 check_capture_device 来检查它。接着我们进入一个事件循环,只有当按下 Esc 或 Q 键时才会退出。在每次循环中,我们从视频流中抓取一张图像并将其保存到 frame 中。然后我们调用 detect_face 来执行人脸检测,并返回到检测到的人脸中心位置。如果检测到人脸,我们使用检测到的人脸的 x 坐标调用 move_motor。然后我们通过调用 cv2.rectangle 并使用相关尺寸在图像上绘制我们的区域网格。循环的最后一步是通过调用 imshow 在应用程序中显示最新的视频帧。当我们退出循环时,我们调用 cap.release 来释放视频捕获设备:

def main():
    init_motors()
    cap = cv2.VideoCapture(0)
    check_capture_device(cap)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        center = detect_face(frame)
        if center:
            move_motor(center[0])
        cv2.rectangle(frame, (LEFT_X, -1), (RIGHT_X, IMG_HEIGHT), GREEN)
        cv2.imshow('preview', frame)
    cap.release()

整个脚本可以保存为 follow.py 在 Pi 上,然后执行。

列表 9.6 follow.py:将摄像头移动到跟随检测到的人脸

#!/usr/bin/env python3
import cv2
from face import detect_face
from adafruit_crickit import crickit

ESC_KEY = 27
GREEN = (0, 255, 0)
IMG_WIDTH = 640
IMG_HEIGHT = 480
LEFT_X = int((IMG_WIDTH / 2) - 50)
RIGHT_X = int((IMG_WIDTH / 2) + 50)
PAN = dict(servo=crickit.servo_1, min=30, max=110, start=70, range=142)
ANGLE_STEP = 2
MOVE = dict(L=ANGLE_STEP, C=0, R=-ANGLE_STEP)

def get_zone(face_x):
    if face_x <= LEFT_X:
        return 'L'
    elif face_x <= RIGHT_X:
        return 'C'
    else:
        return 'R'

def move_motor(face_x):
    zone = get_zone(face_x)
    change = MOVE[zone]
    if change and PAN['min'] <= PAN['servo'].angle + change <= PAN['max']:
        PAN['servo'].angle += change

def init_motors():
    PAN['servo'].actuation_range = PAN['range']
    PAN['servo'].angle = PAN['start']

def check_capture_device(cap):
    assert cap.isOpened(), 'Cannot open camera'
    assert cap.get(cv2.CAP_PROP_FRAME_WIDTH) == IMG_WIDTH, 'wrong width'
    assert cap.get(cv2.CAP_PROP_FRAME_HEIGHT) == IMG_HEIGHT, 'wrong height'

def main():
    init_motors()
    cap = cv2.VideoCapture(0)
    check_capture_device(cap)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        center = detect_face(frame)
        if center:
            move_motor(center[0])
        cv2.rectangle(frame, (LEFT_X, -1), (RIGHT_X, IMG_HEIGHT), GREEN)
        cv2.imshow('preview', frame)
    cap.release()

main()

当运行此脚本时,你可以对着摄像头看,在实时摄像头流中看到你的脸,并在检测到的人脸周围放置一个边界。人脸的中心也用十字线在实时图像上标记。从这个标记中,我们可以知道人脸位于哪个区域。如果你将人脸移出中心区域,伺服电机将自动重新定位摄像头,将你的脸放回这个区域。图 9.11 显示了一个在左侧区域被检测并标记的人脸,然后伺服电机移动摄像头将人脸放回中心区域。

图 9.11 区域人脸:人脸在左侧区域被检测并标记。

通过使用计算机视觉和面部跟踪,这个应用给了我们机会将机器学习应用到我们的机器人项目中。在接下来的章节中,我们将使用其他计算机视觉功能,如二维码检测,通过使用摄像头作为观察环境的方式,帮助我们的机器人与环境进行更深入的交互。

现实世界中的机器人:机器人视觉处理

机器人可以使用计算机视觉进行特征检测,以提取诸如物体的角和边缘等视觉特征。有了这种特征检测,机器人可以检测和分类它们在环境中看到的物体。

这种对象交互的一个应用是创建可以在制造和物流中拾取和放置物体的机器人。使用计算机视觉,它们识别一个物体,抓取它,然后将其从一个位置移动到另一个位置。

检查机器人是另一种将计算机视觉和机器人技术结合在一起的情况,以创建可以用于制造过程中的质量控制流程的机器人,以执行制造产品的完全自动化检查。

摘要

  • 需要一个快速的人脸检测机制来在实时视频流中执行人脸检测。

  • 伺服电机用于将连接的摄像头移动到检测到的人脸方向。

  • Haar 特征级联分类器在 OpenCV 中用于执行人脸检测。

  • 直方图均衡化可以改善图像的对比度,有助于提高人脸检测的准确性。

  • 被检测到的人脸周围将绘制一个匹配的矩形和标记。

  • 人脸检测必须能够处理至少每秒 30 帧的相机图像速率,以便实时人脸识别能够工作。

  • 使用较小图像调用人脸分类器可以使人脸检测更快。

10 机器人二维码查找器

本章涵盖

  • 生成二维码

  • 检测和解码二维码中的数据

  • 使用 Motion JPEG 流式传输实时视频

  • 创建一个可以在其环境中搜索特定二维码的机器人

我们从探索二维码标准和学习如何生成我们自己的二维码开始本章。然后,我们使用 OpenCV 计算机视觉库在图像中检测二维码,以及读取二维码本身编码的数据。然后,我们将学习如何将来自相机的视频流数据保存到文件系统中,以便多个应用程序可以同时读取实时视频数据。这将允许我们检查我们感兴趣的二维码视频流,同时将视频流传输到桌面和 Web 应用程序。我们将使用 Tornado Web 框架创建一个 Motion JPEG 视频服务器,可以从网络上的任何移动设备或计算机访问,以获取机器人相机视频流的实时视图。最后,我们通过创建一个可以在其环境中寻找匹配二维码的机器人来结束本章。

将所有这些技术结合起来,帮助我们解决机器人使用计算机视觉来调查其环境并在寻找匹配的二维码时移动到不同期望位置的问题。这对于许多必须在仓库或工厂中执行自主导航的机器人来说是一个核心功能。

10.1 硬件栈

图 10.1 显示了硬件栈,本章中使用的特定组件被突出显示。机器人将使用直流电机在一条设定的轨道上来回移动。相机安装在机器人的侧面,可以在机器人经过时捕捉到机器人旁边的物体。机器人将检查来自实时视频流的图像,寻找匹配的二维码。一旦找到代码,机器人就可以停止电机,因为它已经到达了期望的目的地。

图片

图 10.1 硬件栈:相机将被用来捕捉用于二维码检测的实时图像。

关于机器人组装的更多细节,请查看附录 C 中的机器人组装指南。它展示了如何组装本章中使用的机器人。它还提供了如何为机器人创建轨道的技巧,以便它可以在一个受控路径上往返移动。

10.2 软件栈

本章中使用的特定软件的详细信息在图 10.2 中描述。我们首先创建了一个名为detect_qr的应用程序,该应用程序将使用 OpenCV 库在单个图像上执行二维码检测和解码。然后,我们将使用stream_save脚本来捕获视频流到文件系统。watcher库使用sys模块来读取命令行参数并监视流图像文件的变化。然后,我们为 Web 和图形应用程序创建流应用程序。我们通过在goto_qr应用程序中使用摄像头和直流电机硬件来移动机器人到由二维码标记的特定目标位置来结束本章。

图片

图 10.2 软件栈:将使用 OpenCV 库来执行二维码检测。

10.3 在图像中检测二维码

第一步是在单个图像上执行二维码检测和解码。我们需要创建一个满足以下要求的 Python 应用程序:

  • 应用程序应使用 OpenCV 计算机视觉库来检测图像中二维码的位置。

  • 应在图像中检测到的二维码周围绘制一个矩形。

  • 应用程序应解码并返回存储在二维码中的数据。

解码存储在二维码中的数据的最后要求将在本章的后面非常有帮助,因为我们将使用它来决定我们是否到达了期望的二维码,或者机器人是否应该继续沿着轨道移动。

10.3.1 探索二维码

我们二维码冒险的第一步是安装qrcodePython 包。此模块将使我们能够生成二维码。运行以下命令来安装包:

$ ~/pyenv/bin/pip install qrcode

此软件包可以在 Python 代码中导入或在命令行中直接执行。让我们首先在终端中生成一些二维码。当我们运行下一个命令时,将生成一个包含文本hello的二维码,并将其输出到我们的终端:

$ ~/pyenv/bin/qr hello

您可以使用智能手机扫描二维码来测试它。一旦扫描二维码,文本hello应出现在您的设备上。当我们运行下一个命令时,它将保存生成的二维码到图像而不是输出到终端:

$ ~/pyenv/bin/qr hello > hello.png

您可以打开hello.png图像并再次测试它。这是一种生成二维码的有用方法,因为我们可以将图像打印出来并将其粘贴到我们想要用二维码标记的对象上。图 10.3 显示了使用此命令生成的二维码。

图片

图 10.3 生成的二维码:示例二维码中编码了文本hello

接下来,我们将进入一个读取-评估-打印循环(REPL)会话,以探索从 Python 应用程序中使用该软件包。第一步是导入qrcode库:

>>> import qrcode

下一个步骤将创建一个包含文本hi again的二维码并将其保存到名为hi_again.png的图像中:

>>> qrcode.make('hi again').save('hi_again.png')

对于更高级的应用,我们使用一个QRCode对象。通过这个对象,我们可以设置与二维码的错误纠正、框大小和边框相关的不同选项。在下面的示例中,我们创建一个QRCode对象,并使用add_data方法设置二维码的内容:

>>> qr = qrcode.QRCode()
>>> qr.add_data('www.python.org')

接下来,我们调用make方法来生成二维码。一旦生成,我们可以获取有关生成的二维码的详细信息,例如使用的符号版本。在这个例子中,version属性报告二维码正在使用符号版本1

>>> qr.make()
>>> qr.version
1

二维码标准的官方网站(www.qrcode.com)提供了每个符号版本的确切细节以及它可以存储多少数据。本质上,你放入二维码中的数据越多,符号版本就越大,这反过来又生成一个更密集的二维码。在我们生成二维码图像时能够检查这个值是有用的,因为低版本号更稀疏,即使是在低分辨率图像中也会更容易阅读。我们 REPL 中的下一行将二维码保存到名为python.png的图像中:

>>> qr.make_image().save('python.png')

在大多数智能手机上,如果你读取这个二维码,它将从文本中被检测为一个 URL,并将你导向 Python 网站(www.python.org)。现在我们已经生成了一些二维码,让我们继续检测和解码它们。

10.3.2 标记检测到的二维码

我们将创建一个脚本来在图像上执行二维码检测和解码,然后围绕匹配的二维码绘制一个矩形。我们还将以文本形式在图像上显示解码的数据。然后,该图像将在我们的图形应用程序中显示。

取上节生成的包含文本hello编码的图像,并将其打印出来。然后,使用 Pi 相机拍照,并将图像保存到名为hello.jpg的文件中。你可以使用第八章中的snapshot.py应用程序来拍照。或者,hello.jpg图像也包含在本书 GitHub 仓库的该章节文件夹中。

第一步将是导入cv2库:

import cv2

蓝色的值保存在变量BLUE中,应用程序中显示文本的字体保存在FONT中。然后,我们实例化一个QRCodeDetector对象,并将其保存在decoder中。将调用此对象上的方法来执行二维码检测和解码:

BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
decoder = cv2.QRCodeDetector()

draw_box函数将在图像中绘制一个围绕检测到的二维码的矩形。图像由frame参数提供,检测到的四边形的四个点由points提供。使用colorthickness参数设置框的颜色和粗细。首先将点值转换为整数,因为这是cv2.line函数所期望的。然后,我们将四边形的四个点分别保存到自己的变量中。接下来,我们绘制四条线来连接这四个点:

def draw_box(frame, points, color, thickness):
    points = [(int(x), int(y)) for x, y in points]
    pt1, pt2, pt3, pt4 = points
    cv2.line(frame, pt1, pt2, color, thickness)
    cv2.line(frame, pt2, pt3, color, thickness)
    cv2.line(frame, pt3, pt4, color, thickness)
    cv2.line(frame, pt4, pt1, color, thickness)

然后我们将定义decode_qrcode,它调用detectAndDecode方法来检测和解码frame图像中的 QR 码。解码的数据存储在变量data中,匹配点的列表存储在matches中。如果我们找到解码数据,我们使用putText将其显示为文本,并通过调用draw_box函数在匹配区域周围绘制一个框。我们最终通过返回解码数据来结束函数:

def decode_qrcode(frame):
    data, matches, _ = decoder.detectAndDecode(frame)
    if data:
        cv2.putText(frame, f'data: {data}', (30, 30), FONT, 1, BLUE)
        draw_box(frame, matches[0], BLUE, thickness=3)
    return data

main函数将我们的 QR 码照片加载到名为frame的变量中。然后调用decode_qrcode来执行 QR 码的检测和解码。解码的数据存储在名为decoded_data的变量中并打印出来。然后使用imshow显示图像。调用waitKey函数来显示图像,直到在应用程序中按下键:

def main():
    frame = cv2.imread('hello.jpg')
    decoded_data = decode_qrcode(frame)
    print('decoded_data:', repr(decoded_data))
    cv2.imshow('preview', frame)
    cv2.waitKey()

完整的脚本可以保存为detect_qr.py在 Pi 上,然后执行。

列表 10.1 detect_qr.py:在图像中检测和解码 QR 码

#!/usr/bin/env python3
import cv2

BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
decoder = cv2.QRCodeDetector()

def draw_box(frame, points, color, thickness):
    points = [(int(x), int(y)) for x, y in points]
    pt1, pt2, pt3, pt4 = points
    cv2.line(frame, pt1, pt2, color, thickness)
    cv2.line(frame, pt2, pt3, color, thickness)
    cv2.line(frame, pt3, pt4, color, thickness)
    cv2.line(frame, pt4, pt1, color, thickness)

def decode_qrcode(frame):
    data, matches, _ = decoder.detectAndDecode(frame)
    if data:
        cv2.putText(frame, f'data: {data}', (30, 30), FONT, 1, BLUE)
        draw_box(frame, matches[0], BLUE, thickness=3)
    return data

def main():
    frame = cv2.imread('hello.jpg')
    decoded_data = decode_qrcode(frame)
    print('decoded_data:', repr(decoded_data))
    cv2.imshow('preview', frame)
    cv2.waitKey()

main()

当运行此脚本时,它将在hello.jpg图像上执行 QR 码检测,并在匹配的四边形周围绘制一个框。解码的数据也显示在图像的左上角。图 10.4 显示了应用程序完成 QR 码的检测和解码后的样子。

图片

图 10.4 检测到的 QR 码:应用程序在检测到的 QR 码周围绘制一个框。

我们现在在 QR 码检测和解码方面有了坚实的基础。我们将有多个应用程序,它们都需要访问实时视频流。因此,下一步将是设计一个系统来捕获和分发来自实时视频流的图像到多个应用程序。

10.4 将实时视频流传输到多个应用程序

我们将通过将来自实时视频流的图像保存到文件系统来解决这个问题。然后,多个应用程序可以同时从文件系统中读取这些图像,并将它们用于流式传输到桌面或 Web 应用程序。我们可以使用相同的机制来检测实时流中的 QR 码并控制机器人的运动。我们需要创建一个满足以下要求的 Python 应用程序:

  • 应该捕获并保存来自相机视频流的最新帧到文件系统。

  • 应将帧保存到 ramdisk 中,以避免创建任何额外的磁盘工作负载。

  • 应将图像数据保存为原子操作,以确保数据一致性。

第一步是创建一个应用程序,将视频流中的帧保存到文件系统。然后我们可以创建应用程序将视频流流式传输到桌面和 Web 应用程序。通过使用 ramdisk,我们将获得更好的 I/O 性能进行视频流传输,并且不会给试图从磁盘读取和写入的其他应用程序造成减速。

10.4.1 将视频流保存到 ramdisk

需要注意的是,机器人上的摄像头是倒置的,这样可以为摄像头连接器连接到树莓派腾出足够的空间。这将使得我们捕获的图像看起来是颠倒的。这个问题可以通过在软件中纠正图像方向并在捕获图像后翻转图像来解决。

导入 cv2 库以从视频流中捕获帧。导入 os 模块以便我们可以访问环境变量:

import os
import cv2

在 Linux 系统上默认创建了一个 ramdisk,我们可以通过读取 XDG_RUNTIME_DIR 环境变量的值来访问其位置。ramdisk 中的文件存储在内存中,而不是物理磁盘上。这样,我们可以像处理文件系统上的任何其他文件一样处理它们,但同时又不会对物理磁盘造成额外的负载。我们将图像放置在这个目录中,并使用 IMG_PATH 变量来跟踪其路径。在将其保存到最终位置之前,我们还需要将图像数据保存到位于 TMP_PATH 的临时文件中:

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
TMP_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream_tmp.jpg'

我们将捕获的图像尺寸设置为默认分辨率的二分之一。这将使得保存和流式传输的数据大小更小、更高效。图像仍然足够大,可以清楚地看到机器人看到的景象,以及准确地执行二维码检测和解码。我们将这些值保存到变量 FRAME_WIDTHFRAME_HEIGHT 中:

FRAME_WIDTH = 320
FRAME_HEIGHT = 240

init_camera 函数用于创建视频捕获对象,并将视频捕获的尺寸设置为 FRAME_WIDTHFRAME_HEIGHT。然后返回视频捕获对象:

def init_camera():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
    return cap

save_frames 函数进入一个无限循环,并在每个循环中从视频流中捕获一个帧。变量 counter 跟踪到目前为止捕获的帧数。我们将捕获的图像保存在 frame 中,然后通过调用 cv2.flip 翻转图像。我们使用 imwrite 将图像保存到我们的临时文件中。然后,我们调用 os.replace 将我们的临时文件放置在其最终目的地。这个调用在 Unix 操作系统(如 Linux)上保证是一个原子操作,我们的 Pi 正在运行这样的操作系统。然后,我们打印出到目前为止捕获的帧数。在打印输出时,我们使用回车符作为结束字符,以便在终端中更新同一行的帧计数器:

def save_frames(cap):
    counter = 0
    while True:
        counter += 1
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        frame = cv2.flip(frame, -1)
        cv2.imwrite(TMP_PATH, frame)
        os.replace(TMP_PATH, IMG_PATH)
        print('frames:', counter, end='\r', flush=True)

最后,我们以 main 函数结束,该函数首先初始化视频捕获设备,然后调用 save_frames 函数来保存视频流中的帧。我们使用 finally 确保在退出应用程序时释放视频捕获设备:

def main():
    cap = init_camera()
    try:
        save_frames(cap)
    finally:
        print('releasing video capture device...')
        cap.release()

完整的脚本可以保存为 stream_save.py 并在 Pi 上执行。

列表 10.2 stream_save.py:将捕获的视频帧保存到文件系统中

#!/usr/bin/env python3
import os
import cv2

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
TMP_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream_tmp.jpg'
FRAME_WIDTH = 320
FRAME_HEIGHT = 240

def save_frames(cap):
    counter = 0
    while True:
        counter += 1
        ret, frame = cap.read()
        assert ret, 'Cannot read frame from camera'
        frame = cv2.flip(frame, -1)
        cv2.imwrite(TMP_PATH, frame)
        os.replace(TMP_PATH, IMG_PATH)
        print('frames:', counter, end='\r', flush=True)

def init_camera():
    cap = cv2.VideoCapture(0)
    assert cap.isOpened(), 'Cannot open camera'
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
    return cap

def main():
    cap = init_camera()
    try:
        save_frames(cap)
    finally:
        print('releasing video capture device...')
        cap.release()

main()

这将连续捕获并保存视频流中的帧到 ramdisk。我们可以执行以下命令来列出我们的流图像的位置:

$ ls -alh $XDG_RUNTIME_DIR/robo_stream.jpg
-rw-r--r-- 1 robo robo 23K Mar 14 21:12 /run/user/1000/robo_stream.jpg

我们可以从输出中看到图像大小为 23K,文件位置为/run/user/1000/robo_stream.jpg。每次我们在图像查看器中打开此文件时,它都会显示相机捕获的最新图像。

深入了解:原子操作

原子操作是操作系统和数据库等软件中非常强大且有用的功能。当您有多个进程同时访问相同的数据时,它们尤其有用,并且您想确保在读写数据时不会遇到数据损坏。在我们的情况下,我们希望避免有流媒体应用程序读取尚未完全写入磁盘的图像数据。将这种半写入的图像数据读入我们的应用程序会导致它们失败。OSDev 网站有一个关于原子操作的优秀页面(wiki.osdev.org/Atomic_operation),从操作系统的角度来看。它是关于该主题的详细信息的好资源。

Python 的os模块文档(docs.python.org/3/library/os.html)涵盖了我们在本节中使用的os.replace函数,该函数将图像数据作为原子操作写入磁盘。它提到,在遵循便携式操作系统接口(POSIX)标准的系统(如 Linux)上替换文件将是一个原子操作。

本章中采用的将数据写入临时文件然后重命名文件到最终目标的方法是许多应用程序(如文字处理器和网页浏览器)常用的非常常见的方法,以确保最终输出文件中的数据一致性。

10.4.2 监视文件系统更改

现在我们已经将视频流保存到文件系统中,我们可以读取这些实时视频图像并在不同的应用程序中显示它们。然而,为了做到这一点,我们需要一种机制,通过轮询文件系统,我们可以定期检查是否有新的图像可用。实现这一点的简单而有效的方法是检查图像文件的修改时间。每当它发生变化时,我们就知道有新的图像可供我们使用。为了帮助不同的应用程序执行此任务,我们将功能放入一个库中,它们都可以导入和使用。

sys模块将被用来读取命令行参数,而time模块将被用来在检查文件更改之间暂停。getmtime函数将给我们提供图像文件的修改时间:

import sys
import time
from os.path import getmtime

FileWatcher 类在创建新实例时接收要监视的 path,并将 last_mtime 属性初始化为 None。每次调用 has_changed 方法时,它都会获取正在监视的文件的当前修改时间,并返回自上次检查以来此值是否已更改:

class FileWatcher:
    def __init__(self, path):
        self.path = path
        self.last_mtime = None

    def has_changed(self):
        mtime = getmtime(self.path)
        last_mtime = self.last_mtime
        self.last_mtime = mtime
        return (mtime != last_mtime)

该库有一个 main 函数,可以用来测试 FileWatcher 类。它将第一个命令行参数保存到 path 变量中。然后,它创建一个 FileWatcher 实例来监视指定的路径。接下来,它以每秒 60 帧的速度循环 10 次,并检查文件是否有变化。在每次循环中,它都会打印出是否检测到变化:

def main():
    path = sys.argv[1]
    print('path:', path)
    watcher = FileWatcher(path)
    for i in range(10):
        print(i, watcher.has_changed())
        time.sleep(1 / 60)

完整脚本可以保存为 watcher.py 在 Pi 上,然后执行。

列表 10.3 watcher.py:监视文件变化

#!/usr/bin/env python3
import sys
import time
from os.path import getmtime

class FileWatcher:
    def __init__(self, path):
        self.path = path
        self.last_mtime = None

    def has_changed(self):
        mtime = getmtime(self.path)
        last_mtime = self.last_mtime
        self.last_mtime = mtime
        return (mtime != last_mtime)

def main():
    path = sys.argv[1]
    print('path:', path)
    watcher = FileWatcher(path)
    for i in range(10):
        print(i, watcher.has_changed())
        time.sleep(1 / 60)

if __name__ == "__main__":
    main()

在一个终端中,保持我们之前的 stream_save.py 运行,以便它持续将最新帧保存到 robo_stream.jpg 文件中。然后,在另一个终端中执行 watcher.py 脚本,并为其提供要监视的流图像。以下会话显示了脚本的执行和生成的输出:

$ watcher.py $XDG_RUNTIME_DIR/robo_stream.jpg
path: /run/user/1000/robo_stream.jpg
0 True
1 False
2 True
3 False
4 True
5 False
6 True
7 False
8 True
9 False

摄像机以每秒 30 帧的速度捕获图像,而我们以每秒 60 帧的速度轮询图像文件的变化。这会创建一个预期的模式,文件在已更改和未更改之间交替,以精确匹配从视频流中捕获的图像速率。

10.4.3 将流式传输发送到图形应用程序

watcher 库就绪后,我们可以继续创建一个使用它来监视流式图像变化并显示更新图像的图形应用程序。导入 os 模块以便我们可以访问环境变量。在应用程序中,将使用 cv2 模块显示图像,FileWatcher 将检测流式图像文件的变化:

import os
import cv2
from watcher import FileWatcher

IMG_PATH 变量指向流式图像文件路径。ESC_KEY 包含 Esc 键的键码值:

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
ESC_KEY = 27

main 函数在变量 watcher 中创建一个 FileWatcher 对象,然后进入事件循环。事件循环将一直循环,直到按下 Esc 键或 Q 键。在每次循环周期中,通过调用 has_changed 方法检查图像文件是否有变化。如果检测到变化,则调用 imread 函数读取新的图像,然后调用 imshow 显示图像:

def main():
    watcher = FileWatcher(IMG_PATH)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        if watcher.has_changed():
            img = cv2.imread(IMG_PATH)
            cv2.imshow('preview', img)

完整脚本可以保存为 stream_view.py 在 Pi 上,然后执行。

列表 10.4 stream_view.py:在图形应用程序中显示视频流

#!/usr/bin/env python3
import os
import cv2
from watcher import FileWatcher

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
ESC_KEY = 27

def main():
    watcher = FileWatcher(IMG_PATH)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        if watcher.has_changed():
            img = cv2.imread(IMG_PATH)
            cv2.imshow('preview', img)

main()

确保在另一个终端中运行stream_save.py。现在,当你运行stream_view.py时,你可以看到来自摄像头的实时视频流。与前面章节中的摄像头应用不同,你可以多次启动应用,每个应用将同时传输视频图像。如果你尝试使用第八章中的snapshot.py应用来做这件事,它会失败,因为你不能同时有多个应用直接从视频流中捕获帧。通过这种基于文件系统的视频流图像共享机制,我们可以安全地有任意多的应用访问并处理实时视频图像。图 10.5 显示了同时运行多个图形应用,并且能够同时传输相同的视频流。

图 10.5 图形应用视频流:多个窗口可以读取视频流。

由于我们已经在我们的图形应用中实现了视频流,我们现在可以尝试将二维码检测功能添加到我们的视频流应用中。

10.4.4 在视频流中检测二维码

下一个应用将允许我们在实时视频流中进行二维码检测。任何检测到的二维码都将标记在图像上,解码后的文本将在应用中显示。此应用本质上结合了detect_qr.pystream_view.py脚本中的代码和逻辑。我们从stream_view.py脚本中导入并使用了相同的三个模块:

import os
import cv2
from watcher import FileWatcher

IMG_PATHESC_KEY变量来自stream_view.py,并具有相同的作用。BLUEFONT变量将用于设置应用中绘图的颜色和字体。decoder对象将执行我们的二维码解码:

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
ESC_KEY = 27
BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
decoder = cv2.QRCodeDetector()

main函数的前四行与stream_view.py中的相同,将负责检测新图像和处理事件循环。一旦检测到新图像,就会调用decode_qrcode函数来解码二维码并在任何检测到的代码周围绘制一个框。decode_qrcodedraw_box函数与在detect_qr.py中定义的相同。函数的最后部分通过调用cv2.imshow来显示图像:

def main():
    watcher = FileWatcher(IMG_PATH)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        if watcher.has_changed():
            img = cv2.imread(IMG_PATH)
            decode_qrcode(img)
            cv2.imshow('preview', img)

完整脚本可以保存为 Pi 上的stream_qr.py并执行。

列表 10.5 stream_qr.py:在流媒体视频中检测二维码

#!/usr/bin/env python3
import os
import cv2
from watcher import FileWatcher

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
ESC_KEY = 27
BLUE = (255, 0, 0)
FONT = cv2.FONT_HERSHEY_SIMPLEX
decoder = cv2.QRCodeDetector()

def draw_box(frame, points, color, thickness):
    points = [(int(x), int(y)) for x, y in points]
    pt1, pt2, pt3, pt4 = points
    cv2.line(frame, pt1, pt2, color, thickness)
    cv2.line(frame, pt2, pt3, color, thickness)
    cv2.line(frame, pt3, pt4, color, thickness)
    cv2.line(frame, pt4, pt1, color, thickness)

def decode_qrcode(frame):
    data, matches, _ = decoder.detectAndDecode(frame)
    if data:
        cv2.putText(frame, f'data: {data}', (30, 30), FONT, 1, BLUE)
        draw_box(frame, matches[0], BLUE, thickness=3)
    return data

def main():
    watcher = FileWatcher(IMG_PATH)
    while cv2.waitKey(1) not in [ord('q'), ESC_KEY]:
        if watcher.has_changed():
            img = cv2.imread(IMG_PATH)
            decode_qrcode(img)
            cv2.imshow('preview', img)

main()

确保在另一个终端中运行stream_save.py。现在,当你运行stream_qr.py时,你可以看到来自摄像头的实时视频流。视频流图像中检测到的任何二维码都将被标记。图 10.6 显示了用于标记正在检测的轨迹起始位置的二维码。

图 10.6 在实时视频中检测二维码:检测到的二维码在实时视频中标记。

此脚本对于测试打印标签的 QR 码检测非常有用。在打印标签时,重要的是不要将它们打印得太小,否则相机将无法轻松检测到它们。已经测试了 6 厘米的宽度和高度,对于 QR 码来说效果良好。图 10.7 展示了如何在 LibreOffice Writer 中设置 QR 码标签的确切尺寸。

图片

图 10.7 QR 码标签尺寸:正确设置打印 QR 码的大小非常重要。

我们现在可以继续下一个挑战,即将图像从相机传输到流式传输到网络浏览器。

10.4.5 流式传输到网络浏览器

将相机视频流式传输到网络浏览器可以理解的格式,为我们的机器人网络应用程序打开了新的强大功能,即网络应用程序能够获取实时视频流并看到机器人当时所看到的场景。这将是一个我们之前机器人网络应用程序中不可用的新功能。

在此应用程序中,将使用 Motion JPEG 视频格式来传输连续的视频图像流到连接的网络浏览器。此格式广泛用于视频流,并向网络浏览器发送一系列 JPEG 图像,然后在网络浏览器中回放,就像其他任何视频内容一样。

将使用 os 模块读取环境变量,FileWatcher 将监视图像文件的变化。将使用 Tornado 网络框架创建网络应用程序。asyncio 是 Python 标准库的一部分,将用于运行 tornado 主事件循环:

import os
import asyncio
import tornado.web
from watcher import FileWatcher

IMG_PATH 变量指向我们将用于检查新图像并读取它们的图像文件。更改的轮询频率在 POLL_DELAY 中定义,设置为每秒 60 次。这是相机帧速率的两倍,因此应该足以检测到任何新的视频帧:

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
POLL_DELAY = 1 / 60

CONTENT_TYPE 变量存储了 Motion JPEG 内容的 HTTP 内容类型。它还定义了将用于标记新图像的边界值。BOUNDARY 包含边界值和需要在图像之间发送的字节。JPEG_HEADER 包含将发送到视频流中的每个 JPEG 图像的内容类型:

CONTENT_TYPE = 'multipart/x-mixed-replace;boundary=image-boundary'
BOUNDARY = b'--image-boundary\r\n'
JPEG_HEADER = b'Content-Type: image/jpeg\r\n\r\n'

MainHandler 类实现了 get 方法,该方法在接收到服务器上的 HTTP GET 请求时被调用,并通过向浏览器流式传输视频内容进行响应。它首先将响应的 Content-Type 设置为 Motion JPEG,然后创建一个 FileWatcher 对象来监视流图像文件的变化。接下来,它进入一个无限循环,每当检测到新图像时,它就会读取并发送带有相关边界和 JPEG HTTP 标头的图像到浏览器。然后我们调用 self.flush 将内容发送到浏览器。使用 asyncio.sleep 暂停指定的轮询持续时间:

class MainHandler(tornado.web.RequestHandler):
    async def get(self):
        self.set_header('Content-Type', CONTENT_TYPE)
        watcher = FileWatcher(IMG_PATH)
        while True:
            if watcher.has_changed():
                img_bytes = open(IMG_PATH, 'rb').read()
                self.write(BOUNDARY + JPEG_HEADER + img_bytes)
                self.flush()
            await asyncio.sleep(POLL_DELAY)

main函数首先定义了一个tornado.web.Application对象,它将顶级路径映射到MainHandler类。然后它在端口9000上监听传入的 HTTP 请求,然后调用shutdown_event.wait()等待关闭事件:

async def main():
    app = tornado.web.Application([('/', MainHandler)])
    app.listen(9000)
    shutdown_event = asyncio.Event()
    await shutdown_event.wait()

完整脚本可以保存为stream_web.py在 Pi 上,然后执行。

列表 10.6 stream_web.py:将视频流传输到 Web 应用程序

#!/usr/bin/env python3
import os
import asyncio
import tornado.web
from watcher import FileWatcher

IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
POLL_DELAY = 1 / 60
CONTENT_TYPE = 'multipart/x-mixed-replace;boundary=image-boundary'
BOUNDARY = b'--image-boundary\r\n'
JPEG_HEADER = b'Content-Type: image/jpeg\r\n\r\n'

class MainHandler(tornado.web.RequestHandler):
    async def get(self):
        self.set_header('Content-Type', CONTENT_TYPE)
        watcher = FileWatcher(IMG_PATH)
        while True:
            if watcher.has_changed():
                img_bytes = open(IMG_PATH, 'rb').read()
                self.write(BOUNDARY + JPEG_HEADER + img_bytes)
                self.flush()
            await asyncio.sleep(POLL_DELAY)

async def main():
    app = tornado.web.Application([('/', MainHandler)])
    app.listen(9000)
    shutdown_event = asyncio.Event()
    await shutdown_event.wait()

asyncio.run(main())

在一个终端中,保持之前的stream_save.py运行,以便它持续将最新帧保存到robo_stream.jpg文件中。然后在另一个终端中执行stream_web.py脚本。您可以通过访问网络上的计算机上的地址http://robopi:9000来访问 Web 应用程序。您也可以通过将 URL 中的 RoboPi 替换为机器人的 IP 地址来访问 Web 应用程序。当从移动设备访问 Web 应用程序时,使用 IP 地址将是一个更方便的选择。图 10.8 显示了在移动设备上访问时的视频流外观。在这个例子中,实时视频流是通过 Wi-Fi 网络在 Android 移动设备上查看的。

图片

图 10.8 Web 应用程序视频流:图像显示了通过 Web 传输到移动设备。

与图形应用程序相比,基于 Web 的方法提供了更大的灵活性,因为任何现代的移动或桌面计算机上的 Web 浏览器都可以用来访问视频流。再次强调,这是一个额外的优点,即许多计算机和设备可以同时无问题地访问和查看视频流。

10.5 将机器人移动到目标 QR 码

我们现在可以接受本章的最终挑战,即沿着轨道驾驶机器人直到找到特定的 QR 码。我们需要创建一个满足以下要求的 Python 应用程序:

  • 目标 QR 码的名称应作为第一个命令行参数提供。

  • 机器人应在不断扫描 QR 码的同时向前行驶。

  • 机器人应在首次检测到目标 QR 码时停止。

我们可以在机器人轨道上放置许多带有 QR 码的物体,并使用这种技术让机器人前往这些特定位置之一。

10.5.1 查找 QR 码

当机器人沿着轨道移动时,它将不断检查视频流中是否有检测到的 QR 码。如果它找到一个 QR 码,它将比较其值与我们正在寻找的目标。如果找到匹配项,我们停止机器人。为了安全起见,我们还将提供一个机器人可以移动的最大次数,以防止它撞到轨道的末端。图 10.9 显示了安装在机器人侧面的摄像头,正好位于轮子上方,以便它可以捕获它经过的物体的 QR 码。

图片

图 10.9 侧摄像头:摄像头安装在机器人侧面的轮子上方。

将使用 os 模块读取环境变量,sys 将获取命令行参数。我们将使用 cv2 进行 QR 码检测,并使用 motor 控制机器人电机:

import os
import sys
import cv2
import motor

MAX_MOVES 变量设置了一个限制,在机器人放弃寻找目标之前,最多移动 20 次。IMG_PATH 指向视频流图像,而 decoder 将用于解码 QR 码:

MAX_MOVES = 20
IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
decoder = cv2.QRCodeDetector()

decode_qr 函数从视频流中读取最新图像,并尝试解码图像中发现的任何 QR 码。解码后的数据随后返回:

def decode_qr():
    img = cv2.imread(IMG_PATH)
    data, points, _ = decoder.detectAndDecode(img)
    return data

goto 函数循环执行 MAX_MOVES 中指定的次数。在每次循环中,它以最低速度将机器人向前移动 0.1 秒。然后,它从最新视频图像中解码 QR 码数据,并打印出迄今为止的进度以及它刚刚解码的数据。如果解码的数据与 target 的值匹配,我们返回 True 值以指示成功搜索。如果我们超过了 MAX_MOVES,则返回 False 以指示搜索 target 未成功:

def goto(target):
    for i in range(MAX_MOVES):
        motor.forward(speed=1, duration=0.1)
        data = decode_qr()
        print(f'searching {i + 1}/{MAX_MOVES}, data: {data}')
        if data == target:
            return True
    return False

main 函数从第一个命令行参数获取 target 的值。它打印出该值,然后调用 goto 并传入 target。最后,搜索的结果被保存并打印出来:

def main():
    target = sys.argv[1]
    print('target:', repr(target))
    found = goto(target)
    print('found status:', found)

完整脚本可以保存为 goto_qr.py 并在 Pi 上执行。

列表 10.7 goto_qr.py:搜索并前往目标 QR 码

#!/usr/bin/env python3
import os
import sys
import cv2
import motor

MAX_MOVES = 20
IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
decoder = cv2.QRCodeDetector()

def decode_qr():
    img = cv2.imread(IMG_PATH)
    data, points, _ = decoder.detectAndDecode(img)
    return data

def goto(target):
    for i in range(MAX_MOVES):
        motor.forward(speed=1, duration=0.1)
        data = decode_qr()
        print(f'searching {i + 1}/{MAX_MOVES}, data: {data}')
        if data == target:
            return True
    return False

def main():
    target = sys.argv[1]
    print('target:', repr(target))
    found = goto(target)
    print('found status:', found)

main()

确保在另一个终端中运行 stream_save.py,以便它持续将最新帧保存到文件系统中。然后,在另一个终端中执行 goto_qr.py 脚本。您还可以通过使用 stream_view.pystream_web.py 来观看机器人所看到的。以下会话显示了脚本的执行和它生成的输出:

$ goto_qr.py start
target: 'start'
searching 1/20, data: 
searching 2/20, data: 
searching 3/20, data: 
searching 4/20, data: start
found status: True

我们让机器人搜索一个名为 start 的目标。机器人移动了四次,然后成功到达了带有 start QR 码的标记。搜索随后结束,并报告为成功。让我们看看当我们搜索轨道更远处的目标时会发生什么:

$ goto_qr.py end
target: 'end'
searching 1/20, data: 
searching 2/20, data: 
searching 3/20, data: 
searching 4/20, data: start
searching 5/20, data: start
searching 6/20, data: start
searching 7/20, data: start
searching 8/20, data: start
searching 9/20, data: 
searching 10/20, data: 
searching 11/20, data: 
searching 12/20, data: end
found status: True

我们可以看到,在移动次数 4 时,机器人再次遇到了 start 标记。在持续搜索后,它最终在移动次数 12 时到达了目标 end。像之前一样,它随后返回,并返回一个表示成功搜索的返回值。让我们尝试让机器人寻找一个不存在的目标,看看会发生什么:

$ goto_qr.py never_find_me
target: 'never_find_me'
searching 1/20, data: 
searching 2/20, data: 
searching 3/20, data: 
searching 4/20, data: 
searching 5/20, data: start
searching 6/20, data: start 
searching 7/20, data: start
searching 8/20, data: start
searching 9/20, data: start
searching 10/20, data: 
searching 11/20, data: 
searching 12/20, data: 
searching 13/20, data: 
searching 14/20, data: end
searching 15/20, data: end
searching 16/20, data: end
searching 17/20, data: end
searching 18/20, data: 
searching 19/20, data: 
searching 20/20, data: 
found status: False

机器人超过了允许的最大移动次数,但没有找到其目标。它返回了一个 False 值以指示搜索未成功。这涵盖了搜索不存在物体的场景。

现实世界中的机器人:仓库机器人

机器人越来越多地被用于仓库中检索即将运送给客户的物品。Supply Chain Today 的 Smart Warehouse (mng.bz/ored) 文章展示了亚马逊在其仓库中使用的不同类型的自动化。

在仓库中使用的移动机器人,用于在不同位置移动物品,采用了一种有趣的导航机制。它们在底部有摄像头,可以读取地面的二维码。仓库地板上布满了以网格图案排列的二维码,机器人可以读取这些二维码以确定它们在仓库中的确切位置。这种方法与本章中机器人读取其环境中二维码以导航到特定位置的方式非常相似。

摘要

  • 使用 OpenCV 计算机视觉库在图像中检测二维码,以及读取二维码中编码的数据。

  • 机器人将使用直流电机在一条设定的轨道上来回移动。

  • 你在二维码中放入的数据越多,符号版本就越大,这反过来又生成一个更密集的二维码。

  • 检测到的二维码有四个点,分别对应于四边形的四个角。

  • 使用 ramdisk 来流式传输视频图像,因为这不会创建额外的磁盘工作负载。

  • 通过定期轮询文件系统来检查流媒体图像的变化。

  • 在网络视频流应用中使用 Motion JPEG 视频格式,以向网络浏览器传输连续的视频图像流。

11 构建零食推动机器人

本章涵盖

  • 从 CSV 文件中读取二维码和图标列表

  • 定位和推动选定的对象

  • 创建用于视频流和零食选择的用户界面

  • 构建零食推动机器人

本章旨在构建一个可以通过基于 Web 的 Python 应用程序控制的零食推动机器人。本章首先从 CSV 文件中读取零食列表。每个零食都将分配一个二维码和图标。二维码将用于帮助机器人找到零食。零食图标将与代码一起在 Web 应用程序中显示。然后,我们接受挑战,将机器人移动到选定的零食并定位在理想的位置,以便将零食从边缘推下并送到饥饿的零食食用者手中。然后,机器人将返回起始位置并等待另一个零食请求。在章节的最后部分,我们创建了一个用户界面和 Web 应用程序,该应用程序显示来自机器人摄像头的实时视频流并提供可用零食的列表。选择零食,并观看机器人将其从桌边推下。

此应用程序可以用作创建许多不同类型的起点,这些类型的应用程序可以从移动设备控制,并让机器人从其环境中寻找和抓取不同的物品。能够驾驶并使用机械臂与其周围环境交互的机器人非常灵活。

11.1 硬件堆栈

图 11.1 展示了硬件堆栈,本章中使用的特定组件被突出显示。机器人将使用直流电机沿着轨道移动以寻找特定的目标二维码。将从摄像头捕获图像,并在这些图像上应用二维码检测,直到找到匹配项。接下来,机器人的电机将停止。然后,机器人将使用电机将伺服臂定位在理想的位置以推动检测到的零食。在这个阶段,连接到伺服电机的臂将上下移动以将零食从柜台上推下。有关如何定位零食以便机器人能够轻松检测并推动它们的技巧,请参阅附录 C。

图片

图 11.1 硬件堆栈:伺服电机将被用来推动零食。

11.2 软件堆栈

本章中使用的特定软件的详细信息如图 11.2 所示。我们首先使用 csv 库从 CSV 文件中读取零食列表。每个零食都有一个使用 emoji 库转换的 emoji 图标。接下来,我们创建 pusher_qr 库,该库将使用 OpenCV 检测二维码并使用伺服电机推动零食。我们将使用 Tornado Web 框架创建 pusher_web 应用程序,以便用户可以从他们的移动设备控制机器人。

图片

图 11.2 软件堆栈:将使用 emoji 库将图标转换为 emoji 短码。

11.3 寻找和推动零食

第一步将是创建一个库,根据 QR 码定位和推动零食。我们需要创建一个满足以下要求的 Python 库:

  • 它应该从 CSV 文件中读取零食列表并将找到的任何表情符号简码转换为 Unicode 字符。

  • 该库应该有一个函数可以上下移动伺服臂。

  • 该库应该有一个函数用于查找匹配的 QR 码并在找到代码时移动机械臂。

此库将为我们提供机器人的核心功能。图 11.3 显示了机器人的侧面视图,其中包含用于检测 QR 码的摄像头和用于推动零食的伺服臂。

图 11.3 拍推机器人:伺服电机用于推动零食。

一旦库就位,我们就能开发一个网络应用程序,根据需要调用不同的机器人功能。

11.3.1 读取零食列表

第一步是安装 emoji Python 包。此模块将使我们能够将表情符号简码转换为 Unicode 字符。我们将使用此包为每个零食创建应用程序中的图标。运行以下命令安装包:

$ ~/pyenv/bin/pip install emoji

现在我们已经安装了所有需要的软件,让我们打开一个读取-评估-打印循环(REPL)会话并开始工作。首先,我们将处理从 CSV 文件中读取和解析零食列表的任务。我们导入DictReader对象以将 CSV 数据作为字典列表读取。然后我们导入函数pprint以美化打印我们的数据结构:

>>> from csv import DictReader
>>> from pprint import pprint

CSV 文件应保存为 Pi 上的items.csv,内容如列表 11.1 所示。

列表 11.1 items.csv:零食 QR 码和图标列表

code,icon
grapes,:grapes:
carrots,:carrot:
candy,:candy:
lollipop,:lollipop:

文件的第一行包含字段名。code字段存储 QR 码的值,而icon字段存储表情符号简码的值。第一步将是使用以下代码从 CSV 文件中读取行:

>>> lines = list(open('items.csv'))

现在我们可以查看lines中有什么。它包含一个字符串列表。每个字符串是文件中的一行:

>>> pprint(lines)
['code,icon\n',
 'grapes,:grapes:\n',
 'carrots,:carrot:\n',
 'candy,:candy:\n',
 'lollipop,:lollipop:\n']

我们使用DictReader解析行并返回一个字典列表:

>>> items = list(DictReader(lines))

现在我们可以美化打印items以更好地查看其内部内容:

>>> pprint(items)
[{'code': 'grapes', 'icon': ':grapes:'},
 {'code': 'carrots', 'icon': ':carrot:'},
 {'code': 'candy', 'icon': ':candy:'},
 {'code': 'lollipop', 'icon': ':lollipop:'}]

我们可以从列表中获取第一个项目并检查该项目的codeicon

>>> items[0]
{'code': 'grapes', 'icon': ':grapes:'}
>>> items[0]['code']
'grapes'
>>> items[0]['icon']
':grapes:'

现在,让我们继续将表情符号简码转换为 Unicode 字符。我们将导入emojize函数以转换简码,并使用pathlib将测试 HTML 文件保存到磁盘:

>>> from emoji import emojize
>>> from pathlib import Path

emoji 包页面(pypi.org/project/emoji/)提供了关于使用模块的详细文档,并链接到 Unicode 联盟页面,其中列出了官方表情符号简码。让我们通过调用emojize函数来转换一些文本:

>>> text = emojize('Have some :pie: with your pi!')

我们想在网页浏览器中看到最终结果的样子,所以让我们将文本添加到一些 HTML 中,并保存到文件中:

>>> html = '<!DOCTYPE html><title>_</title>' + text
>>> Path('pie.html').write_text(html)

现在,当我们用我们的网络浏览器打开pie.html文件时,我们将能够看到这些表情符号图标将是什么样子。图 11.4 显示了在网页浏览器中显示的此 HTML 文件。

图 11.4 表情符号简码:派饼的表情符号简码被转换为 Unicode。

既然我们已经读取了我们的零食列表并了解了如何创建一些表情符号图标,那么让我们继续使用机器人寻找和推送零食。

11.3.2 推送零食

我们将创建一个包含几个功能的库,帮助我们定位和推送零食。我们导入dirname函数来获取我们的 Python 文件路径,以及csv来解析我们的 CSV 零食列表。然后,我们导入emojize来帮助处理表情符号图标,以及crickit来控制伺服电机:

from os.path import dirname
from csv import DictReader
from emoji import emojize
from adafruit_crickit import crickit

接下来,我们导入motor来处理直流电机的向前和向后运动。os模块将访问环境变量,而time将用于在伺服臂运动之间暂停。cv2模块将帮助执行二维码检测:

import motor
import os
import time
import cv2

常量ITEMS_FILE指向我们的 CSV 文件,IMG_PATH指向我们的流式图像文件。我们使用MAX_MOVES限制机器人的移动,并在SERVO_ANGLES中定义伺服角度,以在伺服臂上下移动。decoder对象将解码我们的二维码:

ITEMS_FILE = dirname(__file__) + '/items.csv'
IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
MAX_MOVES = 20
SERVO_ANGLES = dict(up=70, down=180)
decoder = cv2.QRCodeDetector()

get_items函数打开我们的 CSV 文件,并将文件中每一行的所有表情符号简码转换为 emoji。接下来,我们调用DictReader来解析 CSV 内容,并返回一个字典列表:

def get_items():
    lines = [emojize(i) for i in open(ITEMS_FILE)]
    return list(DictReader(lines))

我们可靠的decode_qr函数将执行解码我们遇到的任何二维码的工作:

def decode_qr():
    img = cv2.imread(IMG_PATH)
    data, points, _ = decoder.detectAndDecode(img)
    return data

goto函数将机器人移动到提供的direction,寻找与二维码匹配的target。我们使用direction来查找我们的运动函数并将其保存到motor_func中。然后,我们循环移动我们的机器人到期望的方向,并调用decode_qr来检查我们是否遇到了任何二维码。如果我们找到一个匹配的target,我们返回一个True值。否则,如果我们向前移动并到达轨道的尽头,我们返回一个False。同样,如果我们超过了MAX_MOVES运动尝试次数,我们返回一个False

def goto(target, direction):
    motor_func = getattr(motor, direction)
    for i in range(MAX_MOVES):
        motor_func(speed=1, duration=0.1)
        data = decode_qr()
        if data == target:
            return True
        if data == 'end' and direction == 'forward':
            return False
    return False

我们使用swing_arm来挥动我们的伺服臂向上并撞倒零食。我们暂停半秒钟,然后将臂挥回到原始位置。相同的伺服电机用于将臂移动到上下位置。图 11.5 显示了臂在向下位置,这是在沿着轨道行驶时使用的。图 11.6 显示了臂在向上位置,这是用来撞倒零食的:

def swing_arm():
    crickit.servo_2.angle = SERVO_ANGLES['up']
    time.sleep(0.5)
    crickit.servo_2.angle = SERVO_ANGLES['down']
    time.sleep(0.5)

图 11.5 臂向下:当机器人在轨道上移动时,臂保持在向下位置。

push_item函数用于驱动机器人向前移动以寻找匹配的二维码code。如果找到,我们将机器人向后移动,将伺服臂放置在我们的零食中心,然后通过调用swing_arm来摆动臂。最后,我们调用goto来驱动机器人返回起始位置:

def push_item(code):
    found = goto(code, 'forward')
    if found:
        motor.backward(speed=1, duration=0.3)
        swing_arm()
    goto('start', 'backward')

图 11.6 臂抬起:将臂放置在抬起位置以倾倒零食。

完整的脚本可以保存为pusher_qr.py在 Pi 上,然后执行。

列表 11.2 pusher_qr.py:用于检测和推送匹配零食的库

#!/usr/bin/env python3
from os.path import dirname
from csv import DictReader
from emoji import emojize
from adafruit_crickit import crickit
import motor
import os
import time
import cv2

ITEMS_FILE = dirname(__file__) + '/items.csv'
IMG_PATH = os.environ['XDG_RUNTIME_DIR'] + '/robo_stream.jpg'
MAX_MOVES = 20
SERVO_ANGLES = dict(up=70, down=180)
decoder = cv2.QRCodeDetector()

def get_items():
    lines = [emojize(i) for i in open(ITEMS_FILE)]
    return list(DictReader(lines))

def decode_qr():
    img = cv2.imread(IMG_PATH)
    data, points, _ = decoder.detectAndDecode(img)
    return data

def goto(target, direction):
    motor_func = getattr(motor, direction)
    for i in range(MAX_MOVES):
        motor_func(speed=1, duration=0.1)
        data = decode_qr()
        if data == target:
            return True
        if data == 'end' and direction == 'forward':
            return False
    return False

def swing_arm():
    crickit.servo_2.angle = SERVO_ANGLES['up']
    time.sleep(0.5)
    crickit.servo_2.angle = SERVO_ANGLES['down']
    time.sleep(0.5)

def push_item(code):
    found = goto(code, 'forward')
    if found:
        motor.backward(speed=1, duration=0.3)
        swing_arm()
    goto('start', 'backward')

我们现在可以对这个库进行测试。就像我们在上一章中所做的那样,确保在另一个终端中运行stream_save.py。将机器人放置在轨道的起始位置,指向起始二维码。我们可以在 REPL 会话中尝试这个库。首先,我们导入pusher_qr库:

>>> import pusher_qr

我们调用decode_qr函数,它返回我们起始位置的代码作为start

>>> pusher_qr.decode_qr()
'start'

我们现在可以通过以下函数调用让机器人前往轨道的末端:

>>> pusher_qr.goto('end', 'forward')
True

函数返回了True,这意味着它成功到达了目标位置。我们可以调用decode_qr来确认这一点。函数返回的end值:

>>> pusher_qr.decode_qr()
'end'

接下来,我们返回到起始位置:

>>> pusher_qr.goto('start', 'backward')
True

现在,让我们通过调用push_item函数将带有代码candy的零食推出去。机器人将移动到带有二维码candy的零食处,用伺服臂将其推出去,然后返回到起始位置:

>>> pusher_qr.push_item('candy')

和之前一样,我们可以通过调用decode_qr来确认机器人是否在起始位置:

>>> pusher_qr.decode_qr()
'start'

这次会议是我们在将我们的 Web 应用程序作为机器人控制前端之前,对库和机器人进行测试的好方法。

现实世界中的机器人:拣选和放置机器人

机器人中一个非常受欢迎的类别是拣选和放置机器人。它们通常用于制造环境中,需要将生产出的物品打包以便运输。本章中的机器人具有定位特定物品并将它们从计数器上推下来的能力。想象一下将物品推到传送带上,以便运送到工厂的另一个部分进行进一步处理。

推出拣选和放置机器人的好处是,与人工拣选和放置相比,速度和可靠性都有所提高。它们的形状和大小各不相同,这取决于它们需要拣选的物品类型及其特性。6 River Systems 网站(6river.com/what-is-a-pick-and-place-robot)对拣选和放置机器人进行了很好的介绍,是了解不同类型机器人和它们应用的好地方。

11.4 创建零食推送应用程序

现在,我们可以深入创建我们的 Web 应用程序来控制我们的零食推送机器人。我们需要创建一个满足以下要求的 Python 应用程序:

  • 它应显示一个零食按钮列表供选择。

  • 一旦选择了零食,机器人应驶向零食并将其推动。然后它应返回起始位置。

  • 应在应用程序中包含机器人摄像头的实时视频流。

我们面前有许多挑战,所以让我们将问题分解一下。首先,我们将处理列出和选择项目。然后,我们将专注于如何使用样式表更好地控制用户界面的布局和设计。最后,我们将添加实时视频流到应用程序中。

11.4.1 使用应用程序选择零食

我们首先将专注于阅读零食列表,并将它们呈现为一系列按钮。点击这些零食按钮之一后,我们的机器人将驶向零食并施展其魔法。

正如我们之前所做的那样,我们从 Tornado 网络框架导入了许多函数和对象来帮助我们创建网络应用程序。这些都是我们在前几章中使用过的相同函数和对象:

from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging

我们接着从os模块导入以获取目录名称和环境变量。我们导入get_itemspush_item以列出可用的并推送选定的项目:

from os.path import dirname
import os
from pusher_qr import get_items, push_item

我们将应用程序的设置保存在SETTINGS中。我们使用static_path以便我们可以提供静态内容,如样式表:

SETTINGS = dict(
    debug=bool(os.environ.get('ROBO_DEBUG')),
    template_path=dirname(__file__) + '/templates',
    static_path=dirname(__file__) + '/static',
)

MainHandler对象将处理传入的请求。对于GET请求,我们将保存零食列表并将其传递给模板以进行渲染。当访问索引页面时,name将为空,因此我们将其设置为值index。否则,正在访问的页面的名称将直接映射到模板名称。当零食选择表单提交时,post方法将通过调用push_item来推送项目,然后调用redirect将浏览器带到列出所有项目的页面:

class MainHandler(RequestHandler):
    def get(self, name):
        name = name or 'index'
        self.render(f'{name}.html', items=get_items())

    def post(self, code):
        push_item(code)
        self.redirect('items')

最后一步与我们已经看到的类似。我们启用漂亮的日志记录,然后创建我们的应用程序对象并使其监听端口 8888上的传入请求:

enable_pretty_logging()
app = Application([('/([a-z_]*)', MainHandler)], **SETTINGS)
app.listen(8888)
IOLoop.current().start()

完整的脚本可以保存为pusher_web.py在 Pi 上,然后执行。

列表 11.3 pusher_web.py:处理零食推送应用程序的请求

#!/usr/bin/env python3
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler, Application
from tornado.log import enable_pretty_logging
from os.path import dirname
import os
from pusher_qr import get_items, push_item

SETTINGS = dict(
    debug=bool(os.environ.get('ROBO_DEBUG')),
    template_path=dirname(__file__) + '/templates',
    static_path=dirname(__file__) + '/static',
)

class MainHandler(RequestHandler):
    def get(self, name):
        name = name or 'index'
        self.render(f'{name}.html', items=get_items())

    def post(self, code):
        push_item(code)
        self.redirect('items')

enable_pretty_logging()
app = Application([('/([a-z_]*)', MainHandler)], **SETTINGS)
app.listen(8888)
IOLoop.current().start()

在运行此脚本之前,我们至少需要创建一个 HTML 模板以供网页浏览器使用。最终,应用程序将有一个用于显示索引页面的模板和一个用于显示零食列表的模板。我们将首先处理零食列表模板。让我们看看这个 HTML 模板的内容。

我们从 HTML 文档的标题部分开始。在这里,我们设置页面的标题并使用meta标签确保页面在移动设备上良好渲染。像之前一样,我们为页面设置一个空白图标。然后我们指向一个名为style.css的样式表,它将成为我们的静态内容的一部分。我们使用 Tornado 的static_url函数生成此静态内容的 URL:

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Snack Pusher</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
  <link rel="stylesheet" href="{{ static_url('style.css') }}">
</head>

现在我们继续到文档的主体部分,其中包含一个使用 POST 方法提交的表单。我们遍历items变量中的每个小吃。对于每个小吃,我们输出一个由code定义操作的按钮。按钮的文本将显示iconcode的值:

<body>
<form method="post">
  {% for item in items %}
    <button formaction="{{ item['code'] }}">
      {{ item['icon'] }}<br>
      {{ item['code'] }}
    </button>
  {% end %}
</form>
</body>
</html>

完整模板可以保存为items.html,位于应用的模板文件夹中。

列表 11.4 items.html:显示可用项目列表的 HTML 模板

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Snack Pusher</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
  <link rel="stylesheet" href="{{ static_url('style.css') }}">
</head>
<body>
<form method="post">
  {% for item in items %}
    <button formaction="{{ item['code'] }}">
      {{ item['icon'] }}<br>
      {{ item['code'] }}
    </button>
  {% end %}
</form>
</body>
</html>

现在,我们的应用已经足够完善,可以运行并开始测试其功能的一部分。像之前一样,确保在另一个终端中运行stream_save.py。现在,继续运行我们新的pusher_web.py应用。我们可以使用同一网络上的任何计算机或移动设备上的网络浏览器访问 Web 应用。通过访问地址 http://robopi

:8888/items 或通过将 URL 中的robopi替换为机器人的 IP 地址。图

11.7 展示了应用这一部分的外观。它显示了我们在 CSV 文件中定义的四个小吃的列表。每个小吃都有其图标和名称显示。按下这些按钮之一,机器人将行驶,找到所选的小吃,并将其从柜台上推下。

图 11.7 项目列表:应用中显示的小吃列表。

我们已经实现了良好的功能水平。现在,让我们通过样式表给这个应用添加一些样式。

11.4.2 样式化 Web 应用

我们将创建一个样式表来样式化我们的两个页面。我们有一些样式元素在两个页面上都是通用的,所以将所有样式保存在一个样式表中是有意义的。

我们首先对主体内容、链接和按钮进行样式化。我们设置要使用的字体,使文本居中,并通过将text-decoration设置为none来移除链接下划线:

body, a, button {
  font-family: Verdana, Arial, sans-serif;
  text-align: center;
  text-decoration: none;
}

我们将按钮的字体大小加倍,并添加适量的边距和填充,使它们更大,更容易在移动设备上按下。我们将它们都设置为 140 像素的相同宽度,以便它们具有统一的大小:

button {
  font-size: 200%;
  padding: 10px;
  margin: 10px;
  width: 140px;
}

在下一节中,我们将添加索引页的模板。该页面有一个我们想要样式化的iframe。我们使iframe占据整个屏幕宽度,并具有 300 像素的高度。我们还移除了边框,使其更自然地适应页面外观:

iframe {
  width:100%;
  height:300px;
  border:none;
}

样式表可以保存为style.css,位于应用的静态内容文件夹中。

列表 11.5 style.css:将样式表应用于 HTML 模板

body, a, button {
  font-family: Verdana, Arial, sans-serif;
  text-align: center;
  text-decoration: none;
}

button {
  font-size: 200%;
  padding: 10px;
  margin: 10px;
  width: 140px;
}

iframe {
  width:100%;
  height:300px;
  border:none;
}

现在,我们可以再次启动pusher_web.py应用,看看我们的应用。访问相同的 URL,查看页面如何变化。图 11.8 显示了应用样式后页面的新外观。按钮现在将显得更大,并且在小屏幕的移动设备上按下将更容易。

图 11.8 样式化按钮:按钮已使用更大的字体和更多的填充进行样式化。

在处理完样式表之后,我们可以着手处理应用程序的最后部分。索引页面将结合零食列表和实时视频流。

11.4.3 将实时视频流添加到应用程序中

将实时视频流添加到网络应用程序相对简单。我们只需将视频流服务的 URL 放入一个 img 标签中。问题是,每次我们通过按按钮选择零食时,网络浏览器都会提交整个页面。这将刷新整个页面,使我们错过视频流中最激动人心的部分,即机器人沿着轨道寻找我们的零食。我们可以通过将我们的零食列表放在自己的 iframe 中来解决这个问题。这样,无论我们选择多少零食,视频流播放都不会被打断。现在我们可以看看主索引页面的模板。

我们在标题中使用了我们常用的标签来设置页面的语言和标题。标题中的所有标签和值都与我们在 items.html 中使用的相同:

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Snack Pusher</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
  <link rel="stylesheet" href="{{ static_url('style.css') }}">
</head>

在主体中,我们在页面顶部放置了一个带有页面标题的标题。然后,我们在标题之后放置我们的实时视频流。我们确保使用请求中的 host_name 值,以便无论您是通过主机名还是 IP 地址访问它,应用程序都能正确工作。接下来,我们在实时视频流下方加载包含零食列表的页面:

<body>
<h1><a href='/'>Snack Pusher</a></h1>
<img src="http://{{request.host_name}}:9000/" alt="camera"><br>
<iframe src="/items"></iframe> 
</body>
</html>

完整模板可以保存为 index.html,位于应用程序的模板文件夹中。

列表 11.6 index.html:显示实时视频流和零食列表的模板

<!DOCTYPE HTML>
<html lang="en">
<head>
  <title>Snack Pusher</title>
  <meta name="viewport" content="width=device-width">
  <link rel="icon" href="data:,">
  <link rel="stylesheet" href="{{ static_url('style.css') }}">
</head>
<body>
<h1><a href='/'>Snack Pusher</a></h1>
<img src="http://{{request.host_name}}:9000/" alt="camera"><br>
<iframe src="/items"></iframe> 
</body>
</html>

我们应用程序的所有部件现在都已就位,我们可以尝试最终版本。除了运行 stream_save.py 之外,确保 stream_web.py 也正在运行,以便将实时视频流提供给应用程序。运行 pusher_web.py 脚本,以便我们可以尝试我们的应用程序。通过访问地址 http://robopi:8888/ 或机器人的 IP 地址来访问网络应用程序。图 11.9 显示了完整应用程序的外观。现在我们可以看到实时视频流,并在同一应用程序中做出零食选择。我们可以选择任何零食,当请求被机器人处理时,视频流将保持不间断地播放。

图 11.9 最终应用程序:在选择零食时显示实时视频流。

深入了解:应用程序中的视频流

这是我们第一次将实时视频流嵌入到网络应用程序中,这是 HTML 语言的一个非常强大的功能。我们可以以多种方式扩展这个功能。

我们可以创建同时显示多个视频流的用户界面。这对于拥有多个摄像头的机器人尤其有用,一个面向前方,另一个面向后方。通过同时显示来自摄像头的所有视频流,我们可以在机器人行驶时获得其环境的全面视图。

另一个我们可以添加的有用功能是能够连续录制和播放视频流。这可以通过创建一个将视频流中的每一帧保存到带时间戳的文件名中的归档应用程序来实现。然后我们会在用户界面中添加回放选项。如果我们让视频流持续保存,磁盘最终会填满,我们会耗尽存储空间。我们可以通过实施数据保留策略来解决这个问题,其中我们只会保留过去 24 小时的视频流数据。任何更旧的数据都会自动删除,然后应用程序可以维护其记录和播放功能,不会耗尽磁盘空间。或者,我们可以将旧视频数据同步到网络上的集中式视频归档服务器。这是处理机器人有限本地存储的另一种常见策略。

这个最后的项目结合了许多不同的技术,创建了一个能够以强大方式与环境交互的机器人,并且其功能可以扩展。我们可以添加一个功能,让机器人自动检查可用的物品。而不是推动物品,我们可以创建一个抓取和放置机器人的功能,它抓取物品并将它们放置在另一个位置,类似于仓库机器人所做的那样。我们可以以许多方式改进我们的机器人,而我们唯一受限制的是我们的想象力。

就这样,这就是一个漫长而有趣的旅程的结束,它汇集了许多不同的硬件和软件组件,使我们能够通过无线网络使用移动设备控制机器人。手拿移动设备,选择你喜欢的任何小吃,享受机器人快速穿梭,寻找和抛出你心中所想的小吃。

摘要

  • 小吃推送机器人由一个基于 Web 的 Python 应用程序控制。

  • 伺服电机用于移动伺服臂上下移动并推送小吃。

  • 使用 emoji Python 包将表情符号简码转换为 Unicode 字符。这些用作应用程序中小吃图标。

  • 小吃列表是从包含每个小吃二维码和图标的 CSV 文件中读取的。

  • 在应用程序中,使用单个样式表来格式化模板页面。这样做是因为这两个页面中都有共同的样式元素,因此将所有样式放在一个样式表中会更加方便。

  • 使用iframe帮助我们在这个应用程序中播放实时视频流并提交小吃选择,而不会中断视频播放。

附录 A. 硬件购买指南

本附录涵盖了构建本书中所述机器人所需的各类硬件组件。它提供了书项目中所需产品的具体型号细节,以及销售这些产品的在线零售商的产品页面链接。本书中使用了三种不同的机器人配置,本附录涵盖了所有这些配置所需的硬件。在购买硬件之前,请务必查阅本指南。还值得注意的是,附录 D 提供了一种模拟机器人硬件的机制,可以在任何笔记本电脑或台式计算机上运行本书中的所有代码。

A.1 Raspberry Pi 组件

Raspberry Pi 是由 Raspberry Pi 基金会创建的小型单板计算机 (raspberrypi.org)。它是本书中所有机器人项目的核心。Pi 还支持广泛的附加硬件板,这些板可以增加计算机的额外功能。我们项目需要以下 Raspberry Pi 硬件:

全球有许多本地和在线零售商销售这些产品。以下是一些有用的提示和网站,以帮助您选择最适合您所在地区的最佳选项:

  • Raspberry Pi 基金会在其每个产品页面上列出官方零售商,您可以在其网站上点击购买产品时找到这些零售商。在线工具列出了特定产品和国家/地区的官方零售商。

  • Adafruit 产品可以在网上购买 (www.adafruit.com/) 或通过其官方分销商之一 (www.adafruit.com/distributors)。

  • Pimoroni 产品可以在网上 (shop.pimoroni.com/) 或通过分销商 (mng.bz/RmN0) 购买。

Raspberry Pi 需要使用 microSD 卡或 USB 闪存驱动器作为存储。只要满足安装 Raspberry Pi OS 的空间要求,本书中的项目就没有特定的存储要求。以下是一些关于不同可用存储选项需要注意的要点:

  • 如果这是您第一次使用树莓派,那么购买套件会提供许多对初学者有帮助的额外物品,并且通常物有所值。套件通常会包括用于存储的内存卡、电源和用于视频输出的 HDMI 线。其中一个选项是树莓派 4 桌面套件(mng.bz/27gd)。Pimoroni 树莓派 4 基础套件(mng.bz/1JaV)也是另一个受欢迎的选择。如果您发现常规的树莓派 4 缺货,这个套件也是一个不错的选择。

  • USB 闪存驱动器在树莓派上可以比 microSD 卡快得多,这使得在计算机上安装和升级软件的速度也更快。关于树莓派存储性能的这篇文章提供了更多关于磁盘基准测试和快速 USB 闪存驱动器的详细信息(mng.bz/PRN9)。

  • 由于 USB 闪存驱动器在树莓派上的位置,它们通常比 microSD 卡更容易更换。当树莓派完全组装成机器人底盘时,这一点尤其正确,因为 USB 端口比 microSD 插槽更容易访问。您可能想要移除存储设备,以便能够轻松地在另一台计算机上备份整个系统,或者您可能有多个 USB 闪存驱动器,每个驱动器都有不同的软件设置,可以互换使用。

A.2 电机、底盘套件和摇杆控制器

两种最常见的电机类型是直流电机和伺服电机。这两种类型在本书中都有使用。机器人底盘也需要用来连接计算机、电机和电池。推荐的底盘套件有三层,比较小的两层底盘套件提供了更多的空间用于电路板和电池:

这两个套件都非常灵活,支持许多不同的硬件平台。它们的尺寸、连接性和电源需求非常适合使用 CRICKIT HAT 的树莓派。

对于第七章,该章节涉及使用摇杆控制机器人,控制器有多种硬件选项。可以使用原始的索尼 PlayStation 4 或 5 控制器。也可以使用原始的 Xbox 或兼容控制器。以下两个兼容 Xbox 的控制器已经过测试,可以在 Linux 和树莓派上使用:

需要注意的一点是,无线蓝牙连接仅适用于 PlayStation 控制器。然而,你可以使用无线网络连接和任何控制器来控制机器人,这可以通过第七章中介绍的方法,即通过 Wi-Fi 网络连接远程控制机器人。

A.3 电源和布线

树莓派和 CRICKIT HAT 各自需要一个电源。电源选项很多,从电池组到连接电源线到电源插座都有。推荐的方法是使用一个单 USB 电源宝为两个设备供电。有一些电源宝允许同时连接并给两个设备供电。电源宝是可充电和便携的。我们需要一个便携式电源,以便我们的机器人在没有连接电线的情况下行驶。任何支持同时为两个设备充电的 USB 电源宝都可以使用。以下是一个经过测试并且工作良好的电源宝:

CRICKIT HAT 通过一个圆柱形插头连接器接收电源,因此使用 USB 到圆柱形插头线缆将其连接到电源宝。我们还需要扩展跳线,因为机器人底盘套件中的电缆长度不足以连接到 CRICKIT HAT,一旦我们组装了所有必需的部件。以下是一些推荐的电缆:

A.4 可选购买

有许多物品可以帮助改善你的机器人构建体验,但这些不是必需的。你经常会想要拆解和重新配置你的机器人硬件以适应不同的布局。你可能正在尝试不同的电机布局,或者修改电池的位置以改变机器人的重心。在这个过程中,你将希望能够轻松地将树莓派和电源宝从底盘上连接和断开。涤纶粘合方形是解决这个问题的绝佳方案。当与树莓派、CRICKIT HAT 和机器人底盘一起工作时,每个板子和底盘上都有许多位置可以牢固地拧紧部件。尼龙螺丝和支架套件提供了许多不同长度和类型的螺丝和支架,用于此特定目的。磁吸 USB 线缆提供了一种干净且易于连接和断开电源宝到树莓派以及连接电源宝到 USB 充电器的方法。SlimRun 以太网电缆比标准网络电缆轻便且细薄,这在使用有线网络连接时为机器人提供了更多的机动性:

附录 B. 配置 Raspberry Pi

本附录涵盖了 Raspberry Pi 上主要软件的安装和配置。首先,将在计算机上安装 Raspberry Pi OS Linux 发行版。然后,将配置 Python,以便有一个专用的虚拟环境,可以在其中安装 Python 库。接下来将安装 Adafruit CRICKIT 库,然后使用它来运行 Python 代码以与 CRICKIT 硬件交互。

B.1 设置 Raspberry Pi

Raspberry Pi 官方文档([raspberrypi.com/documentation/](https://raspberrypi.com/documentation/))页面是使用 Raspberry Pi 时的优秀资源。对于书中的项目,以下文档部分值得查看:

  • 入门:此页面提供了有关安装操作系统和使用 Raspberry Pi Imager 的详细信息。

  • 配置:有关使用 raspi-config 工具的详细信息,请在此处查看。

  • 远程访问:它涵盖了使用 SSH 连接到您的 Pi、传输文件以及使用 VNC 软件远程访问桌面。

要安装 Raspberry Pi OS,请执行以下操作:

  1. 访问 http://mng.bz/JdN0

  2. 点击 Raspberry Pi OS 链接并下载“带有桌面的 Raspberry Pi OS”镜像。这将下载最新版本。为了参考,书中的代码是在 2022-04-04 32 位 Raspberry Pi OS 版本上测试的。桌面镜像包含桌面环境,这对于我们为机器人项目创建图形应用程序将很有用。

  3. 点击“Raspberry Pi Imager”链接,按照说明下载和安装 Imager 软件。

  4. Raspberry Pi 4 可以从 microSD 卡或 USB 闪存驱动器安装和启动。USB 闪存驱动器提供更好的性能,是推荐选项。

  5. 使用 Imager 软件准备安装介质,使用下载的镜像(microSD 卡/USB 闪存驱动器)。

  6. 一旦 Raspberry Pi 使用安装程序启动,请在欢迎屏幕上点击“下一步”。

  7. 设置国家值,然后设置用户名为 robo,并继续配置步骤。

  8. 重新启动后,我们将使用 Raspberry Pi 配置工具进一步配置 Pi。

  9. 使用此工具将主机名设置为 robopi。图 B.1 显示了用于更改 Raspberry Pi 上主机名的屏幕。

    图片

    图 B.1 更改主机名:使用 Raspberry Pi 配置工具更改主机名。

  10. 接下来,使用配置工具启用 SSH、VNC 和 I2C 接口。图 B.2 显示了启用这些接口后接口屏幕将呈现的样子。

    图片

    图 B.2 启用接口:此屏幕可用于启用不同的接口。

  11. 现在,重新启动 Pi 以使更改生效。

  12. 通过将网络电缆连接到以太网端口或加入 Wi-fi 网络,将 Raspberry Pi 连接到网络。

  13. 通过在终端中运行 hostname -I 命令来获取机器的 IP 地址。

  14. 从网络上的另一台计算机,测试您是否可以使用用户 robo 通过 IP 地址 SSH 到 Raspberry Pi。现在您可以使用 SSH 从网络上的任何计算机运行命令和执行 Python 脚本。

  15. 您也可以通过其主机名 robopi 连接到 Raspberry Pi。为此,您需要在客户端机器的 hosts 文件中添加一行,包含 robopi 主机名及其关联的 IP 地址。How-To Geek 网站提供了一个关于如何在 Windows、Mac 和 Linux 上编辑 hosts 文件的优秀指南(mng.bz/5owz)。

  16. sftp 命令或 FileZilla 应用程序都是将文件在网络中传输到 Pi 的流行选择。如果您的计算机正在运行 Linux,那么 sshfs 是将远程文件挂载并作为本地文件在 Pi 上工作的绝佳方式。

现在我们已经完成了 Raspberry Pi 的主要配置步骤,我们可以继续配置 Adafruit CRICKIT HAT。

B.2 设置 Adafruit CRICKIT HAT

按照以下步骤完成 Adafruit CRICKIT HAT 的硬件和软件配置:

  1. Adafruit 网站上有一份非常全面的指南,用于设置 CRICKIT HAT 和解决任何问题。在下一步中,我们将参考具体部分(mng.bz/wj5q)。

  2. 在第一次使用 CRICKIT HAT 之前,最好更新其固件。在 Adafruit 学习指南中,按照“更新您的 CRICKIT”部分中的步骤操作。

  3. 关闭 Raspberry Pi。要将 CRICKIT HAT 连接到 Raspberry Pi,首先将随 CRICKIT 一起提供的头对齐支架连接到 Raspberry Pi 的 GPIO 连接器。然后连接 CRICKIT HAT。

  4. 将电源线插入 CRICKIT DC 插座,并打开 CRICKIT 电源开关。检查 CRICKIT LED 是否为绿色,这表示有健康的电源供应。

  5. 打开 Raspberry Pi,并打开一个终端或打开到它的 SSH 连接。

  6. 运行 i2cdetect 命令,并检查 i2c 地址 0x49 是否出现在输出中。地址将显示为文本 49,如下所示:

    $ i2cdetect -y 1
         0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
    00:                         -- -- -- -- -- -- -- -- 
    10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
    20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
    30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
    40: -- -- -- -- -- -- -- -- -- 49 -- -- -- -- -- -- 
    50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
    60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
    70: -- -- -- -- -- -- -- -- 
    
  7. 运行以下命令以更新软件包:

    $ sudo apt update
    $ sudo apt upgrade
    $ sudo apt autoremove
    
  8. 运行以下命令以重启机器:

    $ sudo reboot
    
  9. 重启后,重新连接到机器,并运行以下行以创建 Python 虚拟环境并在其中安装 Adafruit CRICKIT 库:

    $ python3 -m venv ~/pyenv
    $ ~/pyenv/bin/pip install adafruit-circuitpython-crickit
    
  10. 运行下一行以添加 activate Bash 别名,该别名可以在需要时激活 Python 虚拟环境。运行命令后,打开一个新的终端以使新别名生效:

    $ echo "alias activate='source ~/pyenv/bin/activate'" >> ~/.bashrc
    
  11. 下一行命令将在虚拟环境中启动一个 Python 读取-评估-打印循环(REPL)会话:

    $ ~/pyenv/bin/python
    
  12. 在 REPL 中运行以下 Python 代码,并确认板载 Neopixel 变红以配置 Adafruit CRICKIT HAT:

    >>> from adafruit_crickit import crickit
    >>> crickit.onboard_pixel.fill(0xFF0000)
    

B.3 激活 Python 虚拟环境

我们现在已经完成了设置并创建了一个 Python 虚拟环境。这些虚拟环境是保持我们已安装的 Python 包和环境与操作系统使用的系统 Python 环境分离的绝佳方式。这样,我们可以在任何时候重新创建机器人的 Python 环境,而不会影响系统安装。我们还可以安装我们想要的任何包及其版本,而不用担心会破坏操作系统正在使用的 Python 包。有关 Python 虚拟环境的更多详细信息,请参阅官方文档(docs.python.org/3/library/venv.html),这是一个关于该主题的极好资源。

在本节中,我们将介绍一些与虚拟环境相关的常见操作。当你打开终端或通过 SSH 连接到 Pi 时,你会得到一个看起来像的提示符

robo@robopi:~ $ 

到目前为止,我们还没有激活我们的虚拟环境。我们可以使用以下命令询问终端它将使用哪个 Python 解释器:

robo@robopi:~ $ which python
/usr/bin/python

输出显示了操作系统使用的 Python 解释器的位置。现在运行 activate 命令以激活我们的虚拟环境:

robo@robopi:~ $ activate 
(pyenv) robo@robopi:~ $ 

我们可以看到,提示符开头出现了 (pyenv) 文本,这表明我们处于上一节创建的名为 pyenv 的 Python 虚拟环境中。现在我们可以使用 which 命令再次检查 Python 解释器的位置:

(pyenv) robo@robopi:~ $ which python
/home/robo/pyenv/bin/python

我们可以查看它是否正在使用为我们的机器人项目创建的虚拟环境中的 Python 解释器。现在,我们可以使用以下命令在我们的虚拟环境中打开一个 REPL 会话:

(pyenv) robo@robopi:~ $ python
Python 3.9.2 (default, Mar 12 2021, 04:06:34) 
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 

按下 Ctrl+D 退出 REPL 会话。现在,当我们执行 Python 脚本时,它们将在我们的虚拟环境中运行,并且能够使用我们已安装的 Python 包。

附录 C. 机器人组装指南

本附录涵盖了如何将不同的硬件组件组装成完整的机器人。本书中使用了三种不同的机器人配置,本指南将涵盖如何构建每个配置。在使用本指南之前,请确保已经阅读附录 A 和 B。每一组章节使用特定的机器人配置:

  • 第二章至第七章使用移动机器人配置。

  • 第八章和第九章使用伺服摄像头机器人配置。

  • 第十章和第十一章使用推杆机器人配置。

C.1 构建移动机器人

移动机器人配置创建了一个可以带板上电源行驶的机器人,并且可以通过无线连接进行控制。按照以下步骤构建机器人:

  1. Adafruit 网站上有一个关于 CRICKIT HAT 的优秀指南(mng.bz/qj0w)。按照指南中的说明将 CRICKIT HAT 连接到 Raspberry Pi。

  2. 然后按照 Pibow 指南(mng.bz/7v59)组装并将 Raspberry Pi 4 放入 Pibow 案中。

  3. 如果你已经购买了可选的尼龙支撑件套装,你现在可以在 CRICKIT HAT 两个没有支撑的角落各放置一个支撑件,使 HAT 以更安全的方式放置在 Raspberry Pi 上。

  4. 最后,将延长跳线连接到电机连接 1 和 2。

  5. 图 C.1 展示了完成这些步骤后 Raspberry Pi 的样子。

    图 C.1 Raspberry Pi:Raspberry Pi 和 CRICKIT HAT 放置在 Pibow 案中。

下一步涉及准备电源银行:

  1. 不同电源银行的尺寸、按钮布局和端口位置可能会有所不同。然而,从根本上说,步骤将是相同的,尽管你可能需要调整电源银行的位置。

  2. Anker PowerCore Select 10000 电源银行在顶部部分有一个按钮和充电指示灯。我们将在电源银行的底部放置 Velcro 胶粘方形。这些方形有助于将电缆的额外部分粘附在电源银行下面,以实现更整洁的电缆管理。按照图 C.2 所示放置胶粘方形。

    图 C.2 胶粘方形电源银行:这些方形粘附在电源银行的底部。

我们现在将继续将电源电缆连接到电源银行:

  1. 将磁吸 USB 电缆连接到电源银行的一个电源输出端口。这条电缆将用于为 Raspberry Pi 提供电力。当机器人不使用时,可以断开磁吸连接以减少对电源银行的电力消耗。

  2. 将 USB 到圆柱形插座电缆插入电源银行的另一个输出端口。这条电缆将为 CRICKIT HAT 提供电力。与 Raspberry Pi 不同,CRICKIT HAT 在板上集成了电源开关,可以用来断开电源,因此在不使用时不需要拔掉。

  3. 将磁铁尖端连接到电源银行的电源输入端口。这提供了一个方便的方法将电源银行连接到电源进行充电。

  4. 图 C.3 展示了我们连接所有电源电缆后的电源银行外观。

    图 C.3 带电缆的电源银行:USB 电缆用于将电源连接到 Raspberry Pi。

我们现在可以组装机器人底盘并安装电源银行:

  1. 按照指南(mng.bz/mjRr)组装机器人底盘套件的所有部件。

  2. 底盘套件非常灵活,底盘上的板子支持许多不同的螺丝和黄铜支撑件排列。你可以尝试不同的排列,看看哪种最适合你电源银行的具体尺寸。现在可以将电源银行放置在底盘的中间层。

  3. 如果可能,最好将电源银行放在中间层而不是顶层。这是机器人最重的部分,当重量保持在较低层时,机器人倾覆的可能性会更小。这在机器人从静止状态加速到最高速度或以高速度减速到完全停止时最为明显。

  4. 现在可以将粘合方块粘贴到底盘的上层。粘贴了四个粘合方块,以提供 Pibow 外壳和底盘之间非常牢固的连接。

  5. 图 C.4 展示了放置电源银行后的机器人底盘外观。

    图 C.4 带电源银行的底盘:Raspberry Pi 放置在底盘的上层。

组装的最后一部分是将 Raspberry Pi 安装到底盘上:

  1. 将带有外壳的 Raspberry Pi 安装在底盘的上层。Raspberry Pi 的 USB 端口应朝向机器人的后面。这使 CRICKIT HAT 和 Raspberry Pi 的电源连接器与电源银行的电源电缆更近。

  2. 连接 CRICKIT HAT 和 Raspberry Pi 的电源电缆。

  3. 最后,将跳线连接到直流电机。确保将正确的直流电机连接到电机连接 1,将左侧的直流电机连接到电机连接 2。

  4. 图 C.5 展示了一个完全组装好的机器人。

    图 C.5 完全组装好的机器人:Raspberry Pi 的 USB 端口可以从机器人的后面访问。

C.2 构建伺服摄像头机器人

伺服摄像头机器人配置创建了一个带有摄像头连接到两个伺服电机的机器人。一个伺服电机将允许我们旋转摄像头,另一个将应用倾斜动作。按照以下步骤构建机器人:

  1. Adafruit Mini Pan-Tilt Kit 是完全组装好的。该套件支持安装不同尺寸和样式的摄像头模块。本 Adafruit 机器人组装指南(mng.bz/5oOB)使用了 Pan-Tilt Kit,并对移除用于下一步所需的侧边卡扣有很好的解释。

  2. 套件有两个由软尼龙制成的侧卡扣,可以拆卸。可以通过拧下或使用线切割工具移除其中一个侧卡扣。我们只需要一个卡扣在位即可。

  3. 在套件的相机安装点上放置一个魔术贴粘合方块。

  4. 套件的基础部分应该固定在一个坚固的表面上以获得更好的稳定性。你可以剪下一块纸板,并使用胶水或双面泡沫胶带将套件固定在纸板上。

  5. 图 C.6 显示了此时万向节套件的外观。

    图 C.6 展示了带有魔术贴的万向节套件:魔术贴方块将用于固定相机。

下一步是准备相机:

  1. 现在可以将树莓派相机模块放入 Adafruit 相机套件中。

  2. 相机套件非常适合安装到套件上,因为它在相机两侧都有槽位,这些槽位可以很好地与套件的侧卡扣配合。

  3. 在相机套件的背面放置一个魔术贴粘合方块。

  4. 图 C.7 显示了带有魔术贴方块的相机套件应该看起来是什么样子。

    图 C.7 显示了相机套件:魔术贴方块已固定在相机套件的背面。

我们现在将继续将相机套件连接到万向节套件:

  1. 使用魔术贴方块可以让我们根据需要连接和拆卸相机。

  2. 现在可以将相机套件连接到万向节套件。确保相机电缆在底部。

  3. 这是相机的正确方向,这样从相机拍摄的照片将会是正立的。

  4. 万向节套件侧面的卡扣可以滑入相机套件中的槽位。

  5. 图 C.8 显示了连接后相机和万向节套件的外观。

    图 C.8 显示了相机连接到万向节套件后的样子:相机将通过魔术贴固定到套件上。

组装的最后一步是将万向节套件连接到树莓派:

  1. 树莓派相机模块的官方文档(mng.bz/6nYo)提供了有关如何将相机电缆连接到树莓派的详细信息。

  2. CRICKIT HAT 上有一个用于相机电缆的开口。将相机电缆穿过 CRICKIT HAT 并连接到树莓派。然后,将 CRICKIT HAT 固定到树莓派上。

  3. 现在将树莓派 4 放入 Pibow 套件中。

  4. 在上一节中提到的 Adafruit 关于 CRICKIT HAT 的指南是一个极好的资源,用于连接万向节套件到 CRICKIT HAT。特别是,CircuitPython 伺服部分提供了关于如何将万向节套件上的伺服电机连接到 CRICKIT HAT 的详细信息。

  5. 指南解释了将伺服连接器连接到 CRICKIT HAT 的方向。伺服连接器一侧的线将有深色,如黑色和棕色。另一侧将使用浅色电线,如黄色、橙色或白色。将深色电线连接到 CRICKIT 标志处,将浅色电线连接到直流电源插孔处。

  6. 将下方的伺服连接器连接到伺服连接 1,将上方的伺服连接器连接到伺服连接 2。

  7. 现在连接 CRICKIT HAT 和 Raspberry Pi 的电源线,就像我们在上一节中做的那样。

  8. 图 C.9 显示了完全组装好的机器人的外观。这个机器人可以使用伺服电机在水平和倾斜方向上移动连接的摄像头。它不像之前的机器人那样使用直流电机。下一节的机器人配置同时使用了伺服电机和直流电机。

    图片

    图 C.9 伺服摄像头机器人:摄像头可以使用伺服电机移动。

C.3 构建推送机器人

推送机器人配置创建了一个可以使用直流电机在轨道上来回行驶的机器人,然后,使用摄像头寻找具有匹配 QR 码的对象。一旦找到,可以使用连接到伺服电机的机械臂将物品从柜台上推下。这个机器人以多种方式结合了前两种机器人配置。这个配置将移动机器人配置添加摄像头和伺服电机。按照以下步骤构建机器人:

  1. 完成移动机器人的配置。然后从机器人底盘上拆下 Pibow 外壳。

  2. 切割一块纸板,将其粘贴到底盘上,然后使用胶水或双面泡沫胶带将万向节套件粘贴到纸板上。图 C.10 展示了从顶部视角看的样子。纸板条被切割成可以放置在魔术贴方块和电源按钮之间的样子。或者,也可以使用短尺代替纸板切割。确保将万向节套件放置在 Raspberry Pi 没有端口的一侧。这是与 HDMI 和 USB 电源端口相对的一侧。这一侧没有端口,所以将摄像头和伺服电机放在上面不会阻挡我们访问任何端口。

    图片

    图 C.10 万向节套件安装在底盘上:照片显示了万向节套件和机器人底盘的顶部视角。

组装步骤的下一步是安装摄像头:

  1. 我们现在可以通过将魔术贴方块重叠在一起的方式重新将 Pibow 外壳安装到机器人底盘上。

  2. 我们然后将一些魔术贴方块粘贴到 Pibow 外壳的侧面,以便我们可以将摄像头安装在机器人侧面。图 C.11 展示了粘贴这些粘合方块后机器人侧面的外观。

    图片

    图 C.11 摄像头魔术贴方块:魔术贴方块被安装在 Pibow 外壳的侧面。

  3. 接下来,我们将摄像头安装在机器人的侧面。图 C.12 展示了摄像头安装后机器人的外观。摄像头外壳安装后将会放在机器人底盘上。连接线将从摄像头外壳顶部伸出。这意味着摄像头将倒置拍摄视频。这不会成为问题,因为我们可以在视频捕获期间通过翻转图像来纠正这一点。

    图片

    图 C.12 摄像头安装:摄像头安装在机器人的侧面。

组装的最后一步是为机器人创建一个伺服臂:

  1. 我们将使用 Pan-Tilt 套件的倾斜伺服器作为推动检测到的物体离开计数器的伺服器。我们想要将一个臂附加到倾斜伺服器上,以扩展倾斜伺服器的物理范围。套件上的安装支架有一个侧卡和槽,我们可以使用它们来固定臂在倾斜伺服器上。

  2. 我们可以用铅笔作为臂,因为它坚固且轻,长度适中,可以为我们的臂提供良好的推动范围。将橡皮擦放在臂的底部,因为它在接触我们推动的物体时将提供一个较软的表面。使用两个拉链带将铅笔固定:一个拉链带在侧卡上方,另一个在下方。图 C.13 显示了带有伺服臂的机器人的侧面视图。

    图片

    图 C.13 伺服臂侧面视图:照片显示了带有伺服臂的机器人的侧面视图。

  3. 图 C.14 显示了机器人的后视图。从这个视角,你可以更好地看到一根拉链带放在侧卡上方,另一根放在下方。这将使臂在反复抬起和放下时牢固地保持在原位。确保拧紧拉链带,以便它们可以牢固地抓住伺服臂。

    图片

    图 C.14 伺服臂后视图:照片显示了带有伺服臂的机器人的后视图。

机器人现在已完成,可以放置在轨道上。检查下一节以获取有关为机器人创建轨道的更多详细信息。

C.4 为推进机器人创建轨道

推进机器人沿着轨道前后移动。这是通过将机器人的移动限制在轨道路径上来控制火车路径的方式完成的。我们可以用两根棍子或杆子为机器人创建一个轨道。我们将在轨道的每一端放一些书,以确保当机器人沿着轨道移动时,轨道不会移动。按照以下步骤创建轨道:

  1. 取两根棍子或杆子,并将它们平行放置在桌子上。图 C.15 显示了我们的桌子上并排放置的两根棍子。照片中的棍子是两根扫帚柄,刷子已被拧下。可以使用任何棍子或杆子。

  2. 尽可能保持棍子之间的距离,使它们紧贴机器人的轮胎。图 C.15 也显示了机器人将要推动的物品可以放置在桌子的边缘,它们的 QR 码指向机器人,以便它在沿着轨道行驶时读取它们的 QR 码。

    图片

    图 C.15 机器人轨道:轨道应紧贴机器人的轮胎。

  3. 图 C.16 显示了放置在轨道每端的书籍。当机器人来回移动时,它们将确保轨道牢固地保持在原位。

    图片

    图 C.16 轨道与书籍:我们使用书籍来稳固地固定轨道。

  4. 起始标记应放置在轨道的起点,结束标记应放置在轨道上的最后一个项目处。图 C.15 显示了起始和结束标记,以及它们之间放置的四个小吃。

设置的最后部分是小吃盒:

  1. 图 C.17 是其中一个小吃盒的照片。这些可以是任何容器或包装,只要我们的二维码放置在容器的正面。标签上的二维码应足够大,以便摄像头容易读取。测试了二维码的宽度和高度为 6 厘米,效果良好。

  2. 二维码应面向机器人,以便它在经过时能够看到它。在测试中,摄像头与二维码之间的距离为 8 厘米效果良好。

    图片

    图 C.17 小吃盒:小吃盒前面有一个二维码。

附录 D. 模拟 CRICKIT 库

本附录涵盖了模拟 Adafruit Python CRICKIT 库的主题。模拟是测试软件时常用的一种机制。它允许我们用模拟对象替换软件的一部分。在我们的情况下,这样做有许多好处:

  • 在不使用机器人硬件的情况下运行代码——本附录中的模拟库将允许我们在没有任何机器人硬件的情况下运行本书中的所有代码。这很有用,因为它提供了对代码中不需要机器人硬件的计算机视觉、网络、操纵杆和网络部分的更深入的了解。

  • 在不使用树莓派的情况下执行代码——代码被编写为在许多不同的 Linux 系统上运行。所有代码都在 Ubuntu 22.04 上进行了测试,它可以在任何装有 Windows 或 Mac 的笔记本电脑或虚拟机上运行。

  • 更好的编码体验——在现代计算机上运行代码通常比在较慢的机器(如树莓派)上执行代码要快得多,也更舒适。例如,当在机器人应用程序的 Web 前端进行大量工作时,在笔记本电脑上开发周期可以更快、更高效。

D.1 安装模拟 CRICKIT 库

将作为 Python 标准库一部分提供的模拟对象库将用于模拟 CRICKIT 库的不同功能。实现将解决模拟本书中使用的特定功能,而不是整个 CRICKIT 库。本附录将主要关注使用 mock_crickit 库,而不会深入探讨实现细节。将以下脚本保存到名为 adafruit_crickit.py 的文件中。

列表 D.1 adafruit_crickit.py:模拟 CRICKIT 库

#!/usr/bin/env python3
import os
from unittest.mock import Mock, PropertyMock
from functools import partial

DEBUG = bool(os.environ.get('ROBO_DEBUG'))
PROP_VALUES = {'touch_1.value': True}

def print_msg(msg):
    if DEBUG:
        print('MOCK_CRICKIT:', msg)

def prop_access(name, *args):
    action = 'set' if args else 'get'
    if action == 'set':
        PROP_VALUES[name] = args[0]
    val = PROP_VALUES.get(name)
    print_msg(f'{action} crickit.{name}: {val!r}')
    return val

def pixel_fill(val):
    print_msg(f'call crickit.onboard_pixel.fill({val!r})')

def add_property(name):
    parent, child = name.split('.')
    property_mock = PropertyMock(side_effect=partial(prop_access, name))
    setattr(type(getattr(crickit, parent)), child, property_mock)

crickit = Mock()
crickit.onboard_pixel.fill = Mock(side_effect=pixel_fill)
names = [
    'onboard_pixel.brightness', 'touch_1.value', 'dc_motor_1.throttle',
    'dc_motor_2.throttle', 'servo_1.angle', 'servo_2.angle',
    'servo_1.actuation_range', 'servo_2.actuation_range']
for name in names:
    add_property(name)

def demo():
    print('starting mock_crickit demo...')
    crickit.onboard_pixel.brightness = 0.01
    crickit.onboard_pixel.fill(0xFF0000)
    crickit.touch_1.value
    crickit.dc_motor_1.throttle = 1
    crickit.dc_motor_2.throttle = -1
    crickit.servo_1.angle = 70
    crickit.servo_1.angle
    crickit.servo_2.angle = 90
    crickit.servo_2.angle
    crickit.servo_1.actuation_range = 142
    crickit.servo_2.actuation_range = 180

if __name__ == "__main__":
    demo()

该库被编写为 adafruit_crickit 库的直接替代品,这就是为什么它有相同的名字。我们可以用它来替代 Adafruit 库,而无需更改我们的 Python 代码。正如我们在整本书中所做的那样,我们可以设置 ROBO_DEBUG 环境变量,使模拟库打印出它接收到的每个模拟调用。当库直接执行时,它将执行 demo 函数,该函数演示了它模拟的 CRICKIT 库的所有不同部分。以下会话显示了库的示例运行:

$ export ROBO_DEBUG=1
$ ./adafruit_crickit.py
starting mock_crickit demo...
MOCK_CRICKIT: set crickit.onboard_pixel.brightness: 0.01
MOCK_CRICKIT: call crickit.onboard_pixel.fill(16711680)
MOCK_CRICKIT: get crickit.touch_1.value: True
MOCK_CRICKIT: set crickit.dc_motor_1.throttle: 1
MOCK_CRICKIT: set crickit.dc_motor_2.throttle: -1
MOCK_CRICKIT: set crickit.servo_1.angle: 70
MOCK_CRICKIT: get crickit.servo_1.angle: 70
MOCK_CRICKIT: set crickit.servo_2.angle: 90
MOCK_CRICKIT: get crickit.servo_2.angle: 90
MOCK_CRICKIT: set crickit.servo_1.actuation_range: 142
MOCK_CRICKIT: set crickit.servo_2.actuation_range: 180

我们还可以将模拟库安装到我们选择的任何 Python 虚拟环境中。库的代码和安装程序可以在 GitHub 上找到(github.com/marwano/robo)。在下一个会话中,我们将使用 pip install 命令安装 mock_crickit 库。请确保在设置脚本所在的目录中运行 pip install 命令:

(main) robo@robopi:/tmp$ cd mock_crickit
(main) robo@robopi:/tmp/mock_crickit$ pip install .
Processing /tmp/mock_crickit
 Installing build dependencies ... done
 Getting requirements to build wheel ... done
 Preparing metadata (pyproject.toml) ... done
Building wheels for collected packages: mock-crickit
 Building wheel for mock-crickit (pyproject.toml) ... done
 Created wheel for mock-crickit: filename=mock_crickit-1.0-py3-none-any.whl
Successfully built mock-crickit
Installing collected packages: mock-crickit
Successfully installed mock-crickit-1.0

我们现在可以运行 pip list 来获取我们虚拟环境中安装的包列表。我们可以看到我们已经安装了 mock-crickit 库的 1.0 版本:

(main) robo@robopi:/tmp/mock_crickit$ pip list
Package      Version
------------ -------
mock-crickit 1.0
pip          23.1.2
setuptools   59.6.0

我们可以通过以下命令调用demo函数来验证库是否正常工作:

(main) robo@robopi:~$ python -m adafruit_crickit
starting mock_crickit demo...
MOCK_CRICKIT: set crickit.onboard_pixel.brightness: 0.01
MOCK_CRICKIT: call crickit.onboard_pixel.fill(16711680)
MOCK_CRICKIT: get crickit.touch_1.value: True
MOCK_CRICKIT: set crickit.dc_motor_1.throttle: 1
MOCK_CRICKIT: set crickit.dc_motor_2.throttle: -1
MOCK_CRICKIT: set crickit.servo_1.angle: 70
MOCK_CRICKIT: get crickit.servo_1.angle: 70
MOCK_CRICKIT: set crickit.servo_2.angle: 90
MOCK_CRICKIT: get crickit.servo_2.angle: 90
MOCK_CRICKIT: set crickit.servo_1.actuation_range: 142
MOCK_CRICKIT: set crickit.servo_2.actuation_range: 180

书中的项目可以使用这个库在各种硬件上执行。附录 A 中提到的操纵杆硬件可以与任何运行 Linux 的笔记本电脑或台式计算机一起使用。此外,任何标准摄像头都可以替代书中的树莓派摄像头模块使用,无需对书中的代码进行任何修改。这使得计算机视觉功能能够实现人脸和二维码检测。

posted @ 2025-11-22 09:02  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报