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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:第九章:语义分割

这可能是关于深度学习最先进的章节,因为我们将会深入到使用一种称为语义分割的技术,对图像进行像素级的分类。我们将充分利用到目前为止所学的内容,包括使用生成器进行数据增强。

我们将非常详细地研究一个非常灵活且高效的神经网络架构,称为 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 的例子。以下是原始图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.1_B16322.jpg

图 9.1 – Carla 的一帧

现在让我们看看语义分割相机产生的同一帧:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.2_B16322.jpg

图 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 相机和语义分割:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.3_B16322.jpg

图 9.3 – synchronous_mode.py的输出

不要对自动驾驶算法过于印象深刻,因为虽然对我们来说非常方便,但它也非常有限。

卡拉拉(Carla)有大量的路标,这些是 3D 指向的点。这些点在每个轨道上数以千计,沿着道路排列,并来自 OpenDRIVE 地图;OpenDRIVE 是一个开放文件格式,卡拉拉(Carla)使用它来描述道路。这些点与道路方向一致,因此如果你在移动汽车的同时也应用这些点的方向,汽车实际上就像自动驾驶一样移动。太棒了!直到你添加汽车和行人;然后你开始得到这样的帧,因为汽车会移动到其他车辆中:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.4_B16322.jpg

图 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 的高级架构在以下图中展示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.5_B16322.jpg

图 9.5 – DenseNet 作为分类器的高级视图,包含三个密集块

图中只显示了三个密集块,但实际上通常会有更多。

如从图中所示,理解以下内容相当简单:

  • 输入是一个 RGB 图像。

  • 存在一个初始的 7 X 7 卷积。

  • 存在一个密集块,其中包含一些卷积操作。我们很快会对此进行深入描述。

  • 每个密集块后面都跟着一个 1 X 1 卷积和一个平均池化,这会减小图像的大小。

  • 最后一个密集块直接跟着平均池化。

  • 最后,有一个密集(全连接)层,带有 softmax

1 X 1 卷积可以用来减少通道数以加快计算速度。DenseNet 论文中将 1 X 1 卷积后面跟着平均池化称为 过渡层,当通道数减少时,他们称得到的网络为 DenseNet-C,其中 C 代表 压缩,卷积层被称为 压缩层

作为分类器,这个高级架构并不特别引人注目,但正如你可能猜到的,创新在于密集块,这是下一节的重点。

理解密集块

密集块是该架构的名称,也是 DenseNet 的主要部分;它们包含卷积,通常根据分辨率、你想要达到的精度以及性能和训练时间,你会有几个这样的块。请注意,它们与我们之前遇到的密集层无关。

密集块是我们可以通过重复来增加网络深度的块,它们实现了以下目标:

  • 它们解决了 梯度消失 问题,使我们能够构建非常深的网络。

  • 他们非常高效,使用相对较少的参数。

  • 它们解决了 无效神经元 问题,意味着所有的卷积都对最终结果有贡献,我们不会浪费 CPU 和内存在基本无用的神经元上。

这些是宏伟的目标,许多架构都难以实现这些目标。那么,让我们看看 DenseNet 如何做到其他许多架构无法做到的事情。以下是一个密集块:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.6_B16322.jpg

图 9.6 – 带有五个卷积的密集块,以及输入

这确实很了不起,需要一些解释。也许你还记得来自 第七章检测行人和交通信号灯ResNet,这是一个由微软构建的神经网络,它有一个名为 跳过连接 的特性,这些快捷方式允许一层跳过其他层,有助于解决梯度消失问题,从而实现更深的网络。实际上,ResNet 的一些版本可以有超过 1,000 层!

DenseNet 将这一概念推向了极致,因为在每个密集块内部,每个卷积层都与其他卷积层连接并连接起来!这有两个非常重要的含义:

  • 跳过连接的存在显然实现了 ResNet 中跳过连接的相同效果,使得训练更深的网络变得容易得多。

  • 多亏了跳跃连接,每一层的特征可以被后续层复用,这使得网络非常高效,并且与其它架构相比,大大减少了参数数量。

该功能的复用效果可以通过以下图表更好地理解,该图表解释了密集块的效果,重点关注通道而不是跳跃连接:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.7_B16322.jpg

图 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 层。

在实践中,我们希望实现一个类似于以下架构的架构:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.8_B16322.jpg

图 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 或者决定记录额外的图像。

这是训练的图表:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.9_B16322.jpg

图 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 格式。

让我们看看它的表现如何,记住这些图像与网络在训练期间看到的图像非常相似。

下面是一张图像的结果,以及其他分割图像的版本:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.10_B16322.jpg

图 9.10 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

