一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)

目录

一 . 基本介绍

二、模拟平台安装和基本使用

三、基于OpenCV的自动驾驶控制

3.1基于HSV空间的特定颜色区域提取

3.2基于canny算子的边缘轮廓提取

3.3感兴趣区域定位

3.4基于霍夫变换的线段检测

3.5动作控制:转向角

四、基于深度学习的自动驾驶控制

4.1 算法原理

4.2 数据采集

4.3 模型训练和验证

4.3.1 代码结构组织

4.3.2 训练

4.3.3 批量验证

4.4 模型单张图片测试

4.5 系统集成

五.真实自动驾驶小车开发(树莓派小车+神经网络计算棒NCS2)

5.1 硬件环境

5.2 小车基本控制和摄像头测试

5.2.1 基本运动控制

5.2.2 树莓派和PC电脑间图像传输

5.3 数据采集

5.4 训练和验证

5.5 基于树莓派和神经计算棒部署

5.5.1 OpenVINO介绍

5.5.2 部署流程

5.5.3 导出onnx模型

5.5.4 在Windows10上安装OpenVINO套件

5.5.5 转换onnx模型为IR模型

5.5.6 在树莓派上实现神经计算棒推理

六.小结

参考文献


一 . 基本介绍

众所周知,自动驾驶技术已成为汽车圈一个风口浪尖的热词。相较于传统汽车,自动驾驶汽车拥有诸多显著优势,一是自动驾驶汽车能够缓解道路拥堵、提升交通安全;二是自动驾驶汽车能为用户提供更便利更好的出行体验;三是自动驾驶技术将带动整个汽车产业链发生巨变,催生交通出行新生态。如今,无论是科技巨头还是传统车企都在钻研自动驾驶的相关技术,国外典型代表有特斯拉、谷歌、Uber,国内的有百度、蔚来、华为,小鹏,滴滴,小米等。

自动驾驶汽车是一项艰巨而复杂的任务,涉及硬件、软件、AI算法于一体,开发一套完整的自动驾驶汽车系统需要多个领域的工程师、研究者协同合作才可能完成。那么我们个人是否能够搭建一套类似的简易自动驾驶小车,领略下自动驾驶的乐趣呢? 接下来的一系列内容就围绕这个任务来实现。本文将会一步步的讲解如何搭建一套比较真实的、基于深度学习算法驱动的自动驾驶小车。

本文使用纯视觉方案实现一个端到端的能够在规定道路上行驶的自动驾驶小车,通过USB摄像头拍摄路面图像,实时分析并规划车辆转向角度。

考虑到成本因素,本文会分别使用模拟驾驶平台和真实的树莓派小车来实现。如果没有树莓派小车的读者可以通过模拟平台来学习。实际上不管是平台仿真还是真实树莓派小车环境,整个实现思路和方法都是一样的,只不过在树莓派小车上实现难度要大一些,因为需要额外的硬件支持,并且需要自己去调试、组装小车和布置道路环境。

为了开发方便,本文使用Python语言进行全流程开发,如果对Python语言不熟悉的读者可以先学习下Python基础语法再来学习本文内容。

本文使用OpenCV作为基本的图像处理工具,使用Pytorch作为深度学习框架。同样的,如果不熟悉这两个工具的读者可以先去官网学习下OpenCV和Pytorch的基本教程。

本文所有代码和数据均开源,下载地址(包含了模拟器、所有训练数据、完整代码):

链接:https://pan.baidu.com/s/1yhIQpKCJlUqbU6xWzHiY9A 
提取码:ln2a 

具体效果如下视频所示(第1个是在模拟环境中通过深度学习实现,第2个是真实的使用树莓派搭建的深度学习自动驾驶小车):

基于深度学习的自动驾驶小车

基于纯视觉的端到端自动驾驶小车,使用pytorch和神经网络计算棒实现 完整教程请在csdn上搜索“钱彬的博客 一文掌握基于深度学习的自动驾驶小车开发”

二、模拟平台安装和基本使用

下载地址:Releases · tawnkramer/gym-donkeycar · GitHub

该地址中提供的模拟器是基于Unity开发的,是经过删减过后的可执行程序,不再需要额外安装unity,下载下来后就可以直接运行。目前覆盖windows、Linux、Mac共3个版本。本文为了简单教学,只讲解如何在windows平台上运行和使用该模拟器。

具体的,下载Windows平台对应的DonkeySimWin.zip压缩包,解压后内容如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第1张图片

双击运行其中的donkey_sim.exe即可启动模拟器。

主界面如下所示:

该模拟器中提供了很多不同的赛道,在模拟器左侧是相关设置,可以设置不同的视角等。这里我们选择最简单的赛道generated road,因为这个赛道没有障碍物且跟我们真实高速公路环境比较像,上手比较容易。我们先设置左侧的Settings如下:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第2张图片

注意我们设置了paceCar并且勾选了manualDriving,这样我们就可以自己手动操作小车了,不需要使用内置的自动驾驶模式。 

接下来单击generated road,进入具体的场景:

 在场景中,如果我们前面主界面使用了手工模式(paceCar处勾选manualDriving),那么我们就可以通过键盘来操控小车进行体验了。与一般的赛车游戏类似(qq飞车、跑跑卡丁车等),W键表示前进,A表示左转,D表示右转,S表示后退。

在该模拟器中,控制小车的主要是两个参数:油门(W和S键)和转向角度(A和D键),这个与我们真实驾驶的汽车基本一致:挂挡+踩油门来控制前进动力,打方向盘控制车辆转向。为了能够实现自动驾驶,我们首先要能够根据这两个参数去控制模拟器里面小车的运行。我们怎么样通过Python代码来控制这个模拟器呢?

这个模拟器的好处就在于预留了Python控制接口,我们只需要安装一个驱动库就可以直接驱动模拟器里面的小车运行(提前安装好Git工具):

pip install git+https://github.com/tawnkramer/gym-donkeycar

安装好以后我们可以运行下面的python代码来实现小车的控制(注意:运行下面的代码前先启动模拟器,并停留在模拟器主界面上):

# 导入库
import gym
import gym_donkeycar
import numpy as np
import cv2


# 设置模拟器环境
env = gym.make("donkey-generated-roads-v0")

# 重置当前场景
obv = env.reset()

# 运行100帧
for t in range(100):
    # 定义控制动作
    action = np.array([0.3,0.5]) # 动作控制,0.3表示转向,0.5表示油门
    # 执行动作
    obv, reward, done, info = env.step(action)
    # 取一张图像保存
    if t == 20:
        img = cv2.cvtColor(obv,cv2.COLOR_RGB2BGR)
        cv2.imwrite('test.jpg',img)

# 运行完以后重置当前场景
obv = env.reset()

我们先分析下这段代码。下面这行代码用于设置模拟器环境,简单来说就是启用哪张地图:

env = gym.make("donkey-generated-roads-v0")

在这个模拟器里面我们可以用到的地图如下所示:

  • "donkey-warehouse-v0"
  • "donkey-generated-roads-v0"
  • "donkey-avc-sparkfun-v0"
  • "donkey-generated-track-v0"
  • "donkey-roboracingleague-track-v0"
  • "donkey-waveshare-v0"
  • "donkey-minimonaco-track-v0"
  • "donkey-warren-track-v0"
  • "donkey-thunderhill-track-v0"
  • "donkey-circuit-launch-track-v0"

接下来的代码里面,我们运行了100帧,每帧都用固定的控制参数来执行:右转0.3、前进0.5。这两个字段就是我们前面提到的转向和油门值。下面给出这两个值的具体定义:

油门值取值范围是[-1,1],负值代表倒退,正值代表前进。转向值取值范围也是[-1,1],负值代表向左,正值代表向右。

接下来使用np.array封装这两个参数,然后通过env.step来执行单步动作。执行完动作以后会返回一些信息,其中我们需要重点关注obs这个返回参数,这个参数表示当前位于小车正中间行车记录仪摄像头返回的一帧图像 ,图像宽160像素,高120像素,3通道RGB图像。如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第3张图片

由于本文主要使用摄像头图像数据来控制小车运行,因此上述代码中我们抽取了一张图像并保存到本地用来分析并测试算法。

通过上述代码,我们就可以使用python调整两个参数[油门值、转向值]来控制小车的运行,并且可以得到小车每次运行后的图像数据。实现了这样一个逻辑,我们自然就可以通过建立自动驾驶模型,逐帧分析图像,然后控制小车的这两个参数来实现小车的自动驾驶。

本小节内容重点使读者重新熟悉下python基本使用方法,同时熟悉下这个小车驾驶模拟器,接下来我们将正式进入自动驾驶算法研发环节。

三、基于OpenCV的自动驾驶控制

在学习自动驾驶前,我们先看看传统算法是怎么解决上面这个任务的。只有综合比较了传统算法和深度学习算法,我们才能真正体会到深度学习的强大能力。

本小节,我们将使用传统图像处理算法进行行道线检测等步骤来控制小车运行在行道线内。一方面帮助读者巩固下基本的opencv图像处理技术,另一方面可以更清晰的认识这个任务难点,为后面实现基于深度学习的自动驾驶做好铺垫。

具体的,我们希望通过算法来控制小车,最终让这个小车稳定运行在行车道内。这里面涉及到两方面:感知和动作规划感知部分我们主要通过行道线检测来实现,动作规划通过操控转向角度来实现。行道线检测的目的就是希望能够根据检测到的行道线位置来计算最终应该转向的角度,从而控制小车始终运行在当前车道线内。

由于道路环境比较简单,针对我们这个任务,我们进一步简化我们的控制变量,我们只控制转向角度,对于油门值我们在运行时保持低匀速,这样我们的重点就可以放在一个变量上面—转向角度

3.1基于HSV空间的特定颜色区域提取

颜色过滤是目前经常被使用到的图像处理技巧之一,例如天气预报抠像等,经常会使用绿幕作为背景进行抠图。本小节使用颜色过滤来初步提取出行道线。

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第4张图片

从模拟平台的图像数据上进行分析,小车左侧是黄实线,右侧是白实线。我们希望小车一直运行在这两根线之间。因此,我们首先要定位出这两根线。我们可以通过颜色空间变换来定位这两根线。

为了方便将黄色线和白色线从图像中过滤出来,我们需要将图像从RGB空间转换到HSV空间再处理。

这里首先我们解释下RGB和HSV颜色空间的区别。

RGB 是我们接触最多的颜色空间,由三个通道表示一幅图像,分别为红色(R),绿色(G)和蓝色(B)。这三种颜色的不同组合可以形成几乎所有的其他颜色。RGB 颜色空间是图像处理中最基本、最常用、面向硬件的颜色空间,比较容易理解。RGB 颜色空间利用三个颜色分量的线性组合来表示颜色,任何颜色都与这三个分量有关,而且这三个分量是高度相关的,所以连续变换颜色时并不直观,想对图像的颜色进行调整需要更改这三个分量才行。自然环境下获取的图像容易受自然光照、遮挡和阴影等情况的影响,即对亮度比较敏感。而 RGB 颜色空间的三个分量都与亮度密切相关,即只要亮度改变,三个分量都会随之相应地改变,而没有一种更直观的方式来表达。但是人眼对于这三种颜色分量的敏感程度是不一样的,在单色中,人眼对红色最不敏感,蓝色最敏感,所以 RGB 颜色空间是一种均匀性较差的颜色空间。如果颜色的相似性直接用欧氏距离来度量,其结果与人眼视觉会有较大的偏差。对于某一种颜色,我们很难推测出较为精确的三个分量数值来表示。所以,RGB 颜色空间适合于显示系统,却并不适合于图像处理。

