写在前面

这是葱酱发布的第一篇正式的技术类贴文哦,写稿的时候状态不是很好,可能会有很多疏漏的地方呢,还请大家在评论区指正哦~

关于单边桥

有关单边桥的资料几乎在网上无法查找到,笔者参加的组别是摄像头基础组,这个组别是安徽省赛区组织的省级参赛组别,不设国赛。相比于镜头组和完全模型组,我们的车子是使用差速法控制的三轮车,没有多余的加速度/编码/陀螺仪传感器,也不允许自己加以改装。尽管如此,笔者仍认为该组别对于初学者而言,在图像处理方面的学习和调试仍有着一定的挑战性,加之单边桥元素的资料稀少,基本为笔者自创,故撰写成博文,供大家参考。
单边桥,顾名思义,就是用一侧车轮轧过的桥梁。具体效果请参见下图。

单边桥描述

关于单边桥的设计和评分细节,比赛规则做出了如下要求:

单边桥使用路肩制作为等腰梯形形状。距离赛道黑色边界内边沿2.5cm,单边桥宽度5cm,长度45cm,2个斜坡长度相同,平台长度25cm。
评分细节:智能车B识别磁标②,表示前方50cm±2cm右侧有单边桥,要求智能车单轮通过单边桥行驶(即单轮始终处于单边桥上)。(满分20分)
——全国大学生智能汽车竞赛安徽赛区基础组比赛规则

综上所述,我们可以得知:单边桥位于赛道的右侧边缘,在检测到单边桥的特征后,车子应调整姿态,完成转向->对准->直行过桥->下桥->姿态调整的全过程,那么就让我们来愉快的开始吧!

问题的解决思路

单边桥的识别

在车辆位于单边桥之前时,由于道路标线的扫描机制是由中线的顶点向左右延伸,获取两侧的界限之后再合成新的中线顶点,由此循环往复。因此,在遇到道路右侧的单边桥后,图像的中线会出现一个向左的突变,再出现一个向右的突变。我们先捕捉这个向左的突变点。

示意图

寻找该突变,只需又下往上遍历,直到找到一个符合以下条件的点,循环停止,判定进入单边桥:

  • 下方每个y索引的横坐标差量不大于2
  • 上方每个y索引的横坐标差量不大于2
  • 在突变的位置,上方的横坐标比下方横坐标小6个像素以上
    这样,我们就能初步判定单边桥的特征了,但是,这样的突变点很容易在其他路况中出现,造成误判。因此,我引入了一个二级判断方案,即识别到中线的变化之后,在一个适当的参考点,从右到左进行行扫描,统计上升沿和下降沿,一般,如果可以发现上升-下降-上升三个明显的跳变,即认为是单边桥。
    对应的代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for(i=IMAGEWID-3;i>IMAGEWID/2+15;i--){
if(M_ARR[i]-M_ARR[i-2] >= 8 &&
abs(M_ARR[i]-M_ARR[i+1]) <= 2 &&
abs(M_ARR[i]-M_ARR[i+2]) <= 3 &&
abs(M_ARR[i-2]-M_ARR[i-3]) <= 2 &&
abs(M_ARR[i-2]-M_ARR[i-4]) <= 3)
{
//判断上升沿和下降沿
for(j=IMAGELEN-1;j>IMAGELEN/2;j--){
if(OutImage[60][j] == 0x00 && OutImage[60][j-1] == 0xFF) rising++;
if(OutImage[60][j] == 0xFF && OutImage[60][j-1] == 0x00) falling++;
}
//找到向左的突变位置,若之前未检测到单边桥,进入状态1
if(falling == 1 && rising == 2){
tz_down = 1;
bridge_flag = 1;
bridge_stage = 1;
break;
}
}
}

这样,就完成了下方特征点的抓取和单边桥的判断,单边桥的状态变为true,阶段设为1,开始执行上桥的动作。