如您从图像中看到的,它并不完美,但做得很好:道路被正确检测到,护栏和树木也相当不错,行人也被检测到,但不是很好。当然我们可以改进这一点。

让我们看看另一张有问题的图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.11_B16322.jpg

图 9.11 – 从左到右:RGB 图像、Carla 的真实情况、彩色分割掩码和叠加分割

上一张图像相当具有挑战性,因为道路昏暗,汽车也是暗的,但网络在检测道路和汽车方面做得相当不错(尽管形状不是很好)。它没有检测到车道线,但实际上在道路上并不明显,所以这里的真实情况过于乐观。

让我们再看另一个例子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.12_B16322.jpg

图 9.12 – 从左至右:RGB 图像、Carla 的地面真实情况、彩色分割掩码和叠加分割

在这里,结果也不算差:道路和树木都被很好地检测到,交通标志也被相当好地检测到,但它没有看到车道线,这是一个具有挑战性但可见的线条。

为了确保它确实可以检测到车道线,让我们看看一个不那么具有挑战性的图像:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.13_B16322.jpg

图 9.13 – 从左至右:RGB 图像、彩色分割掩码和叠加分割

我没有这张图像的地面真实情况,这也意味着尽管它是从与训练数据集相同的批次中拍摄的,但它可能略有不同。在这里,网络表现非常好:道路、车道线、人行道和植被都被很好地检测到。

我们已经看到该网络表现尚可,但当然我们应该添加更多样本,既包括同一轨迹的样本,也包括其他轨迹的样本,以及不同天气条件下的样本。不幸的是,这意味着训练将更加耗时。

尽管如此,我认为大约有一千张图像的这种结果是一个好结果。但如果你无法在数据集中获得足够的样本呢?让我们学习一个小技巧。

改进不良语义分割

有时候事情不会像你希望的那样进行。也许为数据集获取大量样本成本太高,或者花费太多时间。或者可能没有时间,因为你需要尝试给一些投资者留下深刻印象,或者存在技术问题或其他类型的问题,你被一个不良网络和几分钟的时间困住了。你能做什么?

好吧,有一个小技巧可以帮助你;它不会将一个不良网络变成一个良好的网络,但它仍然可以比什么都没有好。

让我们来看一个来自不良网络的例子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.14_B16322.jpg

图 9.14 – 恶劣训练的网络

它的验证准确率大约为 80%,并且大约使用了 500 张图像进行训练。这相当糟糕,但由于满是点的区域,它看起来比实际情况更糟糕,因为这些区域网络似乎无法确定它在看什么。我们能通过一些后处理来修复这个问题吗?是的,我们可以。你可能还记得从第一章OpenCV 基础和相机标定,OpenCV 有几种模糊算法,特别是中值模糊,它有一个非常有趣的特性:它选择遇到的颜色的中值,因此它只发出它在分析的少数像素中已经存在的颜色,并且它非常有效地减少盐和胡椒噪声,这正是我们正在经历的。所以,让我们看看将这个算法应用到之前图像上的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_9.15_B16322.jpg

图 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 模块图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.1_B16322.jpg

图 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。系统的响应在以下图中展示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.2_B16322.jpg

图 10.2 – 步进响应示例

我们可以看到,这个系统和控制器组合产生了一个响应,它将超过设定点并在其周围振荡,直到响应最终稳定在设定点值。

另一个与本书更相关的控制系统示例将是自动驾驶汽车的巡航控制系统。在这种情况下,汽车当前的速度是 CV,期望的速度是设定点,汽车和电机的物理动力学是过程。你将看到如何实现巡航控制器以及转向控制器。

现在,让我们了解 PID 控制中的比例、积分和微分控制术语的含义。

全油门加速!

你是否曾经想过在驾驶时如何决定踩油门踏板的力度?

哪些因素决定了你踩油门的力度?

–这是你的速度与你想更快速度的对比吗?

–这和您接近目标速度的速度有多快有关吗?

–你是否不断检查你的速度以确保你没有偏离目标速度?

当我们快速浏览接下来的几节内容时,考虑所有这些。

在我们开始讨论 P、I 和 D 控制术语之前,我们需要定义增益是什么。

增益是一个缩放因子,用于在总控制输入中对控制项进行加权或减权。

理解比例的含义

在巡航控制示例中,你试图匹配你的汽车速度到一个设定点速度。设定点速度和你的汽车当前速度之间的差异被称为误差。当汽车速度低于设定点时,误差为正,当它高于设定点时,误差为负:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_001.png

比例控制项只是将 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_002.png 乘以一个称为比例增益的缩放因子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_003.png