基于上述理由,在图像处理中使用较多的是 HSV 颜色空间,它比 RGB 更接近人们对彩色的感知经验。非常直观地表达颜色的色调、鲜艳程度和明暗程度,方便进行颜色的对比。在 HSV 颜色空间下,比 BGR 更容易跟踪某种颜色的物体,常用于分割指定颜色的物体。HSV 表达彩色图像的方式由三个部分组成:

  • Hue(色调、色相)
  • Saturation(饱和度、色彩纯净度)
  • Value(明度)

其中Hue用角度度量,取值范围为0~360°,表示色彩信息,即所处的光谱颜色的位置,如下图所示。

Hue色调取值图

 如果我们想要过滤出黄色线,那么我们就可以将色调范围控制在[30~90]之间即可。注意,在OpenCV中色调范围是[0~180],因此上述黄色范围需要缩小1倍,即[15~45]。检测白色行道线也是采用类似的原理。

下面我们用代码实现一下:

import cv2
import numpy as np

#读取图像并转换到HSV空间
frame = cv2.imread('test.jpg')
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

# 黄色线检测
lower_blue = np.array([15, 40, 40])
upper_blue = np.array([45, 255, 255])
yellow_mask = cv2.inRange(hsv, lower_blue, upper_blue)
cv2.imwrite('yellow_mask.jpg',yellow_mask)

# 白色线检测
lower_blue = np.array([0, 0, 200])
upper_blue = np.array([180, 30, 255])
white_mask = cv2.inRange(hsv, lower_blue, upper_blue)
cv2.imwrite('white_mask.jpg',white_mask)

效果如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第5张图片

 可以看到,黄色区域和白色区域基本都检测出来了,当然还存在不少干扰区域,需要进一步处理。

3.2基于canny算子的边缘轮廓提取

目前我们仅获得了行道线区域,为了后续能够方便的计算行道线角度,我们需要得到行道线具体的轮廓/线段信息,即从区域中提取出线段。这里我们使用Canny算法实现。

Canny边缘检测是从不同视觉对象中提取有用的结构信息并大大减少要处理的数据量的一种技术,于1986年被提出,目前已广泛应用于各种计算机视觉系统。

Canny算法具体包括5个步骤:

1)        使用高斯滤波器,以平滑图像,滤除噪声。

2)        计算图像中每个像素点的梯度强度和方向。

3)        应用非极大值(Non-Maximum Suppression)抑制,以消除边缘检测带来的杂散响应。

4)        应用双阈值(Double-Threshold)检测来确定真实的和潜在的边缘。

5)        通过抑制孤立的弱边缘最终完成边缘检测。

具体实现细节我们不再详细剖析,在OpenCV中集成了canny算法,只需要一行代码即可实现canny边缘检测。

# 黄色线边缘提取
yellow_edge = cv2.Canny(yellow_mask, 200, 400)
cv2.imwrite('yellow_edge.jpg', yellow_edge)

# 白色线边缘提取white
white_edge = cv2.Canny(white_mask, 200, 400)
cv2.imwrite('white_edge.jpg', white_edge)

代码中200和400这两个参数表示canny算子的低、高阈值,按照opencv教程一般可以不用修改。

最终效果如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第6张图片

3.3感兴趣区域定位

在利用OpenCV对图像进行处理时,通常会遇到一个情况,就是只需要对部分感兴趣区域(Region Of Interest, ROI)进行处理。例如针对我们这个模拟平台上的智能小车任务来说,对于黄色行道线,我们只关注图像左下部分,而对于白色行道线,我们只关注图像右下部分即可。至于图像其他部分因为我们通过人工分析知道,这些区域我们并不需要处理。因此,针对黄色边缘我们只需要提取图像左下块区域,针对白色边缘我们只需要提取图像右下块区域。

def region_of_interest(edges, color='yellow'):
    '''
    感兴趣区域提取
    '''
    height, width = edges.shape
    mask = np.zeros_like(edges)
    # 定义感兴趣区域掩码轮廓
    if color == 'yellow':
        polygon = np.array([[(0, height * 1 / 2),
                             (width * 1 / 2, height * 1 / 2),
                             (width * 1 / 2, height), 
                             (0, height)]], np.int32)
    else:
        polygon = np.array([[(width * 1 / 2, height * 1 / 2),
                             (width, height * 1 / 2), 
                             (width, height),
                             (width * 1 / 2, height)]], np.int32)
    # 填充感兴趣区域掩码
    cv2.fillPoly(mask, polygon, 255)
    # 提取感兴趣区域
    croped_edge = cv2.bitwise_and(edges, mask)
    return croped_edge

最终效果如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第7张图片

 到这里我们看到行道线区域基本定位的比较“干净”了。

3.4基于霍夫变换的线段检测

到目前,我们抽取出了比较精确的行道线轮廓,但是对于实际的自动驾驶任务来说还没有完成目标任务要求,我们要对行道线轮廓再进一步处理,得到行道线的具体线段信息(每条线段的起始点坐标)。本小节我们使用霍夫变换来完成这个任务。霍夫变换,英文名称Hough Transform,作用是用来检测图像中的直线或者圆等几何图形的。

具体的,一条直线的表示方法有好多种,最常见的是y=mx+b的形式。结合我们这个任务,对于最终检测出的感兴趣区域,怎么把图片中的直线提取出来。基本的思考流程是:如果直线 y=mx+b 在图片中,那么图片中,必需有N多点在直线上(像素点代入表达式成立),只要有这条直线上的两个点,就能确定这条直线。该问题可以转换为:求解所有的(m,b)组合。

设置两个坐标系,左边的坐标系表示的是(x,y)值,右边的坐标系表达的是(m,b)的值,即直线的参数值。那么一个(x,y)点在右边对应的就是一条线,左边坐标系的一条直线就是右边坐标系中的一个点。这样,右边左边系中的交点就表示有多个点经过(k,b)确定的直线。但是,该方法存在一个问题,(m,b)的取值范围太大。

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第8张图片为了解决(m,b)取值范围过大的问题,在直线的表示方面用 xcosθ+ysinθ=p 的规范式代替一般表达式,参数空间变成(θ,p),0=<θ<=2PI。这样图像空间中的一个像素点在参数空间中就是一条曲线(三角函数曲线)。

  霍夫线段检测算法原理步骤如下:

  • 初始化(θ,p)空间,N(θ,p)=0 。(N(θ,p)表示在该参数表示的直线上的像素点的个数)
  • 对于每一个像素点(x,y),在参数空间中找出令 xcosθ+ysinθ=p 的(θ,p)坐标,N(θ,p)+=1
  • 统计所有N(θ,p)的大小,取出N(θ,p)>threasold的参数 。(threadsold是预设的阈值)

OpenCV中封装好了基于霍夫变换的直线检测方法HoughLinesP,下面我们就来使用它进行线段检测。

def detect_line(edges):
    '''
    基于霍夫变换的直线检测
    '''
    rho = 1  # 距离精度:1像素
    angle = np.pi / 180  #角度精度:1度
    min_thr = 10  #最少投票数
    lines = cv2.HoughLinesP(edges,
                            rho,
                            angle,
                            min_thr,
                            np.array([]),
                            minLineLength=8,
                            maxLineGap=8)
    return lines

我们可以打印返回的lines查看内容:

[[[  1  94  47  62]]
 [[143  94 156 103]]
 [[103  67 119  77]]
 [[  1  86  41  60]]
 [[101  52 158  56]]
 [[104  69 159 100]]
 [[  5  52  22  53]]
 [[129  63 140  63]]
 [[ 87  50 110  52]]
 [[  0  88  17  77]]
 [[ 88  55 134  89]]
 [[  2  94  36  70]]
 [[ 17  50  29  50]]
 [[ 23  73  42  60]]
 [[ 90  56 110  70]]
 [[  1  56  16  51]]
 [[128  55 148  56]]
 [[  0  89   8  84]]
 [[ 88  56 112  75]]
 [[151 101 159 104]]
 [[ 30  73  43  61]]]

返回的每组值都是一条线段表示线段起始位置(x_start,y_start,x_end,y_end)。可以看到小线段很多,我们对这些小线段做一下聚类和平均:

def average_lines(frame, lines, direction='left'):
    '''
    小线段聚类
    '''
    lane_lines = []
    if lines is None:
        print(direction + '没有检测到线段')
        return lane_lines
    height, width, _ = frame.shape
    fits = []

    for line in lines:
        for x1, y1, x2, y2 in line:
            if x1 == x2:
                continue
            # 计算拟合直线
            fit = np.polyfit((x1, x2), (y1, y2), 1)
            slope = fit[0]
            intercept = fit[1]
            if direction == 'left' and slope < 0:
                fits.append((slope, intercept))
            elif direction == 'right' and slope > 0:
                fits.append((slope, intercept))
    if len(fits) > 0:
        fit_average = np.average(fits, axis=0)
        lane_lines.append(make_points(frame, fit_average))
    return lane_lines

这里需要注意,由于图像的y坐标跟我们数学上经常遇到的y坐标方向是相反的(图像的y坐标轴正向是朝下的),因此,左侧黄色实线斜率是负值,右侧白色实线斜率是正值。上述代码我们将所有小线段的斜率和截距进行了平均,并且使用make_points函数重新计算了该平均线对应到图像上的起始坐标位置,make_points函数如下所示:

def make_points(frame, line):
    '''
    根据直线斜率和截距计算线段起始坐标
    '''
    height, width, _ = frame.shape
    slope, intercept = line
    y1 = height
    y2 = int(y1 * 1 / 2)
    x1 = max(-width, min(2 * width, int((y1 - intercept) / slope)))
    x2 = max(-width, min(2 * width, int((y2 - intercept) / slope)))
    return [[x1, y1, x2, y2]]

上述函数最后返回的是坐标数值,这样看线段的坐标值不是很直观,我们可以写个脚本显式的观察这些线段:

def display_line(frame, lines, line_color=(0, 0, 255), line_width=2):
    '''
    在原图上展示线段
    '''
    line_img = np.zeros_like(frame)
    if lines is not None:
        for line in lines:
            for x1, y1, x2, y2 in line:
                cv2.line(line_img, (x1, y1), (x2, y2), line_color, line_width)
    line_img = cv2.addWeighted(frame, 0.8, line_img, 1, 1)
    return line_img

上述代码我们将行道线按照一定权重与原图进行合成,方便我们查看最终效果。

最终检测效果如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第9张图片

 从效果上看我们准确的将两条行道线检测了出来。接下来就是根据这两条行道线进行自动驾驶方向控制。

3.5动作控制:转向角

针对前面的测试图片,我们可以有效的检测出两条行道线(左侧黄色线和右侧白色线),但是在真实的运行过程中,可能会出现3种情况:

(1)正常检测到2条行道线:这种情况一般是直线车道且车辆稳定运行在行道线内,这时候我们只需要根据检测出的两条行道线微调整角度即可。

(2)检测出1条行道线:这种情况在转弯处容易出现,或者在车辆开始大范围偏离时出现,这时候我们的策略应该是向能够检测到的这条行道线方向前进。

(3)检测不到行道线:这种情况应该停下小车。

因此,针对三种情况我们需要不同的处理方式。代码如下所示:

# 计算转向角
x_offset = 0
y_offset = 0
if len(yellow_lane)>0 and len(white_lane)>0:  # 检测到2条线
    _, _, left_x2, _ = yellow_lane[0][0]
    _, _, right_x2, _ = white_lane[0][0]
    mid = int(width / 2)
    x_offset = (left_x2 + right_x2) / 2 - mid
    y_offset = int(height / 2)
elif len(yellow_lane)>0 and len(yellow_lane[0])==1:  # 只检测到黄色行道线
    x1, _, x2, _ = yellow_lane[0][0]
    x_offset = x2 - x1
    y_offset = int(height / 2)
