
阐述了基于OpenCV的传统图像处理算法在车道线检测技术中的应用。
本节将使用传统图像处理算法检测车道线,然后根据车道线方向逐帧调整小车运行状态。这个过程涉及两个方面:感知和动作规划。感知部分主要通过车道线检测来实现,动作规划则通过操控转向角度来实现。车道线检测的目的就是希望能够根据检测到的车道线位置来计算最终应该转向的角度,从而控制小车始终行驶在当前车道线内。
由于道路环境比较简单,可以进一步简化控制变量。对于油门值,可以在运行时保持低匀速,这样只需要控制转向角度即可,实现起来更加容易。这种模式类似于现实生活中在驾驶汽车时开启了定速巡航功能。
下面针对模拟环境采集到的图像,进行算法分析。
#01、基于HSV空间的特定颜色区域提取
从仿真平台捕获的图像上进行分析,小车左侧是黄实线,右侧是白实线。最终目标是希望小车一直运行在这两条车道线中间。因此,首先要提取出这两条线才能定性分析出它们的斜率,从而为小车转向角度提供依据。具体的,可以通过颜色空间变换来提取车道线区域。为了方便将黄色线和白色线从图像中提取出来,可以将图像从RGB空间转换到HSV空间再处理。这里首先解释下RGB和HSV颜色空间的区别。
RGB是平时接触最多的颜色空间,由三个通道表示一幅图像,分别为红色(R)、绿色(G)和蓝色(B)。RGB颜色空间是图像处理中最基本、最常用的颜色空间,其利用三个颜色分量的线性组合来表示颜色,任何颜色都与这三个分量有关,但是这三个分量是高度相关的,想对图像的颜色进行调整需要同时更改这三个分量才行。在图像处理领域,针对特定颜色提取问题,使用较多的是HSV颜色空间,它比RGB空间更接近人类对色彩的感知经验,可以非常直观地表达色彩的色调、鲜艳程度和明暗程度,方便进行颜色的对比。
HSV表达彩色图像的方式由三个部分组成:色调(Hue)、饱和度(Saturation)、明度(Value)。其中色调用角度度量表示,取值范围为0~360,不同角度代表不同的色彩信息,即所处的光谱颜色的位置。
如果想要提取出黄色线,可以将色调范围控制在30~90。注意,在OpenCV中色调取值范围是[0~180],因此上述黄色范围需要缩小1倍,即[15~45]。检测白色车道线也是采用类似的原理。读者可以自行查找色调表来找到特定颜色的色调范围。
具体实现代码如下(opencv_drive/img_analysis.py):
python import cv2 import numpy as np import math #----------------------1.基于HSV空间的特定颜色区域提取------------------ # 读取图像并转换到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('yellow_mask.jpg', yellow_mask) cv2.imwrite('white_mask.jpg', white_mask)
上述代码首先将图像从BGR空间转换到了HSV空间,然后使用cv2.inRange()函数提取特定颜色范围内的图像区域。
特定颜色区域提取效果如图1.37所示。

■ 特定颜色区域提取(从左至右:原图、白色提取区域、黄色提取区域)
02、基于高斯模糊的噪声滤除
观察前面的颜色区域提取效果,会发现有不少的离散噪声点,这些噪声会对后面的计算造成干扰,因此可以先提前用滤波算法处理一下。这里可以使用1.3节中介绍过的高斯模糊来消除这些高频噪声,具体可以通过OpenCV提供的现成的高斯模糊函数cv2.GaussianBlur()来实现。
实现代码如下:
frame = cv2.imread('test.jpg')
frame = cv2.GaussianBlur(frame,(5,5),1) # 添加高斯模糊代码
在读入图像后立即用高斯模糊操作一下,高斯核大小选择5×5,然后再进行特定颜色提取,最终效果如图1.38所示:

