GPNU_XOR_Image
第十六届广技师全向组从机图像处理程序解析
2021.07.24:本文是对我个人(大三下学期时写的)图像程序的解析,希望能为后人所用,祝你们智能车比赛取到应得的成绩。
一、介绍
1.使用平台
- 单片机:沁恒
CH32V103R8T6 ; - 摄像头:逐飞
MT9V034 ; - 操作系统:国产
RT-Thread ; - 上位机工具:自制
him_com_v021.py ;
2.核心思想
- 压缩图像,将32个像素点的二值化值压缩在1个32位数据中保存;
- 异或运算,对压缩图像进行异或运算,只保留边缘图像;
- 伪逆透视,提取边界后还原边界形状;
- 单边巡线,选取选取最长的单边合成中线;
- 压缩和异或属于相辅相成。如果只压缩不异或,那压缩就没有意义;反之,如果不压缩只异或,那单个数据点异或也没有意义。
- 伪逆透视和单边巡线也属于相辅相成。如果只有前者无后者,则无意义;如果只有后者无前置,则无法进行。
3.优缺点
- 减少数据量,压缩图像大小是原图像的1/32,大大减少了需要的数据量。也是该算法的初衷,因为单片机
CH32 的SRAM 空间只有20KB大小,连2个原图像数组都存不下!! - 减少计算量,同理上,数据量减少了,计算量也减少了。原本需要重复判断32次的循环,最大可省略至1次。得意于此,在判断条件上有了新的选择 —— 有时可判断32位全空的数目。
- 不怕丢边,因为是单边巡线,所以只有2条边界都丢失时才会真正丢边。而且补线也很方便,特别是出环巡线的编写。
- 防止丢边,理论上可以,但实际上我自己并没有实现这个功能,因为我没写。还没想好怎么实现,是利用补线的方式。
- 增加计算时间,这主要指压缩图像和异或运算的时间(约2ms以内),传统的图像处理没有这两步,所以可以快一点。但因图像帧率本身就不高(约14ms),我设置的中断周期也不高(10ms周期),所以我认为2ms的计算时间不会造成控制延误等影响。
4.框架
- 整个图像处理分为三大部分,分别是:预处理、边界提取、测试程序。
- 预处理,包含:计算阈值、二值化、压缩、异或、计算伪逆透视数组;
- 边界提取,包含:扫描边界,识别元素,单边巡线;
- 测试程序,最开始写程序时,事先将图片保存在程序中,离线读取运行。无须真正装载摄像头并跑到赛道上看效果。我利用自制的上位机工具,记录了几百张图片数据。在写程序时可自由选择图片测试不同情况。
二、图像预处理
- 预处理,包含:计算阈值、二值化、压缩、异或、计算伪逆透视数组;
1.灰度阈值
-
摄像头读取的图像数据为0~255 灰度数据,需要计算一个阈值,根据灰度值将图像所有像素点区分为前景和后景。本文暂且定义,前景为小于阈值的部分,后景为大于或等于阈值的部分;即前景就是赛道,后景就是背景。 -
求阈值的方法有很多,举例几个常见的:图像处理——常用阈值分割方法及源码。一般选取要求有:抗反光(灯光太强)、抗前后景差距大(摄像头太低)、抗干扰点(光线太暗)、计算速度快(单片机太弱)等。 -
我试用过局部阈值法、Otsu阈值分割、迭代阈值法;个人经验,第一个计算时间长;第二个抗反光差;第三个平均,简单粗暴实用。
个人经验:选取计算的方法是个可以“投机取巧”的事情,因为赛道本身是白色的,在灰度图中,赛道的阈值和背景的阈值差异其实很明显的。计算得到的阈值甚至再减小10,赛道也不会被误认为是背景。而且某一特定空间下,整体阈值也是固定在一定范围内的。结合智能车比赛的特点,可以去取巧的计算合适的阈值、筛选不合理阈值或像素点。
在所有程序实现时,应该尽可能利用智能车赛道的特殊性取巧。除非没有地方可提升,不然没必要卷阈值计算这一部分。
重要设定:我的摄像头没有倒装,图像的坐标原点在左上角。在写自制上位机时,发现导入图片后坐标默认就是左上角。为了统一习惯,不倒装摄像头。
2.二值化 并 压缩
- 二值化和压缩可同步进行,每连续分割完32个像素点的
0/1 后,就直接储存在32位变量中。如何选择连续的32个点,需要考虑两个问题:
- 确定图像的长和宽;为了合理充分利用数据,应该设定长宽都是32的整数倍。(强迫症)
- 选择列压缩,还是行压缩;二值化图像数据为二维数组,可以选择是同一列的32个点压缩,或是同一行的32个点压缩。
- 选择列压缩还是行压缩需要结合后面算法选择,它直接决定了整个算法的判断标准。
个人经验:因为一般图像为横向,所以设置行压缩时需要连续几个变量才能完整保存一行数据。这增大了后续算法的计算量,如果选择列压缩,则就可以一个变量是一列数据。结合考虑,设定 “长宽为128*32 ,选择列压缩” 的方案挺不错。
128*32 图像的二值化结束,列压缩 后得到长度为128 的一维数组,进行下一步。
重要设定:列压缩后,图像一列中原本的低位,一一对应着压缩后数据的低位。
3.异或运算
- 异或_百度百科 (baidu.com):
1 ^ 0 = 1 、0 ^ 1 = 1 、1 ^ 1 = 0 、0 ^ 0 = 0 ; - 二值化后,像素点被区分为
0/1 。假设二值化图像的一部分为:0011 1111 。先右移得到0001 1111 ,再与原数据进行异或运算得0010 0000 ;这样便得到了前景0 和背景1 的边缘。 - 选择左移还是右移,就是选择视
01 组合中的0 为边缘,还是1 为边缘(部分)。如果含有多个01 组合,其实是把整个边缘往左或右位移。
1)列内异或运算
- 如果上一步已经完成了
列压缩 ,就可直接进行列内异或运算。对32位的列数据 位移再异或即可。选择左移还是右移?因为图像中越远的点信息越不准确,所以为了保留更多准确点,我倾向于左移,将最远的点移出。 - 这样单列的32位数据的异或运算有一个致命缺点,只有
01 组合的边缘处于列数据 中时,才能得到边缘。假设某列数据 全为1 ,另一列数据 全为0 ,如果采用列内异或运算,则会直接丢失这条边界。 - 简单点说,就是当边缘有一部分恰好是在一条竖线上时,经过列运算会丢失这一部分边缘。
2)行内异或运算
- 如果压缩图像不是
行压缩 而是列压缩 ,需要先拷贝、切换一份行压缩 的行数据 图像再进行行内异或运算。这一部分运算占用蛮多时间的。 - 和列内异或运算不同,一行有几个
行数据 组成,需联合计算。比较麻烦。 - 和列运算同理,当边缘的一部分恰好在一条横线上时,经过行运算也会丢失这一部分边缘。
3)总结
- 结合行列两种异或运算,可以互补各自短板,避免丢失边缘。但是这样计算时间过长,我在80MHz的CH32单片机上测试,一个异或运算大约花费1ms,结合两个异或运算应该3ms左右。
- 丢失横线上的边缘,属于连续转弯时远处的曲线边缘,常见但重要性不高。丢失竖线上的边缘,属于姿态不正时近处的曲线边缘,少见但重要性极高。(下面会讲到,我扫描边界是从近处的曲线边缘开始,如果丢失了,就相当于整条边界都丢失了)
- 列/行压缩 与 列/行内异或运算 的组合没有限制。只要你想,你也可以选着行压缩+列内异或运算的组合。又或者全部都用上。
个人经验:我选择的是列压缩+列内异或运算 的组合。自己坑自己,后期因丢失边缘的问题十分苦恼。因为我觉得只要路径好一点就会减少丢边,加上我的目标是单边巡线,所以认为只丢一边也没关系。实际上寻边确实没有问题,但是识别特殊元素时就有问题了。如果刚转向就是特殊元素,那丢边可能会导致无法识别!为了解决这个问题,我在识别特殊元素上又花费了许多功夫……所以建议一开始就使用列压缩+行和列内异或运算 的组合,一劳永逸。
4.伪逆透视
- 基于
压缩+异或 后,灰度图像转换为边缘图像,从以往的计量白点数,变为扫描白点坐标。至此完成第一阶段的准备,还有第二阶段的准备 —— 伪逆透视数组的计算。
推荐文章:智能车图像处理之透视变换;原本的逆透视,是指将斜视图像还原为俯视图像,这需要对图像的每个像素点都进行一定的x轴和y轴的偏移。而这个偏移量需要计算得到,过程有点复杂( 我没看懂,所以没实践 )。
1)计算思路
-
确定只计算x坐标的偏移量后,我只需要确定实际点(透视图)和偏移点(原图)的距离差即可。 -
如果设定每个像素点为偏移点就太麻烦了,所以我决定每一行都采用相同偏移量。我只偏移(扫描得到的)边界点,并不是所有点都偏移。因为我只关心有效边界点,所以我先计算每一行边界点的偏移量,作为该行任意点的偏移量。 -
如果计算每个像素点的实际点也太麻烦了,所以我决定采用相对点代替,计算的偏移量都是与相对点而言。 -
总结:选取最近一行的左右边界点为相对点,计算、记录其他所有行的边界点相对该点的偏移量。
- 最后得到的偏移量数组,我称作:伪逆透视数组。该数组在程序第一次运行时应重新计算一次,如果是相同赛道、相同摄像头高度和角度则不需要重复计算。不需要重新计算时,可以选择将数组存进
Flash ,方便下次程序运行时使用。
三、边界提取
一些废话:边界提取的重点应该是框架结构,也是我最想分享的东西。
-
在无特殊元素单纯单边巡线时,我将边界提取归为单纯的扫描边界的功能。然后我将扫描边界的功能又作了细分。如果扫描得到的边界符合特殊情况,就认为是遇到特殊元素,特殊元素里的边界又是重新扫描,因为大部分都会遇到丢边情况。所以有无特殊元素,最后都会得到一条最长边界,我再将此边界偏移到中线位置,就可以实现单边巡线了。 -
扫描边界一个阶段,识别元素一个阶段,退出元素一个阶段,元素内识别一个阶段,四个阶段按顺序执行。代码集中,方便维护。而且元素内识别不设立阶段,所以在元素内进退都没有关系。 -
我起初甚至设想完全不设立阶段,给程序任意一幅图像都能准确识别属于什么元素,并提取有效边界。一开始只写十字和三岔时是可以做到的。但是轮到写出入圆环就不行了。因为看到的画面确实是完全不一样,如果程序判断的话,和十字与三岔都有不少共同之处。所以我只能写了阶段保护,如果进入了一个元素直到退出前不会再识别为其他元素。但是元素内识别我还是保持着不分阶段。 -
要做到:给程序任意一幅图像都能准确识别属于什么元素,并提取有效边界。估计只能寄托在神经网络的训练上了。使用上位机也收集了不少图片,人工标注一下就能用了。说不定还能完成阳光算法。(本来是这么打算的)
1.扫描边界
- 扫描边界的被划分为:区域内找点、找起始点、沿点找点、沿起始点找点、找左右边界。其中前三个是较为独立的功能。后二个是打包组合,方便调用。最后程序会调用找左右边界的功能,返回左右边界的相关数据。
- 功能被划分是为了方便调用,除了扫描边界阶段,其他阶段也可以调用这些基础功能函数。不过后期修改优化程序后,基本没调用过……不过划分也为了方便维护修改,不同函数之间调用只需要确保输入输出的准确性,并不关心函数的实现。
- 因为划分几个函数,所以在数据的传输上参考了RTOS系统的形式。使用结构体+共用体的形式打包数据。发现真方便,也解决了多年来命名困难症问题。
1)区域内找点
- 设定目标点,在目标的左或右列找第二个点。如果找不到就开始从目标点所在的列找。这时需要注意,因为异或图像的特殊性,如果第二个点和目标点同列,就表示这一点处形成了拐点,曲线方向发生了反向变化。
- 找第二个点时,需要注意找的方向。这也是一个重点思想,为了防止反光或其他不确定因素,我采取从赛道内往外扫描边界的决策。所以区域内找点时应该保证找点是方向始终是从前景往后景的方向一个个点寻找。
- 还有区域大小的设定,我设定为
3x4 ,如果找不到可以选择是否扩大到3x8 。也就说左右一列,加上目标点所在列,然后往选定方向的4个点内寻找。这中方法有点像另一位大佬分享的八领域方案:第十五届全国大学生智能汽车竞赛-双车组三轮图像处理总结,不过大佬的方案我不太清楚实现流程,所以我就自己想了个化简版本。
2)找起始点
- 从内往外扫描,需要先设定一个找起始点的起始点。一般设置为上一次中线的起始点位置。如果上一次中线位置有误则触发重置,从图像中间开始往两边找。
- 因为列压缩和列异或的特性,找起始点时我选择同时扫描一列的4个点,这样更加快速和精准,防止在转向时出现找不到起始点。之前提过的,如果边缘在一条直线上,异或后得不到该边缘。按4个点一起扫描会导致另一个问题。因为边界是斜的,所以如果4个点一起找,基本上只找到第四行(从下往上数)以上的点。以下的点都被忽略了,因为这些点用不到,所以也没关系。
- 找到点后,便开始调用区域内找点,连续找到2个有校点,才认为这个起始点是正常的。判断方式结合了寻找方向,2个点的分布位置要符合预期设定,否者无效。
3)沿点找点
- 该函数就是简单的循环调用区域内找点,并加了判断:是否符合边界衍生规律的退出条件。如果遇到拐点后就会退出。
4)沿起始点找点
- 该函数就是打包了2)和3)的功能,方便调用。先找到起始点,然后根据起始点找边界。
5)找左右边界
- 该函数就是打包了4)功能,调用两次,分别设定要找左右边界的参数。得到左右两条边界。得到左右边界后,结合伪逆透视数组,将扫描到的斜边界
拉直 。 - 最后呈现出来的效果图还是很像逆透视的,直道部分就是直的,弯道部分就是弯的。不过弯道部分的程度并不能用来计算/判断斜率或半径,因为我并没有拉伸y轴,所以弯曲的程度并对应真实的程度。
2.识别元素
这一部分的内容在不同高度。角度的摄像头上变化非常大。比如我这个角度可能某些元素内全列都是空的,而换个角度就是并不是全空。所以以下内容只能做个参考和启发,不能照搬。
-
以第十六届比赛元素为例,传统的十字路口、出入圆环、坡道,还有最近两年新加的车库、三岔路口。 -
经过上一步,扫描边界后,得到左右边界的相关数据。判断特殊元素的最前提依据就是左右边界的相关数据:起始坐标,终止坐标、起始方向、终止方向。终止方向如果不是拐点,就是和起始方向一样。起始坐标和终止坐标可以算出长度和分布位置。 -
依照三种情况,每当不处于元素中时,都会一直检测循环检测是否满足元素条件。
- 左右边界都短时:可能是十字、三岔、出圆环。
- 左右边界一长一短时,可能是圆环、车库。
- 左右边界都长时:可能是坡道。
- 如果是处于元素中时,就会使用元素标志覆盖循环检测的结果,防止在元素内误识别。取而代之的,是识别退出元素的条件是否满足,不同元素有不同的特定退出条件。如果退出条件也不满足,就进入元素内补线环节。因为原本扫描得到的左右边界已经不可信,需要重新扫描、补线、连线。
1)左右边界都短
-
左右边界都短时:可能是十字、三岔、出圆环。 -
十字的特点是中间有全空列、且左右边界的方向都朝里(也就是在逆透视之前:左边界朝右倾斜,右边界朝左倾斜)。 -
三岔的特点是中间有个最大点,该点两边都呈单调变化,且左右边界的方向都朝里。 -
出环的特点是中间有全不空列,而且这些列数大多都是相等的,也就是说看到近视水平线的直线。且左右边界的方向都朝里。 -
先判断出环、再判断三岔、最后判断十字。主要因为三岔的角,只看到一点点时,也可能满足十字的情况。而在识别十字时不会出现类似三岔的情况。所以先三岔再十字的判断顺序更稳定。而出环放在三岔前面是因为:出圆环、三岔、十字,三者刚好组成中间列数递增的情况。
挖坑:异或运算时说过,只采用列内异或时,如果边界正好处于一列上,边界会丢失。在转弯时常出现,如果刚转弯就是特殊元素,会因为丢失而错过识别元素的时机。
我早期的解决方法是不包涵检测左右边界的存在,无论左右边界是否存在都能识别元素。但是后期发现这样逻辑相当混乱,因为我是三四个元素同时依次判断。经常出现误判。为了去除不确定因素,我还是加上了一定要左右边界都有效存在时才识别元素。
我后期的解决方法是检测跳变点,因为如果出现跳变点就大概率是边界的结束点了。这个方法可能还有些bug,虽然我加了一些限制,但难免有我意料之外的情况没考虑到(比如有阳光?)。导致程序可能卡死跑飞。
经过好几次修改维护,现在已经几乎不会出现三岔和十字开始的情况了。不过十字如果姿态不正还是有可能无法识别。这一点我在运动控制上做了点功夫,使小车的路径好一点后就解决了。
- 十字和三岔的退出条件类似,都是看到较长直道后就退出。不需要“很长”,因为赛道比较紧凑时可能一出元素就是下一个弯道了。
- 而出环的条件则是很长直道,因为如果只是较长直道,可能会在出环和入环的交点出误退出,因为那个地方刚好也能看到左右边界为较长的情况。
2)左右边界一长一短
-
左右边界一长一短时,可能是入圆环、车库。 -
我的出入环并没有捆绑在一起,程序可以只入环或只出环,相当灵活。对于车库只写了识别斑马线,并没有对入库和出库写专门的补线。因为我觉得在运动控制方面直接写死打角会更加方便。 -
入环的特点是一边很长,一边很短。哪边短就是要入哪边环。如果不处理,寻单边就可以直接忽略入环直走。 -
入库斑马线的识别和入环差不多,不一样的就是斑马线会挡住赛道中间,在赛道中间产生边缘,所以只需要扫描赛道中间是否有点,就可以知道是不是入库斑马线。如果不是入库斑马线就是入环,所以先判断入库斑马线,再判断入环。
一边“很短”,到底是多短?一开始入环和入库我都采用相同的标准 —— 可以很短,也可以不存在。后来发现在经过斑马线时,如果姿态抖一下,可能就出现误判入环的情况。所以后来我将入库的条件改为一定存在的很短,而入环的条件改为一定不存在的很短,而还添加了扫描入环时,短的那一侧是一大片空区域。
3)左右边界都长
- 左右边界都长时:可能是坡道。
- 关于坡道的处理,一直是最让我头疼的。因为按照极限情况来调试时,转弯就要上坡道。如果转弯姿态还没摆正,就看不到两条长 边界,也就无法及时识别坡道,又因为坡道的边界已经朝外飘了。所以如果没有及时识别,就会冲出坡道。
坡道的边界已经朝外飘:因为我是单边巡线,基础是根据伪逆透视数组拉直斜边,坡道上的边界倾斜程度和平面的边界倾斜程度不一样。还是按照原本的数组拉直边界,得到的就不是直道,而是一个朝外的弯道。如果是双边巡线求中点的话就没有这个困扰。
- 退出坡道也是个问题,如果一下坡就是转弯,就很容易退不出坡道。即使后来减速下坡了依旧不行。最后忍无可忍就采用了编码器定距……也是我唯一一个不是纯摄像头图像判断的元素。当进入坡道后就开始定距退出。
定距退出还有个问题,轮子上坡的姿态不一定很好,可能会在坡道上抖一下。这会导致轮子出现正反转,也就是编码器读值有正负。导致定距时常不准确。为此我又加了一个小条件,当反转时减去更多的累加值,转正时正常累加。因为如果反转就表示需要更多的正转来修正。
- 实际大省赛或国赛应该不会出现那么刁钻的条件,我看往年的赛道,元素间的间隔还是很充裕的。所以其实很有多可以取巧的识别方法。
3.单边巡线
- 在确定当前所处元素后,需要重新找线。或是确定为正常赛道后,就已经找到线。最后选择的线偏移到中线位置,用于代替中线。
- 后者属于正常情况,就不用多讲了。以下讲述处于各个元素内时如何找线。
1)退出圆环
- 较简单,先打死角,直到看到直道边界,且直道边界刚好能和死角重合时切换寻单边。直到退出元素。
早期我还是采用补线完成出环的,后来发现极其不稳定。也是恼羞成怒下就尝试打死角出环,没想到及其丝滑。搞那么多花里花哨的干嘛,简单实用才是最重要的。不过速度快后就有点内切,不过内切正好也是我想要的。歪打正着。整圈赛道下来,就出环速度最快了,因为是打死角直冲,基本有多快冲多快。
2)三岔路口
- 出入三岔都是一个样,寻点。在三岔路口时一直追着三岔路口的顶点,直到一定程度或看不到为止,就可以开始退出三岔的运动控制。当看到直道后就会自动巡线,不需要特殊处理。
3)十字路口
- 需要扫描上边界和下边界,将两条边界直接连线,简单粗暴的两点一线。可能感觉不流畅,实际跑起来感觉还行。如果入十字时路径好就不会有影响,如果不好就要减速,防止剧烈抖动。
- 如果上边界没有时,下边界直接往上延伸即可,如果下边界没有,则上边界需要连接屏幕中心位置,确保边界始终完整。
对于摄像头太矮或看太近的人,可能还会出现上下边界都看不到,接近全白的情况,这种情况采用直走冲过去即可。
4)坡道
- 没有处理直接过。我目前采用的是电磁过坡道,建议用陀螺仪应该也可以。因为摄像头上坡后看到的东西相当不确定,在坡顶还会卡一下。手推时就感觉不太稳定,为了求稳使用了电磁。如果想纯摄像头不用电磁,可以考虑搭配陀螺仪。
5)斑马线
- 没有处理直接过。运动控制可以选择处理,图像识别方面没有额外处理。
6)进入圆环
入环相当复杂,因为入环过程中图像的特点一直在改变,我又想写得可以进退的灵活。所以我每次都会判断处于哪种情况下,然后再根据不同情况下去补线。
- 主要思路是从直道边往圆环边扫描,将扫到两部分的边界。假设左入环,会得到左边界的圆环(只有下部分),和右边界的圆环(只有上部分)。需要将两部分的边界偏移拼接到一起。
个人认为:我这个写法导致边界在补线时会有些许跳跃,为了防止小车失控不得已减速入环了。应该再重新想过一个方法。
- 扫描边界的前提是确认开始扫描的点。在入环时,直道边会出现四种情况:存在、不存在但是没完全消失、完全消失、甚至看到了圆环边。主要是区分这四种情况,所以写得复杂了。第四种是一种bug’情况,路径特定才会出现。后来调试时又发现一种bug情况,摄像头看太远了,圆环的前的弯道都能看到……
比较抽象,要搭配图文才好理解。
四.总结
懒没有配图,如果有人看再配吧。目前还只是个人的笔记总结。
- 略。
|