elif len(white_lane)>0 and len(white_lane[0])==1:  # 只检测到白色行道线
    x1, _, x2, _ = white_lane[0][0]
    x_offset = x2 - x1
    y_offset = int(height / 2)
else: # 一条线都没检测到
    print('检测不到行道线,退出程序')
    break

angle_to_mid_radian = math.atan(x_offset / y_offset)  
angle_to_mid_deg = int(angle_to_mid_radian * 180.0 / math.pi) 
steering_angle = angle_to_mid_deg/45.0
action = np.array([steering_angle, 0.3])  # 油门值恒定

到这里我们就可以开始启动程序了。完整代码如下所示:

# 导入系统库
import cv2
import numpy as np
import math
import gym
import gym_donkeycar

# 导入自定义库
from tools import region_of_interest,detect_line,make_points,average_lines,display_line


def main():
    '''
    主函数
    '''
    # 设置模拟器环境
    env = gym.make("donkey-generated-roads-v0")

    # 重置当前场景
    obv = env.reset()

    # 开始启动
    action = np.array([0, 0.3])  # 动作控制,第1个转向值,第2个油门值

    # 执行动作
    obv, reward, done, info = env.step(action)

    # 获取图像
    frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR)

    # 运行1000次动作
    for t in range(1000):
        # 转换图像到HSV空间
        height, width, _ = frame.shape
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

        # 黄色区域检测
        lower_blue = np.array([15, 40, 40])
        upper_blue = np.array([45, 255, 255])
        yellow_mask = cv2.inRange(hsv, lower_blue, upper_blue)
    
        # 白色区域检测
        lower_blue = np.array([0, 0, 200])
        upper_blue = np.array([180, 30, 255])
        white_mask = cv2.inRange(hsv, lower_blue, upper_blue)
    
        # 黄色线边缘提取
        yellow_edge = cv2.Canny(yellow_mask, 200, 400)
        
        # 白色线边缘提取
        white_edge = cv2.Canny(white_mask, 200, 400)

        # 黄色线感兴趣区域提取
        yellow_roi = region_of_interest(yellow_edge, color='yellow')

        # 白色线感兴趣区域提取
        white_roi = region_of_interest(white_edge, color='white')

        # 黄色线段检测
        yellow_lines = detect_line(yellow_roi)
        yellow_lane = average_lines(frame, yellow_lines, direction='left')
        #yellow_show = display_line(frame, yellow_lane)

        # 白色线段检测
        white_lines = detect_line(white_roi)
        white_lane = average_lines(frame, white_lines, direction='right')
        #white_show = display_line(frame, white_lane, line_color=(255, 0, 0))

        # 计算转向角
        x_offset = 0
        y_offset = 0
        if len(yellow_lane)>0 and len(white_lane)>0:  # 检测到2条线
            _, _, left_x2, _ = yellow_lane[0][0]
            _, _, right_x2, _ = white_lane[0][0]
            mid = int(width / 2)
            x_offset = (left_x2 + right_x2) / 2 - mid
            y_offset = int(height / 2)
        elif len(yellow_lane)>0 and len(yellow_lane[0])==1:  # 只检测到黄色行道线
            x1, _, x2, _ = yellow_lane[0][0]
            x_offset = x2 - x1
            y_offset = int(height / 2)
        elif len(white_lane)>0 and len(white_lane[0])==1:  # 只检测到白色行道线
            x1, _, x2, _ = white_lane[0][0]
            x_offset = x2 - x1
            y_offset = int(height / 2)
        else: # 一条线都没检测到
            print('检测不到行道线,退出程序')
            break

        angle_to_mid_radian = math.atan(x_offset / y_offset)  
        angle_to_mid_deg = int(angle_to_mid_radian * 180.0 / math.pi) 
        steering_angle = angle_to_mid_deg/45.0
        action = np.array([steering_angle, 0.3])  # 油门值恒定

        # 执行动作
        obv, reward, done, info = env.step(action)

        # 重新获取图像
        frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR)

    # 运行完以后重置当前场景
    obv = env.reset()


if __name__ == '__main__':
    '''
    主函数入口
    '''
    main()

下面是实际执行效果:

基于OpenCV的自动驾驶小车模拟平台控制

可以看到我们通过简单的图像处理方法实现了这样模拟平台上的小车控制。

注意:如果当前生成的赛道有“十字路口交叉”(每次重新进入赛道其生成的赛道都是随机绘制的),那么在运行的时候可能会出现失败、跑出赛道的现象。因为这种十字路口我们在程序中没有考虑。如何规避这个问题,有兴趣的读者可以自行研究。

本文更多的关注基于深度学习的图像处理技术,对于传统的图像处理算法(例如霍夫变换等)本文不再深入分析,读者如果对这些传统图像处理算法不熟悉的可以自行再查阅资料深入研究。

截止到目前为止,我们借助上面这个基于opencv的自动驾驶模拟平台,我们重新巩固了Python、opencv图像处理的基本使用方法,了解了自动驾驶项目的难点,对整个处理流程有了更进一步的认识。需要说明的是,尽管我们上述操作步骤是针对我们这个自动驾驶模拟平台的,但是以上步骤同样适用于很多其他图像处理任务,很多传统的图像处理任务都涵盖颜色空间变换、特定颜色物体提取、感兴趣区域过滤、霍夫变换等步骤,因此掌握上述常规的图像处理技术是非常重要的。

四、基于深度学习的自动驾驶控制

在上一节中我们通过OpenCV图像处理技术实现了一个简易的自动驾驶小车。但是很明显,这辆自动驾驶小车的适应性很差,当图像中有相同颜色的干扰物出现时,那么对于这辆自动驾驶的小车来说就是顶级灾难。另外,我们需要大量人工定义的参数,例如行道线颜色(黄色或白色)、颜色阈值、霍夫变换阈值等,而且一旦地图环境换了,所有这些参数我们都得重新手工调整,这些参数之间又有一定的耦合性,参数调整很麻烦。很显然,这种处理方法普适性不好。

那么能不能丢给机器一大堆图片,让机器自己去学习如何从当前图像中分析出小车应该转向的合适角度?如果没有接触过深度学习,那么乍一听这个想法简直是天方夜谭,然而深度学习确实做到了。这就是为什么近十年深度学习在图像处理领域取得了全面成功。深度学习能够从大量图像数据中自行学习高层次语义特征,完成媲美人类甚至超越人类的推理水平,整个学习过程不用人为干预,我们要做的就是“喂”一堆图片并且设定好需要优化的目标函数即可。当我们“喂”的图片越多、种类越丰富,那么最终机器学习到的驾驶水平越强,而且适应性越好。

本小节开始我们将正式进入基于深度学习的自动驾驶领域。

4.1 算法原理

本项目实现思路参考2016年英伟达发表的论文《End to End Learning for Self-Driving Cars》。这篇文章提出的方法核心思想就是使用神经网络自动提取图像特征,从传统的 image -> features -> action变成了image -> action。该论文使用了深度网络结构,大大增强了图像特征提取能力,最终取得了不错的效果,其训练的模型不论是普通道路还是高速路,不论有道路标线还是没有道路标线都非常有效,解决了传统算法泛化性能差的问题。本文方法的测试性能非常好,在16年自动驾驶研究火热时,是一篇影响力很大的文章,即使放到现在,也是作为自动驾驶入门必读的Paper。

整个算法原理很简单,是对真实人类操作的一个模拟。对于我们人类驾驶员来说,假设我们正在驾驶这辆车,我们的执行流程跟上面算法也是一样的。首先我们用眼睛观看路面,然后我们的大脑根据当前眼睛看到的路面情况“下意识”的转动方向盘,转动一个我们认为合适的角度,从而避免车辆开出路面。这篇论文算法实现原理也是这样,具体如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第10张图片

 通过中间摄像头采集图像,然后图像输入到预先训练好的CNN网络,这个网络的输出是一个转向角度(可以理解为方向盘的转向角度),有了这个角度就可以控制小车按照这个角度进行转向。

有了这样一个模式,我们就只需要想办法训练这个CNN模型,针对每帧图像,都有一个我们认为合适的转向角度输出,即输入图像,输出一个回归值。具体模型结构如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第11张图片

 整个模型结构并不复杂,就是一堆的普通的CNN卷积神经网络模块按照顺序堆叠,最后使用全连接网络输出回归值。这个模型一共包含30层,由于其输入精度比较低(66x200),因此推理速度也是比较快的,借助GPU可以实现实时推理。具体的,图像首先经过Normalization标准化,然后经过5组卷积层处理,最后拉平以后通过4个全连接层输出一个回归值,这个回归值就是我们项目中的转向角。

这里我们会遇到一个问题,训练上述深度神经网络我们需要大量的数据,即每帧图像以及对应的最佳转向值,这些数据怎么来呢?这篇论文里提出了一个方法,既然是模拟人类行为,那么只要让驾驶水平高超的“老司机”在相关赛道上进行手动驾驶,驾驶时一边记录每帧图像同时记录当前帧对应的操控的转向角,这样一组组数据记录下来就是我们认为的“最佳”训练数据。训练时,将模型预测的角度与给定图像帧的期望转向角度进行比较,误差通过反向传播反馈到CNN训练过程中,如下图所示。从图可以看出,这个过程在一个循环中重复,直到误差(本例中使用均方误差)足够低,这意味着模型已经学会了如何合理地转向。事实上,这是一个非常典型的图像分类训练过程,只不过这里预测输出是数值(回归值)而不是对象类别(分类概率)。

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第12张图片

 可以想象,如果能够完全的训练好这个模型,那么最终模型的输出结果是非常接近人类驾驶经验的。这篇论文通过大量实验证明,上述模型能够直接从拍摄的路面图像中有效的学习到最终的转向角,省去了传统算法颜色区域检测、感兴趣区域选择、霍夫变换等一系列复杂的耦合步骤。这篇论文做了一组实验,通过收集不到一百小时的少量训练数据进行训练,最后得到的模型足以支持在各种条件下操控车辆,比如高速公路、普通公路和居民区道路,以及晴天、多云和雨天等天气状况。

需要说明的是,这个模型的输出仅有一个转向角度,这样容易学习成功。如果输出变量再多一些(例如油门值、摄像头角度、行人避障等),那么这个模型还需要再进一步优化,感兴趣的读者可以借鉴近两年的论文进行深入研究(毕竟真正的自动驾驶是very very复杂的,要考虑的情况非常多)。
接下来我们就按照这个算法流程进行实现。

4.2 数据采集

针对我们采用的自动驾驶模拟平台,为了能够采集到每帧图像及对应的最佳转向角度,我们可以使用前面第2节方法编写控制代码通过键盘控制小车(低匀速运行,仅仅只需要控制转向角度),然后记录每帧数据即可。这种模式是真实自动驾驶使用的,但是需要我们自己把自己练成经验充足的“老司机”,然后再去教会算法怎么驾驶。这样比较麻烦,这里可以有一种“偷懒”的办法。我们使用前面调参调的不错的OpenCV自动驾驶版本,使用OpenCV算法自动驾驶,然后记录每帧图像及对应角度。尽管这个OpenCV自动驾驶水平本身也一般(没有一直控制在两条行道线的绝对正中间),但是胜在能够基本稳定在行道线内。本文只是一个自动驾驶入门项目,可以采用这样的方法收集数据,来快速验证深度学习自动驾驶可行性。真实项目的话还是需要向“老司机”学习的。

完整采集代码如下:

# 导入系统库
import cv2
import numpy as np
import math
import gym
import gym_donkeycar

# 导入自定义库
from tools import region_of_interest, detect_line, make_points, average_lines, display_line