■ 高斯模糊后特定颜色区域提取(从左至右:原图、白色提取区域、黄色提取区域)
对比上述处理效果,可以看到使用高斯模糊后离散噪声点被有效滤除了,整个检测结果更“干净”了。
#03、基于Canny算子的边缘轮廓提取
目前仅获得了车道线区域,为了方便后续计算车道线角度,需要得到车道线具体的线段信息,即从区域中提取出线段。这里可以使用Canny算法来实现。
Canny边缘检测是从图像中提取结构信息的一种技术,于1986年被提出,目前已得到广泛应用。
Canny算法包括5个步骤:
(1)使用高斯滤波器,以平滑图像、滤除噪声;
(2)计算图像中每个像素点的梯度强度和方向;
(3)应用非极大值抑制(Non-Maximum Suppression,NMS),消除边缘检测带来的杂散响应;
(4)应用双阈值检测来确定真实的和潜在的边缘;
(5)抑制孤立的弱边缘;
OpenCV中集成了Canny算法,只需要一行代码即可实现。
具体实现代码如下:
python # 黄色线边缘提取 yellow_edge = cv2.Canny(yellow_mask, 200, 400) # 白色线边缘提取 whitewhite_edge = cv2.Canny(white_mask, 200, 400)
上述代码中200和400这两个参数表示Canny算子的低、高阈值,一般可以不用修改。Canny边缘检测效果如图1.39所示:

■ Canny边缘检测(从左至右:原图、白色边缘、黄色边缘)
可以看到,通过Canny边缘检测,准确的将每个子区域的外围轮廓提取了出来,后续只需要处理这些整幅图像中的边缘线段即可,大幅减少了需要处理的图像数据量。
#04、感兴趣区域提取
在利用OpenCV对图像进行处理时,通常会遇到一种情况,就是只需要对部分感兴趣区域(Region Of Interest,ROI)进行处理。例如针对本章这个模拟平台自动驾驶任务,正常情况下,黄色车道线位于图像左下角,白色车道线位于图像右下角,而图像中其他区域并不需要处理。因此,针对黄色车道线只需要提取图像左下部分,针对白色车道线只需要提取图像右下部分即可。
具体代码如下:
python # ----------------------------4.感兴趣区域提取---------------------------- 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 # 黄色车道线感兴趣区域提取 yellow_croped = region_of_interest(yellow_edge, color="yellow")cv2.imwrite("yellow_croped.jpg", yellow_croped) # 白色车道线感兴趣区域提取 white_croped = region_of_interest(white_edge, color="white")cv2.imwrite("white_croped.jpg", white_croped)
上述代码定义了region_of_interest()函数来提取感兴趣区域。具体实现时预先设置好一个值全为0的掩码区域,然后将需要提取的掩码区域赋值为255,最后使用cv2.bitwise_and()函数将掩码图像mask和待处理图像edges进行逐像素与运算,从而将非感兴趣区域像素赋值为0。
感兴趣区域提取效果如图1.40所示。

■ 感兴趣区域提取(从左至右:原图、白色边缘、黄色边缘)
到这一步,可以看到基本上准确的把需要的黄色车道线和白色车道线提取了出来。
#05、基于霍夫变换的线段检测
目前提取出了比较精确的车道线轮廓,但是对于实际的自动驾驶任务来说还没有完成最终的目标,还需要对车道线轮廓再进一步处理,得到车道线的具体线段信息,即每条线段的起始点坐标,这样才能方便计算小车最终需要的转向角度。本小节将使用霍夫变换来完成这个任务。
霍夫变换(Hough Transform,HT),作用是检测图像中的直线、圆等几何图形。一条直线的表示方法有好多种,最常见的是y=mx+b的形式。结合这个任务,所要解决的问题就是对于最终检测出的感兴趣区域,怎么把图片中的直线提取出来。
这里可以设置两个坐标系,左边的坐标系表示的是(x,y)值,对应图像空间(Image Space),右边的坐标系表示的是的(m,b)值,对应参数空间(Parameter Space),即直线的参数值,如图1.41所示。

■ 霍夫变换图像空间到参数空间的转换
很显然,一个左侧坐标系中的(x,y)点在右边坐标系中对应的就是一条线。假设将左边坐标系图像中所有目标区域的像素坐标都对应到右边坐标系中的每条直线,那么右边坐标系中的交点(m,b)就表示左侧坐标系中有多个点经过(m,b)确定的直线。当右侧坐标系中这个交点(m,b)上相交的直线超过指定数量时,可以认为左侧坐标系图像中存在着表达式为y=mx+b的直线,有很多像素落在这条直线上。可以采用这种方法来估计图像中出现的直线,但是该方法存在一个问题,就是(m,b)的取值范围太大。为了解决这个问题,在直线的表示方面可以改用

的规范式代替一般直线表达式