来自笔者的碎碎念:
将摄像头的视野抬高并且适当左转,可以让前瞻更开阔,中线偏向右侧。更利于单边桥的捕获。
另外!!减小阈值会显著增加灵敏度,但误判的概率也会同时增加。

上桥

发现单边桥时,车子应处于巡线直行的状态,此时应该执行一系列动作,让车头准确对准单边桥。我把动作分为2步。

粗略对准

这个步骤使车辆快速向右倾斜,对准单边桥,步骤为一个固定动作,期间不使用摄像头。对准后,阶段标志位变为2。

原谅葱宝吧,孩子真买不起数位板惹

车辆执行了右转->回正->直行的操作,以下是对应的代码。

1
2
3
4
5
6
7
8
9
if(bridge_stage == 1){ //检测到第一个突变,向右打角
motor_motion(3800, 1300, SPEED_PARAM_INIT); //车轮右偏
Delay_Ms(400);
motor_motion(1500,3800,SPEED_PARAM_INIT); //回正
Delay_Ms(300);
motor_motion(3800, 3800, SPEED_PARAM_INIT); //直行
Delay_Ms(200);
bridge_stage = 2;
}

精确对正

执行完1阶段后,离桥头仍有一定距离,摄像头可以看见中线从左向右的突变点。我的方案选取了一条较为合适的固定中线参考点,依据图像的左边缘和单边桥左边缘作为左右边界,生成了新的中线,且用这组数据进行寻迹操作,减小误差,使车子直行上单边桥。
其中,由单边桥确定的右侧边界需要进行补线操作,此时判定函数不断对图像扫描,当发现无法在限制范围内检测出单边桥的特征时,开始进行下桥的操作。
代码如下,其中补线等功能函数,我全部整理在了文末,此处展示代码的逻辑。
1.补线和修改判定线数据部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
else if(bridge_flag == 1 && bridge_stage == 2){
Linedata_Typedef ldata; //申请一个备份的结构体
activateflag = 1;
//左侧标线不变,直接复制
for(i=0;i<IMAGEWID;i++)
ldata.Left_line_array[i] = linedata->Left_line_array[i];
//右侧标线,选择原有的底部右边缘进行补线
k2 = getAverageK(IMAGEWID-7,
linedata->Right_line_array[IMAGEWID-7],
IMAGEWID-8,
linedata->Right_line_array[IMAGEWID-8],
IMAGEWID-9,
linedata->Right_line_array[IMAGEWID-9]
);
//对新的结构体执行补线
editLineMap_K(&ldata, RIGHT_BOUNDARY, IMAGEWID-1, 0, k2);
//合成新的中线
reGenerate_MidLine(&ldata);
//用+47作为新的中线参考点,重新计算误差
getRoadError_Default(&ldata, YPOS_REFERENCE, 47, &midLineAxis, &midLineError);
//禁止写入误差和计算中线
not_to_write_error = 1;
not_to_write_midline = 1;
}