def main():
    '''
    主函数
    '''
    # 设置模拟器环境
    env = gym.make("donkey-generated-roads-v0")

    # 重置当前场景
    obv = env.reset()

    # 开始启动
    action = np.array([0, 0.3])  # 动作控制,第1个转向值,第2个油门值

    # 执行动作
    obv, reward, done, info = env.step(action)

    # 获取图像
    frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR)

    # 运行4000次动作
    pic_index = 0
    for t in range(4000):
        # 转换图像到HSV空间
        height, width, _ = frame.shape
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

        # 黄色区域检测
        lower_blue = np.array([15, 40, 40])
        upper_blue = np.array([45, 255, 255])
        yellow_mask = cv2.inRange(hsv, lower_blue, upper_blue)

        # 白色区域检测
        lower_blue = np.array([0, 0, 200])
        upper_blue = np.array([180, 30, 255])
        white_mask = cv2.inRange(hsv, lower_blue, upper_blue)

        # 黄色线边缘提取
        yellow_edge = cv2.Canny(yellow_mask, 200, 400)

        # 白色线边缘提取
        white_edge = cv2.Canny(white_mask, 200, 400)

        # 黄色线感兴趣区域提取
        yellow_roi = region_of_interest(yellow_edge, color='yellow')

        # 白色线感兴趣区域提取
        white_roi = region_of_interest(white_edge, color='white')

        # 黄色线段检测
        yellow_lines = detect_line(yellow_roi)
        yellow_lane = average_lines(frame, yellow_lines, direction='left')
        #yellow_show = display_line(frame, yellow_lane)

        # 白色线段检测
        white_lines = detect_line(white_roi)
        white_lane = average_lines(frame, white_lines, direction='right')
        #white_show = display_line(frame, white_lane, line_color=(255, 0, 0))

        # 计算转向角
        x_offset = 0
        y_offset = 0
        if len(yellow_lane) > 0 and len(white_lane) > 0:  # 检测到2条线
            _, _, left_x2, _ = yellow_lane[0][0]
            _, _, right_x2, _ = white_lane[0][0]
            mid = int(width / 2)
            x_offset = (left_x2 + right_x2) / 2 - mid
            y_offset = int(height / 2)
        elif len(yellow_lane) > 0 and len(yellow_lane[0]) == 1:  # 只检测到黄色行道线
            x1, _, x2, _ = yellow_lane[0][0]
            x_offset = x2 - x1
            y_offset = int(height / 2)
        elif len(white_lane) > 0 and len(white_lane[0]) == 1:  # 只检测到白色行道线
            x1, _, x2, _ = white_lane[0][0]
            x_offset = x2 - x1
            y_offset = int(height / 2)
        else:  # 一条线都没检测到
            print('检测不到行道线,退出程序')
            break

        angle_to_mid_radian = math.atan(x_offset / y_offset)
        angle_to_mid_deg = int(angle_to_mid_radian * 180.0 / math.pi)
        steering_angle = angle_to_mid_deg / 45.0
        action = np.array([steering_angle, 0.1])  # 油门值恒定

        # 记录当前图像和转向角度
        img_path = "log/{:d}_{:.4f}.jpg".format(pic_index, steering_angle)
        cv2.imwrite(img_path, frame)
        pic_index += 1

        # 执行动作
        obv, reward, done, info = env.step(action)

        # 重新获取图像
        frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR)

    # 运行完以后重置当前场景
    print('结束本次采集')
    obv = env.reset()


if __name__ == '__main__':
    '''
    主函数入口
    '''
    main()

我们将每帧图像对应的“最佳”转向角度以文件名的形式保存,最终收集图片如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第13张图片

 图片名采用“图片帧号_转向角度.jpg”的形式命名。上述代码每次跑完会在log目录下生成4000多张图片。由于每次的地图都是随机生成的,因此我们可以多跑几次,多收集一些数据。

最终共采集10个文件夹图片,总共4万张图片:

 接下来我们需要对这些图片进行整理,拆分数据集用于训练和验证。

详细脚本代码create_data_lists.py如下:

# 导入系统库
import os
import random


def creat_data_list(dataset_path, file_list, mode='train'):
    '''
    创建txt文件列表
    '''
    with open(os.path.join(dataset_path, (mode + '.txt')), 'w') as f:
        for (imgpath, angle) in file_list:
            f.write(imgpath + ' ' + str(angle) + '\n')
    print(mode + '.txt 已生成')


def getFileList(dir, Filelist, ext=None):
    """
    获取文件夹及其子文件夹中文件列表
    输入 dir: 文件夹根目录
    输入 ext: 扩展名
    返回: 文件路径列表
    """
    newDir = dir
    if os.path.isfile(dir):
        if ext is None:
            Filelist.append(dir)
        else:
            if ext in dir[-3:]:
                Filelist.append(dir)

    elif os.path.isdir(dir):
        for s in os.listdir(dir):
            newDir = os.path.join(dir, s)
            getFileList(newDir, Filelist, ext)

    return Filelist


def main():
    '''
    主函数
    '''
    # 设置参数
    org_img_folder = './data/simulate'  # 数据集根目录
    train_ratio = 0.8  # 训练集占比

    # 检索jpg文件
    jpglist = getFileList(org_img_folder, [], 'jpg')
    print('本次执行检索到 ' + str(len(jpglist)) + ' 个jpg文件\n')

    file_list = list()
    # 解析转向值
    for jpgpath in jpglist:
        print(jpgpath)
        curDataDir = os.path.dirname(jpgpath)
        basename = os.path.basename(jpgpath)
        angle = (basename[:-4]).split('_')[-1]
        imgPath = os.path.join(curDataDir, basename).replace("\\", "/")
        file_list.append((imgPath, angle))

    # 切分数据
    random.seed(256)
    random.shuffle(file_list)
    train_num = int(len(file_list) * train_ratio)
    train_list = file_list[0:train_num]
    val_list = file_list[train_num:]

    # 创建列表文件
    creat_data_list(org_img_folder, train_list, mode='train')
    creat_data_list(org_img_folder, val_list, mode='val')


if __name__ == "__main__":
    '''
    程序入口
    '''
    main()

上述代码我们查找每个log文件夹下的jpg文件,然后解析出对应的转向值。将这些值最后分别保存到train.txt和val.txt文件中。在代码里面,我们设定训练集占比0.8,剩下的0.2则为验证集。

生成的train.txt和val.txt文件每行内容表示一个样本,由图片路径和转向值组成,中间用空格隔开,如下所示:

./data/simulate/log3/1932_0.0667.jpg 0.0667

到这里,我们就把基本数据准备工作做好了。

接下来我们将使用Pytoch框架实现深度学习算法进行训练、验证。

4.3 模型训练和验证

4.3.1 代码结构组织

为了方便读者阅读、运行和修改代码,本文深度学习部分将采用比较简单的代码组织方式。完整结构如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第14张图片

项目根目录下有7个.py文件和3个文件夹,下面对各个文件和文件夹进行简单说明。

  • create_data_lists.py:生成数据列表,解析图像路径和转向角,然后写入txt文件列表供后续PyTorch调用;
  • datasets.py:用于构建数据集加载器,主要沿用Pytorch标准数据加载器格式进行封装;
  • models.py:模型结构文件,存储自动驾驶模型的结构定义;
  • utils.py:工具函数文件,所有项目中涉及到的一些自定义函数均放置在该文件中;
  • train.py:用于训练算法;
  • eval.py:用于模型评估;
  • test.py:用于单张样本测试,运用训练好的模型为单张图像进行测试,查看角度转向是否正确;
  • data:用于存放训练和测试数据集以及文件列表;
  • results:用于存放运行结果,包括训练好的模型以及单张测试样本;
  • runs:训练时,由tensorboard记录损失值变化;
  • auto_drive:调用训练好的模型实现自动驾驶;

读者可以下载本文代码和数据集进行查看和运行,整个代码运行顺序如下:

(1)运行create_data_lists.py文件用于为数据集生成文件列表;
(2)运行train.py进行算法训练,训练结束后在results文件夹中会生成checkpoint.pth模型文件;
(3)运行eval.py文件对测试集进行评估,输出均方误差;
(4)运行test.py文件对results文件夹下名为test.jpg的图像进行角度预测;

(5)运行auto_drive.py调用训练好的模型实现自动驾驶;

这里需要说明的是,本文不希望整个项目成为负担很重的“工程”,所以在代码编写上尽量手工化、傻瓜化,简洁化,方便读者后期自行魔改。

4.3.2 训练

首先定义数据采集器,位于datasets.py文件中,代码如下:

# 导入系统库
import os
import numpy as np
import cv2

# 导入PyTorch库
import torch
from torch.utils.data import Dataset
 
 
class AutoDriveDataset(Dataset):
    """
    数据集加载器
    """
 
    def __init__(self, data_folder, mode, transform=None):
        """
        :参数 data_folder: # 数据文件所在文件夹根路径(train.txt和val.txt所在文件夹路径)
        :参数 mode: 'train' 或者 'val'
        :参数 normalize_type: 图像归一化处理方式
        """
 
        self.data_folder = data_folder
        self.mode = mode.lower()
        self.transform = transform
 
        assert self.mode in {'train', 'val'}
 
        # 读取图像列表路径
        if self.mode == 'train':
            file_path=os.path.join(data_folder, 'train.txt')            
        else:
            file_path=os.path.join(data_folder, 'val.txt')
        
        self.file_list=list()      
        with open(file_path, 'r') as f:
            files = f.readlines()
            for file in files:
                if file.strip() is None:
                    continue
                self.file_list.append([file.split(' ')[0],float(file.split(' ')[1])])
                
 
    def __getitem__(self, i):
        """
        :参数 i: 图像检索号
        :返回: 返回第i个图像和标签
        """
        # 读取图像
        img = cv2.imread(self.file_list[i][0])
        img = cv2.cvtColor(img,cv2.COLOR_BGR2HSV)
        if self.transform:
            img = self.transform(img)
        # 读取标签
        label = self.file_list[i][1]
        label = torch.from_numpy(np.array([label])).float()
        return img, label
 
    def __len__(self):
        """
        为了使用PyTorch的DataLoader,必须提供该方法.
        :返回: 加载的图像总数
        """
        return len(self.file_list)

上述代码比较简单,我们构造了AutoDriveDataset类用于作为自动驾驶小车数据读取类,从train.txt和val.txt中根据每行内容得到每个样本的图像路径和对应的真值标签。这里需要注意下颜色空间,我们最终是使用HSV空间进行训练的,因此需要做一下转化。

有了数据读取类以后我们就下来定义模型,具体代码如下:

# 导入PyTorch库
import torch.nn as nn
import torch.nn.functional as F


class AutoDriveNet(nn.Module):
    '''
    端到端自动驾驶模型
    '''

    def __init__(self):
        """
        初始化
        """
        super(AutoDriveNet, self).__init__()
        self.conv_layers = nn.Sequential(nn.Conv2d(3, 24, 5, stride=2),
                                         nn.ELU(),
                                         nn.Conv2d(24, 36, 5, stride=2),
                                         nn.ELU(),
                                         nn.Conv2d(36, 48, 5, stride=2),
                                         nn.ELU(), nn.Conv2d(48, 64, 3),
                                         nn.ELU(), nn.Conv2d(64, 64, 3),
                                         nn.Dropout(0.5))
        self.linear_layers = nn.Sequential(
            #nn.Linear(in_features=64 * 2 * 33, out_features=100),
            nn.Linear(in_features=64 * 8 * 13, out_features=100),
            nn.ELU(),
            nn.Linear(in_features=100, out_features=50),
            nn.ELU(),
            nn.Linear(in_features=50, out_features=10),
            nn.Linear(in_features=10, out_features=1))

    def forward(self, input):
        '''
        前向推理
        '''
        input = input.view(input.size(0), 3, 120, 160)
        output = self.conv_layers(input)
        output = output.view(output.size(0), -1)
        output = self.linear_layers(output)
        return output