这意味着误差越大,对过程的控制输入就越大,或者在这种情况下,油门输入就越大。这说得通,对吧?

让我们用一些实际数字来验证这一点。首先,让我们定义油门从 0 到 100%的变化。接下来,让我们将其映射到一辆汽车的加速度上,比如特斯拉 Model X 的加速度,为 37 m/s²。疯狂模式!所以,100%的油门提供了 37 m/s²的加速度。

如果你的设定点速度是 100 km/h,而你从 0 km/h 开始,那么你当前的误差是 100 km/h。然后,如果你想以 100 km/h 的误差实现最大加速度,你可以将你的比例增益设置为 1:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_004.png

随着速度误差的减小,油门也会减小,直到你达到误差为零时的零油门输入。

等等!零油门意味着我们在滑行。如果没有摩擦、空气阻力等情况,这将非常有效,但我们都知道情况并非如此。这意味着你永远不会真正保持在目标速度,而会振荡在目标速度以下,给我们留下一个稳态偏差:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.3_B16322.jpg

图 10.3 – 比例控制器中的稳态偏差

哦不——我们如何保持目标速度?别担心,我们有一个强大的积分器可以帮助我们提高速度。

理解积分项

介绍强大的积分器!PID 中的积分器项旨在解决系统中的任何稳态偏差。它是通过整合系统中的所有过去误差来做到这一点的。实际上,这意味着它在每个时间步长上累加我们看到的所有误差:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_005.png

然后,我们用增益 KI 缩放总误差,就像我们处理比例项时做的那样。然后,我们将这个结果作为控制输入到系统中,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_006.png

在你的定速巡航控制示例中,Model X 的速度只是短暂地达到了设定点速度,然后迅速下降到以下,因为比例输入变为零,空气阻力减慢了它的速度。这意味着如果你在时间上累加所有误差,你会发现它们始终是正的,并且继续增长。

因此,随着时间的推移,积分器项,total_error速度,变得越大。这意味着如果你选择 KI 适当,即使瞬时误差为零,油门命令也会大于零。记住,我们累加所有控制项以获得总油门输入。到目前为止,我们有 P 和 I 项,它们给我们以下结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_007.png

太好了!现在你正在围绕设定点振荡,而不是低于它。但你可能会问,我们如何防止速度设定点的持续超调和欠调,并稳定在平滑的油门应用上? 我想你永远不会问这个问题!

导数控制来救命!

导数项

你必须克服的最后难题是在接近设定点时调整油门,而不会超过它。导数项通过根据你接近设定点的速度调整油门来帮助解决这个问题。从误差的角度来看,这意味着误差的变化率,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_008.png

当先前的公式简化后,我们得到以下公式,其中 d 表示变化:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_009.png

如果误差在减小——这意味着你正在接近设定点——导数项将是负的。这意味着导数项将试图减少你的总油门,因为油门现在由所有 P、I 和 D 控制项的总和给出。以下方程展示了这一点:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_010.png

好吧,你现在知道 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 问题的许多你已学到的概念和参数:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.4_B16322.jpg

图 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。你应该会看到一个看起来像这样的模拟器环境窗口弹出:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.5_B16322.jpg

图 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 来计算控制输入。回想一下,这个数学计算如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_011.png

如果值是正的,则命令油门,否则命令刹车:

        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 来计算控制输入。我们还没有看到转向的情况,但它与速度的工作方式相同:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Formula_10_012.png

负的转向角度简单意味着向左转,而正的则意味着向右转

        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

你应该会看到一个新窗口弹出,其外观如下截图所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_10.6_B16322.jpg

图 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 了?我敢肯定你可以做到,但你可能会每隔几公里就停下来,向当地人询问接下来的几个方向,以便更接近那个大胆而质朴的两美元一瓶的查克。但你可以看到地图为什么真的让我们的生活变得更轻松,并为我们探索新地方提供了可能性,几乎不用担心迷路,最终到达瓦利世界。

现在,你非常幸运,像谷歌和苹果这样的公司已经不辞辛劳地绘制了你所能想到的每条街道、小巷和支路。这是一项巨大的任务,我们每天都在从中受益。万岁,地图!

定位

好的,现在想象一下你被传送到这里:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.1_B16322.jpg

图 11.1 – 俄罗斯猴脸。图片来源:bit.ly/6547672-17351141

你已经得到了该地区的地图,需要找到通往最近的水体的路。在你因为传送而颤抖停止后,你需要做的第一件事是确定你在地图上的位置。你可能会环顾四周,以辨认附近的标志,然后尝试在地图上找到这些标志。“猴脸,我正处在猴脸的正中央!”恭喜你,你已经在地图上定位了自己,并可以使用它来找到生命的灵丹妙药!

