1. 赛道识别为什么值得单独拎出来讲
做过智能车的人都有一个共识:车能不能跑起来,一半看机械,一半看感知。机械决定了车的下限,而感知——尤其是赛道识别——决定了车的上限。我见过太多队伍,电机调得飞快、PID参数整得漂亮,结果一上赛道就各种冲出边界、压线、误判十字路口,根子就出在图像处理这一环。
赛道识别要解决的问题很具体:摄像头采集到一帧灰度图像之后,怎么从这堆像素里把赛道边线、中心线、特殊元素(十字、环岛、坡道、断路)准确地提取出来,然后交给控制层去算偏差、打舵、给速度。这件事听起来简单,但实际做起来,光照变化、赛道反光、背景干扰、图像噪声,每一个都能让你的算法当场翻车。
八邻域算法就是在这个环节里被广泛使用的一种边线提取方法。它的核心思想不复杂:从某个起始点出发,沿着赛道边线逐像素追踪,每次只看当前像素周围的八个邻居,根据邻域内黑白分布决定下一个追踪点往哪走。相比全局扫描或者简单的行列阈值法,八邻域的好处是追踪速度快、对局部噪声有一定容忍度,而且天然适合处理连续边线。
这篇文章适合谁看?如果你正在准备智能车竞赛,已经能把摄像头图像读出来但不知道怎么稳定提取边线,或者你已经用了八邻域但总是断线、跳点、十字误判,那这篇内容就是给你写的。我会从算法思路、核心细节、实操流程到问题排查,把我自己踩过的坑和调参经验完整地摊开讲。
2. 八邻域算法的整体设计与思路拆解
2.1 为什么选八邻域而不是全局阈值或者边缘检测
先说说赛道识别常见的几条技术路线。最粗暴的是全局阈值加行列扫描:对整幅图做二值化,然后逐行找黑白跳变点作为边线。这种方法实现简单,但问题也很明显——每行都要扫,计算量大;而且一旦某行出现噪声或者赛道中断,那一行就直接废了。另一种是用Sobel、Canny这类边缘检测算子,理论上更“正规”,但在单片机上跑这些算子的开销不小,而且边缘检测出来的结果是一堆离散的边缘点,你还得再做一次连接和筛选,反而更麻烦。
八邻域算法的定位刚好卡在中间。它不是对全图做处理,而是从一个已知的起始点出发,沿着边线“走”。每走一步只看当前点周围的3×3邻域,判断哪个邻居是下一个边线点。这样做的好处有三个:第一,计算量集中在边线附近,不需要处理整幅图;第二,追踪过程天然保证了边线的连续性,不会出现孤立点;第三,八邻域的方向判断可以很好地处理斜线和弯道,不像四邻域那样只能走直线。
我个人的经验是,对于分辨率在80×60到160×120之间的赛道图像,八邻域算法的单帧处理时间可以控制在几毫秒以内,完全满足智能车实时性的要求。而且它的内存占用很小,只需要维护一个边线点数组和几个临时变量,对单片机非常友好。
2.2 八邻域追踪的基本原理
八邻域的核心逻辑可以用一句话概括:站在当前点,看周围八个方向,哪个方向像是边线的延续,就往哪个方向走。
具体来说,假设当前追踪到的边线点是P(x, y),那么它的八个邻居分别是:
- 左:(x-1, y)
- 右:(x+1, y)
- 上:(x, y-1)
- 下:(x, y+1)
- 左上:(x-1, y-1)
- 右上:(x+1, y-1)
- 左下:(x-1, y+1)
- 右下:(x+1, y+1)
在二值化图像中,边线点通常表现为黑(赛道)和白(边界)的交界。八邻域追踪的目标就是找到下一个处于交界处的像素点。判断的依据通常是:该邻居像素的二值化值与当前点不同,或者该邻居满足某种边线特征(比如梯度值超过阈值)。
但这里有一个关键问题:如果八个邻居里有多个都满足条件怎么办?这就涉及到搜索顺序和方向优先级的设定。常见的做法是维护一个“来向”信息,也就是记录上一个追踪点相对于当前点的方向,然后优先沿着与来向相近的方向继续搜索。这样可以避免追踪线来回震荡或者走回头路。
2.3 起始点的选择策略
八邻域追踪必须有一个起点。起点选得好不好,直接决定了追踪能不能顺利展开。常见的起始点选择方式有几种:
第一种是固定行扫描法。从图像底部往上扫,找到第一行满足“中间是黑、两边是白”或者“存在明显黑白跳变”的行,把跳变点作为起始点。这种方法简单可靠,适合赛道从图像底部进入视野的常规情况。
第二种是中心扩散法。从图像底部中心点开始,向左右两侧分别搜索第一个边线点,作为左右边线的起始点。这种方法的好处是可以同时启动左右两条边线的追踪,效率更高。
第三种是基于上一帧结果的预测法。如果上一帧已经成功追踪到了边线,那么可以把上一帧边线的末端点或者某个中间点作为这一帧的起始点。这种方法在连续帧之间稳定性最好,但需要处理追踪失败时的重置逻辑。
我在实际项目中用的是第二种和第三种结合的方式:正常情况下用上一帧的结果做起始点,如果上一帧追踪失败或者置信度太低,就退回到中心扩散法重新找起点。这样既保证了连续性,又有了兜底方案。
2.4 追踪终止条件的设定
八邻域追踪不能无限走下去,必须设定合理的终止条件。常见的终止条件包括:
- 追踪点到达图像边界(x或y超出图像范围)
- 追踪点回到已经访问过的位置(形成闭环)
- 连续多步没有找到有效的下一个点
- 追踪步数超过预设上限
这里特别要注意的是“回到已访问位置”这个条件。在十字路口或者环岛区域,边线可能会出现分叉或者闭合,如果不做已访问标记,追踪可能会陷入死循环。我的做法是维护一个访问标记数组,每追踪到一个新点就标记为已访问,如果下一个候选点已经被访问过,就跳过它选择其他候选点。
另外,追踪步数上限也很重要。我一般会根据图像高度设定一个上限,比如图像高度是60像素,那么单条边线的追踪步数上限设为80到100步,留出一定的余量。超过这个步数还没终止,说明追踪逻辑可能出了问题,需要强制退出并标记为异常。
3. 核心细节解析与实操要点
3.1 图像二值化的阈值怎么定
八邻域追踪的前提是图像已经二值化。二值化的质量直接决定了追踪的稳定性。阈值定高了,赛道边线会断裂;阈值定低了,背景噪声会被误判为边线。
固定阈值是最简单的做法,但实际赛道的光照条件变化很大,固定阈值基本不可靠。我推荐用动态阈值,常见的有大津法(Otsu)和均值阈值法。
大津法的原理是找到一个阈值,使得前景和背景的类间方差最大。它的好处是自适应能力强,不需要手动调参。但大津法的计算量相对较大,在单片机上需要对整幅图做直方图统计。如果你的单片机性能足够,比如主频在100MHz以上,大津法完全跑得动。
均值阈值法更简单:计算整幅图的平均灰度值,然后以平均值为阈值,或者取平均值的某个比例(比如0.8倍)作为阈值。这种方法计算量小,但对光照变化的适应性不如大津法。
我实际用的是“分区均值阈值”:把图像分成上中下三个区域,每个区域单独计算均值阈值。这样做的好处是可以适应赛道远近不同带来的光照差异——通常图像底部的赛道比较亮,顶部的赛道比较暗,用统一阈值容易出问题。
注意:二值化之后建议做一次简单的去噪处理,比如3×3的均值滤波或者中值滤波。赛道图像里常见的椒盐噪声会让八邻域追踪频繁跳点,滤波之后稳定性会明显提升。
3.2 八邻域搜索顺序的设计
搜索顺序是八邻域算法里最容易被忽视但影响最大的细节。假设当前点周围有多个候选边线点,你先检查哪个方向,后检查哪个方向,直接决定了追踪的走向。
最朴素的搜索顺序是按固定顺序遍历八个方向,比如从左上开始顺时针检查。但这种做法在弯道处容易出问题:比如当前点在左弯道上,固定顺序可能会优先选择直行方向,导致追踪偏离边线。
更好的做法是引入方向优先级。具体来说,记录上一个追踪点到当前点的方向向量,然后按照与这个方向向量的夹角从小到大排序八个邻居,优先选择方向最接近的邻居。这样可以保证追踪的连贯性,不会突然拐一个奇怪的角度。
实现上,可以用一个简单的查表法:把八个方向编号为0到7,然后预先计算好每个来向对应的搜索顺序表。比如来向是“右”(从左边走到当前点),那么优先搜索右上、右、右下,然后是上、下,最后是左上、左、左下。这样既避免了实时计算夹角,又保证了方向优先级的合理性。
3.3 边线断裂和跳点的处理
实际赛道图像里,边线断裂是家常便饭。光照不均、赛道反光、摄像头噪点,都会导致某几个像素的二值化结果出错,边线中间出现缺口。如果八邻域追踪遇到缺口就直接终止,那追踪出来的边线就是一段一段的,没法用。
处理边线断裂的常见方法是“允许跳跃”。具体来说,当当前点的八个邻居都没有找到有效边线点时,不要立即终止,而是尝试在更大的范围内搜索,比如5×5邻域或者沿着当前方向向前搜索几个像素。如果找到了新的边线点,就跳过缺口继续追踪;如果搜索范围内都没有,才判定为真正的断裂。
跳跃搜索的步长需要根据图像分辨率和赛道宽度来定。我的经验是,跳跃搜索的最大距离不要超过赛道宽度的一半,否则容易跳到另一条边线上。比如赛道在图像中的宽度大约是40像素,那么跳跃搜索距离设为15到20像素比较合适。
跳点处理还有一个重要细节:跳跃之后要验证新找到的点是否真的是边线点。验证方法可以是检查该点周围是否也有边线特征,或者检查新点与跳跃前点的方向是否一致。如果跳跃后的方向发生了剧烈变化,那很可能是跳到了错误的边线上,应该放弃这次跳跃。
3.4 十字路口的识别与处理
十字路口是八邻域追踪最容易翻车的地方。在十字路口,赛道边线会出现分叉,左边界和右边界可能会交叉或者合并。如果追踪逻辑不做特殊处理,很容易从一条边线跳到另一条边线上,导致中心线计算完全错误。
识别十字路口的常用方法是检测边线的“分叉点”。在八邻域追踪过程中,如果某个点的邻域内出现了两个或以上的有效边线方向,而且这些方向之间的夹角较大(比如超过60度),就可以判定为遇到了分叉点。
处理十字路口的策略有几种:
第一种是“直接穿越”。在识别到十字路口后,强制让追踪沿当前方向直行若干步,忽略分叉,直到越过十字区域后再恢复正常追踪。这种方法简单,但需要准确判断十字区域的宽度。
第二种是“补线法”。在十字路口区域,根据十字前后的边线趋势,用直线或者曲线拟合补出缺失的边线。这种方法更精细,但实现复杂度也更高。
第三种是“状态机法”。维护一个赛道状态机,正常直道、弯道、十字、环岛等状态之间切换。进入十字状态后,用专门的逻辑处理边线,处理完再切回正常状态。
我实际用的是状态机法加补线法的组合。状态机负责识别和切换,补线法负责在十字区域内生成虚拟边线。这样既能保证十字路口的通过性,又能让中心线计算保持连续。
3.5 环岛区域的特殊处理
环岛比十字更复杂,因为环岛的边线是弧形的,而且存在入口和出口的判别问题。八邻域追踪在环岛区域容易出现的问题是:追踪到环岛入口时,不知道应该继续沿外圈走还是拐进内圈。
处理环岛的核心是识别环岛的入口特征。通常环岛入口处会出现边线的急剧弯曲,而且左右边线的距离会突然变大。通过检测这些特征,可以判断车辆即将进入环岛。
进入环岛后,追踪策略需要切换。一般来说,环岛内部只需要追踪一侧边线(通常是内侧边线),另一侧边线用补线或者固定偏移来生成。这样可以避免在环岛内部因为边线交叉而追踪混乱。
环岛出口的识别同样重要。出口处边线会再次出现分叉或者弯曲,通过检测这些特征来判断是否已经驶出环岛,然后恢复正常追踪。
实操心得:环岛处理最忌讳的是“一套逻辑走天下”。我建议在代码里把直道、弯道、十字、环岛分别写成独立的处理函数,用状态机来调度。虽然代码量会增加,但调试和维护会容易很多。
4. 实操过程与核心环节实现
4.1 图像采集与预处理流程
整个赛道识别的第一步是图像采集。智能车常用的摄像头有灰度摄像头和彩色摄像头两种。灰度摄像头输出的是单通道灰度图,数据量小、处理简单,是赛道识别的首选。彩色摄像头虽然信息更丰富,但需要额外的颜色空间转换,而且赛道识别本质上只需要灰度信息,用彩色摄像头有点浪费。
图像采集之后,预处理流程一般包括以下几个步骤:
第一步是去噪。常用的方法有中值滤波和均值滤波。中值滤波对椒盐噪声效果好,但计算量稍大;均值滤波实现简单,但对椒盐噪声效果一般。我一般用3×3的中值滤波,在单片机上可以用排序网络来加速。
第二步是光照补偿。如果图像整体偏暗或者偏亮,可以先做一次简单的灰度拉伸,把灰度范围映射到0到255之间。这样可以让后续的二值化更稳定。
第三步是二值化。前面已经讲过,推荐用分区均值阈值或者大津法。二值化之后得到一个只包含0和1的二维数组,0代表赛道(黑),1代表边界(白),或者反过来。
第四步是边线提取。用八邻域算法从起始点开始追踪,得到左右两条边线的点序列。
第五步是中心线计算。根据左右边线的点序列,逐行计算中心点,得到赛道的中心线。中心线是后续控制层计算偏差的依据。
4.2 八邻域追踪的代码实现框架
下面给出一个八邻域追踪的核心代码框架,用C语言编写,适合在单片机上运行。这个框架包含了起始点搜索、方向优先级、跳跃搜索和终止条件判断。
// 八邻域方向定义,按顺时针排列 const int dir_x[8] = {-1, 0, 1, 1, 1, 0, -1, -1}; const int dir_y[8] = {-1, -1, -1, 0, 1, 1, 1, 0}; // 方向优先级表:根据来向确定搜索顺序 // 来向0到7分别对应八个方向,表中存储的是搜索顺序 const int search_order[8][8] = { {0, 1, 7, 2, 6, 3, 5, 4}, // 来向0(左上) {1, 0, 2, 7, 3, 6, 4, 5}, // 来向1(上) {2, 1, 3, 0, 4, 7, 5, 6}, // 来向2(右上) {3, 2, 4, 1, 5, 0, 6, 7}, // 来向3(右) {4, 3, 5, 2, 6, 1, 7, 0}, // 来向4(右下) {5, 4, 6, 3, 7, 2, 0, 1}, // 来向5(下) {6, 5, 7, 4, 0, 3, 1, 2}, // 来向6(左下) {7, 6, 0, 5, 1, 4, 2, 3} // 来向7(左) }; // 八邻域追踪函数 // 输入:二值化图像bin_img,起始点start_x, start_y,起始方向start_dir // 输出:边线点序列edge_points,返回追踪到的点数 int trace_edge(uint8_t bin_img[IMG_H][IMG_W], int start_x, int start_y, int start_dir, Point edge_points[], int max_points) { int count = 0; int cur_x = start_x; int cur_y = start_y; int cur_dir = start_dir; uint8_t visited[IMG_H][IMG_W] = {0}; while (count < max_points) { // 记录当前点 edge_points[count].x = cur_x; edge_points[count].y = cur_y; visited[cur_y][cur_x] = 1; count++; // 按优先级搜索下一个点 int found = 0; int next_x = -1, next_y = -1, next_dir = -1; // 第一轮:在八邻域内搜索 for (int i = 0; i < 8; i++) { int d = search_order[cur_dir][i]; int nx = cur_x + dir_x[d]; int ny = cur_y + dir_y[d]; if (nx < 0 || nx >= IMG_W || ny < 0 || ny >= IMG_H) continue; if (visited[ny][nx]) continue; // 判断是否为边线点:当前点与邻居的二值化值不同 if (bin_img[ny][nx] != bin_img[cur_y][cur_x]) { next_x = nx; next_y = ny; next_dir = d; found = 1; break; } } // 第二轮:如果八邻域内没找到,尝试跳跃搜索 if (!found) { for (int step = 2; step <= JUMP_MAX; step++) { for (int i = 0; i < 8; i++) { int d = search_order[cur_dir][i]; int nx = cur_x + dir_x[d] * step; int ny = cur_y + dir_y[d] * step; if (nx < 0 || nx >= IMG_W || ny < 0 || ny >= IMG_H) continue; if (visited[ny][nx]) continue; if (bin_img[ny][nx] != bin_img[cur_y][cur_x]) { next_x = nx; next_y = ny; next_dir = d; found = 1; break; } } if (found) break; } } // 如果还是没找到,终止追踪 if (!found) break; // 更新当前点和方向 cur_x = next_x; cur_y = next_y; cur_dir = next_dir; // 终止条件:到达图像边界 if (cur_y <= 0 || cur_y >= IMG_H - 1) break; } return count; }这段代码的核心逻辑是:先按方向优先级在八邻域内搜索,找不到就扩大搜索范围做跳跃搜索,再找不到就终止。search_order表定义了每个来向对应的搜索顺序,保证追踪的连贯性。
4.3 中心线计算与偏差提取
得到左右边线之后,下一步是计算中心线。最简单的方法是逐行取左右边线点的中点:
// 计算中心线 for (int y = 0; y < IMG_H; y++) { if (left_edge[y].valid && right_edge[y].valid) { center_line[y].x = (left_edge[y].x + right_edge[y].x) / 2; center_line[y].valid = 1; } else { center_line[y].valid = 0; } }但实际中左右边线不一定在每一行都有效,有些行可能只有左边线或者只有右边线。这时候需要用补线策略:如果只有左边线,就根据赛道宽度估计右边线的位置;如果只有右边线,同理。赛道宽度可以用最近几行的左右边线距离来估计。
中心线算出来之后,控制层需要的是偏差值。常用的偏差计算方式是取图像底部若干行的中心点,计算它们与图像中心列的平均偏差:
// 计算偏差 int sum_offset = 0; int valid_rows = 0; for (int y = IMG_H - 1; y >= IMG_H - 20; y--) { if (center_line[y].valid) { sum_offset += center_line[y].x - IMG_W / 2; valid_rows++; } } int offset = (valid_rows > 0) ? sum_offset / valid_rows : 0;这个偏差值直接送给舵机PID控制器,用来计算打舵角度。偏差为正说明赛道偏右,需要向右打舵;偏差为负说明赛道偏左,需要向左打舵。
4.4 参数调优的实操记录
八邻域算法涉及的可调参数不少,我把自己调参的过程和结论整理一下,供参考。
| 参数 | 含义 | 推荐范围 | 调参心得 |
|---|---|---|---|
| 二值化阈值 | 区分赛道和边界的灰度值 | 动态计算 | 分区均值阈值比全局阈值稳定,上下区域分别计算 |
| 跳跃搜索最大步长 | 边线断裂时允许的最大跳跃距离 | 10-20像素 | 太大容易跳到另一条边线,太小无法跨越缺口 |
| 追踪步数上限 | 单条边线最大追踪步数 | 图像高度的1.5倍 | 超过说明追踪异常,需要强制退出 |
| 方向优先级表 | 八邻域搜索顺序 | 按来向动态调整 | 固定顺序在弯道容易出问题,必须动态调整 |
| 中心线有效行数 | 用于计算偏差的行数 | 底部15-25行 | 行数太少偏差抖动大,行数太多响应慢 |
调参的顺序建议是:先调二值化阈值,确保二值化图像清晰;再调跳跃搜索步长,确保边线断裂能正确跨越;最后调中心线有效行数,平衡稳定性和响应速度。
踩坑记录:我一开始用固定阈值,白天调好的参数到了傍晚就完全不能用。后来改成动态阈值,但大津法在单片机上跑太慢,最后用了分区均值阈值才解决问题。建议大家在阈值这块多花点时间,这是整个算法的基础。
5. 常见问题与排查技巧实录
5.1 边线追踪断断续续怎么办
这是最常见的问题,表现为追踪出来的边线一段有一段没有,中心线计算时好时坏。
排查思路按以下顺序进行:
第一,检查二值化图像质量。把二值化后的图像通过无线模块传到电脑上看一眼,如果二值化图像本身就有大量断裂,那问题出在阈值上,需要调整阈值算法。
第二,检查跳跃搜索参数。如果二值化图像看起来还行,但追踪还是断,那可能是跳跃搜索步长太小,跨不过缺口。适当增大跳跃搜索步长试试。
第三,检查方向优先级表。如果追踪在弯道处断裂,很可能是方向优先级设置不合理,导致追踪在弯道处选择了错误的方向。检查search_order表,确保来向对应的搜索顺序是合理的。
第四,检查图像噪声。如果二值化图像上有大量孤立噪点,追踪可能会被噪点带偏。增加中值滤波的强度,或者在二值化后做一次形态学开运算,去除孤立噪点。
5.2 十字路口误判怎么解决
十字路口误判的表现是:车辆在直道上正常行驶,但到了十字路口突然打舵异常,或者中心线突然跳到一边。
排查思路:
第一,确认十字识别逻辑是否触发。在代码里加调试输出,看看十字状态机是否正确切换到了十字状态。
第二,检查补线逻辑。十字区域的补线是否合理,补出来的边线是否与十字前后的边线趋势一致。如果补线方向偏差太大,中心线就会跳。
第三,检查十字区域的宽度判断。如果十字区域宽度判断错误,可能会导致补线范围过大或过小。用实际赛道数据校准这个参数。
第四,考虑多帧确认。单帧识别十字容易误判,可以连续几帧都检测到十字特征才确认进入十字状态,这样可以过滤掉偶发的误判。
5.3 环岛入口识别不稳定的处理
环岛入口识别不稳定表现为:有时候能正确进入环岛状态,有时候直接冲过去,有时候在环岛入口反复切换状态。
排查思路:
第一,检查环岛入口特征检测的阈值。环岛入口的边线弯曲程度和左右边线距离变化是判断依据,如果阈值设置得太严格,可能漏检;太宽松,可能误检。用实际赛道数据反复调整。
第二,增加状态保持机制。进入环岛状态后,强制保持若干帧,不要因为单帧特征不明显就退出环岛状态。这样可以避免状态反复切换。
第三,检查环岛内部的追踪策略。环岛内部如果还在追踪外侧边线,很容易因为边线弯曲而追踪失败。确认环岛内部切换到了内侧边线追踪。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 边线断裂 | 二值化阈值不当 | 查看二值化图像 | 调整阈值算法,用分区均值阈值 |
| 追踪跳点 | 图像噪声大 | 检查滤波效果 | 增强中值滤波,加形态学去噪 |
| 弯道追踪偏离 | 方向优先级不合理 | 检查search_order表 | 按来向动态调整搜索顺序 |
| 十字误判 | 十字识别逻辑太敏感 | 加调试输出观察状态切换 | 增加多帧确认,调整特征阈值 |
| 环岛状态反复切换 | 状态保持机制缺失 | 观察状态机日志 | 增加状态保持帧数 |
| 中心线抖动 | 有效行数太少 | 观察偏差曲线 | 增加用于计算偏差的行数 |
| 追踪速度慢 | 跳跃搜索范围太大 | 测量单帧处理时间 | 缩小跳跃搜索范围,优化代码 |
| 远端边线丢失 | 图像顶部光照不足 | 查看原始图像 | 分区阈值,顶部区域单独处理 |
5.5 几个容易被忽视的实操细节
第一个细节是图像坐标系的方向。不同摄像头的图像坐标系可能不一样,有的原点在左上角,有的在左下角。八邻域的方向定义必须和图像坐标系一致,否则追踪方向会完全错乱。我在第一次调试时就因为这个问题浪费了一整天。
第二个细节是数组越界。八邻域搜索时,当前点如果在图像边界上,邻居坐标可能会超出图像范围。必须在每次访问邻居之前做边界检查,否则会出现莫名其妙的错误。
第三个细节是浮点数运算。单片机上浮点数运算比整数慢很多,八邻域追踪里尽量用整数运算。比如方向优先级的夹角计算,可以用查表法代替实时计算。
第四个细节是调试信息的输出。八邻域追踪的中间结果很多,如果全部通过串口输出,会严重影响实时性。建议只输出关键信息,比如追踪起点、终点、追踪步数、异常标志等。需要详细调试时,可以把图像和追踪结果缓存到内存里,停车后统一导出。
第五个细节是代码的可移植性。不同型号的单片机,寄存器配置和中断处理方式不同。八邻域追踪的核心逻辑应该和硬件相关的代码分离,这样换平台时只需要改硬件层,算法层不用动。
6. 性能优化与进阶方向
6.1 计算量的优化
八邻域算法本身计算量不大,但在高分辨率图像上,追踪步数会显著增加。优化的方向有几个:
一是降低图像分辨率。赛道识别不需要太高的分辨率,80×60甚至64×48就足够用了。分辨率降低一半,计算量降低到四分之一。
二是限制追踪范围。不需要对整幅图都做追踪,只需要追踪图像下半部分或者中间区域。上半部分的远端赛道可以用简单的行扫描来估计。
三是用查表代替计算。方向优先级、跳跃搜索的偏移量,都可以预先算好存成表,运行时直接查表,避免实时计算。
四是并行化。如果单片机有多个核心,可以把左右边线的追踪分配到不同核心上并行执行。不过大多数智能车用的单片机是单核的,这个优化方向适用性有限。
6.2 鲁棒性的提升
鲁棒性是赛道识别的生命线。提升鲁棒性的方法包括:
一是多帧滤波。单帧的追踪结果可能有噪声,可以对连续几帧的中心线做滑动平均,或者用卡尔曼滤波来平滑。这样即使某一帧追踪失败,也不会对控制产生太大影响。
二是异常检测与恢复。当追踪结果出现异常(比如中心线突变、追踪步数异常少)时,不要直接把异常结果送给控制层,而是用上一帧的结果或者预测结果来替代。同时启动恢复逻辑,重新搜索起始点。
三是多算法融合。八邻域不是唯一的边线提取方法,可以结合行扫描、边缘检测等方法,互相验证。比如用行扫描得到粗略的边线位置,再用八邻域做精细追踪,两者结果不一致时以置信度高的为准。
6.3 从赛道识别到路径规划
赛道识别的最终目的是为路径规划和控制提供输入。中心线算出来之后,还可以进一步做路径规划。比如根据中心线的曲率计算最优行驶路径,或者根据赛道宽度动态调整速度。
曲率计算可以用中心线相邻三点的坐标来估计。曲率大的地方说明弯道急,需要减速;曲率小的地方说明直道或者缓弯,可以加速。这样可以让车辆在赛道上跑得更快更稳。
另外,赛道识别还可以用来识别特殊元素,比如坡道、断路、障碍物等。这些元素的识别逻辑和十字、环岛类似,都是通过边线特征来判断。识别到特殊元素后,控制层需要做相应的处理,比如坡道前减速、断路前停车等。
6.4 调试工具与数据记录
调试赛道识别,光靠看代码是不够的,必须有好的调试工具。我常用的调试方式有几种:
一是图像回传。通过无线串口把摄像头图像和二值化图像传到电脑上,实时观察处理效果。这是最直观的调试方式,但需要无线模块的支持。
二是数据记录。把每帧的追踪结果(起点、终点、步数、中心线、偏差值)记录到内存或者SD卡里,跑完之后导出分析。这种方式适合分析偶发问题。
三是仿真验证。把赛道图像保存下来,在电脑上用MATLAB或者Python做仿真,验证算法逻辑。仿真环境可以方便地调整参数,快速迭代。
四是现场调试。在赛道上实际跑车,观察车辆行为,结合数据记录分析问题。这是最终的验证环节,也是最接近比赛环境的调试方式。
实操心得:我建议在代码里预留一个调试模式,可以通过按键或者串口命令切换。调试模式下输出详细的中间结果,正常模式下只输出关键信息。这样既不影响比赛时的实时性,又方便平时调试。
7. 关于八邻域算法的一些个人体会
八邻域算法不是什么高深的技术,但它的实战价值很高。我见过很多队伍在算法选型上追求“高大上”,用神经网络、用深度学习,结果在单片机上跑不动,或者训练数据不够导致泛化能力差。八邻域算法虽然朴素,但胜在可靠、可控、可调试。
我自己的经验是,赛道识别的核心不在于算法多复杂,而在于对实际场景的理解。光照怎么变、赛道怎么铺、摄像头怎么装,这些因素对识别效果的影响,往往比算法本身的优劣更大。把八邻域算法的每一个参数都调到位,把每一种异常情况都处理好,比换一个更复杂的算法要有效得多。
另外,代码的模块化和可维护性也很重要。赛道识别涉及图像采集、预处理、边线提取、中心线计算、特殊元素识别等多个环节,如果全部揉在一起,调试起来会非常痛苦。我建议把每个环节写成独立的函数或者模块,定义清晰的输入输出接口,这样哪个环节出问题就调哪个环节,效率会高很多。
最后说一个容易被忽视的点:赛道识别的时间预算。智能车的控制周期通常是5到10毫秒,赛道识别必须在这个时间内完成。如果识别算法太慢,控制周期就会被拉长,车辆的响应速度就会下降。所以,在算法设计之初就要考虑时间预算,把计算量控制在合理范围内。八邻域算法在这方面有天然优势,但也要注意不要因为过度优化而引入不必要的复杂度。
实际跑车的时候,我习惯在赛道识别模块里加一个计时器,记录每帧的处理时间。如果某帧处理时间明显偏长,就说明可能遇到了异常情况(比如边线断裂导致跳跃搜索范围扩大),需要重点关注。这个习惯帮我提前发现了好几次潜在的性能问题。