这里需要注意的是我们的模型跟论文里的稍微有点不一样,主要是因为我们的图像尺寸是120x160的,而论文里使用的是66x200。因此,我们对应的输入需要调整下,另外,在最后全连接层也相应的在维度上要调整。对于实际项目来说,现在很多的摄像头都是使用3:4分辨率的,例如树莓派摄像头典型的分辨率是480x640,因此,修改过后的模型更具有普遍性,方便后面迁移到真实环境训练。

整个模型比较简单,前面是多个cnn,最后接几个全连接网络,输入是3通道图像,输出是一个转向回归值。

训练脚本代码train.py如下:

# 导入torch库
import torch.backends.cudnn as cudnn
import torch
from torch import nn
import torchvision.transforms as transforms
from torch.utils.tensorboard import SummaryWriter

# 导入自定义库
from models import AutoDriveNet
from datasets import AutoDriveDataset
from utils import *


def main():
    """
    训练.
    """
    # 数据集路径
    data_folder = './data/simulate'

    # 学习参数
    checkpoint = None  # 预训练模型路径,如果不存在则为None
    batch_size = 400  # 批大小
    start_epoch = 1  # 轮数起始位置
    epochs = 1000  # 迭代轮数
    lr = 1e-4  # 学习率

    # 设备参数
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    ngpu = 4  # 用来运行的gpu数量
    cudnn.benchmark = True  # 对卷积进行加速
    writer = SummaryWriter()  # 实时监控     使用命令 tensorboard --logdir runs  进行查看

    # 初始化模型
    model = AutoDriveNet()

    # 初始化优化器
    optimizer = torch.optim.Adam(params=filter(lambda p: p.requires_grad,
                                               model.parameters()),
                                 lr=lr)

    # 迁移至默认设备进行训练
    model = model.to(device)
    criterion = nn.MSELoss().to(device)

    # 加载预训练模型
    if checkpoint is not None:
        checkpoint = torch.load(checkpoint)
        start_epoch = checkpoint['epoch'] + 1
        model.load_state_dict(checkpoint['model'])
        optimizer.load_state_dict(checkpoint['optimizer'])

    # 单机多卡训练
    if torch.cuda.is_available():
        model = nn.DataParallel(model, device_ids=list(range(ngpu)))

    # 定制化的dataloader
    transformations = transforms.Compose([
        transforms.ToTensor(),  # 通道置前并且将0-255RGB值映射至0-1
        # transforms.Normalize(
        #     mean=[0.485, 0.456, 0.406],  # 归一化至[-1,1] mean std 来自imagenet 计算
        #     std=[0.229, 0.224, 0.225])
    ])

    train_dataset = AutoDriveDataset(data_folder,
                                     mode='train',
                                     transform=transformations)
    train_loader = torch.utils.data.DataLoader(train_dataset,
                                               batch_size=batch_size,
                                               shuffle=True,
                                               num_workers=0,
                                               pin_memory=True)

    # 开始逐轮训练
    for epoch in range(start_epoch, epochs + 1):

        model.train()  # 训练模式:允许使用批样本归一化
        loss_epoch = AverageMeter()  # 统计损失函数
        n_iter = len(train_loader)

        # 按批处理
        for i, (imgs, labels) in enumerate(train_loader):

            # 数据移至默认设备进行训练
            imgs = imgs.to(device)
            labels = labels.to(device)

            # 前向传播
            pre_labels = model(imgs)

            # 计算损失
            loss = criterion(pre_labels, labels)

            # 后向传播
            optimizer.zero_grad()
            loss.backward()

            # 更新模型
            optimizer.step()

            # 记录损失值
            loss_epoch.update(loss.item(), imgs.size(0))

            # 打印结果
            print("第 " + str(i) + " 个batch训练结束")

        # 手动释放内存
        del imgs, labels, pre_labels

        # 监控损失值变化
        writer.add_scalar('MSE_Loss', loss_epoch.avg, epoch)
        print('epoch:' + str(epoch) + '  MSE_Loss:' + str(loss_epoch.avg))

        # 保存预训练模型
        torch.save(
            {
                'epoch': epoch,
                'model': model.module.state_dict(),
                'optimizer': optimizer.state_dict()
            }, 'results/checkpoint.pth')

    # 训练结束关闭监控
    writer.close()


if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

本文使用4卡进行训练(Nvidia T4)。在训练过程中我们使用tensorboard进行损失函数变化监控,可以使用下面的命令安装tensorboard:

pip install tensorboard

在训练过程中,可以使用下面的命令启动tensorboard查看:

tensorboard --logdir=runs

训练时batch_size设置为100,如果读者的显卡out of memory则该小这个batch_size。训练结果如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第15张图片

可以看到整个训练过程还是比较平稳正常的,在最终epoch=1000的时候基本处在一个比较好的收敛位置。

训练完成后的文件大小为9.20M,这个模型体量不大,适合嵌入式部署。

4.3.3 批量验证

使用eval.py文件进行精度验证:

# 导入系统库
import time

# 导入PyTorch库
import torch
from torch import nn
import torch.backends.cudnn as cudnn
import torchvision.transforms as transforms

# 导入自定义库
from datasets import AutoDriveDataset
from models import AutoDriveNet
from utils import *


def main():
    # 测试集目录
    data_folder = "./data/simulate"
    
    # 定义运行的GPU数量
    ngpu = 1
    
    #cudnn.benchmark = True
    
    # 定义设备运行环境
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
    # 加载预训练模型
    checkpoint = torch.load("./results/checkpoint.pth")
    model = AutoDriveNet()
    model = model.to(device)
    model.load_state_dict(checkpoint['model'])
 
    # 多GPU封装
    if torch.cuda.is_available():
        model = nn.DataParallel(model, device_ids=list(range(ngpu)))
   
    # 定制化的dataloader
    transformations = transforms.Compose([
        transforms.ToTensor(),  # 通道置前并且将0-255RGB值映射至0-1
        # transforms.Normalize(
        #     mean=[0.485, 0.456, 0.406],  # 归一化至[-1,1] mean std 来自imagenet 计算
        #     std=[0.229, 0.224, 0.225])
    ])
    val_dataset = AutoDriveDataset(data_folder,
                                     mode='val',
                                     transform=transformations
                                     )
    
    val_loader = torch.utils.data.DataLoader(val_dataset, batch_size=1, shuffle=False, num_workers=1,
                                            pin_memory=True)
    
    # 定义评估指标
    criterion = nn.MSELoss().to(device)

    # 记录误差值
    MSEs = AverageMeter()

    # 记录测试时间
    model.eval()
    start = time.time()

    with torch.no_grad():
        # 逐批样本进行推理计算
        for i, (imgs, labels) in enumerate(val_loader):
            
            # 数据移至默认设备进行推理
            imgs = imgs.to(device)
            labels = labels.to(device)   

            # 前向传播
            pre_labels = model(imgs)

            # 计算误差
            loss = criterion(pre_labels, labels)     
            MSEs.update(loss.item(), imgs.size(0))
            
    # 输出平均均方误差
    print('MSE  {mses.avg: .3f}'.format(mses=MSEs))
    print('平均单张样本用时  {:.3f} 秒'.format((time.time()-start)/len(val_dataset)))

 
if __name__ == '__main__':
    '''
    程序入口
    '''
    main()
    

最终的评估结果如下:

MSE   0.001
平均单张样本用时  0.002 秒

由于我们的这个转向角度范围是[-1,1],因此这个MSE相对来说还是比较低的。

4.4 模型单张图片测试

本小节我们再进一步直观的来查看模型效果。我们在验证集里随机选择一张角度偏大的图片,如下所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第16张图片

  可以看到这个时候明显的小车需要大力左转,这张图像对应的真值转向值为:-0.5556,确实是在全力左转中。下面我们用训练好的深度学习模型验证下。

使用test.py文件进行预测:

# 导入OpenCV库
import cv2

# 导入PyTorch库
from torch import nn
import torch

# 导入自定义库
from models import AutoDriveNet
from utils import *


def main():
    '''
    主函数
    '''
    # 测试图像
    imgPath = './results/test.jpg'

    # 推理环境
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载训练好的模型
    checkpoint = torch.load('./results/checkpoint.pth')
    model = AutoDriveNet()
    model = model.to(device)
    model.load_state_dict(checkpoint['model'],strict=False)

    # 加载图像
    img = cv2.imread(imgPath)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

    # 图像预处理
    # PIXEL_MEANS = (0.485, 0.456, 0.406)  # RGB格式的均值和方差
    # PIXEL_STDS = (0.229, 0.224, 0.225)
    img = torch.from_numpy(img.copy()).float()
    img /= 255.0
    # img -= torch.tensor(PIXEL_MEANS)
    # img /= torch.tensor(PIXEL_STDS)
    img = img.permute(2, 0, 1)
    img.unsqueeze_(0)

    # 转移数据至设备
    img = img.to(device)

    # 模型推理
    model.eval()
    with torch.no_grad():
        prelabel = model(img).squeeze(0).cpu().detach().numpy()
        print('预测结果  {:.3f} '.format(prelabel[0]))


if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

预测结果为-0.417。应该说还是基本吻合的,毕竟考虑到实际的话,-0.55等价于方向盘左打55,-0.41等价于方向盘左打41,从趋势上看能把这辆快要跑出去的小车“拽”回来。

4.5 系统集成

结合第三节的内容,我们可以使用PyTorh逐帧分析图像,然后直接给出转向值用于小车控制,不再需要复杂的、分散的图像处理步骤。

前面4.4节已经实现了单张图片预测,只需要把4.4节的代码和第三节的控制代码合并即可实现。

运行auto_drive.py文件,其完整代码如下:

# 导入系统库
import cv2
import numpy as np
import math
import gym
import gym_donkeycar

# 导入PyTorch库
from torch import nn
import torch

# 导入自定义库
from models import AutoDriveNet
from utils import *


def main():
    '''
    主函数
    '''
    # 设置模拟器环境
    env = gym.make("donkey-generated-roads-v0")
    
    # 设置推理环境
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载训练好的模型
    checkpoint = torch.load('./results/checkpoint.pth')
    model = AutoDriveNet()
    model = model.to(device)
    model.load_state_dict(checkpoint['model'])

    # 重置当前场景
    obv = env.reset()

    # 开始启动
    action = np.array([0, 0.1])  # 动作控制,第1个转向值,第2个油门值

    # 执行动作并获取图像
    img, reward, done, info = env.step(action)
    img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)

    # 运行5000次动作
    model.eval()
    for t in range(5000):
        
        # 图像预处理
        img = torch.from_numpy(img.copy()).float()
        img /= 255.0
        img = img.permute(2, 0, 1)
        img.unsqueeze_(0)

        # 转移数据至设备
        img = img.to(device)

        # 模型推理
        steering_angle = 0
        factor=1
        with torch.no_grad():
            # 计算转向角度
            steering_angle = (model(img).squeeze(0).cpu().detach().numpy())[0]
            if steering_angle*factor<-1:
                steering_angle=-1
            elif steering_angle*factor>1:
                steering_angle=1
            else:
                steering_angle=steering_angle*factor
            print(steering_angle)
            action = np.array([steering_angle, 0.1])  # 油门值恒定

            # 执行动作并更新图像
            img, reward, done, info = env.step(action)
            img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)

    # 运行完以后重置当前场景
    obv = env.reset()


if __name__ == '__main__':
    '''
    主函数入口
    '''
    main()

最终完整的运行视频如本文开头所示。

