自动驾驶汽车的视觉和行为实用指南-全-

自动驾驶汽车的视觉和行为实用指南(全)

原文:annas-archive.org/md5/9b92075f71de367fabcae691ae8a60bd

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自动驾驶汽车很快就会出现在我们身边。这个领域的进步可以说是非凡的。我第一次听说自动驾驶汽车是在 2010 年,当时我在东京的丰田展销厅试驾了一辆。这次行程的费用大约是一美元。汽车行驶得非常慢,显然依赖于道路中嵌入的传感器。

快进几年,激光雷达和计算机视觉以及深度学习的发展使这项技术看起来原始且不必要地侵入和昂贵。

在本书的进程中,我们将使用 OpenCV 来完成各种任务,包括行人检测和车道检测;你将发现深度学习,并学习如何利用它进行图像分类、目标检测和语义分割,使用它来识别行人、汽车、道路、人行道和红绿灯,同时了解一些最有影响力的神经网络。

你将熟悉使用 CARLA 模拟器,你将使用它通过行为克隆和 PID 控制器来控制汽车;你将了解网络协议、传感器、摄像头,以及如何使用激光雷达来绘制你周围的世界并找到你的位置。

在深入探讨这些令人惊叹的技术之前,请花一点时间,尝试想象 20 年后的未来。汽车会是什么样子?它们可以自动驾驶。但它们也能飞行吗?是否还有红绿灯?这些汽车的速度、重量和价格如何?我们如何使用它们,使用频率如何?自动驾驶的公交车和卡车又如何?

我们无法预知未来,但可以想象自动驾驶汽车以及一般意义上的自动驾驶事物将以新的和令人兴奋的方式塑造我们的日常生活和城市。

你想在这个未来中扮演一个积极的角色吗?如果是这样,请继续阅读。这本书可能是你旅程的第一步。

这本书面向的对象

本书涵盖了构建自动驾驶汽车所需的多方面内容,旨在为任何编程语言(最好是 Python)有基本知识的程序员编写。不需要深度学习方面的先前经验;然而,为了完全理解最先进的章节,查看一些推荐的阅读材料可能会有所帮助。与第十一章绘制我们的环境相关的可选源代码是用 C++编写的。

这本书涵盖的内容

第一章OpenCV 基础和相机标定,是 OpenCV 和 NumPy 的介绍;你将学习如何操作图像和视频,以及如何使用 OpenCV 检测行人;此外,它还解释了相机的工作原理以及如何使用 OpenCV 对其进行标定。

第二章理解和处理信号,描述了不同类型的信号:串行、并行、数字、模拟、单端和差分,并解释了一些非常重要的协议:CAN、以太网、TCP 和 UDP。

第三章车道检测,教你如何使用 OpenCV 检测道路上的车道。它涵盖了颜色空间、透视校正、边缘检测、直方图、滑动窗口技术和获取最佳检测所需的过滤。

第四章使用神经网络的深度学习,是神经网络的实用介绍,旨在快速教授如何编写神经网络。它描述了神经网络的一般原理,特别是卷积神经网络。它介绍了 Keras,一个深度学习模块,并展示了如何使用它来检测手写数字和分类一些图像。

第五章深度学习工作流程,理想情况下与第四章使用神经网络的深度学习相辅相成,因为它描述了神经网络的原理以及在典型工作流程中所需的步骤:获取或创建数据集,将其分为训练集、验证集和测试集,数据增强,分类器中使用的主体层,以及如何训练、推理和重新训练。本章还涵盖了欠拟合和过拟合,并解释了如何可视化卷积层的激活。

第六章改进你的神经网络,解释了如何优化神经网络,减少其参数,以及如何使用批量归一化、早停、数据增强和 dropout 来提高其准确性。

第七章检测行人和交通灯,向您介绍 CARLA,一个自动驾驶汽车模拟器,我们将用它来创建交通灯数据集。使用名为 SSD 的预训练神经网络,我们将检测行人、汽车和交通灯,并使用称为迁移学习的高级技术来训练神经网络,以根据颜色对交通灯进行分类。

第八章行为克隆,解释了如何训练一个神经网络来驾驶 CARLA。它解释了什么是行为克隆,如何使用 CARLA 构建驾驶数据集,如何创建适合此任务的网络,以及如何训练它。我们将使用显著性图来了解网络正在学习的内容,并将其与 CARLA 集成以帮助它实现自动驾驶!

第九章,“语义分割”,是关于深度学习的最后一章,也是最高级的一章,它解释了什么是语义分割。它详细介绍了一个非常有趣的架构 DenseNet,并展示了如何将其应用于语义分割。

第十章,“转向、油门和制动控制”,是关于控制自动驾驶汽车的内容。它解释了控制器是什么,重点介绍了 PID 控制器,并涵盖了 MPC 控制器的基础知识。最后,我们将在 CARLA 中实现 PID 控制器。

第十一章,“映射我们的环境”,是最后一章。它讨论了地图、定位和激光雷达,并描述了一些开源的地图工具。您将了解什么是同时定位与地图构建(SLAM),以及如何使用 Ouster 激光雷达和 Google Cartographer 来实现它。

为了充分利用这本书

我们假设您具备基本的 Python 知识,并且熟悉您操作系统的 shell。您应该安装 Python,并可能使用虚拟环境来匹配书中使用的软件版本。建议使用 GPU,因为没有 GPU 时训练可能会非常耗时。Docker 将有助于第十一章,“映射我们的环境”。

请参考以下表格了解书中使用的软件:

图片

如果您使用的是这本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars)下载本书的示例代码文件。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

代码实战

本书的相关代码实战视频可在bit.ly/2FeZ5dQ查看。

下载彩色图片

我们还提供了一份包含书中使用的截图/图表的彩色图片 PDF 文件。您可以从这里下载:

static.packt-cdn.com/downloads/9781800203587_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Keras 在模型中提供了一个获取概率的方法,predict(),以及一个获取标签的方法,predict_classes()。”

代码块设置如下:

img_threshold = np.zeros_like(channel)
img_threshold [(channel >= 180)] = 255

当我们希望您注意代码块中的特定部分时,相关的行或项目将被设置为粗体:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都应如下编写:

/opt/carla-simulator/

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“参考轨迹是受控变量的期望轨迹;例如,车辆在车道中的横向位置。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈: 如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一部分:OpenCV、传感器和信号

本节将重点介绍使用 OpenCV 可以实现什么,以及它在自动驾驶汽车环境中的实用性。

本节包括以下章节:

  • 第一章**,OpenCV 基础和相机标定

  • 第二章**,理解和处理信号

  • 第三章**,车道检测

第一章:第一章:OpenCV 基础和相机标定

本章是关于 OpenCV 的介绍,以及如何在自动驾驶汽车管道的初期阶段使用它,以摄取视频流并为其下一阶段做准备。我们将从自动驾驶汽车的角度讨论摄像头的特性,以及如何提高我们从中获得的质量。我们还将研究如何操作视频,并尝试 OpenCV 最著名的功能之一,即目标检测,我们将用它来检测行人。

通过本章,您将建立如何使用 OpenCV 和 NumPy 的坚实基础,这在以后将非常有用。

在本章中,我们将涵盖以下主题:

  • OpenCV 和 NumPy 基础知识

  • 读取、操作和保存图像

  • 读取、操作和保存视频

  • 操作图像

  • 如何使用 HOG 检测行人

  • 摄像头的特性

  • 如何进行相机标定

技术要求

对于本章中的说明和代码,您需要以下内容:

  • Python 3.7

  • opencv-Python 模块

  • NumPy 模块

本章的代码可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter1

本章的“代码在行动”视频可以在以下位置找到:

bit.ly/2TdfsL7

OpenCV 和 NumPy 简介

OpenCV 是一个计算机视觉和机器学习库,它已经发展了 20 多年,提供了令人印象深刻的众多功能。尽管 API 中存在一些不一致,但其简单性和实现的算法数量惊人,使其成为极其流行的库,并且在许多情况下都是最佳选择。

OpenCV 是用 C++编写的,但提供了 Python、Java 和 Android 的绑定。

在这本书中,我们将专注于 Python 的 OpenCV,所有代码都使用 OpenCV 4.2 进行测试。

Python 中的 OpenCV 由opencv-python提供,可以使用以下命令安装:

pip install opencv-python

OpenCV 可以利用硬件加速,但要获得最佳性能,您可能需要从源代码构建它,使用与默认值不同的标志,以针对您的目标硬件进行优化。

OpenCV 和 NumPy

Python 绑定使用 NumPy,这增加了灵活性,并使其与许多其他库兼容。由于 OpenCV 图像是 NumPy 数组,您可以使用正常的 NumPy 操作来获取有关图像的信息。对 NumPy 的良好理解可以提高性能并缩短您的代码长度。

让我们直接通过一些 NumPy 在 OpenCV 中的快速示例来深入了解。

图像大小

可以使用shape属性检索图像的大小:

print("Image size: ", image.shape)

对于 50x50 的灰度图像,image.shape()会返回元组(50, 50),而对于 RGB 图像,结果将是(50, 50, 3)。

拼音错误

在 NumPy 中,size属性是数组的字节数;对于一个 50x50 的灰度图像,它将是 2,500,而对于相同的 RGB 图像,它将是 7,500。shape属性包含图像的大小——分别是(50, 50)和(50, 50, 3)。

灰度图像

灰度图像由一个二维 NumPy 数组表示。第一个索引影响行(y坐标)和第二个索引影响列(x坐标)。y坐标的起点在图像的顶部角落,而x坐标的起点在图像的左上角。

使用np.zeros()可以创建一个黑色图像,它将所有像素初始化为 0:

black = np.zeros([100,100],dtype=np.uint8)  # Creates a black image

之前的代码创建了一个大小为(100, 100)的灰度图像,由 10,000 个无符号字节组成(dtype=np.uint8)。

要创建一个像素值不为 0 的图像,你可以使用full()方法:

white = np.full([50, 50], 255, dtype=np.uint8)

要一次性改变所有像素的颜色,可以使用[:]表示法:

img[:] = 64        # Change the pixels color to dark gray

要只影响某些行,只需要在第一个索引中提供一个行范围:

img[10:20] = 192   # Paints 10 rows with light gray

之前的代码改变了第 10-20 行的颜色,包括第 10 行,但不包括第 20 行。

同样的机制也适用于列;你只需要在第二个索引中指定范围。要指示 NumPy 包含一个完整的索引,我们使用之前遇到的[:]表示法:

img[:, 10:20] = 64 # Paints 10 columns with dark gray

你也可以组合行和列的操作,选择一个矩形区域:

img[90:100, 90:100] = 0  # Paints a 10x10 area with black

当然,可以操作单个像素,就像在普通数组中做的那样:

img[50, 50] = 0  # Paints one pixel with black

使用 NumPy 选择图像的一部分,也称为感兴趣区域(ROI)是可能的。例如,以下代码从位置(90, 90)复制一个 10x10 的ROI到位置(80, 80):

roi = img[90:100, 90:100]
img[80:90, 80:90] = roi 

以下为之前操作的结果:

图 1.1 – 使用 NumPy 切片对图像进行的一些操作

图 1.1 – 使用 NumPy 切片对图像进行的一些操作

要复制一张图片,你可以简单地使用copy()方法:

image2 = image.copy()

RGB 图像

RGB 图像与灰度图像不同,因为它们是三维的,第三个索引代表三个通道。请注意,OpenCV 以 BGR 格式存储图像,而不是 RGB,所以通道 0 是蓝色,通道 1 是绿色,通道 2 是红色。

重要提示

OpenCV 将图像存储为 BGR,而不是 RGB。在本书的其余部分,当谈到 RGB 图像时,它仅意味着它是一个 24 位彩色图像,但内部表示通常是 BGR。

要创建一个 RGB 图像,我们需要提供三个尺寸:

rgb = np.zeros([100, 100, 3],dtype=np.uint8)  

如果你打算用之前在灰度图像上运行的相同代码来运行新的 RGB 图像(跳过第三个索引),你会得到相同的结果。这是因为 NumPy 会将相同的颜色应用到所有三个通道上,这会导致灰色。

要选择一个颜色,只需要提供第三个索引:

rgb[:, :, 2] = 255       # Makes the image red

在 NumPy 中,也可以选择非连续的行、列或通道。您可以通过提供一个包含所需索引的元组来完成此操作。要将图像设置为洋红色,需要将蓝色和红色通道设置为255,这可以通过以下代码实现:

rgb[:, :, (0, 2)] = 255  # Makes the image magenta

您可以使用cvtColor()将 RGB 图像转换为灰度图像:

gray = cv2.cvtColor(original, cv2.COLOR_BGR2GRAY)

处理图像文件

OpenCV 提供了一个非常简单的方式来加载图像,使用imread()

import cv2
image = cv2.imread('test.jpg')

要显示图像,可以使用imshow(),它接受两个参数:

  • 要写在显示图像的窗口标题上的名称

  • 要显示的图像

不幸的是,它的行为不符合直觉,因为它不会显示图像,除非后面跟着对waitKey()的调用:

cv2.imshow("Image", image)cv2.waitKey(0)

imshow()之后调用waitKey()将有两个效果:

  • 它实际上允许 OpenCV 显示imshow()提供的图像。

  • 它将等待指定的毫秒数,或者如果经过的毫秒数<=0,则等待直到按键。它将无限期等待。

可以使用imwrite()方法将图像保存到磁盘上,该方法接受三个参数:

  • 文件名

  • 该图像

  • 一个可选的格式相关参数:

cv2.imwrite("out.jpg", image)

有时,将多张图片并排放置非常有用。本书中的一些示例将广泛使用此功能来比较图像。

OpenCV 为此提供了两种方法:hconcat()用于水平拼接图片,vconcat()用于垂直拼接图片,两者都接受一个图像列表作为参数。以下是一个示例:

black = np.zeros([50, 50], dtype=np.uint8)white = np.full([50, 50], 255, dtype=np.uint8)cv2.imwrite("horizontal.jpg", cv2.hconcat([white, black]))cv2.imwrite("vertical.jpg", cv2.vconcat([white, black]))

这里是结果:

图 1.2 – 使用 hconcat()进行水平拼接和 vconcat()进行垂直拼接

图 1.2 – 使用 hconcat()进行水平拼接和 vconcat()进行垂直拼接

我们可以使用这两个方法来创建棋盘图案:

row1 = cv2.hconcat([white, black])row2 = cv2.hconcat([black, white])cv2.imwrite("chess.jpg", cv2.vconcat([row1, row2]))

您将看到以下棋盘图案:

图 1.3 – 使用 hconcat()结合 vconcat()创建的棋盘图案

图 1.3 – 使用 hconcat()结合 vconcat()创建的棋盘图案

在处理完图像后,我们就可以开始处理视频了。

处理视频文件

在 OpenCV 中使用视频非常简单;实际上,每一帧都是一个图像,可以使用我们已分析的方法进行操作。

要在 OpenCV 中打开视频,需要调用VideoCapture()方法:

cap = cv2.VideoCapture("video.mp4")

之后,您可以通过调用read()(通常在一个循环中),来检索单个帧。该方法返回一个包含两个值的元组:

  • 当视频结束时为 false 的布尔值

  • 下一帧:

ret, frame = cap.read()

要保存视频,有VideoWriter对象;其构造函数接受四个参数:

  • 文件名

  • 视频编码的四字符代码(FOURCC)

  • 每秒帧数

  • 分辨率

以下是一个示例:

mp4 = cv2.VideoWriter_fourcc(*'MP4V')writer = cv2.VideoWriter('video-out.mp4', mp4, 15, (640, 480))

一旦创建了VideoWriter对象,就可以使用write()方法将一帧添加到视频文件中:

writer.write(image)

当你完成使用 VideoCaptureVideoWriter 对象后,你应该调用它们的释放方法:

cap.release()
writer.release()

使用网络摄像头

在 OpenCV 中,网络摄像头被处理得类似于视频;你只需要为 VideoCapture 提供一个不同的参数,即表示网络摄像头的 0 基索引:

cap = cv2.VideoCapture(0)

之前的代码打开了第一个网络摄像头;如果你需要使用不同的一个,你可以指定一个不同的索引。

现在,让我们尝试操作一些图像。

操作图像

作为自动驾驶汽车计算机视觉管道的一部分,无论是否使用深度学习,你可能需要处理视频流,以便其他算法作为预处理步骤更好地工作。

本节将为你提供一个坚实的基础,以预处理任何视频流。

翻转图像

OpenCV 提供了 flip() 方法来翻转图像,它接受两个参数:

  • 图像

  • 一个可以是 1(水平翻转)、0(垂直翻转)或 -1(水平和垂直翻转)的数字

让我们看看一个示例代码:

flipH = cv2.flip(img, 1)flipV = cv2.flip(img, 0)flip = cv2.flip(img, -1)

这将产生以下结果:

图 1.4 – 原始图像,水平翻转,垂直翻转,以及两者都翻转

图 1.4 – 原始图像,水平翻转,垂直翻转,以及两者都翻转

正如你所见,第一幅图像是我们的原始图像,它被水平翻转和垂直翻转,然后两者同时翻转。

模糊图像

有时,图像可能太嘈杂,可能是因为你执行的一些处理步骤。OpenCV 提供了多种模糊图像的方法,这有助于这些情况。你很可能不仅要考虑模糊的质量,还要考虑执行的速率。

最简单的方法是 blur(),它对图像应用低通滤波器,并且至少需要两个参数:

  • 图像

  • 核心大小(更大的核心意味着更多的模糊):

blurred = cv2.blur(image, (15, 15))

另一个选项是使用 GaussianBlur(),它提供了更多的控制,并且至少需要三个参数:

  • 图像

  • 核心大小

  • sigmaX,它是 X 轴上的标准差

建议指定 sigmaXsigmaY(Y 轴上的标准差,第四个参数):

gaussian = cv2.GaussianBlur(image, (15, 15), sigmaX=15, sigmaY=15)

一个有趣的模糊方法是 medianBlur(),它计算中值,因此具有只发出图像中存在的颜色像素(这不一定发生在前一种方法中)的特征。它有效地减少了“盐和胡椒”噪声,并且有两个强制参数:

  • 图像

  • 核心大小(一个大于 1 的奇数整数):

median = cv2.medianBlur(image, 15)

此外,还有一个更复杂的过滤器 bilateralFilter(),它在去除噪声的同时保持边缘清晰。这是最慢的过滤器,并且至少需要四个参数:

  • 图像

  • 每个像素邻域的直径

  • sigmaColor:在颜色空间中过滤 sigma,影响像素邻域内不同颜色混合的程度

  • sigmaSpace:在坐标空间中过滤 sigma,影响颜色比 sigmaColor 更接近的像素如何相互影响:

bilateral = cv2.bilateralFilter(image, 15, 50, 50)

选择最佳过滤器可能需要一些实验。你可能还需要考虑速度。以下是基于我的测试结果和一些基于参数的性能依赖性的大致估计,请注意:

  • blur() 是最快的。

  • GaussianBlur() 类似,但它可能比 blur() 慢 2 倍。

  • medianBlur() 可能会比 blur() 慢 20 倍。

  • BilateralFilter() 是最慢的,可能比 blur() 慢 45 倍。

下面是结果图像:

图 1.5 – 原图、blur()、GaussianBlur()、medianBlur() 和 BilateralFilter(),以及代码示例中使用的参数

图 1.5 – 原图、blur()、GaussianBlur()、medianBlur() 和 BilateralFilter(),以及代码示例中使用的参数

改变对比度、亮度和伽玛

一个非常有用的函数是 convertScaleAbs(),它会对数组的所有值执行多个操作:

  • 它们乘以缩放参数,alpha

  • 它们加上增量参数,beta

  • 如果结果是 255 以上,则将其设置为 255。

  • 结果被转换为无符号 8 位整型。

该函数接受四个参数:

  • 源图像

  • 目标(可选)

  • 用于缩放的 alpha 参数

  • beta 增量参数

convertScaleAbs() 可以用来影响对比度,因为大于 1 的 alpha 缩放因子会增加对比度(放大像素之间的颜色差异),而小于 1 的缩放因子会减少对比度(减少像素之间的颜色差异):

cv2.convertScaleAbs(image, more_contrast, 2, 0)cv2.convertScaleAbs(image, less_contrast, 0.5, 0)

它也可以用来影响亮度,因为 beta 增量因子可以用来增加所有像素的值(增加亮度)或减少它们(减少亮度):

cv2.convertScaleAbs(image, more_brightness, 1, 64)
cv2.convertScaleAbs(image, less_brightness, 1, -64)

让我们看看结果图像:

图 1.6 – 原图、更高对比度(2x)、更低对比度(0.5x)、更高亮度(+64)和更低亮度(-64)

图 1.6 – 原图、更高对比度(2x)、更低对比度(0.5x)、更高亮度(+64)和更低亮度(-64)

改变亮度的一种更复杂的方法是应用伽玛校正。这可以通过使用 NumPy 进行简单计算来完成。伽玛值大于 1 会增加亮度,而伽玛值小于 1 会减少亮度:

Gamma = 1.5
g_1_5 = np.array(255 * (image / 255) ** (1 / Gamma), dtype='uint8')
Gamma = 0.7
g_0_7 = np.array(255 * (image / 255) ** (1 / Gamma), dtype='uint8')

将产生以下图像:

图 1.7 – 原图、更高伽玛(1.5)和更低伽玛(0.7)

图 1.7 – 原图、更高伽玛(1.5)和更低伽玛(0.7)

你可以在中间和右边的图像中看到不同伽玛值的效果。

绘制矩形和文本

在处理目标检测任务时,突出显示一个区域以查看检测到的内容是一个常见需求。OpenCV 提供了 rectangle() 函数,它至少接受以下参数:

  • 图像

  • 矩形的左上角

  • 矩形的右下角

  • 应用的颜色

  • (可选)线条粗细:

cv2.rectangle(image, (x, y), (x + w, y + h), (255, 255, 255), 2)

要在图像中写入一些文本,你可以使用putText()方法,至少需要接受六个参数:

  • 图像

  • 要打印的文本

  • 左下角的坐标

  • 字体样式

  • 缩放因子,用于改变大小

  • 颜色:

cv2.putText(image, 'Text', (x, y), cv2.FONT_HERSHEY_PLAIN, 2, clr)

使用 HOG 进行行人检测

方向梯度直方图(HOG)是 OpenCV 实现的一种目标检测技术。在简单情况下,它可以用来判断图像中是否存在某个特定对象,它在哪里,有多大。

OpenCV 包含一个针对行人训练的检测器,你将使用它。它可能不足以应对现实生活中的情况,但学习如何使用它是很有用的。你也可以用更多图像训练另一个检测器,看看它的表现是否更好。在本书的后面部分,你将看到如何使用深度学习来检测不仅行人,还有汽车和交通灯。

滑动窗口

OpenCV 中的 HOG 行人检测器使用的是 48x96 像素的模型,因此它无法检测比这更小的对象(或者,更好的说法是,它可以,但检测框将是 48x96)。

HOG 检测器的核心有一个机制,可以判断给定的 48x96 像素图像是否为行人。由于这并不特别有用,OpenCV 实现了一个滑动窗口机制,其中检测器被多次应用于略微不同的位置;考虑的“图像窗口”稍微滑动一下。一旦分析完整个图像,图像窗口就会增加大小(缩放),然后再次应用检测器,以便能够检测更大的对象。因此,检测器对每个图像应用数百次甚至数千次,这可能会很慢。

使用 OpenCV 的 HOG

首先,你需要初始化检测器并指定你想要使用该检测器进行行人检测:

hog = cv2.HOGDescriptor()det = cv2.HOGDescriptor_getDefaultPeopleDetector()
hog.setSVMDetector(det)

然后,只需调用detectMultiScale()函数:

(boxes, weights) = hog.detectMultiScale(image, winStride=(1, 1), padding=(0, 0), scale=1.05)

我们使用的参数需要一些解释,如下所示:

  • 图像

  • winStride,窗口步长,指定每次滑动窗口移动的距离

  • 填充,可以在图像边界的周围添加一些填充像素(对于检测靠近边界的行人很有用)

  • 缩放,指定每次增加窗口图像的大小

你应该考虑winSize可以提升准确性(因为考虑了更多位置),但它对性能有较大影响。例如,步长为(4,4)可以比步长为(1,1)快 16 倍,尽管在实际应用中,性能差异要小一些,可能只有 10 倍。

通常,减小缩放可以提高精度并降低性能,尽管影响并不显著。

提高精度意味着检测到更多行人,但这也可能增加误报。detectMultiScale()有几个高级参数可以用于此:

  • hitThreshold,它改变了从支持向量机SVM)平面所需距离。阈值越高,意味着检测器对结果越有信心。

  • finalThreshold,它与同一区域内的检测数量相关。

调整这些参数需要一些实验,但一般来说,较高的hitThreshold值(通常在 0–1.0 的范围内)应该会减少误报。

较高的finalThreshold值(例如 10)也会减少误报。

我们将在由 Carla 生成的行人图像上使用detectMultiScale()

图 1.8 – HOG 检测,winStride=(1, 2),scale=1.05,padding=(0, 0) 左:hitThreshold = 0,inalThreshold = 1;中:hitThreshold = 0,inalThreshold = 3;右:hitThreshold = 0.2,finalThreshold = 1

图 1.8 – HOG 检测,winStride=(1, 2),scale=1.05,padding=(0, 0) 左:hitThreshold = 0,finalThreshold = 1;中:hitThreshold = 0,inalThreshold = 3;右:hitThreshold = 0.2,finalThreshold = 1

如你所见,我们在图像中检测到了行人。使用较低的 hit 阈值和 final 阈值可能导致误报,如左图所示。你的目标是找到正确的平衡点,检测行人,同时避免有太多的误报。

相机简介

相机可能是我们现代世界中最普遍的传感器之一。它们在我们的手机、笔记本电脑、监控系统以及当然,摄影中都被广泛应用。它们提供了丰富的、高分辨率的图像,包含关于环境的广泛信息,包括空间、颜色和时间信息。

没有什么奇怪的,它们在自动驾驶技术中被广泛使用。相机之所以如此受欢迎,其中一个原因就是它反映了人眼的功能。正因为如此,我们非常习惯于使用它们,因为我们与它们的功能、局限性和优点在深层次上建立了联系。

在本节中,你将学习以下内容:

  • 相机术语

  • 相机的组成部分

  • 优缺点

  • 选择适合自动驾驶的相机

让我们逐一详细讨论。

相机术语

在学习相机的组成部分及其优缺点之前,你需要了解一些基本术语。这些术语在评估和最终选择你的自动驾驶应用中的相机时将非常重要。

视场(FoV)

这是环境(场景)中传感器可见的垂直和水平角部分。在自动驾驶汽车中,你通常希望平衡视场与传感器的分辨率,以确保我们尽可能多地看到环境,同时使用最少的相机。视场存在一个权衡空间。较大的视场通常意味着更多的镜头畸变,你需要在相机校准中进行补偿(参见相机校准部分):

图 1.9 – 视场,来源:https://www.researchgate.net/figure/Illustration-of-camera-lenss-field-of-view-FOV_fig4_335011596

图 1.9 – 视场,来源:www.researchgate.net/figure/Illustration-of-camera-lenss-field-of-view-FOV_fig4_335011596

分辨率

这是传感器在水平和垂直方向上的像素总数。这个参数通常使用百万像素MP)这个术语来讨论。例如,一个 5 MP 的相机,如 FLIR Blackfly,其传感器有 2448 × 2048 像素,相当于 5,013,504 像素。

更高的分辨率允许你使用具有更宽视场角(FoV)的镜头,同时仍然提供运行你的计算机视觉算法所需的细节。这意味着你可以使用更少的相机来覆盖环境,从而降低成本。

Blackfly,以其各种不同的版本,由于成本、小型化、可靠性、鲁棒性和易于集成,是自动驾驶汽车中常用的相机:

图 1.10 – 像素分辨率

图 1.10 – 像素分辨率

焦距

这是从镜头光学中心到传感器的长度。焦距最好理解为相机的变焦。较长的焦距意味着你将更靠近环境中的物体进行放大。在你的自动驾驶汽车中,你可能需要根据你在环境中的需求选择不同的焦距。例如,你可能选择一个相对较长的 100 毫米焦距,以确保你的分类器算法能够检测到足够远的交通信号,以便汽车能够平稳、安全地停车:

图 1.11 – 焦距,来源:https://photographylife.com/what-is-focal-length-in-photography

图 1.11 – 焦距,来源:photographylife.com/what-is-focal-length-in-photography

光圈和光圈值

这是光线通过以照亮传感器的开口。通常用来描述开口大小的单位是光圈,它指的是焦距与开口大小的比率。例如,一个焦距为 50 毫米、光圈直径为 35 毫米的镜头将等于 f/1.4 的光圈。以下图示展示了不同光圈直径及其在 50 毫米焦距镜头上的光圈值。光圈大小对你的自动驾驶汽车非常重要,因为它与景深DoF)直接相关。大光圈还允许相机对镜头上的遮挡物(例如,虫子)具有容忍性:更大的光圈允许光线绕过虫子并仍然到达传感器:

图 1.12 – 光圈,来源:https://en.wikipedia.org/wiki/Aperture#/media/File:Lenses_with_different_apertures.jpg

图 1.12 – 光圈,来源:en.wikipedia.org/wiki/Aperture#/media/File:Lenses_with_different_apertures.jpg

景深(DoF)

这是环境中将要聚焦的距离范围。这直接关联到光圈的大小。通常情况下,在自动驾驶汽车中,你希望有较深的景深,以便你的计算机视觉算法能够聚焦视野中的所有物体。问题是,深景深是通过小光圈实现的,这意味着传感器接收到的光线较少。因此,你需要平衡景深、动态范围和 ISO,以确保你能看到环境中所需看到的一切。

以下图示展示了景深与光圈之间的关系:

图 1.13 – 景深与光圈对比,来源:https://thumbs.dreamstime.com/z/aperture-infographic-explaining-depth-field-corresponding-values-their-effect-blur-light-75823732.jpg

图 1.13 – 景深与光圈对比,来源:thumbs.dreamstime.com/z/aperture-infographic-explaining-depth-field-corresponding-values-their-effect-blur-light-75823732.jpg

动态范围

这是传感器的一个属性,表示其对比度或它能解析的最亮与最暗主题之间的比率。这可能会用 dB(例如,78 dB)或对比度(例如,2,000,000/1)来表示。

自动驾驶汽车需要在白天和夜晚运行。这意味着传感器需要在黑暗条件下提供有用的细节,同时不会在明亮阳光下过度饱和。高动态范围(HDR)的另一个原因是当太阳在地平线较低时驾驶的情况。我相信你在早上开车上班时一定有过这样的经历,太阳正对着你的脸,你几乎看不到前面的环境,因为它已经让你的眼睛饱和了。HDR 意味着即使在直射阳光下,传感器也能看到环境。以下图示说明了这些条件:

图 1.14 – HDR 示例,来源:https://petapixel.com/2011/05/02/use-iso-numbers-that-are-multiples-of-160-when-shooting-dslr-video/

图 1.14 – HDR 示例,来源:petapixel.com/2011/05/02/use-iso-numbers-that-are-multiples-of-160-when-shooting-dslr-video/

你的梦想动态范围

如果你可以许愿,并且在你的传感器中拥有你想要的任何动态范围,那会是什么?

国际标准化组织(ISO)灵敏度

这是像素对入射光子的敏感度。

等一下,你说,你的缩写搞混了吗?看起来是这样,但国际标准化组织决定连它们的缩写也要标准化,因为否则每种语言都会不同。谢谢,ISO!

标准化的 ISO 值可以从 100 到超过 10,000。较低的 ISO 值对应于传感器较低的灵敏度。现在你可能要问,“为什么我不想要最高的灵敏度?”好吧,灵敏度是有代价的……噪声。ISO 值越高,你会在图像中看到更多的噪声。这种额外的噪声可能会在尝试对物体进行分类时给你的计算机视觉算法带来麻烦。在下面的图中,你可以看到较高 ISO 值对图像噪声的影响。这些图像都是在镜头盖盖上(完全黑暗)的情况下拍摄的。随着 ISO 值的增加,随机噪声开始渗入:

图 1.15 – 暗室中的 ISO 值示例和噪声

图 1.15 – 暗室中的 ISO 值示例和噪声

帧率(FPS)

这是传感器获取连续图像的速率,通常以 Hz 或每秒帧数(FPS)表示。一般来说,你希望有最快的帧率,这样快速移动的物体在你的场景中就不会模糊。这里的主要权衡是延迟:从真实事件发生到你的计算机视觉算法检测到它的时间。必须处理的帧率越高,延迟就越高。在下面的图中,你可以看到帧率对运动模糊的影响。

模糊并不是选择更高帧率的唯一原因。根据你的车辆速度,你需要一个帧率,以便车辆能够在物体突然出现在其视场(FoV)中时做出反应。如果你的帧率太慢,当车辆看到某物时,可能已经太晚做出反应了:

图 1.16 – 120 Hz 与 60 Hz 帧率对比,来源:https://gadgetstouse.com/blog/2020/03/18/difference-between-60hz-90hz-120hz-displays/

图 1.16 – 120 Hz 与 60 Hz 帧率对比,来源:gadgetstouse.com/blog/2020/03/18/difference-between-60hz-90hz-120hz-displays/

镜头眩光

这些是来自物体的光线在传感器上产生的伪影,这些伪影与物体在环境中的位置不相关。你可能在夜间驾驶时遇到过这种情况,当时你会看到迎面而来的车灯。那种星光效果是由于你的眼睛(或相机)的镜头中散射的光线,由于不完美,导致一些光子撞击了“像素”,而这些像素与光子来自的地方不相关——也就是说,是车灯。以下图示显示了这种效果。你可以看到星光效果使得实际物体,即汽车,非常难以看清!

图 1.17 – 迎面车灯产生的镜头眩光,来源:https://s.blogcdn.com/cars.aol.co.uk/media/2011/02/headlights-450-a-g.jpg

图 1.17 – 迎面车灯产生的镜头眩光,来源:s.blogcdn.com/cars.aol.co.uk/media/2011/02/headlights-450-a-g.jpg

镜头畸变

这是直线或真实场景与你的相机图像所看到场景之间的区别。如果你曾经看过动作相机的视频,你可能已经注意到了“鱼眼”镜头效果。以下图示显示了广角镜头的极端畸变示例。你将学会如何使用 OpenCV 来纠正这种畸变:

图 1.18 – 镜头畸变,来源:https://www.slacker.xyz/post/what-lens-should-i-get

图 1.18 – 镜头畸变,来源:www.slacker.xyz/post/what-lens-should-i-get

相机的组成部分

就像眼睛一样,相机由光敏感阵列、光圈和镜头组成。

光敏感阵列 – CMOS 传感器(相机的视网膜)

在大多数消费级相机中,光敏感阵列被称为 CMOS 有源像素传感器(或简称传感器)。其基本功能是将入射光子转换为电信号,该信号可以根据光子的颜色波长进行数字化。

光圈(相机的光圈)

相机的光圈或瞳孔是光线通往传感器的通道。这可以是可变的或固定的,具体取决于你使用的相机类型。光圈用于控制诸如景深和到达传感器的光线量等参数。

镜头(相机的镜头)

镜头或光学元件是相机将环境中的光线聚焦到传感器上的组成部分。镜头主要通过其焦距决定相机的视场角(FoV)。在自动驾驶应用中,视场角非常重要,因为它决定了汽车单次使用一个摄像头可以看到多少环境。相机的光学元件通常是成本最高的部分之一,并对图像质量和镜头眩光有重大影响。

选择相机的考虑因素

现在你已经了解了摄像头的基本知识和相关术语,是时候学习如何为自动驾驶应用选择摄像头了。以下是在选择摄像头时你需要权衡的主要因素列表:

  • 分辨率

  • 视场角(FoV)

  • 动态范围

  • 成本

  • 尺寸

  • 防护等级(IP 等级)

    完美的摄像头

    如果你能设计出理想的摄像头,那会是什么样子?

我理想的自动驾驶摄像头将能够从所有方向(球形视场角,360º HFoV x 360º VFoV)看到。它将具有无限的分辨率和动态范围,因此你可以在任何距离和任何光照条件下以数字方式解析物体。它的大小将和一粒米一样,完全防水防尘,并且只需 5 美元!显然,这是不可能的。因此,我们必须对我们所需的东西做出一些谨慎的权衡。

从你的摄像头预算开始是最好的起点。这将给你一个关于要寻找哪些型号和规格的想法。

接下来,考虑你的应用需要看到什么:

  • 当你以 100 km/h 的速度行驶时,你是否需要从 200 米外看到孩子?

  • 你需要覆盖车辆周围多大的范围,你能否容忍车辆侧面的盲点?

  • 你需要在夜间和白天都能看到吗?

最后,考虑你有多少空间来集成这些摄像头。你可能不希望你的车辆看起来像这样:

图 1.19 – 摄像头艺术,来源:https://www.flickr.com/photos/laughingsquid/1645856255/

图 1.19 – 摄像头艺术,来源:www.flickr.com/photos/laughingsquid/1645856255/

这可能非常令人不知所措,但在思考如何设计你的计算机视觉系统时,这一点非常重要。一个很好的起点是 FLIR Blackfly S 系列,它非常受欢迎,在分辨率、帧率(FPS)和成本之间取得了极佳的平衡。接下来,搭配一个满足你视场角(FoV)需求的镜头。互联网上有一些有用的视场角计算器,例如来自www.bobatkins.com/photography/technical/field_of_view.html的计算器。

摄像头的优缺点

现在,没有任何传感器是完美的,即使是你的心头好摄像头也会有它的优点和缺点。让我们现在就来看看它们。

让我们先看看它的优势:

  • 高分辨率:与其他传感器类型(如雷达、激光雷达和声纳)相比,摄像头在场景中识别物体时具有出色的分辨率。你很容易就能以相当低的价格找到具有 500 万像素分辨率的摄像头。

  • 纹理、颜色和对比度信息:摄像头提供了其他传感器类型无法比拟的关于环境的丰富信息。这是因为摄像头能够感知多种波长的光。

  • 成本:摄像头是你能找到的性价比最高的传感器之一,尤其是考虑到它们提供的数据质量。

  • 尺寸:CMOS 技术和现代 ASIC 技术使得相机变得非常小,许多小于 30 立方毫米。

  • 范围:这主要归功于高分辨率和传感器的被动性质。

接下来,这里有一些弱点:

  • 大量数据处理用于物体检测:随着分辨率的提高,数据量也随之增加。这就是我们为了如此精确和详细的图像所付出的代价。

  • 被动:相机需要一个外部照明源,如太阳、车头灯等。

  • 遮蔽物(如昆虫、雨滴、浓雾、灰尘或雪):相机在穿透大雨、雾、灰尘或雪方面并不特别擅长。雷达通常更适合这项任务。

  • 缺乏原生深度/速度信息:仅凭相机图像本身无法提供关于物体速度或距离的任何信息。

    测绘学正在帮助弥补这一弱点,但代价是宝贵的处理资源(GPU、CPU、延迟等)。它也比雷达或激光雷达传感器产生的信息准确性低。

现在您已经很好地理解了相机的工作原理,以及其基本部分和术语,是时候动手使用 OpenCV 校准相机了。

使用 OpenCV 进行相机校准

在本节中,您将学习如何使用已知模式的物体,并使用 OpenCV 来纠正镜头扭曲。

记得我们在上一节中提到的镜头扭曲吗?您需要纠正这一点,以确保您能够准确地定位物体相对于车辆的位置。如果您不知道物体是在您前面还是旁边,那么看到物体对您没有任何好处。即使是好的镜头也可能扭曲图像,这尤其适用于广角镜头。幸运的是,OpenCV 提供了一个检测这种扭曲并纠正它的机制!

策略是拍摄棋盘的图片,这样 OpenCV 就可以使用这种高对比度图案来检测点的位置,并根据预期图像与记录图像之间的差异来计算扭曲。

您需要提供几张不同方向的图片。可能需要一些实验来找到一组好的图片,但 10 到 20 张图片应该足够了。如果您使用的是打印的棋盘,请确保纸张尽可能平整,以免影响测量:

图 1.20 – 一些可用于校准的图片示例

图 1.20 – 一些可用于校准的图片示例

如您所见,中心图像清楚地显示了某些桶形扭曲。

扭曲检测

OpenCV 试图将一系列三维点映射到相机的二维坐标。然后,OpenCV 将使用这些信息来纠正扭曲。

首件事是初始化一些结构:

image_points = []   # 2D points object_points = []  # 3D points coords = np.zeros((1, nX * nY, 3), np.float32)coords[0,:,:2] = np.mgrid[0:nY, 0:nX].T.reshape(-1, 2)

请注意nXnY,它们分别是xy轴上棋盘上要找到的点数。在实践中,这是方格数减 1。

然后,我们需要调用findChessboardCorners()

found, corners = cv2.findChessboardCorners(image, (nY, nX), None)

如果 OpenCV 找到了点,则found为真,corners将包含找到的点。

在我们的代码中,我们将假设图像已经被转换为灰度图,但您也可以使用 RGB 图片进行校准。

OpenCV 提供了一个很好的图像,展示了找到的角点,确保算法正在正常工作:

out = cv2.drawChessboardCorners(image, (nY, nX), corners, True)object_points.append(coords)   # Save 3d points image_points.append(corners)   # Save corresponding 2d points

让我们看看结果图像:

图 1.21 – OpenCV 找到的校准图像的角点

图 1.21 – OpenCV 找到的校准图像的角点

校准

在几幅图像中找到角点后,我们最终可以使用calibrateCamera()生成校准数据。

ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(object_points, image_points, shape[::-1], None, None)

现在,我们准备使用undistort()校正我们的图像:

dst = cv2.undistort(image, mtx, dist, None, mtx)

让我们看看结果:

图 1.22 – 原始图像和校准图像

图 1.22 – 原始图像和校准图像

我们可以看到,第二张图像的桶形畸变较少,但并不理想。我们可能需要更多和更好的校准样本。

但我们也可以通过在findChessboardCorners()之后寻找cornerSubPix()来尝试从相同的校准图像中获得更高的精度:

corners = cv2.cornerSubPix(image, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001))

以下是为结果图像:

图 1.23 – 使用亚像素精度校准的图像

图 1.23 – 使用亚像素精度校准的图像

由于完整的代码有点长,我建议您在 GitHub 上查看完整的源代码。

摘要

嗯,你在通往制作真正自动驾驶汽车的计算机视觉之旅中已经取得了很好的开端。

你了解了一个非常有用的工具集,称为 OpenCV,它为 Python 和 NumPy 提供了绑定。有了这些工具,你现在可以使用imread()imshow()hconcat()vconcat()等方法创建和导入图像。你学会了如何导入和创建视频文件,以及使用VideoCapture()VideoWriter()方法从摄像头捕获视频。小心,斯皮尔伯格,镇上来了一个新的电影制作人!

能够导入图像是件好事,但如何开始操作它们以帮助你的计算机视觉算法学习哪些特征很重要呢?你通过flip()blur()GaussianBlur()medianBlur()bilateralFilter()convertScaleAbs()等方法学会了如何这样做。然后,你学会了如何使用rectangle()putText()等方法为人类消费标注图像。

然后,真正的魔法出现了,你学会了如何使用 HOG 检测行人来处理图像并完成你的第一个真正的计算机视觉项目。你学会了如何使用detectMultiScale()方法在图像上以不同大小的窗口滑动窗口来扫描检测器,使用参数如winStridepaddingscalehitThresholdfinalThreshold

你在使用图像处理的新工具时玩得很开心。但似乎还缺少了什么。我该如何将这些图像应用到我的自动驾驶汽车上?为了回答这个问题,你学习了关于相机及其基本术语,例如分辨率视场角焦距光圈景深动态范围ISO帧率镜头眩光,以及最后的镜头畸变。然后,你学习了组成相机的基本组件,即镜头、光圈和光敏阵列。有了这些基础知识,你继续学习如何根据相机的优缺点来选择适合你应用的相机。

带着这些知识,你勇敢地开始使用在 OpenCV 中学习的畸变校正工具来消除这些弱点之一,即镜头畸变。你使用了findChessboardCorners()calibrateCamera()undistort()cornerSubPix()等方法来完成这项工作。

哇,你真的在朝着能够在自动驾驶应用中感知世界的方向前进。你应该花点时间为自己到目前为止所学到的感到自豪。也许你可以通过自拍来庆祝,并应用一些你所学的知识!

在下一章中,你将学习一些基本信号类型和协议,这些类型和协议在你尝试将传感器集成到自动驾驶应用中时可能会遇到。

问题

  1. OpenCV 能否利用硬件加速?

  2. 如果 CPU 性能不是问题,最好的模糊方法是什么?

  3. 哪个检测器可以用来在图像中找到行人?

  4. 你该如何从网络摄像头中读取视频流?

  5. 光圈和景深之间的权衡是什么?

  6. 在什么情况下你需要高 ISO?

  7. 为相机校准计算亚像素精度值得吗?

第二章:第二章:理解和处理信号

本章,你将了解你可能在集成你为项目选择的各种传感器时遇到的不同信号类型。你还将了解各种信号架构,本章将帮助你选择最适合你应用的一个。每个都有其陷阱、协议和规定。

本章我们将涵盖以下主题:

  • 信号类型

  • 模拟与数字

  • 串行数据

  • CAN

  • UDP

  • TCP

到本章结束时,你将能够应用你对每个协议的理解。你将能够手动解码各种协议的串行数据以帮助调试信号。最重要的是,你将拥有应用开源工具为你做繁重工作的知识。

技术要求

要执行本章中的指令,你需要以下条件:

  • 基本电气电路知识(关于电压、电流和电阻)

  • 二进制、十六进制和 ASCII 编程知识

  • 使用示波器探测传感器信号的经验

本章的代码可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter2

本章的“代码实战”视频可以在以下位置找到:

bit.ly/2HpFqZa

理解信号类型

当你集成自动驾驶汽车的传感器、执行器和控制器时,你会遇到许多不同的信号类型。你需要了解每种类型的优缺点,以帮助你选择正确的设备进行集成。接下来的几节将涵盖每种信号类型的所有细节,并为你提供做出正确选择的知识。

这里是你在机器人和自动驾驶汽车中会遇到的基本信号类型:

  • 串行

  • 并行

  • 模拟

  • 数字

  • 单端

  • 差分

在下一节中,你将学习模拟信号和数字信号之间的区别。

模拟与数字

首先要记住的是,我们生活在一个模拟的世界。没有什么是一瞬间发生的,一切都是连续的。这也是我们无法瞬间移动的原因,遗憾的是!

同样,模拟信号是连续且不断变化的;它们不会瞬间跳跃,而是平滑地从一种状态过渡到另一种状态。一个典型的模拟信号例子是古老的调幅AM)收音机。你可以在下面的图中看到,平滑的数据信号是如何调制到平滑的载波上以创建平滑的 AM 信号。在这里,音调由振幅变化的快慢表示,音量由振幅的大小表示:

图 2.1 – 模拟信号示例

图 2.1 – 模拟信号示例

相比之下,数字信号是在已知的时间点采样的。当信号被采样时,它会检查是否高于或低于某个阈值,这将决定它是逻辑0还是1。你可以在下面的图中看到这个例子:

图 2.2 – 数字信号示例

图 2.2 – 数字信号示例

模拟隐藏在数字中

尽管我们谈论数字是从一种状态跳到另一种状态,但实际上并不是。它只是非常快速地变化,但以模拟的方式。我们只是选择在脉冲中间采样它。世界总是模拟的,但有时我们以数字的方式解释它。

如果你仔细观察下面的图,你会看到隐藏在角落中的模拟特性!

图 2.3 – 数字信号示波器 – 信号运行前后

图 2.3 – 数字信号示波器 – 信号运行前后

你看到了吗?尽管这应该是电压之间的尖锐过渡,但你可以看到本应是方形角落的圆润。这是因为自然界中没有什么是瞬间的,一切都是从一种状态平滑过渡到另一种状态。

在下一节中,你将学习串行和并行数据传输之间的区别。

串行与并行

串行数据可能是最普遍的数据传输类型。这是我们人类习惯于沟通的方式。你现在就在这样做,当你阅读这段文字时。串行通信简单来说就是数据一次传输和接收一个单元(与并行传输多个数据单元相对)。

在阅读这本书的情况下,你的眼睛通过从左到右扫描每一行文本来逐字处理,然后回到下一行的开头并继续。你正在处理一个用于传达某些思想和观点的单词序列流。相反,想象一下如果你一次能读几行。这将被认为是并行数据传输,那将是非常棒的!

计算机中使用的单位是位,它是对开或关的二进制表示,更常见的是 1 或 0。

并行数据传输在计算机的早期年份很受欢迎,因为它允许通过多个(通常是 8 个)电线同时传输位,这大大提高了数据传输速度。这种速度是以几个代价为代价的。更多的电线意味着更多的重量、成本和噪音。由于这些多根电线通常相邻,你会在相邻的电线中产生大量的噪声,这被称为串扰。这种噪声导致传输距离缩短。以下图示了 8 位是串行传输还是并行传输:

图 2.4 – 串行与并行

图 2.4 – 串行与并行

现在,你可以看到每根线都专门用于每个位。在早期计算中,当发送 8 位时,这没问题,但你可以想象,当考虑 32 位和 64 位数据时,这会变得多么难以管理。幸运的是,随着协议速度的提高,很明显串行传输要便宜得多,也更容易集成。这并不是说并行数据传输不存在;它确实存在于速度至关重要的应用中。

有几种类型的串行数据协议,如 UART、I2C、SPI、以太网和 CAN。在接下来的几节中,你将了解它们的介绍。

通用异步接收和发送(UART)

UART 是一个非常常见的协议,得益于其简单性和成本效益。许多低数据速率的应用程序会使用它来传输和接收数据。在自动驾驶应用中,你将看到 UART 的一个常见应用是时间同步到 GPS。一个包含所有位置和时间信息的 GPS 接收器消息将被发送到激光雷达、摄像头、雷达或其他传感器,以将它们同步到协调世界时UTC)。

抱歉,我想你的缩写搞混了。

法国人英语人无法就缩写达成一致,所以与其在英语中使用 CUT 或法语中使用TUCTemps Universel Coordonné),他们决定两者都混用,以不偏袒任何一种语言。如果我不能按照我的方式来,你也不能!就这样,UTC 诞生了!

好吧,那么 UART 看起来是什么样子?首先你需要理解的是,该协议是异步的,这意味着不需要时钟信号(线)。相反,两个设备各自必须拥有相当好的内部时钟来为自己计时。对,所以不需要时钟线;但你需要什么线?你只需要两根线:一根用于传输,一根用于接收。因此,在开始游戏之前,两个设备需要就一些基本规则达成一致:

  1. 波特率:这设置了设备之间每秒交换的位数。换句话说,它是位计数的时间长度。常见的波特率有 9,600、19,200、38,400、57,600、115,200、128,000 和 256,000。

  2. 数据位:这设置了数据帧中用于有效载荷(数据)的位数。

  3. 奇偶校验位:这决定了数据包中是否会有奇偶校验位。这可以用来验证接收到的消息的完整性。这是通过计算数据帧中 1 的数量来完成的,如果 1 的数量是偶数,则将奇偶校验位设置为 0,如果是奇数,则设置为 1。

  4. 停止位:这设置了表示数据包结束的停止位数。

  5. 流量控制:这设置了你是否会使用硬件流量控制。这并不常见,因为它需要额外的两根线用于准备发送RTS)和清除发送CTS)。

很好,我们已经有了基本规则。现在让我们看看数据包的样子,然后再解码一个。

以下图示说明了 UART 消息数据包的结构。你可以看到我们从一个精确的起始比特(低电平)开始,然后是 5 到 9 个数据比特,如果规则中有奇偶校验位,则跟随奇偶校验位,最后是停止比特(1 到 2 个,高电平)。空闲状态通常是高电压状态,表示一个 1,而活动状态通常是低电平,表示一个 0。这是一个正常极性。如果需要,你可以反转极性,只要事先设定好规则:

图 2.5 – UART 数据包结构

图 2.5 – UART 数据包结构

以下是一个示例信号波形,展示了如何使用八个数据比特、无奇偶校验和一个停止比特来解码比特。我们从空闲(高电平)状态开始,然后数据包以低电压位开始。这意味着接下来的八个比特是数据。接下来,我们看到五个高电压计数,表示五个 1 比特,然后是三个低电压计数,表示三个 0 比特。你应该知道 UART 消息是以最低有效位首先发送的,意味着最低的二进制值或 20 位,然后是 21 位,然后是 22 位,依此类推。所以,如果你将它们重新排列成人类可读的格式,你的数据消息是 0 0 0 1 1 1 1 1;转换成十进制,那将是 31 或十六进制的 1F。有关不同基数系统(如二进制、十进制和十六进制)的丰富资源,请参阅 www.mathsisfun.com/binary-decimal-hexadecimal.html。还有另一个方便的资源,用于从二进制解码 ASCII 字符 www.asciitable.com/

图 2.6 – UART 示例比特

图 2.6 – UART 示例比特

蛋白质

拿出你的解码环。现在是学习生命、宇宙和万物意义的时候了...

太棒了,现在你知道如何解码 UART 串行消息了。

好吧,所以你可能想知道,UART 如此简单,我为什么要使用其他任何东西呢? 让我们来看看 UART 的优缺点。

优点如下:

  • 便宜

  • 全双工(同时发送和接收)

  • 异步(没有时钟线)

  • 简单,每个设备之间只有两根线

  • 奇偶校验用于错误检查

  • 广泛使用

缺点如下:

  • 每个数据包的最大比特数为九比特。

  • 设备时钟必须在彼此的 10% 以内。

  • 按照现代标准,它的速度较慢,标准波特率从每秒 9,600 到 230,400 比特不等。

  • 需要在每个设备之间建立直接连接,而不是总线架构。

  • 起始和停止比特有一些开销,需要复杂的硬件来发送和接收。

如果你想通过 Python 获得 UART 的经验,你可以测试 UART 通信的最简单设备是 Arduino。如果你有,太好了!然后你可以直接跳转到 PySerial 的文档,开始与你的 Arduino 进行通信。如果你没有 Arduino,你可以在本书的仓库中找到一个示例模拟器代码,在第二章文件夹中:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter2

接下来,你将了解使用 UART 消息的两种不同标准。

差分与单端

UART 信号可以通过几种不同的方式传输。最常见的是推荐标准 232RS-232)和推荐标准 422RS-422)。

RS-232 是一种单端信号,意味着它的电压直接与系统的电气地(0 V)比较。以下图显示了单端信号:

图 2.7 – 单端线

图 2.7 – 单端线

相比之下,RS-422 是一种差分信号,意味着电压是在系统电气地(地线)独立比较的:

图 2.8 – 差分线

图 2.8 – 差分线

现在是故事时间...

从前,信号诞生了,它有一个使命:将发送者的信息传递到接收者居住的遥远之地。世界上充满了鬼怪,它们正在密谋对付我们的英雄信号。这些鬼怪和幽灵绊倒并扭曲了我们可怜的朋友信号。信号在旅途中行进得越远,这些坏蛋就越能偷偷潜入并造成破坏。一般来说,在短途旅行中,信号不受影响,相对无干扰地穿过魔法森林。然而,旅程越长,信号就越需要找到技巧、朋友和守护者,才能安全地到达接收器。

那么,这些鬼怪和幽灵是谁呢?它们是电磁场和感应电流。你看,每当一个幽灵电磁场靠近信号的路径(电线)时,它就会在路径上产生一个鬼怪(电流)。这个鬼怪随后使用它的魔法力量拉伸和收缩信号的臂,直到它到达目的地,接收器看到的信号的臂比它们应有的要短或长。

但不必害怕——技巧、朋友和守护者都在这里!信号有一个很好的小技巧来挫败鬼怪,但首先它必须产生一个双胞胎:我们可以称之为 langis(这是信号的倒写)。langis 和信号在前往接收器的路上相互缠绕。这使鬼怪困惑,导致它们产生两个相等但相反的鬼怪,它们无意中撞在一起并消失,在它们能够使用魔法力量之前。

另一个技巧是 langis 和信号都承诺无论遇到什么小妖精,他们都会始终手牵手一起旅行。所以,当他们到达目的地时,接收器只需测量 langis 和信号之间的距离来获取信息!

好吧,那么这个童话在现实生活中是什么样子呢?你不会只有一个传输线,而是使用两条线。然后你将一条线设置为高电压(V+),另一条线设置为低电压(V-)。现在当比较接收端的信号时,你测量 V+和 V-之间的电压差以确定你是否有高或低信号。以下图显示了单端信号(1a)和差分信号(1b):

图 2.9 – 单端信号与差分信号

图 2.9 – 单端信号与差分信号

这有一个美妙的效果,即任何感应噪声都会以相同的方式影响 V+和 V-,所以当你测量 V+和 V-之间的差异时,它不会改变它发送时的状态。以下图展示了这种情况:

图 2.10 – 差分线的噪声

图 2.10 – 差分线的噪声

另一个技巧是将差分对的两个线绕在一起。这会抵消线中任何感应电流。以下插图显示了直通电缆和绞合对电缆电流之间的差异。你可以看到,在每次绞合时,线会交换位置,因此噪声电流在每次绞合时交替,从而有效地相互抵消:

图 2.11 – 差分绞合对电缆的噪声消除

图 2.11 – 差分绞合对电缆的噪声消除

那么,当你选择单端和差分时,这一切对你意味着什么呢?

这里有一个比较表以供参考:

表 2.1

表 2.1

在下一节中,你将了解另一种形式的串行通信,它可以使事情加快一些,还有一些非常实用的好处。

I2C

I2C,或称 I2C,代表互集成电路,是另一种具有一些酷炫新特性的串行数据传输协议。更多关于这些内容稍后介绍。I2C 通常用于在单个印刷电路板PCB)上的组件之间进行通信。它声称数据传输速率为 100-400 kHz,这通常得到支持,规范甚至为高达 5 MHz 的通信留有空间,尽管这在许多设备上并不常见得到支持。

你可能会问,“为什么使用 I2C?UART 已经非常简单和容易了。” 好吧,I2C 添加了一些非常酷的特性,这些特性在 UART 中是没有的。回想一下,UART 需要在每个设备之间连接两根线,这意味着你需要为每个你想与之通信的设备准备一个连接器。当你想要连接多个设备时,这会迅速变成一个难以管理的线网。此外,在 UART 中,也没有主从设备的概念,因为设备直接在各自的 Tx 和 Rx 线上相互交谈。以下图示将展示一个完全连接的 UART 架构的例子:

图 2.12 – 完全连接的 UART 网络

图 2.12 – 完全连接的 UART 网络

I2C 来拯救!I2C 也只使用两根线:一根串行时钟线SCL)和一根串行数据线SDA)——稍后我们将详细介绍这些是如何工作的。它使用这两根线来在设备之间设置一个新的架构,即总线。总线简单地说是一组共享的线,将信号传输到所有连接到它们的设备。以下插图将帮助我们更好地理解这一点:

图 2.13 – I2C 总线架构

图 2.13 – I2C 总线架构

这允许多个设备之间相互交谈,而不需要每个设备到它需要与之交谈的每个其他设备的专用线路。

你可能正在想,大家怎么能都在同一条线上说话并且理解任何东西呢? I2C 协议实现了主从设备的概念。主设备通过向所有人宣布来控制通信流程,嘿,大家注意了,我在和 RasPi1 说话,请确认你在那里,然后发送数据给我! 然后,主设备将控制权交给 RasPi1,它迅速喊道,我在这里,已经理解了请求!这是你请求的数据;请确认你已收到。然后主设备说,收到了! 然后这个过程重新开始。以下是一个 I2C 交换的时序图:

图 2.14 – I2C 时序图

图 2.14 – I2C 时序图

让我们通过一个通信序列来了解一下。这个序列从主设备将 SDA 线拉低,然后是 SCL 线拉低开始。这标志着开始条件。接下来的 7-10 位(取决于你的设置)是正在交谈的从设备的地址。下一个位,R/W 位,指示从设备要么写入(逻辑 0)要么从(逻辑 1)其内存寄存器读取。R/W 位之后的位是确认(ACK)位。如果被寻址的从设备听到了、理解了、确认了,并且将响应请求,那么它就会设置这个位。然后主设备将在 SCL 线上继续生成脉冲,同时从设备或主设备开始将数据放置在 SDA 上的八个数据位上。

大位在前

与 UART 相反,I2C 中的位是先传输 MSB(最高有效位)。

例如,如果 R/W 位被设置为 0,从设备将接收放置在 SDA 上的数据并将其写入内存。紧随八位数据之后,从设备将 SDA 线拉低一个时钟周期以确认它已接收数据、存储数据并准备好将 SDA 线的控制权交还给主设备。此时,主设备将 SDA 线拉低。最后,为了停止序列,主设备将释放 SCL,然后是 SDA。

当这么多设备可以同时通过总线进行通信时,有一些规则来确保不会发生冲突是很重要的。I2C 通过使用开漏系统来实现这一点,这仅仅意味着任何主设备或从设备都只能将线路拉到地。SCL 和 SDA 的空闲状态通过上拉电阻保持在高电压状态。这可以在图 2.13中看到,电阻连接到 VDD。当主设备或从设备想要发送数据时,它们将线路拉到地(或打开漏极)。这确保了你永远不会有一个设备驱动线路高电平,而另一个设备驱动低电平。

I2C 协议的另一个有趣特性是,总线不仅可以有多个从设备,还可以有多个主设备。在这里,你会问,“等等,你说主设备控制流量。我们将如何知道谁在控制?”这个架构的精髓在于每个设备都连接到相同的线路(总线),因此它们都可以在任何给定时间看到正在发生的事情。所以,如果有两个主设备几乎同时尝试控制总线,第一个将 SDA 线拉低的设备获胜!另一个主设备退让并成为临时从设备。有一种情况是两个主设备同时将 SDA 线拉低,此时不清楚谁有控制权。在这种情况下,将开始仲裁。第一个释放 SDA 线到高电平的主设备将失去仲裁权并成为从设备。

复活节彩蛋

如果一只半的母鸡在一天半的时间里下了一只半的蛋,那么半打母鸡在半打天里会下多少蛋?解码以下信号以获取答案!

这里是 I2C 的优缺点总结。

这些是优点:

  • 多主多从架构,最多支持 1,024 个设备在 10 位地址模式下。

  • 基于总线的,只需要两条线(SCL 和 SDA)

  • 速度高达 5 MHz

  • 价格低廉

  • 消息确认

这些是缺点:

  • 半双工,不能同时发送和接收。

  • 开始、停止和确认条件的开销降低了吞吐量。

  • 拉上电阻限制了时钟速度,消耗了 PCB 空间,并增加了功耗。

  • 短的最大线缆长度(1 cm–2 m)取决于电容、电阻和速度。

你已经学到了很多关于 I2C 的知识,这使你拥有了更多关于处理串行数据的知识。在下一节中,你将学习另一种带有更多活力的串行通信协议,但为此牺牲了其他一些东西。

SPI

串行外设接口SPI)是一种主要用于微控制器的串行传输链路,用于连接外围设备,如 USB、内存和板载传感器。它的主要优势是速度和实现的简单性。SPI 不常用于你在自动驾驶汽车应用中使用的传感器,但如果你遇到它,了解一些相关信息是有价值的。它是一个全双工链路,使用四根线:SCLK、MOSI、MISO 和 SS。以下插图将有助于我们讨论它们的功能:

图 2.15 – SPI 连接图

图 2.15 – SPI 连接图

SPI 是一个同步串行链路,使用主时钟信号(SCLK),这与你在 I2C 中学到的类似。时钟速率通常在 6-12.5 MHz 之间,这也是它的比特率。数据在 主设备输出从设备输入MOSI)和 主设备输入从设备输出MISO)线上传递。MOSI、MISO 和 SCLK 可以用作总线架构,就像 I2C 一样。最后一根线是 从机选择SS)线。这条线被拉低以通知连接到它的从机,它应该监听即将到来的消息。

这与 I2C 相比,I2C 会发送从机地址以及要监听哪个从机的信息。正如你在 图 2.15 中可以看到的,必须为系统添加的每个从机分配一条单独的线和引脚(图中用 SS1SS2SS3 表示)。用于实现 SPI 的硬件相当简单,通常依赖于移位寄存器。你可能会问:“什么是移位寄存器?” 好吧,这是一个简单的内存寄存器,可以存储一定数量的位,比如八位。每次从一侧引入一个新位时,另一侧就会推出一个位。以下图示说明了这在 SPI 中的工作原理:

图 2.16 – SPI 移位寄存器

图 2.16 – SPI 移位寄存器

由于总线上只允许有一个主设备,SPI 数据传输非常简单。以下图示有助于说明如何使用 SCK、MOSI、MISO 和 SS 来进行全双工数据传输:

图 2.17 – SPI 时序图

图 2.17 – SPI 时序图

主设备将 SS 线拉低,这样目标从机就会知道:“嘿,SS1,这条消息是给你的,请准备好接收。” 然后,主设备在 SCK 线上发送时钟脉冲,告诉从机何时应该采样 MOSI 上传来的数据。如果已经预定从机需要发送一些信息,主设备随后会跟随 SCK 脉冲,告诉从机何时应该在 MISO 线上发送数据。由于有两条线,MISO 和 MOSI,这两个事务可以使用移位寄存器格式同时发生。

SPI 在协议方面不像 UART 或 I2C 那样标准化。因此,你需要查阅你想要连接的设备的接口控制文档,以确定操作该设备所需的特定命令、寄存器大小、时钟模式等。

你可以看到,与 UART 和 I2C 相比,SPI 中没有开销。没有起始位、地址、停止位、确认位或其他开销。它是纯粹、甜美、高速的数据。另一方面,在两个设备之间进行通信需要更多的编程和预先安排的设置。SPI 还可以在数据线上驱动高低电平,允许更快地从 01 的转换,这导致了之前讨论的更快传输速率。

因此,让我们总结一下优缺点。

这些是优点:

  • 6-12.5 MHz 的快速数据速率

  • 全双工通信

  • 可以使用简单的移位寄存器硬件

  • 多个从设备

  • 总线架构

这些是缺点:

  • 单个主设备

  • 需要 4 根线,每增加一个从设备还需要一个从设备选择线。

  • 传输长度短,取决于速度、阻抗和电容,最大估计为 3 米

在下一节中,我们将讨论一种在几乎所有道路上行驶的车辆中都使用的非常常见的协议!发动引擎吧!

基于帧的串行协议

到目前为止,我们一直在讨论那些在 8-10 位范围内的消息大小相对较小的协议。如果你想要发送更多的消息呢?在接下来的几节中,你将了解到支持更大消息大小并将它们打包成帧或数据包的协议。

你将了解到以下协议:

  • CAN

  • 以太网:UDP 和 TCP

理解 CAN

控制器局域网络 (CAN) 是一种基于消息的协议,由博世公司开发,旨在减少连接车辆中不断增长的微控制器和 电子控制单元 (ECUs) 所需的线缆数量。

这是一个基于总线的协议,由两根作为差分对的线组成,即 CAN-HI 和 CAN-LO。你在 单端与差分 部分学习了差分对。

幽灵和鬼怪

你还记得我们用来为 langis 和信号提供安全通道的那个技巧吗?这个故事中有一个真正的 转折

CAN 是一个功能丰富的协议,非常健壮、可靠且快速。以下是该协议的一些特性:

  • 分散式多主通信

  • 优先级消息

  • 总线仲裁

  • 远程终端请求

  • 使用循环冗余校验的数据完整性

  • 灵活可扩展的网络

  • 集中式诊断和配置

  • 通过扭绞差分对抑制电磁干扰噪声

CAN 总线架构非常简单,如下图所示:

图 2.18 – CAN 总线架构

图 2.18 – CAN 总线架构

你可以看到节点可以添加在总线内部的任何位置,包括在总线终端 Rterm 内部。连接节点时需要考虑的是未终止的引线长度,标准建议将其保持在 0.3 米以下。

现在我们来看看位是如何在 CAN HI 和 CAN LO 差分双绞线上传输的。以下图说明了 CAN 协议的主导和隐含电压:

从零到英雄

0 以其主导的差分电压上升至最小阈值以上来统治总线。

一劳永逸

1 以其隐含的差分电压在总线上休眠,该电压低于最小阈值。

图 2.19 – CAN 主导和隐含电压

图 2.19 – CAN 主导和隐含电压

通过 EE JRW – own work, CC BY-SA 4.0, https://commons.wikimedia.org/w/index.php?curid=55237229

现在你已经看到了位是如何放置在总线上的,让我们来看看 CAN 帧结构。如果你发现自己正在调试或读取 CAN 总线流量,这将很有用。以下图显示了 CAN 帧的各个部分:

图 2.20 – CAN 消息格式

图 2.20 – CAN 消息格式

CAN 帧以0开始,这是主导的差分电压。这听起来可能有点类似于你在 UART 通信中学到的,UART 也是以逻辑0开始的。与 UART 类似,CAN 中的 SOF 位是从空闲状态到活动状态的转换。然而,与 UART 不同的是,活动状态和主导状态是高电压状态。

在 SOF 位之后是仲裁字段。这可以被认为是 ECU 的功能地址(例如,转向模块、氧传感器、激光雷达传感器等)。

少即是多

在仲裁字段中地址较小的 ECU 在 CAN 协议中被认为是优先级较高的。当两个或多个设备同时开始传输时,较小的地址将赢得仲裁。

下一个位是1,隐含状态。

接下来的六位是帧的数据长度码DLC)部分,它说明了即将到来的数据字段将有多长。CAN 消息的数据长度可以是 0 到 8 字节。

紧接着 DLC 的是数据,其长度可以是 0-64 位(0-8 字节)。

位顺序

CAN 以 MSB(最高有效位)优先发送其信息。

接下来是循环冗余校验CRC)字段,长度为 15 位,用于检查消息中的错误。发送 ECU 对数据字段进行校验和计算,并将其放置在 CRC 字段中。一旦接收 ECU 接收到帧,它将在数据字段上运行相同的校验和计算,并验证它是否与接收帧中的 CRC 字段匹配。CRC 字段紧随其后的是 CRC 定界符字段,以将其与 ACK 位分开。

1位是隐性的,这样任何接收 ECU 都可以在位间隔期间确认接收到无错误的数据。ACK 位后面跟着 ACK 定界符,以允许任何超出 ACK 位的时序差异。

最后,1位,表示 – 您猜对了 – 帧的结束。

好吧,还有一件事:存在一个帧间空间IFS),它由系统的 CAN 控制器定义。

好吧,这已经很长了。不过不用担心;CAN 是一个非常受支持的协议,您可以找到大量的软件和硬件模块来为您做繁重的工作。您可能只有在事情不正常时才需要翻出老式的示波器来探测 CAN 总线并验证消息是否正确传输。

让我们回顾一下 CAN 总线协议的优缺点。

这些是优点:

  • 去中心化多主通信

  • 优先级消息

  • 总线仲裁

  • RTR

  • 使用 CRC 进行数据完整性

  • 灵活、可扩展的网络

  • 集中式诊断和配置

  • 通过双绞差分对抑制 EMI 噪声

  • 最大电缆长度为 40 米

这实际上只有一个缺点:

  • 需要仔细注意布线总线端子和跳线长度。

在下一节中,您将了解现代汽车和家庭中使用的最普遍的网络协议。

以太网和互联网协议

以太网是一个由协议和层组成的框架,在现代网络中被用于几乎与您互动的每个应用中。以太网存在于您的家中,您乘坐的火车上,您乘坐的飞机上,当然也包括您的自动驾驶汽车中。它包括基于网络的通信的物理和协议标准。一切始于不同层的开放系统互连OSI)模型。以下图示说明了 OSI 模型的七层及其每一层的任务:

图 2.21 – OSI 模型的七层

图 2.21 – OSI 模型的七层

每一层都有自己的协议来处理数据。一切始于应用层的原始数据或比特。数据在每一层被处理,以便传递到下一层。每一层将前一层的帧包装成一个新的帧,这就是为什么模型显示了帧越来越大。以下图示说明了每一层的协议:

图 2.22 – OSI 模型的协议

图 2.22 – OSI 模型的协议

我们可以花整整一本书的时间来讨论每一层和协议的细节。相反,我们将专注于您在自动驾驶汽车中与传感器和执行器一起工作时将遇到的两种协议(UDP 和 TCP)。

理解 UDP

用户数据报协议UDP)是激光雷达、摄像头和雷达等传感器非常流行的协议。它是一个无连接协议。等等——如果它是无连接的,它是如何发送数据的? 在这个意义上,无连接只是意味着在发送数据之前,该协议不会验证它是否能够到达目的地。UDP 存在于 OSI 模型的传输层。你可以在以下图中看到,传输层是第一个添加头部的一层:

图 2.23 – 传输层上的 UDP

图 2.23 – 传输层上的 UDP

如果你要给某人送礼物,你希望他们知道关于礼物的哪些信息,以便他们可以确信礼物是给他们的,而不是被换成别人的礼物?你可能会说些像,“Tenretni Olleh,我正在给你发送一套大号奢华彩虹睡衣,希望它们合身。请试穿一下。”这正是 UDP 头部的作用。它存储源端口、目的端口、包括头部在内的数据长度,最后是一个校验和。校验和只是一个在发送数据之前用算法创建的数字,以确保当它被接收时,数据是完整的且未被损坏。这是通过在接收数据上运行相同的算法,并将生成的数字与校验和值进行比较来完成的。这就像给 Tenretni 发送所发送睡衣的图片,这样他们就知道他们收到了正确的礼物。

以下图示展示了 UDP 头部内的字段以及实际的消息本身:

图 2.24 – UDP 头部字段

图 2.24 – UDP 头部字段

端口、插头和插座

在以太网协议中,一个端口可以类比为墙上的电源插座。你将不同的设备插入插座,例如灯具和电视。每个插座一旦连接,就为特定的设备提供电源。同样,端口是创建用于特定设备或协议发送和/或接收数据的一个数字插座。

UDP 头部始终是 8 字节(64 位),而数据(消息)的长度可以达到 65,507 字节。以下图示是一个相关示例,展示了来自一个流行的超高分辨率激光雷达传感器系列的 UDP 数据包的数据(消息)字段大小:

图 2.25 – Ouster 激光雷达 UDP 数据结构

图 2.25 – Ouster 激光雷达 UDP 数据结构

从这里你可以看到,如果你仔细地将所有字节相乘,你会得到 389 个单词 * 4 字节/单词 * 16 个方位/数据包 = 24,896 字节/数据包。这完全在 UDP 数据包的数据限制大小范围内,65,507 字节。为了通过 UDP 发送这些数据,激光雷达传感器需要附加什么数据?你已经猜到了——需要有一个包含源、目的、数据长度和校验和信息的 UDP 头部。

UDP 通常用于流式设备,如激光雷达传感器、摄像头和雷达,因为如果数据没有收到,重新发送数据是没有意义的。想象一下,在激光雷达传感器示例中,你没有收到几个方位角。你会希望重新发送那些数据给你吗?可能不会,因为激光反射的任何东西现在都过去了,并且可能处于不同的位置。使用 UDP 的另一个原因是,由于数据速率高,如果需要重新发送丢失或损坏的数据,这将极大地减慢速度。

在下一节中,我们将讨论一个协议,该协议将解决你可能想要确保每个数据包都进行三次握手的情况。

理解 TCP

如果你打算发送一个命令来转动自动驾驶汽车的转向盘,你会不会对命令从未到达、错误或损坏的情况感到满意?你会对不知道转向执行器是否收到命令的情况感到满意吗?可能不会!

这就是传输控制协议TCP)能为你服务的地方!TCP 的操作方式类似于 UDP...实际上,它完全不同。与 UDP 不同,TCP 是一种基于连接的协议。这意味着每次你想发送数据时,你都需要进行三次握手。这是通过一个称为 SYN-SYN/ACK-ACK 的过程来完成的。让我们将其分解以更好地理解:

  • SYN – 客户端发送一个带有随机选择的初始序列号(x)的 SYN(同步)数据包,该序列号用于计算发送的字节数。它还将 SYN 位标志设置为1(稍后会有更多介绍)。

  • SYN/ACK – 服务器接收到 SYN 数据包:

  1. 它将x增加 1。这成为确认(ACK)编号(x+1),这是它期望接收的下一个字节的编号。

  2. 然后它向客户端发送一个带有 ACK 编号以及服务器自己随机选择的序列号(y)的 SYN/ACK 数据包。

    • ACK – 客户端接收到带有 ACK 编号(x+1)和服务器序列号(y)的 SYN/ACK 数据包:
    1. 它将服务器的初始序列号增加到y+1

    2. 它向服务器发送一个带有 ACK 编号(y+1)和 ACK 位标志设置为1的 ACK 数据包以建立连接。

以下图表说明了连接序列:

图 2.26 – TCP 连接序列图

图 2.26 – TCP 连接序列图

现在连接已经建立,数据可以开始流动。每个发送的数据包都将跟随一个 ACK 数据包,其中包含接收到的字节数加 1,表示数据包已完整接收,并指出它期望接收的下一个字节编号。对于 SYN 和 SYN/ACK 数据包,序列号增加 1,对于 ACK 数据包,增加接收到的有效载荷字节数。

你已经可以看到,为了完成所有这些,头部将需要比 UDP 更多的字段。以下图表说明了 TCP 头部的字段:

图 2.27 – TCP 头部字段

图 2.27 – TCP 头字段

让我们逐个字段及其用途进行解析:

  • 源端口:这是数据包发送的端口。这通常是一个随机分配的端口号。

  • 22。可以在以下链接找到知名端口的列表:www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml.

  • 序列号:这是有效载荷中第一个发送的字节的编号;对于 SYN 和 SYN/ACK 数据包,它是随机选择的初始序列号。

  • 确认号:这是已接收的字节数加一,表示预期的下一个字节编号。

  • 数据偏移:这是 TCP 头的长度,即有效载荷之前的偏移量。

  • 保留:这些是未使用但为未来协议改进预留的位。

  • (01)。这用于标记数据包为紧急。

  • ACK 位标志,当发送有效的确认号时设置为 1

  • 当数据应立即推送到应用程序时设置为 1

  • 当连接需要重置时设置为 1

  • 当初始化 SYN-SYN/ACK 连接建立过程时设置为 1。它表示序列号字段中有一个有效的序列号。

  • 当所有数据发送后应关闭连接时设置为 1

  • 窗口:这是接收端在丢失数据之前可以接受的缓冲区大小。

  • payload+header 用于验证接收到的数据是否有效且未更改。

  • URG 位标志设置为 1

TCP 使用所有这些头部信息来确保所有数据都被接收、验证和确认。如果数据丢失,可以使用最后一个有效的序列号重新发送数据。现在您可以自信地通过以太网发送转向命令,知道您不会冲下悬崖!

汽车主要使用 CAN...

虽然我们在这里以转向为例说明了 TCP,但您通常会发现在工厂中,车辆控制命令是通过以太网总线从 CAN 总线发送的。然而,越来越多的自动驾驶汽车创造者正依赖以太网,因为它具有更高的数据吞吐量和安全性。未来,人们讨论将工厂车辆迁移到以太网总线。军用飞机已经开始了这样的行动!

好吧——天哪,这有很多东西要理解。不过,不用担心:现在您已经看了一次并理解了,您就可以依赖开源工具在将来解析这些信息了。您会发现这很有用,当事情开始出错并且您需要调试数据流时。

说到这个,Wireshark 是一款用于在您的网络上嗅探以太网数据包并查看信息流以进行调试和测试的出色工具。您可以在 www.wireshark.org/ 找到有关安装和使用的所有信息。

如你所见,TCP 是一种强大的基于连接的、高度可靠且安全的协议。现在,出去开始使用本章末尾列出的开源工具使用以太网协议吧!

摘要

恭喜你,你已经和你的新朋友——langis 和信号一起完成了你的任务!你经历了一段相当刺激的冒险!你与以电磁波和感应电流形式出现的鬼怪战斗。在旅途中,你学到了很多关于串行与并行数据传输;数字与模拟信号;以及 UART、I2C、SPI、CAN、UDP 和 TCP 等协议及其秘密解码环的知识!你现在拥有了将传感器和执行器集成到你的真实自动驾驶汽车中所需的知识。

在下一章中,你将学习如何使用 OpenCV 检测道路上的车道,这是确保你的自动驾驶汽车安全合法运行的重要技能!

问题

在阅读本章后,你应该能够回答以下问题:

  1. 每个协议需要多少根线,它们的名称是什么?

  2. 有哪些方法可以减少信号中的噪声?

  3. 串行和并行数据传输有什么区别?

  4. 哪些协议使用总线架构?

  5. 哪些协议包含时钟信号?

  6. 哪个协议被广泛用于将 GPS 信息发送到其他传感器?

进一步阅读

开源协议工具

你还可以参考以下资源,了解更多关于使用这些协议进行编程的工具:

第三章:第三章:车道检测

本章将展示使用计算机视觉(特别是 OpenCV)可以实现的一些令人难以置信的事情:车道检测。你将学习如何分析图像并逐步构建更多的视觉知识,应用多种过滤技术,用对图像的更好理解来替换噪声和近似,直到你能够检测到直道上或转弯处的车道位置,然后我们将把这个流程应用到视频中,以突出道路。

你会看到这种方法依赖于几个可能在实际世界中不成立的假设,尽管它可以进行调整以纠正这一点。希望你会觉得这一章很有趣。

我们将涵盖以下主题:

  • 在道路上检测车道

  • 颜色空间

  • 透视校正

  • 边缘检测

  • 阈值

  • 直方图

  • 滑动窗口算法

  • 多项式拟合

  • 视频滤波

到本章结束时,你将能够设计一个流程,使用 OpenCV 检测道路上的车道线。

技术要求

我们的车道检测流程需要相当多的代码。我们将解释主要概念,并且你可以在 GitHub 上找到完整的代码,链接为github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter3

对于本章中的说明和代码,你需要以下内容:

  • Python 3.7

  • OpenCV-Python 模块

  • NumPy 模块

  • Matplotlib 模块

为了识别车道,我们需要一些图像和一段视频。虽然很容易找到一些开源数据库来使用,但它们通常仅供非商业用途。因此,在这本书中,我们将使用两个开源项目生成的图像和视频:CARLA,一个用于自动驾驶任务的模拟器,以及 Speed Dreams,一个开源视频游戏。所有技术也适用于现实世界的视频,我们鼓励你尝试在 CULane 或 KITTI 等公共数据集上使用它们。

本章的“代码在行动”视频可以在以下位置找到:

bit.ly/37pjxnO

如何执行阈值操作

对于人类来说,跟随车道线很容易,但对于计算机来说,这并不是那么简单。一个问题是一个道路图像包含太多的信息。我们需要简化它,只选择我们感兴趣的部分。我们将只分析图像中车道线部分,但我们也需要将车道线从图像的其余部分分离出来,例如,使用颜色选择。毕竟,道路通常是黑色或深色的,而车道通常是白色或黄色的。

在接下来的几节中,我们将分析不同的颜色空间,以查看哪一个对阈值化最有用。

阈值在不同颜色空间上的工作原理

从实际角度来看,颜色空间是分解图像颜色的一种方式。您可能最熟悉 RGB,但还有其他颜色空间。

OpenCV 支持多种颜色空间,作为此流程的一部分,我们需要从各种颜色空间中选择两个最佳通道。我们为什么要使用两个不同的通道?有两个原因:

  • 对于白色车道来说,一个好的颜色空间可能不适合黄色车道。

  • 当存在困难的帧(例如,道路上存在阴影或车道变色时),一个通道可能比另一个通道受影响较小。

对于我们的示例来说,这可能不是严格必要的,因为车道总是白色的,但在现实生活中这绝对是有用的。

我们现在将看到我们的测试图像在不同颜色空间中的外观,但请记住,您的情况可能不同。

RGB/BGR

起始点将是以下图像:

图 3.1 – 参考图像,来自 Speed Dreams

图 3.1 – 参考图像,来自 Speed Dreams

图像当然可以分解为三个通道:红色、绿色和蓝色。正如我们所知,OpenCV 以 BGR(意味着,第一个字节是蓝色通道,而不是红色通道)存储图像,但从概念上讲,没有区别。

这就是分离后的三个通道:

图 3.2 – BGR 通道:蓝色、绿色和红色通道

图 3.2 – BGR 通道:蓝色、绿色和红色通道

它们看起来都很好。我们可以尝试通过选择白色像素来分离车道。由于白色颜色是(255, 255, 255),我们可以留出一些余地并选择高于 180 的颜色的像素。为了执行此操作,我们需要创建一个与所选通道相同大小的黑色图像,然后在原始通道中所有高于 180 的像素上涂成白色:

img_threshold = np.zeros_like(channel)
img_threshold [(channel >= 180)] = 255

这就是输出看起来像这样:

图 3.3 – BGR 通道:蓝色、绿色和红色通道,阈值为 180 以上

图 3.3 – BGR 通道:蓝色、绿色和红色通道,阈值为 180 以上

它们看起来都很好。红色通道也显示了汽车的一部分,但由于我们不会分析图像的这一部分,所以这不是问题。由于白色在红色、绿色和蓝色通道中具有相同的值,所以在所有三个通道上都能看到车道是预料之中的。然而,对于黄色车道来说,情况并非如此。

我们选择的阈值非常重要,不幸的是,它取决于车道使用的颜色和道路情况;光线条件和阴影也会影响它。

下图显示了完全不同的阈值,20-120:

图 3.4 – BGR 通道:蓝色、绿色和红色通道,阈值为 20-120

图 3.4 – BGR 通道:蓝色、绿色和红色通道,阈值为 20-120

您可以使用以下代码选择 20-120 范围内的像素:

img_threshold[(channel >= 20) & (channel <= 120)] = 255

只要考虑车道是黑色的,图像可能仍然可用,但这并不推荐。

HLS

HLS 颜色空间将颜色分为色调、亮度和饱和度。结果有时会令人惊讶:

图 3.5 – HLS 通道:色调、亮度和饱和度

图 3.5 – HLS 通道:色调、亮度和饱和度

色调通道相当糟糕,噪声大,分辨率低,而亮度似乎表现良好。饱和度似乎无法检测到我们的车道。

让我们尝试一些阈值:

图 3.6 – HLS 通道:色调、亮度和饱和度,阈值高于 160

图 3.6 – HLS 通道:色调、亮度和饱和度,阈值高于 160

阈值显示,亮度仍然是一个好的候选者。

HSV

HSV 颜色空间将颜色分为色调、饱和度和值,它与 HLS 相关。因此,结果是类似于 HLS 的:

图 3.7 – HSV 通道:色调、饱和度和值

图 3.7 – HSV 通道:色调、饱和度和值

色调和不饱和度对我们来说没有用,但应用阈值后的亮度看起来不错:

图 3.8 – HSV 通道:色调、饱和度和值,阈值高于 160

图 3.8 – HSV 通道:色调、饱和度和值,阈值高于 160

如预期,值的阈值看起来不错。

LAB

LAB(CIELAB 或 CIE Lab)颜色空间将颜色分为 L(亮度,从黑色到白色)、a(从绿色到红色)和 b(从蓝色到黄色):

图 3.9 – LAB 通道:L*、a*和 b*

图 3.9 – LAB 通道:L、a和 b*

L看起来不错,而 a和 b*对我们来说没有用:

图 3.10 – LAB 通道:L*、a*和 b*,阈值高于 160

图 3.10 – LAB 通道:L、a和 b*,阈值高于 160

YCbCr

YCbCr 是我们将要分析的最后一个颜色空间。它将图像分为亮度(Y)和两个色度分量(Cb 和 Cr):

图 3.11 – YCbCr 通道:Y、Cb 和 Cr

图 3.11 – YCbCr 通道:Y、Cb 和 Cr

这是应用阈值后的结果:

图 3.12 – YCbCr 通道:Y、Cb 和 Cr,阈值高于 160

图 3.12 – YCbCr 通道:Y、Cb 和 Cr,阈值高于 160

阈值证实了亮度通道的有效性。

我们的选择

经过一些实验,似乎绿色通道可以用于边缘检测,而 HLS 空间中的 L 通道可以用作额外的阈值,因此我们将坚持这些设置。这些设置对于黄色线条也应该适用,而不同的颜色可能需要不同的阈值。

透视校正

让我们退一步,从简单开始。我们可以拥有的最简单的情况是直线车道。让我们看看它看起来如何:

图 3.13 – 直线车道,来自 Speed Dreams

图 3.13 – 直线车道,来自 Speed Dreams

如果我们飞越道路,并从鸟瞰的角度观察,车道线将是平行的,但在图片中,由于透视,它们并不是平行的。

透视取决于镜头的焦距(焦距较短的镜头显示的透视更强)和摄像机的位置。一旦摄像机安装在汽车上,透视就固定了,因此我们可以考虑这一点并校正图像。

OpenCV 有一个计算透视变换的方法:getPerspectiveTransform()

它需要两个参数,都是四个点的数组,用于标识透视的梯形。一个数组是源,一个数组是目标。这意味着可以使用相同的方法通过交换参数来计算逆变换:

perspective_correction = cv2.getPerspectiveTransform(src, dst)
perspective_correction_inv = cv2.getPerspectiveTransform(dst, src)

我们需要选择车道线周围的区域,以及一个小边距:

图 3.14 – 带有车道线周围感兴趣区域的梯形

图 3.14 – 带有车道线周围感兴趣区域的梯形

在我们的情况下,目标是一个矩形(因为我们希望它变得笔直)。图 3.14显示了带有原始视角的绿色梯形(前一段代码中的src变量)和白色矩形(前一段代码中的dst变量),这是期望的视角。请注意,为了清晰起见,它们被绘制为重叠的,但矩形的坐标作为参数传递时发生了偏移,就像它从X坐标 0 开始一样。

现在我们可以应用透视校正并获取我们的鸟瞰视图:

cv2.warpPerspective(img, perspective_correction, warp_size, flags=cv2.INTER_LANCZOS4)

warpPerspective()方法接受四个参数:

  • 源图像。

  • getPerspectiveTransform()获得的变换矩阵。

  • 输出图像的大小。在我们的情况下,宽度与原始图像相同,但高度只是梯形/矩形的宽度。

  • 一些标志,用于指定插值。INTER_LINEAR是一个常见的选项,但我建议进行实验,并尝试使用INTER_LANCZOS4

这就是使用INTER_LINEAR进行扭曲的结果:

图 3.15 – 使用 INTER_LINEAR 扭曲

图 3.15 – 使用 INTER_LINEAR 扭曲

这是使用INTER_LANCZOS4的结果:

图 3.16 – 使用 INTER_LANCZOS4 扭曲

图 3.16 – 使用 INTER_LANCZOS4 扭曲

它们非常相似,但仔细观察会发现,使用LANCZOS4重采样进行的插值更清晰。我们将在后面看到,在管道的末端,差异是显著的。

在两张图片中都很清楚的是,我们的线条现在是垂直的,这直观上可能有助于我们。

我们将在下一节中看到如何利用这张图像。

边缘检测

下一步是检测边缘,我们将使用绿色通道来完成这项工作,因为在我们的实验中,它给出了良好的结果。请注意,您需要根据您计划运行软件的国家/地区以及许多不同的光照条件对图像和视频进行实验。很可能会根据线条的颜色和图像中的颜色,您可能需要选择不同的通道,可能是来自另一个颜色空间;您可以使用cvtColor()等函数将图像转换为不同的颜色空间:

img_hls = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HLS).astype(np.float)

我们将坚持使用绿色。

OpenCV 有几种计算边缘检测的方法,我们将使用 Scharr,因为它表现相当不错。Scharr 计算导数,因此它检测图像中的颜色差异。我们对 X 轴感兴趣,并且希望结果是一个 64 位的浮点数,所以我们的调用将如下所示:

edge_x = cv2.Scharr(channel, cv2.CV_64F, 1, 0)

由于 Scharr 计算导数,值可以是正的也可以是负的。我们感兴趣的并不是符号,而只是存在边缘的事实。因此,我们将取绝对值:

edge_x = np.absolute(edge_x)

另一个问题是我们期望的单通道图像上的 0-255 值范围内的值没有界限,而值是浮点数,而我们需要一个 8 位的整数。我们可以通过以下行解决这两个问题:

edge_x = np.uint8(255 * edge_x / np.max(edge_x))

这是结果:

图 3.17 – 使用 Scharr 进行边缘检测,缩放并取绝对值

图 3.17 – 使用 Scharr 进行边缘检测,缩放并取绝对值

在这一点上,我们可以应用阈值将图像转换为黑白,以便更好地隔离车道像素。我们需要选择要选择的像素强度,在这种情况下,我们可以选择 20-120;我们只选择至少具有 20 的强度值且不超过 120 的像素:

binary = np.zeros_like(img_edge)
binary[img_edge >= 20] = 255

zeros_like()方法创建一个全零数组,其形状与图像相同,第二行将强度在 20 到 120 之间的所有像素设置为 255。

这是结果:

图 3.18 – 应用 20 阈值后的结果

图 3.18 – 应用 20 阈值后的结果

现在,车道非常明显,但有一些噪声。我们可以通过提高阈值来减少噪声:

binary[img_edge >= 50] = 255

这就是输出显示的样子:

图 3.19 – 应用 50 阈值后的结果

图 3.19 – 应用 50 阈值后的结果

现在,噪声更少,但我们失去了顶部的线条。

现在,噪声更少,但我们失去了顶部的线条。

插值阈值

在实践中,我们不必在用很多噪声选择整条线与在检测到部分线的同时减少噪声之间做出选择。我们可以对底部(那里我们分辨率更高,图像更清晰,噪声更多)应用更高的阈值,而在顶部(那里我们得到的对比度更低,检测更弱,噪声更少,因为像素被透视校正拉伸,自然地模糊了)应用较低的阈值。我们可以在阈值之间进行插值:

threshold_up = 15 threshold_down = 60 threshold_delta = threshold_down-threshold_up for y in range(height):  binary_line = binary[y,:]  edge_line = channel_edge[y,:]  threshold_line = threshold_up + threshold_delta * y/height   binary_line[edge_line >= threshold_line] = 255

让我们看看结果:

图 3.20 – 应用插值阈值后的结果,从 15 到 60

图 3.20 – 应用插值阈值后的结果,从 15 到 60

现在,我们可以在底部有更少的噪声,并在顶部检测到更弱的信号。然而,虽然人类可以直观地识别车道,但对于计算机来说,它们仍然只是图像中的像素,所以还有工作要做。但我们极大地简化了图像,并且我们正在取得良好的进展。

组合阈值

如我们之前提到的,我们也想在另一个通道上使用阈值,而不进行边缘检测。我们选择了 HLS 的 L 通道。

这是超过 140 的阈值的结果:

图 3.21 – L 通道,阈值超过 140

图 3.21 – L 通道,阈值超过 140

还不错。现在,我们可以将其与边缘结合起来:

图 3.22 – 两个阈值的组合

图 3.22 – 两个阈值的组合

结果更嘈杂,但也更稳健。

在继续前进之前,让我们引入一个带有转弯的图片:

图 3.23 – 转弯车道,来自 Speed Dreams

图 3.23 – 转弯车道,来自 Speed Dreams

这是阈值:

图 3.24 – 转弯车道,阈值之后

图 3.24 – 转弯车道,阈值之后

它仍然看起来不错,但我们可以看到,由于转弯,我们不再有垂直线。事实上,在图像的顶部,线条基本上是水平的。

使用直方图查找车道

我们如何或多或少地理解车道在哪里?对于人类来说,答案是简单的:车道是一条长线。但对于计算机来说呢?

如果我们谈论垂直线,一种方法可以是计算某一列中白色的像素。但是,如果我们检查转弯的图像,那可能不起作用。然而,如果我们把注意力减少到图像的底部,线条就有点更垂直了:

图 3.25 – 转弯车道,阈值之后,底部部分

图 3.25 – 转弯车道,阈值之后,底部部分

我们现在可以按列计数像素:

partial_img = img[img.shape[0] // 2:, :]  # Select the bottom part
hist = np.sum(partial_img, axis=0)  # axis 0: columns direction

要将直方图保存为文件中的图形,我们可以使用 Matplotlib:

import matplotlib.pyplot as plt

plt.plot(hist)plt.savefig(filename)plt.clf()

我们得到了以下结果:

图 3.26 – 左:直线路道的直方图,右:转弯车道的直方图

图 3.26 – 左:直线车道的直方图,右:转弯车道的直方图

直方图上的 X 坐标代表像素;由于我们的图像分辨率为 1024x600,直方图显示了 1,024 个数据点,峰值集中在车道所在像素周围。

如我们所见,在直线车道的情况下,直方图清楚地识别了两条线;在转弯的情况下,直方图不太清晰(因为线条转弯,因此白色像素在周围稍微分散),但它仍然可用。我们还可以看到,在虚线线的情况下,直方图中的峰值不太明显,但它仍然存在。

这看起来很有希望!

现在,我们需要一种方法来检测两个峰值。我们可以使用 NumPy 的argmax()函数,它返回数组中最大元素的索引,这是我们其中一个峰值。然而,我们需要两个。为此,我们可以将数组分成两半,并在每一半中选择一个峰值:

size = len(histogram)
max_index_left = np.argmax(histogram[0:size//2])
max_index_right = np.argmax(histogram[size//2:]) + size//2

现在我们有了索引,它们代表峰值的 X 坐标。这个值本身(例如,histogram[index])可以被认为是识别车道线的置信度,因为更多的像素意味着更高的置信度。

滑动窗口算法

当我们在取得进展时,图像仍然有一些噪声,这意味着有一些像素可以降低精度。此外,我们只知道线条的起始位置。

解决方案是专注于线条周围的区域——毕竟,没有必要在整个扭曲的图像上工作;我们可以从线条的底部开始,然后“跟随”它。这可能是图像胜过千言万语的例子,所以这是我们想要达到的:

图 3.27 – 顶部:滑动窗口,底部:直方图

图 3.27 – 顶部:滑动窗口,底部:直方图

在图 3.27 的上部,每个矩形代表一个感兴趣窗口。每个通道底部的第一个窗口位于直方图相应峰值的中心。然后,我们需要一种“跟随线条”的方法。每个窗口的宽度取决于我们想要的边距,而高度取决于我们想要的窗口数量。这两个数字可以改变以达到更好的检测(减少不需要的点以及因此的噪声)和检测更困难转弯的可能性,半径更小(这将要求窗口更快地重新定位)。

由于此算法需要相当多的代码,我们将专注于左侧车道以保持清晰,但相同的计算也需要对右侧车道进行。

初始化

我们只对被阈值选择的像素感兴趣。我们可以使用 NumPy 的nonzero()函数:

non_zero = binary_warped.nonzero()
non_zero_y = np.array(non_zero[0])
non_zero_x = np.array(non_zero[1])

non_zero变量将包含白色像素的坐标,然后non_zero_x将包含 X 坐标,而non_zero_y将包含 Y 坐标。

我们还需要设置 margin,即允许车道移动的距离(例如,滑动窗口窗口宽度的一半),以及 min_pixels,这是我们想要检测以接受滑动窗口新位置的最小像素数。低于此阈值,我们不会更新它:

margin = 80 min_pixels = 50 

滑动窗口的坐标

left_x 变量将包含左侧车道的位置,我们需要用从直方图获得的价值初始化它。

在设置好场景后,我们现在可以遍历所有窗口,我们将使用的索引变量是 idx_windowX 范围是从最后的位置计算的,加上 margin

win_x_left_min = left_x - margin
win_x_left_max = left_x + margin

Y 范围由我们正在分析的窗口的索引确定:

win_y_top = img_height - idx_window * window_height win_y_bottom = win_y_top + window_height

现在,我们需要选择白色像素(来自 non_zero_xnon_zero_y)并且被我们正在分析的窗口所约束的像素。

NumPy 数组可以使用重载运算符进行过滤。为了计算所有在 win_y_bottom 之上的 Y 坐标,我们可以简单地使用以下表达式:

non_zero_y >= win_y_bottom

结果是一个数组,选中的像素为 True,其他像素为 False

但我们需要的是 win_y_topwin_y_bottom 之间的像素:

(non_zero_y >= win_y_bottom) & (non_zero_y < win_y_top)

我们还需要 X 坐标,它必须在 win_x_left_minwin_x_left_max 之间。由于我们只需要计数,我们可以添加一个 nonzero() 调用:

non_zero_left = ((non_zero_y >= win_y_bottom) &                 (non_zero_y < win_y_top) &                  (non_zero_x >= win_x_left_min) & 
                 (non_zero_x < win_x_left_max)).nonzero()[0]

我们需要选择第一个元素,因为我们的数组位于另一个只有一个元素的数组内部。

我们还将把这些值保存在一个变量中,以便稍后绘制车道线:

left_lane_indexes.append(non_zero_left)

现在,我们只需要更新左侧车道的位置,取位置的平均值,但前提是有足够多的点:

if len(non_zero_left) > min_pixels:
    left_x = np.int(np.mean(non_zero_x[non_zero_left]))

多项式拟合

现在,我们可能已经选择了成千上万的点,但我们需要理解它们并得到一条线。为此,我们可以使用 polyfit() 方法,该方法可以用指定次数的多项式来近似一系列点;对于二次多项式对我们来说就足够了:

x_coords = non_zero_x[left_lane_indexes]
y_coords = non_zero_y[left_lane_indexes]
left_fit = np.polynomial.polynomial.polyfit(y_coords, x_coords, 2)

注意

请注意,polyfit() 函数接受参数的顺序为 (X, Y),而我们所提供的顺序为 (Y, X)。我们这样做是因为按照数学惯例,在多项式中,X 是已知的,而 Y 是基于 X 计算得出的(例如,Y = X² + 3X + 5*)。然而,我们知道 Y 并需要计算 X,因此我们需要以相反的顺序提供它们。

我们几乎完成了。

Y 坐标只是一个范围:

ploty = np.array([float(x) for x in range(binary_warped.shape[0])])

然后,我们需要从 Y 计算出 X,使用二次多项式的通用公式(在 XY 上反转):

x = Ay² + By + C

这是代码:

Left_fitx = left_fit[2] * ploty ** 2 + left_fit[1] * ploty + left_fit[0]

这是我们现在的位置:

图 3.28 – 在扭曲图像上绘制的车道

图 3.28 – 在扭曲图像上绘制的车道

我们现在可以使用逆透视变换调用 perspectiveTransform() 来移动像素到图像中的相应位置。这是最终结果:

图 3.29 – 图像上检测到的车道

图 3.29 – 图像上检测到的车道

恭喜!这并不特别容易,但现在你可以在正确的条件下检测到帧上的车道。不幸的是,并不是所有的帧都足够好来进行这项检测。让我们在下一节中看看我们如何可以使用视频流的时序演变来过滤数据并提高精度。

增强视频

从计算角度来看,实时分析视频流可能是一个挑战,但通常,它提供了提高精度的可能性,因为我们可以在前一帧的知识基础上构建并过滤结果。

现在我们将看到两种技术,当处理视频流时,可以使用这些技术以更高的精度检测车道。

部分直方图

如果我们假设在前几帧中正确检测到了车道,那么当前帧上的车道应该处于相似的位置。这个假设受汽车速度和相机帧率的影响:汽车越快,车道变化就越大。相反,相机越快,车道在两帧之间移动就越少。在现实中的自动驾驶汽车中,这两个值都是已知的,因此如果需要,可以将其考虑在内。

从实际的角度来看,这意味着我们可以限制我们分析直方图的区域,以避免错误检测,只分析一些之前帧平均值的周围的一些直方图像素(例如,30)。

滚动平均

我们检测的主要结果是每个车道的多项式拟合的三个值。遵循上一节相同的原理,我们可以推断出它们在帧之间不会变化太多,因此我们可以考虑一些之前帧的平均值,以减少噪声。

有一种称为指数加权移动平均(或滚动平均)的技术,可以用来轻松计算值流中最后一些值的近似平均值。

给定 beta,一个大于零且通常接近一的参数,移动平均可以按以下方式计算:

moving_average = beta * prev_average + (1-beta)*new_value

作为一个指示,影响平均值的帧数如下:

1 / (1 - beta)

因此,beta = 0.9 会平均 10 帧,而 beta = 0.95 会平均 20 帧。

这就结束了本章。我邀请您在 GitHub 上查看完整的代码,并尝试对其进行操作。您可以在那里找到一些真实的视频片段并尝试识别车道。

并且不要忘记应用相机标定,如果可能的话。

摘要

在本章中,我们构建了一个很好的车道检测流程。首先,我们分析了不同的颜色空间,如 RGB、HLS 和 HSV,以查看哪些通道对检测车道更有用。然后,我们使用getPerspectiveTransform()进行透视校正,以获得鸟瞰图并使道路上的平行线在分析的图像上也看起来平行。

我们使用Scharr()进行边缘检测,以检测边缘并使我们的分析比仅使用颜色阈值更稳健,并将两者结合起来。然后我们计算直方图以检测车道开始的位置,并使用“滑动窗口”技术来“跟随”图像中的车道。

然后,我们使用polyfit()在检测到的像素上拟合一个二次多项式,使它们有意义,并使用函数返回的系数来生成我们的曲线,在它们上应用反向透视校正后。最后,我们讨论了两种可以应用于视频流以提高精度的技术:部分直方图和滚动平均值。

使用所有这些技术一起,你现在可以构建一个能够检测道路上车道线的管道。

在下一章中,我们将介绍深度学习和神经网络,这些是强大的工具,我们可以使用它们来完成更复杂的计算机视觉任务。

问题

  1. 你能列举一些除了 RGB 之外的颜色空间吗?

  2. 为什么我们要应用透视校正?

  3. 我们如何检测车道开始的位置?

  4. 你可以使用哪种技术来跟随图像中的车道

  5. 如果你有很多点大致形成一个车道,你如何将它们转换成一条线?

  6. 你可以使用哪个函数进行边缘检测?

  7. 你可以使用什么来计算最后N个位置的平均值?

第二部分:使用深度学习和神经网络改进自动驾驶汽车的工作方式

本节将引导你进入深度学习的世界,希望用相对简单和简短的代码所能实现的事情给你带来惊喜。

本节包含以下章节:

  • 第四章**,使用神经网络进行深度学习

  • 第五章**,深度学习工作流程

  • 第六章**,改进你的神经网络

  • 第七章**,检测行人和交通信号灯

  • 第八章**,行为克隆

  • 第九章**,语义分割

第四章:第四章:使用神经网络的深度学习

本章是使用 Keras 介绍神经网络。如果你已经使用过 MNIST 或 CIFAR-10 图像分类数据集,可以自由跳过。但如果你从未训练过神经网络,本章可能对你来说有一些惊喜。

本章非常实用,旨在让你很快就能玩起来,我们将尽可能跳过理论,学习如何以高精度识别由单个数字组成的手写数字。我们在这里所做以及更多内容的理论将在下一章中介绍。

我们将涵盖以下主题:

  • 机器学习

  • 神经网络及其参数

  • 卷积神经网络

  • Keras,一个深度学习框架

  • MNIST 数据集

  • 如何构建和训练神经网络

  • CIFAR-10 数据集

技术要求

对于本章中的说明和代码,你需要以下内容:

  • Python 3.7

  • NumPy

  • Matplotlib

  • TensorFlow

  • Keras

  • OpenCV-Python 模块

  • 一个 GPU(推荐)

本书代码可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter4

本章的“代码实战”视频可以在以下位置找到:

bit.ly/3jfOoWi

理解机器学习和神经网络

根据维基百科,机器学习是“通过经验自动改进的计算机算法的研究。”

实际上,至少对我们来说,这意味着算法本身的重要性只是适度的,而关键的是我们提供给这个算法的数据,以便它能够学习:我们需要训练我们的算法。换一种说法,只要我们为当前任务提供适当的数据,我们就可以在许多不同的情况下使用相同的算法。

例如,在本章中,我们将开发一个能够识别 0 到 9 之间手写数字的神经网络;很可能,完全相同的神经网络可以用来识别 10 个字母,并且通过微小的修改,它可以识别所有字母或甚至不同的对象。实际上,我们将基本上以原样重用它来识别 10 个对象。

这与常规编程完全不同,在常规编程中,不同的任务通常需要不同的代码;为了改进结果,我们需要改进代码,而且我们可能根本不需要数据来使算法可用(使用真实数据)。

话虽如此,这并不意味着只要输入好的数据,神经网络的结果就总是好的:困难的任务需要更高级的神经网络才能表现良好。

为了明确起见,虽然算法(即神经网络模型)在传统编程中不如代码重要,但如果想要获得非常好的结果,它仍然很重要。事实上,如果架构错误,你的神经网络可能根本无法学习。

神经网络只是你可以用来开发机器学习模型的各种工具之一,但这是我们将会关注的。深度学习的准确性通常相当高,你可能会发现,在那些使用不太精确的机器学习技术的应用中,数据量和处理成本有很大的限制。

深度学习可以被认为是机器学习的一个子集,其中的计算由多个计算层执行,这是名称中的“深度”部分。从实际的角度来看,深度学习是通过神经网络实现的。

这引出了一个问题:神经网络究竟是什么?

神经网络

神经网络在一定程度上受到我们大脑的启发:我们大脑中的神经元是一个“计算节点”,它连接到其他神经元。在执行计算时,我们大脑中的每个神经元“感知”它所连接的神经元的兴奋状态,并使用这些外部状态来计算它自己的状态。神经网络中的神经元(感知器)基本上做的是同样的,但这里的相似之处到此为止。为了明确起见,感知器不是神经元的模拟,但它只是受到了启发。

以下是一个小型神经网络,其中包含其神经元:

图 4.1 – 一个神经网络

图 4.1 – 一个神经网络

第一层是输入(例如,图像的像素)和输出层是结果(例如,分类)。隐藏层是计算发生的地方。通常,你会有更多的隐藏层,而不仅仅是单个。每个输入也可以称为特征,在 RGB 图像的情况下,特征通常是像素的单个通道。

在前馈神经网络中,一个层的神经元只与前一层的神经元和下一层的神经元连接:

图 4.2 – 一个神经网络

图 4.2 – 一个神经网络

但神经元究竟是什么?

神经元

神经元是一个计算节点,它根据某些输入产生输出。至于这些输入和输出是什么——嗯,这取决于。我们稍后会回到这个点。

以下是一个神经网络中典型神经元的表示:

图 4.3 – 神经网络中单个神经元的示意图。©2016 Wikimedia Commons

图 4.3 – 神经网络中单个神经元的示意图。©2016 Wikimedia Commons

这需要一些解释。神经元执行的计算可以分为两部分:

  • 转换函数计算每个输入乘以其权重的总和(只是一个数字);这意味着神经元的状况取决于其输入神经元的状况,但不同的神经元提供不同的贡献。这仅仅是一个线性操作:

img/Formula_04_001.jpg

  • 激活函数应用于转换函数的结果,并且它应该是一个非线性操作,通常有一个阈值。由于性能出色,我们将经常使用的一个函数被称为修正线性单元(ReLU)

Figure 4.4 – Two activation functions: Softplus and ReLU

图 4.4 – 两种激活函数:Softplus 和 ReLU

通常还有一个偏差,这是一个用于移动激活函数的值。

线性函数与非线性函数的组合是非线性的,而两个线性函数的组合仍然是线性的。这一点非常重要,因为它意味着如果激活是线性的,那么神经元的输出将是线性的,不同层的组合也将是线性的。因此,整个神经网络将是线性的,因此等同于单层。

在激活函数中引入非线性操作,使得网络能够计算越来越复杂的非线性函数,随着层数的增加,这些函数变得越来越复杂。这是最复杂的神经网络实际上可以有数百层的原因之一。

参数

偏差和权重被称为参数,因为它们不是固定的,需要根据任务进行调整。我们在训练阶段进行这一操作。为了明确起见,训练阶段的整个目的就是为我们的任务找到这些参数的最佳可能值。

这具有深远的影响,因为它意味着同一个神经网络,具有不同的参数,可以解决不同的问题——非常不同的问题。当然,技巧是找到这些参数的最佳值(或一个近似值)。如果你想知道一个典型的神经网络可以有多少参数,答案是数百万。幸运的是,这个过程,即训练,可以自动化。

想象神经网络的一个替代方法是将其视为一个庞大的方程组系统,而训练阶段则是一个尝试找到其近似解的过程。

深度学习的成功

你可能已经注意到,深度学习在过去几年中经历了爆炸性的增长,但神经网络实际上并不是什么新鲜事物。我记得在阅读了一本关于神经网络的书之后,我尝试编写一个神经网络(并且失败得很惨!)那是在 20 多年前。事实上,它们可以追溯到 1965 年,有些理论甚至比那还要早 20 年。

多年前,它们基本上被当作一种好奇心,因为它们计算量太大,不实用。

然而,快进几十年,深度学习成为了新的热门领域,这得益于一些关键性的进步:

  • 计算机运行得更快,并且有更多的 RAM 可用。

  • 可以使用 GPU 来使计算更快。

  • 现在互联网上有许多数据集可以轻松用于训练神经网络。

  • 现在互联网上有许多教程和在线课程专门介绍深度学习。

  • 现在有多个优秀的开源库用于神经网络。

  • 架构已经变得更加优秀和高效。

这正是使神经网络更具吸引力的完美风暴,而且有许多应用似乎都在等待深度学习,例如语音助手和当然,自动驾驶汽车。

有一种特殊的神经网络特别擅长理解图像内容,我们将特别关注它们:卷积神经网络。

学习卷积神经网络

如果你观察一个经典的神经网络,你可以看到第一层由输入组成,它们排列成一行。这不仅是一个图形表示:对于一个经典神经网络来说,输入是输入,它应该独立于其他输入。如果你试图根据大小、ZIP 代码和楼层号预测公寓的价格,这可能没问题,但对于图像来说,像素有邻居,保持这种邻近信息似乎很直观。

卷积神经网络CNNs)正好解决了这个问题,结果发现它们不仅能够高效地处理图像,而且还可以成功应用于自然语言处理。

CNN 是一种至少包含一个卷积层的神经网络,它受到动物视觉皮层的启发,其中单个神经元只对视野中一个小区域内的刺激做出反应。让我们看看卷积究竟是什么。

卷积

卷积基于的概念,这是一个应用于某些像素以得到单个新像素的矩阵。核可以用于边缘检测或对图像应用过滤器,并且如果你愿意,你通常可以在图像处理程序中定义你的核。以下是一个 3x3 的单位核,它以原样复制图像,并且我们正在将其应用于一个小图像:

图 4.5 – 图像的一部分、一个 3x3 的单位核以及结果

图 4.5 – 图像的一部分、一个 3x3 的单位核以及结果

想象一下,在每个核的元素后面放置一个像素,并将它们相乘,然后将结果相加以得到新像素的值;显然,除了中央像素外,其他所有像素都会得到零,中央像素保持不变。这个核保留了中间像素的值,丢弃了所有其他像素。如果你将这个卷积核在整个图片上滑动,你会得到原始图像:

图 4.6 – 单位卷积 – 只复制图像

图 4.6 – 单位卷积 – 只复制图像

你可以看到,当卷积核在图像上滑动时,像素保持不变地复制。你还可以看到分辨率降低了,因为我们使用了 valid 填充。

这是另一个例子:

图 4.7 – 图像的一部分、3x3 的核和结果

图 4.7 – 图像的一部分、3x3 的核和结果

其他核可能比恒等核更有趣。下面的核(在左侧)可以检测边缘,如右侧所示:

图 4.8 – 核检测边缘

图 4.8 – 核检测边缘

如果你对核感兴趣,请继续使用 OpenCV 并享受乐趣:

img = cv2.imread("test.jpg")kernel = np.array(([-1, -1, -1], [-1, 8, -1], [-1, -1, -1]))dst = cv2.filter2D(img,-1,kernel)cv2.imshow("Kernel", cv2.hconcat([img, dst]))cv2.waitKey(0)

核不需要是 3x3 的;它们可以更大。

如果你想象从图像的第一个像素开始,你可能会问那时会发生什么,因为上面或左边没有像素。如果你将核的左上角放在图像的左上像素上,图像的每一边都会丢失一个像素,因为你可以将它想象成核从中心发射出一个像素。这并不总是一个问题,因为在设计神经网络时,你可能希望图像在每一层之后都变得越来越小。

另一个选择是使用填充 – 例如,假装图像周围有黑色像素。

好消息是,你不需要找到核的值;CNN 会在训练阶段为你找到它们。

为什么卷积如此出色?

卷积有一些显著的优势。正如我们之前所说,它们保留了像素的邻近性:

图 4.9 – 黄色的卷积层与绿色的密集层

图 4.9 – 黄色的卷积层与绿色的密集层

如前图所示,卷积知道图像的拓扑结构,例如,它可以知道像素 43 正好位于像素 42 的旁边,位于像素 33 的下方,位于像素 53 的上方。同一图中的密集层没有这个信息,可能会认为像素 43 和像素 51 相近。不仅如此,它甚至不知道分辨率是 3x3、9x1 还是 1x9。直观地了解像素的拓扑结构是一个优势。

另一个重要的优势是它们在计算上效率很高。

卷积的另一个显著特点是它们非常擅长识别模式,例如对角线或圆形。你可能会说它们只能在较小的尺度上做到这一点,这是真的,但你可以组合多个卷积来检测不同尺度的模式,并且它们在这方面可以出奇地好。

它们也能够检测图像不同部分的模式。

所有这些特性使它们非常适合处理图像,并且它们在目标检测中被广泛使用并不令人惊讶。

现在理论部分就到这里。让我们动手写我们的第一个神经网络。

开始使用 Keras 和 TensorFlow

有许多库专门用于深度学习,我们将使用 Keras,这是一个使用多个后端的 Python 库;我们将使用 TensorFlow 作为后端。虽然代码是针对 Keras 的,但原则可以应用于任何其他库。

要求

在开始之前,你需要至少安装 TensorFlow 和 Keras,使用pip

pip install tensorflow
pip install keras

我们正在使用 TensorFlow 2.2,它集成了 GPU 支持,但如果你使用的是 TensorFlow 版本 1.15 或更早版本,你需要安装一个单独的包来利用 GPU:

pip install tensorflow-gpu

我建议使用 TensorFlow 和 Keras 的最新版本。

在开始之前,让我们确保一切就绪。你可能想使用 GPU 来加速训练。不幸的是,让 TensorFlow 使用你的 GPU 并不一定简单;例如,它对 CUDA 的版本非常挑剔:如果它说 CUDA 10.1,那么它确实意味着它——它不会与 10.0 或 10.2 兼容。希望这不会对你的游戏造成太大影响。

要打印 TensorFlow 的版本,可以使用以下代码:

import tensorflow as tf
print("TensorFlow:", tf.__version__)
print("TensorFlow Git:", tf.version.GIT_VERSION)

在我的电脑上,它会打印出以下内容:

TensorFlow: 2.1.0
TensorFlow Git: v2.1.0-rc2-17-ge5bf8de410

要检查 GPU 支持,可以使用以下代码:

print("CUDA ON" if tf.test.is_built_with_cuda() else "CUDA OFF")print("GPU ON" if tf.test.is_gpu_available() else "GPU OFF")

如果一切正常,你应该看到CUDA ON,这意味着你的 TensorFlow 版本已经集成了 CUDA 支持,以及GPU ON,这意味着 TensorFlow 能够使用你的 GPU。

如果你的 GPU 不是 NVIDIA,可能需要更多的工作,但应该可以配置 TensorFlow 在 AMD 显卡上运行,使用 ROCm。

现在你已经正确安装了所有软件,是时候在我们的第一个神经网络上使用了。我们的第一个任务将是使用名为 MNIST 的数据集来识别手写数字。

检测 MNIST 手写数字

当你设计神经网络时,你通常从一个你想要解决的问题开始,你可能从一个已知在类似任务上表现良好的设计开始。你需要一个数据集,基本上是你能得到的尽可能大的数据集。在这方面没有真正的规则,但我们可以这样说,训练你自己的神经网络可能至少需要大约 3000 张图片,但如今,世界级的 CNNs 是使用数百万张图片进行训练的。

我们的首要任务是检测手写数字,这是 CNNs 的经典任务。为此有一个数据集,即 MNIST 数据集(版权属于 Yann LeCun 和 Corinna Cortes),并且它方便地存在于 Keras 中。MNIST 检测是一个简单的任务,因此我们将取得良好的结果。

加载数据集很容易:

from keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = np.reshape(x_train, np.append(x_train.shape, (1)))
x_test = np.reshape(x_test, np.append(x_test.shape, (1)))

reshape只是将形状从(60000, 28, 28)重新解释为(60000, 28, 28, 1),因为 Keras 需要四个维度。

我们刚刚加载了什么?

load_data()方法返回四个东西:

  • x_train:用于训练的图像

  • y_train:用于训练的标签(即每个手写数字的正确数字)

  • x_test:用于测试的图像

  • y_test:用于测试的标签(即每个手写数字的正确数字)

训练样本和标签

让我们打印训练样本(x)和标签(y)的维度:

print('X Train', x_train.shape, ' - X Test', x_test.shape)print('Y Train', y_train.shape, ' - Y Test', y_test.shape)

应该打印出类似以下内容:

X Train (60000, 28, 28, 1)  - X Test (10000, 28, 28, 1)Y Train (60000,)  - Y Test (10000,)

x 变量代表 CNN 的输入,这意味着 x 包含所有我们的图像,分为两个集合,一个用于训练,一个用于测试:

  • x_train 包含 60,000 张用于训练的图像,每张图像有 28x28 像素,为灰度图(一个通道)。

  • x_test 包含 10,000 张用于测试的图像,每张图像有 28x28 像素,为灰度图(一个通道)。

如你所见,训练和测试图像具有相同的分辨率和相同数量的通道。

y 变量代表 CNN 的预期输出,也称为标签。对于许多数据集,有人手动标记所有图像以说明它们是什么。如果数据集是人工的,标记可能是自动化的:

  • y_train 由 60,000 个属于 10 个类别的数字组成,从 0 到 9。

  • y_test 由 10,000 个属于 10 个类别的数字组成,从 0 到 9。

对于每张图像,我们都有一个标签。

通常来说,一个神经网络可以有多个输出,每个输出都是一个数字。在分类任务的情况下,例如 MNIST,输出是一个单独的整数。在这种情况下,我们特别幸运,因为输出值实际上是我们感兴趣的数字(例如,0 表示数字 0,1 表示数字 1)。通常,你需要将数字转换为标签(例如,0 -> 猫,1 -> 狗,2 -> 鸭)。

严格来说,我们的 CNN 不会输出一个从 0 到 9 的整数结果,而是 10 个浮点数,最高值的位置将是标签(例如,如果位置 3 的输出是最高值,则输出将是 3)。我们将在下一章中进一步讨论这个问题。

为了更好地理解 MNIST,让我们看看训练数据集和测试数据集的五个样本:

图 4.10 – MNIST 训练和测试数据集样本。版权属于 Yann LeCun 和 Corinna Cortes

图 4.10 – MNIST 训练和测试数据集样本。版权属于 Yann LeCun 和 Corinna Cortes

如你所料,这些图像的对应标签如下:

  • 训练样本(y_train)包括 5、0、4、1 和 9。

  • 测试样本(y_test)包括 7、2、1、0 和 4。

我们还应该调整样本的大小,使其范围从 0-255 变为 0-1,因为这有助于神经网络获得更好的结果:

x_train = x_train.astype('float32')x_test = x_test.astype('float32')x_train /= 255 x_test /= 255

一维编码

标签不能直接使用,需要使用one-hot encoding将其转换为向量。正如其名所示,你得到一个向量,其中只有一个元素是热的(例如,其值为1),而所有其他元素都是冷的(例如,它们的值为0)。热的元素代表标签的位置,在一个包含所有可能位置的向量中。一个例子应该会使理解更容易。

在 MINST 的情况下,你有 10 个标签:0、1、2、3、4、5、6、7、8 和 9。因此,one-hot encoding 将使用 10 个项目。这是前三个项目的编码:

  • 0 ==> 1 0 0 0 0 0 0 0 0 0

  • 1 ==> 0 1 0 0 0 0 0 0 0 0

  • 2 ==> 0 0 1 0 0 0 0 0 0 0

如果你有三个标签,狗、猫和鱼,你的 one-hot encoding 将如下所示:

  • Dog ==> 1 0 0

  • Cat ==> 0 1 0

  • Fish ==> 0 0 1

Keras 提供了一个方便的函数来处理这个问题,to_categorical(),它接受要转换的标签列表和标签总数:

print("One hot encoding: ", keras.utils.to_categorical([0, 1, 2], 10))

如果你的标签不是数字,你可以使用index()来获取指定标签的索引,并使用它来调用to_categorical()

labels = ['Dog', 'Cat', 'Fish']
print("One hot encoding 'Cat': ", keras.utils.to_categorical(labels.index('Cat'), 10))

训练和测试数据集

x变量包含图像,但为什么我们既有x_train又有x_test

我们将在下一章中详细解释一切,但到目前为止,我们只能说 Keras 需要两个数据集:一个用于训练神经网络,另一个用于调整超参数以及评估神经网络的性能。

这有点像有一个老师先向你解释事情,然后质问你,分析你的答案来更好地解释你没有理解的部分。

定义神经网络模型

现在我们想编写我们的神经网络,我们可以称之为我们的模型,并对其进行训练。我们知道它应该使用卷积,但我们对此了解不多。让我们从一位古老但非常有影响力的 CNN:LeNet中汲取灵感。

LeNet

LeNet 是第一个 CNN 之一。追溯到 1998 年,对于今天的标准来说,它相当小且简单。但对于这个任务来说已经足够了。

这是它的架构:

图 4.11 – LeNet

图 4.11 – LeNet

LeNet 接受 32x32 像素的图像,并具有以下层:

  • 第一层由六个 5x5 卷积组成,输出 28x28 像素的图像。

  • 第二层对图像进行子采样(例如,计算四个像素的平均值),输出 14x14 像素的图像。

  • 第三层由 16 个 5x5 卷积组成,输出 10x10 像素的图像。

  • 第四层对图像进行子采样(例如,计算四个像素的平均值),输出 5x5 像素的图像。

  • 第五层是一个包含 120 个神经元的全连接密集层(即,前一层中的所有神经元都连接到这一层的所有神经元)。

  • 第六层是一个包含 84 个神经元的全连接密集层。

  • 第七个也是最后一个层是输出,一个包含 10 个神经元的完全连接的密集层,因为我们需要将图像分类为 10 个类别,对应于 10 个数字。

我们并不是试图精确地重现 LeNet,我们的输入图像略小一些,但我们会将其作为参考。

代码

第一步是定义我们正在创建哪种类型的神经网络,在 Keras 中通常是Sequential

model = Sequential()

现在我们可以添加第一个卷积层:

model.add(Conv2D(filters=6, kernel_size=(5, 5),
   activation='relu', padding='same',
   input_shape=x_train.shape[1:]))

它接受以下参数:

  • 六个过滤器,因此我们将得到六个核,这意味着六个卷积。

  • 核大小 5x5。

  • ReLU 激活。

  • 使用same填充(例如,在图像周围使用黑色像素),以避免过早地大幅度减小图像大小,并更接近 LeNet。

  • input_shape包含图像的形状。

然后,我们添加了使用最大池化(默认大小=2x2)的子采样,它输出具有最大激活(例如,最大值)的像素值:

model.add(MaxPooling2D())

然后,我们可以添加下一个卷积层和下一个最大池化层:

model.add(Conv2D(filters=16, kernel_size=(5,5), activation='relu'))
model.add(MaxPooling2D())

然后我们可以添加密集层:

model.add(Flatten())model.add(Dense(units=120, activation='relu'))model.add(Dense(units=84, activation='relu'))model.add(Dense(units=num_classes, activation='softmax'))

Flatten()用于将卷积层的 2D 输出展平为单行输出(1D),这是密集层所需的。为了清楚起见,对于我们的用例,卷积滤波器的输入是一个灰度图像,输出也是另一个灰度图像。

最后的激活,softmax,将预测转换为概率,以便方便起见,并且具有最高概率的输出将代表神经网络与图像关联的标签。

就这样:仅仅几行代码就能构建一个能够识别手写数字的 CNN。我挑战你不用机器学习来做同样的事情!

架构

即使我们的模型定义非常直接,可视化它并查看例如维度是否符合预期也是有用的。

Keras 有一个非常有用的函数用于此目的——summary()

model.summary()

这是结果:

_______________________________________________________________
Layer (type)                 Output Shape              Param #   
===============================================================
conv2d_1 (Conv2D)            (None, 28, 28, 6)         156       
_______________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 14, 14, 6)         0         
_______________________________________________________________
conv2d_2 (Conv2D)            (None, 10, 10, 16)        2416      
_______________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 5, 5, 16)          0         
_______________________________________________________________
flatten_1 (Flatten)          (None, 400)               0         
_______________________________________________________________
dense_1 (Dense)              (None, 120)               48120     
_______________________________________________________________
dense_2 (Dense)              (None, 84)                10164     
_______________________________________________________________
dense_3 (Dense)              (None, 10)                850       
===============================================================
Total params: 61,706
Trainable params: 61,706
Non-trainable params: 0

这非常有趣。首先,我们可以看到卷积层的输出维度与 LeNet 相同:28x28 和 10x10。这并不一定很重要;它只是意味着网络的设计符合我们的预期。

我们还可以看到层的顺序是正确的。有趣的是每行的第三个值:参数数量。参数是神经网络需要确定以实际学习某些东西的变量。它们是我们庞大方程系统中的变量。

在全连接密集层的情况下,参数的数量是通过将前一层神经元的数量乘以一,再加上当前层神经元的数量来获得的。如果您还记得神经元的图像,每个神经元都有一个与之相连的权重,所以对于每个神经元都是一个可训练的参数来说,这是很直观的。此外,还有一个用于激活阈值(偏置)的参数。因此,在最后一层,我们有以下内容:

  • 84 个输入 ==> 84 个权重 + 1 个偏置 ==> 85 个参数

  • 10 个输出

  • 85 x 10 ==> 850 个参数

在卷积层的情况下,参数的数量由核的面积加一以及激活的偏置给出。在第一层,我们有以下内容:

  • 5x5 核 ==> 25 + 1 个偏置 ==> 26 个参数

  • 6 个过滤器

  • 26 x 6 ==> 156 个参数

如您所见,我们的网络有 61,706 个参数。虽然这看起来可能很多,但对于神经网络来说,拥有数百万个参数并不罕见。它们如何影响训练?作为一个初步的近似,我们可以这样说,拥有更多的参数使我们的网络能够学习更多的事物,但同时也减慢了它的速度,增加了模型的大小以及它使用的内存量。不要对参数的数量过于着迷,因为并非所有参数都是相同的,但要注意它们,以防某些层使用了过多的参数。您可以看到,密集层倾向于使用许多参数,在我们的例子中,它们占据了超过 95%的参数。

训练神经网络

现在我们已经拥有了我们的神经网络,我们需要对其进行训练。我们将在下一章中更多地讨论训练,但正如其名所示,训练是神经网络学习训练数据集并真正学习它的阶段。至于它学习得有多好——这取决于。

为了快速解释概念,我们将与学生试图为了考试学习一本书的不当比较:

  • 这本书是学生需要学习的学习数据集。

  • 每次学生阅读整本书都称为一个 epoch。学生可能想要多次阅读这本书,对于神经网络来说,做同样的事情并训练超过一个 epoch 是非常常见的。

  • 优化器就像一个人从练习册(验证数据集;尽管在我们的例子中,我们将使用测试数据集进行验证)中提问学生,以查看学生学习得有多好。一个关键的区别是,神经网络不会从验证数据集中学习。我们将在下一章中看到为什么这是非常好的。

  • 为了跟踪他们的进度并在更短的时间内学习,学生可以要求优化器在阅读一定数量的页面后提问;这个页数就是批大小。

首件事是配置模型,使用compile()

model.compile(loss=categorical_crossentropy, optimizer=Adam(),    metrics=['accuracy'])

Keras 有多种损失函数可供使用。loss基本上是衡量你的模型结果与理想输出之间距离的度量。对于分类任务,我们可以使用categorical_crossentropy作为损失函数。optimizer是用于训练神经网络的算法。如果你把神经网络想象成一个巨大的方程组系统,那么优化器就是那个找出如何改变参数以改进结果的人。我们将使用metrics,这只是训练期间计算的一些值,但它们不是由优化器使用的;它们只是作为参考提供给你。

我们现在可以开始训练,这可能需要几分钟,并且会打印出正在进行的进度:

history = model.fit(x_train, y_train, batch_size=16,    epochs=5, validation_data=(x_test, y_test), shuffle=True)

我们需要提供几个参数:

  • x_train:训练图像。

  • y_train:训练标签。

  • batch_size:默认值是 32,通常尝试从 16 到 256 的 2 的幂次方是值得的;批处理大小会影响速度和准确性。

  • epochs:CNN 将遍历数据集的次数。

  • validation_data:正如我们之前所说的,我们正在使用测试数据集进行验证。

  • shuffle:如果我们想在每个 epoch 之前打乱训练数据,这通常是我们想要的。

训练的结果是history,它包含了很多有用的信息:

print("Min Loss:", min(history.history['loss']))
print("Min Val. Loss:", min(history.history['val_loss']))
print("Max Accuracy:", max(history.history['accuracy']))
print("Max Val. Accuracy:", max(history.history['val_accuracy']))

我们在讨论最小值和最大值,因为这些值是在每个 epoch 期间测量的,并不一定总是朝着改进的方向前进。

让我们来看看我们这里有什么:

  • 最小损失是衡量我们接近训练数据集中理想输出的程度,或者神经网络学习训练数据集的好坏的度量。一般来说,我们希望这个值尽可能小。

  • 最小验证损失是我们接近验证数据集中理想输出的程度,或者神经网络在训练后使用验证数据集所能做到的多好。这可能是最重要的值,因为这是我们试图最小化的,所以我们希望这个值尽可能小。

  • 最大准确率是我们 CNN 使用训练数据集所能给出的最大正确答案(预测)百分比。对于之前的学生示例,它将告诉他们他们记住了书本的程度。仅仅记住书本本身并不坏——实际上这是可取的——但目标不是记住书本,而是从中学习。虽然我们希望这个值尽可能高,但它可能会误导。

  • 最大验证准确率是我们 CNN 使用验证数据集所能给出的最大正确答案(预测)百分比。对于之前的学生示例,它将告诉他们他们实际上学到了书本内容的程度,以便他们可以回答书中可能没有的问题。这将是我们神经网络在实际生活中表现如何的一个指标。

这是我们的 CNN 的结果:

Min Loss: 0.054635716760404344
Min Validation Loss: 0.05480437679834067
Max Accuracy: 0.9842833
Max Validation Accuracy: 0.9835000038146973

在你的电脑上,它可能略有不同,实际上每次运行时都应该有所变化。

我们可以看到损失接近零,这是好的。准确率和验证准确率几乎都是 98.5%,这在一般情况下是非常好的。

我们还可以绘制这些参数随时间演变的图表:

plt.plot(history_object.history['loss'])
plt.plot(history_object.history['val_loss'])
plt.plot(history_object.history['accuracy'])
plt.plot(history_object.history['val_accuracy'])
plt.title('model mean squared error loss')
plt.ylabel('mean squared error loss')
plt.xlabel('epoch')
plt.legend(['T loss', 'V loss', 'T acc', 'V acc'], loc='upper left')
plt.show()

这是结果:

图 4.12 – MNIST 随时间变化的验证和准确率图

图 4.12 – MNIST 随时间变化的验证和准确率图

在第一个 epoch 之后,准确率和损失都非常良好,并且持续改进。

到目前为止一切顺利。也许你认为这很简单。但 MNIST 是一个简单的数据集。让我们尝试 CIFAR-10。

CIFAR-10

要使用 CIFAR-10,我们只需请求 Keras 使用不同的数据集:

(x_train, y_train), (x_test, y_test) = cifar10.load_data()

CIFAR-10 是一个更困难的数据集。它包含 32x32 的 RGB 图像,包含 10 种类型的对象:

X Train (50000, 32, 32, 3)  - X Test (10000, 32, 32, 3)
Y Train (50000, 1)  - Y Test (10000, 1)

它看起来与 MNIST 相似。

在 GitHub 上的代码中,要使用 CIFAR 10,你只需将use_mnist变量更改为False

use_mnist = False

你不需要在代码中做任何其他更改,除了移除reshape()调用,因为 CIFAR-10 使用 RGB 图像,因此它已经具有三个维度:宽度、高度和通道。Keras 将适应不同的维度和通道,神经网络将学习一个新的数据集!

让我们看看新的模型:

_______________________________________________________________
Layer (type)                 Output Shape              Param #   
===============================================================
conv2d_1 (Conv2D)            (None, 32, 32, 6)         456       
_______________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 16, 16, 6)         0         
_______________________________________________________________
conv2d_2 (Conv2D)            (None, 12, 12, 16)        2416      
_______________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 6, 6, 16)          0         
_______________________________________________________________
flatten_1 (Flatten)          (None, 576)               0         
_______________________________________________________________
dense_1 (Dense)              (None, 120)               69240     
_______________________________________________________________
dense_2 (Dense)              (None, 84)                10164     
_______________________________________________________________
dense_3 (Dense)              (None, 10)                850       
===============================================================
Total params: 83,126
Trainable params: 83,126
Non-trainable params: 0

模型稍微大一些,因为图像稍微大一些,并且是 RGB 格式。让我们看看它的表现:

Min Loss: 1.2048443819999695
Min Validation Loss: 1.2831668125152589
Max Accuracy: 0.57608
Max Validation Accuracy: 0.5572999715805054

这不是很好:损失很高,验证准确率只有大约 55%。

下一个图表非常重要,你将多次看到它,所以请花些时间熟悉一下。下面的图表显示了我们的模型在时间上每个 epoch 的损失(我们使用均方误差)和准确率的演变。在X轴上,你可以看到 epoch 的数量,然后有四条线:

  • T loss: 训练损失

  • V loss: 验证损失

  • T acc: 训练准确率

  • V acc: 验证准确率:

图 4.13 – CIFAR-10 随时间变化的验证和准确率图

图 4.13 – CIFAR-10 随时间变化的验证和准确率图

我们可以看到损失正在下降,但还没有达到最小值,所以这可能意味着更多的 epochs 会有所帮助。准确率很低,并且保持低水平,可能是因为模型参数不足。

让我们看看 12 个 epochs 的结果:

Min Loss: 1.011266466407776
Min Validation Loss: 1.3062725918769837
Max Accuracy: 0.6473
Max Validation Accuracy: 0.5583999752998352

好消息:损失下降了,准确率提高了。坏消息:验证损失和验证准确率没有提高。在实践中,我们的网络已经通过心算学习了训练数据集,但它不能泛化,因此它在验证数据集上的表现不佳。

让我们尝试显著增加网络的大小:

model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:]))
model.add(AveragePooling2D())
model.add(Conv2D(filters=256, kernel_size=(3, 3), activation='relu'))
model.add(AveragePooling2D())
model.add(Flatten())
model.add(Dense(units=512, activation='relu'))
model.add(Dense(units=256, activation='relu'))
model.add(Dense(units=num_classes, activation = 'softmax'))

这给我们带来了这个新的模型:

_______________________________________________________________
Layer (type)                 Output Shape              Param #   
===============================================================
conv2d_1 (Conv2D)            (None, 30, 30, 64)        1792      
_______________________________________________________________
average_pooling2d_1 (Average (None, 15, 15, 64)        0         
_______________________________________________________________
conv2d_2 (Conv2D)            (None, 13, 13, 256)       147712    
_______________________________________________________________
average_pooling2d_2 (Average (None, 6, 6, 256)         0         
_______________________________________________________________
flatten_1 (Flatten)          (None, 9216)              0         
_______________________________________________________________
dense_1 (Dense)              (None, 512)               4719104   
_______________________________________________________________
dense_2 (Dense)              (None, 256)               131328    
_______________________________________________________________
dense_3 (Dense)              (None, 10)                2570      
===============================================================
Total params: 5,002,506
Trainable params: 5,002,506
Non-trainable params: 0

哇:我们从 83,000 个参数跳到了 5,000,000 个参数!第一个密集层变得很大...

让我们看看是否可以看到一些改进:

Min Loss: 0.23179266978245228
Min Validation Loss: 1.0802633233070373
Max Accuracy: 0.92804
Max Validation Accuracy: 0.65829998254776

现在所有值都有所提高;然而,尽管训练准确率现在超过了 90%,但验证准确率仅为 65%:

图 4.14 – CIFAR-10 验证和准确率随时间的变化图

图 4.14 – CIFAR-10 验证和准确率随时间的变化图

我们看到一些令人担忧的情况:虽然训练损失随时间下降,但验证损失上升。这种情况被称为过拟合,这意味着网络不擅长泛化。这也意味着我们使用了过多的 epoch 而没有效果。

不仅于此,如果我们最后保存了模型,我们也不会保存最佳模型。如果你想知道是否有保存最佳模型(例如,只有当验证损失降低时才保存)的方法,那么答案是肯定的——Keras 可以做到:

checkpoint = ModelCheckpoint('cifar-10.h5', monitor='val_loss', mode='min', verbose=1, save_best_only=True)

这里我们告诉 Keras 执行以下操作:

  • 将模型以名称'cifar-10.h5'保存。

  • 监控验证损失。

  • 根据最小损失选择模型(例如,只有当验证损失降低时才保存)。

  • 只保存最佳模型。

你可以将checkpoint对象传递给model.fit()

history_object = model.fit(x_train, y_train, batch_size=batch_size, epochs=epochs, validation_data=(x_test, y_test), shuffle=True, callbacks=[checkpoint])

这有所帮助,但模型还不够好。我们需要一个根本性的改进。

在下一章中,我们将学习许多有望帮助我们获得更好结果的东西。此外,在第六章提高你的神经网络中,我们将应用这些知识,以及更多,来提高结果。现在,如果你愿意,你可以花些时间尝试调整和改进网络:你可以改变其大小,添加滤波器和层,并看看它的表现如何。

摘要

这已经是一个内容丰富的章节!我们讨论了机器学习的一般概念和深度学习的特别之处。我们谈论了神经网络以及如何利用像素邻近的知识来使用卷积来创建更快、更准确的神经网络。我们学习了权重、偏差和参数,以及训练阶段的目标是优化所有这些参数以学习当前的任务。

在验证了 Keras 和 TensorFlow 的安装后,我们介绍了 MNIST,并指导 Keras 构建一个类似于 LeNet 的网络,以在数据集上实现超过 98%的准确率,这意味着我们现在可以轻松地识别手写数字。然后,我们看到同样的模型在 CIFAR-10 上表现不佳,尽管增加了 epoch 的数量和网络的大小。

在下一章中,我们将深入研究我们在这里介绍的大多数概念,最终目标是完成第六章提高你的神经网络,学习如何训练神经网络。

问题

在阅读本章后,你应该能够回答以下问题:

  1. 什么是感知器?

  2. 你能说出一个在许多任务中表现良好的优化器吗?

  3. 什么是卷积?

  4. 什么是 CNN?

  5. 什么是密集层?

  6. Flatten()层做什么?

  7. 我们一直使用哪个后端进行 Keras 开发?

  8. 第一批卷积神经网络(CNN)的名字是什么?

进一步阅读

第五章:第五章:深度学习工作流程

在本章中,我们将介绍您在训练神经网络和将其投入生产过程中可能执行的操作步骤。我们将更深入地讨论深度学习背后的理论,以更好地解释我们在第四章,“使用神经网络的深度学习”中实际所做的工作,但我们主要关注与自动驾驶汽车相关的论点。我们还将介绍一些有助于我们在 CIFAR-10(一个小型图像的著名数据集)上获得更高精度的概念。我们相信,本章中提出的理论,加上与第四章,“使用神经网络的深度学习”和第六章,“改进您的神经网络”相关的更实际的知识,将为您提供足够的工具来执行自动驾驶汽车领域中的常见任务。

在本章中,我们将涵盖以下主题:

  • 获取或创建数据集

  • 训练、验证和测试数据集

  • 分类器

  • 数据增强

  • 定义模型

  • 如何调整卷积层、MaxPooling层和密集层

  • 训练和随机性的作用

  • 欠拟合和过拟合

  • 激活的可视化

  • 运行推理

  • 重新训练

技术要求

要能够使用本章中解释的代码,您需要安装以下工具和模块:

  • Python 3.7

  • NumPy 模块

  • Matplotlib 模块

  • TensorFlow 模块

  • Keras 模块

  • OpenCV-Python 模块

本章的代码可以在github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter5找到。

本章的“代码在行动”视频可以在以下位置找到:

bit.ly/3dJrcys

获取数据集

一旦您有一个想要用神经网络执行的任务,通常的第一步是获取数据集,这是您需要喂给神经网络的那些数据。在我们这本书中执行的任务中,数据集通常由图像或视频组成,但它可以是任何东西,或者图像和其他数据的混合。

数据集代表您喂给神经网络的输入,但如您所注意到的,您的数据集还包含期望的输出,即标签。我们将用x表示神经网络的输入,用y表示输出。数据集由输入/特征(例如,MNIST 数据集中的图像)和输出/标签(例如,与每个图像关联的数字)组成。

我们有不同的数据集类型。让我们从最简单的开始——Keras 中包含的数据集——然后再继续到下一个。

Keras 模块中的数据集

通常,数据库包含大量数据。在成千上万张图片上训练神经网络是正常的,但最好的神经网络是用数百万张图片训练的。那么我们如何使用它们呢?

最简单的方法,通常对实验很有帮助,是使用 Keras 中包含的数据库,就像我们在第四章中做的那样,使用神经网络进行深度学习,使用load_data(),如下所示:

from keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()

Keras 提供了各种数据库:

  • MNIST – 数字分类

  • CIFAR10 – 小图像的分类

  • CIFAR100 – 小图像的分类

  • IMDB 电影评论情感分类

  • 路透社新闻社分类

  • Fashion MNIST 数据库

  • 波士顿房价

这些数据库对于学习如何构建神经网络非常有用。在下一节中,我们将探讨一些对自动驾驶汽车更有用的数据库。

现有数据库

幸运的是,有几个有趣的公共数据库可供使用,但你必须始终仔细检查许可证,以了解你可以用它做什么,并最终获取或获得更宽松的许可证。

以下是一些你可能想要检查的与自动驾驶汽车相关的数据库:

此外,还有一些其他更通用的数据库你可能觉得很有趣,特别是 WordNet 层次结构组织的图像数据库 ImageNet,www.image-net.org/

这个数据库包含数百万个指向互联网上图片的 URL,对神经网络的发展产生了重大影响。我们将在稍后详细讨论。

公共数据库很棒,但你也可能需要考虑其内容,因为有些图片被错误分类并不罕见。这对你的神经网络来说可能不是什么大问题,但你可能仍然希望获得尽可能好的数据库。

如果找不到令人满意的数据库,你总是可以生成一个。让我们看看如何快速构建用于自动驾驶汽车任务的优质数据库。

合成数据库

当可能时,您可能希望从可以创建足够好的图像的程序中生成数据集。我们在第三章 车道检测中使用这项技术,我们从 Carla 中检测行人,从开源视频游戏 Speed Dreams 中获取图像,您也可以使用 3D 引擎或 3D 建模软件编写自己的生成器。

这些方法相对简单、快捷且非常便宜,可以生成大量数据集,实际上有时是无价的,因为在许多情况下,你可以自动标注图像并节省大量时间。然而,合成图像通常比真实图像简单,结果可能是你的网络在现实世界场景中的表现可能不如你想象的那么好。我们将在第八章 行为克隆中使用这项技术。

如果不是最好的话,Carla 是当前最好的模拟器之一。

Carla,自动驾驶研究的开源模拟器,使用了以下网站:

您可以使用它来生成您任务所需的图像。

当这还不够时,您必须遵循手动流程。

您的定制数据集

有时,您可能没有令人满意的替代方案,您需要自己收集图像。这可能需要收集录像并对数千张图像进行分类。如果图像是从视频中提取的,您可能只需对视频进行分类,然后从中提取数百或数千张图像。

有时情况并非如此,您可能需要自己浏览成千上万张图片。或者,您可以使用专业公司的服务来为您标注图像。

有时您可能拥有图像,但分类可能很困难。想象一下,如果您可以访问汽车的录像,然后需要标注图像,添加汽车所在的框。如果您很幸运,您可能可以访问一个可以为您完成这项工作的神经网络。您可能仍然需要手动检查结果,并对一些图像进行重新分类,但这仍然可以节省大量工作。

接下来,我们将深入了解这些数据集。

理解三个数据集

实际上,你可能不需要一个数据集,但理想情况下需要三个。这些数据集用于训练、验证和测试。在定义它们之前,请考虑不幸的是,有时关于验证和测试的含义存在一些混淆,通常情况下只有两个数据集可用,就像这种情况,验证集和测试集是重合的。我们在第四章 使用神经网络的深度学习中也做了同样的事情,我们使用了测试集作为验证集。

让我们现在定义这三个数据集,然后我们可以解释理想情况下我们应该如何测试 MNIST 数据集:

  • 训练数据集:这是用于训练神经网络的数据库,通常是三个数据集中最大的一个。

  • 验证数据集:这通常是指训练数据集的一部分,这部分数据不用于训练,而仅用于评估模型的性能和调整其超参数(例如,网络的拓扑结构或优化器的学习率)。

  • 测试数据集:理想情况下,这是一个一次性数据集,用于在所有调整完成后评估模型的性能。

您不能使用训练数据集来评估模型的性能,因为训练数据集被优化器用于训练网络,所以这是最佳情况。然而,我们通常不需要神经网络在训练数据集上表现良好,而是在用户抛给它的任何东西上表现良好。因此,我们需要网络能够泛化。回到第四章“使用神经网络的深度学习”中的学生比喻,训练数据集上的高分意味着学生已经把书(训练数据集)背得滚瓜烂熟,但我们希望的是学生能够理解书的内容,并能够将这种知识应用到现实世界的情境中(验证数据集)。

那么,如果验证代表现实世界,为什么我们还需要测试数据集呢?问题是,在调整您的网络时,您将做出一些选择,这些选择将偏向于验证数据集(例如,根据其在验证数据集上的性能选择一个模型而不是另一个模型)。结果,验证数据集上的性能可能仍然高于现实世界。

测试数据集解决了这个问题,因为我们只在所有调整完成后才应用它。这也解释了为什么理想情况下,我们希望在仅使用一次后丢弃测试数据集。

这可能不切实际,但并非总是如此,因为有时您可以很容易地根据需要生成一些样本。

那么,我们如何在 MNIST 任务中使用三个数据集呢?也许您还记得从第四章,“使用神经网络的深度学习”,MNIST 数据集有 60,000(60 K)个样本用于训练,10,000(10 K)个用于测试。

理想情况下,您可以采用以下方法:

  • 训练数据集可以使用为训练准备的 60,000 个样本。

  • 验证数据集可以使用为测试准备的 10,000 个样本(正如我们所做的那样)。

  • 测试数据集可以根据需要生成,现场写数字。

在讨论了三个数据集之后,我们现在可以看看如何将您的完整数据集分成三部分。虽然这似乎是一个简单的操作,但在如何进行操作方面您需要小心。

数据集拆分

给定你的完整数据集,你可能需要将其分成训练、验证和测试三个部分。如前所述,理想情况下,你希望测试是在现场生成的,但如果这不可能,你可能会选择使用总数据集的 15-20%进行测试。

在剩余的数据集中,你可能使用 15-20%作为验证。

如果你有很多样本,你可能为验证和测试使用更小的百分比。如果你有较小的数据集,在你对模型性能满意后(例如,如果你选择它是因为它在验证和测试数据集上都表现良好),你可能会将测试数据集添加到训练数据集中以获得更多样本。如果你这样做,就没有必要在测试数据集中评估性能,因为实际上它将成为训练的一部分。在这种情况下,你信任验证数据集的结果。

但即使大小相同,并不是所有的分割都是一样的。让我们用一个实际的例子来说明。

你想检测猫和狗。你有一个包含 10,000 张图片的数据集。你决定使用 8,000 张进行训练,2,000 张进行验证,测试是通过你家中 1 只狗和 1 只猫的视频实时记录来完成的;每次测试时,你都会制作一个新的视频。看起来完美。可能出什么问题?

首先,你需要在每个数据集中大致有相同数量的猫和狗。如果不是这样,网络将偏向于其中之一。直观地说,如果在训练过程中,网络看到 90%的图像是狗的,那么总是预测狗将给你 90%的准确率!

你读到随机化样本顺序是一种最佳实践,你就这样做了。然后你进行了分割。你的模型在训练、验证和测试数据集上表现良好。一切看起来都很不错。然后你尝试使用几个朋友的宠物,但什么都没发生。发生了什么?

一种可能性是,你的分割在衡量泛化方面并不好。即使你有 1 万张图片,它们可能只来自 100 只宠物(包括你的),每只狗和猫都出现了 100 次,位置略有不同(例如,来自视频)。如果你打乱样本,所有的狗和猫都会出现在所有数据集中,因此验证和测试将相对容易,因为网络已经知道那些宠物。

如果,相反,你为了验证而养了 20 只宠物,并且注意不要在训练或验证数据集中包含你的宠物照片,那么你的估计将更加现实,你有机会构建一个在泛化方面更好的神经网络。

现在我们有了三个数据集,是时候定义我们需要执行的任务了,这通常将是图像分类。

理解分类器

深度学习可以用于许多不同的任务。对于图像和 CNN 来说,一个非常常见的任务是分类。给定一个图像,神经网络需要使用在训练期间提供的标签之一对其进行分类。不出所料,这种类型的网络被称为分类器

要做到这一点,神经网络将为每个标签有一个输出(例如,在 10 个数字的 MNIST 数据集上,我们有 10 个标签和 10 个输出),而只有一个输出应该是 1,其他所有输出应该是 0。

神经网络如何达到这种状态呢?其实,它并不能。神经网络通过内部乘法和求和产生浮点输出,而且很少能得到相似的输出。然而,我们可以将最大值视为热点(1),而所有其他值都可以视为冷点(0)。

我们通常在神经网络的末尾应用一个 softmax 层,它将输出转换为概率,这意味着 softmax 后的输出总和将是 1.0。这非常方便,因为我们可以很容易地知道神经网络对预测的信心程度。Keras 在模型中提供了一个获取概率的方法predict(),以及一个获取标签的方法predict_classes()。如果需要,可以使用to_categorical()将标签轻松转换为独热编码格式。

如果你需要从独热编码转换到标签,可以使用 NumPy 的argmax()函数。

现在我们知道了我们的任务是图像分类,但我们需要确保我们的数据集与我们的网络在生产部署时需要检测的内容相似。

创建真实世界的数据集

当你收集数据集时,无论是使用自己的图像还是其他合适的数据集,你需要确保图像反映了你可能在现实生活中遇到的条件。例如,你应该尝试获取以下列出的问题图像,因为你很可能在生产中遇到这些问题:

  • 恶劣的光线(过曝和欠曝)

  • 强烈的阴影

  • 障碍物遮挡物体

  • 物体部分出图

  • 物体旋转

如果你不能轻松地获得这些类型的图像,可以使用数据增强,这正是我们下一节要讨论的内容。

数据增强

数据增强是增加你数据集中样本的过程,并从你已有的图像中派生新的图像;例如,降低亮度或旋转它们。

Keras 包括一个方便的方式来增强你的数据集,ImageDataGenerator(),它可以随机应用指定的转换,但不幸的是,它的文档并不特别完善,并且在参数方面缺乏一致性。因此,我们现在将分析一些最有用的转换。为了清晰起见,我们将构建一个只有一个参数的生成器,以观察其效果,但你很可能希望同时使用多个参数,我们将在稍后这样做。

ImageDataGenerator() 构造函数接受许多参数,例如以下这些:

  • brightness_range:这将改变图像的亮度,它接受两个参数的列表,分别是最小和最大亮度,例如 [0.1, 1.5]。

  • rotation_range:这将旋转图像,并接受一个表示旋转范围的度数参数,例如 60。

  • width_shift_range:这将使图像水平移动;它接受不同形式的参数。我建议使用可接受值的列表,例如 [-50, -25, 25, 50]。

  • height_shift_range:这将使图像垂直移动;它接受不同形式的参数。我建议使用可接受值的列表,例如 [-50, -25, 25, 50]。

  • shear_range:这是剪切强度,接受以度为单位的一个数字,例如 60。

  • zoom_range:这将放大或缩小图像,它接受两个参数的列表,分别是最小和最大缩放,例如 [0.5, 2]。

  • horizontal_flip:这将水平翻转图像,参数是一个布尔值。

  • vertical_flip:这将垂直翻转图像,参数是一个布尔值。

其中,水平翻转通常非常有效。

下图显示了使用亮度、旋转、宽度移动和高度移动增强图像的结果:

图 5.1 – ImageDataGenerator() 结果。从上到下:brightness_range=[0.1, 1.5], rotation_range=60, width_shift_range=[-50, -25, 25, 50], 和 height_shift_range=[-75, -35, 35, 75]

图 5.1 – ImageDataGenerator() 结果。从上到下:brightness_range=[0.1, 1.5], rotation_range=60, width_shift_range=[-50, -25, 25, 50], 和 height_shift_range=[-75, -35, 35, 75]

下图是使用剪切、缩放、水平翻转和垂直翻转生成的图像:

图 5.2 – ImageDataGenerator() 结果。从上到下:shear_range=60, zoom_range=[0.5, 2], horizontal_flip=True, 和 vertical_flip=True

图 5.2 – ImageDataGenerator() 结果。从上到下:shear_range=60, zoom_range=[0.5, 2], horizontal_flip=True, 和 vertical_flip=True

这些效果通常会组合使用,如下所示:

datagen = ImageDataGenerator(brightness_range=[0.1, 1.5], rotation_range=60, width_shift_range=[-50, -25, 25, 50], horizontal_flip=True)

这是最终结果:

图 5.3 – ImageDataGenerator() 结果。应用参数:brightness_range=[0.1, 1.5], rotation_range=60, width_shift_range=[-50, -25, 25, 50], 和 horizontal_flip=True

图 5.3 – ImageDataGenerator() 结果。应用参数:brightness_range=[0.1, 1.5], rotation_range=60, width_shift_range=[-50, -25, 25, 50], 和 horizontal_flip=True

直观上,网络应该对图像的变化更加宽容,并且应该学会更好地泛化。

请记住,Keras 的数据增强更像是数据替换,因为它替换了原始图像,这意味着原始的、未更改的图像不会被发送到神经网络,除非随机组合是这样的,它们以未更改的形式呈现。

数据增强的巨大效果是样本会在每个周期改变。所以,为了清楚起见,Keras 中的数据增强不会在每个周期增加样本数量,但样本会根据指定的转换在每个周期改变。你可能想训练更多的周期。

接下来,我们将看到如何构建模型。

模型

现在你有一个图像数据集,你知道你想做什么(例如,分类),是时候构建你的模型了!

我们假设你正在工作于一个卷积神经网络,所以你可能甚至只需要使用卷积块、MaxPooling密集层。但如何确定它们的大小?应该使用多少层?

让我们用 CIFAR-10 做一些测试,因为 MINST 太简单了,看看会发生什么。我们不会改变其他参数,但只是稍微玩一下这些层。

我们还将训练 5 个周期,以加快训练速度。这并不是为了得到最好的神经网络;这是为了衡量一些参数的影响。

我们的起点是一个包含一个卷积层、一个 MaxPooling 层和一个密集层的网络,如下所示:

model = Sequential()
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu'))
model.add(MaxPooling2D())
model.add(Flatten())
model.add(Dense(units = 256, activation = "relu"))
model.add(Dense(num_classes))
model.add(Activation('softmax'))

以下是对此的总结:

_______________________________________________________________
Layer (type)                 Output Shape              Param #   
===============================================================
conv2d_1 (Conv2D)            (None, 30, 30, 8)         224       
_______________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 15, 15, 8)         0         
_______________________________________________________________
flatten_1 (Flatten)          (None, 1800)              0         
_______________________________________________________________
dense_1 (Dense)              (None, 256)               461056    
_______________________________________________________________
dense_2 (Dense)              (None, 10)                2570      
_______________________________________________________________
activation_1 (Activation)    (None, 10)                0         
===============================================================
Total params: 463,850
Trainable params: 463,850
Non-trainable params: 0

你可以看到这是一个如此简单的网络,它已经有了 463 K 个参数。层数的数量是误导性的。你并不一定需要很多层来得到一个慢速的网络。

这是性能:

Training time: 90.96391367912292
Min Loss: 0.8851623952198029
Min Validation Loss: 1.142119802236557
Max Accuracy: 0.68706
Max Validation Accuracy: 0.6068999767303467

现在,下一步是调整它。所以,让我们试试吧。

调整卷积层

让我们在卷积层中使用 32 个通道:

Total params: 1,846,922
Training time: 124.37444043159485
Min Loss: 0.6110964662361145
Min Validation Loss: 1.0291267457723619
Max Accuracy: 0.78486
Max Validation Accuracy: 0.6568999886512756

还不错!准确率提高了,尽管比之前大 4 倍,但它的速度慢了不到 50%。

现在我们尝试堆叠 4 层:

model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu'))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))

让我们用model.summary()检查网络的大小,就像通常那样:

Total params: 465,602

它只是比初始模型稍微大一点!原因是由于密集层,大多数参数都存在,并且堆叠相同大小的卷积层并不会改变密集层所需的参数。这就是结果:

Training time: 117.05060386657715
Min Loss: 0.6014562886440754
Min Validation Loss: 1.0268916247844697
Max Accuracy: 0.7864
Max Validation Accuracy: 0.6520000100135803

它非常相似——稍微快一点,准确率基本上相同。由于网络有多个层,它可以学习更复杂的函数。然而,它有一个更小的密集层,因此由于这个原因,它失去了一些准确率。

我们不使用相同的填充,而是尝试使用valid,这将每次减少卷积层的输出大小:

model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu'))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding="valid"))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding="valid"))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding="valid"))

参数数量显著减少,从 465,602:

Total params: 299,714

我们现在使用不到 300 K 个参数,如下所示:

Training time: 109.74382138252258
Min Loss: 0.8018992121839523
Min Validation Loss: 1.0897881112098693
Max Accuracy: 0.71658
Max Validation Accuracy: 0.6320000290870667

非常有趣的是,训练准确率下降了 7%,因为网络对于这个任务来说太小了。然而,验证准确率只下降了 2%。

现在我们使用初始模型,但使用相同的填充,因为这会给我们在卷积后处理一个稍微大一点的图像:

model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], padding="same", activation='relu'))
Total params: 527,338

我们现在有更多的参数,这是性能:

Training time: 91.4407947063446
Min Loss: 0.7653912879371643
Min Validation Loss: 1.0724352446556091
Max Accuracy: 0.73126
Max Validation Accuracy: 0.6324999928474426

与参考模型相比,准确度都有所提高,而时间几乎保持不变,因此这是一个积极的实验。

现在我们将核的大小增加到 7x7:

model.add(Conv2D(8, (7, 7), input_shape=x_train.shape[1:], padding="same", activation='relu'))
Total params: 528,298

由于核现在更大,参数的数量增加是可以忽略不计的。但是它的表现如何?让我们检查一下:

Training time: 94.85121083259583
Min Loss: 0.7786661441159248
Min Validation Loss: 1.156547416305542
Max Accuracy: 0.72674
Max Validation Accuracy: 0.6090999841690063

不太理想。它稍微慢一些,准确度也稍微低一些。很难知道原因;也许是因为输入图像太小。

我们知道在卷积层之后添加 MaxPooling 层是一个典型的模式,所以让我们看看我们如何调整它。

调整 MaxPooling

让我们回到之前的模型,并且只去掉MaxPooling

Total params: 1,846,250

移除MaxPooling意味着密集层现在大了 4 倍,因为卷积层的分辨率不再降低:

Training time: 121.01851439476013
Min Loss: 0.8000291277170182
Min Validation Loss: 1.2463579467773438
Max Accuracy: 0.71736
Max Validation Accuracy: 0.5710999965667725

这看起来并不太高效。与原始网络相比,它更慢,准确度有所提高,但验证准确度却下降了。与具有四个卷积层的网络相比,它具有相同的速度,但验证准确度却远远低于后者。

看起来 MaxPooling 在减少计算的同时提高了泛化能力。毫不奇怪,它被广泛使用。

现在我们增加 MaxPooling 层的数量:

model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu'))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))
model.add(MaxPooling2D())
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))
model.add(Conv2D(8, (3, 3), input_shape=x_train.shape[1:], activation='relu', padding = "same"))
model.add(MaxPooling2D())

由于第二卷积层现在是原来大小的四分之一,因此大小现在要小得多:

Total params: 105,154

让我们检查一下性能:

Training time: 105.30972981452942
Min Loss: 0.8419396163749695
Min Validation Loss: 0.9395202528476715
Max Accuracy: 0.7032
Max Validation Accuracy: 0.6686999797821045

虽然训练准确度并不高,但验证准确度是我们所达到的最佳水平,而且所有这些只使用了 100 K 个参数!

在调整网络的卷积部分之后,现在是时候看看我们如何调整由密集层组成的部分。

调整密集层

让我们回到初始模型,并将密集层增加到 4 倍,即 1,024 个神经元:

Total params: 1,854,698

如预期的那样,参数的数量几乎增加了四倍。但是性能如何?

Training time: 122.05767631530762
Min Loss: 0.6533840216350555
Min Validation Loss: 1.093649614238739
Max Accuracy: 0.7722
Max Validation Accuracy: 0.630299985408783

训练准确度还不错,但与最佳模型相比,验证准确度较低。

让我们尝试使用三个密集层:

model.add(Dense(units = 512, activation = "relu"))
model.add(Dense(units = 256, activation = "relu"))
model.add(Dense(units = 128, activation = "relu"))

现在我们得到了以下参数:

Total params: 1,087,850

参数的数量现在更少了:

Training time: 111.73353481292725
Min Loss: 0.7527586654126645
Min Validation Loss: 1.1094331634044647
Max Accuracy: 0.7332
Max Validation Accuracy: 0.6115000247955322

结果可能有些令人失望。我们可能不应该过多地依赖增加密集层的数量。

下一步现在是要训练网络。让我们开始。

训练网络

我们现在可以更深入地讨论训练阶段,这是“魔法”发生的地方。我们甚至不会尝试描述其背后的数学概念。我们只会用非常通用和简化的术语讨论用于训练神经网络的算法。

我们需要一些定义:

  • 损失函数代价函数:一个计算神经网络预测与预期标签之间距离的函数;它可能是MSE(即均方误差)或更复杂的某种函数。

  • 导数:函数的导数是一个新函数,可以测量函数在特定点上的变化程度(以及变化方向)。例如,如果你想象自己在一辆车上,速度可以是你的初始函数,其导数是加速度。如果速度是恒定的,导数(例如,加速度)为零;如果速度在增加,导数将是正的,如果速度在减少,导数将是负的。

  • 局部最小值:神经网络的工作是使损失函数最小化。考虑到参数数量巨大,神经网络的函数可以非常复杂,因此达到全局最小值可能是不可能的,但网络仍然可以达到一个很好的局部最小值。

  • 收敛:如果网络持续接近一个好的局部最小值,那么它就是在收敛。

使用这些定义,我们现在将看到训练实际上是如何进行的。

如何训练网络

算法由两部分组成,为了简化,让我们说它是为每个样本执行的,当然,整个过程在每个 epoch 都会重复。所以,让我们看看它是如何工作的:

  • 前向传播:最终,你的神经网络只是一个具有许多参数(权重和可能的偏差)以及许多操作的函数,当提供输入时,可以计算一些输出。在前向传播中,我们计算预测和损失。

  • 反向传播:优化器(例如,Adam 或随机梯度下降)向(从最后一层到第一层)更新所有权重(例如,所有参数),试图最小化损失函数;学习率(一个介于 0 和 1.0 之间的数字,通常为 0.01 或更小)决定了权重将调整多少。

更大的学习率可以使它们训练得更快,但可能会跳过局部最小值,而较小的学习率可能会收敛,但花费太多时间。优化器正在积极研究,以尽可能提高训练速度,并且它们会动态地改变学习率来提高速度和精度。

Adam 是一个可以动态改变每个参数学习率的优化器示例:

图 5.4 – 梯度下降寻找最小值

图 5.4 – 梯度下降寻找最小值

虽然编写训练神经网络的算法非常复杂,但从某种意义上说,这个概念与某人试图学习打台球类似,直到重复相同的复杂射击直到成功。你选择你想要击中的点(标签),你做出你的动作(前向传播),你评估你离目标有多远,然后你尝试调整力量、方向以及所有其他变量(权重)。我们也有一种随机初始化的方法。让我们试试下一个。

随机初始化

你可能会想知道第一次运行神经网络时参数的值。将权重初始化为零效果不佳,而使用小的随机数则相当有效。Keras 提供了多种算法供你选择,你也可以更改标准差。

这个有趣的结果是,神经网络开始时带有相当数量的随机数据,你可能注意到在同一个数据集上用同一个模型训练实际上会产生不同的结果。让我们用我们之前的基本 CIFAR-10 CNN 来尝试。

第一次尝试产生了以下结果:

Min Loss: 0.8791077242803573
Min Validation Loss: 1.1203862301826477
Max Accuracy: 0.69174
Max Validation Accuracy: 0.5996000170707703

第二次尝试产生了以下结果:

Min Loss: 0.8642362675189972
Min Validation Loss: 1.1310886552810668
Max Accuracy: 0.69624
Max Validation Accuracy: 0.6100000143051147

你可以尝试使用以下代码来减少随机性:

from numpy.random import seed
seed(1)
import tensorflow as tf
tf.random.set_seed(1)

然而,如果你在 GPU 上训练,仍然可能存在许多变化。在调整你的网络模型时,你应该考虑这一点,否则你可能会因为随机性而排除小的改进。

下一个阶段是了解过拟合和欠拟合是什么。

过拟合和欠拟合

在训练神经网络时,你将在欠拟合过拟合之间进行斗争。让我们看看如何:

  • 欠拟合是指模型过于简单,无法正确学习数据集。你需要添加参数、滤波器和神经元来增加模型的容量。

  • 过拟合是指你的模型足够大,可以学习训练数据集,但它无法泛化(例如,它记忆了数据集,但当你提供其他数据时效果不佳)。

你也可以从准确率和损失随时间变化的图中看到:

图 5.5 – 欠拟合:模型太小(7,590 个参数)且没有学习到很多

图 5.5 – 欠拟合:模型太小(7,590 个参数)且没有学习到很多

前面的图表显示了一个极端的欠拟合,准确率保持非常低。现在参考以下图表:

图 5.6 – 过拟合:模型非常大(29,766,666 个参数)且没有很好地泛化

图 5.6 – 过拟合:模型非常大(29,766,666 个参数)且没有很好地泛化

图 5.6 展示了一个神经网络过拟合的相对极端例子。你可能注意到,虽然训练损失随着 epoch 的增加而持续下降,但验证损失在第一个 epoch 达到最小值,然后持续增加。验证损失的最小值是你想要停止训练的地方。在第六章,“改进你的神经网络”中,我们将看到一个允许我们做到这一点或类似的技术——提前停止

虽然你可能听说过过拟合是一个大问题,但实际上,首先尝试得到一个可以过拟合训练数据集的模型,然后应用可以减少过拟合并提高泛化能力的技术的策略可能是一个好方法。然而,这只有在你能以非常高的精度过拟合训练数据集的情况下才是好的。

我们将在第六章“改进你的神经网络”中看到非常有效的方法来减少过拟合,但有一点需要考虑的是,较小的模型不太容易过拟合,而且通常也更快。所以,当你试图过拟合训练数据集时,尽量不要使用一个非常大的模型。

在本节中,我们看到了如何使用损失图来了解我们在网络训练中的位置。在下一节中,我们将看到如何可视化激活,以了解我们的网络正在学习什么。

可视化激活

现在我们可以训练一个神经网络。太好了。但神经网络能看懂和理解什么?这是一个很难回答的问题,但既然卷积输出一个图像,我们可以尝试展示这一点。现在让我们尝试展示 MINST 测试数据集前 10 个图像的激活:

  1. 首先,我们需要构建一个模型,这个模型是从我们之前的模型派生出来的,它从输入读取并得到我们想要的卷积层作为输出。名称可以来自摘要。我们将可视化第一个卷积层,conv2d_1

    conv_layer = next(x.output for x in model.layers if     x.output.name.startswith(conv_name))act_model = models.Model(inputs=model.input, outputs=[conv_layer])activations = act_model.predict(x_test[0:num_predictions, :, :, :])
    
  2. 现在,对于每个测试图像,我们可以取所有激活并将它们连接起来以得到一个图像:

    col_act = []
    for pred_idx, act in enumerate(activations):
        row_act = []
        for idx in range(act.shape[2]):
            row_act.append(act[:, :, idx])
        col_act.append(cv2.hconcat(row_act))
    
  3. 然后我们可以展示它:

    plt.matshow(cv2.vconcat(col_act), cmap='viridis')plt.show()
    

这是第一卷积层conv2d_1的结果,它有 6 个通道,28x28:

图 5.7 – MNIST,第一卷积层的激活

图 5.7 – MNIST,第一卷积层的激活

这看起来很有趣,但试图理解激活,以及通道学习识别的内容,总是涉及一些猜测工作。最后一个通道似乎专注于水平线,第三和第四个通道不是很强,这可能意味着网络没有正确训练,或者它已经比所需的要大。但看起来不错。

让我们现在检查第二层,conv2d_2,它有 16 个通道,10x10:

图 5.8 – MNIST,第二卷积层的激活

图 5.8 – MNIST,第二卷积层的激活

现在更复杂了——输出要小得多,我们有的通道也更多。看起来有些通道正在检测水平线,而有些则专注于对角线或垂直线。那么第一个最大池化层,max_pooling2d_1呢?它的分辨率低于原始通道,为 10x10,但它选择最大激活,应该是可以理解的。参考以下截图:

图 5.9 – MNIST,第一个最大池化层的激活状态

图 5.9 – MNIST,第一个最大池化层的激活状态

的确,激活状态看起来很好。为了好玩,让我们检查第二个最大池化层,max_pooling2d_2,它的大小是 5x5:

图 5.10 – MNIST,第二个最大池化层的激活状态

图 5.10 – MNIST,第二个最大池化层的激活状态

现在看起来很混乱,但仍然可以看出一些通道正在识别水平线,而另一些则专注于垂直线。这就是密集层发挥作用的时候,因为它们试图理解这些难以理解但并非完全随机的激活状态。

可视化激活状态对于了解神经网络正在学习什么、通道是如何被使用的非常有用,并且它是你在训练神经网络时可以使用的另一个工具,尤其是当你觉得它学习得不好,正在寻找问题时。

现在我们将讨论推理,这实际上是训练神经网络的全部目的。

推理

推理是将输入提供给你的网络并得到分类或预测的过程。当神经网络经过训练并部署到生产环境中时,我们使用它,例如,来分类图像或决定如何在道路上驾驶,这个过程称为推理。

第一步是加载模型:

model = load_model(os.path.join(dir_save, model_name))

然后,你只需调用 predict(),这是 Keras 中的推理方法。让我们用 MNIST 的第一个测试样本来试一试:

x_pred = model.predict(x_test[0:1, :, :, :])print("Expected:", np.argmax(y_test))print("First prediction probabilities:", x_pred)print("First prediction:", np.argmax(x_pred))

这是我的 MNIST 网络的结果:

Expected: 7
First prediction probabilities: [[6.3424804e-14 6.1755254e-06 2.5011676e-08 2.2640785e-07 9.0170204e-08 7.4626680e-11 5.6195684e-13 9.9999273e-01 1.9735349e-09 7.3219508e-07]]
First prediction: 7

predict() 函数的结果是一个概率数组,这对于评估网络的置信度非常方便。在这种情况下,所有数字都非常接近零,除了数字 7,因此它是网络的预测,置信度超过 99.999%!在现实生活中,不幸的是,你很少看到网络工作得如此好!

在推理之后,有时你可能想要定期在新样本上重新训练,以改进网络。让我们看看这需要做什么。

重新训练

有时候,一旦你得到了一个表现良好的神经网络,你的工作就完成了。然而,有时候你可能想要在新样本上重新训练它,以获得更高的精度(因为你的数据集现在更大了),或者如果你的训练数据集变得相对快速过时,以获得更新的结果。

在某些情况下,你可能甚至想要持续不断地重新训练,例如每周一次,并将新模型自动部署到生产环境中。

在这种情况下,你有一个强大的程序来验证你在验证数据集上新的模型性能,以及在新的、可丢弃的测试数据集上性能是至关重要的。也许还建议保留所有模型的备份,并尝试找到一种方法来监控生产中的性能,以便快速识别异常。在自动驾驶汽车的情况下,我预计模型在投入生产之前将经历严格的自动和手动测试,但其他没有安全问题的行业可能要宽松得多。

有了这些,我们结束了关于重新训练的主题。

摘要

这是一章内容密集的章节,但希望你能更好地了解神经网络是什么以及如何训练它们。

我们讨论了很多关于数据集的内容,包括如何获取用于训练、验证和测试的正确数据集。我们描述了分类器是什么,并实现了数据增强。然后我们讨论了模型以及如何调整卷积层、MaxPooling 层和密集层。我们看到了训练是如何进行的,什么是反向传播,讨论了随机性在权重初始化中的作用,并展示了欠拟合和过度拟合网络的图表。为了了解我们的 CNN 表现如何,我们甚至可视化激活。然后我们讨论了推理和重新训练。

这意味着你现在有足够的知识来选择或创建一个数据集,从头开始训练一个神经网络,并且你将能够理解模型或数据集的变化是否提高了精度。

第六章 提高你的神经网络中,我们将看到如何将这些知识应用到实践中,以便显著提高神经网络的精度。

问题

在阅读这一章后,你应该能够回答以下问题:

  1. 你可以重复使用测试数据集吗?

  2. 数据增强是什么?

  3. Keras 中的数据增强是否向你的数据集添加图像?

  4. 哪一层倾向于具有最多的参数?

  5. 观察损失曲线,你如何判断一个网络正在过度拟合?

  6. 网络过度拟合是否总是不好的?

第六章:第六章:改进你的神经网络

第四章“使用神经网络的深度学习”中,我们设计了一个能够在训练数据集中达到几乎 93%准确率的网络,但它在验证数据集中的准确率却低于 66%。

在本章中,我们将继续改进那个神经网络,目标是显著提高验证准确率。我们的目标是达到至少 80%的验证准确率。我们将应用在第五章“深度学习工作流程”中获得的一些知识,我们还将学习一些对我们非常有帮助的新技术,例如批量归一化。

我们将涵盖以下主题:

  • 通过数据增强减少参数数量

  • 增加网络大小和层数

  • 理解批量归一化

  • 使用提前停止提高验证准确率

  • 通过数据增强几乎增加数据集大小

  • 使用 dropout 提高验证准确率

  • 使用空间 dropout 提高验证准确率

技术要求

本章的完整源代码可以在以下位置找到:github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter6

本章需要以下软件先决条件,以及以下基本知识将有助于更好地理解本章:

  • Python 3.7

  • NumPy 模块

  • Matplotlib 模块

  • TensorFlow 模块

  • Keras 模块

  • OpenCV-Python 模块

  • 推荐的 GPU

本章的“代码实战”视频可以在以下位置找到:

bit.ly/3dGIdJA

更大的模型

训练自己的神经网络是一种艺术;你需要直觉、一些运气、大量的耐心,以及你能找到的所有知识和帮助。你还需要资金和时间,要么购买更快的 GPU,使用集群测试更多配置,或者付费获取更好的数据集。

但没有真正的食谱。话虽如此,我们将把我们的旅程分为两个阶段,如第五章“深度学习工作流程”中所述:

  • 过拟合训练数据集

  • 提高泛化能力

我们将从第四章“使用神经网络的深度学习”中我们留下的地方开始,我们的基本模型在 CIFAR-10 上达到了 66%的验证准确率,然后我们将显著改进它,首先是让它更快,然后是让它更精确。

起始点

以下是我们第四章“使用神经网络的深度学习”中开发的模型,该模型由于在相对较低的验证准确率下实现了较高的训练准确率,因此过拟合了数据集:

model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:]))
model.add(AveragePooling2D())
model.add(Conv2D(filters=256, kernel_size=(3, 3),    activation='relu'))
model.add(AveragePooling2D())

model.add(Flatten())
model.add(Dense(units=512, activation='relu'))
model.add(Dense(units=256, activation='relu'))
model.add(Dense(units=num_classes, activation = 'softmax'))

它是一个浅但相对较大的模型,因为它有以下数量的参数:

Total params: 5,002,506

我们之前训练了 12 个 epoch,结果如下:

Training time: 645.9990749359131
Min Loss: 0.12497963292273692
Min Validation Loss: 0.9336215916395187
Max Accuracy: 0.95826
Max Validation Accuracy: 0.6966000199317932

训练准确率实际上对我们来说已经足够好了(在这里,在这个运行中,它高于第五章,“深度学习工作流程”,主要是因为随机性),但验证准确率也很低。它是过拟合的。因此,我们甚至可以将其作为起点,但最好对其进行一点调整,看看我们是否可以做得更好或使其更快。

我们还应该关注五个 epoch,因为我们可能会在较少的 epoch 上进行一些测试,以加快整个过程:

52s 1ms/step - loss: 0.5393 - accuracy: 0.8093 - val_loss: 0.9496 - val_accuracy: 0.6949 

当你使用较少的 epoch 时,你是在赌自己能够理解曲线的演变,因此你是在用开发速度换取选择准确性。有时,这很好,但有时则不然。

我们的模型太大,所以我们将开始减小其尺寸并稍微加快训练速度。

提高速度

我们的模式不仅非常大——事实上,它太大了。第二个卷积层有 256 个过滤器,与密集层的 512 个神经元结合,它们使用了大量的参数。我们可以做得更好。我们知道我们可以将它们分成 128 个过滤器的层,这将节省几乎一半的参数,因为密集层现在只需要一半的连接。

我们可以尝试一下。我们在第四章,“使用神经网络的深度学习”中了解到,为了在卷积后不丢失分辨率,我们可以在两层(密集层省略)上以相同的方式使用填充,如下所示:

model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:]))
model.add(AveragePooling2D())

model.add(Conv2D(filters=128, kernel_size=(3, 3),     activation='relu', padding="same"))
model.add(Conv2D(filters=128, kernel_size=(3, 3),     activation='relu', padding="same"))
model.add(AveragePooling2D())

在这里,我们可以看到现在的参数数量更低:

Total params: 3,568,906

让我们检查完整的结果:

Training time: 567.7167596817017
Min Loss: 0.1018450417491654
Min Validation Loss: 0.8735350118398666
Max Accuracy: 0.96568
Max Validation Accuracy: 0.7249000072479248

太好了!它更快了,准确率略有提高,而且,验证也提高了!

让我们在第一层做同样的操作,但这次不增加分辨率,以免增加参数,因为,在两个卷积层之间,增益较低:

model.add(Conv2D(filters=32, kernel_size=(3, 3),     activation='relu', input_shape=x_train.shape[1:]))model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:], padding="same"))model.add(AveragePooling2D())model.add(Conv2D(filters=128, kernel_size=(3, 3),     activation='relu', padding="same"))model.add(Conv2D(filters=128, kernel_size=(3, 3),     activation='relu', padding="same"))model.add(AveragePooling2D())

当我们尝试这样做时,我们得到以下结果:

Training time: 584.955037355423
Min Loss: 0.10728564778155182
Min Validation Loss: 0.7890052844524383
Max Accuracy: 0.965
Max Validation Accuracy: 0.739300012588501

这与之前类似,尽管验证准确率略有提高。

接下来,我们将添加更多层。

增加深度

之前的模型实际上是一个很好的起点。

但我们将添加更多层,以增加非线性激活的数量,并能够学习更复杂的函数。这是模型(密集层省略):

model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:], padding="same"))model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:], padding="same"))model.add(AveragePooling2D())model.add(Conv2D(filters=128, kernel_size=(3, 3), activation='relu', padding="same"))model.add(Conv2D(filters=128, kernel_size=(3, 3), activation='relu', padding="same"))model.add(AveragePooling2D())model.add(Conv2D(filters=256, kernel_size=(3, 3), activation='relu', padding="same"))model.add(Conv2D(filters=256, kernel_size=(3, 3), activation='relu', padding="same"))model.add(AveragePooling2D())

这是结果:

Training time: 741.1498856544495
Min Loss: 0.22022022939510644
Min Validation Loss: 0.7586277635633946
Max Accuracy: 0.92434
Max Validation Accuracy: 0.7630000114440918

网络现在明显变慢,准确率下降(可能因为需要更多的 epoch),但验证准确率提高了。

让我们现在尝试减少密集层,如下所示(卷积层省略):

model.add(Flatten())model.add(Dense(units=256, activation='relu'))model.add(Dense(units=128, activation='relu'))model.add(Dense(units=num_classes, activation = 'softmax'))

现在,我们有更少的参数:

Total params: 2,162,986

但发生了一件非常糟糕的事情:

Training time: 670.0584089756012
Min Loss: 2.3028031995391847
Min Validation Loss: 2.302628245162964
Max Accuracy: 0.09902
Max Validation Accuracy: 0.10000000149011612

两个验证都下降了!事实上,它们现在为 10%,或者如果你愿意,网络现在正在产生随机结果——它没有学习!

你可能会得出结论说我们把它搞坏了。实际上并非如此。只需再次运行它,利用随机性来利用它,我们的网络就会按预期学习:

Training time: 686.5172057151794
Min Loss: 0.24410496438018978
Min Validation Loss: 0.7960220139861107
Max Accuracy: 0.91434
Max Validation Accuracy: 0.7454000115394592

然而,这并不是一个好兆头。这可能是由于层数的增加,因为具有更多层的网络更难训练,因为原始输入在向上层传播时可能存在问题。

让我们检查一下图表:

图 6.1 – 损失和准确率图

图 6.1 – 损失和准确率图

你可以看到,虽然训练损失(蓝色线)持续下降,但经过一些个 epoch 后,验证损失(橙色线)开始上升。正如在 第五章深度学习工作流程 中解释的那样,这意味着模型过拟合了。这不一定是最优模型,但我们将继续开发它。

在下一节中,我们将简化这个模型。

一个更高效的网络

在我的笔记本电脑上训练先前的模型需要 686 秒,并达到 74.5% 的验证准确率和 91.4% 的训练准确率。理想情况下,为了提高效率,我们希望在保持准确率不变的同时减少训练时间。

让我们检查一些卷积层:

图 6.2 – 第一卷积层,32 个通道

图 6.2 – 第一卷积层,32 个通道

我们已经在 第五章深度学习工作流程 中看到了这些激活图,并且我们知道黑色通道没有达到大的激活,因此它们对结果贡献不大。在实践中,看起来一半的通道都没有使用。让我们尝试在每个卷积层中将通道数减半:

model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:], padding="same"))
model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:], padding="same"))
model.add(AveragePooling2D())

model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(AveragePooling2D())

model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    padding="same"))

这是我们的结果:

Total params: 829,146

如预期,参数数量现在少得多,训练速度也快得多:

Training time: 422.8525400161743
Min Loss: 0.27083665314182637
Min Validation Loss: 0.8076118688702584
Max Accuracy: 0.90398
Max Validation Accuracy: 0.7415000200271606

我们可以看到,我们失去了一些准确率,但不是太多:

图 6.3 – 第一卷积层,16 个通道

图 6.3 – 第一卷积层,16 个通道

现在好一些了。让我们检查第二层:

图 6.4 – 第二卷积层,16 个通道

图 6.4 – 第二卷积层,16 个通道

这也好一些。让我们检查一下第四卷积层:

图 6.5 – 第四卷积层,64 个通道

图 6.5 – 第四卷积层,64 个通道

它看起来有点空。让我们将第三层和第四层减半:

Total params: 759,962

我们得到了以下结果:

Training time: 376.09818053245544
Min Loss: 0.30105597005218265
Min Validation Loss: 0.8148738072395325
Max Accuracy: 0.89274
Max Validation Accuracy: 0.7391999959945679

训练准确率下降了,但验证准确率仍然不错:

图 6.6 – 第四卷积层,32 个通道

图 6.6 – 第四卷积层,32 个通道

让我们检查第六卷积层:

图 6.7 – 第六卷积层,128 个通道

图 6.7 – 第六卷积层,128 个通道

它有点空。让我们也将最后两个卷积层减半:

Total params: 368,666

它要小得多,这些结果是:

Training time: 326.9148383140564
Min Loss: 0.296858479853943
Min Validation Loss: 0.7925313812971115
Max Accuracy: 0.89276
Max Validation Accuracy: 0.7425000071525574

它看起来仍然不错。让我们检查一下激活情况:

图 6.8 – 第六卷积层,64 个通道

图 6.8 – 第六卷积层,64 个通道

你可以看到现在,许多通道被激活了,这希望是神经网络更好地利用其资源的指示。

将这个模型与上一节中构建的模型进行比较,你可以看到这个模型可以在不到一半的时间内训练完成,验证准确率几乎未变,训练准确率略有下降,但不是很多。所以,它确实更有效率。

在下一节中,我们将讨论批量归一化,这是一个在现代神经网络中非常常见的层。

使用批量归一化构建更智能的网络

我们将提供给网络的输入进行归一化,将范围限制在 0 到 1 之间,因此在中部网络中也这样做可能是有益的。这被称为批量归一化,它确实很神奇!

通常,你应该在想要归一化的输出之后、激活之前添加批量归一化,但添加在激活之后可能会提供更快的性能,这正是我们将要做的。

这是新的代码(省略了密集层):

model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:], padding="same"))model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu',    input_shape=x_train.shape[1:], padding="same"))model.add(BatchNormalization())model.add(AveragePooling2D())model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu',    padding="same"))model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu',    padding="same"))model.add(BatchNormalization())model.add(AveragePooling2D())model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    padding="same"))model.add(Conv2D(filters=64, kernel_size=(3, 3), activation='relu',    padding="same"))model.add(BatchNormalization())model.add(AveragePooling2D())

参数数量仅略有增加:

Total params: 369,114

这是结果:

Training time: 518.0608556270599
Min Loss: 0.1616916553277429
Min Validation Loss: 0.7272815862298012
Max Accuracy: 0.94308
Max Validation Accuracy: 0.7675999999046326

还不错,尽管不幸的是,现在它要慢得多。但我们可以添加更多的批量归一化,看看是否可以改善情况:

Training time: 698.9837136268616
Min Loss: 0.13732857785719446
Min Validation Loss: 0.6836542286396027
Max Accuracy: 0.95206
Max Validation Accuracy: 0.7918999791145325

是的,准确率都有所提高。我们实际上非常接近我们最初的 80%准确率的目标。但让我们更进一步,看看我们能做什么。

到目前为止,我们只使用了 ReLU 激活函数,但即使它被广泛使用,它也不是唯一的。Keras 支持多种激活函数,有时进行实验是值得的。我们将坚持使用 ReLU。

让我们检查一些激活情况:

图 6.9 – 第二卷积层,16 个通道,批量归一化

图 6.9 – 第二卷积层,16 个通道,批量归一化

现在,第二层的所有通道都在学习。非常好!

这是第四层的成果:

图 6.10 – 第四卷积层,32 个通道,批量归一化

图 6.10 – 第四卷积层,32 个通道,批量归一化

这是第六层的成果:

图 6.11 – 第六卷积层,64 个通道,批量归一化

图 6.11 – 第六卷积层,64 个通道,批量归一化

它开始看起来不错了!

让我们尝试可视化批量归一化对第一层在批量归一化前后激活情况的影响:

图 6.12 – 第一卷积层,16 个通道,批量归一化前后的激活情况

图 6.12 – 第一卷积层,16 个通道,批标准化前后的对比

你可以看到,通道的强度现在更加均匀;不再有既无活动又没有非常强烈的激活的通道。然而,那些不活跃的通道仍然没有真实信息。

让我们也检查第二层:

图 6.13 – 第二卷积层,16 个通道,批标准化前后的对比

图 6.13 – 第二卷积层,16 个通道,批标准化前后的对比

这里可能不太明显,但你仍然可以看到通道之间的差异减少了,因为很明显,它们已经被归一化。直观上看,这有助于通过层传播较弱的信号,并且它有一些正则化效果,这导致更好的验证准确率。

既然我们已经讨论了批标准化,现在是时候进一步讨论什么是批,以及批的大小有什么影响。

选择合适的批大小

在训练过程中,我们有大量的样本,通常从几千到几百万。如果你还记得,优化器将计算损失函数并更新超参数以尝试减少损失。它可以在每个样本之后这样做,但结果可能会很嘈杂,连续的变化可能会减慢训练。在另一端,优化器可能只在每个 epoch 更新一次超参数——例如,使用梯度的平均值——但这通常会导致泛化不良。通常,批大小有一个范围,比这两个极端表现更好,但不幸的是,它取决于特定的神经网络。

较大的批量大小的确可以略微提高 GPU 上的训练时间,但如果你的模型很大,你可能会发现你的 GPU 内存是批量大小的限制因素。

批标准化也受到批量大小的影響,因为小批量会降低其有效性(因为没有足够的数据进行适当的归一化)。

考虑到这些因素,最好的做法是尝试。通常,你可以尝试使用 16、32、64 和 128,如果看到最佳值在范围的极限,最终可以扩展范围。

正如我们所见,最佳批大小可以提高准确率并可能提高速度,但还有一种技术可以帮助我们在加快速度的同时提高验证准确率,或者至少简化训练:提前停止。

提前停止

我们应该在什么时候停止训练?这是一个好问题!理想情况下,你希望在最小验证错误时停止。虽然你事先不知道这一点,但你可以通过检查损失来获得需要多少个 epoch 的概览。然而,当你训练你的网络时,有时你需要更多的 epoch,这取决于你如何调整你的模型,而且事先知道何时停止并不简单。

我们已经知道我们可以使用ModelCheckpoint,这是 Keras 的一个回调,在训练过程中保存具有最佳验证错误的模型。

但还有一个非常有用的回调,EarlyStopping,当预定义的一组条件发生时,它会停止训练:

stop = EarlyStopping(min_delta=0.0005, patience=7, verbose=1)

配置提前停止最重要的参数如下:

  • monitor: 这决定了要监控哪个参数,默认为验证损失。

  • min_delta: 如果 epoch 之间的验证损失差异低于此值,则认为损失没有变化。

  • 耐心: 这是允许在停止训练之前没有验证改进的 epoch 数量。

  • verbose: 这是指示 Keras 提供更多信息。

我们需要提前停止的原因是,在数据增强和 dropout 的情况下,我们需要更多的 epoch,而不是猜测何时停止,我们将使用提前停止来为我们完成这项工作。

现在让我们来谈谈数据增强。

使用数据增强改进数据集

是时候使用数据增强了,基本上,增加我们数据集的大小。

从现在起,我们将不再关心训练数据集的精度,因为这项技术会降低它,但我们将关注验证精度,预计它会提高。

我们也预计需要更多的 epoch,因为我们的数据集现在更难了,所以我们将 epoch 设置为500(尽管我们并不打算达到它)并使用具有7个 patience 的EarlyStopping

让我们尝试这个增强:

ImageDataGenerator(rotation_range=15, width_shift_range=[-5, 0, 5],    horizontal_flip=True)

您应该注意不要过度操作,因为网络可能会学习到一个与验证集差异很大的数据集,在这种情况下,您将看到验证精度停滞在 10%。

这是结果:

Epoch 00031: val_loss did not improve from 0.48613
Epoch 00031: early stopping
Training time: 1951.4751739501953
Min Loss: 0.3638068118467927
Min Validation Loss: 0.48612626193910835
Max Accuracy: 0.87454
Max Validation Accuracy: 0.8460999727249146

提前停止在31个 epoch 后中断了训练,我们达到了 84%以上的验证精度——还不错。正如预期的那样,我们现在需要更多的 epoch。这是损失图:

图 6.14 – 使用数据增强和提前停止的损失

图 6.14 – 使用数据增强和提前停止的损失

您可以看到训练精度一直在增加,而验证精度在某些时候下降了。网络仍然有点过拟合。

让我们检查第一卷积层的激活:

图 6.15 – 使用数据增强和提前停止的第一卷积层,16 个通道

图 6.15 – 使用数据增强和提前停止的第一卷积层,16 个通道

它略有改善,但可能还会再次提高。

我们可以尝试稍微增加数据增强:

ImageDataGenerator(rotation_range=15, width_shift_range=[-8, -4, 0,    4, 8], horizontal_flip=True, height_shift_range=[-5, 0, 5],    zoom_range=[0.9, 1.1])

这是结果:

Epoch 00040: early stopping
Training time: 2923.3936190605164
Min Loss: 0.5091392234659194
Min Validation Loss: 0.5033097203373909
Max Accuracy: 0.8243
Max Validation Accuracy: 0.8331999778747559

这个模型速度较慢且精度较低。让我们看看图表:

图 6.16 – 增加数据增强和提前停止的损失

图 6.16 – 增加数据增强和提前停止的损失

可能需要更多的耐心。我们将坚持之前的数据增强。

在下一节中,我们将分析一种简单但有效的方法,通过使用 dropout 层来提高验证准确率。

使用 dropout 提高验证准确率

过拟合的一个来源是神经网络更依赖于一些神经元来得出结论,如果这些神经元出错,网络也会出错。减少这种问题的一种方法是在训练期间随机关闭一些神经元,而在推理期间保持它们正常工作。这样,神经网络就能学会更加抵抗错误,更好地泛化。这种机制被称为dropout,显然,Keras 支持它。Dropout 会增加训练时间,因为网络需要更多的 epoch 来收敛。它可能还需要更大的网络,因为在训练期间一些神经元会被随机关闭。当数据集对于网络来说不是很大时,它更有用,因为它更有可能过拟合。实际上,由于 dropout 旨在减少过拟合,如果你的网络没有过拟合,它带来的好处很小。

对于密集层,dropout 的典型值是 0.5,尽管我们可能使用略小的值,因为我们的模型过拟合不多。我们还将增加耐心20,因为现在模型需要更多的 epoch 来训练,验证损失可能会波动更长的时间。

让我们尝试在密集层中添加一些 dropout:

model.add(Flatten())
model.add(Dense(units=256, activation='relu'))
model.add(Dropout(0.4))
model.add(Dense(units=128, activation='relu'))
model.add(Dropout(0.2))
model.add(Dense(units=num_classes, activation = 'softmax'))

这是结果:

Epoch 00097: early stopping
Training time: 6541.777503728867
Min Loss: 0.38114651718586684
Min Validation Loss: 0.44884318161308767
Max Accuracy: 0.87218
Max Validation Accuracy: 0.8585000038146973

这有点令人失望。训练花费了很长时间,但只有微小的进步。我们假设我们的密集层有点小。

让我们将层的尺寸增加 50%,同时增加第一个密集层中的 dropout,并减少第二个密集层中的 dropout:

model.add(Flatten())model.add(Dense(units=384, activation='relu'))model.add(Dropout(0.5))model.add(Dense(units=192, activation='relu'))model.add(Dropout(0.1))model.add(Dense(units=num_classes, activation='softmax'))

当然,它更大,正如我们在这里可以看到的:

Total params: 542,426

它的结果略好一些:

Epoch 00122: early stopping
Training time: 8456.040931940079
Min Loss: 0.3601766444931924
Min Validation Loss: 0.4270844452492893
Max Accuracy: 0.87942
Max Validation Accuracy: 0.864799976348877

随着我们提高验证准确率,即使是小的进步也变得困难。

让我们检查一下图表:

图 6.17 – 在密集层上使用更多数据增强和 dropout 的损失

图 6.17 – 在密集层上使用更多数据增强和 dropout 的损失

存在一点过拟合,所以让我们尝试修复它。我们也可以在卷积层中使用Dropout

让我们尝试这个:

model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:],    padding="same"))
model.add(BatchNormalization())
model.add(Dropout(0.5))
model.add(Conv2D(filters=16, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:],    padding="same"))
model.add(BatchNormalization())
model.add(AveragePooling2D())
model.add(Dropout(0.5))

这是令人失望的结果:

Epoch 00133: early stopping
Training time: 9261.82032418251
Min Loss: 0.6104169194960594
Min Validation Loss: 0.4887285701841116
Max Accuracy: 0.79362
Max Validation Accuracy: 0.8417999744415283

网络没有改进!

这里,我们看到一个有趣的情况——验证准确率显著高于训练准确率。这是怎么可能的?

假设你的分割是正确的(例如,你没有包含与训练数据集太相似图像的验证集),两个因素可以造成这种情况:

  • 数据增强可能会使训练数据集比验证数据集更难。

  • Dropout 在训练阶段是激活的,而在预测阶段是关闭的,这意味着训练数据集可以比验证数据集显著更难。

在我们这个例子中,罪魁祸首是Dropout。你不必一定避免这种情况,如果它是合理的,但在这个例子中,验证准确率下降了,所以我们需要修复我们的Dropout,或者也许增加网络的大小。

我发现Dropout在卷积层中使用起来更困难,我个人在那个情况下不会使用大的Dropout。这里有一些指导方针:

  • 不要在Dropout之后立即使用批量归一化,因为归一化会受到影响。

  • DropoutMaxPooling之后比之前更有效。

  • 在卷积层之后的Dropout会丢弃单个像素,但SpatialDropout2D会丢弃通道,并且建议在神经网络开始的第一几个层中使用。

我又进行了一些(长!)实验,并决定增加卷积层的大小,减少Dropout,并在几个层中使用Spatial Dropout。最终我得到了这个神经网络,这是我认为的最终版本。

这是卷积层的代码:

model = Sequential()
model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:], padding="same"))
model.add(BatchNormalization())
model.add(Conv2D(filters=32, kernel_size=(3, 3), activation='relu', input_shape=x_train.shape[1:], padding="same"))
model.add(BatchNormalization())
model.add(AveragePooling2D())
model.add(SpatialDropout2D(0.2))

model.add(Conv2D(filters=48, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(BatchNormalization())
model.add(Conv2D(filters=48, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(BatchNormalization())
model.add(AveragePooling2D())
model.add(SpatialDropout2D(0.2))

model.add(Conv2D(filters=72, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(BatchNormalization())
model.add(Conv2D(filters=72, kernel_size=(3, 3), activation='relu',    padding="same"))
model.add(BatchNormalization())
model.add(AveragePooling2D())
model.add(Dropout(0.1))

And this is the part with the dense layers:model.add(Flatten())
model.add(Dense(units=384, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(units=192, activation='relu'))
model.add(Dropout(0.1))
model.add(Dense(units=num_classes, activation='softmax'))

这些是结果:

Epoch 00168: early stopping
Training time: 13122.931826591492
Min Loss: 0.4703261657243967
Min Validation Loss: 0.3803714614287019
Max Accuracy: 0.84324
Max Validation Accuracy: 0.8779000043869019

验证准确率有所提高:

图 6.18 – 在密集和卷积层上使用更多数据增强和的损失

图 6.18 – 在密集和卷积层上使用更多数据增强和Dropout的损失

恭喜!现在你有了如何训练神经网络的思路,并且可以自由地实验和发挥创意!每个任务都是不同的,可能性真的是无限的。

为了好玩,让我们再次训练它,看看相同的逻辑模型在 MNIST 上的表现如何。

将模型应用于 MNIST

我们之前的 MNIST 模型达到了 98.3%的验证准确率,正如你可能已经注意到的,你越接近 100%,提高模型就越困难。

我们的 CIFAR-10 是在与 MNIST 不同的任务上训练的,但让我们看看它的表现:

Epoch 00077: early stopping
Training time: 7110.028198957443
Min Loss: 0.04797766085289389
Min Validation Loss: 0.02718053938352254
Max Accuracy: 0.98681664
Max Validation Accuracy: 0.9919000267982483

这是它的图表:

图 6.19 – MNIST,损失

图 6.19 – MNIST,损失

我希望每个任务都像 MNIST 一样简单!

出于好奇,这是第一层的激活:

图 6.20 – MNIST,第一卷积层的激活

图 6.20 – MNIST,第一卷积层的激活

如你所见,许多通道被激活,并且它们很容易检测到数字的最重要特征。

这可能是一个尝试 GitHub 上的代码并对其进行实验的绝佳时机。

现在轮到你了!

如果你有些时间,你真的应该尝试一个公共数据集,或者甚至创建你自己的数据集,并从头开始训练一个神经网络。

如果你已经没有想法了,可以使用 CIFAR-100。

记住,训练神经网络通常不是线性的——你可能需要猜测什么可以帮助你,或者你可能尝试许多不同的事情。并且记住要反复试验,因为当你的模型发展时,不同技术和不同层的重要性可能会改变。

摘要

这是一个非常实用的章节,展示了在训练神经网络时的一种进行方式。我们从一个大模型开始,实现了 69.7%的验证准确率,然后我们减小了其尺寸并添加了一些层来增加非线性激活的数量。我们使用了批量归一化来均衡所有通道的贡献,然后我们学习了提前停止,这有助于我们决定何时停止训练。

在学习如何自动停止训练后,我们立即将其应用于数据增强,这不仅增加了数据集的大小,还增加了正确训练网络所需的 epoch 数量。然后我们介绍了DropoutSpatialDropout2D,这是一种强大的减少过拟合的方法,尽管使用起来并不总是容易。

我们最终得到了一个达到 87.8%准确率的网络。

在下一章中,我们将训练一个能够在空旷的轨道上驾驶汽车的神经网络!

问题

在本章之后,你将能够回答以下问题:

  1. 我们为什么想使用更多层?

  2. 具有更多层的网络是否自动比较浅的网络慢?

  3. 我们如何知道何时停止训练模型?

  4. 我们可以使用哪个 Keras 函数在模型开始过拟合之前停止训练?

  5. 你如何归一化通道?

  6. 你如何有效地使你的数据集更大、更难?

  7. dropout 会使你的模型更鲁棒吗?

  8. 如果你使用数据增强,你会期望训练变慢还是变快?

  9. 如果你使用 dropout,你会期望训练变慢还是变快?

第七章:第七章:检测行人和交通灯

恭喜您完成了深度学习的学习,并进入了这个新的章节!现在您已经了解了如何构建和调整神经网络的基础知识,是时候转向更高级的主题了。

如果您还记得,在 第一章OpenCV 基础和相机标定,我们已经使用 OpenCV 检测了行人。在本章中,我们将学习如何使用一个非常强大的神经网络——单次多框检测器SSD)来检测对象,我们将使用它来检测行人、车辆和交通灯。此外,我们将通过迁移学习训练一个神经网络来检测交通灯的颜色,迁移学习是一种强大的技术,可以帮助您使用相对较小的数据集获得良好的结果。

在本章中,我们将涵盖以下主题:

  • 检测行人和交通灯

  • 使用 CARLA 收集图像

  • 使用 单次多框检测器SSD)进行目标检测

  • 检测交通灯的颜色

  • 理解迁移学习

  • Inception 的理念

  • 识别交通灯及其颜色

技术要求

要能够使用本章中解释的代码,您需要安装以下工具和模块:

  • Carla 模拟器

  • Python 3.7

  • NumPy 模块

  • TensorFlow 模块

  • Keras 模块

  • OpenCV-Python 模块

  • 一个 GPU(推荐)

本章的代码可以在 github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter7 找到。

本章的“代码实战”视频可以在以下位置找到:

bit.ly/3o8C79Q

使用 SSD 检测行人、车辆和交通灯

当自动驾驶汽车在道路上行驶时,它肯定需要知道车道在哪里,并检测可能存在于道路上的障碍物(包括人!),它还需要检测交通标志和交通灯。

在本章中,我们将迈出重要的一步,因为我们将学习如何检测行人、车辆和交通灯,包括交通灯的颜色。我们将使用 Carla 生成所需的图像。

解决我们的任务是两步过程:

  1. 首先,我们将检测车辆、行人和交通灯(无颜色信息),我们将使用一个名为 SSD 的预训练神经网络。

  2. 然后,我们将检测交通灯的颜色,这需要我们从名为 Inception v3 的预训练神经网络开始训练一个神经网络,使用迁移学习技术,并且我们还需要收集一个小数据集。

因此,让我们首先使用 Carla 来收集图像。

使用 Carla 收集一些图像

我们需要一些带有行人、车辆和交通灯的街道照片。我们可以使用 Carla 来做这件事,但这次,我们将更详细地讨论如何使用 Carla 收集数据集。您可以在 carla.org/ 找到 Carla。

您可以在 Carla GitHub 页面上找到 Linux 和 Windows 的二进制文件:

github.com/carla-simulator/carla/releases

安装说明可以在 Carla 网站上找到:

carla.readthedocs.io/en/latest/start_quickstart/

如果您使用 Linux,则使用 CarlaUE4.sh 命令启动 Carla,而在 Windows 上,它被称为 CarlaUE4.exe。我们将其称为 CarlaUE4。您可以在没有参数的情况下运行它,或者您可以手动设置分辨率,如下所示:

CarlaUE4 -windowed -ResX=640 -ResY=480

在 Carla 中,您可以使用一些键在轨道周围移动:

  • W:向前

  • S:向后

  • A:向左,侧向

  • D:向右,侧向

此外,在 Carla 中,您可以使用鼠标,按住左键并移动光标来改变视角的角度并沿其他角度移动。

您应该看到以下类似的内容:

图 7.1 – Carla – 默认轨道

图 7.1 – Carla – 默认轨道

虽然服务器有时很有用,但您可能想运行一些位于 PythonAPI\utilPythonAPI\examples 中的文件。

对于这个任务,我们将使用 Town01 改变轨道。您可以使用 PythonAPI\util\config.py 文件这样做,如下所示:

python config.py -m=Town01

您现在应该看到一个不同的轨道:

图 7.2 – Town01 轨道

图 7.2 – Town01 轨道

您的城市是空的,因此我们需要添加一些车辆和一些行人。我们可以使用 PythonAPI\examples\spawn_npc.py 来完成,如下所示:

python spawn_npc.py  -w=100 -n=100

-w 参数指定要创建的行人数量,而 –n 指定要创建的车辆数量。现在,您应该看到一些动作:

图 7.3 – 带有车辆和行人的 Town01 轨道

图 7.3 – 带有车辆和行人的 Town01 轨道

好多了。

Carla 设计为作为服务器运行,您可以连接多个客户端,这应该允许进行更有趣的模拟。

当您运行 Carla 时,它启动一个服务器。您可以使用服务器四处走动,但您可能更希望运行一个客户端,因为它可以提供更多功能。如果您运行一个客户端,您将有两个带有 Carla 的窗口,这是预期的:

  1. 让我们使用 PythonAPI\examples\manual_control.py 运行一个客户端,如下所示:

    python manual_control.py
    

    您可能会看到以下类似的内容:

    图 7.4 – 使用 manual_control.py 的 Town01 轨道

    图 7.4 – 使用 manual_control.py 的 Town01 轨道

    您可以在左侧看到很多统计数据,并且可以使用 F1 键切换它们。您会注意到现在您有一辆车,并且可以使用退格键更改它。

  2. 你可以使用与之前相同的按键移动,但这次,行为更加有用和逼真,因为有一些物理模拟。你还可以使用箭头键进行移动。

    你可以使用Tab键切换相机,C键更改天气,正如我们可以在以下截图中所见:

图 7.5 – Town01 赛道;中午大雨和日落时晴朗的天空

图 7.5 – Town01 赛道;中午大雨和日落时晴朗的天空

Carla 有许多传感器,其中之一是 RGB 相机,你可以使用`(反引号键)在它们之间切换。现在,请参考以下截图:

图 7.6 – Town01 赛道 – 左:深度(原始),右:语义分割

图 7.6 – Town01 赛道 – 左:深度(原始),右:语义分割

上述截图显示了几款非常有趣的传感器:

  • 深度传感器,为每个像素提供从摄像机到距离

  • 语义分割传感器,使用不同的颜色对每个对象进行分类

在撰写本文时,完整的摄像机传感器列表如下:

  • 摄像机 RGB

  • 摄像机深度(原始)

  • 摄像机深度(灰度)

  • 摄像机深度(对数灰度)

  • 摄像机语义分割(CityScapes 调色板)

  • 激光雷达(射线投射)

  • 动态视觉传感器DVS

  • 摄像机 RGB 畸变

激光雷达是一种使用激光检测物体距离的传感器;DVS,也称为神经形态相机,是一种记录亮度局部变化的相机,克服了 RGB 相机的一些局限性。摄像机 RGB 畸变只是一个模拟镜头效果的 RGB 相机,当然,你可以根据需要自定义畸变。

以下截图显示了激光雷达摄像机的视图:

图 7.7 – 激光雷达摄像机视图

图 7.7 – 激光雷达摄像机视图

以下截图显示了 DVS 的输出:

图 7.8 – DVS

图 7.8 – DVS

你现在可以四处走动,从 RGB 相机收集一些图像,或者你可以使用 GitHub 仓库中的那些。

现在我们有一些图像了,是时候使用名为 SSD 的预训练网络来检测行人、车辆和交通灯了。

理解 SSD

在前面的章节中,我们创建了一个分类器,一个能够从预定义的选项集中识别图片中内容的神经网络。在本章的后面部分,我们将看到一个预训练的神经网络,它能够非常精确地对图像进行分类。

与许多神经网络相比,SSD 脱颖而出,因为它能够在同一张图片中检测多个对象。SSD 的细节有些复杂,如果你感兴趣,可以查看“进一步阅读”部分以获取一些灵感。

不仅 SSD 可以检测多个物体,它还可以输出物体存在的区域!在内部,这是通过检查不同宽高比下的 8,732 个位置来实现的。SSD 也足够快,在有良好 GPU 的情况下,它可以用来实时分析视频。

但我们可以在哪里找到 SSD?答案是 TensorFlow 检测模型动物园。让我们看看这是什么。

发现 TensorFlow 检测模型动物园

TensorFlow 检测模型动物园是一个有用的预训练神经网络集合,它支持在多个数据集上训练的多个架构。我们感兴趣的是 SSD,因此我们将专注于这一点。

在模型动物园支持的各个数据集中,我们感兴趣的是 COCO。COCO是微软的Common Objects in Context数据集,一个包含 2,500,000(250 万)张图片的集合,按类型分类。你可以在进一步阅读部分找到 COCO 的 90 个标签的链接,但我们感兴趣的是以下这些:

  • 1: person

  • 3: car

  • 6: bus

  • 8: truck

  • 10: traffic light

你可能还对以下内容感兴趣:

  • 2: bicycle

  • 4: motorcycle

  • 13: stop sign

值得注意的是,在 COCO 上训练的 SSD 有多个版本,使用不同的神经网络作为后端以达到所需的速度/精度比。请参考以下截图:

图 7.9 – 在 COCO 上训练的 SSD 的 TensorFlow 检测模型动物园

图 7.9 – 在 COCO 上训练的 SSD 的 TensorFlow 检测模型动物园

在这里,mAP列是平均精度均值,所以越高越好。MobileNet 是一个专为在移动设备和嵌入式设备上表现良好而开发的神经网络,由于其性能,它是在需要实时进行推理时 SSD 的经典选择。

为了检测道路上的物体,我们将使用以ResNet50作为骨干网络的 SSD,这是一个由微软亚洲研究院开发的具有 50 层的神经网络。ResNet 的一个特点是存在跳跃连接,这些捷径可以将一层连接到另一层,跳过中间的一些层。这有助于解决梯度消失问题。在深度神经网络中,训练过程中的梯度可能会变得非常小,以至于网络基本上停止学习。

但我们如何使用我们选定的模型ssd_resnet_50_fpn_coco?让我们来看看!

下载和加载 SSD

在模型动物园页面,如果你点击ssd_resnet_50_fpn_coco,你会得到一个 Keras 需要从中下载模型的 URL;在撰写本文时,URL 如下:

http://download.tensorflow.org/models/object_detection/ssd_resnet50_v1_fpn_shared_box_predictor_640x640_coco14_sync_2018_07_03.tar.gz

模型的全名如下:

ssd_resnet50_v1_fpn_shared_box_predictor_640x640_coco14_sync_2018_07_03.

要加载模型,你可以使用以下代码:

url = 'http://download.tensorflow.org/models/object_detection/'
+ model_name + '.tar.gz'
model_dir = tf.keras.utils.get_file(fname=model_name,
untar=True, origin=url)

print("Model path: ", model_dir)
model_dir = pathlib.Path(model_dir) / "saved_model"
model = tf.saved_model.load(str(model_dir))
model = model.signatures['serving_default']

如果你第一次运行这段代码,它将花费更多时间,因为 Keras 将下载模型并将其保存在你的硬盘上。

现在我们已经加载了模型,是时候用它来检测一些物体了。

运行 SSD

运行 SSD 只需要几行代码。你可以使用 OpenCV 加载图像(分辨率为 299x299),然后你需要将图像转换为张量,这是一种由 TensorFlow 使用的多维数组类型,类似于 NumPy 数组。参考以下代码:

img = cv2.imread(file_name)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
input_tensor = tf.convert_to_tensor(img)
input_tensor = input_tensor[tf.newaxis, ...]
# Run inference
output = model(input_tensor)

请注意,我们向网络输入的是RGB图像,而不是BGR。你可能还记得前几章中提到的 OpenCV 使用的是BGR格式的图片,因此我们需要注意通道的顺序。

如你所见,运行 SSD 相当简单,但输出相对复杂,需要一些代码将其转换为有用且更紧凑的形式。output变量是一个 Python 字典,但它包含的值是张量,因此你需要将它们转换。

例如,打印output['num_detections'],它包含预测的数量(例如,图像中找到的对象),将得到以下结果:

tf.Tensor([1.], shape=(1,), dtype=float32)

对于转换,我们可以使用int()

所有其他张量都是数组,并且可以使用它们的numpy()函数进行转换。因此,你的代码可能看起来像这样:

num_detections = int(output.pop('num_detections'))
output = {key: value[0, :num_detections].numpy()
          for key, value in output.items()}
output['num_detections'] = num_detections

仍然有以下两个问题需要修复:

  • 检测类别是浮点数,而作为我们的标签,它们应该是整数。

  • 框的坐标以百分比形式表示。

我们可以用几行代码修复这些问题:

output['detection_classes'] =    output['detection_classes'].astype(np.int64)
output['boxes'] = [
    {"y": int(box[0] * img.shape[0]), 
     "x": int(box[1] * img.shape[1]), 
     "y2": int(box[2] * img.shape[0]),
     "x2": int(box[3] * img.shape[1])} 
        for box in output['detection_boxes']]

让我们将 SSD 应用于这张图像:

图 7.10 – 来自 Town01 的图像

图 7.10 – 来自 Town01 的图像

我们得到以下输出:

{ 'detection_scores': array([0.4976843, 0.44799107, 0.36753723,      0.3548107 ], dtype=float32),    'detection_classes': array([ 8, 10,  6,  3], dtype=int64),  'detection_boxes': array([     [0.46678272, 0.2595877, 0.6488052, 0.40986294],     [0.3679817, 0.76321596, 0.45684734, 0.7875406],     [0.46517858, 0.26020002, 0.6488801, 0.41080648],     [0.46678272, 0.2595877, 0.6488052, 0.40986294]],      dtype=float32),  'num_detections': 4,  'boxes': [{'y': 220, 'x': 164, 'y2': 306, 'x2': 260},            {'y': 174, 'x': 484, 'y2': 216, 'x2': 500},            {'y': 220, 'x': 165, 'y2': 306, 'x2': 260},            {'y': 220, 'x': 164, 'y2': 306, 'x2': 260}]}

这就是代码的含义:

  • detection_scores:分数越高,预测的置信度越高。

  • detection_classes:预测的标签 – 在这种情况下,卡车(8)、交通灯(10)、公交车(6)和汽车(3)。

  • detection_boxes:原始框,坐标以百分比形式表示。

  • num_detections:预测的数量。

  • boxes:将坐标转换为原始图像分辨率的框。

请注意,三个预测基本上在同一区域,并且它们按分数排序。我们需要修复这种重叠。

为了更好地看到检测到的内容,我们现在将标注图像。

标注图像

为了正确标注图像,我们需要执行以下操作:

  1. 只考虑对我们有意义的标签。

  2. 移除标签的重叠。

  3. 在每个预测上画一个矩形。

  4. 写上标签及其分数。

要移除重叠的标签,只需比较它们,如果框的中心相似,我们只保留分数更高的标签。

这是结果:

图 7.11 – 来自 Town01 的图像,仅使用 SSD 进行标注

图 7.11 – 来自 Town01 的图像,仅使用 SSD 进行标注

这是一个很好的起点,即使其他图像的识别效果不是很好。车辆被识别为卡车,这并不完全准确,但我们并不真的关心这一点。

主要问题是我们知道有交通灯,但我们不知道它的颜色。不幸的是,SSD 无法帮助我们;我们需要自己来做。

在下一节中,我们将开发一个能够通过称为迁移学习的技术检测交通灯颜色的神经网络。

检测交通灯的颜色

原则上,我们可以尝试使用一些计算机视觉技术来检测交通灯的颜色——例如,检查红色和绿色通道可能是一个起点。此外,验证交叉灯底部和上部的亮度也有助于。这可能会有效,即使一些交叉灯可能会有问题。

然而,我们将使用深度学习,因为这项任务非常适合探索更高级的技术。我们还将不遗余力地使用一个小数据集,尽管我们很容易创建一个大数据集;原因在于我们并不总是有轻松增加数据集大小的奢侈。

要能够检测交通灯的颜色,我们需要完成三个步骤:

  1. 收集交叉灯的数据集。

  2. 训练一个神经网络来识别颜色。

  3. 使用带有 SSD 的网络来获取最终结果。

有一个你可以使用的交通灯数据集,即Bosch Small Traffic Lights数据集;然而,我们将使用 Carla 生成我们自己的数据集。让我们看看怎么做。

创建交通灯数据集

我们将使用 Carla 创建一个数据集。原则上,它可以大如我们所愿。越大越好,但大数据集会使训练变慢,当然,创建它也需要更多的时间。在我们的案例中,由于任务简单,我们将创建一个相对较小的、包含数百张图片的数据集。我们将在稍后探索迁移学习,这是一种在数据集不是特别大时可以使用的技巧。

小贴士

在 GitHub 上,你可以找到我为这个任务创建的数据集,但如果你有时间,自己收集数据集可以是一项很好的练习。

创建这个数据集是一个三步任务:

  1. 收集街道图片。

  2. 找到并裁剪所有的交通灯。

  3. 对交通灯进行分类。

收集图片的第一项任务非常简单。只需使用manual_control.py启动 Carla 并按R键。Carla 将开始录制,并在你再次按R键后停止。

假设我们想要记录四种类型的图片:

  • 红灯

  • 黄灯

  • 绿灯

  • 交通灯背面(负样本)

我们想要收集交通灯背面的原因是因为 SSD 将其识别为交通灯,但我们没有用,所以我们不希望使用它。这些都是负样本,它们也可能包括道路或建筑或任何 SSD 错误分类为交通灯的东西。

因此,在你记录图片时,请尽量为每个类别收集足够的样本。

这些是一些你可能想要收集的图片示例:

图 7.12 – Town01 – 左:红灯,右:绿灯

图 7.12 – Town01 – 左:红灯,右:绿灯

第二步是应用 SSD 并提取交叉灯的图像。这很简单;参考以下代码:

obj_class = out["detection_classes"][idx]if obj_class == object_detection.LABEL_TRAFFIC_LIGHT:    box = out["boxes"][idx]    traffic_light = img[box["y"]:box["y2"], box["x"]:box["x2"]]

在前面的代码中,假设 out 变量包含运行 SSD 的结果,调用 model(input_tensor),并且 idx 包含当前预测中的当前检测,你只需要选择包含交通灯的检测,并使用我们之前计算的坐标进行裁剪。

我最终得到了 291 个检测,图像如下:

图 7.13 – Town01,从左至右:小红灯,小绿灯,绿灯,黄灯,交通灯背面,被错误分类为交通灯的建筑的一部分

图 7.13 – Town01,从左至右:小红灯,小绿灯,绿灯,黄灯,交通灯背面,被错误分类为交通灯的建筑的一部分

如你所见,图像具有不同的分辨率和比例,这是完全可以接受的。

也有一些图像与交通灯完全无关,例如一块建筑的一部分,这些是很好的负样本,因为 SSD 将它们错误分类,因此这也是提高 SSD 输出的方法之一。

最后一步只是对图像进行分类。有了几百张这种类型的图片,只需要几分钟。你可以为每个标签创建一个目录,并将适当的图片移动到那里。

恭喜你,现在你有一个用于检测交通灯颜色的自定义数据集了。

如你所知,数据集很小,因此,正如我们之前所说的,我们将使用转移学习。下一节将解释它是什么。

理解转移学习

转移学习是一个非常恰当的名字。从概念上看,的确,它涉及到将神经网络在一个任务上学习到的知识转移到另一个不同但相关的任务上。

转移学习有几种方法;我们将讨论两种,并选择其中一种来检测交通灯的颜色。在两种情况下,起点都是一个在类似任务上预训练过的神经网络 – 例如,图像分类。我们将在下一节“了解 ImageNet”中更多地讨论这一点。我们专注于用作分类器的 卷积神经网络 (CNN),因为这是我们识别交通灯颜色的需要。

第一种方法是加载预训练的神经网络,将输出的数量调整到新问题(替换一些或所有密集层,有时只是添加一个额外的密集层),并在新的数据集上基本保持训练。你可能需要使用较小的学习率。如果新数据集中的样本数量小于用于原始训练的数据集,但仍然相当大,这种方法可能有效。例如,我们自定义数据集的大小可能是原始数据集位置的 10%。一个缺点是训练可能需要很长时间,因为你通常在训练一个相对较大的网络。

第二种方法,我们将要采用的方法,与第一种方法类似,但你将冻结所有卷积层,这意味着它们的参数是固定的,在训练过程中不会改变。这有一个优点,即训练速度更快,因为你不需要训练卷积层。这里的想法是,卷积层已经在大型数据集上进行了训练,并且能够检测到许多特征,对于新任务来说也将是可行的,而真正的分类器,由密集层组成,可以被替换并从头开始训练。

中间方法也是可能的,其中你训练一些卷积层,但通常至少保持第一层冻结。

在了解如何使用 Keras 进行迁移学习之前,让我们再思考一下我们刚才讨论的内容。一个关键假设是我们想要从中学习的这个假设网络已经在大型数据集上进行了训练,其中网络可以学习识别许多特征和模式。结果发现,有一个非常大的数据集符合要求——ImageNet。让我们再谈谈它。

了解 ImageNet

ImageNet 是一个巨大的数据集,在撰写本文时,它由 14,197,122(超过 1400 万)张图片组成!实际上,它并不提供图片,只是提供下载图片的 URL。这些图片被分类在 27 个类别和总共 21,841 个子类别中!这些子类别,称为 synsets,基于称为WordNet的分类层次结构。

ImageNet 已经是一个非常具有影响力的数据集,这也要归功于用于衡量计算机视觉进步的竞赛:ImageNet 大规模视觉识别挑战ILSVRC)。

这些是主要类别:

  • 两栖动物

  • 动物

  • 家用电器

  • 鸟类

  • 覆盖物

  • 设备

  • 织物

  • 鱼类

  • 花卉

  • 食物

  • 水果

  • 真菌

  • 家具

  • 地质构造

  • 无脊椎动物

  • 哺乳动物

  • 乐器

  • 植物

  • 爬行动物

  • 运动

  • 结构

  • 工具

  • 树木

  • 器具

  • 蔬菜

  • 车辆

  • 人物

子类别的数量非常高;例如,树木类别有 993 个子类别,覆盖了超过五十万张图片!

当然,在这个数据集上表现良好的神经网络将非常擅长识别多种类型图像上的模式,并且它可能也有相当大的容量。所以,是的,它将过度拟合你的数据集,但正如我们所知如何处理过拟合,我们将密切关注这个问题,但不会过于担心。

由于大量研究致力于在 ImageNet 上表现良好,因此许多最有影响力的神经网络都在其上进行了训练,这并不令人惊讶。

在 2012 年首次出现时,其中一个特别引人注目的是 AlexNet。让我们看看原因。

发现 AlexNet

当 AlexNet 在 2012 年发布时,它的准确率比当时最好的神经网络高出 10%以上!显然,这些解决方案已经得到了广泛的研究,其中一些现在非常常见。

AlexNet 引入了几个开创性的创新:

  • 多 GPU 训练,其中 AlexNet 在一半的 GPU 上训练,另一半在另一个 GPU 上,使得模型的大小翻倍。

  • 使用 ReLU 激活而不是 Tanh,这显然使得训练速度提高了六倍。

  • 添加了重叠池化,其中 AlexNet 使用了 3x3 的最大池化,但池化区域仅移动 2x2,这意味着池化区域之间存在重叠。根据原始论文,这提高了 0.3–0.4%的准确率。在 Keras 中,你可以使用MaxPooling2D(pool_size=(3,3), strides=(2,2))实现类似的重叠池化。

AlexNet 拥有超过 6000 万个参数,相当大,因此为了减少过拟合,它广泛使用了数据增强和 dropout。

虽然 2012 年的 AlexNet 在当时的标准下是当时最先进和开创性的,但按照今天的标准,它相当低效。在下一节中,我们将讨论一个神经网络,它只需 AlexNet 十分之一的参数就能实现显著更高的准确率:Inception

Inception 背后的理念

拥有一个像 ImageNet 这样的大型数据集是非常好的,但有一个已经用这个数据集训练好的神经网络会更容易。结果发现,Keras 提供了几个这样的神经网络。一个是 ResNet,我们已经遇到了。另一个非常有影响力且具有重大创新的是 Inception。让我们简单谈谈它。

Inception 是一系列神经网络,意味着有多个,它们对初始概念进行了细化。Inception 是由谷歌设计的,在 2014 年 ILSVRC(ImageNet)竞赛中参赛并获胜的版本被称为GoogLeNet,以纪念 LeNet 架构。

如果你想知道 Inception 的名字是否来自著名的电影Inception,是的,它确实如此,因为他们想要更深入!Inception 是一个深度网络,有一个名为InceptionResNetV2的版本达到了惊人的 572 层!当然,这是如果我们计算每个层,包括激活层的话。我们将使用 Inception v3,它只有159 层

我们将重点关注 Inception v1,因为它更容易,我们还将简要讨论后来添加的一些改进,因为它们可以成为你的灵感来源。

谷歌的一个关键观察是,由于一个主题在图片中可能出现的各种位置,很难提前知道卷积层的最佳核大小,所以他们并行添加了 1x1、3x3 和 5x5 卷积,以覆盖主要情况,加上最大池化,因为它通常很有用,并将结果连接起来。这样做的一个优点是网络不会太深,这使得训练更容易。

我们刚才描述的是天真的 Inception 块:

图 7.14 – 天真的 Inception 块

图 7.14 – 天真的 Inception 块

你可能已经注意到了一个 1x1 的卷积。那是什么?只是将一个通道乘以一个数字?并不完全是这样。1x1 卷积执行起来非常便宜,因为它只有 1 次乘法,而不是 3x3 卷积中的 9 次或 5x5 卷积中的 25 次,并且它可以用来改变滤波器的数量。此外,你可以添加一个 ReLU,引入一个非线性操作,这会增加网络可以学习的函数的复杂性。

这个模块被称为天真,因为它计算成本太高。随着通道数的增加,3x3 和 5x5 卷积变得缓慢。解决方案是在它们前面放置 1x1 卷积,以减少更昂贵的卷积需要操作的通道数:

图 7.15 – 包含维度减少的 Inception 块

图 7.15 – 包含维度减少的 Inception 块

理解这个块的关键是要记住,1x1 卷积用于减少通道数并显著提高性能。

例如,GoogLeNet 中的第一个 Inception 块有 192 个通道,5x5 的卷积会创建 32 个通道,所以乘法次数将与 25 x 32 x 192 = 153,600 成比例。

他们添加了一个输出为 16 个滤波器的 1x1 卷积,所以乘法次数将与 16 x 192 + 25 x 32 x 16 = 3,072 + 12,800 = 15,872 成比例。几乎减少了 10 倍。不错!

还有一件事。为了使连接操作生效,所有的卷积都需要有相同大小的输出,这意味着它们需要填充,以保持输入图像相同的分辨率。那么最大池化呢?它也需要有与卷积相同大小的输出,所以即使它在 3x3 的网格中找到最大值,也无法减小尺寸。

在 Keras 中,这可能是这样的:

MaxPooling2D(pool_size=(3, 3), padding='same', strides=(1, 1))

strides 参数表示在计算最大值后移动多少像素。默认情况下,它设置为与 pool_size 相同的值,在我们的例子中这将使大小减少 3 倍。将其设置为 (1, 1) 并使用相同的填充将不会改变大小。Conv2D 层也有一个 strides 参数,可以用来减小输出的大小;然而,通常使用最大池化层这样做更有效。

Inception v2 引入了一些优化,其中以下是一些:

  • 5x5 卷积类似于两个堆叠的 3x3 卷积,但速度较慢,因此他们用 3x3 卷积重构了它。

  • 3x3 卷积相当于一个 1x3 卷积后跟一个 3x1 卷积,但使用两个卷积要快 33%。

Inception v3 引入了以下优化:

  • 使用几个较小的、较快的卷积创建的分解 7x7 卷积

  • 一些批量归一化层

Inception-ResNet 引入了 ResNet 典型的残差连接,以跳过一些层。

既然你对 Inception 背后的概念有了更好的理解,让我们看看如何在 Keras 中使用它。

使用 Inception 进行图像分类

在 Keras 中加载 Inception 简单得不能再简单了,正如我们在这里可以看到的:

model = InceptionV3(weights='imagenet', input_shape=(299,299,3))

由于 Inception 可以告诉我们图像的内容,让我们尝试使用我们在本书开头使用的测试图像:

img = cv2.resize(preprocess_input(cv2.imread("test.jpg")),
(299, 299))
out_inception = model.predict(np.array([img]))
out_inception = imagenet_utils.decode_predictions(out_inception)
print(out_inception[0][0][1], out_inception[0][0][2], "%")

这是结果:

sea_lion 0.99184495 %

的确是正确的:我们的图像描绘了来自加拉帕戈斯群岛的海狮:

图 7.16 – Inception 以 0.99184495 的置信度识别为海狮

图 7.16 – Inception 以 0.99184495 的置信度识别为海狮

但我们想使用 Inception 进行迁移学习,而不是图像分类,因此我们需要以不同的方式使用它。让我们看看如何。

使用 Inception 进行迁移学习

迁移学习的加载略有不同,因为我们需要移除 Inception 上方的分类器,如下所示:

base_model = InceptionV3(include_top=False, input_shape=    (299,299,3))

使用 input_shape,我们使用 Inception 的原始大小,但你可以使用不同的形状,只要它有 3 个通道,并且分辨率至少为 75x75。

重要的参数是 include_top,将其设置为 False 将会移除 Inception 的顶部部分——具有密集滤波器的分类器——这意味着网络将准备好进行迁移学习。

我们现在将创建一个基于 Inception 但可以由我们修改的神经网络:

top_model = Sequential()top_model.add(base_model) # Join the networks

现在,我们可以在其上方添加一个分类器,如下所示:

top_model.add(GlobalAveragePooling2D())top_model.add(Dense(1024, activation='relu'))top_model.add(Dropout(0.5))top_model.add(Dense(512, activation='relu'))top_model.add(Dropout(0.5))top_model.add(Dense(n_classes, activation='softmax'))

我们添加了一些 dropout,因为我们预计 Inception 在我们的数据集上会过度拟合很多。但请注意 GlobalAveragePooling2D。它所做的就是计算通道的平均值。

我们可以使用Flatten,但由于 Inception 输出 2,048 个 8x8 的卷积通道,而我们使用了一个有 1,024 个神经元的密集层,参数数量会非常大——134,217,728!使用GlobalAveragePooling2D,我们只需要 2,097,152 个参数。即使计算 Inception 的参数,节省也是相当显著的——从 156,548,388 个参数减少到 24,427,812 个参数。

我们还需要做一件事:冻结我们不想训练的 Inception 层。在这种情况下,我们想冻结所有层,但这可能并不总是这样。这是冻结它们的方法:

for layer in base_model.layers:
    layer.trainable = False

让我们检查一下我们的网络看起来如何。Inception 太大,所以我只会显示参数数据:

Total params: 21,802,784
Trainable params: 21,768,352
Non-trainable params: 34,432

请注意,summary()实际上会打印出两个摘要:一个用于 Inception,一个用于我们的网络;这是第一个摘要的输出:

Model: "sequential_1"

____________________________________________________________
Layer (type)                 Output Shape              Param # 
============================================================
inception_v3 (Model)         (None, 8, 8, 2048)        21802784  
____________________________________________________________
global_average_pooling2d_1 ( (None, 2048)              0 
____________________________________________________________
dense_1 (Dense)              (None, 1024)              2098176 
____________________________________________________________
dropout_1 (Dropout)          (None, 1024)              0 
____________________________________________________________
dense_2 (Dense)              (None, 512)               524800    
____________________________________________________________
dropout_2 (Dropout)          (None, 512)               0 
____________________________________________________________
dense_3 (Dense)              (None, 4)                 2052      
============================================================
Total params: 24,427,812
Trainable params: 2,625,028
Non-trainable params: 21,802,784
____________________________________________________________

如您所见,第一层是 Inception。在第二个摘要中,您也确认了 Inception 的层被冻结了,因为我们有超过 2100 万个不可训练的参数,正好与 Inception 的总参数数相匹配。

为了减少过拟合并补偿小数据集,我们将使用数据增强:

datagen = ImageDataGenerator(rotation_range=5, width_shift_
range=[-5, -2, -1, 0, 1, 2, 5], horizontal_flip=True,
height_shift_range=[-30, -20, -10, -5, -2, 0, 2, 5, 10, 20,
30])

我只进行了一小点旋转,因为交通灯通常很直,我也只添加了很小的宽度偏移,因为交通灯是由一个神经网络(SSD)检测的,所以切割往往非常一致。我还添加了更高的高度偏移,因为我看到 SSD 有时会错误地切割交通灯,移除其三分之一。

现在,网络已经准备好了,我们只需要给它喂入我们的数据集。

将我们的数据集输入到 Inception 中

假设您已经将数据集加载到两个变量中:imageslabels

Inception 需要一些预处理,将图像的值映射到[-1, +1]范围。Keras 有一个函数可以处理这个问题,preprocess_input()。请注意,要从keras.applications.inception_v3模块导入它,因为其他模块中也有相同名称但不同行为的函数:

from keras.applications.inception_v3 import preprocess_input
images = [preprocess_input(img) for img in images]

我们需要将数据集分为训练集和验证集,这很简单,但我们还需要随机化顺序,以确保分割有意义;例如,我的代码加载了所有具有相同标签的图像,所以如果没有随机化,验证集中只会有一两个标签,其中一个甚至可能不在训练集中。

NumPy 有一个非常方便的函数来生成新的索引位置,permutation()

indexes = np.random.permutation(len(images))

然后,您可以使用 Python 的一个特性——for comprehension,来改变列表中的顺序:

images = [images[idx] for idx in indexes]
labels = [labels[idx] for idx in indexes]

如果您的标签是数字,您可以使用to_categorical()将它们转换为 one-hot 编码。

现在,只是切片的问题。我们将使用 20%的样本进行验证,所以代码可以像这样:

idx_split = int(len(labels_np) * 0.8)
x_train = images[0:idx_split]
x_valid = images[idx_split:]
y_train = labels[0:idx_split]
y_valid = labels[idx_split:]

现在,您可以像往常一样训练网络。让我们看看它的表现如何!

迁移学习的性能

模型的性能非常好:

Min Loss: 0.028652783162121116
Min Validation Loss: 0.011525456588399612
Max Accuracy: 1.0
Max Validation Accuracy: 1.0

是的,100%的准确率和验证准确率!不错,不错。实际上,这非常令人满意。然而,数据集非常简单,所以预期一个非常好的结果是公平的。

这是损失图的图表:

图 7.17 – 使用 Inception 迁移学习的损失

图 7.17 – 使用 Inception 迁移学习的损失

问题在于,尽管结果很好,但网络在我的测试图像上表现并不太好。可能是因为图像通常比 Inception 的原生分辨率小,网络可能会遇到由插值产生的模式,而不是图像中的真实模式,并且可能被它们所困惑,但这只是我的理论。

为了获得好的结果,我们需要更加努力。

改进迁移学习

我们可以假设网络正在过拟合,标准的应对措施是增加数据集。在这种情况下,这样做很容易,但让我们假设我们无法这样做,因此我们可以探索其他在这种情况下可能对你有用的选项。

我们可以做一些非常简单的事情来减少过拟合:

  • 增加数据增强的多样性。

  • 增加 dropout。

尽管 Inception 显然能够处理比这更复杂的任务,但它并不是为此特定任务优化的,而且它也可能从更大的分类器中受益,所以我将添加一个层:

  • 这是经过几次测试后的新数据增强:

    datagen = ImageDataGenerator(rotation_range=5, width_
    shift_range= [-10, -5, -2, 0, 2, 5, 10],
    zoom_range=[0.7, 1.5],
    height_shift_range=[-10, -5, -2, 0, 2, 5, 10],
    horizontal_flip=True)
    
  • 这是新模型,具有更多的 dropout 和额外的层:

    top_model.add(GlobalAveragePooling2D())top_model.add(Dropout(0.5))top_model.add(Dense(1024, activation='relu'))top_model.add(BatchNormalization())top_model.add(Dropout(0.5))top_model.add(Dense(512, activation='relu'))top_model.add(Dropout(0.5))top_model.add(Dense(128, activation='relu'))top_model.add(Dense(n_classes, activation='softmax'))
    
  • 我在全局平均池化之后添加了一个 dropout,以减少过拟合,我还添加了一个批量归一化层,这也有助于减少过拟合。

  • 然后,我添加了一个密集层,但我没有在上面添加 dropout,因为我注意到网络在大量 dropout 的情况下训练存在问题。

    即使我们不希望增加数据集,我们仍然可以对此采取一些措施。让我们看看类别的分布:

    print('Labels:', collections.Counter(labels))
    

    这是结果:

    Labels: Counter({0: 123, 2: 79, 1: 66, 3: 23})
    

如你所见,数据集中绿色比黄色或红色多得多,而且负面样本不多。

通常来说,标签不平衡并不是一个好的现象,因为实际上网络预测的绿灯比实际存在的绿灯要多,这在统计上意味着预测绿灯比预测其他标签更有利可图。为了改善这种情况,我们可以指导 Keras 以这种方式自定义损失函数,即预测错误的红灯比预测错误绿灯更糟糕,这样会产生类似于使数据集平衡的效果。

你可以用这两行代码做到这一点:

n = len(labels)
class_weight = {0: n/cnt[0], 1: n/cnt[1], 2: n/cnt[2], 3: n/cnt[3]}

以下结果是:

Class weight: {0: 2.365, 1: 4.409, 2: 3.683, 3: 12.652}

如你所见,对于绿色(标签 0)的损失惩罚比其他标签要小。

这是网络的表现方式:

Min Loss: 0.10114006596268155
Min Validation Loss: 0.012583946840742887
Max Accuracy: 0.99568963
Max Validation Accuracy: 1.0

与之前没有太大不同,但这次,网络表现更好,并准确识别了我测试图像中的所有交通灯。这应该是一个提醒,不要完全相信验证准确率,除非你确信你的验证数据集非常优秀。

这是损失图的图表:

图 7.18 – 使用 Inception 进行迁移学习后的损失,改进

图 7.18 – 使用 Inception 进行迁移学习后的损失,改进

现在我们有一个好的网络,是时候完成我们的任务了,使用新的网络结合 SSD,如下一节所述。

识别交通灯及其颜色

我们几乎完成了。从使用 SSD 的代码中,我们只需要以不同的方式管理交通灯。因此,当标签是10(交通灯)时,我们需要做以下操作:

  • 裁剪带有交通灯的区域。

  • 调整大小为 299x299。

  • 预处理它。

  • 通过我们的网络运行它。

然后,我们将得到预测结果:

img_traffic_light = img[box["y"]:box["y2"], box["x"]:box["x2"]]img_inception = cv2.resize(img_traffic_light, (299, 299))img_inception = np.array([preprocess_input(img_inception)])prediction = model_traffic_lights.predict(img_inception)label = np.argmax(prediction)

如果你运行 GitHub 上本章的代码,标签0代表绿灯,1代表黄灯,2代表红灯,而3表示不是交通灯。

整个过程首先涉及使用 SSD 检测物体,然后使用我们的网络检测图像中是否存在交通灯的颜色,如以下图解所示:

图 7.19 – 展示如何结合使用 SSD 和我们的网络的图解

图 7.19 – 展示如何结合使用 SSD 和我们的网络的图解

这些是在运行 SSD 后运行我们的网络获得的示例:

图 7.20 – 带有交通灯的一些检测

图 7.20 – 带有交通灯的一些检测

现在交通灯的颜色已经正确检测。有一些误检:例如,在前面的图中,右边的图像标记了一个有树的地方。不幸的是,这种情况可能发生。在视频中,我们可以在接受之前要求检测几帧,始终考虑到在真正的自动驾驶汽车中,你不能引入高延迟,因为汽车需要快速对街道上发生的事情做出反应。

摘要

在本章中,我们专注于预训练的神经网络,以及我们如何利用它们来实现我们的目的。我们将两个神经网络结合起来检测行人、车辆和交通灯,包括它们的颜色。我们首先讨论了如何使用 Carla 收集图像,然后发现了 SSD,这是一个强大的神经网络,因其能够检测物体及其在图像中的位置而突出。我们还看到了 TensorFlow 检测模型库以及如何使用 Keras 下载在名为 COCO 的数据集上训练的 SSD 的所需版本。

在本章的第二部分,我们讨论了一种称为迁移学习的强大技术,并研究了 Inception 神经网络的一些解决方案,我们使用迁移学习在我们的数据集上训练它,以便能够检测交通灯的颜色。在这个过程中,我们还讨论了 ImageNet,并看到了达到 100%验证准确率是如何具有误导性的,因此,我们必须减少过拟合以提高网络的真正精度。最终,我们成功地使用两个网络一起工作——一个用于检测行人、车辆和交通灯,另一个用于检测交通灯的颜色。

既然我们已经知道了如何构建关于道路的知识,那么现在是时候前进到下一个任务——驾驶!在下一章中,我们将真正地坐在驾驶座上(Carla 的驾驶座),并使用一种称为行为克隆的技术来教我们的神经网络如何驾驶,我们的神经网络将尝试模仿我们的行为。

问题

你现在应该能够回答以下问题:

  1. 什么是 SSD?

  2. 什么是 Inception?

  3. 冻结一层意味着什么?

  4. SSD 能否检测交通灯的颜色?

  5. 什么是迁移学习?

  6. 你能列举一些减少过拟合的技术吗?

  7. 你能描述 Inception 块背后的想法吗?

进一步阅读

第八章:第八章: 行为克隆

在本章中,我们将训练一个神经网络来控制汽车的转向盘,有效地教会它如何驾驶汽车!希望你会对这项任务的核心如此简单感到惊讶,这要归功于深度学习。

为了实现我们的目标,我们不得不修改 CARLA 模拟器的一个示例,首先保存创建数据集所需的图像,然后使用我们的神经网络来驾驶。我们的神经网络将受到英伟达 DAVE-2 架构的启发,我们还将看到如何更好地可视化神经网络关注的区域。

在本章中,我们将涵盖以下主题:

  • 使用行为克隆教神经网络如何驾驶

  • 英伟达 DAVE-2 神经网络

  • 从 Carla 记录图像和转向盘

  • 记录三个视频流

  • 创建神经网络

  • 训练用于回归的神经网络

  • 可视化显著性图

  • 与 Carla 集成以实现自动驾驶

  • 使用生成器训练更大的数据集

技术要求

为了能够使用本章中解释的代码,您需要安装以下工具和模块:

  • Carla 模拟器

  • Python 3.7

  • NumPy 模块

  • TensorFlow 模块

  • Keras 模块

  • keras-vis模块

  • OpenCV-Python 模块

  • 一个 GPU(推荐)

本章的代码可以在以下链接找到:github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter8

本章的《代码实战》视频可以在以下链接找到:

bit.ly/3kjIQLA

使用行为克隆教神经网络如何驾驶

自动驾驶汽车是一个复杂的硬件和软件集合。普通汽车的硬件已经很复杂了,通常有成千上万的机械部件,而自动驾驶汽车又增加了许多传感器。软件并不简单,事实上,据说早在 15 年前,一家世界级的汽车制造商不得不退步,因为软件的复杂性已经失控。为了给您一个概念,一辆跑车可以有超过 50 个 CPU!

显然,制造一个既安全又相对快速的自动驾驶汽车是一个难以置信的挑战,但尽管如此,我们将看到一行代码可以有多强大。对我来说,意识到如此复杂的事情如驾驶可以用如此简单的方式编码,是一个启发性的时刻。但我并不应该感到惊讶,因为,在深度学习中,数据比代码本身更重要,至少在某种程度上。

我们没有在真实自动驾驶汽车上测试的奢侈条件,所以我们将使用 Carla,并且我们将训练一个神经网络,在输入摄像头视频后能够生成转向角度。尽管如此,我们并没有使用其他传感器,原则上,你可以使用你想象中的所有传感器,只需修改网络以接受这些额外的数据。

我们的目标是教会 Carla 如何进行一次“绕圈”,使用 Town04 轨道的一部分,这是 Carla 包含的轨道之一。我们希望我们的神经网络能够稍微直行,然后进行一些右转,直到到达初始点。原则上,为了训练神经网络,我们只需要驾驶 Carla,记录道路图像和我们应用的相应转向角度,这个过程被称为行为克隆

我们的任务分为三个步骤:

  • 构建数据集

  • 设计和训练神经网络

  • 在 Carla 中集成神经网络

我们将借鉴 Nvidia 创建的 DAVE-2 系统。那么,让我们开始描述它。

介绍 DAVE-2

DAVE-2 是 Nvidia 设计的一个系统,用于训练神经网络驾驶汽车,旨在作为一个概念验证,证明原则上一个单一的神经网络能够在一个道路上控制汽车。换句话说,如果提供足够的数据,我们的网络可以被训练来在真实的道路上驾驶真实的汽车。为了给你一个概念,Nvidia 使用了大约 72 小时的视频,每秒 10 帧。

这个想法非常简单:我们给神经网络提供视频流,神经网络将简单地生成转向角度,或者类似的东西。训练是由人类驾驶员创建的,系统从摄像头(训练数据)和驾驶员操作的转向盘(训练标签)收集数据。这被称为行为克隆,因为网络试图复制人类驾驶员的行为。

不幸的是,这会过于简单,因为大部分标签将简单地是 0(驾驶员直行),因此网络将难以学习如何移动到车道中间。为了缓解这个问题,Nvidia 使用了三个摄像头:

  • 车辆中央的一个,这是真实的人类行为

  • 左侧的一个,模拟如果汽车过于靠左时应该怎么做

  • 右侧的一个,模拟如果汽车过于靠右时应该怎么做

为了使左右摄像头有用,当然有必要更改与它们视频相关的转向角度,以模拟一个校正;因此,摄像头需要与一个更向右的转向相关联,而摄像头需要与一个更向左的转向相关联。

下面的图示显示了系统:

图 8.1 – Nvidia DAVE-2 系统

图 8.1 – Nvidia DAVE-2 系统

为了使系统更健壮,Nvidia 添加了随机的平移和旋转,并调整转向以适应,但我们将不会这样做。然而,我们将使用他们建议的三个视频流。

我们如何获取三个视频流和转向角度?当然是从 Carla 获取的,我们将在本章中大量使用它。在开始编写代码之前,让我们熟悉一下manual_control.py文件,这是一个我们将复制并修改的文件。

了解manual_control.py

我们不会编写一个完整的客户端代码来完成我们需要的操作,而是会修改manual_control.py文件,从PythonAPI/examples

我通常会说明需要修改的代码位置,但您实际上需要检查 GitHub 以查看它。

在开始之前,请考虑本章的代码可能比通常更严格地要求版本,特别是可视化部分,因为它使用了一个尚未更新的库。

我的建议是使用 Python 3.7,并安装 TensorFlow 版本 2.2、Keras 2.3 和scipy 1.2,如下所示:

pip install tensorflow==2.2.0 pip install keras==2.3.1 pip install scipy==1.2.3

如果您现在查看manual_control.py,您可能会注意到的第一件事是这个代码块:

try:  sys.path.append(glob.glob('../carla/dist/carla-*%d.%d-%s.egg' % (    sys.version_info.major,    sys.version_info.minor,    'win-amd64' if os.name == 'nt' else 'linux-x86_64'))[0])except IndexError:  pass

它加载一个包含 Carla 代码的egg文件,该文件位于PythonAPI/carla/dist/文件夹中。作为替代方案,您也可以使用以下命令安装 Carla,当然需要使用您的egg文件名:

python -m easy_install carla-0.9.9-py3.7-win-amd64.egg

在此之后,您可能会注意到代码被组织成以下类:

  • World: 我们车辆移动的虚拟世界,包括地图和所有演员(车辆、行人和传感器)。

  • KeyboardControl: 这个类会响应用户按下的键,并且包含一些逻辑,将转向、制动和加速的二进制开/关键转换为更广泛的值范围,这取决于它们被按下的时间长短,从而使汽车更容易控制。

  • HUD: 这个类渲染与模拟相关的所有信息,如速度、转向和油门,并管理可以显示一些信息给用户的提示,持续几秒钟。

  • FadingText: 这个类被 HUD 类使用,用于显示几秒钟后消失的通知。

  • HelpText: 这个类使用pygame(Carla 使用的游戏库)显示一些文本。

  • CollisionSensor: 这是一个能够检测碰撞的传感器。

  • LaneInvasionSensor: 这是一个能够检测您是否跨越了车道线的传感器。

  • GnssSensor: 这是一个 GPS/GNSS 传感器,它提供了 OpenDRIVE 地图内的 GNSS 位置。

  • IMUSensor: 这是一个惯性测量单元,它使用陀螺仪来检测施加在汽车上的加速度。

  • RadarSensor: 一个雷达,提供检测到的元素(包括速度)的二维地图。

  • CameraManager: 这是一个管理相机并打印其信息的类。

此外,还有一些其他值得注意的方法:

  • main(): 这部分主要致力于解析操作系统接收到的参数。

  • game_loop():这个函数主要初始化 pygame、Carla 客户端以及所有相关对象,并且实现了游戏循环,其中每秒 60 次,分析按键并显示最新的图像在屏幕上。

帧的可视化是由game_loop()触发的,以下是一行:

world.render(display)

world.render()方法调用CameraManager.render(),显示最后一帧可用的图像。

如果你检查了代码,你可能已经注意到 Carla 使用弱引用来避免循环引用。弱引用是一种不会阻止对象被垃圾回收的引用,这在某些场景中很有用,例如缓存。

当你与 Carla 一起工作时,有一件重要的事情需要考虑。你的一些代码在服务器上运行,而另一些代码在客户端上运行,可能不容易在这两者之间划清界限。这可能会导致意想不到的后果,例如你的模型运行速度慢 10 到 30 倍,这可能是由于它被序列化到服务器上,尽管这只是我在看到这个问题后的推测。因此,我在game_loop()方法中运行我的推理,这肯定是在客户端上运行的。

这也意味着帧是在服务器上计算并发送到客户端的。

另一个不幸需要考虑的事情是,Carla 的 API 并不稳定,版本 0.9.0 删除了许多应该很快就会恢复的功能。

文档也没有特别更新这些缺失的 API,所以如果你发现事情没有按预期工作,请不要感到惊讶。希望这很快就会得到修复。同时,你可以使用旧版本。我们使用了 Carla 0.9.9.2,还有一些粗糙的边缘,但对于我们的需求来说已经足够好了。

现在我们对 CARLA 有了更多的了解,让我们看看我们如何录制我们的数据集,从只有一个视频流开始。

录制一个视频流

在原则上,使用 Carla 录制一个视频流非常简单,因为已经有一个选项可以这样做。如果你从PythonAPI/examples目录运行manual_control.py,当你按下R键时,它就开始录制。

问题是我们还想要转向角度。通常,你可以将此类数据保存到某种类型的数据库中,CSV 文件或 pickle 文件。为了使事情更简单并专注于核心任务,我们只需将转向角度和一些其他数据添加到文件名中。这使得你构建数据集变得更容易,因为你可能想要记录多个针对特定问题修复的运行,你只需将文件移动到新目录中,就可以轻松地保留所有信息,而无需在数据库中更新路径。

但如果你不喜欢,请随意使用更好的系统。

我们可以从头开始编写一个与 Carla 服务器集成的客户端,并完成我们所需的功能,但为了简单起见,并更好地隔离所需更改,我们只需将manual_control.py复制到一个名为manual_control_recording.py的文件中,然后我们只需添加所需的内容。

请记住,这个文件应该在 PythonAPI/examples 目录下运行。

我们首先想做的事情是将轨道改为 Town04,因为它比默认轨道更有趣:

client.load_world('Town04')
client.reload_world()

之前的代码需要放入 game_loop() 方法中。

变量 client 明显是连接到 Carla 服务器的客户端。

我们还需要将出生点(模拟开始的地方)改为固定,因为通常,它会每次都改变:

spawn_point = spawn_points[0] if spawn_points else carla.Transform()

现在,我们需要更改文件名。在此过程中,我们不仅会保存转向角度,还会保存油门和刹车。我们可能不会使用它们,但如果你想进行实验,它们将为你提供。以下方法应在 CameraManager 类中定义:

def set_last_controls(self, control):
    self.last_steer = control.steer
    self.last_throttle = control.throttle
    self.last_brake = control.brake

现在,我们可以按以下方式保存文件:

image.save_to_disk('_out/%08d_%s_%f_%f_%f.jpg' % (image.frame,     camera_name, self.last_steer, self.last_throttle,    self.last_brake))

image.frame 变量包含当前帧的编号,而 camera_name 目前并不重要,但它的值将是 MAIN

image 变量还包含我们想要保存的当前图像。

你应该得到类似以下的名字:

00078843_MAIN_0.000000_0.500000_0.000000.jpg

在上一个文件名中,你可以识别以下组件:

  • 帧编号(00078843

  • 相机(MAIN

  • 转向角度(0.000000

  • 油门(0.500000

  • 刹车(0.000000

这是图像,以我的情况为例:

图 8.2 – Carla 的一帧,转向 0 度

图 8.2 – Carla 的一帧,转向 0 度

这帧还可以,但并不完美。我应该待在另一条车道上,或者转向应该稍微指向右边。在行为克隆的情况下,汽车会从你那里学习,所以你的驾驶方式很重要。用键盘控制 Carla 并不好,而且在记录时,由于保存图像所花费的时间,效果更差。

真正的问题是,我们需要记录三个相机,而不仅仅是其中一个。让我们看看如何做到这一点。

记录三个视频流

要记录三个视频流,起点是拥有三个相机。

默认情况下,Carla 有以下五个相机:

  • 一个经典的 第三人称 视角,从车后上方

  • 从车前朝向道路(向前看)

  • 从车前朝向汽车(向后看)

  • 从高空

  • 从左侧

在这里,你可以看到前三个相机:

图 8.3 – 从上方、朝向道路和朝向汽车的相机

图 8.3 – 从上方、朝向道路和朝向汽车的相机

第二个相机对我们来说非常有趣。

以下是从剩余的两个相机中获取的:

图 8.4 – 从高空和左侧的 Carla 相机

图 8.4 – 从高空和左侧的 Carla 相机

最后一个相机也有些有趣,尽管我们不想在我们的帧中记录汽车。由于某种原因,Carla 的作者没有将其添加到列表中,所以我们缺少右侧的相机。

幸运的是,更换相机或添加新相机相当简单。这是原始相机的定义,CameraManager构造函数:

bound_y = 0.5 + self._parent.bounding_box.extent.y
self._camera_transforms = [
    (carla.Transform(carla.Location(x=-5.5, z=2.5),        carla.Rotation(pitch=8.0)), Attachment.SpringArm),
    (carla.Transform(carla.Location(x=1.6, z=1.7)),         Attachment.Rigid),
    (carla.Transform(carla.Location(x=5.5, y=1.5, z=1.5)),        Attachment.SpringArm),
    (carla.Transform(carla.Location(x=-8.0, z=6.0),         carla.Rotation(pitch=6.0)), Attachment.SpringArm),
    (carla.Transform(carla.Location(x=-1, y=-bound_y, z=0.5)),        Attachment.Rigid)]

作为第一次尝试,我们可以只保留第二和第五个相机,但我们希望它们处于可比较的位置。Carla 是用一个非常著名的游戏引擎编写的:Unreal Engine 4。在Unreal Engine中,z轴是垂直轴(上下),x轴用于前后,y轴用于横向移动,左右。因此,我们希望相机具有相同的xz坐标。我们还想有一个第三个相机,从右侧。为此,只需改变y坐标的符号即可。这是仅针对相机的结果代码:

(carla.Transform(carla.Location(x=1.6, z=1.7)), Attachment.Rigid),(carla.Transform(carla.Location(x=1.6, y=-bound_y, z=1.7)),    Attachment.Rigid),(carla.Transform(carla.Location(x=1.6, y=bound_y, z=1.7)),    Attachment.Rigid)

你可能可以在这里停止。我最终将侧向相机移动得更靠边,这可以通过更改bound_y来实现。

bound_y = 4

这些是我们现在得到的图像:

图 8.5 – 新相机:从左、从正面(主相机)和从右

图 8.5 – 新相机:从左、从正面(主相机)和从右

现在,应该更容易理解,与主相机相比,左右相机可以用来教神经网络如何纠正轨迹,如果它不在正确的位置。当然,这假设主相机录制的流是预期的位置。

即使现在有了正确的相机,它们也没有在使用。我们需要在World.restart()中添加它们,如下所示:

self.camera_manager.add_camera(1)self.camera_manager.add_camera(2)

CameraManager.add_camera()方法定义如下:

camera_name = self.get_camera_name(camera_index)if not (camera_index in self.sensors_added_indexes):    sensor = self._parent.get_world().spawn_actor(                self.sensors[self.index][-1],                self._camera_transforms[camera_index][0],                attach_to=self._parent,                attachment_type=self._camera_transforms[camera_index][1])        self.sensors_added_indexes.add(camera_index)        self.sensors_added.append(sensor)        # We need to pass the lambda a weak reference to self to avoid         # circular reference.        weak_self = weakref.ref(self)        sensor.listen(lambda image: CameraManager._save_image(weak_self,         image, camera_name))

这段代码的作用如下:

  1. 使用指定的相机设置传感器

  2. 将传感器添加到列表中

  3. 指示传感器调用一个 lambda 函数,该函数调用save_image()方法

下面的get_camera_name()方法用于根据其索引为相机获取一个有意义的名称,该索引依赖于我们之前定义的相机:

def get_camera_name(self, index):    return 'MAIN' if index == 0 else ('LEFT' if index == 1 else         ('RIGHT' if index == 2 else 'UNK'))

在查看save_image()的代码之前,让我们讨论一个小问题。

每一帧录制三个相机有点慢,导致每秒帧数FPS)低,这使得驾驶汽车变得困难。因此,你会过度纠正,录制一个次优的数据集,基本上是在教汽车如何蛇形行驶。为了限制这个问题,我们将为每一帧只录制一个相机视图,然后在下一帧旋转到下一个相机视图,我们将在录制过程中循环所有三个相机视图。毕竟,连续的帧是相似的,所以这不是一个大问题。

英伟达使用的相机以 30 FPS 的速度录制,但他们决定跳过大多数帧,只以 10 FPS 的速度录制,因为帧非常相似,这样会增加训练时间而不会增加太多信息。你不会以最高速度录制,但你的数据集会更好,如果你想有一个更大的数据集,你总是可以多开一些车。

save_image() 函数需要首先检查这是否是我们想要记录的帧:

if self.recording:
    n = image.frame % 3

    # Save only one camera out of 3, to increase fluidity
    if (n == 0 and camera_name == 'MAIN') or (n == 1 and         camera_name == 'LEFT') or (n == 2 and camera_name ==            'RIGHT'):
       # Code to convert, resize and save the image

第二步是将图像转换为适合 OpenCV 的格式,因为我们将要使用它来保存图像。我们需要将原始缓冲区转换为 NumPy,我们还需要删除一个通道,因为 Carla 产生的图像是 BGRA,有四个通道:蓝色、绿色、红色和透明度(不透明度):

img = np.frombuffer(image.raw_data, dtype=np.dtype('uint8'))
img = np.reshape(img, (image.height, image.width, 4))
img = img[:, :, :3]

现在,我们可以调整图像大小,裁剪我们需要的部分,并保存它:

img = cv2.resize(img, (200, 133))
img = img[67:, :, :]

cv2.imwrite('_out/%08d_%s_%f_%f_%f.jpg' % (image.frame, camera_name,   self.last_steer, self.last_throttle, self.last_brake), img). 

你可以在 GitHub 的代码仓库中看到,我记录了大量帧,足以驾驶一两个转弯,但如果你想沿着整个赛道驾驶,你需要更多的帧,而且你开得越好,效果越好。

现在,我们有了摄像头,我们需要使用它们来构建我们所需的数据集。

记录数据集

显然,为了构建数据集,你需要记录至少你期望你的网络做出的转弯。越多越好。但你也应该记录有助于你的汽车纠正轨迹的运动。左右摄像头已经帮助很多,但你应该也记录一些汽车靠近道路边缘,方向盘将其转向中心的段。

例如,考虑以下内容:

图 8.6 – 车辆靠近左侧,转向右侧

图 8.6 – 车辆靠近左侧,转向右侧

如果有转弯没有按照你想要的方式进行,你可以尝试记录它们多次,就像我这样做。

现在,你可能看到了将方向盘编码到图像名称中的优势。你可以将这些纠正段,或者你喜欢的任何内容,放在专用目录中,根据需要将它们添加到数据集中或从中移除。

如果你愿意,甚至可以手动选择图片的一部分,以纠正错误的转向角度,尽管如果只有有限数量的帧有错误的角度,这可能不是必要的。

尽管每帧只保存一个摄像头,但你可能仍然发现驾驶很困难,尤其是在速度方面。我个人更喜欢限制加速踏板,这样汽车不会开得太快,但当我想要减速时,我仍然可以减速。

加速踏板通常可以达到1的值,所以要限制它,只需要使用类似以下的一行代码,在KeyboardControl ._parse_vehicle_keys()方法中:

self._control.throttle = min(self._control.throttle + 0.01, 0.5)

为了增加流畅性,你可能需要以较低的分辨率运行客户端:

python manual_control_packt.py --res 480x320

你也可以降低服务器的分辨率,如下所示:

CarlaUE4  -ResX=480-ResY=320

现在你有了原始数据集,是时候创建真实数据集了,带有适当的转向角度。

预处理数据集

我们记录的数据集是原始的,这意味着在使用之前需要一些预处理。

最重要的是要纠正左右摄像头的转向角度。

为了方便,这是通过一个额外的程序完成的,这样你最终可以更改它,而无需再次记录帧。

首先,我们需要一种方法从名称中提取数据(我们假设文件是 JPG 或 PNG 格式):

def expand_name(file):
    idx = int(max(file.rfind('/'), file.rfind('\\')))
    prefix = file[0:idx]
    file = file[idx:].replace('.png', '').replace('.jpg', '')
    parts = file.split('_')

    (seq, camera, steer, throttle, brake, img_type) = parts

    return (prefix + seq, camera, to_float(steer),        to_float(throttle), to_float(brake), img_type)

to_float方法只是一个方便的转换,将-0 转换为 0。

现在,改变转向角度很简单:

(seq, camera, steer, throttle, brake, img_type) = expand_name(file_name)

    if camera == 'LEFT':
        steer = steer + 0.25
    if camera == 'RIGHT':
        steer = steer - 0.25

我添加了 0.25 的校正。如果你的相机离车更近,你可能想使用更小的数字。

在此过程中,我们还可以添加镜像帧,以稍微增加数据集的大小。

现在我们已经转换了数据集,我们准备训练一个类似于 DAVE-2 的神经网络来学习如何驾驶。

神经网络建模

要创建我们的神经网络,我们将从 DAVE-2 中汲取灵感,这是一个出奇简单的神经网络:

  • 我们从一个 lambda 层开始,将图像像素限制在(-1, +1)范围内:

    model = Sequential()
    model.add(Lambda(lambda x: x/127.5 - 1., input_shape=(66, 200, 3)))
    
  • 然后,有三个大小为5和步长(2,2)的卷积层,它们将输出分辨率减半,以及三个大小为3的卷积层:

    model.add(Conv2D(24, (5, 5), strides=(2, 2), activation='elu'))
    model.add(Conv2D(36, (5, 5), strides=(2, 2), activation='relu'))
    model.add(Conv2D(48, (5, 5), strides=(2, 2), activation='relu'))
    
    model.add(Conv2D(64, (3, 3), activation='relu'))
    model.add(Conv2D(64, (3, 3), activation='relu'))
    
  • 然后,我们有密集层:

    model.add(Flatten())
    model.add(Dense(1164, activation='relu'))
    model.add(Dense(100, activation='relu'))
    model.add(Dense(50, activation='relu'))
    model.add(Dense(10, activation='relu'))
    model.add(Dense(1, activation='tanh'))
    

当我想这些几行代码足以让汽车在真实道路上自动驾驶时,我总是感到惊讶!

虽然它看起来与之前我们看到的其他神经网络或多或少相似,但有一个非常重要的区别——最后的激活函数不是 softmax 函数,因为这不是一个分类器,而是一个需要执行回归任务的神经网络,根据图像预测正确的转向角度。

当神经网络试图在一个可能连续的区间内预测一个值时,我们称其为回归,例如在-1 和+1 之间。相比之下,在分类任务中,神经网络试图预测哪个标签更有可能是正确的,这很可能代表了图像的内容。因此,能够区分猫和狗的神经网络是一个分类器,而试图根据大小和位置预测公寓成本的神经网络则是在执行回归任务。

让我们看看我们需要更改什么才能使用神经网络进行回归。

训练回归神经网络

正如我们已经看到的,一个区别是缺少 softmax 层。取而代之的是,我们使用了 Tanh(双曲正切),这是一个用于生成(-1, +1)范围内值的激活函数,这正是我们需要用于转向角度的范围。然而,原则上,你甚至可以没有激活函数,直接使用最后一个神经元的值。

下图显示了 Tanh 函数:

图 8.7 – tanh 函数

图 8.7 – tanh 函数

如您所见,Tanh 将激活函数的范围限制在(-1, +1)范围内。

通常情况下,当我们训练一个分类器,例如 MNIST 或 CIFAR-10 的情况,我们使用categorical_crossentropy作为损失函数,accuracy作为指标。然而,对于回归问题,我们需要使用mse作为损失函数,并且我们可以选择性地使用cosine_proximity作为指标。

余弦相似度是向量的相似性指标。所以,1 表示它们是相同的,0 表示它们是垂直的,-1 表示它们是相反的。损失和度量代码片段如下:

model.compile(loss=mse, optimizer=Adam(), metrics=    ['cosine_proximity'])

其余的代码与分类器相同,只是我们不需要使用 one-hot 编码。

让我们看看训练的图表:

图 8.8 – 使用 DAVE-2 的行为克隆,训练

图 8.8 – 使用 DAVE-2 的行为克隆,训练

你可以看到轻微的过拟合。这是损失值:

Min Loss: 0.0026791724107401277
Min Validation Loss: 0.0006011795485392213
Max Cosine Proximity: 0.72493887
Max Validation Cosine Proximity: 0.6687041521072388

在这种情况下,损失是训练中记录的转向角与网络计算的角度之间的均方误差。我们可以看到验证损失相当好。如果你有时间,你可以尝试对这个模型进行实验,添加 dropout 或甚至改变整个结构。

很快,我们将把我们的神经网络与 Carla 集成,看看它是如何驾驶的,但在那之前,质疑神经网络是否真的专注于道路的正确部分可能是合理的。下一节将展示我们如何使用称为显著性图的技术来做这件事。

可视化显著性图

要理解神经网络关注的是什么,我们应该使用一个实际例子,所以让我们选择一张图片:

图 8.9 – 测试图像

图 8.9 – 测试图像

如果我们作为人类必须在这条路上驾驶,我们会注意车道和墙壁,尽管诚然,墙壁的重要性不如之前的最后一个车道。

我们已经知道如何了解一个CNN(卷积神经网络的简称)如 DAVE-2 在考虑什么:因为卷积层的输出是一个图像,我们可以这样可视化:

图 8.10 – 第一卷积层的部分激活

图 8.10 – 第一卷积层的部分激活

这是一个好的起点,但我们希望得到更多。我们希望了解哪些像素对预测贡献最大。为此,我们需要获取一个显著性图

Keras 不支持它们,但我们可以使用keras-vis。你可以用pip安装它,如下所示:

sudo pip install keras-vis

获取显著性图的第一步是创建一个从我们模型的输入开始但以我们想要分析的层结束的模型。生成的代码与我们所看到的激活非常相似,但为了方便,我们还需要层的索引:

conv_layer, idx_layer = next((layer.output, idx) for idx, layer in   enumerate(model.layers) if layer.output.name.startswith(name))
act_model = models.Model(inputs=model.input, outputs=[conv_layer])

虽然在我们的情况下不是必需的,但你可能想将激活变为线性,然后重新加载模型:

conv_layer.activation = activations.linear
sal_model = utils.apply_modifications(act_model)

现在,只需要调用visualize_saliency()

grads = visualize_saliency(sal_model, idx_layer,    filter_indices=None, seed_input=img)
plt.imshow(grads, alpha=.6)

我们对最后一层,即输出的显著性图感兴趣,但作为一个练习,我们将遍历所有卷积层,看看它们理解了什么。

让我们看看第一卷积层的显著性图:

图 8.11 – 第一卷积层的显著性图

图 8.11 – 第一卷积层的显著性图

并不是很令人印象深刻,因为没有显著性,我们只能看到原始图像。

让我们看看第二层的地图:

图 8.12 – 第二卷积层的显著性图

图 8.12 – 第二卷积层的显著性图

这是一种改进,但即使我们在中间线、墙上和右车道后的土地上看到了一些注意力,也不是很清晰。让我们看看第三层:

图 8.13 – 第三卷积层的显著性图

图 8.13 – 第三卷积层的显著性图

现在才是重点!我们可以看到对中央线和左线的极大关注,以及一些注意力集中在墙上和右线上。网络似乎在试图理解道路的尽头在哪里。让我们也看看第四层:

图 8.14 – 第四卷积层的显著性图

图 8.14 – 第四卷积层的显著性图

在这里,我们可以看到注意力主要集中在中央线上,但左线和墙上也有注意力的火花,以及在整个道路上的少许注意力。

我们还可以检查第五个也是最后一个卷积层:

图 8.15 – 第五卷积层的显著性图

图 8.15 – 第五卷积层的显著性图

第五层与第四层相似,并且对左线和墙上的注意力有所增加。

我们还可以可视化密集层的显著性图。让我们看看最后一层的成果,这是我们认为是该图像真实显著性图的地方:

图 8.16 – 输出层的显著性图

图 8.16 – 输出层的显著性图

最后一个显著性图,最重要的一个,对中央线和右线给予了极大的关注,加上对右上角的少许关注,这可能是一次尝试估计右车道的距离。我们还可以看到一些注意力在墙上和左车道上。所以,总的来说,这似乎很有希望。

让我们用另一张图片试试:

图 8.17 – 第二测试图像

图 8.17 – 第二测试图像

这是一张很有趣的图片,因为它是从网络尚未训练的道路部分拍摄的,但它仍然表现得很出色。

让我们看看第三卷积层的显著性图:

图 8.18 – 第三卷积层的显著性图

图 8.18 – 第三卷积层的显著性图

神经网络似乎非常关注道路的尽头,并且似乎还检测到了几棵树。如果它被训练用于制动,我敢打赌它会这么做!

让我们看看最终的地图:

图 8.19 – 输出层的显著性图

图 8.19 – 输出层的显著性图

这与之前的一个非常相似,但更加关注中央线和右侧线,以及一般道路上的一小部分。对我来说看起来不错。

让我们尝试使用最后一张图像,这张图像是从训练中取出的,用于教授何时向右转:

图 8.20 – 第三测试图像

图 8.20 – 第三测试图像

这是它的最终显著性图:

图 8.21 – 输出层的显著性图

图 8.21 – 输出层的显著性图

你可以看到神经网络主要关注右侧线,同时关注整个道路,并对左侧线投入了一些注意力的火花。

如你所见,显著性图可以是一个有效的工具,帮助我们更好地理解网络的行为,并对其对世界的解释进行一种合理性检查。

现在是时候与 Carla 集成,看看我们在现实世界中的表现如何了。系好安全带,因为我们将要驾驶,我们的神经网络将坐在驾驶座上!

将神经网络与 Carla 集成

我们现在将我们的神经网络与 Carla 集成,以实现自动驾驶。

如前所述,我们首先复制manual_control.py文件,可以将其命名为manual_control_drive.py。为了简化,我将只编写你需要更改或添加的代码,但你可以在 GitHub 上找到完整的源代码。

请记住,这个文件应该在PythonAPI/examples目录下运行。

从原则上讲,让我们的神经网络控制方向盘相当简单,因为我们只需要分析当前帧并设置转向。然而,我们还需要施加一些油门,否则汽车不会移动!

同样重要的是,你需要在游戏循环中运行推理阶段,或者你确实确信它在客户端上运行,否则性能将大幅下降,你的网络将难以驾驶,因为接收帧和发送驾驶指令之间的延迟过多。

由于 Carla 客户端每次都会更换汽车,油门的效果也会改变,有时会使你的汽车速度过快或过慢。因此,你需要一种方法通过按键来改变油门,或者你可以始终使用同一辆汽车,这将是我们的解决方案。

你可以使用以下代码行获取 Carla 中可用的汽车列表:

vehicles = world.get_blueprint_library().filter('vehicle.*')

在撰写本文时,这会产生以下列表:

vehicle.citroen.c3
vehicle.chevrolet.impala
vehicle.audi.a2
vehicle.nissan.micra
vehicle.carlamotors.carlacola
vehicle.audi.tt
vehicle.bmw.grandtourer
vehicle.harley-davidson.low_rider
vehicle.bmw.isetta
vehicle.dodge_charger.police
vehicle.jeep.wrangler_rubicon
vehicle.mercedes-benz.coupe
vehicle.mini.cooperst
vehicle.nissan.patrol
vehicle.seat.leon
vehicle.toyota.prius
vehicle.yamaha.yzf
vehicle.kawasaki.ninja
vehicle.bh.crossbike
vehicle.tesla.model3
vehicle.gazelle.omafiets
vehicle.tesla.cybertruck
vehicle.diamondback.century
vehicle.audi.etron
vehicle.volkswagen.t2
vehicle.lincoln.mkz2017
vehicle.mustang.mustang

World.restart()中,你可以选择你喜欢的汽车:

bp=self.world.get_blueprint_library().filter(self._actor_filter)
blueprint = next(x for x in bp if x.id == 'vehicle.audi.tt')

Carla 使用演员,可以代表车辆、行人、传感器、交通灯、交通标志等;演员是通过名为try_spawn_actor()的模板创建的:

self.player = self.world.try_spawn_actor(blueprint, spawn_point)

如果你现在运行代码,你会看到汽车,但视角是错误的。按下Tab键可以修复它:

图 8.22 – 左:默认初始相机,右:自动驾驶相机

图 8.22 – 左:默认初始相机,右:自动驾驶相机

如果你想要从我在训练汽车的地方开始,你也需要在相同的方法中设置起始点:

spawn_point = spawn_points[0] if spawn_points else carla.Transform()

如果你这样做,汽车将在随机位置生成,并且可能驾驶时会有更多问题。

game_loop()中,我们还需要选择合适的赛道:

client.load_world('Town04')
client.reload_world()

如果你现在运行它,在按下Tab之后,你应该看到以下类似的内容:

图 8.23 – Carla 的图像,准备自动驾驶

图 8.23 – Carla 的图像,准备自动驾驶

如果你按下F1,你可以移除左侧的信息。

为了方便起见,我们希望能够触发自动驾驶模式的开和关,因此我们需要一个变量来处理,如下所示,以及一个在KeyboardControl构造函数中保存计算出的转向角度的变量:

self.self_driving = False

然后,在KeyboardControl.parse_events()中,我们将拦截D键,并切换自动驾驶功能的开和关:

elif event.key == K_d:
  self.self_driving = not self.self_driving
  if self.self_driving:
    world.hud.notification('Self-driving with Neural Network')
  else:
    world.hud.notification('Self-driving OFF')

下一步是将从服务器接收到的最后一张图像进行缩放并保存,当它仍然是 BGR 格式时,在CameraManager._parse_image()中。这在这里展示:

array_bgr = cv2.resize(array, (200, 133))
self.last_image = array_bgr[67:, :, :]
array = array[:, :, ::-1]  # BGR => RGB

array变量最初包含 BGR 格式的图像,而 NumPy 中的::-1反转了顺序,所以最后一行代码实际上在可视化之前将图像从 BGR 转换为 RGB。

现在,我们可以在game_loop()函数外部,主循环之外加载模型:

model = keras.models.load_model('behave.h5')

然后,我们可以在game_loop()主循环内部运行模型,并保存转向,如下所示:

if world.camera_manager.last_image is not None:
  image_array = np.asarray(world.camera_manager.last_image)
  controller.self_driving_steer = model.predict(image_array[    None, :, :, :], batch_size=1)[0][0].astype(float)

最后要做的事情就是使用我们计算出的转向,设置一个固定的油门,并限制最大速度,同时进行:

if self.self_driving:
  self.player_max_speed = 0.3
  self.player_max_speed_fast = 0.3
  self._control.throttle = 0.3
  self._control.steer = self.self_driving_steer
  return

这听起来很好,但是它可能因为 GPU 错误而无法工作。让我们看看是什么问题以及如何克服它。

让你的 GPU 工作

你可能会得到类似于以下错误的错误:

failed to create cublas handle: CUBLAS_STATUS_ALLOC_FAILED

我对发生的事情的理解是,与 Carla 的某些组件存在冲突,无论是服务器还是客户端,这导致 GPU 内存不足。特别是,TensorFlow 在尝试在 GPU 中分配所有内存时造成了问题。

幸运的是,这可以通过以下几行代码轻松修复:

import tensorflow
gpus = tensorflow.config.experimental.list_physical_devices('GPU')
if gpus:
  try:
    for gpu in gpus:
      tensorflow.config.experimental.set_memory_growth(gpu, True)
    print('TensorFlow allowed growth to ', len(gpus), ' GPUs')
  except RuntimeError as e:
    print(e)

set_memory_growth()的调用指示 TensorFlow 只分配 GPU RAM 的一部分,并在需要时最终分配更多,从而解决问题。

到目前为止,你的汽车应该能够驾驶,让我们讨论一下它是如何工作的。

自动驾驶!

现在,你可以开始运行manual_control_drive.py,可能需要使用--res 480x320参数来降低分辨率。

如果你按下 D 键,汽车应该会开始自动行驶。它可能相当慢,但应该会运行,有时运行得很好,有时则不那么好。它可能不会总是按照它应该走的路线行驶。你可以尝试向数据集中添加图像或改进神经网络的架构——例如,通过添加一些 dropout 层。

你可以尝试更换汽车或提高速度。你可能会注意到,在更高的速度下,汽车开始更加无规律地移动,就像司机喝醉了一样!这是由于汽车进入错误位置和神经网络对其做出反应之间的过度延迟造成的。我认为这可以通过一个足够快的计算机来部分解决,以便处理许多 FPS。然而,我认为真正的解决方案还需要记录更高速度的运行,其中校正会更强烈;这需要一个比键盘更好的控制器,你还需要在输入中插入速度,或者拥有多个神经网络,并根据速度在它们之间切换。

有趣的是,有时即使我们使用外部摄像头,它也能以某种方式驾驶,结果是我们的汽车成为了图像的一部分!当然,结果并不好,即使速度很低,你也会得到 醉酒驾驶 的效果。

出于好奇,让我们检查一下显著性图。这是我们发送给网络的图像:

图 8.24 – 后视图

图 8.24 – 后视图

现在,我们可以检查显著性图:

图 8.25 – 显著性图:第三卷积层和输出层

图 8.25 – 显著性图:第三卷积层和输出层

网络仍然能够识别线条和道路;然而,它非常关注汽车。我的假设是神经网络 认为 它是一个障碍物,道路在结束。

如果你想要教汽车如何使用这个摄像头或任何其他摄像头来驾驶,你需要用那个特定的摄像头来训练它。如果你想汽车在另一条赛道上正确驾驶,你需要在那条特定的赛道上训练它。最终,如果你在许多赛道和许多条件下训练它,它应该能够去任何地方驾驶。但这意味着构建一个包含数百万图像的巨大数据集。最终,如果你的数据集太大,你会耗尽内存。

在下一节中,我们将讨论生成器,这是一种可以帮助我们克服这些问题的技术。

使用生成器训练更大的数据集

当训练大数据集时,内存消耗可能成为一个问题。在 Keras 中,解决这个问题的方法之一是使用 Python 生成器。Python 生成器是一个可以懒加载地返回可能无限值流的函数,具有非常低的内存占用,因为你只需要一个对象的内存,当然,还需要所有可能需要的支持数据;生成器可以用作列表。典型的生成器有一个循环,并且对于需要成为流一部分的每个对象,它将使用yield关键字。

在 Keras 中,生成器需要知道批次大小,因为它需要返回一个样本批次和一个标签批次。

我们将保留一个要处理的文件列表,我们将编写一个生成器,可以使用这个列表返回与之关联的图像和标签。

我们将编写一个通用的生成器,希望你在其他情况下也能重用,它将接受四个参数:

  • 一个 ID 列表,在我们的例子中是文件名

  • 一个从 ID 检索输入(图像)的函数

  • 一个从 ID 检索标签(方向盘)的函数

  • 批次大小

首先,我们需要一个函数,可以返回给定文件的图像:

def extract_image(file_name):
    return cv2.imread(file_name)

我们还需要一个函数,给定一个文件名,可以返回标签,在我们的例子中是转向角度:

def extract_label(file_name):
  (seq, camera, steer, throttle, brake, img_type) =    expand_name(file_name)
  return steer

我们现在可以编写生成器,如下所示:

def generator(ids, fn_image, fn_label, batch_size=32):
  num_samples = len(ids)
  while 1: # The generator never terminates
    samples_ids = shuffle(ids) # New epoch

    for offset in range(0, num_samples, batch_size):
      batch_samples_ids = samples_ids[offset:offset + batch_size]
      batch_samples = [fn_image(x) for x in batch_samples_ids]
      batch_labels = [fn_label(x) for x in batch_samples_ids]

      yield np.array(batch_samples), np.array(batch_labels)

while循环中的每次迭代对应一个周期,而for循环生成完成每个周期所需的全部批次;在每个周期的开始,我们随机打乱 ID 以改善训练。

在 Keras 中,过去你必须使用fit_generator()方法,但现在fit()能够理解如果参数是一个生成器,但你仍然需要提供一些新的参数:

  • steps_per_epoch:这表示单个训练周期中批次的数量,即训练样本数除以批次大小。

  • validation_steps:这表示单个验证周期中批次的数量,即验证样本数除以批次大小。

这是你需要使用我们刚刚定义的generator()函数的代码:

files = shuffle(files)idx_split = int(len(files) * 0.8)
val_size = len(files) - idx_split
train_gen = generator(files[0:idx_split], extract_image,  extract_label, batch_size)
valid_gen = generator(files[idx_split:], extract_image,  extract_label, batch_size)
history_object = model.fit(train_gen, epochs=250,  steps_per_epoch=idx_split/batch_size, validation_data=valid_gen,  validation_steps=val_size/batch_size, shuffle=False, callbacks=  [checkpoint, early_stopping])

多亏了这段代码,你现在可以利用非常大的数据集了。然而,生成器还有一个应用:自定义按需数据增强。让我们简单谈谈这个话题。

硬件方式增强数据

我们已经看到了一种简单的方法来进行数据增强,使用第七章中的ImageDataGenerator检测行人和交通灯。这可能适用于分类器,因为应用于图像的变换不会改变其分类。然而,在我们的情况下,这些变换中的一些会需要改变预测。实际上,英伟达设计了一种自定义数据增强,其中图像被随机移动,方向盘根据移动量相应更新。这可以通过生成器来完成,其中我们取原始图像,应用变换,并根据移动量调整方向盘。

但我们不仅限于复制输入中相同数量的图像,我们还可以创建更少(过滤)或更多;例如,镜像可以在运行时应用,结果是在内存中重复图像,而不必存储双倍数量的图像和保存,因此节省了文件访问和 JPEG 解压缩的一半;当然,我们还需要一些 CPU 来翻转图像。

摘要

在本章中,我们探讨了众多有趣的主题。

我们首先描述了 DAVE-2,这是英伟达的一个实验,旨在证明神经网络可以学会在道路上驾驶,我们决定在更小的规模上复制相同的实验。首先,我们从 Carla 收集图像,注意不仅要记录主摄像头,还要记录两个额外的侧摄像头,以教会网络如何纠正错误。

然后,我们创建了我们的神经网络,复制了 DAVE-2 的架构,并对其进行回归训练,这与其他我们迄今为止所做的训练相比需要一些改变。我们学习了如何生成显著性图,并更好地理解神经网络关注的地方。然后,我们与 Carla 集成,并使用该网络来自动驾驶汽车!

最后,我们学习了如何使用 Python 生成器训练神经网络,并讨论了如何利用这种方法实现更复杂的数据增强。

在下一章中,我们将探索一种用于在像素级别检测道路的尖端技术——语义分割。

问题

阅读本章后,你应该能够回答以下问题:

  1. 英伟达为自动驾驶训练的神经网络的原始名称是什么?

  2. 分类和回归任务之间的区别是什么?

  3. 你可以使用哪个 Python 关键字来创建生成器?

  4. 什么是显著性图?

  5. 为什么我们需要记录三个视频流?

  6. 为什么我们从game_loop()方法中进行推理?

进一步阅读

第九章:第九章:语义分割

这可能是关于深度学习最先进的章节,因为我们将会深入到使用一种称为语义分割的技术,对图像进行像素级的分类。我们将充分利用到目前为止所学的内容,包括使用生成器进行数据增强。

我们将非常详细地研究一个非常灵活且高效的神经网络架构,称为 DenseNet,以及其用于语义分割的扩展,FC-DenseNet,然后我们将从头编写它,并使用由 Carla 构建的数据集进行训练。

希望您会发现这一章既鼓舞人心又具有挑战性。并且准备好进行长时间的训练,因为我们的任务可能相当有挑战性!

在本章中,我们将涵盖以下主题:

  • 介绍语义分割

  • 理解 DenseNet 用于分类

  • 使用 CNN 进行语义分割

  • 将 DenseNet 应用于语义分割

  • 编写 FC-DenseNet 的模块

  • 改进糟糕的语义分割

技术要求

要使用本章中解释的代码,您需要安装以下工具和模块:

  • Carla 模拟器

  • Python 3.7

  • NumPy 模块

  • TensorFlow 模块

  • Keras 模块

  • OpenCV-Python 模块

  • 一个 GPU(推荐)

本章的代码可以在github.com/PacktPublishing/Hands-On-Computer-Vision-for-Self-Driving-Cars找到。

本章的“代码实战”视频可以在以下位置找到:

bit.ly/3jquo3v

介绍语义分割

在前面的章节中,我们实现了几个分类器,我们提供了一个图像作为输入,网络告诉我们它是什么。这在许多情况下可能是非常好的,但要非常有用,通常需要结合一种可以识别感兴趣区域的方法。我们在第七章检测行人和交通灯中做到了这一点,我们使用了 SSD 来识别交通灯的感兴趣区域,然后我们的神经网络能够说出颜色。但即使这样,对我们来说也不会非常有用,因为 SSD 生成的感兴趣区域是矩形,因此一个告诉我们基本上和图像一样大的道路的网络不会提供太多信息:道路是直的?有转弯吗?我们无法知道。我们需要更高的精度。

如果对象检测器如 SSD 将分类提升到了下一个层次,现在我们需要达到那个层次之后,也许还有更多。实际上,我们想要对图像的每个像素进行分类,这被称为语义分割,这是一个相当有挑战性的任务。

为了更好地理解,让我们看看一个来自 Carla 的例子。以下是原始图像:

图 9.1 – Carla 的一帧

图 9.1 – Carla 的一帧

现在让我们看看语义分割相机产生的同一帧:

图 9.2 – 语义分割

图 9.2 – 语义分割

这真是太好了!不仅图像非常简化,而且每种颜色都有特定的含义——道路是紫色,人行道是洋红色,树木是深绿色,车道线是亮绿色,等等。为了设定你的期望,我们可能无法达到如此完美的结果,我们也将以更低的分辨率工作,但我们仍然会取得有趣的结果。

为了更精确,这张图片并不是网络的真正输出,但它已经被转换为显示颜色;rgb(7,0,0),其中 7 将被转换为紫色。

Carla 创建具有语义分割的图像的能力非常有帮助,可以让你随意实验,而无需依赖于预制的和有限的数据库。

在我们开始收集数据集之前,让我们更详细地讨论一下计划。

定义我们的目标

我们的目标是使用我们收集的数据集从头开始训练一个神经网络进行语义分割,以便它可以在像素级别检测道路、人行道、行人、交通标志等等。

需要的步骤如下:

  1. 创建数据集:我们将使用 Carla 保存原始图像、原始分割图像(黑色图像,颜色较深)以及转换为便于我们使用的更好颜色的图像。

  2. 构建神经网络:我们将深入研究一个称为 DenseNet 的架构,然后我们将看到执行语义分割的网络通常是如何构建的。在此之后,我们将查看用于语义分割的 DenseNet 的一个变体,称为 FC-DenseNet,然后我们将实现它。

  3. 训练神经网络:在这里,我们将训练网络并评估结果;训练可能需要几个小时。

我们现在将看到收集数据集所需的更改。

收集数据集

我们已经看到了如何从 Carla 记录图像并修改 第八章 中的 manual_control.py行为克隆,你也能做到这一点,但我们有一个问题:我们真的希望 RGB 和原始相机能够完全同步,以避免使我们的数据集变得不那么有效的运动。这个问题可以通过同步模式来解决,其中 Carla 等待所有传感器都准备好后再将它们发送到客户端,这确保了我们将要保存的三台相机之间完美的对应关系:RGB、原始分割和彩色分割。

这次,我们将修改另一个文件,synchronous_mode.py,因为它更适合这项任务。

我将指定每个代码块在文件中的位置,但建议你前往 GitHub 并查看那里的完整代码。

这个文件比 manual_control.py 简单得多,基本上有两个有趣的方面:

  • CarlaSyncMode,一个使能同步模式的类

  • main(),它初始化世界(代表轨道、天气和车辆的物体)和相机,然后移动汽车,在屏幕上绘制它

如果你运行它,你会看到这个文件可以自动驾驶汽车,可能速度非常快,合并 RGB 相机和语义分割:

图 9.3 – 的输出

图 9.3 – synchronous_mode.py的输出

不要对自动驾驶算法过于印象深刻,因为虽然对我们来说非常方便,但它也非常有限。

卡拉拉(Carla)有大量的路标,这些是 3D 指向的点。这些点在每个轨道上数以千计,沿着道路排列,并来自 OpenDRIVE 地图;OpenDRIVE 是一个开放文件格式,卡拉拉(Carla)使用它来描述道路。这些点与道路方向一致,因此如果你在移动汽车的同时也应用这些点的方向,汽车实际上就像自动驾驶一样移动。太棒了!直到你添加汽车和行人;然后你开始得到这样的帧,因为汽车会移动到其他车辆中:

图 9.4 – 发生碰撞的帧

图 9.4 – 发生碰撞的帧

当你看到这个时可能会有些惊讶,但对我们来说这仍然很好,所以这不是一个大问题。

让我们看看现在我们需要如何修改synchronous_mode.py

修改synchronous_mode.py

所有后续的更改都需要在main()函数中进行:

  • 我们将改变相机位置,使其与我们在行为克隆中使用的相同,尽管这不是必需的。这涉及到将两个carla.Transform()调用改为这一行(对两个位置都是相同的行):

    carla.Transform(carla.Location(x=1.6, z=1.7), 	  carla.Rotation(pitch=-15))
    
  • 在移动汽车后,我们可以保存 RGB 相机和原始语义分割图像:

    save_img(image_rgb, '_out/rgb/rgb_%08d.png' % 	  image_rgb.frame)
    save_img(image_semseg, '_out/seg_raw/seg_raw_%08d.png' % 	  image_rgb.frame)
    
  • 在代码中,调用image_semseg.convert()的下一行将原始图像转换为彩色版本,根据 CityScapes 调色板;现在我们可以保存带有语义分割的图像,使其正确着色:

    save_img(image_semseg, '_out/seg/seg_%08d.png' % 	  image_rgb.frame)
    
  • 我们几乎完成了。我们只需要编写save_img()函数:

    def save_img(image, path):
        array = np.frombuffer(image.raw_data, dtype=np.dtype("uint8"))
        array = np.reshape(array, (image.height, image.width, 4))
        array = array[:, :, :3]
        img = cv2.resize(array, (160, 160), 	 interpolation=cv2.INTER_NEAREST)
        cv2.imwrite(path, img)
    

代码的前几行将卡拉拉(Carla)的图像从缓冲区转换为 NumPy 数组,并选择前三个通道,丢弃第四个通道(透明度通道)。然后我们使用INTER_NEAREST算法将图像调整大小为 160 X 160,以避免在调整大小时平滑图像。

最后一行保存了图像。

小贴士:使用最近邻算法调整分割掩码的大小

你是否好奇为什么我们使用INTER_NEAREST,即最近邻算法进行缩放,这是最基础的插值方法?原因在于它不是进行颜色插值,而是选择接近插值位置的像素颜色,这对于原始语义分割非常重要。例如,假设我们将四个像素缩小到一点。其中两个像素的值为 7(道路),另外两个像素的值为 9(植被)。我们可能对输出为 7 或 9 都感到满意,但肯定不希望它为 8(人行道)!

但对于 RGB 和彩色分割,你可以使用更高级的插值。

这就是收集图像所需的一切。160 X 160 的分辨率是我为我的网络选择的,我们稍后会讨论这个选择。如果你使用另一个分辨率,请相应地调整设置。

你也可以以全分辨率保存,但这样你就必须编写一个程序在之后更改它,或者在你训练神经网络时进行此操作,由于我们将使用生成器,这意味着我们需要为每张图像和每个 epoch 使用这个约定——在我们的案例中超过 50,000 次——此外,它还会使加载 JPEG 变慢,在我们的案例中这也需要执行 50,000 次。

现在我们有了数据集,我们可以构建神经网络。让我们从 DenseNet 的架构开始,这是我们模型的基础。

理解 DenseNet 在分类中的应用

DenseNet是一个令人着迷的神经网络架构,旨在具有灵活性、内存效率、有效性和相对简单性。关于 DenseNet 有很多值得喜欢的地方。

DenseNet 架构旨在构建非常深的网络,通过从 ResNet 中提取的技术解决了梯度消失的问题。我们的实现将达到 50 层,但你很容易构建一个更深层的网络。实际上,Keras 有三种在 ImageNet 上训练的 DenseNet,分别有 121、169 和 201 层。DenseNet 还解决了神经元死亡的问题,即当你有基本不活跃的神经元时。下一节将展示 DenseNet 的高级概述。

从鸟瞰角度看 DenseNet

目前,我们将关注 DenseNet 作为一个分类器,这并不是我们即将实现的内容,但作为一个概念来开始理解它是很有用的。DenseNet 的高级架构在以下图中展示:

图 9.5 – DenseNet 作为分类器的高级视图,包含三个密集块

图 9.5 – DenseNet 作为分类器的高级视图,包含三个密集块

图中只显示了三个密集块,但实际上通常会有更多。

如从图中所示,理解以下内容相当简单:

  • 输入是一个 RGB 图像。

  • 存在一个初始的 7 X 7 卷积。

  • 存在一个密集块,其中包含一些卷积操作。我们很快会对此进行深入描述。

  • 每个密集块后面都跟着一个 1 X 1 卷积和一个平均池化,这会减小图像的大小。

  • 最后一个密集块直接跟着平均池化。

  • 最后,有一个密集(全连接)层,带有 softmax

1 X 1 卷积可以用来减少通道数以加快计算速度。DenseNet 论文中将 1 X 1 卷积后面跟着平均池化称为 过渡层,当通道数减少时,他们称得到的网络为 DenseNet-C,其中 C 代表 压缩,卷积层被称为 压缩层

作为分类器,这个高级架构并不特别引人注目,但正如你可能猜到的,创新在于密集块,这是下一节的重点。

理解密集块

密集块是该架构的名称,也是 DenseNet 的主要部分;它们包含卷积,通常根据分辨率、你想要达到的精度以及性能和训练时间,你会有几个这样的块。请注意,它们与我们之前遇到的密集层无关。

密集块是我们可以通过重复来增加网络深度的块,它们实现了以下目标:

  • 它们解决了 梯度消失 问题,使我们能够构建非常深的网络。

  • 他们非常高效,使用相对较少的参数。

  • 它们解决了 无效神经元 问题,意味着所有的卷积都对最终结果有贡献,我们不会浪费 CPU 和内存在基本无用的神经元上。

这些是宏伟的目标,许多架构都难以实现这些目标。那么,让我们看看 DenseNet 如何做到其他许多架构无法做到的事情。以下是一个密集块:

图 9.6 – 带有五个卷积的密集块,以及输入

图 9.6 – 带有五个卷积的密集块,以及输入

这确实很了不起,需要一些解释。也许你还记得来自 第七章检测行人和交通信号灯ResNet,这是一个由微软构建的神经网络,它有一个名为 跳过连接 的特性,这些快捷方式允许一层跳过其他层,有助于解决梯度消失问题,从而实现更深的网络。实际上,ResNet 的一些版本可以有超过 1,000 层!

DenseNet 将这一概念推向了极致,因为在每个密集块内部,每个卷积层都与其他卷积层连接并连接起来!这有两个非常重要的含义:

  • 跳过连接的存在显然实现了 ResNet 中跳过连接的相同效果,使得训练更深的网络变得容易得多。

  • 多亏了跳跃连接,每一层的特征可以被后续层复用,这使得网络非常高效,并且与其它架构相比,大大减少了参数数量。

该功能的复用效果可以通过以下图表更好地理解,该图表解释了密集块的效果,重点关注通道而不是跳跃连接:

图 9.7 – 跳跃连接对具有五个层和增长率为三的密集块的影响

图 9.7 – 跳跃连接对具有五个层和增长率为三的密集块的影响

第一条水平线显示了每个卷积添加的新特征,而所有其他水平线都是前一层提供的卷积,并且由于跳跃连接而得以复用。

通过分析图表,其中每一层的内容是一列,我们可以看到以下内容:

  • 输入层有 5 个通道。

  • 第 1 层增加了 3 个新通道,并复用了输入,因此它实际上有 8 个通道。

  • 第 2 层增加了 3 个新通道,并复用了输入和第 1 层,因此它实际上有 11 个通道。

  • 这种情况一直持续到第 5 层,它增加了 3 个新通道,并复用了输入以及第 1、2、3 和 4 层,因此它实际上有 20 个通道。

这非常强大,因为卷积可以复用之前的层,只添加一些新通道,结果使得网络紧凑且高效。此外,这些新通道将提供新的信息,因为它们可以直接访问之前的层,这意味着它们不会以某种方式复制相同的信息或失去与之前几层已计算内容的联系。每一层添加的新通道数量被称为增长率;在我们的例子中它是3,而在现实生活中它可能为 12、16 或更多。

为了使密集块正常工作,所有卷积都需要使用same值进行填充,正如我们所知,这保持了分辨率不变。

每个密集块后面都跟着一个具有平均池化的过渡层,这降低了分辨率;由于跳跃连接需要卷积的分辨率相同,这意味着我们只能在同一密集块内部有跳跃连接。

密集块的每一层由以下三个组件组成:

  • 一个批量归一化层

  • 一个 ReLU 激活

  • 卷积

因此,卷积块可以写成如下形式:

layer = BatchNormalization()(layer)
layer = ReLU()(layer)
layer = Conv2D(num_filters, kernel_size, padding="same",   kernel_initializer='he_uniform')(layer)

这是一种不同的 Keras 代码编写风格,在这种风格中,不是使用模型对象来描述架构,而是构建一系列层;这是使用跳跃连接时应该使用的风格,因为你需要灵活性,能够多次使用相同的层。

在 DenseNet 中,每个密集块的开始处,你可以添加一个可选的 1 X 1 卷积,目的是减少输入通道的数量,从而提高性能;当存在这个 1 X 1 卷积时,我们称之为瓶颈层(因为通道数量减少了),而网络被称为DenseNet-B。当网络同时具有瓶颈层和压缩层时,它被称为DenseNet-BC。正如我们所知,ReLU 激活函数会添加非线性,因此有很多层可以导致网络学习非常复杂的函数,这对于语义分割肯定是非常需要的。

如果你对 dropout 有所疑问,DenseNet 可以在没有 dropout 的情况下很好地工作;其中一个原因是存在归一化层,它们已经提供了正则化效果,因此与 dropout 的组合并不特别有效。此外,dropout 的存在通常要求我们增加网络的大小,这与 DenseNet 的目标相悖。尽管如此,原始论文提到在卷积层之后使用 dropout,当没有数据增强时,我认为通过扩展,如果样本不多,dropout 可以帮助。

现在我们已经了解了 DenseNet 的工作原理,让我们学习如何构建用于语义分割的神经网络,这将为后续关于如何将 DenseNet 应用于语义分割任务的章节铺平道路。

使用 CNN 进行图像分割

典型的语义分割任务接收一个 RGB 图像作为输入,并需要输出一个包含原始分割的图像,但这种方法可能存在问题。我们已经知道,分类器使用one-hot encoded标签生成结果,我们也可以为语义分割做同样的事情:而不是生成一个包含原始分割的单个图像,网络可以创建一系列one-hot encoded图像。在我们的案例中,由于我们需要 13 个类别,网络将输出 13 个 RGB 图像,每个标签一个,具有以下特征:

  • 一张图像只描述一个标签。

  • 属于该标签的像素在红色通道中的值为1,而所有其他像素都被标记为0

每个给定的像素在一个图像中只能为1,在所有其他图像中都将为0。这是一个困难的任务,但并不一定需要特定的架构:一系列带有same填充的卷积层可以完成这项任务;然而,它们的成本很快就会变得计算昂贵,并且你可能还会遇到在内存中拟合模型的问题。因此,人们一直在努力改进这种架构。

如我们所知,解决此类问题的典型方法是通过一种形式的池化来降低分辨率,同时增加层和通道。这对于分类是有效的,但我们需要生成与输入相同分辨率的图像,因此我们需要一种方法来回退到该分辨率。实现这一目标的一种方法是通过使用转置卷积,也称为反卷积,这是一种与卷积相反方向的转换,能够增加输出分辨率。

如果你添加一系列卷积和一系列反卷积,得到的网络是 U 形的,左侧从输入开始,添加卷积和通道同时降低分辨率,右侧有一系列反卷积将分辨率恢复到原始值。这比仅使用相同大小的卷积更有效率,但生成的分割实际上分辨率会比原始输入低得多。为了解决这个问题,可以从左侧引入跳过连接到右侧,以便网络有足够的信息来恢复正确的分辨率,不仅在形式上(像素数),而且在实际层面(掩码级别)。

现在我们可以看看如何将这些想法应用到 DenseNet 中。

将 DenseNet 应用于语义分割

DenseNet 由于其效率、准确性和丰富的跳层而非常适合语义分割。事实上,即使在数据集有限且标签表示不足的情况下,使用 DenseNet 进行语义分割也已被证明是有效的。

要使用 DenseNet 进行语义分割,我们需要能够构建U网络的右侧,这意味着我们需要以下内容:

  • 一种提高分辨率的方法;如果我们称 DenseNet 的过渡层为transition down,那么我们需要transition-up层。

  • 我们需要构建跳层来连接U网络的左右两侧。

我们的参考网络是 FC-DenseNet,也称为一百层提拉米苏,但我们并不试图达到 100 层。

在实践中,我们希望实现一个类似于以下架构的架构:

图 9.8 – FC-DenseNet 架构示例

图 9.8 – FC-DenseNet 架构示例

图 9.8中连接拼接层的水平红色箭头是用于提高输出分辨率的跳连接,并且它们只能在工作时,左侧相应的密集块的输出与右侧相应的密集块的输入具有相同的分辨率;这是通过使用过渡-up 层实现的。

现在我们来看如何实现 FC-DenseNet。

编码 FC-DenseNet 的模块

DenseNet 非常灵活,因此您可以轻松地以多种方式配置它。然而,根据您计算机的硬件,您可能会遇到 GPU 的限制。以下是我计算机上使用的值,但请随意更改它们以实现更好的精度或减少内存消耗或训练网络所需的时间:

  • 输入和输出分辨率: 160 X 160

  • 增长率(每个密集块中每个卷积层添加的通道数): 12

  • 密集块数量: 11: 5 个向下,1 个用于在向下和向上之间过渡,5 个向上

  • 每个密集块中的卷积块数量: 4

  • 批量大小: 4

  • 密集块中的瓶颈层: 否

  • 压缩因子: 0.6

  • Dropout: 是的,0.2

    我们将定义一些函数,您可以使用它们来构建 FC-DenseNet,并且,像往常一样,我们邀请您查看 GitHub 上的完整代码。

    第一个函数只是定义了一个带有批量归一化的卷积:

    def dn_conv(layer, num_filters, kernel_size, dropout=0.0):
        layer = BatchNormalization()(layer)
        layer = ReLU()(layer)
        layer = Conv2D(num_filters, kernel_size, padding="same", kernel_initializer='he_uniform')(layer)
         if dropout > 0.0:
            layer = Dropout(dropout)(layer)
         return layer
    

    没有什么特别的——我们在 ReLU 激活之前有一个批量归一化,然后是一个卷积层和可选的 Dropout。

    下一个函数使用先前的方法定义了一个密集块:

    def dn_dense(layer, growth_rate, num_layers, add_bottleneck_layer, dropout=0.0):
      block_layers = []
      for i in range(num_layers):
        new_layer = dn_conv(layer, 4 * growth_rate, (1, 1),       dropout) if add_bottleneck_layer else layer
        new_layer = dn_conv(new_layer, growth_rate, (3, 3), dropout)
        block_layers.append(new_layer)
        layer = Concatenate()([layer, new_layer])
      return layer, Concatenate()(block_layers)
    

    发生了很多事情:

num_layers 3 X 3 卷积层,每次添加growth_rate通道。此外,如果add_bottleneck_layer被设置,则在每个 3 X 3 卷积之前,它添加一个 1 X 1 卷积来将输入的通道数转换为4* growth_rate;在我的配置中我没有使用瓶颈层,但您可以使用。

它返回两个输出,其中第一个输出,layer,是每个卷积的所有输出的连接,包括输入,第二个输出,来自block_layers,是每个卷积的所有输出的连接,不包括输入。

我们需要两个输出的原因是因为下采样路径和上采样路径略有不同。在下采样过程中,我们包括块的输入,而在上采样过程中则不包括;这只是为了保持网络的大小和计算时间合理,因为,在我的情况下,如果没有这个变化,网络将从 724 K 参数跳变到 12 M!

下一个函数定义了用于在下采样路径中降低分辨率的过渡层:

def dn_transition_down(layer, compression_factor=1.0, dropout=0.0):
  num_filters_compressed = int(layer.shape[-1] *     compression_factor)
  layer = dn_conv(layer, num_filters_compressed, (1, 1), dropout)

  return AveragePooling2D(2, 2, padding='same')(layer)

它只是创建了一个 1 X 1 的卷积,然后是一个平均池化;如果您选择添加压缩因子,则通道数将减少;我选择了压缩因子0.6,因为没有压缩的网络太大,无法适应我的 GPU 的 RAM。

下一个方法是用于在上采样路径中增加分辨率的过渡层:

def dn_transition_up(skip_connection, layer):  num_filters = int(layer.shape[-1])  layer = Conv2DTranspose(num_filters, kernel_size=3, strides=2,    padding='same',                            kernel_initializer='he_uniform')(layer)  return Concatenate()([layer, skip_connection])

它创建了一个反卷积来增加分辨率,并添加了跳过连接,这对于使我们能够增加分割掩码的有效分辨率当然很重要。

现在我们已经拥有了所有构建块,剩下的只是组装完整的网络。

将所有部件组合在一起

首先,关于分辨率的说明:我选择了 160 X 160,因为这基本上是我笔记本电脑能做的最大分辨率,结合其他设置。你可以尝试不同的分辨率,但你将看到并非所有分辨率都是可能的。实际上,根据密集块的数量,你可能需要使用 16、32 或 64 的倍数。为什么是这样?简单来说,让我们举一个例子,假设我们将使用 160 X 160。如果在下采样过程中,你将分辨率降低 16 倍(例如,你有 4 个密集块,每个块后面都有一个向下转换层),那么你的中间分辨率将是一个整数——在这种情况下,10 X 10。

当你上采样 4 次时,你的分辨率将增长 16 倍,所以你的最终分辨率仍然是 160 X 160。但如果你从 170 X 170 开始,你最终仍然会得到一个中间分辨率为 10 X 10 的分辨率,上采样它将产生一个最终分辨率为 160 X 160 的分辨率!这是一个问题,因为你需要将这些输出与下采样期间取出的跳跃层连接起来,如果两个分辨率不同,那么我们无法连接层,Keras 将生成一个错误。至于比例,它不需要是平方的,也不需要匹配你图像的比例。

下一步,我们需要做的是创建神经网络的输入和第一个卷积层的输入,因为密集块假设它们之前有一个卷积:

input = Input(input_shape)layer = Conv2D(36, 7, padding='same')(input)

我使用了一个没有最大池化的 7 X 7 卷积,但请随意实验。你可以使用更大的图像并引入最大池化或平均池化,或者如果你能训练它,也可以创建一个更大的网络。

现在我们可以生成下采样路径:

skip_connections = []for idx in range(groups):  (layer, _) = dn_dense(layer, growth_rate, 4,     add_bottleneck_layer, dropout)  skip_connections.append(layer)  layer = dn_transition_down(layer, transition_compression_factor,      dropout)

我们简单地创建我们想要的全部组,在我的配置中是五个,对于每个组我们添加一个密集层和一个向下转换层,并且我们还记录跳跃连接。

以下步骤构建上采样路径:

skip_connections.reverse()
(layer, block_layers) = dn_dense(layer, growth_rate, 4,   add_bottleneck_layer, dropout)

for idx in range(groups):
  layer = dn_transition_up(skip_connections[idx], block_layers)
  (layer, block_layers) = dn_dense(layer, growth_rate, 4,     add_bottleneck_layer, dropout)

我们反转了跳跃连接,因为在向上传递时,我们会以相反的顺序遇到跳跃连接,并且我们添加了一个没有跟随向下转换的密集层。这被称为瓶颈层,因为它包含的信息量很少。然后我们简单地创建与下采样路径对应的向上转换和密集层。

现在我们有了最后一部分,让我们生成输出:

layer = Conv2D(num_classes, kernel_size=1, padding='same',   kernel_initializer='he_uniform')(layer)output = Activation('softmax')(layer)model = Model(input, output)

我们简单地添加一个 1 X 1 卷积和一个 softmax 激活。

困难的部分已经完成,但我们需要学习如何将输入馈送到网络中。

向网络输入数据

向神经网络输入数据并不太难,但有一些实际上的复杂性,因为网络要求很高,将所有图像加载到 RAM 中可能不可行,所以我们将使用一个生成器。然而,这次,我们还将添加一个简单的数据增强——我们将镜像一半的图像。

但首先,我们将定义一个层次结构,其中所有图像都位于dataset文件夹的子目录中:

  • rgb包含图像。

  • seg包含分割并着色的图像。

  • seg_raw包含原始格式的图像(红色通道中的数值标签)。

这意味着当给定一个rgb文件夹中的图像时,我们只需更改路径到seg_raw即可获取相应的原始分割。这很有用。

我们将定义一个通用的生成器,可用于数据增强;我们的方法如下:

  • 生成器将接收一个 ID 列表——在我们的案例中,是rgb图像的路径。

  • 生成器还将接收两个函数——一个函数给定一个 ID 可以生成一个图像,另一个函数给定一个 ID 可以生成相应的标签(更改路径到seg_raw)。

  • 我们将在每个 epoch 中提供索引以帮助数据增强。

这是通用的生成器:

def generator(ids, fn_image, fn_label, augment, batch_size):
    num_samples = len(ids)
    while 1:  # Loop forever so the generator never terminates
        samples_ids = shuffle(ids)  # New epoch

        for offset in range(0, num_samples, batch_size):
            batch_samples_ids = samples_ids[offset:offset + batch_size]
            batch_samples = np.array([fn_image(x, augment, offset + idx) for idx, x in enumerate(batch_samples_ids)])
            batch_labels = np.array([fn_label(x, augment, offset + idx) for idx, x in enumerate(batch_samples_ids)])

            yield batch_samples, batch_labels

这与我们在第八章中已经看到的类似,行为克隆。它遍历所有 ID 并获取批次的图像和标签;主要区别是我们向函数传递了两个额外的参数,除了当前 ID 之外:

  • 一个标志,指定我们是否想要启用数据增强

  • 当前 epoch 中的索引以告诉函数我们的位置

现在将相对容易编写一个返回图像的函数:

def extract_image(file_name, augment, idx):
  img = cv2.resize(cv2.imread(file_name), size_cv,     interpolation=cv2.INTER_NEAREST)

  if augment and (idx % 2 == 0):
    img = cv2.flip(img, 1)

  return img

我们使用最近邻算法加载图像并调整大小,正如已经讨论过的。这样,一半的时间图像将被翻转。

这是提取标签的函数:

def extract_label(file_name, augment, idx):
  img = cv2.resize(cv2.imread(file_name.replace("rgb", "seg_raw",        2)), size_cv, interpolation=cv2.INTER_NEAREST)

  if augment and (idx % 2 == 0):
    img = cv2.flip(img, 1)

  return convert_to_segmentation_label(img, num_classes)

如预期,要获取标签,我们需要将路径从rgb更改为seg_raw,而在分类器中对数据进行增强时,标签不会改变。在这种情况下,掩码需要以相同的方式进行增强,因此当我们镜像rgb图像时,我们仍然需要镜像它。

更具挑战性的是生成正确的标签,因为原始格式不适合。通常,在一个分类器中,你提供一个 one-hot 编码的标签,这意味着如果你有十个可能的标签值,每个标签都会转换为一个包含十个元素的向量,其中只有一个元素是1,其余都是0。在这里,我们需要做同样的事情,但针对整个图像和像素级别:

  • 我们的标签不是一个单独的图像,而是 13 个图像(因为我们有 13 个可能的标签值)。

  • 每个图像都对应一个单独的标签。

  • 图像的像素只有在分割掩码中存在该标签时才为1,其他地方为0

  • 在实践中,我们在像素级别应用 one-hot 编码。

这是生成的代码:

def convert_to_segmentation_label(image, num_classes):
  img_label = np.ndarray((image.shape[0], image.shape[1],     num_classes), dtype=np.uint8)

  one_hot_encoding = []

  for i in range(num_classes):
    one_hot_encoding.append(to_categorical(i, num_classes))

  for i in range(image.shape[0]):
    for j in range(image.shape[1]):
      img_label[i, j] = one_hot_encoding[image[i, j, 2]]

  return img_label

在方法的开始阶段,我们创建了一个包含 13 个通道的图像,然后我们预先计算了一个包含 13 个值的 one-hot 编码(用于加速计算)。然后我们简单地根据红色通道的值将 one-hot 编码应用于每个像素,红色通道是卡拉拉存储原始分割值的地方。

现在你可以开始训练了。你可能考虑让它过夜运行,因为它可能需要一段时间,特别是如果你使用 dropout 或者决定记录额外的图像。

这是训练的图表:

图 9.9 – 训练 FC-DenseNet

图 9.9 – 训练 FC-DenseNet

这并不理想,因为验证损失有很多峰值,这表明训练不稳定,有时损失增加得相当多。理想情况下,我们希望有一个平滑的下降曲线,这意味着损失在每次迭代时都会减少。可能需要更大的批量大小。

但整体表现还不错:

Min Loss: 0.19355240797595402
Min Validation Loss: 0.14731630682945251
Max Accuracy: 0.9389197
Max Validation Accuracy: 0.9090136885643005

验证准确率超过 90%,这是一个好兆头。

现在我们来看看它在测试数据集上的表现。

运行神经网络

在网络上运行推理与常规过程没有不同,但我们需要将输出转换为我们可以理解和使用的有色图像。

要做到这一点,我们需要定义一个 13 种颜色的调色板,我们将使用它来显示标签:

palette = [] # in rgb

palette.append([0, 0, 0])  # 0: None
palette.append([70, 70, 70])  # 1: Buildings
palette.append([190, 153, 153])  # 2: Fences
palette.append([192, 192, 192])  # 3: Other  (?)
palette.append([220, 20, 60])  # 4: Pedestrians
palette.append([153,153, 153])  # 5: Poles
palette.append([0, 255, 0])  # 6: RoadLines  ?
palette.append([128, 64, 128])  # 7: Roads
palette.append([244, 35,232])  # 8: Sidewalks
palette.append([107, 142, 35])  # 9: Vegetation
palette.append([0, 0, 142])  # 10: Vehicles
palette.append([102,102,156])  # 11: Walls
palette.append([220, 220, 0])  # 11: Traffic signs

现在我们只需要使用这些颜色导出两张图像——原始分割和彩色分割。以下函数执行这两个操作:

def convert_from_segmentation_label(label):
    raw = np.zeros((label.shape[0], label.shape[1], 3), dtype=np.uint8)
    color = np.zeros((label.shape[0], label.shape[1], 3), dtype=np.uint8)

    for i in range(label.shape[0]):
        for j in range(label.shape[1]):
            color_label = int(np.argmax(label[i,j]))
            raw[i, j][2] = color_label
            # palette from rgb to bgr
            color[i, j][0] = palette[color_label][2]
            color[i, j][1] = palette[color_label][1]
            color[i, j][2] = palette[color_label][0]

    return (raw, color)

你可能记得我们的输出是一个 13 通道的图像,每个标签一个通道。所以你可以看到我们使用argmax从这些通道中获取标签;这个标签直接用于原始图像,其中它存储在红色通道中,而对于彩色分割图像,我们使用调色板中的颜色,使用label作为索引,交换蓝色和红色通道,因为 OpenCV 是 BGR 格式。

让我们看看它的表现如何,记住这些图像与网络在训练期间看到的图像非常相似。

下面是一张图像的结果,以及其他分割图像的版本:

图 9.10 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

图 9.10 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

如您从图像中看到的,它并不完美,但做得很好:道路被正确检测到,护栏和树木也相当不错,行人也被检测到,但不是很好。当然我们可以改进这一点。

让我们看看另一张有问题的图像:

图 9.11 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

图 9.11 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

上一张图像相当具有挑战性,因为道路昏暗,汽车也是暗的,但网络在检测道路和汽车方面做得相当不错(尽管形状不是很好)。它没有检测到车道线,但实际上在道路上并不明显,所以这里的真实情况过于乐观。

让我们再看另一个例子:

图 9.12 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

图 9.12 – 从左至右:RGB 图像、Carla 的地面真实情况、彩色分割掩码和叠加分割

在这里,结果也不算差:道路和树木都被很好地检测到,交通标志也被相当好地检测到,但它没有看到车道线,这是一个具有挑战性但可见的线条。

为了确保它确实可以检测到车道线,让我们看看一个不那么具有挑战性的图像:

图 9.13 – 从左至右:RGB 图像,彩色分割掩码和叠加分割

图 9.13 – 从左至右:RGB 图像、彩色分割掩码和叠加分割

我没有这张图像的地面真实情况,这也意味着尽管它是从与训练数据集相同的批次中拍摄的,但它可能略有不同。在这里,网络表现非常好:道路、车道线、人行道和植被都被很好地检测到。

我们已经看到该网络表现尚可,但当然我们应该添加更多样本,既包括同一轨迹的样本,也包括其他轨迹的样本,以及不同天气条件下的样本。不幸的是,这意味着训练将更加耗时。

尽管如此,我认为大约有一千张图像的这种结果是一个好结果。但如果你无法在数据集中获得足够的样本呢?让我们学习一个小技巧。

改进不良语义分割

有时候事情不会像你希望的那样进行。也许为数据集获取大量样本成本太高,或者花费太多时间。或者可能没有时间,因为你需要尝试给一些投资者留下深刻印象,或者存在技术问题或其他类型的问题,你被一个不良网络和几分钟的时间困住了。你能做什么?

好吧,有一个小技巧可以帮助你;它不会将一个不良网络变成一个良好的网络,但它仍然可以比什么都没有好。

让我们来看一个来自不良网络的例子:

图 9.14 – 恶劣训练的网络

图 9.14 – 恶劣训练的网络

它的验证准确率大约为 80%,并且大约使用了 500 张图像进行训练。这相当糟糕,但由于满是点的区域,它看起来比实际情况更糟糕,因为这些区域网络似乎无法确定它在看什么。我们能通过一些后处理来修复这个问题吗?是的,我们可以。你可能还记得从第一章OpenCV 基础和相机标定,OpenCV 有几种模糊算法,特别是中值模糊,它有一个非常有趣的特性:它选择遇到的颜色的中值,因此它只发出它在分析的少数像素中已经存在的颜色,并且它非常有效地减少盐和胡椒噪声,这正是我们正在经历的。所以,让我们看看将这个算法应用到之前图像上的结果:

图 9.15 – 训练不良的网络,从左到右:RGB 图像,彩色分割,使用媒体模糊(三个像素)校正的分割,以及叠加分割

图 9.15 – 训练不良的网络,从左到右:RGB 图像,彩色分割,使用媒体模糊(三个像素)校正的分割,以及叠加分割

如你所见,虽然远非完美,但它使图像更易于使用。而且这只是一行代码:

median = cv2.medianBlur(color, 3)

我使用了三个像素,但如果需要,你可以使用更多。我希望你不会发现自己处于网络表现不佳的情况,但如果确实如此,那么这肯定值得一试。

摘要

恭喜!你已经完成了关于深度学习的最后一章。

我们本章开始时讨论了语义分割的含义,然后我们广泛地讨论了 DenseNet 及其为何是一个如此出色的架构。我们简要地提到了使用卷积层堆叠来实现语义分割,但我们更关注一种更有效的方法,即在适应此任务后使用 DenseNet。特别是,我们开发了一个类似于 FC-DenseNet 的架构。我们使用 Carla 收集了一个带有语义分割真实值的数据库,然后我们在其上训练我们的神经网络,并观察了它的表现以及它在检测道路和其他物体,如行人和人行道时的表现。我们甚至讨论了一个提高不良语义分割输出的技巧。

本章内容相当高级,需要很好地理解所有关于深度学习的先前章节。这是一段相当刺激的旅程,我认为可以说这是一章内容丰富的章节。现在你已经很好地了解了如何训练一个网络来识别汽车前方的物体,是时候控制汽车并让它转向了。

问题

在阅读本章后,你将能够回答以下问题:

  1. DenseNet 的一个显著特征是什么?

  2. 像 DenseNet 的作者所受到启发的家族架构叫什么名字?

  3. 什么是 FC-DenseNet?

  4. 我们为什么说 FC-DenseNet 是 U 形的?

  5. 你需要像 DenseNet 这样的花哨架构来执行语义分割吗?

  6. 如果你有一个在语义分割上表现不佳的神经网络,有没有一种快速修复方法,你可以在没有其他选择时使用?

  7. 在 FC-DenseNet 和其他 U 形架构中,跳过连接用于什么?

进一步阅读

第三部分:映射和控制

在这里,我们将学习如何映射和定位自己,以便我们能够在现实世界中控制并导航我们的汽车!

在本节中,我们包含以下章节:

  • 第十章**,转向、油门和刹车控制

  • 第十一章**,映射我们的环境

第十章:第十章:转向、油门和制动控制

在本章中,你将了解更多使用控制理论领域技术来控制转向、油门和制动的方法。如果你还记得第八章行为克隆,你学习了如何使用神经网络和摄像头图像来控制汽车。虽然这最接近人类驾驶汽车的方式,但由于神经网络的计算需求,它可能非常消耗资源。

存在更多传统且资源消耗较少的车辆控制方法。其中最广泛使用的是PID(即比例、积分、微分)控制器,你将在 CARLA 中实现它来驾驶你的汽车在模拟城镇中行驶。

另有一种在自动驾驶汽车中广泛使用的方法,称为MPC(即模型预测控制器)。MPC 专注于模拟轨迹,计算每个轨迹的成本,并选择成本最低的轨迹。我们将通过一些示例代码,展示你可以用这些代码替代你将要学习的 PID 控制器。

在本章中,你将学习以下主题:

  • 为什么你需要控制?

  • 控制器的类型

  • 在 CARLA 中实现 PID 控制器

  • C++中的 MPC 示例

到本章结束时,你将了解为什么我们需要控制,以及如何为特定应用选择控制器。你还将知道如何在 Python 中实现 PID 控制器,并接触到用 C++编写的 MPC 控制器示例。

技术要求

在本章中,我们将需要以下软件和库:

本章的代码可以在以下位置找到:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter10

本章的“代码实战”视频可以在以下位置找到:

bit.ly/2T7WnKo

为什么你需要控制?

这可能看起来非常明显,因为你正在尝试构建自动驾驶汽车,但让我们快速了解一下。

当你构建一辆自动驾驶汽车时,你试图实现什么?最终目标是通过控制执行器(如转向、油门和刹车)来指挥车辆从起始位置移动到目的地。历史上,这些执行器的命令是由你,即人类驾驶员,通过方向盘和油门及刹车踏板提供的。现在你试图将自己从主要负责驾驶任务的角色中移除。那么,你将什么放在自己的位置?一个控制器!

控制器是什么?

控制器简单来说是一个算法,它接收某种类型的误差信号并将其转换成执行信号,以实现给定过程的期望设定点。以下是对这些术语的定义:

  • 控制变量CV)或过程变量是你想要控制的变量。

  • 设定点是 CV 的期望值。

  • 误差是 CV 当前状态与设定点之间的差异。

  • 执行是发送到过程以影响误差减少的信号。

  • 过程是被控制的系统。

  • 你有时可能会看到过程被称为植物传递函数

例如,假设你试图保持你的自动驾驶汽车在其行驶车道内。车道的中心点将是设定点。首先,你需要知道误差,即你距离车道中心的距离——让我们称这个为你的横穿误差CTE)。然后,你需要确定你需要什么执行命令来安全地将汽车(即过程)返回到车道中心,从而最小化汽车的 CTE。最终,将控制器视为一个函数,它不断地试图最小化给定 CV 相对于该变量的设定点误差

为了实现这一点,让我们回顾一下可用的控制器类型。

控制器类型

已经发明并在控制系统中得到实施的控制器种类繁多。以下是一些不同类型控制器的示例:

  • PID 控制器及其衍生品

  • 最优控制

  • 鲁棒控制

  • 状态空间控制

  • 向量控制

  • MPC

  • 线性-二次控制

控制器也可以根据它们所使用的系统类型进行分类,以下是一些示例:

  • 线性与非线性

  • 模拟(连续)与数字(离散)

  • 单输入单输出SISO)与多输入多输出MIMO

到目前为止,在自动驾驶汽车中最常见和广泛使用的控制器是 PID 和 MPC。PID 控制器用于 SISO 系统,而 MPC 可以用于 MIMO 系统。当你考虑为你的自动驾驶汽车选择哪种类型的控制器时,这将是有用的。例如,如果你只想通过实施定速巡航来控制车辆的速度,你可能想选择一个 SISO 控制器,如 PID。相反,如果你想在单个控制器中控制多个输出,如转向角度和速度,你可能选择实现一个 MIMO 控制器,如 MPC。

在下一节中,你将了解 PID 的基础知识,以便理解你将要学习的代码。

PID

PID 控制器是最普遍的控制系统的形式,它背后有超过一个世纪的研究和实施。它有许多不同的版本和针对特定应用的细微调整。在这里,你将专注于学习基础知识并实现一个简单的控制器,用于自动驾驶汽车的横向和纵向控制。由于 PID 是 SISO 控制器,你需要纵向和横向 PID 控制器。参考以下典型的 PID 模块图:

图 10.1 – PID 模块图

图 10.1 – PID 模块图

让我们通过图 10.1来观察一个简单的例子,即使用它来控制你家的温度。你家可能有一个允许你设定所需温度的恒温器。我们将你选择的温度称为设定点r(t)。你家中的瞬时温度是CVy(t)。现在恒温器的工作就是利用你家的加热器/冷却器来驱动家中的温度(CV)达到设定点。CV,y(t),被反馈到减法模块以确定误差e(t) = r(t) - y(t),即家中的期望温度和当前温度之间的差异。然后误差被传递到 P、I 和 D 控制项,这些项各自乘以一个增益值(通常用K表示),然后相加以产生输入到加热器/冷却器的控制信号。你的加热器/冷却器有一定的功率容量,你的家有一定的空气体积。加热器/冷却器容量和家中的空气体积的组合决定了当你选择一个新的设定点时,家会多快加热或冷却。这被称为家的过程设备传递函数。这个过程代表了系统 CV 对设定点变化的响应,也称为阶跃响应

下面的图表显示了一个系统的阶跃响应示例。在时间t=0时,设定点(由虚线表示)从 0 阶跃到 0.95。系统的响应在以下图中展示:

图 10.2 – 步响应示例

图 10.2 – 步进响应示例

我们可以看到,这个系统和控制器组合产生了一个响应,它将超过设定点并在其周围振荡,直到响应最终稳定在设定点值。

另一个与本书更相关的控制系统示例将是自动驾驶汽车的巡航控制系统。在这种情况下,汽车当前的速度是 CV,期望的速度是设定点,汽车和电机的物理动力学是过程。你将看到如何实现巡航控制器以及转向控制器。

现在,让我们了解 PID 控制中的比例、积分和微分控制术语的含义。

全油门加速!

你是否曾经想过在驾驶时如何决定踩油门踏板的力度?

哪些因素决定了你踩油门的力度?

–这是你的速度与你想更快速度的对比吗?

–这和您接近目标速度的速度有多快有关吗?

–你是否不断检查你的速度以确保你没有偏离目标速度?

当我们快速浏览接下来的几节内容时,考虑所有这些。

在我们开始讨论 P、I 和 D 控制术语之前,我们需要定义增益是什么。

增益是一个缩放因子,用于在总控制输入中对控制项进行加权或减权。

理解比例的含义

在巡航控制示例中,你试图匹配你的汽车速度到一个设定点速度。设定点速度和你的汽车当前速度之间的差异被称为误差。当汽车速度低于设定点时,误差为正,当它高于设定点时,误差为负:

比例控制项只是将 乘以一个称为比例增益的缩放因子:

这意味着误差越大,对过程的控制输入就越大,或者在这种情况下,油门输入就越大。这说得通,对吧?

让我们用一些实际数字来验证这一点。首先,让我们定义油门从 0 到 100%的变化。接下来,让我们将其映射到一辆汽车的加速度上,比如特斯拉 Model X 的加速度,为 37 m/s²。疯狂模式!所以,100%的油门提供了 37 m/s²的加速度。

如果你的设定点速度是 100 km/h,而你从 0 km/h 开始,那么你当前的误差是 100 km/h。然后,如果你想以 100 km/h 的误差实现最大加速度,你可以将你的比例增益设置为 1:

随着速度误差的减小,油门也会减小,直到你达到误差为零时的零油门输入。

等等!零油门意味着我们在滑行。如果没有摩擦、空气阻力等情况,这将非常有效,但我们都知道情况并非如此。这意味着你永远不会真正保持在目标速度,而会振荡在目标速度以下,给我们留下一个稳态偏差:

图 10.3 – 比例控制器中的稳态偏差

图 10.3 – 比例控制器中的稳态偏差

哦不——我们如何保持目标速度?别担心,我们有一个强大的积分器可以帮助我们提高速度。

理解积分项

介绍强大的积分器!PID 中的积分器项旨在解决系统中的任何稳态偏差。它是通过整合系统中的所有过去误差来做到这一点的。实际上,这意味着它在每个时间步长上累加我们看到的所有误差:

公式 10_005

然后,我们用增益 KI 缩放总误差,就像我们处理比例项时做的那样。然后,我们将这个结果作为控制输入到系统中,如下所示:

公式 10_006

在你的定速巡航控制示例中,Model X 的速度只是短暂地达到了设定点速度,然后迅速下降到以下,因为比例输入变为零,空气阻力减慢了它的速度。这意味着如果你在时间上累加所有误差,你会发现它们始终是正的,并且继续增长。

因此,随着时间的推移,积分器项,total_error速度,变得越大。这意味着如果你选择 KI 适当,即使瞬时误差为零,油门命令也会大于零。记住,我们累加所有控制项以获得总油门输入。到目前为止,我们有 P 和 I 项,它们给我们以下结果:

公式 10_007

太好了!现在你正在围绕设定点振荡,而不是低于它。但你可能会问,我们如何防止速度设定点的持续超调和欠调,并稳定在平滑的油门应用上? 我想你永远不会问这个问题!

导数控制来救命!

导数项

你必须克服的最后难题是在接近设定点时调整油门,而不会超过它。导数项通过根据你接近设定点的速度调整油门来帮助解决这个问题。从误差的角度来看,这意味着误差的变化率,如下所示:

公式 10_008

当先前的公式简化后,我们得到以下公式,其中 d 表示变化:

公式 10_009

如果误差在减小——这意味着你正在接近设定点——导数项将是负的。这意味着导数项将试图减少你的总油门,因为油门现在由所有 P、I 和 D 控制项的总和给出。以下方程展示了这一点:

公式 10_010

好吧,你现在知道 PID 的每个部分在实际情况中的含义了。真正的技巧是调整你的KP、KI 和KD 增益,使汽车的速度和加速度按照你的意愿行动。这超出了本书的范围,但本章末尾有一些很好的参考资料,可以了解更多关于这方面的内容。

接下来,你将了解一种更现代的控制形式,这种形式在今天的自动驾驶汽车中非常流行——MPC。

刹车!

你会称负油门为什么?

MPC

MPC是一种现代且非常通用的控制器,用于 MIMO 系统。这对于你的自动驾驶汽车来说非常完美,因为你有多达油门、刹车和转向扭矩等多个输入。你也有多个输出,如相对于车道的横向位置和汽车的速度。正如你之前所学的,PID 需要两个单独的控制器(横向和纵向)来控制汽车。有了 MPC,你可以在一个漂亮的控制器中完成所有这些。

由于计算速度的提高,MPC 近年来变得流行,这允许进行所需的在线优化,以执行实时驾驶任务。

在你学习 MPC 做什么之前,让我们首先思考一下当你开车时,你的神奇大脑都在做什么:

  1. 你选择一个目的地。

  2. 你规划你的路线(航点)。

  3. 你在遵守交通法规、你的车辆动力学和性能(疯狂的驾驶模式,启动!)、你的时间限制(我迟到了一个改变一生的面试!)以及你周围的交通状况的范围内执行你的路线。

MPC 工作方式就像你

如果你真正思考一下你是如何驾驶的,你不断地评估你周围车辆的状态、你的车辆、到达目的地的时间、你在车道中的位置、你与前车之间的距离、交通标志和信号、你的速度、你的油门位置、你的转向扭矩、你的刹车位置,以及更多!同时,你也在不断地模拟基于当前交通状况你可以执行的多种操作——例如,我的左边车道有辆车,所以我不能去那里我前面的车开得很慢我的右边车道没有车,但有一辆车正在快速接近我需要赶到这个面试,我迟到了

你也在不断地权衡如果执行任何操作的成本,基于以下成本考虑:

  • 迟到面试的成本:高!

  • 违反法律的成本:高!

  • 事故的成本:难以想象!

  • 使用疯狂的驾驶模式的成本:中等!

  • 损坏你的车的成本:有什么比无限大还高?让我们选择那个!

然后你迅速估算任何可能的操作的成本:

  • 在左边车道超车意味着我可能会撞到旁边的车,可能引发事故或损坏我的车,而且可能还错过我的面试。成本:天文数字!

  • 我可以继续在这辆迟钝的汽车后面行驶。这将使我迟到。成本:高!

  • 在右车道超车需要疯狂的加速,以确保接近的车辆不会撞到我。成本:中等!

你选择上述选项中的最后一个,因为它基于你对每个考虑因素所赋予的成本,具有最低的模拟机动成本。

太好了,你已经选择了机动!现在你需要执行它。

你按下疯狂的按钮 5 秒钟,同时系紧安全带,直到感觉像一条响尾蛇,然后像《空手道小子》一样切换你的转向灯!你用白手紧握方向盘,然后全油门加速,想象着额外 35 匹马的力量把你推回座位。你同时转动方向盘,进入右车道,用肾上腺素和自信的笑容飞驰而过,看着之前接近的车辆在你身后消失在虚无中!

现在你对自己的机动感到无比满意,你开始整个过程,为下一个机动重复,直到你安全准时到达面试!你做到了!

MPC 管道

MPC 采用与人类动态驾驶任务相似的方法。MPC 只是将驾驶任务形式化为数学和物理(少了些刺激和兴奋)。步骤非常相似。

建立如下约束:

  1. 车辆的动态模型,用于估计下一个时间步的状态:

    • 最小转弯半径

    • 最大转向角度

    • 最大油门

    • 最大制动

    • 最大横向急动(加速度的导数)

    • 最大纵向加速度

  2. 建立成本函数,包括以下内容:

    • 不在期望状态的成本

    • 使用执行器的成本

    • 顺序激活的成本

    • 使用油门和转向的成本

    • 横穿车道线的成本

    • 碰撞的成本

  3. 接下来,模拟可能的轨迹和相关的控制输入,这些输入在下一个 N 个时间步遵守数学成本和约束。

  4. 使用优化算法选择具有最低成本的模拟轨迹。

  5. 执行一个时间步的控制输入。

  6. 在新时间步测量系统的状态。

  7. 重复 步骤 3–6

每个步骤都有很多细节,你被鼓励通过查看章节末尾的“进一步阅读”部分中的链接来了解更多。现在,这里有几点快速指南供你思考。

样本时间,TS

  • 这是重复 MPC 管道 步骤 3–7 的离散时间步。

  • 通常,TS 被选择,以确保在开环上升时间中至少有 10 个时间步。

预测范围,N

  • 这是你将模拟汽车状态和控制输入的未来时间步数。

  • 通常,使用 20 个时间步来覆盖汽车的开环响应。

你还可以查看以下图表,它说明了构成 MPC 问题的许多你已学到的概念和参数:

图 10.4 – 构成 MPC 问题的概念和参数

图 10.4 – 构成 MPC 问题的概念和参数

这里是对前面图表中显示的每个参数的简要描述:

  • 参考轨迹是受控变量的期望轨迹;例如,车辆在车道中的横向位置。

  • 预测输出是在应用预测控制输入后对受控变量状态的预测。它由系统的动态模型、约束和先前测量的输出所指导。

  • 测量输出是受控变量的过去测量状态。

  • 预测控制输入是系统对必须执行以实现预测输出的控制动作的预测。

  • 过去控制输入是在当前状态之前实际执行的控制动作。

MPC 是一种强大但资源密集的控制算法,有时可以通过允许 MIMO 适应单个模块来简化你的架构。

这一次需要吸收的内容很多,但如果你已经走到这一步,你很幸运!在下一节中,我们将深入探讨你可以用来在 CARLA 中使用 PID 控制自动驾驶汽车的真正代码!

在 CARLA 中实现 PID

恭喜你来到了本章真正有趣且实用的部分。到目前为止,你已经学到了很多关于 PID 和 MPC 的知识。现在是时候将所学知识付诸实践了!

在本节中,我们将遍历 GitHub 上本章可用的所有相关代码:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars

你将学习如何在 Python 中应用 PID 的方程和概念,然后与 CARLA 进行接口。

首先,你需要安装 CARLA。

安装 CARLA

CARLA 项目在carla.readthedocs.io/en/latest/start_quickstart/提供了 Linux 和 Windows 快速入门指南。

对于 Linux,CARLA 文件将位于这里:

/opt/carla-simulator/

在这个文件夹中,你可以找到一个/bin/文件夹,其中包含可执行的模拟器脚本,你可以使用以下命令运行它:

$  /opt/carla-simulator/bin/CarlaUE4.sh -opengl

–opengl标签使用 OpenGL 而不是 Vulkan 来运行模拟器。根据你的系统设置和 GPU,你可能需要省略–opengl。你应该会看到一个看起来像这样的模拟器环境窗口弹出:

图 10.5 – CARLA 模拟器环境打开

图 10.5 – CARLA 模拟器环境打开

对于本章,您将主要从以下位置的 examples 文件夹中工作:

  • Linux: /opt/carla-simulator/PythonAPI/examples

  • Windows: WindowsNoEditor\PythonAPI\examples

这个文件夹包含所有示例 CARLA 脚本,这些脚本教您 CARLA API 的基础知识。在这个文件夹中,您将找到一个名为 automatic_control.py 的脚本,这是本章其余部分您将使用的脚本的基础。

现在您已经安装并成功运行了模拟器,您将克隆包含 PID 控制器的 Packt-Town04-PID.py 脚本。

克隆 Packt-Town04-PID.py

您可以在 Chapter10 下的 github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars 找到本章的代码库。

您可以将整个仓库克隆到您机器上的任何位置。

然后,您需要将 Packt-Town04-PID.py 脚本链接到之前讨论过的 examples 文件夹中。您可以在 Linux 中使用以下命令:

$  ln -s /full/path/to/Packt-Town04-PID.py /opt/carla-simulator/PythonAPI/examples/

现在您已经有了脚本,并且已经将其链接到 CARLA 中的正确位置,让我们来浏览一下代码以及它所做的工作。

浏览您的 Packt-Town04-PID.py 控制脚本

您的 Packt-Town04-PID.py 代码基于 automatic_control.py 示例脚本,并从 /opt/carla-simulator/PythonAPI/carla/agents 子文件夹中的相关代码片段拼接而成,具体包括以下脚本:

  • behavior_agent.py

  • local_planner.py

  • controller.py

  • agent.py

这是一种非常好的学习如何与 CARLA 模拟器交互以及学习 API 的方法,而无需从头开始编写所有内容。

查找 CARLA 模块

如果您现在查看 Packt-Town04-PID.py,您可能会注意到在常规导入之后,这个代码块:

try:
    sys.path.append(glob.glob('../carla/dist/carla-*%d.%d-%s.egg' % (
        sys.version_info.major,
        sys.version_info.minor,
        'win-amd64' if os.name == 'nt' else 'linux-x86_64'))[0])
except IndexError:
    pass

这个块加载了一个包含 CARLA 代码的 egg 文件,该文件位于 /opt/carla-simulator/PythonAPI/carla/dist/ 文件夹中。

相关类

之后,您可能会注意到代码被组织成以下类:

  • World:我们的车辆移动的虚拟世界,包括地图和所有演员(如车辆、行人和传感器)。

  • KeyboardControl:这个类响应用户按下的键,并有一些逻辑将转向、制动和加速的二进制开/关键转换为更广泛的值范围,这取决于它们被按下的时间长短,从而使汽车更容易控制。

  • HUD:这个类渲染与模拟相关的所有信息,包括速度、转向和油门。它管理可以显示几秒钟信息的通知。

  • FadingText:这个类被 HUD 类用来显示几秒钟后消失的通知。

  • HelpText:这个类使用 CARLA 使用的游戏库 pygame 显示一些文本。

  • CollisionSensor:这是一个可以检测碰撞的传感器。

  • LaneInvasionSensor: 这是一个可以检测到你跨越车道线的传感器。

  • GnssSensor: 这是一个提供 OpenDRIVE 地图内 GNSS 位置的 GPS/GNSS 传感器。

  • CameraManager: 这是一个管理相机并打印相机的类。

  • Agent: 这是定义游戏中的代理的基类。

  • AgentState: 这是一个表示代理可能状态的类。

  • BehaviorAgent: 这个类实现了一个通过计算到达目的地的最短路径来导航世界的代理。

  • LocalPlanner: 这个类通过动态生成航点来实现一个要跟随的轨迹。它还调用带有适当增益的VehiclePIDController类。这就是本章魔法发生的地方。

  • VehiclePIDController: 这个类调用横向和纵向控制器。

  • PIDLongitudinalController: 这个类包含了你一直在学习的用于定速巡航的 PID 数学。

  • PIDLateralController: 这个类包含了用于转向控制的 PID 数学,以保持你的车辆跟随由LocalPlanner类生成的航点。

此外,还有一些其他值得注意的方法:

  • main(): 这主要致力于解析操作系统接收到的参数。

  • game_loop(): 这主要初始化pygame、CARLA 客户端以及所有相关对象。它还实现了游戏循环,每秒 60 次分析按键并显示最新的图像在屏幕上。

设置世界

game_loop()方法中,你可以找到设置世界地图的位置。它目前设置为Town04

selected_world = client.load_world("Town04")

车辆个性化

如果你是一个想要选择你的车型和颜色的汽车爱好者,你可以通过在World()类内的代码中这样做:

blueprint=self.world.get_blueprint_library().filter('vehicle.lincoln.mkz2017')[0] 
        blueprint.set_attribute('role_name', 'hero')
        if blueprint.has_attribute('color'):
            color = '236,102,17'
            blueprint.set_attribute('color', color)

生成点

你可能想要更改的下一个东西是地图中车辆的生成点。你可以通过为spawn_points[0]选择不同的索引来完成此操作:

spawn_point = spawn_points[0] if spawn_points else carla.Transform()

现在你已经完成了自定义并了解了类及其功能,我们将深入本章代码的核心——PID 控制器!

PIDLongitudinalController

这是你的定速巡航,负责操作油门和刹车。你还记得我们之前刺激你的大脑并问一个负油门会被叫什么吗?好吧,这里的答案是刹车。所以每当控制器计算出一个负油门输入时,它将使用控制值激活刹车。

收益

这个增益类使用 CARLA 团队调整过的 PID 增益初始化:

        self._k_p = K_P
        self._k_d = K_D
        self._k_i = K_I

这些值在以下代码中的LocalPlanner类中设置回:

        self.args_long_hw_dict = {
            'K_P': 0.37,
            'K_D': 0.024,
            'K_I': 0.032,
            'dt': 1.0 / self.FPS}
        self.args_long_city_dict = {
            'K_P': 0.15,
            'K_D': 0.05,
            'K_I': 0.07,
            'dt': 1.0 / self.FPS}

增益调度

注意,根据高速公路和城市驾驶的不同,有不同的增益。增益根据汽车当前的速度在LocalPlanner中进行调度:

   if target_speed > 50:
            args_lat = self.args_lat_hw_dict
            args_long = self.args_long_hw_dict
        else:
            args_lat = self.args_lat_city_dict
            args_long = self.args_long_city_dict

PID 数学

现在是时候展示你一直等待的 PID 实现数学了!_pid_control() 方法包含了 PID 控制器的核心以及你在“控制器类型”部分的“PID”子部分中学到的计算:

  1. 首先,我们计算速度误差:

            error = target_speed – current_speed
    
  2. 接下来,我们将当前误差添加到误差缓冲区中,以便稍后用于计算积分和导数项:

            self._error_buffer.append(error)
    
  3. 然后,如果误差缓冲区中至少有两个值,我们计算积分和导数项:

            if len(self._error_buffer) >= 2:
    
  4. 接下来,我们通过从当前误差值中减去前一个误差值并除以采样时间来计算导数项:

                _de = (self._error_buffer[-1] - self._error_buffer[-2]) / self._dt
    
  5. 接下来,我们通过将所有观察到的误差求和并乘以采样时间来计算积分项:

                _ie = sum(self._error_buffer) * self._dt
    

    如果缓冲区中没有足够的内容,我们只需将积分和导数项设置为零:

            else:
                _de = 0.0
                _ie = 0.0
    
  6. 最后,我们通过将所有增益加权的 PID 项相加并返回值裁剪到±1.0 来计算控制输入。回想一下,这个数学计算如下:

如果值是正的,则命令油门,否则命令刹车:

        return np.clip((self._k_p * error) + (self._k_d * _de) + (self._k_i * _ie), -1.0, 1.0)

现在你已经了解了 PID 的基本数学,接下来我们将看到如何在横向 PID 控制器中实现这一点。

PIDLateralController

这是你的转向控制,负责执行转向角度。

增益

这个类使用 CARLA 团队调校的 PID 增益进行初始化:

        self._k_p = K_P
        self._k_d = K_D
        self._k_i = K_I

LocalPlanner 类中设置的值如下所示:

        self.args_lat_hw_dict = {
            'K_P': 0.75,
            'K_D': 0.02,
            'K_I': 0.4,
            'dt': 1.0 / self.FPS}
        self.args_lat_city_dict = {
            'K_P': 0.58,
            'K_D': 0.02,
            'K_I': 0.5,
            'dt': 1.0 / self.FPS}

增益调度

注意,与纵向控制一样,根据高速公路和城市驾驶的不同,存在不同的增益。增益在 LocalPlanner 中根据当前车速进行调度:

   if target_speed > 50:
            args_lat = self.args_lat_hw_dict
            args_long = self.args_long_hw_dict
        else:
            args_lat = self.args_lat_city_dict
            args_long = self.args_long_city_dict

PID 数学

横向控制的数学计算略有不同,但基本原理相同。再次强调,数学计算在 _pid_control() 方法中。让我们看看如何进行:

  1. 首先,我们在全局坐标系中找到车辆向量的起点:

            v_begin = vehicle_transform.location
    
  2. 接下来,我们使用车辆的偏航角在全局坐标系中找到车辆向量的终点:

            v_end = v_begin + carla.Location(x=math.cos(math.radians(vehicle_transform.rotation.yaw)),
                                             y=math.sin(math.radians(vehicle_transform.rotation.yaw)))
    
  3. 接下来,我们创建车辆向量,这是车辆在全局坐标系中的指向:

            v_vec = np.array([v_end.x - v_begin.x, v_end.y - v_begin.y, 0.0])
    
  4. 接下来,我们计算从车辆位置到下一个航点的向量:

            w_vec = np.array([waypoint.transform.location.x -
                              v_begin.x, waypoint.transform.location.y -
                              v_begin.y, 0.0])
    
  5. 接下来,我们找到车辆向量和从车辆位置指向航点的向量之间的角度。这本质上就是我们的转向误差:

            _dot = math.acos(np.clip(np.dot(w_vec, v_vec) /
                                     (np.linalg.norm(w_vec) * np.linalg.norm(v_vec)), -1.0, 1.0))
    
  6. 接下来,我们找到两个向量的叉积以确定我们位于航点的哪一侧:

            _cross = np.cross(v_vec, w_vec)
    
  7. 接下来,如果叉积是负的,我们调整 _dot 值使其为负:

            if _cross[2] < 0:
                _dot *= -1.0
    
  8. 接下来,我们将当前的转向误差追加到我们的误差缓冲区中:

            self._e_buffer.append(_dot)
    
  9. 接下来,如果误差缓冲区中至少有两个值,我们计算积分和导数项:

            if len(self._e_buffer) >= 2:
    
  10. 接下来,我们通过从当前误差值中减去前一个误差值并除以采样时间来计算导数项:

                _de = (self._e_buffer[-1] - self._e_buffer[-2]) / self._dt
    
  11. 接下来,我们通过求和所有已看到的误差并乘以采样时间来计算积分项:

                _ie = sum(self._e_buffer) * self._dt
    

    如果缓冲区中不足够,我们只需将积分和导数项设置为 0:

            else:
                _de = 0.0
                _ie = 0.0
    
  12. 最后,我们通过求和所有增益加权的 PID 项,并返回值裁剪到±1.0 来计算控制输入。我们还没有看到转向的情况,但它与速度的工作方式相同:

负的转向角度简单意味着向左转,而正的则意味着向右转

        return np.clip((self._k_p * _dot) + (self._k_d * _de) + (self._k_i * _ie), -1.0, 1.0)

现在你已经学会了如何在 Python 中实现 PID 控制,是时候看看它的工作效果了!

运行脚本

首先,你应该确保已经通过运行以下代码启动了 CARLA 模拟器:

$  /opt/carla-simulator/bin/CarlaUE4.sh -opengl

然后在新的终端窗口中,你可以运行Packt-Town04-PID.py脚本,并观看魔法展开。运行脚本的命令如下:

$  python3 /opt/carla-simulator/PythonAPI/examples/Packt-Town04-PID.py

你应该会看到一个新窗口弹出,其外观如下截图所示:

图 10.6 – Packt-Town04-PID.py 运行窗口

图 10.6 – Packt-Town04-PID.py 运行窗口

恭喜!你只用键盘和你的新知识就成功地让一辆车自己转向和加速!在下一节中,你将学习如何使用 C++应用 MPC 控制器。

一个 C++中的 MPC 示例

MPC 的完整实现超出了本章的范围,但你可以查看这个用 C++编写的示例实现github.com/Krishtof-Korda/CarND-MPC-Project-Submission/blob/master/src/MPC.cpp

以下示例将指导你实现一个 MPC 模块,你可以用它来替代 PID 控制器进行横向和纵向控制。回想一下,MPC 是一个 MIMO 系统,这意味着你可以控制多个输出。

以下示例展示了构建 MPC 控制器所需的所有基本组件和代码:

  1. 首先,使用以下代码将多项式拟合到你的预测范围航点上:

    Main.cpp --> polyfit()
    

    使用以下代码来计算交叉跟踪误差:

    Main.cpp --> polyeval()
    double cte = polyeval(coeffs, px) - py;
    

    使用以下代码来计算方向误差:

    double epsi = psi - atan(coeffs[1] + 2*coeffs[2]*px + 3*coeffs[3]*px*px) ;
    
  2. 现在,我们使用MPC.cpp来构建向量,以便将其传递给优化器。优化器将所有状态和执行器变量放在一个单一的向量中。因此,在这里,你将确定向量中每个变量的起始索引:

    size_t x_start = 0;
    size_t y_start = x_start + N;
    size_t psi_start = y_start + N;
    size_t v_start = psi_start + N;
    size_t cte_start = v_start + N;
    size_t epsi_start = cte_start + N;
    size_t delta_start = epsi_start + N;
    size_t a_start = delta_start + N - 1;
    
  3. 接下来,分配你成本的所有可调整权重:

    const double w_cte = 1;
    const double w_epsi = 100;
    const double w_v = 1;
    const double w_delta = 10000;
    const double w_a = 7;
    const double w_delta_smooth = 1000;
    const double w_a_smooth = 1;
    const double w_throttle_steer = 10;
    
  4. 之后,你可以根据这些权重建立你的成本函数。

    对于这个,你必须添加一个成本,如果你相对于参考状态处于相对状态。换句话说,添加一个成本,以表示不在期望路径、航向或速度上,如下所示:

    for (int t = 0; t < N; t++) {
        fg[0] += w_cte * CppAD::pow(vars[cte_start + t], 2);
        fg[0] += w_epsi * CppAD::pow(vars[epsi_start + t], 2);
        fg[0] += w_v * CppAD::pow(vars[v_start + t] - ref_v, 2);
        }
    
    

    然后,你需要为执行器的使用添加一个成本。这有助于在不需要时最小化执行器的激活。想象一下,这辆车喜欢偷懒,只有当成本足够低时才会发出执行命令:

    for (int t = 0; t < N - 1; t++) {
        fg[0] += w_delta * CppAD::pow(vars[delta_start + t], 2);
        fg[0] += w_a * CppAD::pow(vars[a_start + t], 2);
        }
    
  5. 接下来,你需要为执行器的顺序使用添加成本。这将有助于最小化执行器的振荡使用,例如当新驾驶员笨拙地在油门和刹车之间跳跃时:

    for (int t = 0; t < N - 2; t++) {
        fg[0] += w_delta_smooth * CppAD::pow(vars[delta_start + t                                    + 1] - vars[delta_start + t], 2);
        fg[0] += w_a_smooth * CppAD::pow(vars[a_start + t + 1]                                               - vars[a_start + t], 2);
        }
    
    
  6. 接下来,添加一个在高速转向角度时使用油门的成本是个好主意。你不想在转弯中猛踩油门而失控:

    for (int t = 0; t < N - 1; t++) {
        fg[0] += w_throttle_steer * CppAD::pow(vars[delta_start                                          + t] / vars[a_start + t], 2);
    
        }
    
  7. 现在,建立初始约束:

    fg[1 + x_start] = vars[x_start];
    fg[1 + y_start] = vars[y_start];
    fg[1 + psi_start] = vars[psi_start];
    fg[1 + v_start] = vars[v_start];
    fg[1 + cte_start] = vars[cte_start];
    fg[1 + epsi_start] = vars[epsi_start];
    
  8. 现在我们已经做了这些,我们可以根据状态变量和t+1;即当前时间步,建立车辆模型约束:

    for (int t = 1; t < N; t++) {
          AD<double> x1 = vars[x_start + t];
          AD<double> y1 = vars[y_start + t];
          AD<double> psi1 = vars[psi_start + t];
          AD<double> v1 = vars[v_start + t];
          AD<double> cte1 = vars[cte_start + t];
          AD<double> epsi1 = vars[epsi_start + t];
    
  9. 然后,创建时间t的状态变量;即前一个时间步:

          AD<double> x0 = vars[x_start + t - 1];
          AD<double> y0 = vars[y_start + t - 1];
          AD<double> psi0 = vars[psi_start + t - 1];
          AD<double> v0 = vars[v_start + t - 1];
          AD<double> cte0 = vars[cte_start + t - 1];
          AD<double> epsi0 = vars[epsi_start + t - 1];
    
  10. 现在,你需要确保你只考虑时间t的驱动。因此,在这里,我们只考虑时间t的转向(delta0)和加速度(a0):

          AD<double> delta0 = vars[delta_start + t - 1];
          AD<double> a0 = vars[a_start + t - 1];
    
  11. 接下来,你需要添加你试图跟随的航点线的约束。这是通过创建一个拟合航点的多项式来完成的。这取决于系数的数量。例如,一个二阶多项式将有三项系数:

    AD<double> f0 = 0.0;
    for (int i=0; i<coeffs.size(); i++){
    f0 += coeffs[i] * CppAD::pow(x0, i);
    }
    

    使用相同的系数,你可以为汽车期望的航向建立约束:

          AD<double> psides0 = 0.0;
          for (int i=1; i<coeffs.size(); i++){
    psides0 += i * coeffs[i] * pow(x0, i-1);
          }
          psides0 = CppAD::atan(psides0);
    
  12. 最后,你需要为车辆模型创建约束。在这种情况下,可以使用一个简化的车辆模型,称为自行车模型:

          fg[1 + x_start + t] = x1 - (x0 + v0 * CppAD::cos(psi0) * dt);
          fg[1 + y_start + t] = y1 - (y0 + v0 * CppAD::sin(psi0) * dt);
          fg[1 + psi_start + t] = psi1 - (psi0 + v0 * delta0 * dt / Lf);
          fg[1 + v_start + t] = v1 - (v0 + a0 * dt);
          fg[1 + cte_start + t] = cte1 - ((f0 - y0) + (v0 * CppAD::sin(epsi0) * dt));
          fg[1 + epsi_start + t] = epsi1 - ((psi0 - psides0) + v0 * delta0 / Lf * dt);
    
    }
    

太棒了!你现在至少有一个如何在 C++中编码 MPC 的例子。你可以将这个基本示例转换为你的控制应用所需的任何语言。你在控制知识库中又多了一件武器!

总结

恭喜!你现在已经拥有了一个自动驾驶汽车的横向和纵向控制器!你应该为你在本章中学到并应用的知识感到自豪。

你已经学习了两种最普遍的控制器,即 PID 和 MPC。你了解到 PID 非常适合 SISO 系统,并且非常高效,但需要多个控制器来控制多个输出。同时,你也了解到 MPC 适合具有足够资源在每一步实时不断优化的 MIMO 系统。

通过这种方式,你已经艰难地穿越了数学和模型的细节,并实现了你自己的 PID 控制器,在 CARLA 和 Python 中实现。

在下一章中,你将学习如何构建地图并定位你的自动驾驶汽车,这样你就可以始终知道你在世界中的位置!

问题

读完这一章后,你应该能够回答以下问题:

  1. 什么控制器类型最适合计算资源较低的车辆?

  2. PID 控制器的积分项是用来纠正什么的?

  3. PID 控制器的导数项是用来纠正什么的?

  4. MPC 中的成本和约束有什么区别?

进一步阅读

第十一章:第十一章:绘制我们的环境

你的自动驾驶汽车在导航世界时需要一些基本的东西。

首先,你需要有一个你环境的地图。这个地图与你用来到达你最喜欢的餐厅的手机上的地图非常相似。

其次,你需要一种方法在现实世界中定位你在地图上的位置。在你的手机上,这就是由 GPS 定位的蓝色点。

在本章中,你将了解你的自动驾驶汽车如何通过其环境进行地图和定位的各种方法,以便知道它在世界中的位置。你可以想象这为什么很重要,因为制造自动驾驶汽车的全部原因就是为了去往各个地方!

你将学习以下主题,帮助你构建一个值得被称为麦哲伦的自动驾驶汽车:

  • 为什么你需要地图和定位

  • 地图和定位的类型

  • 开源地图工具

  • 使用 Ouster 激光雷达和 Google Cartographer 进行 SLAM

技术要求

本章需要以下软件:

本章的代码可以在以下链接找到:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars

本章动作视频中的代码可以在以下位置找到:

bit.ly/2IVkJVZ

为什么你需要地图和定位

在本章中,你将学习地图和定位的重要性,以及它们的结合。在现代世界中,我们常常认为地图和定位是理所当然的,但正如你将看到的,它们非常重要,尤其是在自动驾驶汽车中,那里的人类大脑没有得到充分利用。

地图

抽空想象一下,一个没有手机、没有 MapQuest(是的,我是个老千禧一代)、没有纸质地图,也没有希腊的安纳克西曼德的世界!

你认为你能多好地从你家导航到一个你从未去过的小镇,更不用说那些刚刚在几个城市外开业的新 Trader Joe's 了?我敢肯定你可以做到,但你可能会每隔几公里就停下来,向当地人询问接下来的几个方向,以便更接近那个大胆而质朴的两美元一瓶的查克。但你可以看到地图为什么真的让我们的生活变得更轻松,并为我们探索新地方提供了可能性,几乎不用担心迷路,最终到达瓦利世界。

现在,你非常幸运,像谷歌和苹果这样的公司已经不辞辛劳地绘制了你所能想到的每条街道、小巷和支路。这是一项巨大的任务,我们每天都在从中受益。万岁,地图!

定位

好的,现在想象一下你被传送到这里:

图 11.1 – 俄罗斯猴脸。图片来源:http://bit.ly/6547672-17351141

图 11.1 – 俄罗斯猴脸。图片来源:bit.ly/6547672-17351141

你已经得到了该地区的地图,需要找到通往最近的水体的路。在你因为传送而颤抖停止后,你需要做的第一件事是确定你在地图上的位置。你可能会环顾四周,以辨认附近的标志,然后尝试在地图上找到这些标志。“猴脸,我正处在猴脸的正中央!”恭喜你,你已经在地图上定位了自己,并可以使用它来找到生命的灵丹妙药!

现在你明白为什么在导航世界和环境时,既需要地图又需要定位。

现在,你说,“但是等等,如果自从地图生成以来世界发生了变化呢?!”

一定有你在开车时,跟随手机上导航甜美的声音,突然“砰!”你撞到了一些正在施工的道路,道路被封闭,迫使你绕行 30 分钟。你对你的手机大喊,“诅咒你,来自虚无的导航声音!你怎么不知道有施工呢?”

事实是,无论你的导航语音多么更新,总会错过关于世界的实时信息。想象一下一些鸭子正在过马路;语音永远不会警告你这一点。在下一节中,你将学习到许多使用各种类型的制图和定位来拯救鸭子的方法。

制图和定位的类型

定位和制图领域绝对充满了惊人的研究,并且一直在不断发展。GPU 和计算机处理速度的进步导致了某些非常激动人心的算法的发展。

快点,让我们回到拯救我们的鸭子!回想一下上一节,我们亲爱的卫星导航语音没有看到在我们面前过马路的鸭子。由于世界一直在变化和演变,地图永远不会完全准确。因此,我们必须有一种方法,不仅能够使用预先构建的地图进行定位,而且还能实时构建地图,以便我们可以在地图上看到新障碍物的出现,并绕过它们。为鸭子介绍 SLAM(不是 dunks)。

虽然有独立的制图和定位方法,但在本章中,我们将重点介绍同时定位与制图SLAM)。如果你好奇的话,以下是一些最常用的独立定位和制图算法的快速概述:

以下是一些示例类型的建图:

关于这些算法和实现有很多很好的信息,但在这本书中,我们将重点关注最广泛使用的定位和建图形式,即同时进行的:SLAM。

同时定位与建图(SLAM)

让我们暂时回到我们的想象中。

想象一下,你突然在一个晚上醒来,周围一片漆黑,没有月光,没有萤火虫——只有一片漆黑!别担心,你将使用 SLAM 的魔法从床边导航到获取美味的午夜小吃!

你摸索着你的左手,直到感觉到床的边缘。砰,你刚刚在床上定位了自己,并在心中绘制了床的左侧。你假设你在睡觉时没有在床上垂直翻转,所以这确实是你的床的左侧。

接下来,你将双腿从床边放下,慢慢地降低身体,直到你感觉到地板。砰,你刚刚绘制了你地板的一部分。现在,你小心翼翼地站起来,将手臂伸向前方。你像海伦·凯勒寻找蜘蛛网一样,在你面前摆动你的手臂,形成 Lissajous 曲线。同时,你小心翼翼地在地板上扫动你的脚,就像一个现代的诠释舞者寻找任何步骤、过渡、边缘和陷阱,以免摔倒。

每当你向前移动时,你都要仔细地在心里记录你面向的方向和走了多远(里程计)。始终,你正在构建一个心理地图,并用你的手和脚作为范围传感器,给你一种你在房间中的位置感(定位)。每次你发现障碍物,你都会将它存储在你的心理地图中,并小心翼翼地绕过它。你正在进行 SLAM!

SLAM 通常使用某种测距传感器,例如激光雷达传感器:

图 11.2 – OS1-128 数字激光雷达传感器,由 Ouster, Inc. 提供

图 11.2 – OS1-128 数字激光雷达传感器,由 Ouster, Inc. 提供

当你在房间内导航时,你的手臂和腿就像你的测距仪。激光雷达传感器使用激光光束,它照亮环境并从物体上反射回来。光从发出到返回的时间被用来通过光速估计到物体的距离。例如 OS1-128 这样的激光雷达传感器,可以产生丰富且密集的点云,具有高度精确的距离信息:

图 11.3 – 城市环境中的激光雷达点云,由 Ouster, Inc. 提供

图 11.3 – 城市环境中的激光雷达点云,由 Ouster, Inc. 提供

这种距离信息是 SLAM 算法用来定位和绘制世界地图的。

还需要一个惯性测量单元IMU)来帮助估计车辆的姿态并估计连续测量之间的距离。Ouster 激光雷达传感器之所以在地图创建中很受欢迎,一个原因是它们内置了 IMU,这使得你可以用单个设备开始制图。在本章的后面部分,你将学习如何使用 Ouster 激光雷达传感器和 Google Cartographer 进行制图。

SLAM 是在没有任何先验信息的情况下实时构建地图并同时在地图中定位的概念。你可以想象这非常困难,有点像“先有鸡还是先有蛋”的问题。为了定位,你需要一个地图(蛋)来定位,但与此同时,为了实时构建你的地图,你需要定位(鸡)并知道你在你试图构建的地图上的位置。这就像一部时间旅行电影中的问题:生存足够长的时间回到过去,首先救自己。你的头还疼吗?

好消息是,这个领域已经研究了 30 多年,并以机器人技术和自动驾驶汽车算法的形式结出了美丽的果实。让我们看看未来有什么在等待我们!

SLAM 类型

以下是一些在机器人、无人机制图和自动驾驶行业中广泛使用的最先进算法的简要列表。这些算法各有不同的应用。例如,RGB-D SLAM 用于基于摄像头的 SLAM,而 LIO SAM 则专门用于激光雷达传感器。动力融合是另一种有趣的 SLAM 形式,用于绘制室内复杂物体。更完整的列表可以在 KITTI 网站上找到:www.cvlibs.net/datasets/kitti/eval_odometry.php

接下来,你将学习关于在 SLAM 算法中减少误差的一个非常重要的方法。

SLAM 中的闭环检测

在地图制作和定位时,有一件事需要考虑,那就是没有任何东西是完美的。你永远找不到一个完全准确的传感器。所有传感器都是概率性的,包含一个测量值的平均值和方差。这些值在工厂的校准过程中通过经验确定,并在数据表中提供。你可能会问,我为什么要关心这个?

好问题!传感器总是存在一些误差的事实意味着,你使用这些传感器导航的时间越长,你的地图以及你在地图中的位置估计与现实之间的偏差就越大。

几乎所有的 SLAM 算法都有一个应对这种漂移的技巧:闭环检测!闭环检测的工作原理是这样的。假设你在前往阿布扎德的旅途中经过了阿达尔大楼:

图 11.4 – 阿布扎比的阿达尔总部大楼,阿联酋

图 11.4 – 阿布扎比的阿达尔总部大楼,阿联酋

你将这个壮观的圆形建筑注册到你的地图中,然后继续你的旅程。然后,在某个时候,也许在你吃了在黎巴嫩午餐之后,你开车返回,第二次经过阿达尔大楼。现在当你经过它时,你测量你与它的距离,并将其与你第一次在地图上注册它时的位置进行比较。你意识到你并不在你期望的位置。咔嚓!算法利用这些信息,迭代地纠正整个地图,以表示你在世界中的真实位置。

SLAM 会不断地对每个它映射的特征做这件事,并在之后返回。在你接下来几节中玩开源 SLAM 时,你会看到这个动作。在那之前,让我们快速展示一些可用的开源地图工具,供你在地图制作中享受。

开源地图工具

SLAM 的实现和理解相当复杂,但幸运的是,有许多开源解决方案可供你在自动驾驶汽车中使用。网站 Awesome Open Source (awesomeopensource.com/projects/slam) 收集了大量的 SLAM 算法,你可以使用。

这里有一些精心挑选的内容来激发你的兴趣:

由于 Cartographer 迄今为止是最受欢迎和支持的,您将在下一节中有机会体验和探索它所提供的一切。

使用 Ouster 激光雷达和 Google Cartographer 进行 SLAM

这就是您一直等待的时刻:使用 Cartographer 和 Ouster 激光雷达传感器亲自动手绘制地图!

在这个动手实验中,选择 Ouster 激光雷达是因为它内置了IMU,这是执行 SLAM 所需的。这意味着您不需要购买另一个传感器来提供惯性数据。

您将看到的示例是从 Ouster 传感器收集的数据的离线处理,并改编自 Wil Selby 的工作。请访问 Wil Selby 的网站主页,了解更多酷炫的项目和想法:www.wilselby.com/

Selby 还有一个相关的项目,该项目在 ROS 中为 DIY 自动驾驶汽车执行在线(实时)SLAM:github.com/wilselby/diy_driverless_car_ROS

Ouster 传感器

您可以从 OS1 用户指南中了解更多关于 Ouster 数据格式和传感器使用的信息。

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/blob/master/Chapter11/OS1-User-Guide-v1.14.0-beta.12.pdf

别担心,您不需要购买传感器就可以在本章中亲自动手。我们已经为您提供了从 OS1-128 收集的一些样本数据,您可以稍后看到如何下载这些数据。

仓库

您可以在以下链接中找到本章的代码,位于ouster_example_cartographer子模块中:

github.com/PacktPublishing/Hands-On-Vision-and-Behavior-for-Self-Driving-Cars/tree/master/Chapter11

为了确保您拥有子模块中的最新代码,您可以从Chapter11文件夹中运行以下命令:

$ git submodule update --remote ouster_example_cartographer

开始使用 cartographer_ros

在我们深入代码之前,我们鼓励您通过阅读算法遍历来学习 Cartographer 的基础知识:

google-cartographer-ros.readthedocs.io/en/latest/algo_walkthrough.html

让我们从快速概述 Cartographer 配置文件开始,这些配置文件是使它能够使用您的传感器工作的。

Cartographer_ros 配置

地图绘制器需要以下配置文件来了解您的传感器、机器人、变换等信息。这些文件可以在ouster_example_cartographer/cartographer_ros/文件夹中找到:

  • configuration_files/demo_3d.rviz

  • configuration_files/cart_3d.lua

  • urdf/os_sensor.urdf

  • launch/offline_cart_3d.launch

  • configuration_files/assets_writer_cart_3d.lua

  • configuration_files/transform.lua

这里引用的文件是用于在从 Ouster 传感器收集的包上执行离线 SLAM 的。

现在,让我们逐个分析每个文件,并解释它们如何有助于在 ROS 内部实现 SLAM。

demo_3d.rviz

此文件设置了 rviz 图形用户界面窗口的配置。它基于 cartographer_ros 源文件中提供的示例文件:

github.com/cartographer-project/cartographer_ros/blob/master/cartographer_ros/configuration_files/demo_3d.rviz

它指定了参考框架。有关各种参考框架的详细信息,请参阅以下链接:

www.ros.org/reps/rep-0105.html

以下代码片段是你在根据项目使用的传感器添加框架名称的位置:

      Frames:
        All Enabled: true
        base_link:
          Value: true
        map:
          Value: true
        odom:
          Value: true
        os:
          Value: true
        os_imu:
          Value: true

以下是对前面代码中每个框架定义的说明:

  • base_link 是你的机器人的坐标框架。

  • map 是世界的固定坐标框架。

  • odom 是一个基于惯性测量单元(IMU)、轮编码器、视觉里程计等测量的世界固定框架。这可能会随时间漂移,但可以在没有离散跳跃的情况下保持连续平滑的位置信息。Cartographer 使用这个框架来发布非闭环局部 SLAM 结果。

  • os 是 Ouster 传感器或你为项目选择的任何其他激光雷达传感器的坐标框架。这用于将激光雷达距离读数转换到 base_link 框架。

  • os_imu 是 Ouster 传感器或你为项目选择的任何其他 IMU 的坐标框架。这是 Cartographer 在 SLAM 期间将跟踪的框架。它也将被转换回 base_link 框架。

接下来,定义了框架的 tf 变换树层次结构,以便你可以在任何框架之间进行转换:

      Tree:
        map:
          odom:
            base_link:
              os:
                {}
              os_imu:
                {}

你可以看到 osos_imu 框架都与 base_link(车辆框架)相关。这意味着你不能直接从 os(激光雷达框架)转换到 os_imu(IMU 框架)。相反,你需要将两者都转换到 base_link 框架。从那里,你可以通过 tf 树转换到地图框架。这就是 Cartographer 在使用激光雷达距离测量和 IMU 姿态测量构建地图时将执行的操作。

接下来,RobotModel 被配置为根据先前定义的 tf 变换树显示链接(意味着传感器、机械臂或任何你想要跟踪的具有机器人坐标框架的东西)的正确姿态。

以下代码片段显示了你在 Frames 部分之前定义的链接名称放置的位置:

Class: rviz/RobotModel
      Collision Enabled: false
      Enabled: true
      Links:
        All Links Enabled: true
        Expand Joint Details: false
        Expand Link Details: false
        Expand Tree: false
        Link Tree Style: Links in Alphabetic Order
        base_link:
          Alpha: 1
          Show Axes: false
          Show Trail: false
        os:
          Alpha: 1
          Show Axes: false
          Show Trail: false
          Value: true
        os_imu:
          Alpha: 1
          Show Axes: false
          Show Trail: false
          Value: true

你可以看到 base_linkos 激光雷达和 os_imu 链接都被添加在这里。

接下来,rviz/PointCloud2 被映射到 PointCloud2 激光雷达点数据的话题,对于 Ouster 激光雷达传感器文件包,存储在 /os_cloud_node/points 话题中。如果您使用任何其他激光雷达传感器,您应将那个激光雷达的话题名称放在 Topic: 字段中:

      Name: PointCloud2
      Position Transformer: XYZ
      Queue Size: 200
      Selectable: true
      Size (Pixels): 3
      Size (m): 0.029999999329447746
      Style: Flat Squares
      Topic: /os_cloud_node/points

您可以看到激光雷达的主题被映射为 PointCloud2 类型。

这就完成了对 rviz 中激光雷达和 IMU 传感器的特定配置。接下来,您将看到如何修改 cart_3d.lua 文件以匹配您的机器人特定布局。

cart_3d.lua

此文件设置了机器人 SLAM 调优参数的配置。.lua 文件应该是针对特定机器人的,而不是针对特定文件。它基于 cartographer_ros 源文件中提供的示例文件:

github.com/cartographer-project/cartographer_ros/blob/master/cartographer_ros/configuration_files/backpack_3d.lua

鼓励您根据具体应用调整 .lua 文件中的参数。调整指南可在以下链接中找到:

google-cartographer-ros.readthedocs.io/en/latest/algo_walkthrough.html

在这里,我们将简要介绍您可以配置的一些自动驾驶汽车选项:

options = {
  map_builder = MAP_BUILDER,
  trajectory_builder = TRAJECTORY_BUILDER,
  map_frame = "map",
  tracking_frame = "os_imu",
  published_frame = "base_link",
  odom_frame = "base_link",
  provide_odom_frame = false,
  publish_frame_projected_to_2d = false,
  use_odometry = false,
  use_nav_sat = false,
  use_landmarks = false,
  num_laser_scans = 0,
  num_multi_echo_laser_scans = 0,
  num_subdivisions_per_laser_scan = 1,
  num_point_clouds = 1,
  lookup_transform_timeout_sec = 0.2,
  submap_publish_period_sec = 0.3,
  pose_publish_period_sec = 5e-3,
  trajectory_publish_period_sec = 30e-3,
  rangefinder_sampling_ratio = 1.,
  odometry_sampling_ratio = 1.,
  fixed_frame_pose_sampling_ratio = 1.,
  imu_sampling_ratio = 1.,
  landmarks_sampling_ratio = 1.,
}

上述选项是为从 Ouster 网站提供的文件包中离线 SLAM 配置的。

data.ouster.io/downloads/os1_townhomes_cartographer.zip

data.ouster.io/downloads/os1_townhomes_cartographer.zip

如果您在自动驾驶汽车上进行在线(实时)SLAM,则需要修改突出显示的部分。

  • odom_frame = "base_link": 应将其设置为 odom,以便 Cartographer 发布非闭环连续位姿为 odom_frame

  • provide_odom_frame = false: 应将其设置为 true,以便 Cartographer 知道 odom_frame 已发布。

  • num_laser_scans = 0: 应将其设置为 1,以便直接从传感器使用激光雷达传感器的扫描数据,而不是从文件中的点云数据。

  • num_point_clouds = 1: 如果不使用文件包,而是使用实时激光雷达扫描,则应将其设置为 0

接下来,您将看到传感器 urd 文件的配置。

os_sensor.urdf

此文件用于配置自动驾驶汽车的物理变换。您在车辆上安装的每个传感器都将是一个链接。将链接想象成链中的刚体,就像链中的链接一样。每个链接在链中都是刚性的,但链接可以相对于彼此移动,并且每个链接都有自己的坐标系。

在此文件中,您可以看到我们已经将 Ouster 传感器设置为机器人,<robot name="os_sensor">

我们添加了表示激光雷达坐标系的链接<link name="os_lidar">和传感器的 IMU 坐标系<link name="os_imu">

以下代码显示了如何提供从每个帧到base_link帧的变换:

  <joint name="sensor_link_joint" type="fixed">
    <parent link="base_link" />
    <child link="os_sensor" />
    <origin xyz="0 0 0" rpy="0 0 0" />
  </joint>
  <joint name="imu_link_joint" type="fixed">
    <parent link="os_sensor" />
    <child link="os_imu" />
    <origin xyz="0.006253 -0.011775 0.007645" rpy="0 0 0" />
  </joint>
  <joint name="os1_link_joint" type="fixed">
    <parent link="os_sensor" />
    <child link="os_lidar" />
    <origin xyz="0.0 0.0 0.03618" rpy="0 0 3.14159" />
  </joint>

你可以看到os_sensor被放置在base_link坐标系的中心,而os_imuos_lidar则相对于os_sensor给出了各自的平移和旋转。这些平移和旋转可以在 Ouster 传感器用户指南的第八部分中找到:

github.com/Krishtof-Korda/ouster_example_cartographer/blob/master/OS1-User-Guide-v1.14.0-beta.12.pdf

接下来,你将学习如何配置启动文件以调用所有之前的配置文件并启动 SLAM 过程。

offline_cart_3d.launch

此文件用于调用之前讨论的所有配置文件。

它还将points2imu主题重新映射到 Ouster os_cloud_node主题。如果你使用的是其他类型的激光雷达传感器,只需简单地使用该传感器的主题名称即可:

    <remap from="points2" to="/os_cloud_node/points" />
    <remap from="imu" to="/os_cloud_node/imu" />

接下来,你将学习如何使用assets_writer_cart_3d.lua文件来保存地图数据。

assets_writer_cart_3d.lua

此文件用于配置生成将输出为.ply格式的完全聚合点云的选项。

你可以设置用于下采样点并仅取质心的VOXEL_SIZE值。这很重要,因为没有下采样,你需要巨大的处理周期。

VOXEL_SIZE = 5e-2

你还设置了min_max_range_filter,它只保留位于激光雷达传感器指定范围内的点。这通常基于激光雷达传感器的数据表规格。Ouster OS1 数据表可以在 Ouster(outser.com/)网站上找到。

以下代码片段显示了你可以配置范围过滤器选项的位置:

  tracking_frame = "os_imu",
  pipeline = {
    {
      action = "min_max_range_filter",
      min_range = 1.,
      max_range = 60.,
    },

最后,你将学习如何使用transform.lua文件来进行 2D 投影。

transform.lua 文件

此文件是一个用于执行变换的通用文件,并在上一个文件中使用它来创建 2D 地图 X 射线和概率网格图像。

太棒了,现在你已经了解了每个配置文件的作用,是时候看到它在实际中的应用了!下一节将指导你如何使用预构建的 Docker 镜像运行 SLAM。这可能会让你比说“未来的汽车将带我们走!”更快地开始 SLAM。

Docker 镜像

已为你创建了一个 Docker 镜像供下载。这将有助于确保所有必需的软件包都已安装,并最小化你需要的时间来让一切正常工作。

如果你正在 Linux 操作系统上运行,你可以简单地使用以下命令运行位于ouster_example_cartographer子模块中的install-docker.sh

$ ./install-docker.sh 

如果你使用的是其他操作系统(Windows 10 或 macOS),你可以直接从他们的网站下载并安装 Docker:

docs.docker.com/get-docker/

你可以使用以下命令验证 Docker 是否正确安装:

$ docker –version

太好了!希望一切顺利,你现在可以准备在容器中运行 Docker 镜像。强烈建议使用带有 Nvidia 显卡的 Linux 机器,以便使代码和 Docker 镜像正常工作。run-docker.sh脚本提供了一些选项,以帮助使用正确的图形处理器启动 Docker。强烈建议使用 Nvidia GPU 来高效地处理 SLAM。你也可以使用其他 GPU,但对其支持较低。

以下部分将为你提供一些连接 Docker 与你的 Nvidia GPU 的故障排除步骤。

Docker Nvidia 故障排除

根据你的 Linux 机器上的 Nvidia 设置,在连接到你的 Docker 容器之前,你可能需要执行以下命令:

# Stop docker before running 'sudo dockerd --add-runtime=nvidia=/usr/bin/nvidia-container-runtime'
$ sudo systemctl stop docker
# Change mode of docker.sock if you have a permission issue
$ sudo chmod 666 /var/run/docker.sock
# Add the nvidia runtime to allow docker to use nvidia GPU
# This needs to be run in a separate shell from run-docker.sh
$ sudo dockerd --add-runtime=nvidia=/usr/bin/nvidia-container-runtime

现在,你可以使用以下命令运行 Docker 并将其连接到你的 GPU:

$ ./run-docker.sh

此脚本将从 Docker Hub 拉取最新的 Docker 镜像,并在有 Nvidia 运行时的情况下运行该镜像,如果没有,则简单地使用 CPU 运行。

此文件在注释中也有许多有用的命令,用于在 2D 或 3D 模式下运行 Cartographer。你将在这里了解 3D 模式。

接下来的几个部分将指导你执行从 Ouster 下载的数据的 SLAM。

获取样本数据

你将要 SLAM 的样本数据可以从 Ouster 网站获取。

使用以下命令下载:

$ mkdir /root/bags
$ cd /root/bags
$ curl -O https://data.ouster.io/downloads/os1_townhomes_cartographer.zip
$ unzip /root/bags/os1_townhomes_cartographer.zip -d /root/bags/

源工作空间

你需要 source catkin工作空间以确保它已设置 ROS:

$ source /root/catkin_ws/devel/setup.bash

验证 rosbag

使用内置的 cartographer bag 验证工具验证rosbag是一个好主意。这将确保数据包具有连续的数据,并将产生以下结果:

$ rosrun cartographer_ros cartographer_rosbag_validate -bag_filename /root/bags/os1_townhomes_cartographer.bag

准备启动

要在数据包上运行你的离线 SLAM,你首先需要到达发射台:

$ cd /root/catkin_ws/src/ouster_example_cartographer/cartographer_ros/launch

在数据包上启动离线

现在,你已准备好启动离线 SLAM。这将创建一个.pbstream文件,稍后将用于写入你的资产,例如以下内容:

  • .ply,点云文件

  • 已映射空间的二维 X 射线图像

  • 一个开放区域与占用区域的二维概率网格图像

以下命令将在你的数据包文件上启动离线 SLAM 过程:

$ roslaunch offline_cart_3d.launch bag_filenames:=/root/bags/os1_townhomes_cartographer.bag

你应该会看到一个打开的rviz窗口,其外观类似于以下图示:

图 11.5 – Cartographer 启动的 rviz 窗口

图 11.5 – Cartographer 启动的 rviz 窗口

现在,你可以坐下来,惊奇地观看 Cartographer 精心执行 SLAM。

首先,它将生成较小的局部子图。然后,它将扫描匹配子图到全局地图。你会注意到,当它收集到足够的数据以匹配全局地图时,每几秒钟就会捕捉一次点云。

当过程完成后,你将在/root/bags文件夹中找到一个名为os1_townhomes_cartographer.bag.pbstream的文件。你将使用这个文件来编写你的资产。

编写你的甜蜜,甜蜜的资产

我希望你已经准备好了,因为你即将从 SLAM 中获得最终产品——一张你从未见过的随机街道地图。这不正是你梦寐以求的吗?

运行以下命令来领取你的奖品!

$ roslaunch assets_writer_cart_3d.launch bag_filenames:=/root/bags/os1_townhomes_cartographer.bag  pose_graph_filename:=/root/bags/os1_townhomes_cartographer.bag.pbstream

这需要一些时间;去吃点你最喜欢的舒适食品。一个小时后我们在这里见。

欢迎回来!欣赏你的奖品吧!

打开你的第一个奖品

哇!你自己的 X 射线 2D 地图!

$ xdg-open os1_townhomes_cartographer.bag_xray_xy_all.png

这就是输出结果:

图 11.6 – 住宅区的 2D X 射线地图

图 11.6 – 住宅区的 2D X 射线地图

打开你的第二个奖品

哇!你自己的概率网格 2D 地图!

$ xdg-open os1_townhomes_cartographer.bag_probability_grid.png

这就是输出结果:

图 11.7 – 住宅区的 2D 概率网格地图

图 11.7 – 住宅区的 2D 概率网格地图

你的最终奖品

你将在/root/bags文件夹中找到一个名为os1_townhomes_cartographer.bag_points.ply的文件。这个奖品需要更多的努力才能真正欣赏。

你可以使用任何能够打开.ply文件的工具。CloudCompare 是用于此目的的FOSS(即免费开源软件)工具,可以从以下链接下载:

www.danielgm.net/cc/

你还可以使用 CloudCompare 将你的.ply文件保存为其他格式,例如 XYZ、XYZRGB、CGO、ASC、CATIA ASC、PLY、LAS、PTS 或 PCD。

unitycoder在以下链接提供了制作转换的良好说明:

github.com/unitycoder/UnityPointCloudViewer/wiki/Converting-Points-Clouds-with-CloudCompare

这就是输出结果:

图 11.8 – 在 CloudCompare 中查看的点云 3D 地图

图 11.8 – 在 CloudCompare 中查看的点云 3D 地图

看看图 11.8图 11.9,它们展示了使用 CloudCompare 查看器查看的 3D 点云地图的样子:

图 11.9 – 在 CloudCompare 中查看的点云 3D 地图,俯视图

图 11.9 – 在 CloudCompare 中查看的点云 3D 地图,俯视图

恭喜你制作了你的第一张地图,我们希望这只是你旅程的开始,我们迫不及待地想看看你将用你新获得的技术创造出什么!接下来,我们将总结你所学到的所有内容。

摘要

哇,你在这一章和这本书中已经走得很远了。你开始时一无所有,只有一部手机和一个蓝色的 GPS 点。你穿越全球来到俄罗斯,在猴脸找到了生命的精华。你通过 SLAM 的方式穿越你的基米里亚黑暗家园,抓了一些小吃。你学习了地图和定位之间的区别,以及每种类型的各种类型。你挑选了一些开源工具,并将它们系在你的冒险带上,以备将来使用。

你还学会了如何将开源的 Cartographer 应用于 Ouster OS1-128 激光雷达传感器数据,并结合内置的 IMU 生成一些非常漂亮的联排别墅的密集和实体地图,你使用 CloudCompare 进行了操作。现在你知道如何创建地图,可以出去绘制你自己的空间并在其中定位!世界是你的 Ouster(请原谅我,牡蛎)!我们迫不及待地想看看你将用你的创造力和知识构建出什么!

我们真心希望你喜欢和我们一起学习;我们当然喜欢与你分享这些知识,并希望你能受到启发去构建未来!

问题

现在,你应该能够回答以下问题:

  1. 地图绘制和定位之间的区别是什么?

  2. Cartographer 通常使用哪个框架作为跟踪框架?

  3. 为什么需要 SLAM?

  4. 你在哪个文件中设置了min_max_range_filter

进一步阅读

第十二章:评估

第一章

  1. 是的,尽管在某些情况下,你可能需要一个定制的构建。

  2. 通常使用bilateralFilter()

  3. HOG 检测器。

  4. 使用VideoCapture()

  5. 较大的光圈增加了传感器可用的光线量,但减少了景深。

  6. 当没有足够的光线来满足所需的快门速度和光圈设置时,你需要更高的 ISO。

  7. 是的,亚像素精度以显著的方式提高了校准。

第二章

  1. 对于UART单端:两根线(数据和地)。由于它是异步的,设备保持自己的时间,并在事先就波特率达成一致,因此不需要时钟线。差分:两根线(数据高和数据低)。测量的是差分电压,而不是相对于地的电压。

    I2C:两根线,串行时钟SCL)和串行数据SDA),使用具有主从设备的总线架构。

    SPI:3 + 1n根线,其中n是从设备的数量。三条主要线:信号时钟SCLK)、主设备输出从设备输入MOSI)和主设备输入从设备输出MISO);以及每个从设备的一个从设备选择SS)线。

    CAN:两根线,CAN-HI 和 CAN-LO,使用 CAN-HI 和 CAN-LO 作为差分对的总线架构。

  2. 通过使用噪声对两个信号产生相似影响的差分对,可以减少噪声。线相互缠绕以消除任何感应电流。

  3. 串行传输按顺序逐个通过单根线发送所有位,串行。并行传输同时将每个位发送到其自己的线。因此,对于 8 位字,并行传输将快 8 倍。

  4. I2C、SPI、CAN 和以太网。

  5. I2C 和 SPI。

  6. UART。

第三章

  1. HLS、HSV、LAB 和 YcbCr。

  2. 为了获得车道线的鸟瞰图,以便它们在图像中也保持平行。

  3. 使用直方图。

  4. 滑动窗口。

  5. 使用polyfit(),然后使用系数来画线。

  6. Scharr()效果很好。

  7. 指数加权移动平均简单且有效。

第四章

  1. 它是神经网络中的一个神经元。

  2. Adam。

  3. 这是一个将核应用于某些像素的操作,从而得到一个新的像素作为结果。

  4. 它是一个至少包含一个卷积层的神经网络。

  5. 它是一个层,将某一层的所有神经元连接到前一层的所有神经元。

  6. 它将卷积层的 2D 输出线性化,以便可以使用密集层。

  7. TensorFlow。

  8. LeNet。

第五章

  1. 你必须做你必须做的事情...但理想情况下,你只想使用一次,以避免在模型选择上的偏差。

  2. 它是从初始数据集中生成更多数据的过程,以增加其大小并提高网络的泛化能力。

  3. 不完全是这样:Keras 用数据增强得到的新图像替换了原始图像。

  4. 通常,密集层往往是参数最多的层;特别是,在最后一个卷积层之后的第一个通常是最大的。

  5. 当训练损失的线随着 epoch 数的增加而下降时,验证损失上升。

  6. 不一定:你可以使用一种策略,首先过度拟合网络(以正确学习训练数据集),然后提高泛化能力并消除过度拟合。

第六章

  1. 为了增加非线性激活的数量,并让网络学习更复杂的函数。

  2. 不一定。事实上,一个精心设计的深度网络可以更快且更精确。

  3. 当训练准确率提高但验证准确率下降时。

  4. 提前停止。

  5. 使用批量归一化。

  6. 使用数据增强。

  7. 因为它学会了不仅仅依赖少数几个通道。

  8. 训练可能需要更慢的速度。

  9. 训练可能需要更慢的速度。

第七章

  1. SSD 是一种能够在图像中找到多个对象的神经网络,其输出包括检测到的对象位置。它可以实时工作。

  2. Inception 是由谷歌创建的一个有影响力和精确的神经网络。

  3. 一个被冻结的层无法进行训练。

  4. 不,它不能。它只能检测交通灯,但不能检测其颜色。

  5. 迁移学习是将一个在某个任务上训练好的神经网络适应解决一个新、相关任务的过程。

  6. 添加 dropout,增加数据集的大小,增加数据增强的多样性,并添加批量归一化。

  7. 考虑到 ImageNet 中图像的多样性,很难选择卷积层的核大小,所以他们并行使用了多个大小的核。

第八章

  1. DAVE-2,但也可以称为 DriveNet。

  2. 在分类任务中,图像根据一些预定义的类别进行分类,而在回归任务中,我们生成一个连续的预测;在我们的例子中,例如,一个介于-1 和 1 之间的转向角度。

  3. 你可以使用yield关键字。

  4. 这是一个可视化工具,可以帮助我们了解神经网络关注的地方。

  5. 我们需要三个视频流来帮助神经网络理解如何纠正错误的位置,因为侧摄像头实际上是从远离汽车中心的位置进行校正。

  6. 由于性能原因,并且确保所有代码只运行在客户端。

第九章

  1. 密集块,其中每一层都连接到前一层的所有输出,包括输入。

  2. ResNet。

  3. 这是对 DenseNet 进行语义分割的改进。

  4. 因为它可以像 U 形一样可视化,左侧下采样,右侧上采样。

  5. 不,你只需堆叠一系列卷积即可。然而,由于高分辨率,实现良好的结果将具有挑战性,并且很可能会使用大量内存并且相当慢。

  6. 你可以使用中值模糊来去除可能存在于训练不良网络的分割掩码中的盐和胡椒噪声。

  7. 它们用于传播高分辨率通道,并帮助网络达到良好的真实分辨率。

第十章

  1. PID 控制因为只需要解决简单的代数方程。回想一下,MPC 需要在实时中解决多元优化问题,这需要非常高的处理能力以确保足够低的延迟以进行驾驶。

  2. PID 控制中的积分项通过根据系统的累积误差应用控制输入来纠正系统中的任何稳态偏差。

  3. PID 控制中的微分项通过根据误差的时间变化率调整控制输入来纠正对设定点的超调。

  4. 成本用于为轨迹分配一个值,该值在碰撞成本、连续动作成本、使用执行器的成本以及未到达目的地的成本等情况下被最小化。

    约束是系统的物理限制,例如转弯半径、最大横向和纵向加速度、车辆动力学和最大转向角度。

第十一章

  1. 地图绘制旨在将有关环境中的可导航空间的信息存储起来,而定位旨在确定机器人在环境中的位置。

  2. odom_frame.

  3. SLAM 是必需的,因为地图永远不会完全拥有关于环境的最新信息。因此,你需要不断地在移动过程中创建可导航空间的地图。SLAM 还提供了一种在没有昂贵的高精度惯性测量单元(IMU)设备的情况下绘制环境的方法。

  4. assets_writer_cart_3d.lua.

第十三章:您可能还会喜欢的其他书籍

如果您喜欢这本书,您可能会对 Packt 出版的这些其他书籍感兴趣:

精通 Adobe Photoshop Elements

](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Image2.png)](https://www.packtpub.com/product/hands-on-computer-vision-with-tensorflow-2/9781788830645)

学习 OpenCV 4 计算机视觉与 Python 3 – 第三版

Joseph Howse, Joe Minichino

ISBN: 978-1-78953-161-9

  • Install and familiarize yourself with OpenCV 4's Python 3 bindings

  • 理解图像处理和视频分析的基础知识

  • Use a depth camera to distinguish foreground and background regions

  • Detect and identify objects, and track their motion in videos

  • Train and use your own models to match images and classify objects

  • Detect and recognize faces, and classify their gender and age

  • 构建一个增强现实应用程序来跟踪 3D 中的图像

  • 与机器学习模型一起工作,包括 SVMs、人工神经网络(ANNs)和深度神经网络(DNNs)

精通 Adobe Captivate 2019 - 第五版

使用 TensorFlow 2 进行实战计算机视觉

Benjamin Planche, Eliot Andres

ISBN: 978-1-78883-064-5

  • 从零开始创建自己的神经网络

  • Classify images with modern architectures including Inception and ResNet

  • Detect and segment objects in images with YOLO, Mask R-CNN, and U-Net

  • Tackle problems in developing self-driving cars and facial emotion recognition systems

  • 通过迁移学习、GANs 和领域自适应来提高您应用程序的性能

  • 使用循环神经网络进行视频分析

  • 在移动设备和浏览器上优化和部署您的网络

Leave a review - let other readers know what you think

请通过在您购买书籍的网站上留下评论的方式,与别人分享您对这本书的看法。如果您从亚马逊购买了这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以看到并使用您的客观意见来做出购买决定,我们也可以了解客户对我们产品的看法,我们的作者也可以看到他们对与 Packt 合作创作的标题的反馈。这只需要您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都是宝贵的。谢谢!

posted @ 2025-09-24 13:51  绝不原创的飞龙  阅读(44)  评论(0)    收藏  举报