本博客使用的硬件是逐飞总钻风130°无畸变摄像头,采用的图像分辨率为188*120,主控为CH32V307VCT6,使用DVI接口进行连接。
我们在本次比赛中采用的是头尾车总钻风摄像头+中间车线性CCD的方案。这两个感光部件各有优劣,使用方式和图像处理也大相径庭。这里讨论的是总钻风摄像头二维图像数组的处理。
在CH32V307VCT6+逐飞开源库的架构下,总钻风摄像头采集的图像通过DMA传输至内存,该图像是一个120*188的二维256阶灰度数组mt9v03x_image_dvp
,元素取值范围为0~255,其中0为黑,255为白,坐标原点位于图像左上角。
我尝试过索贝尔算子边缘检测法和大津法二值化(期间也尝试过三角法二值化,由于在大多数情况下效果甚至不如大津法,所以弃用了)两种方法进行图像的预处理,考虑到今年是第一次做智能车,没有经验,再加上今年的CH32V307芯片实际算力也并不高(去年的CH32V103更加可怜,32KB的内存甚至只能允许处理半张图像),1月开始参考的是逐飞的灰度处理方案后,但是当时太菜了,根本看不懂这种思想,然后感觉这种方案相比大津法更加吃资源,因为每两个像素算一次差比和,浮点运算量很大,而大津法只需要遍历一次图像统计灰度直方图,其他的浮点运算交给FPU就好了(然而现实打脸,在-O0
优化等级下,大津法为35.3fps,索贝尔边缘检测为43.3fps),再加上网上的资料大多数是基于二值化图像进行的处理,大家的评价是“新手就老老实实用二值化”,所以我就选择了大津法方案。大津法方案其实有致命的缺陷,就是因为大津法采用了全图所有的信息,其原理用一句话大体概括就是“寻找灰度直方图最高的两个峰之间的谷底作为阈值”(这么说也未必准确,在我的理解里是这样的,欢迎大家进行指正),在光照不均匀时,全图干扰信息大量增加,灰度直方图会出现多峰的情况,这个时候的二值化阈值就会出现强烈的抖动,导致二值化图像出现严重的噪点失真甚至全黑全白,这种图像就无法使用了。直到6月,与使用龙邱库的四轮摄像头组同学交流后才知道,龙邱的库中有一套索贝尔算子边缘检测算法(没学过数字图像处理の痛),这套算法可以对图像中所有的边缘进行提取,可以直接有效地提取赛道边线,并且由于其采用的是局部信息,抗不均匀光照干扰能力大大增强(深入理解后才发现,逐飞的差比和就是这种算法的简化版!),实际使用后的效果惊艳到我了,因为它甚至可以在自然光的正面照射下识别出赛道的边线!但奈何知道得太晚,当时的元素识别基本上都是基于二值化图像做的,移植起来较为困难(基本上就是重写),再加上当时也没有完赛,而且索贝尔图像的使用上还有一些关键细节问题没有想到好的对策,再起一套方案成本过高,所以就延续了大津法二值化方案。由于今年省赛是线上赛,场地的光线可控,我们就设置了均匀的光照来解决大津法不适用的情况,最终效果良好。下面就对这两种方案进行简单的讲解记录,算是给自己做的一个小备忘,写的可能不是很好,欢迎广大车友指正!
直接处理188*120的图像,计算量其实是比较大的。过大的图像不仅降低了运算速度,也使得图传变得困难,响应速度骤降(串口的波特率即使拉到2000000bps也是慢的)。但是通过配置的方式直接修改总钻风输出图像的分辨率存在很大的问题,就是直接修改总钻风的分辨率实际上只截取了图像中心的部分,图像四周的信息是全部丢失的(这点一定要注意!!!我开始的时候就是为了降低运算量裁剪图像分辨率到90*60,我以为是输出的全图,只是像素降低变模糊了而已,没想到根本不是这样,结果得到的视野非常小,边缘丢失了大量信息,环岛甚至在图像中几乎看不到!我1月份写的除环岛的90*60元素识别,在3月底才发现这个问题,修改分辨率直接就是代码的一波大remake!),而图像四周的信息在元素识别中是非常重要的!
为了解决直接裁剪图像带来的信息丢失问题,在与其他同学的交流过程中,我了解到了新的方法:图像隔行抽取法。这个方法就是在原图上每隔一行抽取一行像素存到新的图像数组里,这样就可以得到一幅只有原图一半数据量、图像上信息几乎不丢失且只有锯齿稍微增加的图像。当然,在列方向上也是可以这么处理的。由于7月上旬我才知道这个方法,而当时大部分基于188*120二值化图像的元素识别我都已经写好了,所以只尝试了输出一下隔行抽取的图像,看了看效果,并没有实装,但是显而易见的是这个方法可以在几乎不牺牲图像信息的情况下大大降低运算量!
网上关于大津法二值化的资料已经不少了,这里也就不过多赘述其原理(其实是我讲的没他们讲得好),贴三篇参考文章供大家阅览:
大津法实现图像二值化
最大类间方差法(大津法OTSU)
图像处理——图像的二值化操作及阈值化操作(固定阈值法(全局阈值法——大津法OTSU和三角法TRIANGLE)和自适应阈值法(局部阈值法——均值和高斯法))
网上已经有很多现成的封装好的阈值计算函数,这里也给出一个:
u8 GetOTSU(u8 Image[MT9V03X_DVP_H][MT9V03X_DVP_W])
{
int16_t i,j;
uint32_t Amount = 0;
uint32_t PixelBack = 0;
uint32_t PixelIntegralBack = 0;
uint32_t PixelIntegral = 0;
int32_t PixelIntegralFore = 0;
int32_t PixelFore = 0;
float OmegaBack, OmegaFore, MicroBack, MicroFore, SigmaB, Sigma; // 类间方差;
int16_t MinValue, MaxValue;
u8 Threshold = 0;//阈值
u8 HistoGram[256]={0};//灰度直方图
for (j = 0; j < MT9V03X_DVP_H; j++)
{
for (i = 0; i < MT9V03X_DVP_W; i++)
{
HistoGram[Image[j][i]]++; //统计灰度级中每个像素在整幅图像中的个数
}
}
for (MinValue = 0; MinValue < 256 && HistoGram[MinValue] == 0; MinValue++) ; //获取最小灰度的值
for (MaxValue = 255; MaxValue > MinValue && HistoGram[MinValue] == 0; MaxValue--) ; //获取最大灰度的值
if (MaxValue == MinValue) return MaxValue; // 图像中只有一个颜色
if (MinValue + 1 == MaxValue) return MinValue; // 图像中只有二个颜色
for (j = MinValue; j <= MaxValue; j++) Amount += HistoGram[j]; // 像素总数
PixelIntegral = 0;
for (j = MinValue; j <= MaxValue; j++)
{
PixelIntegral += HistoGram[j] * j;//灰度值总数
}
SigmaB = -1;
for (j = MinValue; j < MaxValue; j++)
{
PixelBack = PixelBack + HistoGram[j]; //前景像素点数
PixelFore = Amount - PixelBack; //背景像素点数
OmegaBack = (float)PixelBack / Amount;//前景像素百分比
OmegaFore = (float)PixelFore / Amount;//背景像素百分比
PixelIntegralBack += HistoGram[j] * j; //前景灰度值
PixelIntegralFore = PixelIntegral - PixelIntegralBack;//背景灰度值
MicroBack = (float)PixelIntegralBack / PixelBack; //前景灰度百分比
MicroFore = (float)PixelIntegralFore / PixelFore; //背景灰度百分比
Sigma = OmegaBack * OmegaFore * (MicroBack - MicroFore) * (MicroBack - MicroFore);//计算类间方差
if (Sigma > SigmaB) //遍历最大的类间方差g //找出最大类间方差以及对应的阈值
{
SigmaB = Sigma;
Threshold = j;
}
}
return Threshold; //返回最佳阈值
}
这个方法的优点就是有很多现成的封装好的函数,可以直接使用,而且在大多数情况下效果尚可,方便新手直接上手开始图像处理;而且得到的图像,边界黑白分明,方便使用遍历+简单if
判断的方法找到赛道的边界。缺点也正如前面所说,大津法是很怕不均匀光照条件的,在有蓝布或者赛道反光、自然光一侧照射或是场地存在射灯等集中光源的情况下,二值化的效果不理想,无法清晰分辨赛道和蓝布。
三角法二值化的效果在大多数条件下是不如大津法的,曾经尝试过的效果不是很理想,所以就弃用了。当然,具体情况还是得具体分析,具体使用哪个还得由实际情况决定。之前也看过上交AuTop战队的十六届资料,自适应阈值二值化法的计算量确实是很大的,应该不太适合CH32V307芯片,所以就没有考虑这个方案,最终采用的是大津法。
个人水平所限,不能去细细分析这些高阶的图像处理方法,只能以鄙人粗浅的认识,简单地说明一下在处理智能车图像中我对其的理解。当时遇到的几篇比较帮助理解的博文我现在已经难以找着了,如果对数字图像处理想有深入了解的车友可以自行去百度一下帮助理解原理!
图像的卷积核就是一个自己设定数据的矩阵(二维数组),其大小(x*y
)也可以自行设定,它在图像后续的卷积运算中,将在全图上进行逐行的滑动,下面给出一个简单的3*3卷积核示例:
[ 1 1 1 0 0 0 1 1 1 ] \left[ \begin{matrix} 1 & 1 & 1 \\ 0 & 0 & 0 \\ 1 & 1 & 1 \end{matrix} \right] ⎣ ⎡101101101⎦ ⎤
这是一个y偏好的Prewitt算子。卷积核的“方向偏好”,说的就是卷积核在哪个方向上的数据绝对值更大(也就是说在图像卷积运算中的权值会更高)。
图像的卷积运算和信号的卷积运算有所不同。它是将卷积核叠放在原有的图像数组上,并且将图像数组与卷积核中对应位置的数分别相乘,然后相加,得到一个“梯度值”的过程:
根据卷积核的方向偏好,这个梯度值就代表了在这个方向上像素点数值变化的幅度大小。在这一次计算完毕后,卷积核根据设定的滑动方向,向x或者y方向移动一个像素(可以使用for
循环,但是要注意数组下标越界问题),然后继续进行这样的计算,直到遍历完全图为止。
算子就是卷积核。Prewitt算子的特征就是矩阵中的数据绝对值仅为0或者1。比如这样:
[ 1 1 1 0 0 0 − 1 − 1 − 1 ] \left[ \begin{matrix} 1 & 1 & 1 \\ 0 & 0 & 0 \\ -1 & -1 & -1 \end{matrix} \right] ⎣ ⎡10−110−110−1⎦ ⎤
这就是最基本的y偏好的Prewitt边缘检测算子。在Prewitt算子中,各个方向上像素的权值是相同的,这就使得Prewitt算子的特异性较弱。
而Sobel算子不同的地方在于它允许算子中的数据绝对值不为0或者1,比如这样:
[ 1 2 3 0 0 0 − 4 − 5 − 6 ] \left[ \begin{matrix} 1 & 2 & 3 \\ 0 & 0 & 0 \\ -4 & -5 & -6 \end{matrix} \right] ⎣ ⎡10−420−530−6⎦ ⎤
这样就使得Sobel算子相比Prewitt算子表现出更强的方向偏好。因此,Sobel算子可以对某些亮度不均匀的图像进行特定方向上的异化处理,其使用更加灵活。但是由于智能车行进过程中的图像变化较多,光照均匀与否和光照的偏向性都难以固定,所以一般情况下采用没有特殊的偏向性的Prewitt算子即可,当然如果摄像头存在曝光不均匀的问题,则可以考虑引入Sobel算子进行一定程度的纠偏(虽然但是,那样子为什么不直接更换摄像头呢>_<)。
边缘,本质上就是信号的突变,在灰度图像中就是灰度值的突变。 赛道和蓝布之间的灰度值变化是很大的,在均匀日光灯光照的条件下,蓝布的灰度值大约在90~110左右,而赛道的灰度值可以达到210以上,二者之间的交界是非常明显的(其中还有黑色胶带的赛道边界可以更加放大这一突变)。 通过特殊设计的算子,利用卷积运算获得梯度值,利用这个梯度值,可以放大并且检测这种突变,从而输出检测到的边缘图像,这就是边缘检测。 相当于数学中的求导,导数大的点变化率大,意味着更加可能存在突变。
在龙邱的TC264整车示例程序中,有一个索贝尔边缘检测函数:
void sobelAutoThreshold (u8 source[MT9V03X_DVP_H/2][MT9V03X_DVP_W],u8 target[MT9V03X_DVP_H/2][MT9V03X_DVP_W])
{
/** 卷积核大小 */
short KERNEL_SIZE = 3;
short xStart = KERNEL_SIZE / 2;
short xEnd = MT9V03X_DVP_W - KERNEL_SIZE / 2;
short yStart = KERNEL_SIZE / 2;
short yEnd = MT9V03X_DVP_H - KERNEL_SIZE / 2;
short i, j, k;
short temp[3];
for (i = yStart; i < yEnd; i++)
{
for (j = xStart; j < xEnd; j++)
{
/* 计算不同方向梯度幅值 */
temp[0] = -(short) source[i - 1][j - 1] + (short) source[i - 1][j + 1] // {-1, 0, 1},
- (short) source[i][j - 1] + (short) source[i][j + 1] // {-1, 0, 1},
- (short) source[i + 1][j - 1] + (short) source[i + 1][j + 1]; // {-1, 0, 1};
temp[1] = -(short) source[i - 1][j - 1] + (short) source[i + 1][j - 1] // {-1, -1, -1},
- (short) source[i - 1][j] + (short) source[i + 1][j] // { 0, 0, 0},
- (short) source[i - 1][j + 1] + (short) source[i + 1][j + 1]; // { 1, 1, 1};
temp[2] = -(short) source[i - 1][j] + (short) source[i][j - 1] // {0, -1, -1},
- (short) source[i][j + 1] + (short) source[i + 1][j] // {1, 0, -1},
- (short) source[i - 1][j + 1] + (short) source[i + 1][j - 1]; // {1, 1, 0};
temp[3] = -(short) source[i - 1][j] + (short) source[i][j + 1] // {-1, -1, 0},
- (short) source[i][j - 1] + (short) source[i + 1][j] // {-1, 0, 1},
- (short) source[i - 1][j - 1] + (short) source[i + 1][j + 1]; // {0, 1, 1};
temp[0] = abs(temp[0]);
temp[1] = abs(temp[1]);
temp[2] = abs(temp[2]);
temp[3] = abs(temp[3]);
/* 找出梯度幅值最大值 */
for (k = 1; k < 3; k++)
{
if (temp[0] < temp[k])
{
temp[0] = temp[k];
}
}
/* 使用像素点邻域内像素点之和的一定比例 作为阈值 */
temp[3] =
(short) source[i - 1][j - 1] + (short) source[i - 1][j] + (short) source[i - 1][j + 1]
+ (short) source[i][j - 1] + (short) source[i][j] + (short) source[i][j + 1]
+ (short) source[i + 1][j - 1] + (short) source[i + 1][j] + (short) source[i + 1][j + 1];
if (temp[0] > (temp[3] / 12.0f))
{
target[i][j] = 0xFF;
}
else
{
target[i][j] = 0x00;
}
}
}
}
可以看出来,龙邱的索贝尔边缘提取函数实际上使用的是x、y、左上-右下、右上-左下四个方向偏好的Prewitt算子。在实际使用中,我对这个函数进行了改造,将卷积核改为了1*3的x偏好和y偏好卷积核,使之在不怎么牺牲边缘提取精度的情况下,可以更快地在这颗性能不强的芯片上运行。实测在-O0
优化等级下,输入188*120图像可以跑到43.3fps。
为了快速判定梯度值,龙邱在这里引入了一个临时数组temp
用于存储四个方向上的梯度,并且用这四个梯度值中最大的那一个方向来判断算子这次覆盖的像素区域是否存在边缘。比较的阈值是自动计算得到的,整个边缘检测的过程有点类似于差比和的计算思路,前面的卷积部分为“差”而这个阈值的计算为“和”,就是把卷积核覆盖的位置的像素灰度值相加,再除以一个指定的系数,就是阈值。通过调节这个系数的大小可以调整边缘检测的灵敏度,灵敏度过高会导致边缘检测不出来的问题,灵敏度过低会导致边缘检测输出的图像中存在较多的非边缘噪点。如果梯度值大于计算的这个阈值,就认为存在边缘,且在最后一步将目标图像边缘上的点描成白色,而其他部分的点则是黑色(当然也可以反一下,看个人喜好了)。
这样,就可以在target
图像数组中得到一幅边缘图像了。这幅图像中基本上就有了全图中赛道的边缘信息。虽然相比八邻域的跟踪算法,它的计算量更大一些,但是胜在不需要特殊处理断线的情况,而且理解和修改起来比起八邻域要更简单,而且对于光线的适应能力强了不止一星半点(可惜的是我在调试过程中并没有保留索贝尔图像,但是相信我,用过的人都会被效果折服的)。