从视频效果上看,通过深度学习的自动驾驶小车其操控流畅性感觉上超过了它的“师傅”OpenCV版本。可能的原因在于纯粹的OpenCV图像处理方法对每帧单独处理,没有一个整体的去噪概念,容易在某一帧出现偏差。但是基于深度学习的方法更多的是学习整个数据集的操作体验,某种意义上做了一定的概率去噪,或者说是平均化,因此,整个的操控才会显得更加流畅。

深度学习确实是YYDS。

到这里我们已经完成了所有算法的预研和实现。所有数据和代码下载地址见本文开头。到这里本文所有核心内容已全部讲解完毕。接下来的内容主要结合前面的代码在真实硬件平台上重新实现下,如果读者对硬件没有兴趣,可以不用学习下面的内容。

五.真实自动驾驶小车开发(树莓派小车+神经网络计算棒NCS2)

前面的内容我们主要在模拟平台上实现自动驾驶小车开发。尽管整个开发流程在模拟平台环境和真实环境上异曲同工,但是,没有在真实环境中部署一套自己研发的自动驾驶小车总会感觉有一点缺憾。

接下来我们将进入真实小车开发环节。下面的内容会结合特定硬件进行讲解,会涉及一些相关的小车配件,感兴趣的读者可以参照本文进行实现。

5.1 硬件环境

为了方便,本文使用Sunfounder PiCar-X作为小车控制平台。PiCar-X是一款人工智能驱动的自动驾驶小车,用树莓派开发板作为控制中心。PiCar-X具备双轴摄像头模块、超声波模块。当然本文不需要这么多传感器模块,我们只需要基本的运动控制和摄像头即可,因此在安装的时候可以简单点,超声测距、循迹之类的传感器不安装也可以。

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第17张图片

之所以选用PiCar-X,主要是因为它的集成组件比较适合本文研究路线:后轮驱动前进、前轮负责转向。并且它的整体售价适中,(除去树莓派以外整体大概在700元左右)。具体开发文档见:Welcome to PiCar-X’s Documentation! — SunFounder picar-x documentation

这里需要说明的是该小车套装买回来后是散件,需要自己组装(另外,自己还需要额外买两节18650 电池)。受限于内容和篇幅,本文不再详细讲解如何拼装PiCar-X,读者可以按照官方教程进行拼装(官方教程有很详细的拼装说明图纸和视频教程)。个人感觉参考视频教程更有用。

Component List and Assembly Instructions — SunFounder picar-x documentation

树莓派的操作系统安装以及SSH配置本文不再赘述,相关教程非常多,本文使用官方推荐的32位 操作系统版本。树莓派选择当前最新的4b版本

读者也可以自行制作小车,从完整的电机、电路设计、机械等都手工打磨。我们要的核心功能无非就是小车能够根据角度控制转向,能够按照固定油门匀速前进,另外再加一个摄像头功能即可。怎么组装完全可以由读者自由DIY。由于本人并不是硬件出身,短期内没这个能力,只能买现成品组装了。

考虑到摄像头视野问题,本文在实际使用时做了一些调整,更换了picarx的摄像头,使用了一个支持135度视角、无畸变的USB摄像头,并且摄像头是固定死的,不需要电机进行角度控制。另外多余的传感器也都没装。具体如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第18张图片

 在道路环境布置上,本文使用深绿色绝缘胶带在阳台自己贴了个小赛道,颜色区分度还是比较明显的,用来作为行道线完全合适。如下图所示。

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第19张图片

到这里硬件环境都准备好了。

5.2 小车基本控制和摄像头测试

5.2.1 基本运动控制

基本的运动控制我们可以参考官方网站。下面给出基本的运动控制代码。这里我们使用了官方网站提供好的运动库Picarx。如果读者是自己研发的小车,那么需要自己编写驱动软件。

from picarx import Picarx
import time


def main():
    '''
    主程序,基本程序控制测试
    '''
    px = Picarx()
    try:
        while True:
            # 前进
            px.forward(5)
            time.sleep(0.5)
            # 向右摆头
            for angle in range(0, 35):
                px.set_dir_servo_angle(angle)
                time.sleep(0.02)
            # 从右向左摆头
            for angle in range(35, -35, -1):
                px.set_dir_servo_angle(angle)
                time.sleep(0.02)
            # 从左向右摆头至中间位
            for angle in range(-35, 0):
                px.set_dir_servo_angle(angle)
                time.sleep(0.02)
    except KeyboardInterrupt:
        px.forward(0)
        px.set_dir_servo_angle(0)


if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

5.2.2 树莓派和PC电脑间图像传输

针对摄像头我们需要分析小车运行过程中的图像,下面的脚本用于测试摄像头并且可以捕获图片。由于我是使用SSH来远程控制小车,因此,为了能够从树莓派小车上高效稳定的传输图像到我的上位机PC软件,我使用了基于TCP的zmq库来实现,具体使用方法请参考我的另一篇博客。

具体的,下位机代码如下(树莓派端):

import cv2
import zmq
import base64


def main():
    '''
    主函数
    '''
    IP = '192.168.2.148' #上位机视频接受端的IP地址

    # 创建并设置视频捕获对象
    cap = cv2.VideoCapture(0)
    print("摄像头是否已经打开 ? {}".format(cap.isOpened()))
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)  # 设置图像宽度
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)  # 设置图像高度

    # 建立TCP通信协议
    contest = zmq.Context()
    footage_socket = contest.socket(zmq.PAIR)
    footage_socket.connect('tcp://%s:5555'%IP)

    while True:
        # 读取图像
        ret, frame = cap.read()

        # 转换为流数据并编码
        encoded, buffer = cv2.imencode('.jpg', frame) 
        jpg_as_test = base64.b64encode(buffer) #把内存中的图像流数据进行base64编码

        # 发送数据
        footage_socket.send(jpg_as_test) #把编码后的流数据发送给视频的接收端
        cv2.waitKey(5) #延时等待,防止出现窗口无响应


if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

上位机代码如下(PC电脑端):

import cv2
import zmq
import base64
import numpy as np

def main():
    '''
    主函数
    '''
    context = zmq.Context()
    footage_socket = context.socket(zmq.PAIR)
    footage_socket.bind('tcp://*:5555')
    cv2.namedWindow('Stream',flags=cv2.WINDOW_NORMAL | cv2.WINDOW_KEEPRATIO)


    while True:
        print("监听中")
        frame = footage_socket.recv_string() #接收TCP传输过来的一帧视频图像数据
        img = base64.b64decode(frame) #把数据进行base64解码后储存到内存img变量中
        npimg = np.frombuffer(img, dtype=np.uint8) #把这段缓存解码成一维数组
        source = cv2.imdecode(npimg, 1) #将一维数组解码为图像source
        cv2.imshow("Stream", source) #把图像显示在窗口中
        cv2.waitKey(5) #延时等待,防止出现窗口无响应

 
if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

运行时先打开上位机端代码,就可以在树莓派和PC端之间高效传输图像了。

采集1张图像我们看下效果:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第20张图片

可以看到整个图像还是比较清晰地,并且摄像头能够比较好的捕获到完整的两条行道线(得益于135度无畸变摄像头)。接下来我们就可以按照第二节模拟平台上的操作步骤在真实平台上来分析和处理图像了。

5.3 数据采集

数据采集方面,我们可以按照前面的思路自己做一个遥控终端,把自己练成操控高手,然后记录每帧对应的角度值。但是和模拟平台会遇到一样的问题,这样做太麻烦了。我们还是采用之前的“偷懒”方法,以OpenCV自动驾驶版本为基准采集数据。

本文在真实树莓派小车上采集图像大小为320x240。

详细代码如下(opencv_auto_drive.py):

# 导入库函数
import cv2

# 导入小车运动库
from picarx import Picarx

# 导入自定义函数库
from tools import detect_lane, display_lines, compute_steer, stabilize_steering_angle, take_photo, ImageTrans

# 定义全局小车变量
px = Picarx()

# 创建并设置视频捕获对象
cap = cv2.VideoCapture(0)
print("摄像头是否已经打开 ? {}".format(cap.isOpened()))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)  # 设置图像宽度
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)  # 设置图像高度

# 创建图像传输管道
img_trans = ImageTrans('192.168.2.148')


def main():
    '''
    主函数
    '''
    # 定义小车初始油门、转向角和状态
    speed = 0.1
    last_steer_angle = 0
    pic_index = 0

    # 循环控制
    while True:
        # 读取图像
        ret, frame = cap.read()
        if not ret:
            print("图像获取失败,请检查")
            break

        # 检测行道线
        lane_lines = detect_lane(frame)
        # lane_lines_image = display_lines(frame, lane_lines)
        # img_trans.sendImg(lane_lines_image)

        # 计算转向角
        line_num, steer_angle = compute_steer(lane_lines, frame)
        if line_num == 0:
            take_photo(-1, frame, pic_index)  # 记录异常图像
            pic_index += 1
            continue

        # 平滑处理
        last_steer_angle = stabilize_steering_angle(last_steer_angle,
                                                    steer_angle, line_num)

        # 输出转向值
        print("\r 当前转向角度steer: %s    " % (last_steer_angle))

        # 执行动作
        px.set_dir_servo_angle(last_steer_angle)
        px.forward(speed)

        # 保存图像
        if pic_index % 5 == 0:
            take_photo(last_steer_angle, frame, pic_index)
        pic_index += 1


if __name__ == "__main__":
    '''
    程序入口
    '''
    try:
        main()
    except Exception as e:
        print("error:%s" % e)
    finally:
        px.set_dir_servo_angle(0)
        px.forward(0)
        cap.release()
        cv2.destroyAllWindows()

对应的工具处理函数脚本tools.py文件内容如下:

import cv2
import numpy as np
import math
from time import time, strftime, localtime
import zmq
import base64


def detect_edges(frame):
    '''
    检测蓝色区域边缘
    '''
    # 高斯滤波滤除小的噪点
    frame = cv2.GaussianBlur(frame,(9,9),2)
    # 特定颜色区域提取
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    lower_blue = np.array([50, 30, 30])
    upper_blue = np.array([100, 255, 255])
    #lower_blue = np.array([40, 40, 40])
    #upper_blue = np.array([70, 255, 255])
    mask = cv2.inRange(hsv, lower_blue, upper_blue)

    mask = region_of_interest(mask)

    # 连通域分析
    ret, thresh = cv2.threshold(mask, 127, 255, cv2.THRESH_BINARY)
    contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    contours_new = []
    for c in contours:
        rect = cv2.minAreaRect(c)
        w,h=rect[1]
            
        # if max(w,h)<3*min(w,h): # 长宽比不合适
        #     contours_new.append(c)
        #     continue
        if w*h < 500: # 面积太小
            contours_new.append(c)
            continue

    mask = cv2.fillPoly(mask, contours_new, (0,))
    #cv2.imwrite("mask.jpg", mask)
    edges = cv2.Canny(mask, 200, 400)
    return edges


def region_of_interest(edges):
    '''
    提取感兴趣区域(截取下半部分)
    '''
    height, width = edges.shape
    mask = np.zeros_like(edges)
    polygon = np.array([[
        (0, height * 1 / 2),
        (width, height * 1 / 2),
        (width, height),
        (0, height),
    ]], np.int32)

    cv2.fillPoly(mask, polygon, 255)
    cropped_edges = cv2.bitwise_and(edges, mask)
    return cropped_edges


def detect_line_segments(cropped_edges):
    '''
    霍夫变换检测
    '''
    rho = 1  # 距离精度, 以像素为单位
    angle = np.pi / 180  # 径向角度精度, 以度为单位
    min_threshold = 10  # 最小投票数
    line_segments = cv2.HoughLinesP(cropped_edges,
                                    rho,
                                    angle,
                                    min_threshold,
                                    np.array([]),
                                    minLineLength=8,
                                    maxLineGap=4)

    return line_segments


