基于深度学习的端到端自动驾驶 主体来源:钱斌的博客
1.模拟平台安装和基本使用 1.1模拟器的安装 下载地址:右键或点击此处 或按住ctrl+/进入代码模式,复制链接到浏览器打开。
该地址中提供的模拟器是基于Unity开发的,是经过删减过后的可执行程序,不再需要额外安装unity,下载下来后就可以直接运行。目前覆盖windows、Linux、Mac共3个版本。以下只讲解如何在windows平台上运行和使用该模拟器。
下载完成后运行donkey_sim.exe文件即可进入模拟器。左边是设置,根据自己的需要进行设置即可。
在场景中,如果我们前面主界面使用了手工模式(paceCar处勾选manualDriving),那么我们就可以通过键盘来操控小车进行体验了。与一般的赛车游戏类似,W键表示前进,A表示左转,D表示右转,S表示后退。
在该模拟器中,控制小车的主要是两个参数:油门(W和S键)和转向角度(A和D键),这个与我们真实驾驶的汽车基本一致:挂挡+踩油门来控制前进动力,打方向盘控制车辆转向。为了能够实现自动驾驶,我们首先要能够根据这两个参数去控制模拟器里面小车的运行。我们怎么样通过Python代码来控制这个模拟器呢?
1.2git工具的安装与配置 详细的git工具安装与教程请参照这篇文章 ,这里仅描述如何在pycharm中使用git
1.2.1注册github账号 GitHub官网 按照提示进行注册。请注意记下用户名和邮箱地址,下面步骤需要使用。
1.2.2安装git 到git官网下载git安装包 ,点击downloads,选择操作平台(windows),下载完成后运行,第一次选择全勾,后面一直下一步即可。
下载完成后,运行cmd,输入命令检查下载版本。能正确显示版本即为安装成功
1.2.3配置git 安装成功后,配置git
1 2 3 git config --global user.name "用户名" git config --global user.email "用户邮箱"
请注意此处空格必须严格遵循,否则将无法配置成功。建议直接复制过去改。
检查配置是否成功。在最下面会出现属性user.name与user.email,如果没有请重新到回到上一步。
1.2.4在pycharm中配置git file->settings->version control->git,一般此处默认检测系统中安装的第一个git,如果没有检测到请手动选择。
具体git操作请参照这篇文章 这里不进行描述。
###1.3自动驾驶初体验
这个模拟器的好处就在于预留了Python控制接口,我们只需要安装一个驱动库就可以直接驱动模拟器里面的小车运行(提前安装好Git工具):
在pycharm下方终端输入以下pip命令
1 pip install git+https://github.com/tawnkramer/gym-donkeycar
安装好以后我们可以运行下面的python代码来实现小车的控制(注意:运行下面的代码前先启动模拟器,并停留在模拟器对应地图里) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import gymimport gym_donkeycarimport numpy as npimport cv2 env = gym.make("donkey-generated-roads-v0" ) obv = env.reset() for t in range (100 ): action = np.array([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()
我们先分析下这段代码。下面这行代码用于设置模拟器环境,简单来说就是启用哪张地图:
1 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图像。可以在代码根目录下找到test.jpg文件查看。
numpy教程十分钟入门
openCV教程 黑马程序员
建议观看以上视频,学习一定的基础知识。当然没有这样的知识硬记语法也是可以的。
2.基于OpenCV的自动驾驶控制 在学习自动驾驶前,我们先看看传统算法是怎么解决自动驾驶任务的。本节为后续自动驾驶作铺垫,如果不感兴趣可以直接跳转到深度学习部分。
我们希望通过算法来控制小车,最终让这个小车稳定运行在行车道内。这里面涉及到两方面:感知和动作规划。感知部分我们主要通过行道线检测来实现,动作规划通过操控转向角度来实现。行道线检测的目的就是希望能够根据检测到的行道线位置来计算最终应该转向的角度,从而控制小车始终运行在当前车道线内。
由于道路环境比较简单,针对我们这个任务,我们进一步简化我们的控制变量,我们只控制转向角度,对于油门值我们在运行时保持低匀速,这样我们的重点就可以放在一个变量上面—转向角度。
2.1基于HSV空间的特定颜色区域提取 颜色过滤是目前经常被使用到的图像处理技巧之一,例如天气预报抠像等,经常会使用绿幕作为背景进行抠图。本小节使用颜色过滤来初步提取出行道线。
从模拟平台的图像数据上进行分析,小车左侧是黄实线,右侧是白实线。我们希望小车一直运行在这两根线之间。因此,我们首先要定位出这两根线。我们可以通过颜色空间变换来定位这两根线。
为了方便将黄色线和白色线从图像中过滤出来,我们需要将图像从RGB空间转换到HSV空间再处理。
这里首先我们解释下RGB和HSV颜色空间的区别。
RGB 是我们接触最多的颜色空间,由三个通道表示一幅图像,分别为红色(R),绿色(G)和蓝色(B)。这三种颜色的不同组合可以形成几乎所有的其他颜色。RGB 颜色空间是图像处理中最基本、最常用、面向硬件的颜色空间,比较容易理解。RGB 颜色空间利用三个颜色分量的线性组合来表示颜色,任何颜色都与这三个分量有关,而且这三个分量是高度相关的,所以连续变换颜色时并不直观,想对图像的颜色进行调整需要更改这三个分量才行。自然环境下获取的图像容易受自然光照、遮挡和阴影等情况的影响,即对亮度比较敏感。而 RGB 颜色空间的三个分量都与亮度密切相关,即只要亮度改变,三个分量都会随之相应地改变,而没有一种更直观的方式来表达。但是人眼对于这三种颜色分量的敏感程度是不一样的,在单色中,人眼对红色最不敏感,蓝色最敏感,所以 RGB 颜色空间是一种均匀性较差的颜色空间。如果颜色的相似性直接用欧氏距离来度量,其结果与人眼视觉会有较大的偏差。对于某一种颜色,我们很难推测出较为精确的三个分量数值来表示。所以,RGB 颜色空间适合于显示系统,却并不适合于图像处理。
基于上述理由,在图像处理中使用较多的是 HSV 颜色空间,它比 RGB 更接近人们对彩色的感知经验。非常直观地表达颜色的色调、鲜艳程度和明暗程度,方便进行颜色的对比。在 HSV 颜色空间下,比 BGR 更容易跟踪某种颜色的物体,常用于分割指定颜色的物体。HSV 表达彩色图像的方式由三个部分组成:
Hue(色调、色相)
Saturation(饱和度、色彩纯净度)
Value(明度)
其中Hue用角度度量,取值范围为0~360°,表示色彩信息,即所处的光谱颜色的位置,如下图所示。
如果我们想要过滤出黄色线,那么我们就可以将色调范围控制在[30~ 90]之间即可。注意:在OpenCV中色调范围是[0~ 180],因此上述黄色范围需要缩小1倍,即[15~ 45]。检测白色行道线也是采用类似的原理。
RGB转化到HSV的算法(c++):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 float retmax (float a,float b,float c) { float max = 0 ; max = a; if (max<b) max = b; if (max<c) max = c; return max; } float retmin (float a,float b,float c) { float min = 0 ; min = a; if (min>b) min = b; if (min>c) min = c; return min; } void rgb_to_hsv (float *h,float *s,float *v,float R,float G,float B) { float max = 0 ,min = 0 ; R = R/100 ; G = G/100 ; B = B/100 ; max = retmax (R,G,B); min = retmin (R,G,B); *v = max; if (max == 0 ) *s = 0 ; else *s = 1 - (min/max); if (max == min) *h = 0 ; else if (max == R && G>=B) *h = 60 *((G-B)/(max-min)); else if (max == R && G<B) *h = 60 *((G-B)/(max-min)) + 360 ; else if (max == G) *h = 60 *((B-R)/(max-min)) + 120 ; else if (max == B) *h = 60 *((R-G)/(max-min)) + 240 ; *v = *v * 100 ; *s = *s * 100 ; }
HSV转RGB算法:(c++)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void hsv_to_rgb (int h,int s,int v,float *R,float *G,float *B) { float C = 0 ,X = 0 ,Y = 0 ,Z = 0 ; int i=0 ; float H=(float )(h); float S=(float )(s)/100.0 ; float V=(float )(v)/100.0 ; if (S == 0 ) *R = *G = *B = V; else { H = H/60 ; i = (int )H; C = H - i; X = V * (1 - S); Y = V * (1 - S*C); Z = V * (1 - S*(1 -C)); switch (i){ case 0 : *R = V; *G = Z; *B = X; break ; case 1 : *R = Y; *G = V; *B = X; break ; case 2 : *R = X; *G = V; *B = Z; break ; case 3 : *R = X; *G = Y; *B = V; break ; case 4 : *R = Z; *G = X; *B = V; break ; case 5 : *R = V; *G = X; *B = Y; break ; } } *R = *R *100 ; *G = *G *100 ; *B = *B *100 ; }
而以上方法在opencv中都有封装,仅需要一句代码即可实现。现在我们来检测黄色线与白色线。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import cv2import numpy as np 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)
运行效果如下图:
可以看到还有很大的干扰存在。主要来自于相似的颜色,因此我们还需要进行进一步的处理。
2.2基于canny算子的边缘轮廓提取 目前我们仅获得了行道线区域,为了后续能够方便的计算行道线角度,我们需要得到行道线具体的轮廓/线段信息,即从区域中提取出线段。这里我们使用Canny算法实现。
Canny边缘检测是从不同视觉对象中提取有用的结构信息并大大减少要处理的数据量的一种技术,于1986年被提出,目前已广泛应用于各种计算机视觉系统。
Canny算法具体包括5个步骤:
使用高斯滤波器,以平滑图像,滤除噪声。
计算图像中每个像素点的梯度强度和方向。
应用非极大值(Non-Maximum Suppression)抑制,以消除边缘检测带来的杂散响应。
应用双阈值(Double-Threshold)检测来确定真实的和潜在的边缘。
通过抑制孤立的弱边缘最终完成边缘检测。
具体实现细节我们不再详细剖析,在OpenCV中集成了canny算法,只需要一行代码即可实现canny边缘检测。
1 2 3 4 5 6 7 yellow_edge = cv2.Canny(yellow_mask, 200 , 400 ) cv2.imwrite('yellow_edge.jpg' , yellow_edge) white_edge = cv2.Canny(white_mask, 200 , 400 ) cv2.imwrite('white_edge.jpg' , white_edge)
有兴趣了解canny的同学可以查看这篇博客 ,其中运用到的正态分布、偏导、梯度与极限的知识我们都学过,可以理解。高斯核滤波也用于卷积,而实际上的卷积还会复杂一点,可以自行了解。在后面的pytorch版本我会详细解释运行原理。
代码中200和400这两个参数表示canny算子的低、高阈值,按照opencv教程一般可以不用修改。
最终效果:
2.3感兴趣区域(ROI)定位 在利用OpenCV对图像进行处理时,通常会遇到一个情况,就是只需要对部分感兴趣区域(Region Of Interest, ROI)进行处理。例如针对我们这个模拟平台上的智能小车任务来说,对于黄色行道线,我们只关注图像右下部分,而对于白色行道线,我们只关注图像左下部分即可。至于图像其他部分因为我们通过人工分析知道,这些区域我们并不需要处理。因此,我们只用提取图像的对应区域。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import cv2import numpy as npdef region_of_interest (edges, color='yellow' ): ''' 感兴趣区域提取 ''' height, width, _ = edges.shape mask = np.zeros_like(edges) if color == 'yellow' : polygon = np.array([[(width * 1 / 2 , height * 1 / 2 ), (width, height * 1 / 2 ), (width, height), (width * 1 / 2 , height)]], np.int32) else : polygon = np.array([[(0 , height * 1 / 2 ), (width * 1 / 2 , height * 1 / 2 ), (width * 1 / 2 , height), (0 , height)]], np.int32) cv2.fillPoly(mask, polygon, (255 ,255 ,255 )) croped_edge = cv2.bitwise_and(edges, mask) return croped_edge if __name__=="__main__" : yimg = cv2.imread('yellow_edge.jpg' ) wimg = cv2.imread('white_edge.jpg' ) cv2.imwrite('white_edge_new.jpg' , region_of_interest(wimg, 'white' )) cv2.imwrite('yellow_edge_new.jpg' , region_of_interest(yimg))
这里定义感兴趣区域、填充感兴趣区域是使用了暴力的直接定位法,按住ctrl+函数名可以进入cv2.fillPoly查看函数的参数与具体作用。在实际应用中我们还有一些更加高级的方法来锁定感兴趣区域,但对于模拟器而言这种方法已经够用。注意这一段代码:
1 2 3 4 5 ''' 感兴趣区域提取 ''' height, width, _ = edges.shape mask = np.zeros_like(edges)
edges.shape返回有三个参数,分别为高度、宽度、通道数,如rgb图像就是三通道的。但是下面我们用不到通道数的值,但解包赋值需要把返回值全部接受,因此此处用_ 暂时存放通道数。
对于如何填充矩形有兴趣的同学,可以查看这篇博客 。这里进行部分的转载。
一、fillConvexPoly( )函数 以填充矩形为例 我图中想填充以1、2、3、4为顶点的矩形,我就要按1、2、3、4的顺序给出坐标序列array,或者是连贯的相邻顶点顺序(比如1432,4321…)
1 2 rectangular = np.array([ [0 ,0 ],[0 ,740 ], [4032 ,740 ], [4032 ,0 ] ]) cv2.fillConvexPoly(img_gray, rectangular, (0 ,0 ,0 ))
效果如下图所示
那如果不按邻边顺序,如果我写成1423会如何呢?来看。
emmm, 我觉得想画五角星的画可以这样。
二、fillPoly( )函数 好了,上面都是基本操作,看看官方教程都可以。 我们玩点别的。 我如果想把一个矩形区域留住(拿车牌举例),剩下的填充为白色应该怎么办呢? 举一反三一下,两种办法: (1)
1 cv2.fillPoly(img_gray, [rec1, rec2, rec3, rec4], (255 ,255 ,255 ))
四个矩形,改用fillPoly()方法。
结果如上图。
(2)非要死脑筋其实用fillConvexPoly( )也不是不可以。
画个示意图,你们懂我意思吧。
定位后,我们的图片如下所示,可以看到更加“整洁”了。
###2.4基于霍夫变换的线段检测
到目前,我们抽取出了比较精确的行道线轮廓,但是对于实际的自动驾驶任务来说还没有完成目标任务要求,我们要对行道线轮廓再进一步处理,得到行道线的具体线段信息(每条线段的起始点坐标)。本小节我们使用霍夫变换来完成这个任务。霍夫变换,英文名称Hough Transform,作用是用来检测图像中的直线或者圆等几何图形的。
具体的,一条直线的表示方法有好多种,最常见的是y=mx+b的形式。结合我们这个任务,对于最终检测出的感兴趣区域,怎么把图片中的直线提取出来。基本的思考流程是:如果直线 y=mx+b 在图片中,那么图片中,必需有N多点在直线上(像素点代入表达式成立),只要有这条直线上的两个点,就能确定这条直线。该问题可以转换为:求解所有的(m,b)组合。【以下是部分原理,不感兴趣的同学可以直接跳到代码】
设置两个坐标系,左边的坐标系表示的是(x,y)值,右边的坐标系表达的是(m,b)的值,即直线的参数值。那么一个(x,y)点在右边对应的就是一条线,左边坐标系的一条直线就是右边坐标系中的一个点。这样,右边左边系中的交点就表示有多个点经过(k,b)确定的直线。但是,该方法存在一个问题,(m,b)的取值范围太大。
为了解决(m,b)取值范围过大的问题,在直线的表示方面用 xcosθ+ysinθ=r 的规范式代替一般表达式,参数空间变成(θ,r),0=<θ<=2PI。这样图像空间中的一个像素点在参数空间中就是一条曲线(三角函数曲线)。
此时,图像空间和参数空间的对应关系如下:
从图中可以看出,霍夫直线检测即为在参数空间中对r和theta投票的过程,得票最高者为最终的直线参数。 theta表示与直线垂直的线与x轴的夹角,那么他的取值范围就是-pi到pi ,但显然取0-pi就可以表示所有直线。
更详细的数学原理请查看这篇博客 ,其中涉及到笛卡尔坐标向极坐标变换(事实上是一个特殊的参数空间)的讨论,对数学感兴趣的同学可以深入了解,这里不再展示。走到这一步,流程图如下所示。
霍夫线段检测算法原理步骤如下:
初始化(θ,r)空间,N(θ,r)=0 。(N(θ,r)表示在该参数表示的直线上的像素点的个数)
对于每一个像素点(x,y),在参数空间中找出令 xcosθ+ysinθ=r 的(θ,r)坐标,N(θ,r)+=1
统计所有N(θ,r)的大小,取出N(θ,r)>threasold的参数 。(threadsold是预设的阈值)
OpenCV中封装好了基于霍夫变换的直线检测方法HoughLinesP,下面我们就来使用它进行线段检测。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def detect_line (edges ): ''' 基于霍夫变换的直线检测 ''' rho = 1 angle = np.pi / 180 min_thr = 10 lines = cv2.HoughLinesP(edges, rho, angle, min_thr, np.array([]), minLineLength=8 , maxLineGap=8 ) return lines
我们可以print一下lines,结果如下(此处仅作展示,后面有这一步的完整代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 [[[ 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)。可以看到小线段很多,我们对这些小线段做一下聚类和平均:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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函数如下所示:
1 2 3 4 5 6 7 8 9 10 11 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]]
上述函数最后返回的是坐标数值,这样看线段的坐标值不是很直观,我们可以写个脚本显式的观察这些线段:
1 2 3 4 5 6 7 8 9 10 11 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
上述代码我们将行道线按照一定权重与原图进行合成,方便我们查看最终效果。
完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import cv2import numpy as npdef detect_line (edges ): ''' 基于霍夫变换的直线检测 ''' rho = 1 angle = np.pi / 180 min_thr = 10 lines = cv2.HoughLinesP(edges,rho, angle,min_thr,np.array([]),minLineLength=8 ,maxLineGap=8 ) return lines def average_lines (frame, lines, direction='left' ): ''' 小线段聚类 ''' lane_lines = [] if lines is None : print (direction + '没有检测到线段' ) return lane_lines 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 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 def main (): yimg = cv2.imread('yellow_edge_new.jpg' ,0 ) wimg = cv2.imread('white_edge_new.jpg' ,0 ) yimg_detect_line = detect_line(yimg) wimg_detect_line = detect_line(wimg) yimg_average_lines = average_lines(yimg,yimg_detect_line,direction='right' ) wimg_average_lines = average_lines(wimg,wimg_detect_line) y=cv2.imread('test.jpg' ) w=cv2.imread('test.jpg' ) yellow_display_line = display_line(y, yimg_average_lines) white_display_line = display_line(w, wimg_average_lines) cv2.imwrite('yellow_display_line.jpg' , yellow_display_line) cv2.imwrite('white_display_line.jpg' , white_display_line) if __name__=="__main__" : main()
我们来分析一下几段容易出错的代码:
1 2 3 if len (fits) > 0 : fit_average = np.average(fits, axis=0 ) lane_lines.append(make_points(frame, fit_average))
注意此处已经嵌套调用了make_points函数,返回了图片的x,y坐标。后续不需要再进行make_points,否则将会出现传入参数错误。
1 2 3 def main (): yimg = cv2.imread('yellow_edge_new.jpg' ,0 ) wimg = cv2.imread('white_edge_new.jpg' ,0 )
这里imread后面多带一个参数0,表示以单通道读入。即使图像显示为黑白,它仍然可能是三通道的,而yimg_detect_line中HoughLinesP霍夫变换只接受单通道的图片。这里不加0这个参数将会出现错误。
1 2 3 4 def display_line (frame, lines, line_color=(0 ,0 ,255 ), line_width=2 ): ''' 在原图上展示线段 '''
注意line_color传入的rgb三色值是倒过来的,实际上红色对应的是(255,0,0),而(0, 0, 255)是紫蓝色(?)
1 height, width= frame.shape
某些.shape返回的是三个参数,第三个表示的是通道数。一般用_ 来接收这个不需要用到的通道数,而此处不会返回这个值,所以加入_ 会出错。实际应该根据编译器的提示进行修改(因为我也没摸透为什么,可能图片经过其他函数的一些转换后,不会返回这个参数。实际上修改起来也比较简单。)
1 2 3 4 5 6 7 y=cv2.imread('test.jpg' ) w=cv2.imread('test.jpg' ) yellow_display_line = display_line(y, yimg_average_lines) white_display_line = display_line(w, wimg_average_lines) cv2.imwrite('yellow_display_line.jpg' , yellow_display_line) cv2.imwrite('white_display_line.jpg' , white_display_line)
这里读入我们拍摄的彩色图片,将红色的线段合成上去。整体代码运行结果如下:
从效果上看我们准确的将两条行道线检测了出来。接下来就是根据这两条行道线进行自动驾驶方向控制。
2.5动作控制:转向角 针对前面的测试图片,我们可以有效的检测出两条行道线(左侧黄色线和右侧白色线),但是在真实的运行过程中,可能会出现3种情况:
(1)正常检测到2条行道线:这种情况一般是直线车道且车辆稳定运行在行道线内,这时候我们只需要根据检测出的两条行道线微调整角度即可。
(2)检测出1条行道线:这种情况在转弯处容易出现,或者在车辆开始大范围偏离时出现,这时候我们的策略应该是向能够检测到的这条行道线方向前进。
(3)检测不到行道线:这种情况应该停下小车。
因此,针对三种情况我们需要不同的处理方式。代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 x_offset = 0 y_offset = 0 if len (yellow_lane)>0 and len (white_lane)>0 : _, _, 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 ])
到这里我们就可以开始启动程序了。完整代码如下所示:
先编写自定义库tools:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 import cv2import numpy as npdef detect_line (edges ): ''' 基于霍夫变换的直线检测 ''' rho = 1 angle = np.pi / 180 min_thr = 10 lines = cv2.HoughLinesP(edges,rho, angle,min_thr,np.array([]),minLineLength=8 ,maxLineGap=8 ) return lines def average_lines (frame, lines, direction='left' ): ''' 小线段聚类 ''' lane_lines = [] if lines is None : print (direction + '没有检测到线段' ) return lane_lines 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 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 def main (): yimg = cv2.imread('yellow_edge_new.jpg' ,0 ) wimg = cv2.imread('white_edge_new.jpg' ,0 ) yimg_detect_line = detect_line(yimg) wimg_detect_line = detect_line(wimg) yimg_average_lines = average_lines(yimg,yimg_detect_line,direction='right' ) wimg_average_lines = average_lines(wimg,wimg_detect_line) y=cv2.imread('test.jpg' ) w=cv2.imread('test.jpg' ) yellow_display_line = display_line(y, yimg_average_lines) white_display_line = display_line(w, wimg_average_lines) cv2.imwrite('yellow_display_line.jpg' , yellow_display_line) cv2.imwrite('white_display_line.jpg' , white_display_line) def region_of_interest (edges, color='yellow' ): ''' 感兴趣区域提取 ''' height, width = edges.shape mask = np.zeros_like(edges) if color == 'yellow' : polygon = np.array([[(width * 1 / 2 , height * 1 / 2 ), (width, height * 1 / 2 ), (width, height), (width * 1 / 2 , height)]], np.int32) else : polygon = np.array([[(0 , height * 1 / 2 ), (width * 1 / 2 , height * 1 / 2 ), (width * 1 / 2 , height), (0 , height)]], np.int32) cv2.fillPoly(mask, polygon, (255 , 255 , 255 )) croped_edge = cv2.bitwise_and(edges, mask) return croped_edge
再编写主函数入口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 import cv2import numpy as npimport mathimport gymimport gym_donkeycarfrom tools import region_of_interest, detect_line, make_points, average_lines, display_linedef main (): ''' 主函数 ''' env = gym.make("donkey-generated-roads-v0" ) obv = env.reset() action = np.array([0 , 1 ]) obv, reward, done, info = env.step(action) frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR) for t in range (1000 ): 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='right' ) white_lines = detect_line(white_roi) white_lane = average_lines(frame, white_lines, direction='left' ) x_offset = 0 y_offset = 0 if len (yellow_lane) > 0 and len (white_lane) > 0 : _, _, 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图像处理的基本使用方法,了解了自动驾驶项目的难点,对整个处理流程有了更进一步的认识。需要说明的是,尽管我们上述操作步骤是针对我们这个自动驾驶模拟平台的,但是以上步骤同样适用于很多其他图像处理任务,很多传统的图像处理任务都涵盖颜色空间变换、特定颜色物体提取、感兴趣区域过滤、霍夫变换等步骤,因此掌握上述常规的图像处理技术是非常重要的。
3.基于深度学习的自动驾驶控制 在上一节中我们通过OpenCV图像处理技术实现了一个简易的自动驾驶小车。但是很明显,这辆自动驾驶小车的适应性很差,当图像中有相同颜色的干扰物出现时,那么对于这辆自动驾驶的小车来说就是顶级灾难。另外,我们需要大量人工定义的参数,例如行道线颜色(黄色或白色)、颜色阈值、霍夫变换阈值等,而且一旦地图环境换了,所有这些参数我们都得重新手工调整,这些参数之间又有一定的耦合性,参数调整很麻烦。很显然,这种处理方法普适性不好。
那么能不能丢给机器一大堆图片,让机器自己去学习如何从当前图像中分析出小车应该转向的合适角度?如果没有接触过深度学习,那么乍一听这个想法简直是天方夜谭,然而深度学习确实做到了。这就是为什么近十年深度学习在图像处理领域取得了全面成功。深度学习能够从大量图像数据中自行学习高层次语义特征,完成媲美人类甚至超越人类的推理水平,整个学习过程不用人为干预,我们要做的就是“喂”一堆图片并且设定好需要优化的目标函数即可。当我们“喂”的图片越多、种类越丰富,那么最终机器学习到的驾驶水平越强,而且适应性越好。
本小节开始我们将正式进入基于深度学习的自动驾驶领域。
3.0一些基础知识 根据后面要用到的网络,这里放上一些基础知识,篇幅都不(太)长,请同学们阅读。
神经网络与深度学习 这种基本的认识想必大家已经有了,但这里还是放一放。
pytorch基础教程 pytorch可以使用英伟达NVIDIA的产品进行GPU运算,但AMD锐龙的显卡并不支持。教程后期有教到如何使用。这是非常浅显易懂的课(用张老师的话来说就是傻瓜式的),因此虽然我们主要用到的是pytorch但这里不进行教学。如果仍有困难,后面我会现场讲解或录视频,加上写文档的方式帮助同学们。
bp神经网络原理 bp神经网络我在导论课上讲的那个就是,忘了的话可以去导论那个群下载对应的excel演示下来玩一下。对应的论文也是非常有趣,预训练现在的应用非常广泛,同学们可以阅读一下。
卷积神经网络与池化、全连接、归一化
全连接神经网络
归一化
端到端是什么
同时建议大家复习一下我当时给大家讲python的时候说到的os库。
3.1算法原理 本项目实现思路参考2016年英伟达发表的论文《End to End Learning for Self-Driving Cars》。这篇文章提出的方法核心思想就是使用神经网络自动提取图像特征,从传统的 image -> features -> action变成了image -> action。该论文使用了深度网络结构,大大增强了图像特征提取能力,最终取得了不错的效果,其训练的模型不论是普通道路还是高速路,不论有道路标线还是没有道路标线都非常有效,解决了传统算法泛化性能差的问题。本文方法的测试性能非常好,在16年自动驾驶研究火热时,是一篇影响力很大的文章,即使放到现在,也是作为自动驾驶入门必读的Paper。
整个算法原理很简单,是对真实人类操作的一个模拟。对于我们人类驾驶员来说,假设我们正在驾驶这辆车,我们的执行流程跟上面算法也是一样的。首先我们用眼睛观看路面,然后我们的大脑根据当前眼睛看到的路面情况“下意识”的转动方向盘,转动一个我们认为合适的角度,从而避免车辆开出路面。这篇论文算法实现原理也是这样,具体如下图所示:
通过中间摄像头采集图像,然后图像输入到预先训练好的CNN网络,这个网络的输出是一个转向角度(可以理解为方向盘的转向角度),有了这个角度就可以控制小车按照这个角度进行转向。
有了这样一个模式,我们就只需要想办法训练这个CNN模型,针对每帧图像,都有一个我们认为合适的转向角度输出,即输入图像,输出一个回归值。具体模型结构如下图所示:
整个模型结构并不复杂,就是一堆的普通的CNN卷积神经网络模块按照顺序堆叠,最后使用全连接网络输出回归值。这个模型一共包含30层,由于其输入精度比较低(66x200),因此推理速度也是比较快的,借助GPU可以实现实时推理。具体的,图像首先经过Normalization标准化,然后经过5组卷积层处理,最后拉平以后通过4个全连接层输出一个回归值,这个回归值就是我们项目中的转向角。
这里我们会遇到一个问题,训练上述深度神经网络我们需要大量的数据,即每帧图像以及对应的最佳转向值,这些数据怎么来呢?这篇论文里提出了一个方法,既然是模拟人类行为,那么只要让驾驶水平高超的“老司机”在相关赛道上进行手动驾驶,驾驶时一边记录每帧图像同时记录当前帧对应的操控的转向角,这样一组组数据记录下来就是我们认为的“最佳”训练数据。训练时,将模型预测的角度与给定图像帧的期望转向角度进行比较,误差通过反向传播反馈到CNN训练过程中,如下图所示。从图可以看出,这个过程在一个循环中重复,直到误差(本例中使用均方误差)足够低,这意味着模型已经学会了如何合理地转向。事实上,这是一个非常典型的图像分类训练过程,只不过这里预测输出是数值(回归值)而不是对象类别(分类概率)。 可以想象,如果能够完全的训练好这个模型,那么最终模型的输出结果是非常接近人类驾驶经验的。这篇论文通过大量实验证明,上述模型能够直接从拍摄的路面图像中有效的学习到最终的转向角,省去了传统算法颜色区域检测、感兴趣区域选择、霍夫变换等一系列复杂的耦合步骤。这篇论文做了一组实验,通过收集不到一百小时的少量训练数据进行训练,最后得到的模型足以支持在各种条件下操控车辆,比如高速公路、普通公路和居民区道路,以及晴天、多云和雨天等天气状况。
需要说明的是,这个模型的输出仅有一个转向角度,这样容易学习成功。如果输出变量再多一些(例如油门值、摄像头角度、行人避障等),那么这个模型还需要再进一步优化,感兴趣的同学可以借鉴近两年的论文进行深入研究(而我们的项目就是要做这个)。 接下来我们就按照这个算法流程进行实现。
###3.2数据采集
针对我们采用的自动驾驶模拟平台,为了能够采集到每帧图像及对应的最佳转向角度,我们可以使用前面第2节方法编写控制代码通过键盘控制小车(低匀速运行,仅仅只需要控制转向角度),然后记录每帧数据即可。这种模式是真实自动驾驶使用的,但是需要我们自己把自己练成经验充足的“老司机”,然后再去教会算法怎么驾驶。这样比较麻烦,这里可以有一种“偷懒”的办法。我们使用前面调参调的不错的OpenCV自动驾驶版本,使用OpenCV算法自动驾驶,然后记录每帧图像及对应角度。尽管这个OpenCV自动驾驶水平本身也一般(没有一直控制在两条行道线的绝对正中间),但是胜在能够基本稳定在行道线内。本文只是一个自动驾驶入门项目,可以采用这样的方法收集数据,来快速验证深度学习自动驾驶可行性。真实项目的话还是需要向“老司机”学习的。
先定义自定义tools库,这里对上面的进行了一定的修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 import cv2import numpy as npdef 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 def detect_line (edges ): ''' 基于霍夫变换的直线检测 ''' rho = 1 angle = np.pi / 180 min_thr = 10 lines = cv2.HoughLinesP(edges, rho, angle, min_thr, np.array([]), minLineLength=8 , maxLineGap=8 ) return lines 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 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
完整采集代码如下,同样有一定的变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 import cv2import numpy as npimport mathimport gymimport 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 ]) obv, reward, done, info = env.step(action) frame = cv2.cvtColor(obv, cv2.COLOR_RGB2BGR) pic_index = 0 for t in range (4000 ): 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' ) white_lines = detect_line(white_roi) white_lane = average_lines(frame, white_lines, direction='right' ) x_offset = 0 y_offset = 0 if len (yellow_lane) > 0 and len (white_lane) > 0 : _, _, 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()
图片名采用“图片帧号_转向角度.jpg”的形式命名。上述代码每次跑完会在log目录下生成4000多张图片。由于每次的地图都是随机生成的,因此我们可以多跑几次,多收集一些数据。
最终共采集10个文件夹图片,总共4万张图片:
接下来我们需要对这些图片进行整理,拆分数据集用于训练和验证。我们把这些文件夹移动到data/simulate下,注意这个文件夹需要自己手动创建 。
详细脚本代码create_data_lists.py如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import osimport 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 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文件每行内容表示一个样本,由图片路径和转向值组成,中间用空格隔开。
接下来我们将使用Pytoch框架实现深度学习算法进行训练、验证。
3.3模型训练 首先定义数据采集器datasets.py,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import osimport numpy as npimport cv2 import torchfrom 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空间进行训练的,因此需要做一下转化。
这里插播一下一个自定义的方法库utils.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class AverageMeter (object ): ''' 平均器类,用于计算平均值、总和 ''' def __init__ (self ): self.reset() def reset (self ): self.val = 0 self.avg = 0 self.sum = 0 self.count = 0 def update (self, val, n=1 ): self.val = val self.sum += val * n self.count += n self.avg = self.sum / self.count
虽然这个文件里面只有一个类,但创建这样一个文件是习惯。
有了数据读取类以后我们就下来定义模型,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import torch.nn as nnimport 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 * 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,因此,修改过后的模型更具有普遍性,还方便后面迁移到真实环境训练。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 * 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 ))
整个模型比较简单,前面是多个cnn,最后接几个全连接网络,输入是3通道图像,输出是一个转向回归值。
训练脚本代码train.py如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 import torch.backends.cudnn as cudnnimport torchfrom torch import nnimport torchvision.transforms as transformsfrom torch.utils.tensorboard import SummaryWriterfrom models import AutoDriveNetfrom datasets import AutoDriveDatasetfrom utils import *def main (): """ 训练. """ data_folder = './data/simulate' checkpoint = None batch_size = 400 start_epoch = 1 epochs = 1000 lr = 1e-4 device = torch.device("cuda" if torch.cuda.is_available() else "cpu" ) print (torch.cuda.is_available(), device) cudnn.benchmark = True writer = SummaryWriter() model = AutoDriveNet() optimizer = torch.optim.Adam(params=filter (lambda p: p.requires_grad, model.parameters()), lr=lr) model = model.to(device) model = model.cuda() criterion = nn.MSELoss().to(device) criterion = nn.MSELoss().cuda() 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' ]) transformations = transforms.Compose([ transforms.ToTensor(), ]) 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.state_dict(), 'optimizer' : optimizer.state_dict() }, 'results/checkpoint.pth' ) writer.close() if __name__ == '__main__' : ''' 程序入口 ''' main()
新旧版更替,不同的运行环境都有可能导致代码报各种错误。这里真是坑到不行qwq。经过我反复测试,大家报错时可以解开就近的注释部分进行尝试,这应该是目前最为稳定的版本了。
注意:
1 2 writer = SummaryWriter() writer.add_scalar('MSE_Loss' , loss_epoch.avg, epoch)
第一行代码及下面的相关方法使得我们可以在pycharm下方终端使用命令 tensorboard –logdir runs ,点击出现的网页链接(用edge或者chome),可以看到误差改变情况。如果这行代码报错了,请在终端运行命令 pip install tensorboard等待安装完成。反复刷新浏览器可以更新。根据网页提示进行使用。如果测试的时候运行过太多次,可以修改第二行代码的字符串,会生成一个新的表。删除根目录下run的文件也可以起到减少的作用。
只有一个显卡的同学一定要注意保持所有单机多卡训练的代码处于注释状态(我在这儿被坑惨了)
1 2 3 4 5 6 7 torch.save( { 'epoch' : epoch, 'model' : model.state_dict(), 'optimizer' : optimizer.state_dict() }, 'results/checkpoint.pth' )
对model字段的保存,有的版本需要用注释里的字段,有的用当前的就行。请注意results文件夹最好手动创建。
3.4模型验证 上面的代码需要迭代1000次, epoch=1000的时候基本处在一个比较好的收敛位置,此时误差下降到1e-5左右。不过在跑到第140次左右时就已经下降到1e-4,让误差下降一个数量级在实际工程中还是非常重要的,不过同学们在使用的时候其实跑到这样也能用了。
验证代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 import timeimport torchfrom torch import nnimport torch.backends.cudnn as cudnnimport torchvision.transforms as transformsfrom datasets import AutoDriveDatasetfrom models import AutoDriveNetfrom utils import *def main (): data_folder = "./data/simulate" ngpu = 1 print ("定义设备环境" ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu" ) print ("加载预训练模型" ) checkpoint = torch.load("./results/checkpoint.pth" ) model = AutoDriveNet() model = model.to(device) model.load_state_dict(checkpoint['model' ]) transformations = transforms.Compose([ transforms.ToTensor(), ]) 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(): print ("开始进行测试" ) for i, (imgs, labels) in enumerate (val_loader): print ("第{}次计算进行中" .format (i)) 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()
运行结果如下:
我们的转向角度取值范围是[-1, 1],这样的误差比较小,是可以接受的。请注意多卡训练的同学,一定要解开nn.DataParallel的注释,否则是无法运行的。
这里等待的时间比较长,所以我增加了一些print输出来判断到底是电脑卡了还是程序在运行。同学们如果配置比较低,也建议这样自行增加一些print。
3.5单张图片预测 为什么上一步已经用8000张图片进行预测了,这里还要用单张图片来尝试呢?真实的自动驾驶的时候,我们是要对每一张图片进行分析的。所以这里来尝试一下用单张图片来分析。
我们选取一张比较有代表性的图片:
大家可以先猜测一下这个转向角度。
下面是测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import cv2 from torch import nnimport torch from models import AutoDriveNetfrom 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) img = torch.from_numpy(img.copy()).float () img /= 255.0 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()
请注意一定要把测试的图片移动到result目录下,且命名为test.jpg,或修改代码中对应行。
测试结果:
而实际上这台小车正准备右转,转向值为0.244。趋势上是正确的,而效果上也是可以接受的。
3.6系统集成,自动驾驶 我们现在可以使用pytorch逐帧分析图像,然后直接给出转向值用于小车控制,不再需要复杂的、分散的图像处理步骤。
只需要把上一小节的代码和之前的控制代码合并即可实现。编程能力强的同学可以留作练习。
运行auto_drive.py文件,其完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 import cv2import numpy as npimport mathimport gymimport gym_donkeycar from torch import nnimport torch from models import AutoDriveNetfrom 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 ]) img, reward, done, info = env.step(action) img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV) 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图像处理方法对每帧单独处理,没有一个整体的去噪概念,容易在某一帧出现偏差。但是基于深度学习的方法更多的是学习整个数据集的操作体验,某种意义上做了一定的概率去噪,或者说是平均化,因此,整个的操控才会显得更加流畅。
到这里,这个简单的教程就结束了。恭喜你,你已经入门了end to end自动驾驶(大概)。希望我的拙见可以帮助到大家学习。