车道线检测 c++ 实现
完整代码及车道线数据链接:
github:https://github.com/xuzf-git/lane_detection_by_DIP
欢迎来 star && fork && issue ~~
1、主要内容
使用数字图像处理的基本方法,构建一个车道线检测模型。该模型可以识别图像中所有的车道线,并得到完整的车道线信息。模型在tuSimple Lane Dataset大小为100的数据子集进行了测试,达到了较好的结果。本文专注于体会和理解数字图像处理中的基本算法,因此除图像的读入和展示使用OpenCV外,算法均由C++标准库实现。
2、实现思路
实现车道线检测,主要包含两部分操作
道路图像的处理,主要包括灰度图转换、基于高斯平滑的图像去噪、基于Canny算法的边缘提取
车道线检测方法,主要包括获取感兴趣区域(ROI)、形态学闭运算、基于Hough变换的直线检测
模型的处理流程如下:
2.1 道路图像处理
通过对道路图像进行处理,突出图像中的车道线部分。模型将彩色图像转化成灰度图像进行处理,目的是简化模型的复杂度,提高运行效率。
2.1.1 高斯平滑
由于光照、路面情况、拍摄质量等问题,道路图像上存在很多噪声,通过高斯滤波使图像变得平滑,减弱图像中的噪声对结果的影响,提高车道线检测模型的鲁棒性。
高斯平滑就是使用高斯滤波器与原图像进行卷积,得到平滑图像。与均值滤波类似,它们都是取滤波器窗口内像素的加权均值作为输出。高斯滤波器的权值分布满足二维高斯函数。 h ( x , y ) = e − x 2 + y 2 2 σ 2 h(x,y)=e^{- \frac{x^2 + y^2}{2 \sigma ^2}}h(x,y)=e
−
2σ
2
x
2
+y
2
由于高斯平滑是线性离散滤波,因此离散形式的高斯滤波器为 H i , j = 1 2 π σ 2 e ( i − k − 1 ) 2 + ( j − k − 1 ) 2 2 σ 2 H_{i,j} = \frac{1}{2\pi\sigma^2}e^{\frac{(i - k -1)^2 + (j - k - 1)^2}{2\sigma^2}}H
i,j
=
2πσ
2
1
e
2σ
2
(i−k−1)
2
+(j−k−1)
2
本实验采用 3 × 3 3\times33×3 的高斯滤波器。具体实现为定义 Kernel 类实现通用的卷积操作,定义派生类 GaussianKernel 实现不同 size 和 σ \sigmaσ 高斯滤波器的构建的运算,实现接口如下:
/* Kernel.h */
class Kernel
{
public:
double **data;
int size;
Kernel(int size); // 空的卷积核
Kernel(Kernel &cp); // 拷贝构造函数
~Kernel();
double *operator[](const int idx) const;
// 卷积操作
template<typename T1, typename T2>
void convolve(const Img<T1> &src, Img<T2> &dst, const bool is_clip = true) const;
};
class GaussianKernel : public Kernel
{
public:
double sigma;
GaussianKernel(const int size, const double sigma);
GaussianKernel(GaussianKernel &cp);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2.1.2 边缘提取
在实验过程中,我曾尝试采用以下方法进行边缘提取的方法。由于在图像中车道线的灰度值较大,因此我设计了一种参数自适应的阈值分割算法,把车道线从图像中抽取出来。具体方法如下:统计图像的灰度分布,选取整体灰度分布相应比例对应的灰度值作为阈值,对图像进行二值化。效果如下:
可以发现,通过阈值分割有效的过滤掉了大部分背景,如山脉、路面、车辆,这为下面的直线检测去除了一定的干扰。但是由于部分图像存在反光或较亮区域,这导致一些车道线丢失,或特征不再明显,如下图。
虽然可以通过求图像梯度的方法将大面积的高亮度区域滤除,但是直接将原图转成二值图像处理,会丢失车道线的细节信息导致结果车道线信息不完整。因此舍弃该方案。
最终采用基于图像梯度的边缘提取方法——Canny算法。Canny主要包含三个步骤:
Sobel算子:计算图像梯度
非极大值抑制:去除非边缘的噪点,细化边缘
双阈值:检测并连接边缘
(1)Sobel 算子计算图像梯度
灰度图可以看做灰度值 h ( x , y ) h(x,y)h(x,y) 关于 ( x , y ) (x,y)(x,y) 坐标的二元函数,计算图像梯度可以通过Sobel算子计算得到。
x xx 方向梯度: g r a d x ( x , y ) = ∂ h ( x , y ) ∂ x {grad}_x(x,y) = \frac{\partial h(x,y)}{\partial x}grad
x
(x,y)=
∂x
∂h(x,y)
y yy 方向梯度: g r a d y ( x , y ) = ∂ h ( x , y ) ∂ x {grad}_y(x,y) = \frac{\partial h(x,y)}{\partial x}grad
y
(x,y)=
∂x
∂h(x,y)
梯度幅度: g r a d = g r a d x 2 + g a r d y 2 grad = \sqrt{{grad_x}^2 + {gard_y}^2}grad=
grad
x
2
+gard
y
2
梯度方向:g a r d θ = a r c t a n ( g r a d y g r a d x ) gard_\theta = arctan(\frac{grad_y}{grad_x})gard
θ
=arctan(
grad
x
grad
y
)
其中计算 x , y x,yx,y 方向的梯度使用Sobel算子对图像进行卷积
g r a d x = [ − 1 0 1 − 2 0 2 − 1 0 1 ] × i m g g r a d y = [ 1 2 1 0 0 0 − 1 − 2 − 1 ] × i m g grad_x =
⎡⎣⎢−1−2−1000121⎤⎦⎥
[
−
1
0
1
−
2
0
2
−
1
0
1
]
\times img \quad grad_y =
⎡⎣⎢10−120−210−1⎤⎦⎥
[
1
2
1
0
0
0
−
1
−
2
−
1
]
\times img
grad
x
=
⎣
⎡
−1
−2
−1
0
0
0
1
2
1
⎦
⎤
×imggrad
y
=
⎣
⎡
1
0
−1
2
0
−2
1
0
−1
⎦
⎤
×img
Sobel 算子计算梯度效果如下:
/* Sobel 算子:计算图像梯度 */
void Sobel(const Img<uchar> &src, Img<uchar> &dst, Img<double> &theta)
{
assert(src.rows == dst.rows);
assert(src.cols == dst.cols);
const double sobelX_arr[3][3] = {
{-1, 0, 1},
{-2, 0, 2},
{-1, 0, 1}
};
const double sobelY_arr[3][3] = {
{1, 2, 1},
{0, 0, 0},
{-1, -2, -1}
};
Kernel sobelX(3);
Kernel sobelY(3);
for (int i = 0; i < 3; ++i)
{
for (int j = 0; j < 3; ++j)
{
sobelX[i][j] = sobelX_arr[i][j];
sobelY[i][j] = sobelY_arr[i][j];
}
}
Img<double> imgGradX(src.rows, src.cols);
Img<double> imgGradY(src.rows, src.cols);
sobelX.convolve(src, imgGradX, false);
sobelY.convolve(src, imgGradY, false);
for (int i = 0; i < src.rows; ++i)
{
for (int j = 0; j < src.cols; ++j)
{
dst[i][j] = sqrt(imgGradX[i][j] * imgGradX[i][j] + imgGradY[i][j] * imgGradY[i][j]);
if (fabs(imgGradX[i][j]) < 1e-10) // 防止除以0,导致溢出
imgGradX[i][j] = 1e-10;
theta[i][j] = atan((double) imgGradY[i][j] / imgGradX[i][j]);
}
}
}
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
(2)非极大值抑制
分析上图发现,由于图像灰度存在起伏,所以有一些不是边缘的区域也存在较大的梯度。采用非极大值抑制(NMS)的方法,消除梯度图像中非边缘的噪声,并将边缘细化。
NMS实现的思路如下:计算每个中心像素点沿梯度方向邻域内各点的梯度值,如果该中心像素点的梯度值是以上像素点梯度值的局部极大值,则保留梯度,否则梯度置为零。由于邻域内在梯度方向上的点不一定是在整数坐标位置,因此需要通过插值计算邻域内梯度方向点的梯度值。实现效果如下:
一些非边缘的噪点得到了一定程度的抑制,边缘也得到细化。
关于Canny边缘检测中非极大值抑制原理的详细说明,可以参考博客 Canny算子中的非极大值抑制(Non-Maximum Suppression)分析
/* 非极大值抑制 */
void NonMaxSuppression(const Img<uchar> &src, Img<uchar> &dst, const Img<double> &theta)
{
assert(src.rows == dst.rows);
assert(src.cols == dst.cols);
// 将 src 的值拷贝到 dst 中
dst = src;
uchar local[3][3];
uchar temp1, temp2;
double weight;
const double PI_2 = PI / 2;
const double PI_4 = PI / 4;
for (int i = 1; i < src.rows - 1; ++i)
{
for (int j = 1; j < src.cols - 1; ++j)
{
// 记录考察点的局部值
for (int x = 0; x < 3; ++x)
{
for (int y = 0; y < 3; ++y)
{
local[x][y] = src[i - 1 + x][j - 1 + y];
}
}
if (theta[i][j] > -PI_2 && theta[i][j] <= -PI_4)
{
weight = fabs(1 / tan(theta[i][j]));
temp1 = uchar(weight * local[2][2] + (1 - weight) * local[2][1]);
temp2 = uchar(weight * local[0][0] + (1 - weight) * local[0][1]);
if (local[1][1] <= temp1 || local[1][1] <= temp2)
dst[i][j] = 0;
} else if (theta[i][j] > -PI_4 && theta[i][j] <= 0)
{
weight = fabs(tan(theta[i][j]));
temp1 = uchar(weight * local[2][2] + (1 - weight) * local[1][2]);
temp2 = uchar(weight * local[0][0] + (1 - weight) * local[1][0]);
if (local[1][1] <= temp1 || local[1][1] <= temp2)
dst[i][j] = 0;
} else if (theta[i][j] > 0 && theta[i][j] <= PI_4)
{
weight = tan(theta[i][j]);
temp1 = uchar(weight * local[0][2] + (1 - weight) * local[1][2]);
temp2 = uchar(weight * local[2][0] + (1 - weight) * local[1][0]);
if (local[1][1] <= temp1 || local[1][1] <= temp2)
dst[i][j] = 0;
} else if (theta[i][j] > PI_4 && theta[i][j] < PI_2)
{
weight = 1 / tan(theta[i][j]);
temp1 = uchar(weight * local[0][2] + (1 - weight) * local[0][1]);
temp2 = uchar(weight * local[2][0] + (1 - weight) * local[2][1]);
if (local[1][1] <= temp1 || local[1][1] <= temp2)
dst[i][j] = 0;
}
}
}
}
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
(3)双阈值检测和边缘连接
需要将得到的梯度图像进行阈值分割,得到二值图以便后续进行hough变换。采用双阈值对图像进行阈值分割,实现思路如下:
当梯度值大于高阈值时,将其灰度值取为255。
当梯度值小于低阈值时,将其灰度值取为0。
当梯度介于两者之间是,如果该点邻域内有高阈值点,则取为255,否则取0。
双阈值处理中,高阈值将物体边缘和背景区分开,但是当高阈值较大时,可能导致边缘断断续续;此时低阈值平滑边缘轮廓,能实现较好的分割效果。同时借鉴之前尝试对灰度图做阈值分割的思路,采用整体灰度分布相应比例处的灰度值为高阈值,低阈值取高阈值的 2 3 \frac{2}{3}
3
2
,实现效果如下:
/* 双阈值检测 & 连接边缘 */
void DoubleThreshold(Img<uchar> &image, const double weight)
{
double highThreshold;
double lowThreshold;
bool flag = false;
std::vector<uchar> vec;
// 高阈值取灰度分布图中 weight 对应的灰度值
for (int i = 0; i < image.rows; ++i)
{
for (int j = 0; j < image.cols; ++j)
vec.push_back(image[i][j]);
}
std::sort(vec.begin(), vec.end());
highThreshold = vec[weight * image.rows * image.cols];
// 低阈值为高阈值的 2/3
lowThreshold = highThreshold / 1.5;
for (int i = 1; i < image.rows - 1; ++i)
{
for (int j = 1; j < image.cols - 1; ++j)
{
if (image[i][j] < lowThreshold) // 检测低阈值
image[i][j] = 0;
else if (image[i][j] > highThreshold) // 检测高阈值
image[i][j] = 255;
else // 介于双阈值之间,连接边缘
{
// 检查邻域中是否有高于阈值点(排除孤立的局部极大值点)
for (int x = -1; x < 2; ++x)
{
if (flag) break;
for (int y = -1; y < 2; ++y)
{
if (image[i + x][j + y] > highThreshold)
{
image[i][j] = 255;
flag = true;
break;
}
}
}
if (!flag)
image[i][j] = 0;
}
}
}
}
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
2.2 车道线检测
2.2.1 梯形 ROI mask
经过图像的边缘提取,车道线边缘已经从图像中抽取出来。观察边缘图像发现:道路两边的环境复杂,存在很多干扰车道线检测的直线边缘,如天际线、山脉边缘、电线杆、树丛等。同时考虑到道路图像中,车道线集中在图像的中间偏下区域,因此可以仅对感兴趣区域(ROI)进行处理和检测。根据车道线图像特点,采用梯形掩码获取ROI。
观察图像选取了(400, 0) (220, 420) (200, 860), (400, 1280)四个点作为 mask 的角点。mask图像如下
ROI 效果如下:
/* 遮盖无效部分 */
void RoiMask(Img<uchar> &src)
{
// 梯形 ROI 区域进行 mask (400, 0) (220, 420) (200, 860), (400, 1280)
for (int i = 0; i < src.rows; ++i)
{
for (int j = 0; j < src.cols; ++j)
{
if (i <= 200)
src[i][j] = 0;
else if (i > 400)
continue;
else if (2.1 * (400 - i) > j || j > 1280 - 2.1 * (400 - i))
src[i][j] = 0;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2.2.2 hough 变换检测直线
hough变换是一种目标检测的方法,可以检测出有明确表达式的图形。hough 变换的基本原理:利用两个不同坐标系之间的变换来检测图像中的直线。将图像空间中的直线映射到参数空间的一个点,然后对参数空间中的点进行累计投票,进而得到参数空间中的峰值点,该峰值点就对应空间坐标系中的真实存在的直线参数。
hough变换中,直线采用极坐标方程表示,因为参数θ \thetaθ 和 r rr 的范围有限,便于以相同步长进行离散化
实现思路:
初始化参数空间(二维矩阵)
遍历空间坐标系的每个非零像素点,为所有可能经过该点的直线的参数进行投票。
找出参数空间中大于指定阈值的参数点
hough 变换效果如下图:
hough变换的原理可以参考博客 Hough直线检测的理解
由于车道线存在一定的弧度并非严格地直线,且存在一定宽度,导致每条车道线都会检测出多条对应直线。可以采用聚类的方法对检测出的直线进行聚类,以得到更精准的效果。
void HoughTransform(Img<uchar> &src, vector<pair<int, int>> &lines, int threshold)
{
// 参数空间的计数矩阵
int **count;
// 计数器初始化
int rows = src.rows;
int cols = src.cols;
int r_max = 2 * (int) sqrt(rows * rows + cols * cols) + 1;
count = new int *[181];
for (int i = 0; i < 181; ++i)
{
count[i] = new int[r_max];
memset(count[i], 0, r_max * sizeof(int));
}
// 参数空间变量
int theta, r;
// 遍历图像为每组参数投票
for (int row = 0; row < rows - 2; ++row)
{
for (int col = 2; col < cols - 2; ++col)
{
// 对边缘点进行统计
if (src[row][col] == 255)
{
for (theta = 0; theta < 181; ++theta)
{
r = int(row * sin(theta * PI / 180.0) + col * cos(theta * PI / 180.0) + r_max / 2.0);
count[theta][r]++;
}
}
}
}
// 遍历计数矩阵,选出超出阈值的参数
for (theta = 0; theta < 181; ++theta)
{
for (r = 0; r < r_max; ++r)
{
if (count[theta][r] >= threshold && abs(theta - 90) >= 15 && abs(theta) > 10 && 180 - theta > 10)
// if (count[theta][r] >= threshold)
{
if (theta > 90 && (r - r_max / 2) < 0)
lines.emplace_back(theta - 180, r_max / 2 - r);
else
lines.emplace_back(theta, r - r_max / 2);
}
}
}
// 直线参数聚类
lines_cluster(lines);
for (int i = 0; i < 181; ++i)
{
delete[] count[i];
}
delete[] 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
2.2.3 车道线聚类
由于 k-means 等聚类算法复杂度较高,影响车道线检测的实时性。所以我设计了一种高效的聚类方案。具体思路如下:根据以两个直线的角度参数距离为相似度函数,遍历hough变换检测出的所有直线参数,如果相似度高于阈值,则认为属于同一类别,该类别大小加一;如果相似度低于阈值,则认为属于不同类别,与下一个类中心点进行比较。如果没有相似的
伪代码如下:
params; // hough 变换得到参数列表
clusters; // 聚类列表
flag; // 标记是否新建类
for param in params
{
flag = true;
for cluster in clusters
{
if is_similar(param, cluster) // 如果相似则添加到该类中
{
flag = false;
update(cluster);
break;
}
}
if flag // 与现有的所有类都不同
clusters.append(param); // 添加新类
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这里相似度函数采用两条直线的角度参数的差值。
一开始选择的更新聚类中心的方法,是取同一类别的平均值,效果不佳。经过尝试最后采用取每个类别的初始值为中心点,实现较好的效果。示例如下:
评测结果对比:
中心点 Accuracy FP FN
数据均值 0.5740 0.7058 0.7533
聚类初始值 0.7539 0.5025 0.5242
分析原因:由于车道线有一定弧度,导致前半部分和后半部分的车道线参数差距较大。如果降低判定相似的标准,就会导致本不相似的直线求均值,从而使Accuracy较低;如果提高相似的标准就会,导致聚类得到类别很多,从而FP较大;因此采用加权均值更新聚类中心点并不理想。
按照车道线聚类结果中每个类别的大小,对聚类结果进行排序,选择所有聚类结果中规模最大的4个类作为最终确定的直线参数。
代码接口如下:
// 相似函数
bool is_similar(pair<int, int> &l1, pair<int, int> &l2);
// 更新类中心点
void update_cluster(pair<int, int> &line, pair<pair<int, int>, int> &cluster);
// 直线聚类
void lines_cluster(vector<pair<int, int>> &lines);
// hough变换
void HoughTransform(Img<uchar> &src, vector<pair<int, int>> &lines, int threshold);
1
2
3
4
5
6
7
8
2.3 输出结果
函数接口如下:
/* 根据车道线的参数,获取坐标向量 */
void GetLanes(Img<uchar> &src, vector<pair<int, int>> ¶ms, vector<vector<int>> &lanes);
/* 将检测结果写入json文件 */
void WriteJson(string &raw_file, vector<vector<int>> &lanes, double run_time, ofstream &of);
/* 展示车道线检测结果 */
void polyLanes(const string &path, vector<vector<int>> &lanes, int delay);
1
2
3
4
5
6
通过 GetLanes 将每个直线参数转换成直线坐标,WriteJson 函数将结果写入json文件,polyLanes 可视化展示车道线。
实现效果如下:
3、 数据结构
由于只允许使用OpenCV进行图像的读写操作,因此本实验构建了 Img 模板类,作为图像存储和操作的基本数据结构,代码接口如下:
template<typename T>
class Img
{
public:
T **data; // 存放数据
int rows; // 图像的行数
int cols; // 图像的列数
Img(int rows, int cols); /* 构造空值图像 */
Img(const char *path); /* 读入图像:灰度图 */
Img(Img &cp); /* Img类的复制构造函数 */
~Img();
T *operator[](const int idx) const;
Img &operator=(const Img &cp);
cv::Mat toMat() const; /* 将图像转换成 cv::Mat */
void show(const char *name, int delay) const; /* 展示图片 */
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
展示图片的plotLanes函数也使用了 OpenCV 框架对图像进行展示。
4、 实现效果
经过运行 TuSimple Lane Detection 项目的测评脚本,得到在数据子集上检测结果如下:
Accuracy FP FN
0.7539 0.5025 0.5242
实现了较好的测评效果。检测每张图像约用时0.4秒。
5、 总结及改进
实现过程中尝试了很多方案,如采用形态学运算,提高车道线的完整性;通过阈值分割,去除背景和干扰物;采用均值作为聚类中心等。由于方案设计上的主观缺陷和检测任务的存在的光照不均、环境复杂等客观因素,以上方案均被舍弃。最终经过实践得到了一种鲁棒性较好,效果较优的车道线检测方案。
查阅相关资料,了解到可以通过最大类间方差法(OTSU)进行阈值分割、动态ROI区域等。可以通过以上算法进一步提高模型精度和性能。
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_43586043/article/details/113834899