def make_points(frame, line):
    '''
    根据直线斜率和截距返回对应的线段两端坐标
    '''
    height, width, _ = frame.shape
    slope, intercept = line
    y1 = height
    y2 = int(y1 * 1 / 2)

    # 限制坐标在图像区域内
    x1 = max(-width, min(2 * width, int((y1 - intercept) / (slope+0.000001))))
    x2 = max(-width, min(2 * width, int((y2 - intercept) / (slope+0.000001))))
    return [[x1, y1, x2, y2]]


def average_slope_intercept(frame, line_segments):
    """
    汇聚所有线段成1段或2段
    如果所有线段斜率  slopes < 0: 只检测到左边行道线
    如果所有线段斜率  slopes > 0: 只检测到右边行道线
    """
    lane_lines = []
    if line_segments is None:
        print('没有检测到线段')
        return lane_lines

    height, width, _ = frame.shape
    left_fit = []
    right_fit = []

    boundary = 1 / 3
    left_region_boundary = width * (1 - boundary)  # 左行道线应该位于整个图像的左2/3部分
    right_region_boundary = width * boundary  # 右行道线应该位于整个图像的右2/3部分

    for line_segment in line_segments:
        for x1, y1, x2, y2 in line_segment:
            if x1 == x2:  # 忽略垂直线(没有斜率)
                continue
            fit = np.polyfit((x1, x2), (y1, y2), 1)
            slope = fit[0]
            intercept = fit[1]
            if slope < -math.tan(25):
                if x1 < left_region_boundary and x2 < left_region_boundary:
                    left_fit.append((slope, intercept))
            elif slope > math.tan(25):
                if x1 > right_region_boundary and x2 > right_region_boundary:
                    right_fit.append((slope, intercept))
   
    if len(left_fit) > 0:
        left_fit_average = np.average(left_fit, axis=0)
        lane_lines.append(make_points(frame, left_fit_average))

    if len(right_fit) > 0:
        right_fit_average = np.average(right_fit, axis=0)

        lane_lines.append(make_points(frame, right_fit_average))

    return lane_lines


def detect_lane(frame):
    '''
    检测线段
    '''
    edges = detect_edges(frame)
    cropped_edges = region_of_interest(edges)
    line_segments = detect_line_segments(cropped_edges)
    lane_lines = average_slope_intercept(frame, line_segments)

    return lane_lines


def display_lines(frame, lines, line_color=(0, 255, 0), line_width=2):
    '''
    对检测到的线段进行可视化展示
    '''
    line_image = np.zeros_like(frame)
    if lines is not None:
        for line in lines:
            for x1, y1, x2, y2 in line:
                cv2.line(line_image, (x1, y1), (x2, y2), line_color,
                         line_width)
    line_image = cv2.addWeighted(frame, 0.8, line_image, 1, 1)
    return line_image


def compute_steer(lane_lines, frame):
    height, width, _ = frame.shape
    x_offset = 0
    y_offset = 0
    line_num = 0
    steering_angle = 0
    if len(lane_lines) == 2:  # 检测到2条行道线
        _, _, left_x2, _ = lane_lines[0][0]
        _, _, right_x2, _ = lane_lines[1][0]
        mid = int(width / 2)
        x_offset = (left_x2 + right_x2) / 2 - mid
        y_offset = int(height / 2)
        line_num = 2
        print('检测到2条线')
    elif len(lane_lines) == 1:  # 检测到1条行道线
        x1, _, x2, _ = lane_lines[0][0]
        x_offset = int((x2 - x1)/1.0)
        y_offset = int(height / 2.0)
        line_num = 1
        print('检测到1条线')
    else:
        print('检测失败')
        return 0, 0

    angle_to_mid_radian = math.atan(
        x_offset / y_offset)  # angle (in radian) to center vertical line
    steering_angle = int(angle_to_mid_radian * 180.0 /
                         math.pi)  # angle (in degrees) to center vertical line
    return line_num, steering_angle


def stabilize_steering_angle(curr_steering_angle,
                             new_steering_angle,
                             num_of_lane_lines,
                             max_angle_deviation_two_lines=5,
                             max_angle_deviation_one_lane=3):
    """
    用于平稳控制小车转向
    如果当前计算出来的转向角与上一帧差距太大,则做幅度限制
    """
    new_steering_angle = int(new_steering_angle * 1.0 / 1)  # 降低转向灵敏度

    if num_of_lane_lines == 2:
        # 检测到2条线,我们用更快得调整幅度
        max_angle_deviation = max_angle_deviation_two_lines
    else:
        # 检测到1条线,我们缩小调整幅度
        max_angle_deviation = max_angle_deviation_one_lane

    angle_deviation = new_steering_angle - curr_steering_angle
    if abs(angle_deviation) > max_angle_deviation:
        stabilized_steering_angle = int(curr_steering_angle +
                                        max_angle_deviation * angle_deviation /
                                        abs(angle_deviation))
    else:
        stabilized_steering_angle = new_steering_angle

    # 限定转向值上限
    if stabilized_steering_angle > 35:
        stabilized_steering_angle = 35
    if stabilized_steering_angle < -35:
        stabilized_steering_angle = -35
    return stabilized_steering_angle


def take_photo(steer_angle, frame, pic_index):
    '''
    采集照片和对应的转向值
    '''
    _time = strftime('%Y-%m-%d-%H-%M-%S', localtime(time()))
    name = '%s' % _time
    img_path = "./log/" + name + '_photo' + str(pic_index) + '_' + str(steer_angle) + '.jpg'
    cv2.imwrite(img_path, frame)


class ImageTrans(object):
    '''
    视频图像传输
    '''
    def __init__(self, ip):
        self.contest = zmq.Context()
        self.footage_socket = self.contest.socket(zmq.PAIR)
        self.footage_socket.connect('tcp://%s:5555'%ip)
    
    def sendImg(self,img):
        '''
        发送图像给上位机
        '''
        # 转换为流数据并编码
        encoded, buffer = cv2.imencode('.jpg', img) 
        jpg_as_test = base64.b64encode(buffer) #把内存中的图像流数据进行base64编码

        # 发送数据
        self.footage_socket.send(jpg_as_test) #把编码后的流数据发送给视频的接收端

参考前面第二节模拟平台上的OpenCV自动驾驶方法,我们不再详细分步骤解释。这里只需要修改对应的颜色提取即可。模拟平台上是提取黄色和白色行道线,这里的真实环境是提取绿色行道线,本质上是一样的。另外,在实际使用过程中发现,真实环境中的噪声很多,因此额外做了一些去噪步骤,例如高斯模糊、连通域面积分析等。还有一个很重要的点就是,针对真实树莓派小车的预测转向角做了平滑处理,这样能保证小车不会出现快速“摇头”现象,使其运动更加顺畅平滑。

本文一共采集了12000张图像。具体代码请参考给出的代码包里面的opencv_auto_drive.py和tools.py文件,代码有详细的注释。这部分代码我调试了很久,整个项目在这一节上面花的时间最多,因为要综合考虑光线强度、行道线颜色、周围环境、小车硬件稳定性等。读者如果按照我的路线方案执行,那么需要仔细看懂每块代码内容,然后根据自己的小车特性进行精细调整,这个过程是非常麻烦的。

5.4 训练和验证

训练和验证的步骤参考前面的模拟平台实现,代码基本上是一样的,唯独在解析每张图片转向角稍微修改下即可,另外由于之前的模型图片输入是160x120的,而现在采集的图像是320x240的,因此读取每张图片时需要缩小1倍再训练。由于步骤几乎完全一样,本小节就不再贴出相关代码了。训练变化曲线如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第21张图片

应该说收敛速度比较快,在epoch=140时基本满足收敛了。最后在验证集上验证精度,验证结果如下:

MSE   6.281
平均单张样本用时  0.006 秒

从验证集中挑出一张图片做测试:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第22张图片

 该图片对应真值为-35,实际预测结果为-27.9,大趋势是吻合的。

接下来,我们就需要将训练好的模型正式进行部署实时推理了。

在前面的模拟平台上我们是直接使用pytorch进行每帧推理的,但是很显然,这种方式对于我们的树莓派小车来说是行不通的。当然有一种方法是树莓派实时传输图像到服务器,服务器使用pytorch推理完再将结果返回给树莓派小车,但是这种方法太耗时了,在真实的自动驾驶中是不会这么做的。树莓派本身计算能力有限,因此,为了能够赋能树莓派AI的推理能力,我们需要借助一款边缘计算设备:神经网络计算棒(NCS2)。

5.5 基于树莓派和神经计算棒部署

5.5.1 OpenVINO介绍

深度学习项目的开发大致可以分为两个阶段,第一个阶段是训练,这个阶段最重要的事情就是搞好数据采集、模型设计、训练参数调试,找到合适模型并努力训练到满足或者超过项目实际需要的精度;第二个阶段是部署,这个阶段最重要的事情就是把训练好的深度学习模型移植部署到各种不同的计算设备上,尽可能的实现模型规模的小型化,实现推理预测过程的加速。

对于我们这款树莓派小车,树莓派本身的计算能力非常有限,如果让树莓派来进行深度学习推理,其运算负载太大,实时性推理不能得到有效保证。因此,我们需要将深度学习推理的部分转接出去,树莓派只负责数据的采集和小车的机电控制,图像推理部分我们可以使用英特尔的神经计算棒Neural Compute Stick 2(NCS2)来实现,这款计算棒非常像一个U盘,如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第23张图片

 我们可以将这个小U盘一样的东西等价理解为嵌入式板子上的“深度学习显卡”,只不过这个小显卡基本不能用于算法训练,只能用于推理运算。

与英伟达显卡配套的CUDA、CUDNN驱动库一样,英特尔在2018发布加速推理框架OpenVINO,可以方便我们利用神经计算棒进行深度学习推理开发工作。

有了这款神经计算棒NCS2和对应的推理框架OpenVINO,我们就可以在树莓派小车上离线实时的进行深度学习推理了。

5.5.2 部署流程

整个部署流程如下所示:

(1)使用pytorch将训练好的pytorch模型转换为通用格式onnx;

(2)在windows/Ubuntu/Mac平台上安装OpenVINO套件;

(3)在windows/Ubuntu/Mac平台上利用OpenVINO套件对onnx模型进行转换,转换为适合计算棒推理的IR模型(xml和bin文件);

(4)在树莓派上安装OpenVINO推理引擎,利用这个引擎实现IR模型推理;

本文为了简单,使用windows平台进行OpenVINO套件安装和模型转换。下面我们按照上述流程逐个来实现。

5.5.3 导出onnx模型

首先安装onnx库:

pip install onnx

然后使用下面的代码将训练好的pth文件转换成onnx通用格式文件:

# 导入OpenCV库
import cv2

# 导入PyTorch库
from torch import nn
import torch
import torch.onnx

# 导入onnx库
import onnx

# 导入自定义库
from models import AutoDriveNet
from utils import *