,参数空间由此变成。这样图像空间中的一个像素点在参数空间中就是一条曲线(三角函数曲线)。以上就是霍夫直线检测的基本原理。
具体的,霍夫直线检测算法实现步骤如下:
(1)初始化

空间,令

,则

表示在该参数表示的直线上的像素点的个数。
(2)对于每一个像素值大于0的像素点(x,y),在参数空间中找出满足

(3)统计所有

的大小,取出

的参数,threshold是预设的阈值。
OpenCV已经封装好了基于霍夫变换的直线线段检测方法cv2.HoughLinesP(),下面就来使用它进行线段检测,代码如下:
python # ----------------------------5.基于霍夫变换的直线检测---------------------------- rho = 1 # 距离精度:1像素 angle = np.pi / 180 # 角度精度:1度 min_thr = 10 # 最少投票数 white_lines = cv2.HoughLinesP(white_croped, rho, angle, min_thr, np.array([]), minLineLength=8, maxLineGap=8) yellow_lines = cv2.HoughLinesP(yellow_croped, rho, angle, min_thr, np.array([]), minLineLength=8, maxLineGap=8) # 输出查看返回的线段 print(white_lines)
输出查看返回的lines内容,结果如下:
python [[[112 87 142 117]] [[ 94 69 134 119]] [[111 85 137 111]] [[107 84 132 115]] [[119 98 134 117]]]
返回的每组值都是一条线段,表示线段的起始位置(x_start,y_start,x_end,y_end)。从输出结果看到检测出来了很多小线段,但最终需要的是两条车道线,因此可以对检测出来的小线段做一下聚类和平均:
python # --------------------------------6.小线段聚类------------------------------------ def make_points(frame, line): '''根据直线斜率和截距计算指定高度处的起始坐标''' height, width, _ = frame.shape slope, intercept = line y1 = height y2 = int(y1 * 1 / 2) x1 = int((y1 - intercept) / slope) x2 = int((y2 - intercept) / slope) return [x1, y1, x2, y2] def average_lines(frame, lines, direction="left"): """对小线段进行聚类""" lane_line = [] if lines is None: print(direction + "没有检测到线段") return lane_line fits = [] # 计算每条小线段的斜率和截距 for line in lines: for x1, y1, x2, y2 in line: # 最小二乘法拟合 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_line = make_points(frame, fit_average) return lane_line # 聚合线段 yellow_lane = average_lines(frame, yellow_lines, direction="left") white_lane = average_lines(frame, white_lines, direction="right") print(white_lane)
上述代码定义了average_lines()函数用于聚合小线段,其中使用了numpy库中的polyfit()函数来拟合数据点,该函数封装了最小二乘算法来拟合直线,从而得到图像中每条小线段的斜率和截距。
需要注意的是,对于数字图像来说,y坐标轴是向下的,其原点在图像的左上角,而在数学上一般坐标系定义的坐标轴是向上的,因此,测试图像中左侧黄色实线斜率是负值(对应代码中slope<0),右侧白色实线斜率是正值(对应代码中slope>0)。
在求得每条小线段的斜率和截距后,使用了自定义的make_points()函数重新计算了该平均线对应到图像上指定高度处的起始坐标,计算方法如图1.42所示。

■ 车道线线段起始位置示意图
在计算每条车道线的起始坐标时,分别令起始点的纵坐标

,然后计算对应的横坐标|

的值,从而得到最终代表线段的起始点坐标

上述代码最后计算得到的是坐标数值,这样观察线段的坐标值不是很直观,可以写个函数显式的观察检测到的线段,代码如下:
#----------------------------7.可视化显示检测结果-------------------------------
def display_line(frame, line, line_color=(0, 0, 255), line_width=3):
'''在原图上合成展示线段'''
line_img = np.zeros_like(frame)
x1, y1, x2, y2 = 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
# 显示检测结果
img_yellow = display_line(frame, yellow_lane, line_color=(0, 0, 255), line_width=3)img_white = display_line(frame, white_lane, line_color=(0, 0, 255), line_width=3)cv2.imwrite("img_yellow.jpg", img_yellow)cv2.imwrite("img_white.jpg", img_white)
车道线检测结果如图1.43所示,从左至右依次为原图、白色线检测结果和黄色线检测结果。

■ 车道线检测结果
从图1.43可以看到,该方法已经能够准确地将两条车道线检测了出来。接下来就是根据这两条车道线进行小车驾驶方向控制了。