点击上方“小白学视觉”,选择加"星标"或“置顶”
重磅干货,第一时间送达
车道线检测是自动驾驶汽车的重要组成部分之一,有很多方法可以做到这一点。本文,我们将使用最简单的霍夫变换方法。
本文分为三个部分:
第一部分:高斯模糊+ Canny边缘检测
第二部分:霍夫变换
第三部分:优化+显示线条
第1部分和第3部分的重点是编码,第2部分更面向理论。接下来,让我们开始第一部分。
第一部分:高斯模糊+Canny边缘检测
导入必需的库:
import numpy as np
import cv2
import matplotlib.pyplot as plt
第1行:Numpy用于执行数学计算,我们要用它来创建和操作数组。
第3行:使用Matplotlib可视化图像。
接下来,让我们从集合中加载一张图片来测试算法
image_path = r"D:\users\new owner\Desktop\TKS\Article Lane
Detection\udacity\solidWhiteCurve.jpg"
image1 = cv2.imread(image_path)
plt.imshow(image1)
在这里,我们在第4行将图像加载到笔记本中,然后我们将在第5行和第6行读取图像并将其可视化。现在是处理图像的时候了,主要分为以下三步:
def grey(image):
return cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
def gauss(image):
return cv2.GaussianBlur(image, (5, 5), 0)
def canny(image):
edges = cv2.Canny(image,50,150)
return edges
在最后一个代码块中,我们定义了3个函数:
Greyscale the image:这有助于增加颜色的对比度,使它更容易识别像素强度的变化。
Gaussian Filter:高斯滤波器的目的是减少图像中的噪声。我们这样做是因为Canny中的梯度对噪声非常敏感,所以我们想尽可能地消除噪声。cv2.高斯模糊函数有三个参数:
img参数定义了我们要进行归一化(减少噪声)的图像。这个函数使用一个称为高斯核的核函数,用于对图像进行归一化。
sigma参数定义沿x轴的标准偏差。标准偏差衡量图像中像素的分布,我们希望像素扩散是一致的,因此标准偏差为0。
Canny:这是我们检测图像边缘的地方,它所做的是计算像素强度的变化(亮度的变化)在一个图像的特定部分。幸运的是,OpenCV使它变得非常简单。
cv2.Canny函数有3个参数,(img, threshold-1, threshold-2)。
img参数定义了我们要检测边缘的图像。
threshold-1参数过滤所有低于这个数字的梯度(它们不被认为是边缘)。
threshold-2参数决定了边缘的有效值。
如果两个阈值之间的任何梯度连接到另一个高于阈值2的梯度,则将考虑该梯度。
现在我们已经定义了图像中的所有边缘,我们需要分割与车道线相对应的边缘,操作步骤如下:
def region(image):
height, width = image.shape
triangle = np.array([
[(100, height), (475, 325), (width, height)]
])
mask = np.zeros_like(image)
mask = cv2.fillPoly(mask, triangle, 255)
mask = cv2.bitwise_and(image, mask)
return mask
这个函数将分割图像中车道线所在的某个硬编码区域,它以Canny图像为参数,输出孤立区域。
在第1行中,我们将使用numpy.shape函数提取图像的维数。
在第2-4行中,我们要定义一个三角形的尺寸,也就是我们要隔离的区域。
在第5和第6中,我们要创建一个黑色的平面,然后我们要定义一个白色的三角形,它的尺寸和第2行中定义的一样。
在第7行中,我们将执行位运算和运算,使我们能够隔离与车道线对应的边缘。
更深入的解释位运算
在我们的图像中,有两种像素强度:黑色和白色。黑色像素的值为0,白色像素的值为255。在8位二进制中,0转换为00000000,255转换为11111111。对于位运算和运算,我们将使用像素的二进制值。现在,我们将在img1和img2相同的位置上乘以两个像素(我们将img1定义为带有边缘检测的平面,img2定义为我们创建的掩码)。
左:Img1,右图:Img2。(实际上,它是白色的,但我们把它改成了黄色)
例如,img1上(0,0)处的像素将与img2上(0,0)处的像素相乘(同样地,图像上其他位置的每一个像素也是如此)。
如果img1中的(0,0)像素是白色的(意味着它是一条边),img2中的(0,0)像素是黑色的(意味着这个点不是我们的车道线所在的孤立区域的一部分),操作看起来像11111111* 0000000,等于0000000(一个黑色像素)。
我们将对图像上的每个像素重复这个操作,导致只输出掩码中的边缘。
其他一切都被忽略了,仅输出隔离区域中的边。
现在我们已经定义了我们想要的边,接着让我们定义一个函数把这些边变成线:
lines = cv2.HoughLinesP(isolated, rho=2, theta=np.pi/180,
threshold=100, np.array([]), minLineLength=40, maxLineGap=5)
这一行代码是整个算法的核心,它被称为霍夫变换(Hough Transform),将孤立区域的白色像素簇转换为实际的线条。
参数1:孤立梯度
参数5:占位符数组
参数6:最小行长
参数7:最大行间距
下面的部分将深入到算法背后的具体细节,所以在你们读完第二部分后,你们可以回到这部分,希望这部分会更有意义。
第二部分:霍夫变换
简单说明一下,这部分仅仅是理论,如果你们想跳过这一部分,可以继续阅读第3部分,但鼓励小伙伴们通读一遍。
来谈谈霍夫变换。在笛卡尔平面(x和y轴)中,直线由公式y=mx+b定义,其中x和y对应于直线上的一个特定点,m和b分别对应于斜率和y轴截距。
笛卡尔坐标空间中的直线
平面被绘制成x和y值的函数,这意味着我们显示的是这条直线有多少(x, y)对组成(有无穷多的x, y对组成任何一条线,这就是为什么线延伸到无穷远的原因)。
但是,可以用它的m和b值绘制直线,这是在一个叫做霍夫空间的平面上完成的。为了理解Hough变换算法,我们需要了解Hough空间是如何工作的。
霍夫空间的解释
在我们的用例中,我们可以将霍夫空间总结为两行:
笛卡尔平面上的点在霍夫空间中变成直线
笛卡尔平面上的直线在霍夫空间上变成点
想想线的概念,一条线基本上是由一个接一个有序排列的无穷长的点组成的。因为在笛卡尔平面上,我们画的线是x和y的函数,线被显示为无限长因为有无限多的(x, y)对组成了这条线。
现在在霍夫空间中,我们画出直线作为m和b值的函数。因为每条笛卡尔直线上只有一个m和b值,所以这条直线可以表示为一个点。
例如,方程y=2x+1表示笛卡尔平面上的一条直线。它的m和b值分别是' 2 '和' 1 ',这是这个方程唯一可能的m和b值。另一方面,这个方程可以有很多x和y的值,使得这个方程成立(左边=右边)。
如果我们要用m和b的值来画这个方程,我们只会用点(2,1);如果我们要用x和y的值来画这个方程,我们将会有无穷多的选择因为有无穷多的(x, y)对。
把θ看成b, r看成m。稍后我们会在文章中解释θ和r的相关性。
那么为什么霍夫空间中的线在笛卡尔平面上被表示为点(如果你们从之前的解释中很好地理解了这个理论,我们希望小伙伴们在没有阅读解释的情况下就能解决这个问题)。
现在我们考虑笛卡尔平面上的一点。笛卡尔平面上的一个点只有一个可能的(x, y)对可以表示它,因此它是一个点,不是无限长。关于一个点,还有一个事实就是有无限多的可能的线可以通过这个点,换句话说,这个点可以满足无穷多个方程(y=mx + b)(LS=RS)。
目前,在笛卡尔平面中,我们根据x和y值绘制这个点。但是在霍夫空间中,我们根据它的m和b值来画这个点,因为有无限条线穿过这个点,所以在霍夫空间中会得到一条无限长的线。
以点(3,4)为例,可以通过该点的直线有:y= -4x+16, y= -8/3x + 12和y= -4/3x + 8(直线有无穷多,但为了简单起见,我们用3条直线)。
如果你们在霍夫空间中绘制每一条直线([- 4,16],[-8/ 3,12],[-4/ 3,8]),在笛卡尔空间中代表每条直线的点将在霍夫空间中形成一条直线(这条直线对应于点(3,4))。
每个点代表前面显示的线(匹配颜色)
现在如果我们在个笛卡尔平面上放置另一个点呢?这在霍夫空间会有什么结果呢?通过霍夫空间,我们可以找到笛卡尔平面上最适合这两点的直线。
我们可以通过在霍夫空间中绘制与笛卡尔空间中两点相对应的直线,并找到这两条直线在霍夫空间中相交的点(a.k.a它们的POI,交叉点)。
总结上述内容:
笛卡尔平面上的直线在霍夫空间中表示为点
笛卡儿平面上的点在霍夫空间中表示为直线
通过求霍夫空间中与这两个点对应的两条直线的POI的m和b坐标,可以找到笛卡尔空间中两点的最佳拟合直线,然后根据这些m和b的值组成一条直线。
回到解释:
虽然这些概念比较好,但它们为什么重要呢?还记得我们之前提到过的Canny边缘检测吗?它使用梯度来测量图像中的像素强度并输出边缘。
在其核心,梯度只是图像上的点。所以我们能做的就是找到最适合每一组点的直线(图像左边的梯度和图像右边的梯度),这些最合适的线是我们的车道线。为了更好地理解它是如何工作的,让我们再深入了解一下!
我们只是解释了如何通过查看m和b值来找到最合适的线对应于霍夫空间中的点的两条线的POI。然而,当我们的数据集增长时,并不总是有一条线完全适合我们数据。
这就是我们不得不使用容器的原因。当合并容器时,我们将霍夫平面划分为等距部分。每个部分都称为容器,通过关注容器中POI的数量,使我们能够确定一条与我们的数据具有良好相关性的线。一旦找到有最多交集的容器,我们就可以使用m和b值,它们与该容器相对应,并在笛卡尔空间中形成一条直线,这条线就是最适合我们的数据的线。
但是在垂直线上,斜率是无穷大的,我们不能在霍夫空间中表示无穷,这将导致程序崩溃。所以我们不用y=mx+b来表示直线方程,我们用P()和θ()来定义直线,这也被称为极坐标系统。
在极坐标下,直线用方程P=xsinθ + ysinθ表示。在我们深入研究之前,让我们定义一下这些变量的含义:
P表示从原点垂直于直线的距离。
θ表示从正x轴到直线的俯角。
xcosθ表示x方向上的距离。
ysinθ表示y方向上的距离。
这是对极坐标含义的直观解释
用极坐标系统,即使有一条垂直线,也不会有任何误差。例如,取点(6,4)代入方程 P=xcosθ+ ysinθ。现在,我们取经过这个点x=6的垂直线,把它代入极坐标方程,P = 6cos(90) + 4sin(90)
θ是一条垂直线的90度,因为它从正x轴到直线本身的俯角是90度。θ的另一种表示方法是π/2(弧度)。如果你们想了解更多关于弧度的知识,以及我们为什么要使用它们,这里有一个很好的视频。然而,没有必要知道弧度是什么。
X和Y取点(6,4)的值因为这是我们在这个例子中使用的点。
现在我们把这个方程解出来:
P = 6cos(90) + 4sin(90)
P = 6(1) + 4(0)
P = 6
如我们所见,我们不会以错误结束。事实上,我们甚至不需要做这个计算,因为我们在开始之前就已经知道P是多少了。注意,这和从原点到x轴的距离是一样的。
我们想解释的东西的图像。
那么现在这已经解决了问题,我们准备好回去编码了吗?不是现在。还记得之前我们在笛卡尔平面上画点的时候吗?我们最终会得到霍夫空间中的直线?当我们使用极坐标时,我们会得到一条曲线而不是一条直线。然而,概念是一样的,我们将找到具有大多数交叉点并使用那些m和b值来确定最佳拟合线。
第三部分:优化+显示
这一节是为了优化算法,如果我们不平均这些线,它们看起来很不稳定,因为cv2.HoughLinesP输出一串小线段,而不是一条大线。
为了平均这些线,我们将定义一个“average”函数。
def average(image, lines):
left = []
right = []
for line in lines:
slope = parameters[0]
y_int = parameters[1]
if slope < 0:
left.append((slope, y_int))
else:
right.append((slope, y_int))
这个函数对cv2.HoughLinesP函数中生成的行进行平均,它会找到左右两个线段的平均斜率和y轴截距,并输出两条实线(一条在左边,另一条在右边)。在cv2.HoughLinesP函数的输出中,每个线段有两个坐标:一个表示直线的开始,另一个表示直线的结束。利用这些坐标,我们要计算每条线段的斜率和y轴截距。
然后,我们将收集所有线段的斜率,并将每个线段分为与左线或右线对应的列表(负斜率=左线,正斜率=右线)。
第4行:通过直线数组进行循环。
第5行:从每个线段中提取两个点的(x, y)值。
第6-9行:确定每个线段的斜率和y轴截距。
第10-13行:将负斜率添加到左行列表中,将正斜率添加到右行列表中。
注意:通常情况下,正斜率=左直线,负斜率=右直线,但在我们的例子中,图像的y轴是反的,这就是为什么斜率是反的(OpenCV中的所有图像都是反的y轴)。
接下来,我们要从两个表中求斜率和y轴截距的平均值。
right_avg = np.average(right, axis=0)
left_avg = np.average(left, axis=0)
left_line = make_points(image, left_avg)
right_line = make_points(image, right_avg)
return np.array([left_line, right_line])
第1-2行:对两个列表(左边和右边)的所有线段取平均值。
第3-4行:计算每一行的起始点和端点。(我们将在下一节定义make_points函数)
第5行:输出每一行的2个坐标。
现在我们有了两个列表的平均斜率和y轴截距,让我们定义两个列表的起点和终点。
def make_points(image, average):
slope, y_int = average
y1 = image.shape[0]
y2 = int(y1 * (3/5))
x1 = int((y1 — y_int) // slope)
x2 = int((y2 — y_int) // slope)
return np.array([x1, y1, x2, y2])
这个函数有两个参数,一个是带有车道线的图像,另一个是有平均斜率和y_int的列表,输出每条线的起点和终点。
第1行:定义函数
第2行:得到平均斜率和y截距
第3 - 4行:定义的高度线(左右两边都一样)
第5 - 6行:通过重新排列一条线的方程计算x坐标,从y=mx+b to x = (y-b) / m
第7行:输出坐标集
为了进一步说明,在第一行,我们用y1值作为图像的高度。这是因为在OpenCV中,y轴是倒转的,所以0在顶部,而图像的高度在原点(参考下图)。同样,在第二行,y1乘以3/5,这是因为我们想让直线从原点y1开始,以图像的2/5结束。
应用于左线的make_points函数的可视化示例
但是,这个函数并不显示这些线,它只计算显示这些线所需的点。接下来,我们要创建一个函数,它取这些点,并用它们来画线。
def display_lines(image, lines):
lines_image = np.zeros_like(image)
if lines is not None:
for line in lines:
x1, y1, x2, y2 = line
cv2.line(lines_image, (x1, y1), (x2, y2), (255, 0, 0), 10)
return lines_image
这个函数有两个参数:我们想要显示线条的图像以及从平均函数输出的车道线。
第2行:创建一个与原始图像相同尺寸的黑色图像
第3行:确保包含线点的列表不是空的
第4-5行:循环遍历列表,并提取两对(x, y)坐标
我们可能会想,为什么我们不把这些线添加到真实图像上,而是黑色图像上。因为原始图像有点太亮了,所以如果我们把它调暗一点,让车道线看得更清楚一点就好了(是的,我们知道,这不是大不了的,但找到改进算法的方法总是很好的)。
左:直接添加线条到图像。右:使用cv2.addddled函数
所以我们要做的就是调用cv2.addWeighted函数:
lanes =cv2.addWeighted(copy, 0.8, black_lines, 1, 1)
这个函数为实际图像中的每个像素赋予0.8的权重,使它们稍微暗一些(每个像素乘以0.8)。同样地,我们给所有车道线的黑色图像赋予1的权重,这样所有像素都保持相同的强度,使其突出。接下来我们要做的就是调用这些函数:
copy = np.copy(image1)
grey = grey(copy)
gaus = gauss(grey)
edges = canny(gaus,50,150)
isolated = region(edges)
lines = cv2.HoughLinesP(isolated, 2, np.pi/180, 100, np.array([]),
minLineLength=40, maxLineGap=5)
averaged_lines = average(copy, lines)
black_lines = display_lines(copy, averaged_lines)
在这里,我们简单地调用前面定义的所有函数,然后在第12行输出结果,cv2.waitKey函数用于告诉程序图像显示需要多长时间。我们将“0”传递给函数,这意味着它将等待,直到按下一个键关闭输出窗口。
输出结果:
我们也可以把这个算法应用到视频上。
video = r”D:\users\new owner\Desktop\TKS\Article Lane
Detection\test2_v2_Trim.mp4"
cap = cv2.VideoCapture(video)
while(cap.isOpened()):
ret, frame = cap.read()
if ret == True:
#----THE PREVIOUS ALGORITHM----#
gaus = gauss(frame)
edges = cv2.Canny(gaus,50,150)
isolated = region(edges)
lines = cv2.HoughLinesP(isolated, 2, np.pi/180, 50,)
lanes = cv2.ad1dWeighted(frame, 0.8, black_lines, 1, 1)
cv2.imshow(“frame”, lanes)
#----THE PREVIOUS ALGORITHM----#
if cv2.waitKey(10) & 0xFF == ord(‘q’):
break
else:
break
cap.release()
cv2.destroyAllWindows()
这段代码将我们为图像创建的算法应用到视频中。记住,一个视频就是一串快速出现的图片。
第1-2行:定义视频的路径。
第3-4行:捕获视频(使用cv2. videcapture),并循环遍历所有帧。
第5-6行:读取帧,如果有帧,继续。
第10-18行:从前面的算法复制代码,并将所有使用Copy的地方替换为frame,因为我们想确保我们操作的是视频的帧,而不是前面函数中的图像。
第22-23行:显示每一帧10秒,如果按下“q”按钮,退出循环。
第24-25行:它是第5-6行if语句的延续,但它所做的只是如果没有任何帧,就退出循环。
第26-27行:关闭视频
我们刚刚建立了一个可以检测车道线的算法,希望小伙伴们喜欢构建这个算法,但不要止步于此,这只是一个关于计算机视觉世界的入门项目。
关键点:
使用高斯模糊去除图像中的所有噪声
使用canny边缘检测来分离图像中的边缘
关键字:
如果小伙伴们好奇,这里有一些与这个算法相关的关键术语,小伙伴们可以更深入地研究。
高斯模糊
位和二进制
精明的边缘检测
霍夫变换
梯度
极坐标
OpenCV车道线检测
其他需要考虑的资源:
youtube视频。
Github代码连接:
https://github.com/Nushaine/lane-detection/blob/master/Untitled33.ipynb
好消息,小白学视觉团队的知识星球开通啦,为了感谢大家的支持与厚爱,团队决定将价值149元的知识星球现时免费加入。各位小伙伴们要抓住机会哦!
下载1:OpenCV-Contrib扩展模块中文版教程
在「小白学视觉」公众号后台回复:扩展模块中文教程,即可下载全网第一份OpenCV扩展模块教程中文版,涵盖扩展模块安装、SFM算法、立体视觉、目标跟踪、生物视觉、超分辨率处理等二十多章内容。
下载2:Python视觉实战项目52讲
在「小白学视觉」公众号后台回复:Python视觉实战项目,即可下载包括图像分割、口罩检测、车道线检测、车辆计数、添加眼线、车牌识别、字符识别、情绪检测、文本内容提取、面部识别等31个视觉实战项目,助力快速学校计算机视觉。
下载3:OpenCV实战项目20讲
在「小白学视觉」公众号后台回复:OpenCV实战项目20讲,即可下载含有20个基于OpenCV实现20个实战项目,实现OpenCV学习进阶。
交流群
欢迎加入公众号读者群一起和同行交流,目前有SLAM、三维视觉、传感器、自动驾驶、计算摄影、检测、分割、识别、医学影像、GAN、算法竞赛等微信群(以后会逐渐细分),请扫描下面微信号加群,备注:”昵称+学校/公司+研究方向“,例如:”张三 + 上海交大 + 视觉SLAM“。请按照格式备注,否则不予通过。添加成功后会根据研究方向邀请进入相关微信群。请勿在群内发送广告,否则会请出群,谢谢理解~