def main():
    '''
    主函数
    '''
    # 测试图像
    imgPath = './results/test.jpg'

    # 推理环境
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # 加载训练好的模型
    checkpoint = torch.load('./results/checkpoint.pth')
    model = AutoDriveNet()
    model = model.to(device)
    model.load_state_dict(checkpoint['model'],strict=False)

    # 加载图像
    img = cv2.imread(imgPath)
    img = cv2.resize(img, (160,120))
    img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

    # 图像预处理
    # PIXEL_MEANS = (0.485, 0.456, 0.406)  # RGB格式的均值和方差
    # PIXEL_STDS = (0.229, 0.224, 0.225)
    img = torch.from_numpy(img.copy()).float()
    img /= 255.0
    # img -= torch.tensor(PIXEL_MEANS)
    # img /= torch.tensor(PIXEL_STDS)
    img = img.permute(2, 0, 1)
    img.unsqueeze_(0)

    # 转移数据至设备
    img = img.to(device)

    # 模型推理
    model.eval()
    with torch.no_grad():
        prelabel = model(img).squeeze(0).cpu().detach().numpy()
        torch.onnx.export(model,                     # model being run
                  img,                         # model input (or a tuple for multiple inputs)
                  "results/autodrive.onnx",       # where to save the model (can be a file or file-like object)
                  export_params=True,        # store the trained parameter weights inside the model file
                  opset_version=10,          # the ONNX version to export the model to
                  do_constant_folding=True,  # whether to execute constant folding for optimization
                  input_names = ['input'],   # the model's input names
                  output_names = ['output'], # the model's output names
                  dynamic_axes={'input' : {0 : 'batch_size'},    # variable length axes
                                'output' : {0 : 'batch_size'}})
        print('预测结果  {:.3f} '.format(prelabel[0]))


if __name__ == '__main__':
    '''
    程序入口
    '''
    main()

导出后的onnx模型为3.06兆,相对来说是一个比较小的模型,适合在嵌入式设备上推理部署。

5.5.4 在Windows10上安装OpenVINO套件

 详细安装教程请参考官网。目前英特尔对OpenVINO套件一直在全力维护,版本更新速度很快,因此,本文就不再详细阐述安装过程了,只需要跟着最新的官方文档操作即可。英特尔OpenVINO官方文档是比较详细的,建议读者后面有时间可以全面学习下OpenVINO,掌握好这门工具能够让我们在诸多工业项目上发挥作用,大幅降低人工智能终端部署成本(毕竟大部分传统工业工控机都是intel的)。

首先插入神经计算棒到电脑上(建议使用USB3.0接口),然后cd进入到安装目录下,运行下面的命令:

cd C:\"Program Files (x86)"\IntelSWTools\openvino\deployment_tools\demo
.\demo_security_barrier_camera.bat -d MYRIAD

上述代码会对IntelSWTools\openvino\deployment_tools\demo目录下的car_1.bmp图像进行推理,具体执行车辆检测、车牌检测、车牌识别功能。

运行效果如下图所示:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第24张图片

 可以看到使用了这个神经计算棒后推理速度是15.6FPS,达到了实时运算的需求。如果能够成功运行上述demo,说明已经装好openvino库了。

5.5.5 转换onnx模型为IR模型

首先在cmd窗口中激活环境变量:

\bin\setupvars.bat

然后切换到目录D:\code\openvino_2021.4.752\deployment_tools\model_optimizer下面,接下来使用下面的命令进行模型转换:

python mo.py --input_model C:\Users\64522\Desktop\autodrive\autodrive.onnx --output_dir C:\Users\64522\Desktop\autodrive\ir --input_shape (1,3,120,160)

最终转换成下面的3个文件:

一文掌握基于深度学习的自动驾驶小车开发(Pytorch实现,含完整数据和源码,树莓派+神经计算棒)_第25张图片

这三个文件就是最终放在树莓派上的模型推理文件。

5.5.6 在树莓派上实现神经计算棒推理

首先要说明,由于树莓派操作系统对应的OpenVINO库不包括模型优化器(模型优化器可以将onnx转换为ir),因此,一般是使用另一台x86电脑使用模型优化器转换模型,然后再在树莓派上进行应用程序开发。前面我们已经在windows电脑上将模型转换完毕,接下来我们只需要在树莓派上安装OpenVINO推理库并且编写推理代码就可以了,需要的ir模型可以直接从windows上拷贝过去。

树莓派上安装OpenVINO推理引擎请参考官方教程。

官网的树莓派 测试有一个bug,可以参照下面这个解决方案:

raspberry pi - I keep hitting error while trying to run a model on openvino - Stack Overflow

在树莓派上安装完openvino推理引擎后我们可以在树莓派上插上计算棒,来实现一张图片的单张推理,具体脚本(inference.py)如下:

# 导入库
import cv2
import numpy as np
# 提前运行setupvars.bat脚本,保证环境变量已加载
from openvino.inference_engine import IECore


def main():
    '''
    主函数
    '''
    # 设置模型路径
    model = "./ir/autodrive.xml"
    
    # 设置推理环境(CPU、GPU、MYRIAD)
    device = 'MYRIAD'  # 'MYRIAD'

    # 设置推理引擎
    ie = IECore() 
    
    # 读取网络模型
    net = ie.read_network(model=model)

    # 设置推理图像
    input = 'test.jpg'
    
    # 获取模型输入形状
    for input_key in net.input_info:
        if len(net.input_info[input_key].input_data.layout) == 4:
            n, c, h, w = net.input_info[input_key].input_data.shape
    
    # 输入信息预处理
    images = np.ndarray(shape=(n, c, h, w))
    images_hw = []
    for i in range(n):
        image = cv2.imread(input)
        ih, iw = image.shape[:-1]
        images_hw.append((ih, iw))
        if (ih, iw) != (h, w):
            image = cv2.resize(image, (w, h))
        # BGR 转换为 HSV
        image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
        # HWC 转换为 CHW 
        image = image.transpose((2, 0, 1))  
        # 转换至 (0,1)
        image = image / 255. 
        # # 减去均值
        # image -= np.array([0.485, 0.456, 0.406]).reshape(3,1,1)
        # # 除以方差 
        # image /= np.array([0.229, 0.224, 0.225]).reshape(3,1,1)
        images[i] = image  
        
    assert (len(net.input_info.keys()) == 1 or len(
        net.input_info.keys()) == 2), "Sample supports topologies only with 1 or 2 inputs"
    out_blob = next(iter(net.outputs))
    # 单一输出,转向值
    for input_key in net.input_info:
        input_name = input_key
        net.input_info[input_key].precision = 'FP32'
        break
    
    data = {}
    data[input_name] = images

    # 准备执行网络
    exec_net = ie.load_network(network=net, device_name=device)
    
    # 推理
    res = exec_net.infer(inputs=data)
    
    # 输出结果
    for i, probs in enumerate(res[out_blob]):
        probs = np.squeeze(probs)
        print(probs)


if __name__=="__main__":
    '''
    程序入口
    '''
    main()

最终输出结果是-27.9,跟前面单张图片预测结果是一致的,说明可以准确的在树莓派上进行推理了。

最后,我们做一下集成,完整的自动驾驶脚本(deeplearning_autodrive.py)如下:

# 导入库函数
import cv2
import numpy as np

# 导入openvino库
from openvino.inference_engine import IECore

# 导入小车运动库
from picarx import Picarx

# 导入自定义函数库
from tools import detect_lane, display_lines, compute_steer, stabilize_steering_angle, take_photo,ImageTrans

# 定义全局小车变量
px = Picarx()

# 创建并设置视频捕获对象
cap = cv2.VideoCapture(0)
print("摄像头是否已经打开 ? {}".format(cap.isOpened()))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320)  # 设置图像宽度
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240)  # 设置图像高度

# 创建图像传输管道
img_trans = ImageTrans('192.168.2.148')


def main():
    '''
    主函数
    '''
    # 定义小车初始油门、转向角和状态
    speed = 0.1
    last_steer_angle = 0
    pic_index = 0

    # 设置模型路径
    model = "./ir/autodrive.xml"
    
    # 设置推理环境(CPU、GPU、MYRIAD)
    device = 'MYRIAD'  # 'MYRIAD'

    # 设置推理引擎
    ie = IECore() 
    
    # 读取网络模型
    net = ie.read_network(model=model)
    
    # 获取模型输入形状
    for input_key in net.input_info:
        if len(net.input_info[input_key].input_data.layout) == 4:
            n, c, h, w = net.input_info[input_key].input_data.shape

    # 定义运行精度
    for input_key in net.input_info:
        input_name = input_key
        net.input_info[input_key].precision = 'FP32'
        break

    # 准备执行网络
    exec_net = ie.load_network(network=net, device_name=device)

    # 循环控制
    while True:
        # 读取图像
        ret, frame = cap.read()
        if not ret:
            print("图像获取失败,请检查")
            break

        # 输入信息预处理
        images = np.ndarray(shape=(1, c, h, w))
        images_hw = []
        
        ih, iw = frame.shape[:-1]
        images_hw.append((ih, iw))
        img = cv2.resize(frame, (w, h))
        # BGR 转换为 HSV
        img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
        # HWC 转换为 CHW 
        img = img.transpose((2, 0, 1))  
        # 转换至 (0,1)
        img = img / 255.0 
        images[0] = img 
        out_blob = next(iter(net.outputs))
        
        data = {}
        data[input_name] = images
 
        # 推理
        res = exec_net.infer(inputs=data)
        
        # 输出结果
        for i, probs in enumerate(res[out_blob]):
            probs = np.squeeze(probs)

        # 计算转向角
        steer_angle = probs

        # 平滑处理
        last_steer_angle = stabilize_steering_angle(last_steer_angle, steer_angle, 2, max_angle_deviation_two_lines=5)

        # 输出转向值
        print("\r 当前转向角度steer: %s    " % (last_steer_angle))

        # 执行动作
        px.set_dir_servo_angle(last_steer_angle)
        px.forward(speed)

        # 保存图像
        #if abs(last_steer_angle) > 12:
            #take_photo(last_steer_angle, frame, pic_index)
        pic_index += 1


if __name__ == "__main__":
    '''
    程序入口
    '''
    try:
        main()
    except Exception as e:
        print("error:%s" % e)
    finally:
        px.set_dir_servo_angle(0)
        px.forward(0)
        cap.release()
        cv2.destroyAllWindows()

最终运行效果见本文开头视频。应该说推理速度和稳定性是完全达到了本文任务要求(尽管偶尔还是会有压线的操作)。

六.小结

本文在模拟平台和树莓派小车上实现了自动驾驶控制。虽然内容比较简单,仅仅是让小车行驶在固定赛道内,但是通过这样一个完整的项目历练,希望读者能够熟练运用所学,能够贯穿Python、图像处理、AI算法于一体,能够自己通过PyTorch设计模型,完成训练测试,并最终将模型在边缘设备上部署应用。整个过程不可避免的会遇到不少问题,本文也不能详尽罗列。只有稳下心态,优化代码,稳扎稳打的去解决。

本文写的相对比较匆忙,请读者在评论区随时批评指正。今年在撰写一本关于深度学习图像处理的实战书籍《深度学习与图像处理PaddlePaddle》,预计2022年底出版。除了本文项目以外,还会在书中阐述更多的实战项目,有兴趣的读者后面可以关注下,到时候可以买本支持一下哦。

另外,本文出于教学需要,使用的模拟器环境比较简单,读者学完以后如果有更深的兴趣,可以使用更复杂的模拟器,这里给个参考:

https://github.com/Zhenye-Na/self-driving-vehicles-sim-with-ml

上述模拟器平台里面有大量更加真实的场景,可以开动脑筋,仔细钻研。另外本文项目也可以使用强化学习来实现,具体参考:Applying Deep Learning to Autonomous Driving - MuSHR: The UW Open Racecar Project

读万卷书不如行万里路,捧着所谓的经典论文或者经典书籍死记硬背不如真正的动手实践,遇到问题解决问题,在实践中才能真正的脱胎换骨。

AI这世界,星辰大海,你准备好了吗?

参考文献

[1] Bojarski M , Testa D D , Dworakowski D, et al. End to End Learning for Self-Driving Cars[J]. 2016.

你可能感兴趣的:(深度学习,自动驾驶小车,pytorch,代码和数据,树莓派)