现在你明白为什么在导航世界和环境时,既需要地图又需要定位。

现在,你说,“但是等等,如果自从地图生成以来世界发生了变化呢?!”

一定有你在开车时,跟随手机上导航甜美的声音,突然“砰!”你撞到了一些正在施工的道路,道路被封闭,迫使你绕行 30 分钟。你对你的手机大喊,“诅咒你,来自虚无的导航声音!你怎么不知道有施工呢?”

事实是,无论你的导航语音多么更新,总会错过关于世界的实时信息。想象一下一些鸭子正在过马路;语音永远不会警告你这一点。在下一节中,你将学习到许多使用各种类型的制图和定位来拯救鸭子的方法。

制图和定位的类型

定位和制图领域绝对充满了惊人的研究,并且一直在不断发展。GPU 和计算机处理速度的进步导致了某些非常激动人心的算法的发展。

快点,让我们回到拯救我们的鸭子!回想一下上一节,我们亲爱的卫星导航语音没有看到在我们面前过马路的鸭子。由于世界一直在变化和演变,地图永远不会完全准确。因此,我们必须有一种方法,不仅能够使用预先构建的地图进行定位,而且还能实时构建地图,以便我们可以在地图上看到新障碍物的出现,并绕过它们。为鸭子介绍 SLAM(不是 dunks)。

虽然有独立的制图和定位方法,但在本章中,我们将重点介绍同时定位与制图SLAM)。如果你好奇的话,以下是一些最常用的独立定位和制图算法的快速概述:

以下是一些示例类型的建图:

关于这些算法和实现有很多很好的信息,但在这本书中,我们将重点关注最广泛使用的定位和建图形式,即同时进行的:SLAM。

同时定位与建图(SLAM)

让我们暂时回到我们的想象中。

想象一下,你突然在一个晚上醒来,周围一片漆黑,没有月光,没有萤火虫——只有一片漆黑!别担心,你将使用 SLAM 的魔法从床边导航到获取美味的午夜小吃!

你摸索着你的左手,直到感觉到床的边缘。砰,你刚刚在床上定位了自己,并在心中绘制了床的左侧。你假设你在睡觉时没有在床上垂直翻转,所以这确实是你的床的左侧。

接下来,你将双腿从床边放下,慢慢地降低身体,直到你感觉到地板。砰,你刚刚绘制了你地板的一部分。现在,你小心翼翼地站起来,将手臂伸向前方。你像海伦·凯勒寻找蜘蛛网一样,在你面前摆动你的手臂,形成 Lissajous 曲线。同时,你小心翼翼地在地板上扫动你的脚,就像一个现代的诠释舞者寻找任何步骤、过渡、边缘和陷阱,以免摔倒。

每当你向前移动时,你都要仔细地在心里记录你面向的方向和走了多远(里程计)。始终,你正在构建一个心理地图,并用你的手和脚作为范围传感器,给你一种你在房间中的位置感(定位)。每次你发现障碍物,你都会将它存储在你的心理地图中,并小心翼翼地绕过它。你正在进行 SLAM!

SLAM 通常使用某种测距传感器,例如激光雷达传感器:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.2_B16322.jpg

图 11.2 – OS1-128 数字激光雷达传感器,由 Ouster, Inc. 提供

当你在房间内导航时,你的手臂和腿就像你的测距仪。激光雷达传感器使用激光光束,它照亮环境并从物体上反射回来。光从发出到返回的时间被用来通过光速估计到物体的距离。例如 OS1-128 这样的激光雷达传感器,可以产生丰富且密集的点云,具有高度精确的距离信息:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.3_B16322.jpg

图 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 算法都有一个应对这种漂移的技巧:闭环检测!闭环检测的工作原理是这样的。假设你在前往阿布扎德的旅途中经过了阿达尔大楼:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.4_B16322.jpg

图 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窗口,其外观类似于以下图示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.5_B16322.jpg

图 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

这就是输出结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.6_B16322.jpg

图 11.6 – 住宅区的 2D X 射线地图

打开你的第二个奖品

哇!你自己的概率网格 2D 地图!

$ xdg-open os1_townhomes_cartographer.bag_probability_grid.png

这就是输出结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.7_B16322.jpg

图 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

这就是输出结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.8_B16322.jpg

图 11.8 – 在 CloudCompare 中查看的点云 3D 地图

看看图 11.8图 11.9,它们展示了使用 CloudCompare 查看器查看的 3D 点云地图的样子:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/hsn-vis-bhv-slfdrv-car/img/Figure_11.9_B16322.jpg

图 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.

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值