/ 导读 /
车道线检测算法分为传统图像处理方法和深度学习方法。本文详细介绍用传统图像处理方法来解决车道线检测问题,后文的算法源于Udacity无人驾驶课程的项目(https://www.udacity.com/),仅做参考。
计算机视觉基础
在正式开始算法之前,我们先简单补充些计算机视觉的基础知识。已有基础的童鞋,可以略过直接看算法。
像素
像素Pixel,是picture和element的合成词,表示图像元素的意思,可以理解为构成图像的最小单位。像素的大小与图像的分辨率有关,分辨率越高,像素就越小,图像就越清晰。
灰度图像
灰度化,即对彩色图像进行灰度化处理。由于很多算法对颜色的依赖性不高,通常会对彩色图像进行灰度化预处理,去除彩色信息,不仅能提高鲁棒性,还可以提高处理速度。
灰度图像的每个像素信息是由一个量化的灰度级来描述的,没有彩色信息;而彩色图像(例如RGB)的每个像素信息由RGB三原色构成的,RGB分别通过三个不同的灰度级来描述。
二值图像
二值化就是将彩色图像变成二值图像。由于二值图像的数据足够简单,只有两个值,所以算法都依赖二值图像。
二值图像,即黑白图像。二值图像的每个像素只能是黑或白,没有中间的灰度级过渡,像素值为0、1。最常用的就是采用阈值的方法进行二值化。最常用的二值化处理简单来说,就是选取一个阈值,大于它的像素就视为白,小于它就视为黑。
色彩空间
不同色彩空间对颜色采用不同的表示方式,我们最常用的是RGB色彩空间,除此之外还有HLS、HSV、YUV、Lab色彩空间等。我们可以针对个人需求,转换到适合的彩色空间中处理。
例如,RGB色彩空间中的R表示红色Red,G表示绿色Green,B表示蓝色Blue,这三种颜色以不同的量进行叠加,就可以显示出所有颜色。HSL色彩空间即色相Hue、饱和度Saturation、亮度Lightness;HSV色彩空间则是由色相Hue、饱和度Saturation、明度Value组成。YUV色彩空间是由一个亮度Y和两个色度UV决定的。而在Lab色彩空间中,L表示亮度Lightness,A表示从绿色到红色的渐变;B表示从蓝色到黄色的渐变。
颜色阈值过滤
阈值过滤需要设定一个数值,像素值高于此值的像素点变为白色,低于此值则变为黑色。具体来说,例如在RGB色彩空间中,R红色是表示在一个区间内的连续数值;我们通过设置[min, max]值区间,将大小位于该区间内的数值保留,不在该区间的数值统一设置为0。通过这种操作,实现对图像颜色特征的过滤。
边缘检测
这里要介绍的是使用图像滤波的方式实现边缘检测。滤波,其实就是通过放大图像中的某些频段,同时滤掉或减弱某些频段的方法。例如,低通滤波器的作用是消除图像中的高频部分,高通滤波器的作用是消除图像中的低频部分。
Sobel算法
Sobel算法是通过放大图像中高频部分,再对图像进行二值化,可以实现边缘检测。虽然它只对垂直或水平方向的图像频率起作用,能够分别得出x和y方向上的亮度函数的梯度近似值。x方向(水平方向)的Sobel滤波倾向于检测垂直方向的边缘,y方向(垂直方向)的Sobel滤波则倾向于检测水平方向的边缘。而在车道线检测中,车道线是倾向于垂直方向的线,所以采用x方向的Sobel算法较为有效。
Canny算法
Canny算法通常是基于梯度算法,用两个不同的阈值(高阈值和低阈值)来判断那个点属于边缘,双阈值可以有效降低边缘的漏检率。虽然Canny算法能够精度高,能清晰检测出所有边缘,但在车道线检测中,除了车道线还会检测出各种阴影。因此相比之下Sobel单方向的检测也许效果更好。
霍夫变换
霍夫变换是一种一般用于检测直线(也可检测圆形)的经典算法。可以结合上文的Canny算法检测直线车道线。经过Canny算法得到边缘分布图像后,构建一个霍夫参数空间,利用点与线的对偶性,将原始图像空间的直线变换为参数空间的一个点。这样就把原始图像中直线检测问题转变为寻找参数空间中的峰值问题。
简单解释下,元空间的点都对应霍夫参数空间的线,原空间的直线都对应参数空间中曲线的交点。即可以通过参数空间曲线的交点个数来寻找原空间中在一条直线上的点。
图片来自Udacity无人驾驶课程
感兴趣区域
感兴趣区域,即ROI。在图像处理过程中,我们可能会对图像的某一个特定区域感兴趣,只对该区域实施操作。对于车道线检测来说,由于相机是固定安装,可以选取一个区域以排除其他噪声干扰。感兴趣区域可以是任意图形,例如四边形或其他任意多边形。
透视变换
透视变换是将成像投影到一个新的视平面(鸟瞰图)。车载相机拍摄到的图像中,由于受相机角度和高度的影响往往会将平行的车道线拍成梯形,这样的图像会给后续检测带来较大难度,所以需要我们对原图进行透视变换,从而得到一张鸟瞰图。
具体思路是,先读取两组坐标(例如原图像的四点坐标和目标图像的四点坐标),计算变换矩阵;然后根据变换矩阵对图像进行透视变换,并输出到目标图像。
相机标定
由于一般相机都多少存在畸变,而自动驾驶需要准确的车道曲率以执行正确的方向控制,因此需要对相机进行校正。首先读取两个坐标数组,计算变换矩阵;然后根据变换矩阵对原图进行透视变换,并输出到目标画布。
确定相机内参和畸变参数的过程就叫做相机标定。一般只需要一幅图像(国际象棋棋盘图像较为常用)就可以进行相机标定。标定时只需要从不同角度拍摄棋盘图案,通过检测棋盘图案中的角点,得到角点像素,并且我们已知真实世界中的对象角点坐标 ,便可进行相机校正。若要进行精确的标定,通常从不同角度对同一棋盘拍摄多张照片(10-20张)。
直方图滤波
简单来说,就是沿着X轴统计每列像素的数值,并用直方图表示。其中峰值位置的x坐标则对应左右两侧的车道线。通过这种方式,来获取车道线位置信息。
图片来自Udacity无人驾驶课程
初级车道线检测算法--直线
颜色阈值处理
读取图片,对颜色RGB分别设置阈值,将高于所设阈值的像素(较亮的白色部分)分离出来。
# 读取图片
image = mpimg.imread('test.jpg')
ysize = image.shape[0]
xsize = image.shape[1]
color_select = np.copy(image)
# 对RGB通道分别设置阈值
red_threshold = 200 #Tuning
green_threshold = 200 #Tuning
blue_threshold = 200 #Tuning
# 将所设阈值以下的任何像素都设置为0
rgb_threshold = [red_threshold, green_threshold, blue_threshold]
thresholds = (image[:,:,0] < rgb_threshold[0]) \
| (image[:,:,1] < rgb_threshold[1]) \
| (image[:,:,2] < rgb_threshold[2])
color_select[thresholds] = [0,0,0]
提取ROI
Python
# 首先定义一个空图像
mask = np.zeros_like(image)
# 设置ROI区域坐标(任意多边形)
vertices = np.array([[image.shape[1]*.12, image.shape[0]],\
[image.shape[1]*.25,(image.shape[0]+image.shape[0]*.65)/2],\
[image.shape[1]*.42, image.shape[0]*.63],\
[image.shape[1]*.6,image.shape[0]*.63],\
[image.shape[1]*.8,(image.shape[0]+image.shape[0]*.65)/2],\
[image.shape[1]*.95,image.shape[0]]]) #Tuning
# 设置颜色通道,并填充ROI
if len(image.shape) > 2:
channel_count = image.shape[2] # i.e. 3 or 4 depending on your image
ignore_mask_color = (255,) * channel_count
else:
ignore_mask_color = 255
cv2.fillPoly(mask, vertices, ignore_mask_color)
# 合并得到ROI,其余部分为零
masked_image = cv2.bitwise_and(image, mask)
Canny边缘检测
# 将图像转换为灰度图
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
# 进行高斯模糊,去除噪声
kernel_size = 3 #Tuning
blur_gray = cv2.GaussianBlur(gray,(kernel_size, kernel_size), 0) # 参数kernel_size可以是任何奇数
# 计算梯度(Canny边缘检测)
low_threshold = 50 #Tuning
high_threshold = 150 #Tuning
edges = cv2.Canny(gray,low_threshold,higy_threshold) # 参数:灰度图,低阈值,高阈值。输出为边缘图。
霍夫变换
rho = 2
theta = np.pi/180
threshold = 15
min_line_length = 50
max_line_gap = 20
# rho和theta是霍夫空间的距离和夹角
# threshold是通过同一交点的曲线个数最小值
# min_line_length是直线的最小像素长度
# max_line_gap是线与线之间的最大像素长度。输出为多条直线
lines = cv2.HoughLinesP(masked_edges, rho, theta, threshold, np.array([]), min_line_length, max_line_gap)
绘制车道线
# 定义左右车道线的点集合
right_list = []
left_list = []
# 根据霍夫变换得到的线段,绘制左右各一条车道线
if lines is not None:
for line in lines:
for x1,y1,x2,y2 in line:
if (y1 - y2) / (x1 - x2) > 0:# right side
right_list.append([x1,y1,x2,y2,(y2-y1)/(x2-x1),(x1*y2-x2*y1)/(x1-x2)])
else:# left side
left_list.append([x1,y1,x2,y2,(y2-y1)/(x2-x1),(x1*y2-x2*y1)/(x1-x2)])
# 通过计算斜率,筛选得到最优车道线
if len(right_list) != 0:
[k_right_mean,b_right_mean,x_list,y_list] = filter_lines(right_list)
y1_right = img.shape[0]-60
x1_right = (y1_right - b_right_mean) / k_right_mean
y2_right = img.shape[0]*.65 #min(y_right)
x2_right = (y2_right - b_right_mean) / k_right_mean
cv2.line(img, (np.int32(x1_right), np.int32(y1_right)), (np.int32(x2_right), np.int32(y2_right)), [255,0,0], thickness)
if len(left_list) != 0:
[k_left_mean,b_left_mean,x_list,y_list] = filter_lines(left_list)
y1_left = img.shape[0]-60
x1_left = (y1_left - b_left_mean) / k_left_mean
y2_left = img.shape[0]*.65 #min(y_left)
x2_left = (y2_left - b_left_mean) / k_left_mean
cv2.line(img, (np.int32(x1_left), np.int32(y1_left)), (np.int32(x2_left), np.int32(y2_left)), color, thickness)
def filter_lines(xy_kb_list):
n = 3
k_list = [xy_kb[4] for xy_kb in xy_kb_list]
mean = np.mean(k_list)
std = np.std(k_list)
good_k_list = []
good_b_list = []
good_x_list = []
good_y_list = []
for [x1, y1, x2, y2, k, b] in xy_kb_list:
if k < mean + n*std:
good_k_list.append(k)
good_b_list.append(b)
good_x_list.append(x1)
good_x_list.append(x2)
good_y_list.append(y1)
good_y_list.append(y2)
if not good_x_list:
good_k_list = k_list
good_b_list = [xy_kb[5] for xy_kb in xy_kb_list]
good_x_list = [xy_kb[0] for xy_kb in xy_kb_list] + [xy_kb[2] for xy_kb in xy_kb_list]
good_y_list = [xy_kb[1] for xy_kb in xy_kb_list] + [xy_kb[3] for xy_kb in xy_kb_list]
k_mean = np.median(good_k_list)
b_mean = np.median(good_b_list)
return k_mean,b_mean,good_x_list,good_y_list
将算法应用于视频
output = 'test_video_output.mp4'
from moviepy.editor import VideoFileClip
clip1 = VideoFileClip("test_video.mp4")
white_clip = clip1.fl_image(process_image)
%time white_clip.write_videofile(output , audio=False)
高级车道线检测算法--曲线
计算相机校正矩阵和失真系数
# 使用提供的一组棋盘格图片计算相机校正矩阵(camera calibration matrix)和失真系数(distortion coefficients).
objpoints = []
imgpoints = []
for ny in [5,6]:
for nx in [6,7,8,9]:
objp = np.zeros((ny*nx,3), np.float32)
objp[:,:2] = np.mgrid[0:nx, 0:ny].T.reshape(-1,2)
for idx, fname in enumerate(images):
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 使用cv2自动寻找棋盘corners
ret, corners = cv2.findChessboardCorners(gray, (nx,ny), None)
if ret == True:
objpoints.append(objp)
imgpoints.append(corners)
# 绘制corners
cv2.drawChessboardCorners(img, (nx,ny), corners, ret)
image_name=os.path.split(fname)[1]
write_name = out_dir_cam+'corners_found_ny'+str(ny)+'_nx'+str(nx)+'_'+image_name
cv2.imwrite(write_name, img)
print(fname)
plt.imshow(img)
# Test undistortion on an image
img = cv2.imread('camera_cal/calibration.jpg')
img_size = (img.shape[1], img.shape[0])
# Do camera calibration given object points and image points
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(objpoints, imgpoints, img_size,None,None)
dst = cv2.undistort(img, mtx, dist, None, mtx)
# 保存相机校正矩阵和失真系数结果
dist_pickle = {}
dist_pickle["mtx"] = mtx
dist_pickle["dist"] = dist
pickle.dump(dist_pickle, open( "camera_dist_pickle.p", "wb" ) )
校正车道线图片
使用计算好的相机校正矩阵(camera calibration matrix)和失真系数(distortion coefficients)校正车道线图片。
# 打开相机校正矩阵和失真系数结果
with open('camera_dist_pickle.p', mode='rb') as f:
dist_pickle = pickle.load(f)
mtx = dist_pickle["mtx"]
dist = dist_pickle["dist"]
# 应用于车道线图片
images = glob.glob('test_images/*.jpg')
for idx, fname in enumerate(images):
img = cv2.imread(fname)
dst = cv2.undistort(img, mtx, dist, None, mtx)
image_name=os.path.split(fname)[1]
write_name = out_dir_img+'undistorted_'+image_name
cv2.imwrite(write_name,dst)
print(write_name)
组合阈值处理
# 转换到HLS颜色空间(也可尝试其他颜色空间)
hls = cv2.cvtColor(img, cv2.COLOR_RGB2HLS).astype(np.float)
l_channel = hls[:,:,1]
s_channel = hls[:,:,2]
# 使用Sobel算法在x方向进行阈值处理
sobelx = cv2.Sobel(l_channel, cv2.CV_64F, 1, 0)
abs_sobelx = np.absolute(sobelx)
scaled_sobel = np.uint8(255*abs_sobelx/np.max(abs_sobelx))
# Threshold x gradient
sxbinary = np.zeros_like(scaled_sobel)
sxbinary[(scaled_sobel >= sx_thresh[0]) & (scaled_sobel <= sx_thresh[1])] = 1
# Threshold S channel of HLS
s_binary = np.zeros_like(s_channel)
s_binary[(s_channel >= s_thresh[0]) & (s_channel <= s_thresh[1])] = 1
# Threshold L channel of HLS
l_binary = np.zeros_like(l_channel)
l_binary[(l_channel >= l_thresh[0]) & (l_channel <= l_thresh[1])] = 1
# 将各种处理方式进行融合,得到车道线的二进制图。
color_binary = 255*np.dstack(( l_binary, sxbinary, s_binary)).astype('uint8')
combined_binary = np.zeros_like(sxbinary)
combined_binary[((l_binary == 1) & (s_binary == 1) | (sxbinary==1))] = 1
combined_binary = 255*np.dstack((combined_binary,combined_binary,combined_binary)).astype('uint8')
透视变换和ROI提取
# 使用透视变换(perspective transform)得到二进制图(binary image)的鸟瞰图(birds-eye view).
img=plt.imread('test_image.jpg')
corners = np.float32([[190,720],[580,460],[705,460],[1120,720]])# tuning
imshape = img.shape
new_top_left=np.array([corners[0,0],0])
new_top_right=np.array([corners[3,0],0])
offset=[150,0]
src = np.float32([corners[0],corners[1],corners[2],corners[3]])
dst = np.float32([corners[0]+offset,new_top_left+offset,new_top_right-offset ,corners[3]-offset])
M = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(img, M, img_size , flags=cv2.INTER_LINEAR)
# ROI提取
shape = warped.shape
vertices = np.array([[(0,0),(shape[1],0),(shape[1],0),(6*shape[1]/7,shape[0]),
(shape[1]/7,shape[0]), (0,0)]],dtype=np.int32)
mask = np.zeros_like(warped)
if len(shape) > 2:
channel_count = shape[2]
ignore_mask_color = (255,) * channel_count
else:
ignore_mask_color = 255
cv2.fillPoly(mask, vertices, ignore_mask_color)
masked_image = cv2.bitwise_and(img, mask)
利用直方图滤波和滑动窗口进行曲线拟合
# 对二进制图片的像素进行直方图统计,统计左右两侧的峰值点作为左右车道线的起始点坐标进行曲线拟合。(利用前帧图像探索后帧曲线)
def find_peaks(img,thresh):
img_half=img[img.shape[0]//2:,:,0]
data = np.sum(img_half, axis=0)
filtered = scipy.ndimage.filters.gaussian_filter1d(data,20)
xs = np.arange(len(filtered))
peak_ind = signal.find_peaks_cwt(filtered, np.arange(20,300))
peaks = np.array(peak_ind)
peaks = peaks[filtered[peak_ind]>thresh]
return peaks,filtered
def get_next_window(img,center_point,width):
ny,nx,_ = img.shape
mask = np.zeros_like(img)
if (center_point <= width/2): center_point = width/2
if (center_point >= nx-width/2): center_point = nx-width/2
left = center_point - width/2
right = center_point + width/2
vertices = np.array([[(left,0),(left,ny), (right,ny),(right,0)]], dtype=np.int32)
ignore_mask_color=(255,255,255)
cv2.fillPoly(mask, vertices, ignore_mask_color)
masked = cv2.bitwise_and(mask,img)
hist = np.sum(masked[:,:,0],axis=0)
if max(hist>10000):
center = np.argmax(hist)
else:
center = center_point
return masked,center
def lane_from_window(binary,center_point,width):
n_zones=6
ny,nx,nc = binary.shape
zones = binary.reshape(n_zones,-1,nx,nc)
zones = zones[::-1]
window,center = get_next_window(zones[0],center_point,width)
for zone in zones[1:]:
next_window,center = get_next_window(zone,center,width)
window = np.vstack((next_window,window))
return window
left_binary = lane_from_window(warped_binary,380,300)
right_binary = lane_from_window(warped_binary,1000,300)
计算车道曲率
ym_per_pix = 30/720 # meters per pixel in y dimension
xm_per_pix = 3.7/700 # meters per pixel in x dim
lane_width = 3.7
def cal_curvature(line):
fit_coeffs_curv = np.polyfit(y*ym_per_pix, x*xm_per_pix, 2)
radius_of_curvature = ((1 + (2*fit_coeffs_curv[0]*y_eval*ym_per_pix + fit_coeffs_curv[1])**2)**1.5) /np.absolute(2*fit_coeffs_curv[0])
return radius_of_curvature
left_curvature= cal_curvature(left_line)
right_curvature = cal_curvature(right_line)
curvature = 0.5*(round(right_curvature,1) + round(left_curvature,1))
将曲线逆透视到原图片
# 将完成车道线标记的鸟瞰图反透视变换为初始图像视角
newwarp = cv2.warpPerspective(color_warp, Minv, (img.shape[1], img.shape[0]))
将算法应用于视频
output = 'test_video_output.mp4'
clip = VideoFileClip("test_video.mp4")
out_clip = clip.fl_image(process_image)
%time out_clip.write_videofile(output, audio=False)