2.判定函数:不断检测单边桥是否存在于视野内,否则跳到第三阶段,开始直行通过单边桥。

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
else if(bridge_flag == 1){
//等待一阶段转弯纠正执行完成
//扫描上突变点,判定从2阶段跳到3阶段的流程
if(bridge_stage == 2){
for(i=IMAGEWID-6;i>IMAGEWID/2-10;i--){
if(M_ARR[i]-M_ARR[i-2] <= -8 &&
abs(M_ARR[i]-M_ARR[i+1]) <= 2 &&
abs(M_ARR[i]-M_ARR[i+2]) <= 3 &&
abs(M_ARR[i-2]-M_ARR[i-3]) <= 2 &&
abs(M_ARR[i-2]-M_ARR[i-4]) <= 3)
{
//找到向右的突变位置,若之前未检测到单边桥,进入状态1
//若该点高度正常,巡线方式不变
if(i >= IMAGEWID-6){
tz_up = 1;
break;
}
else if(i < IMAGEWID-6){
//车子已经上桥,直行,放弃巡线
tz_up = 0;
bridge_stage = 3;
break;
}
}
}
}

这样,智能车就可以在车轮向右偏转之后,在单边桥处于视野中的一定时间内进行一些细微的姿态调整,以便让车轮平稳的压在单边桥上。

下桥

在单边桥的上边界在视野消失后,执行一段固定的代码,完成下桥的动作。代码如下:

1
2
3
4
5
6
7
8
9
10
11
else if(bridge_stage == 3){
motor_motion(2400, 2400, SPEED_PARAM_INIT); //直行过桥
Delay_Ms(180);
motor_motion(3000, 1400, SPEED_PARAM_INIT); //直行过桥
Delay_Ms(140);
//清空标志位,完成过桥
bridge_stage = 0;
bridge_flag = 0;
tz_down = 0;
tz_up = 0;
}

为什么要有一段代码motor_motion(3000, 1400, SPEED_PARAM_INIT);,给电机施加一个比较大的右转动作呢?这是因为在车辆以较快的速度下桥时,由于姿态原因以及受力作用,车身姿态并非保持直行,而是偏向左侧冲出。因此,在完成一段时间的直行后,应当立马执行一个右转操作以调整姿态。
自此,单边桥的基础操作全部完成。调整车身姿态之后,摄像头打开并将标志位清空,恢复正常行驶。

一些补充

注意!
这些问题是笔者在调试车模时发生的偶然事件,参考价值有限。请选择性阅读本段。

笔者的代码经过测试,在同时开启十字路口、斑马线、避障、直角弯四种判断的情况下,基本不会和其他路况特征发生冲突。但仍然有一些特殊情况。比如在比较复杂的路段(前瞻可能捕捉到过远的场景)行驶时,尤其是经过十字路口、急转弯或者道路边缘存在未粘贴好的情况,有可能会导致误触发。解决这个问题的思路是引入了一个优先级判断机制,将单边桥的判断函数放在了比较靠后的位置,函数的大体实现如下。

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
//-------------------------------------------------------------------------------------------------------------------
// @brief 图像处理函数
// @param void
// @return char state: 1为处理完成,0为未完成刷新
// Sample usage: 图像二值化
//-------------------------------------------------------------------------------------------------------------------
char processImage(void){
char i;
ImageBinarization(mt9v03x_image_dvp, OutImage, Threshold_filter(getThreshold(mt9v03x_image_dvp, IMAGELEN, IMAGEWID)));
LinesGenerate(OutImage, &linedata); //将道路识别线写入结构体
getRoadError_Default(&linedata, YPOS_REFERENCE, IMAGELEN/2, &midLineAxis, &midLineError); //生成边界线

//判断其他路口特征
i = 0;
while(i<1){ //只要识别到一个特殊区域,立马退出并使用获取的数据补线。防止数据覆盖
i++;
isZebra(OutImage, &linedata);
if(zebra_flag) break;
is90Dg(OutImage, &linedata);
if(is90dg_flag){
if(race_flag == 1) quarterTurn();
break;
}
isSingleBridge(OutImage, &linedata);
if(bridge_flag){
if(race_flag == 1) bridgeAdjust();
break;
}
isObstruct(OutImage,&linedata); //判定障碍
if(obstruct_flag == 1) {
if(race_flag == 1) AvoidObs(); //在运转状态下,执行躲避动作
break;
}
i++;
}
//在存在障碍物数据时,进行补线操作
if(i==1) Repair_and_RefreshData(OutImage, &crossing_pos,&linedata);
return 1;
}

另外,我的车子出现了上桥卡顿时传感器(如TOF、摄像头)无法继续获取数据的问题,经过痛苦的排查和测量,确定了问题可能来自电机的负载过大,以及电机驱动器发生了感应电动势的回流。从而致使MCU与传感器通信时,数据帧遭到破坏,从而导致了寄存器的配置错误,无法继续通过指令获取数据。对于这种情况,我在单边桥的下桥函数执行完毕之后将标志位bridge_stage设置为4,同时使循环中的一个静态变量自增。此时车子正常寻迹,一定时间之后才启动传感器,并且减缓电机的转速,这样就可以解决传感器因为电机而死机的严重问题了。

总结

单边桥是一个简单而颇具讲究的比赛条目,对于基础组的车辆,不完善的硬件又加大了处理各种复杂路况条件的难度。在本次基础组的实践中,我从一无所知,到接触智能车的图像处理算法、简单的控制算法以及对参数的调节,重写了几乎全部的代码,也让自己的实践经历又添上了一笔颜色,在此也感谢学长的宝贵建议,以及各位读者的指正,愿今后参加更加高级的组别,不断完善技能树,取的更大的进步!

附录

这是本文使用的一些结构体的声明和函数的定义。

  1. 结构体定义
    道路标线结构体:

    1
    2
    3
    4
    5
    typedef struct{ //存放赛道定位线的结构体
    unsigned char Left_line_array[IMAGEWID];
    unsigned char Middle_line_array[IMAGEWID];
    unsigned char Right_line_array[IMAGEWID];
    }Linedata_Typedef;
  2. 补线
    斜率计算:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    //-------------------------------------------------------------------------------------------------------------------
    // @brief 最小二乘法求斜率
    // @param x,y: 三对拟合直线上的坐标
    // @return float: 直线斜率
    // Sample usage: 在单边补线处计算边界斜率
    //-------------------------------------------------------------------------------------------------------------------

    float getAverageK(uint8 x1,uint8 y1,uint8 x2,uint8 y2,uint8 x3,uint8 y3){
    int sum_x,sum_y,sum_xy,sum_x2;
    int m_numerator,m_denominator;
    int n=3;
    float k;

    sum_x=x1+x2+x3;
    sum_y=y1+y2+y3;
    sum_x2=x1*x1+x2*x2+x3*x3;
    sum_xy=x1*y1+x2*y2+x3*y3;

    m_numerator = n * sum_xy - sum_x * sum_y;
    m_denominator = n * sum_x2 - sum_x * sum_x;
    k = m_numerator / m_denominator;

    return k;
    }

    按照斜率对标线结构体补线:

    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
    //-------------------------------------------------------------------------------------------------------------------
    // @brief 单拐角补线
    // @param linedata: 标线结构体
    // direct: 标线方向,0x01为左,0x02为右
    // yPos_start: 纵坐标起点
    // yPos_end: 纵坐标终点
    // k: 预设(如最小二乘估计)的斜率
    // @return void
    // Sample usage: 补线,适用于十字路口的二阶段
    //-------------------------------------------------------------------------------------------------------------------

    void editLineMap_K(Linedata_Typedef* linedata,char direct,uint8_t yPos_start,uint8_t yPos_end,float k)
    {
    //发散式补线
    float b;
    int i;
    //x = ky+b
    if(direct == 0x01){
    b = linedata->Left_line_array[yPos_start] - k*yPos_start;
    }
    else {
    b = linedata->Right_line_array[yPos_start] - k*yPos_start;
    }
    //链接
    if(yPos_start<=yPos_end){
    for(i=yPos_start;i<=yPos_end;i++){
    if(direct == LEFT_BOUNDARY)
    linedata->Left_line_array[i] = k*i+b;
    else {
    linedata->Right_line_array[i] = k*i+b;
    }
    }
    }
    else{
    for(i=yPos_end;i<=yPos_start;i++){
    if(direct == LEFT_BOUNDARY)
    linedata->Left_line_array[i] = k*i+b;
    else {
    linedata->Right_line_array[i] = k*i+b;
    }
    }
    }
    }