道路识别算法详解
本文详细描述了当前代码中(git 版本: 16b521b12d2e3bdc00bd996acafe4526f1d1cb9a)道路识别的算法。
如果没有特殊说明,下文中所说的“算法”均指本代码中的道路识别算法。
目标
本算法的目标是识别出道路上较为清晰的道路标线,并给出道路标线的位置信息。
算法简要流程
选取 ROI(感兴趣区域);
对 ROI 进行俯视变换,得到鸟瞰图;
对鸟瞰图进行增强处理,增强车道线;
使用一个简化过的霍夫变换,识别直线,并作为可能的候选车道线;
使用上一步中得到的候选车道线,结合图像信息,拟合出实际的车道线;
使用卡尔曼滤波器修正车道线,以获得稳定的车道线位置。
详细流程
为了结合代码进行说明,将使用代码导读的方式慢慢分析算法。由于代码仍在开发中,变动频繁,建议你检出 tag 为 lane-detection-code-tour 的代码进行对照阅读。
选取 ROI
main() 函数位于 driveassist.cpp 文件中,该文件的开始部分定义了一些用于测试的视频的参数。由于该版本的代码仅做研究用,所以将测试视频文件的地址写死在了代码中。
roiX、roiY、roiWidth、roiHeight 就是 ROI 的参数,由于车道线仅仅会出现在视频画面中的固定区域,所以我们可以选取这块固定的区域作为 ROI,这样可以加快处理的速度,也可以避开环境的干扰。
ROI 参数根据不同的视频需要自行调节。
进行俯视变换
从视频画面中来看,车道线是倾斜而且不平行的,但如果我们从空中俯视车道线,应该能看到平行的车道线。平行的车道线比不平行的车道线更容易处理。另外,当我们把 ROI 变换成 IPM 后,在直行的道路上,车道线会变成垂直的线,这就能够让我们更容易地识别出车道线。
要严格地进行俯视变换,需要考虑到摄像头高度、倾角、仰角、光圈大小等不容易测量的量,但我们不需要进行精确的变换,我们只需要做一个简单的梯形变形,就能够得到一个俯视图。虽然这样做得到的俯视图并不精确,但对车道线识别来说已经够用了。
由于摄像头安装位置的不同,我们需要根据实际画面调整一些参数,以便进行俯视变换,我们将这个过程叫做标定。下面介绍标定的过程。
首先运行程序,选择一个车辆保持在车道线中央,且当前车道是直道的场景,接着开始进行标定。
请看下图,图片底部中央的绿色细线的矩形就是 ROI,举行中央的两条绿色细线是用于调整俯视变换参数的辅助线。
标定方法如下:
调整“原图”窗口中的参数,让 ROI 的左下角和右下角正好压在实际的车道线上,ROI 的下边线应该尽可能接近画面的底部;
调整“感兴趣区域”窗口中的 srcX 参数,让 ROI 中两条绿色细线和 ROI 上边线的交点正好压在实际的车道线上;
在确保满足前两条的条件下,适当调整 ROI 上边线的位置,尽量让 ROI 有较高的高度,同时确保 ROI 中两条绿色辅助线不要相距太近。
标定完成之后,可以将这些参数记下来,或者直接改写代码中预设的参数,这样在下次运行程序时,就不需要重新进行标定了。
标定原理是这样的,画面中的车道线和 ROI 的上下两条边线组成了一个梯形,我们只要把这个梯形拉伸成一个矩形,就可以完成俯视变换。严格来说,这并不是一个标准的俯视变换,但我们只需要将车道线变换成平行的且垂直于地平线的两条线,这样的变换对我们来说已经足够了。
如下图所示,我们需要将 E、F 点分别拉伸到 A、B 两点,C 和 D 点保持不动,也就是说,我们的变换是这样的:
E --> A
F --> B
C --> C
D --> D
将这四个点的坐标传给 OpenCV 提供的 getPerspectiveTransform 方法,我们就得到了我们想要的俯视变换的变换矩阵。同时,我们可以求这个矩阵的伪逆矩阵,这样我们就可以将俯视图转换为 ROI 图。
下面是一个 ROI 转换成俯视变换的对比图,可以看出车道线已经变成了两条平行线,并且在屏幕上是垂直的。
车道线增强
现在我们已经获得了姿态比较好的车道线(车道线平行,且几乎是垂直的),为了进一步突出车道线,我们要使用一个特殊的高斯核函对图像进行卷积。
这个高斯核的数学表达是这样的:
这个高斯核的水平和垂直方向看起来是这样的( 和 均取 5):
两个高斯核合并起来之后,看起来就是这个样子的:
可以看到,这个高斯核在水平方向增强了中央的响应,弱化了周围的相应,同时,在垂直方向拉伸了响应。用这个高斯核对图像进行卷积后,就可以增强车道线的响应。
一般取车道线宽度在图像上占据的像素个数,而 一般取虚线车道线的长度在图像上占据的像素个数。
在代码中,高斯核的计算在 onGaussianChange 函数中完成。首先分别计算水平方向和垂直方向的卷积核,然后调用 OpenCV 提供的 sepFilter2D 方法,分别传入水平和垂直方向的卷积核,就可以得到最终的卷积结果。
完成卷积操作后,车道线会在图片上显现出最强的响应,这时候我们要做一个阈值化操作,将车道线过滤出来,把无关的背景(如路面纹理等)消除。
阈值化的操作很简单,计算图像中所有像素值的分布,确定一个阈值,然后保留所有像素值大于阈值的点,删除所有像素值小于阈值的点。在本代码中,我们使用了 97.5% 作为阈值,也就是取像素值分布中位于 97.5% 这个位置的像素值作为阈值。实际的阈值在每一帧中都不相同,而 97.5% 是固定的,需要根据每一帧的像素值分布来确定实际使用的阈值。
车道线经过增强后的效果如下:
使用简化的霍夫变换识别车道线
霍夫变换(Hough Transform)是一种识别图像中直线的方法。标准的霍夫变换会识别出图片中所有方向的直线,但耗时相对较长。对我们来说,我们只需要识别出几乎是垂直的车道线,对于其他类型的直线我们不关注。这样,我们可以极大地简化霍夫变换,提高识别速度。
本代码使用的简化过的霍夫变换流程如下:
在阈值化后的图片中,按列扫描图像,对于每一列,计算该列上值不为 0 的像素点的个数。最后,我们可以得到一个函数 ,其中 是图像的 x 坐标(也就是图像的第几列),函数值是 x 列上不为零的像素点的个数。
显然,如果在 列上存在一条接近垂直的直线,那么 的值就应该很大。事实上,由于图像经过阈值化处理,所以 应该是一个极大值。简单来说,如果我们把 的图像画出来,那么图像的波峰处应该存在一条直线。
在下图中, 被绘制到了“二维高斯模糊”图中,可以看到, 的波峰处确实是有一条几乎垂直的直线。这些识别出来的直线,就是我们想要寻找的车道线的候选。
上图中的 是经过处理的。原始的 函数在直线附近会出现多个波峰,这就会导致一条直线被识别为多条直线,所以,我们需要对 进行一些预处理后,再去寻找波峰(极大值)。
首先我们要对 做一个高斯模糊,这就可以合并大部分的极大值。高斯模糊的范围应该根据车道线的宽度来确认。在本代码中我们没有进行进一步的研究,仅仅依靠实验来确定了一个范围。
完成高斯模糊后,一些相距相对较远的极值点仍无法被合并(如双实线类型的车道线,我们只希望将双实线识别为一条车道线),所以我们仍需要手动对这些极值点进行合并,合并方法如下所述:
确定一个领域范围 a,对距离小于 a 的两个极值点进行合并。如果两个极值点的位置分别为 和 ,那么合并后的极值点位置 应为:
其实这是一个按照 的值作为权重,对两个极值点的位置进行加权求和的过程。对于两个相邻的极值点,合并后的极值点位置将会更偏向 值更大的那个极值点。
上面给出的公式是在代码中使用的公式,如果改写成下面的样子,会更容易看出这个公式的加权思想:
其中
合并极值点的操作到此完成。合并后的极值点就可以认为是候选的车道线。接下来,就可以使用这些候选的车道线,结合图像信息,拟合出实际的车道线。
以上合并极值点以及寻找极值点的代码实现,位于 findPeaks 函数中。
拟合实际的车道线
一般来说,车道线应该总是位于车辆的两侧,所以在俯视图中,车道线应该位于图片中央的两侧。在上一步中,我们已经得到了候选车道线在俯视图中的 x 坐标,我们可以选取距离图像中央最近的左右两个候选车道线作为实际车道线的位置。利用这两条车道线,结合图像信息,可以拟合出实际的车道线。
拟合方法有很多种,可以大致分为曲线拟合和直线拟合两类。在 github 的提交记录中,你可以发现我们尝试过曲线拟合,但最后实在没有找到能够比较好地拟合车道的曲线,所以现在的代码中只使用了直线拟合。
直线拟合相对于曲线拟合的优点是算法简单,计算速度稍快。不过直线拟合的缺点也是比较明显的:在弯道中,俯视图中的车道线其实是一条曲线,而直线拟合是无法拟合曲线的。
直线拟合无法拟合弯道上的车道线,这个问题倒不是很严重。目前我们做车道识别的目的是为了实现车道保持功能,我们只要保证拟合出来的直线车道方向及位置大致上符合实际车道的方向和位置即可。
在现在的代码中,存在几种直线拟合扯到的方法,其中大部分被注释掉了,只留下了一个名为“高级直线拟合道路”的方法。下面会详细介绍这个算法。
拟合道路的方法位于 LineFit 类中,这个类接受的输入是俯视图像和候选车道线位置(在上一步中我们选出来的两个极值点)。
对于每一个输入的车道线位置 ,我们做如下计算:
选取一个邻域范围 a,任意选一个 ,保证 ,令点 为 P1,任意选一个 ,保证 ,令点 为 P2. 连接 P1 和 P2,得到一条直线 Q. 其中, 是图像高度。
遍历所有的直线 Q,我们应该能够得到 条直线,不妨称呼这些直线为 .
对于每一条直线 ,遍历这条直线经过的所有像素点,统计值不为 0 的像素点的个数,并将其作为这条直线的分数。
所有的直线分数都计算出来后,取分数最大的直线,作为实际的车道线。
很显然,这样选取出来的车道线最有可能是实际的车道线。
在代码的实现上,有一些细节需要注意。计算直线的分数时,需要确定直线经过的像素点,直线经过哪些像素点,是这样计算出来的:
取 ,其中 是图像高度, 是整数。对于所有的 ,利用直线的表达式计算 ,于是直线经过的点就是 。最后,我们可以得到 个点。
按此方法选取直线经过的点,可以保证所有直线经过的点的数量都是固定的,最后计算出来的直线分数才具有可比性。
卡尔曼滤波
实际的路面上,不可能总是有清晰无比的道路标线,破烂不堪模糊不清的道路标线是很常见的。对于此类标线,即使是人类都不太容易分别出来,以目前的技术水平就更不可能精确识别了。
我们希望,在无法识别车道线的时候,也能做出一个对于当前实际车道线位置的猜测,为实现这个目标,本代码在最后使用了一个卡尔曼滤波器对识别出来的车道线进行滤波。
对识别出来的车道线进行滤波的好处有两个,第一个好处是,在无法识别出车道线的时,也能给出车道线可能的位置。目前只能给出一个可能的位置,而无法给出车道线位于该位置上的概率,不过,可以在此基础上继续改进算法,使算法可以输出一个概率,上层的自动驾驶程序可以根据此概率进行更好的决策。
第二个好处是,可以识别出来的车道线位置进行平滑。路面的实际状况千变万化,在进行实际的识别任务时,可能在某帧画面上无法识别出车道线,但该帧之前及之后的帧上都能较好地识别出车道线。另外,由于复杂的环境的干扰,识别出来的车道线位置可能会在小范围内摆动。对车道线进行滤波后,我们就可以得到一个足够稳定的车道线预测位置。
本代码使用车道线的坐标点位置作为卡尔曼滤波器的测量变量。对于在一个图像中识别出来的车道线,车道线应该在直线 y = 0 以及 y = h - 1 (h 是图像高度)上分别经过点 和点 (. 和 就是我们输入给卡尔曼滤波器的预测变量。由于一共有两条车道线(左车道线和右车道线),所以我们一共有 4 个变量。
之所以使用两个点的 x 坐标来表示车道线,而不使用截距和斜率来表示车道线的原因是:卡尔曼滤波器是一个线性滤波器,它不能处理非线性变化的变量。如果使用斜率和截距来表示车道线,图像上车道线的斜率 k 与实际车道线的位置并不呈现线性变化关系。当然 与车道线的位置是呈现线性相关的,如果用 代替斜率,就可以使用卡尔曼滤波器。另外,在我们的处理过程中,使用车道线两端点的水平坐标来表示车道线的变化显然更直观。
在这里,我们需要建立一个假设:在大部分时间下,车辆应该行驶在车道中央,且车道是直道。当车辆位置及道路条件满足这个假设的时候,车辆线在图像上的位置应该是固定的,我们把此时的车道线称为理想车道线,理想车道线的位置则作为无法检测到车道时,传递给卡尔曼滤波器的测量变量。
我们的卡尔曼滤波过程如下:
对左右两条车道线,分别进行以下步骤:
1. 检测车道线;
1. 如果检测到了车道线,则将车道线的位置作为测量量传给卡尔曼滤波器;如果没有检测到车道线,则将理想车道线的位置传递给卡尔曼滤波器;
1. 使用卡尔曼滤波器对车道线进行一次预测,预测结果就作为算法最终给出的车道线位置。
在代码的实现上,我们并没有将两条车道线分别传给两个卡尔曼滤波器,我们直接把两条车道线的四个位置传给了一个卡尔曼滤波器,这样做的效果和上述步骤是一样的。
本代码中使用的过程噪声协方差矩阵的对角线元素都是 ,这是一个经验值。
测量噪声矩阵是:
这也是一个经验值,这里需要解释一下为什么右侧车道的测量误差比左车道大。在我们用于测试的视频中,车辆的左车道线是双实线,而右侧是虚线,双实线显然比虚线更容易检测,这就是左车道线的测量误差小于右车道线的原因。
需要指出的是,代码中使用的这些经验值,仅仅是根据很少的几个测试视频测试得到的经验值。如果要用于更一般的情形,这些经验值应该进行修改。最好的做法是记录实际车道线与预测车道线的偏差,然后根据记录的数据计算出协方差矩阵,不过这样做的工作量太大,更简单迅速但不够严格的做法就是根据测试情况选取经验值。
这里给出一个演示视频:带卡尔曼滤波的道路标线识别——一般城市道路。在视频中可以看到,没有经过卡尔曼滤波的车道线存在小范围跳动的情况,而滤波后的车道线位置则更为平滑。如果车道线检测难度增大,算法就会更多地使用理想车道线的位置(算法给出的车道线位置逐渐偏向理想车道线)。在完全无法检测到车道线时,算法会直接使用理想车道线的位置作为预测车道线。
结束语
整个车道检测算法的介绍到这里就结束了。由于笔者水平有限,文中难免出现谬误,还请多多拍砖。