资讯详情

智能车摄像头组八邻域边界跟踪算法:从二值图到赛道元素识别

📅 2026/10/5 17:11:08 | 华诺云谱 👁 阅读
智能车摄像头组八邻域边界跟踪算法:从二值图到赛道元素识别
每年智能车备赛那几个月总会有新队员对着串口助手发上来的二值图发愁明明阈值也调了图像也能看到赛道了可车就是不会走。这时候我一般会扔给他一句话别急着调PID先把八邻域边界跟踪跑通。智能车摄像头组的图像算法里八邻域搜索是绕不开的一环它的职责很纯粹——从一帧二值图里把左右两条赛道边界一点一点抠出来。这篇就把这个算法从原理到落地完整拆一遍说说我实测中踩过的坑以及它和十字、环岛这些元素识别之间到底怎么衔接。适合刚接手摄像头组的新队员也适合想重写图像处理逻辑的老队员。1. 八邻域到底在智能车图像里解决什么问题1.1 一帧二值图在单片机里是什么摄像头车和电磁车最大的区别在于电磁车拿到的是几个感应电压值而摄像头车拿到的是整整一帧图像。以常见的灰度摄像头为例输出分辨率经常是160×120或者188×120每一帧就是几十KB的灰度数据。单片机内存有限处理速度也远不如电脑所以第一步很少直接拿灰度图做复杂运算而是先二值化把代表赛道的像素标成白色255把背景和边界区域标成黑色0。做过图像二值化的朋友都知道这一步做好之后单片机拿到的是一个二维数组图片最上面是远处最下面是车头前的近处赛道。八邻域算法处理的就是这个二维数组——一张只有0和255的“地图”。它要做的事可以概括成一句话从某个已知的赛道像素出发沿着赛道边界一格一格往前走把整条边界的坐标点全部记录成一条“边界链”。左边界一条链右边界一条链两条链之间的中线就是控制算法需要的路径。很多新队员拿到逐飞的例程看到search_neighbor、find_edge这类函数一头雾水实际上它们都是八邻域搜索思路的不同变体。1.2 给每个像素定义八个邻居“八邻域”的含义很直白一个像素周围有八个相邻像素分别是上、下、左、右和四个对角方向。为了在代码里方便表述通常给这八个方向编号我习惯用下面的表// 方向0为正上方顺时针编号图像坐标系下y轴向下 static const int8_t dx[8] {0, 1, 1, 1, 0, -1, -1, -1}; static const int8_t dy[8] {-1, -1, 0, 1, 1, 1, 0, -1};这里的dx表示列偏移dy表示行偏移。方向0对应行减1、列不变方向1对应行减1、列加1方向2向右走方向3向右下走……一直到方向7回到左上。之所以要这样约定好编号是因为后续判断“方向是否连续”要用到模8运算比如当前方向是3下一个点出现在方向4方向差就是1说明边界只拐了一个小角度很合理。方向差太大说明边界发生了不正常跳变多半是搜飞了。1.3 顺着墙走迷宫而不是把整间屋子扫一遍新队员容易犯的第一个错误是试图用全图扫描来找赛道。具体做法是从图像第一行第一列开始逐像素判断哪些白色区域连成片再做连通域标记。这种思路在PC上完全没问题但在单片机上非常浪费——一张160×120的图像就有19200个像素如果每个像素再做连通域记录内存和耗时都吃不消。八邻域算法的聪明之处在于它只在边界附近活动。边界是一条连续的线已知前一个边界点下一个边界点大概率在上一个点的附近根本不需要去关心整张图。用生活里的事类比就是你在迷宫里找出口时与其把每间屋子都翻一遍不如一直摸着右手边的墙往前走。做智能车也是这个道理赛道边界本身就是一堵连续的“墙”八邻域就是那只摸墙的手沿着边界线一路跟到图像顶端整条赛道的形状自然就出来了。2. 从二值图到边界种子搜边界前的输入准备2.1 ROI裁剪与坐标系约定八邻域搜索不是拿着整帧图像直接跑的先用ROI把有用区域框出来能省掉大量无效计算。车跑起来之后图像上方往往是远处的天空、观众席或者场地边缘的杂物这些区域对循迹没有帮助还会让二值化阈值跑偏直接裁掉。图像下方贴近车头的位置大部分时候是同一段赛道信息重复度高也可以裁掉一部分把算力留给中远距离。我常用的ROI大概是原图从第10行到第105行列方向保留全部。坐标系约定成“行号越小越远、行号越大越近”数组读写直接用img[row * width col]。这里有个小建议在代码里把ROI的上下边界定义成宏而不是散落的魔法数字因为后面调车时你一定会反复改这个范围——远处砍多了弯道入弯信息会丢失近处留多了图像底部被车头阴影干扰。2.2 二值化不能只用一个固定阈值二值化的质量直接决定八邻域搜索的生死。如果固定阈值设成120下午三点场地光线好赛道白色区域灰度普遍在180以上二值化没问题到了下午五点太阳斜射赛场一半亮一半暗暗处的白色赛道灰度可能只有100同一个阈值下直接变成黑色边界就断了。所以我现在在摄像头组基本不用固定阈值而是用自适应策略。最简单的是大津法统计ROI内灰度直方图计算使前景背景类间方差最大的阈值更轻量一点的做法是取ROI中间区域灰度均值乘一个系数比如0.8作为当前帧阈值。大津法在“白色占大头”或“黑色占大头”的画面里容易偏所以统计直方图时我会刻意避开最顶部和最底部的行只统计中间60行效果明显更稳。// 自适应阈值简例统计ROI中间区域的灰度均值 uint32_t sum 0, cnt 0; for (uint16_t r 20; r 80; r) { for (uint16_t c 0; c W; c) { sum gray_img[r * W c]; cnt; } } uint8_t threshold (uint8_t)((sum / cnt) * 0.8f);2.3 预处理别让反光把边界切断做过实车测试的都知道赛道上最烦的不是弯道太急而是反光。阳光打在PVC赛道上会形成一块块高亮白斑灰度值甚至比赛道本身还高阴影区域则可能把白色赛道压成灰色。二值化之后反光区域可能变成一大块不规则白色阴影区域则可能在赛道内部挖出一个“黑洞”。这种情况下直接做八邻域搜索边界链会沿着反光的边缘乱跑或者遇到阴影造成的断点直接停下。我在实际项目里的做法是先对二值图做一次简单的3×3中值滤波把孤立的噪点去掉再做一次膨胀操作把因为阴影产生的小缺口补上。注意不能膨胀太多否则会把两条边界之间的小缝隙也填平导致元素识别时看不清楚赛道宽窄变化。2.4 底部扫描左右边界起点怎么找边界跟踪必须有一个起点。最稳妥的起点在图像底部也就是离车身最近的那一行。左边界起点从这一行最左侧开始向右扫描遇到第一个白色像素就停右边界起点从最右侧开始向左扫描遇到第一个白色像素就停。// 底部行找左边界种子点 uint16_t leftStart 0; for (uint16_t c 0; c W; c) { if (binary[bottomRow * W c] WHITE) { leftStart c; break; } }如果底部整行一个白点都没有说明车头可能已经冲出赛道或者前方全部被阴影覆盖。这种情况下不要强行继续而是往上回退三行再找一次。还有一个更稳的经验把上一帧的边界起点坐标存下来当前帧先在上一帧位置附近±10个像素的小窗口里找种子找不到再全行扫描。因为帧与帧之间车只移动了一小段距离边界不会突然跳走这样既省时间又避免选错起点。3. 八邻域边界跟踪的代码落地3.1 为什么要限制搜索方向而不是八个方向全查这是整个八邻域算法里最关键的细节也是新手最容易踩的坑。理论上边界点是连续延伸的下一个点肯定在八个方向之一但如果你真的把八个方向全部扫一遍选一个合格点就走在十字路口或者反光区域一定会出问题——因为那两种场景下横向通道的白色区域会形成一条“岔路”边界继续往前延伸和岔路延伸都是合法的白色像素算法到底选哪个我的做法是引入方向连续性约束维护一个当前方向curDir下一轮的候选方向从curDir开始往外扩展优先走正前方依次放宽到左右偏45度和左右偏90度超过这个角度就认为这个点不可信。这段逻辑用代码写出来很直接uint8_t find_next_boundary( const uint8_t *img, uint16_t width, uint16_t curX, uint16_t curY, uint8_t curDir, uint16_t *outX, uint16_t *outY, uint8_t *outDir, uint8_t maxTurn) { // turn0时只查当前方向 for (uint8_t turn 0; turn maxTurn; turn) { if (turn 0) { uint8_t nd curDir; uint16_t nx curX dx[nd]; uint16_t ny curY dy[nd]; if (nx width img[ny * width nx] WHITE) { *outX nx; *outY ny; *outDir nd; return 1; } } else { // 优先向左偏再向右偏或反过来需要实验确定 for (int8_t sign -1; sign 1; sign 2) { uint8_t nd (curDir turn * sign 8) % 8; uint16_t nx curX dx[nd]; uint16_t ny curY dy[nd]; if (nx width img[ny * width nx] WHITE) { *outX nx; *outY ny; *outDir nd; return 1; } } } } return 0; }maxTurn设成2意味着最多允许一次急转弯90度正常情况下够用。弯道再急只要不是原地掉头边界方向的变化也是渐进的单帧图像里不可能瞬间偏到120度以上。3.2 终止条件什么时候算跟完了边界链不能无限跟下去必须设好终止条件。我在代码里放了四道保险第一到达ROI顶部行也就是找完了整幅图像的有效区域。第二边界链长度超过上限比如ROI高度是90左右边界各最多存120个点防止意外循环。第三连续丢点计数超过阈值。find_next_boundary返回0表示当前位置找不到下一个合法点如果连续3到5次都找不到说明前方边界断了不能再继续否则会越跳越远。第四闭环检测。这个主要防环岛内部那种“岛”形白块边界跟踪可能绕着岛转整整一圈需要判断新点是否回到起点附近5×5范围内是的话立刻停止。3.3 边界断了怎么办丢点回溯即使做了方向连续性约束实车上还是会碰到边界断掉的情况。最典型的是远处对比度低白色赛道和灰色地面在二值化后分不开边界链走到一半就没有合法点了。早年我直接把这条边界标记为“失败”结果车一到稍微远一点的地方就失去路径参考很被动。后来我改成“回溯重试”记录最近若干个有效点一旦连续找不到新点就退回倒数第二个有效点把maxTurn临时放宽到3再试一次如果还是找不到再退回倒数第三个点。这样处理之后很多断点其实只是边界在某个小区域里拐了一个很急的弯放宽角度限制就能重新接上。如果回溯了10个点仍然失败那就真的放弃把这条边界的有效范围记为当前位置之前的部分控制算法只使用有效区域的数据。3.4 左右边界跟踪的顺序与同步左右边界可以分两次独立跟踪但顺序上我建议先跟条件好的一侧。怎么判断哪边条件好看底部种子点附近有没有明显异常比如左边界种子点距离图像左边缘很近说明左侧可能是赛道边缘或者已经压线优先跟右侧更稳。跟完一侧之后另一侧的起点可以用“对应行对方的边界坐标±赛道宽度”预估出来这样即使底部扫描有误也不至于把起点找错。赛道宽度的初值在代码里常写成固定值比如35个像素。真正跑的时候更合理的做法是动态维护一个近处赛道宽度均值每帧用底部几行有效左右边界的差值更新这样十字、环岛等宽度突变场景到来之前预估起点还不会差太远。4. 边界线出来之后赛道元素识别与中心线输出4.1 从边界链上统计赛道宽度和斜率边界链不是最终产品它只是原始材料。拿到左右边界之后第一件事是逐行计算赛道宽度width[row] rightBoundary[row] - leftBoundary[row]。正常直道上这个值基本稳定弯道上会有一侧边界逐渐向另一侧靠近但宽度不会突变。近处的赛道宽度可以用中位数或者平均值维护成“正常宽度参考值”。为什么要用中位数而不是平均数因为一旦进入十字区域宽度会瞬间变大好几倍平均数会被拉高导致之后判断“宽度突变”的阈值不准。中位数对异常值不敏感连续几十行里只要大部分是正常宽度哪怕混入几行十字区域的宽数据参考值也不会漂太多。4.2 十字、环岛在八邻域边界上长什么样十字路口从图像上看最突出的特征不是“路变宽了”而是左右边界在十字横向通道位置各自向外“跳”了一大步。因为原本两侧是黑色边界横着连通了一条宽阔的白色通道边界线下方的白色区域不再收窄。用八邻域跟边界时会发现边界方向在十字入口处突然发生一次大角度转向然后又恢复正常。判断十字的实用做法是统计连续若干行的width数组如果发现其中一段的宽度超过正常参考值的1.5倍同时对应的边界斜率和前后行相比出现明显拐点就置十字候选标志。进入十字之后八邻域搜索很容易在横向通道里误判边界所以我会在确认十字标志后临时把maxTurn降到1让边界尽量沿原来的方向走减少被横通道“吸走”的概率。环岛的特征比十字更明显。进入环岛后一侧边界会呈现连续同方向的圆弧走向另一侧则会出现一个封闭的“岛”形边界。在边界链上可以每隔几个点计算一次切向角变化累计变化量超过180度并且弧长足够长基本就是环岛没跑了。我当年第一次做环岛识别时以为要用复杂的曲率拟合后来发现八邻域边界链本身已经把圆弧信息带出来了我只用简单统计切向角跳变就够了。坡道相对简单远处行数的边界点大面积丢失近处正常同时整幅图像的白色区域高度差变小。这种情况不需要特殊边界算法只要在控制层把远方边界丢失导致的大误差抑制住就行。4.3 中心线生成与控制接口左右边界都齐的时候中心线就是逐行中点center[row] (left[row] right[row]) 1。某一行缺少边界数据时可以用上下有效行的中点做线性插值把这一行补上。插值之后再做一次三点的均值平滑避免边界锯齿直接传导到转向控制导致方向抖动。控制算法一般只需要图像底部往上某一段范围内的中心线比如从底部往上20行到60行。我在实际项目里会计算三个特征量近处中心点的横坐标作为位置误差近处中心点与中远处中心点的横坐标差作为方向误差有效边界长度作为置信度。这三个量全部来自八邻域边界链推导非常简单但已经足够支撑PID或者纯跟踪控制。4.4 用状态机管理元素标志位元素识别结果不能只存在局部变量里否则控制层不知道当前车处于什么场景。我习惯维护一个全局元素状态机取值包括NONE、CROSS、ISLAND、SLOPE等。每次图像处理结束更新状态机的进入和退出条件。十字识别到之后不是永远保持十字状态而是要求连续若干行宽度恢复正常并且边界方向再次趋于平行才退回NONE。这里和控制的接口约定有一点很重要元素标志位只是给控制一个“场景上下文”不能越权直接决定打角。比如十字状态下控制层知道当前不需要着急寻找近处边界可以更大胆地按照中心线直行环岛状态下控制层可以修正自己的预期方向但具体的转向量仍然来自中心线误差。这样图像层和控制层耦合最小出现误判时也容易排查。5. 性能优化的实测思路5.1 算一算全图扫描和边界跟踪差多少很多队伍用TC264做主控主频虽然不低但图像处理如果在主循环里拖太久控制周期就会不稳定。拿160×120的全图扫描来说每个像素判断一次颜色就是19200次操作再加上可能遇到的连通域标记内存访问和逻辑判断会成倍增长。换成八邻域跟踪左右两条边界每一侧大约跟踪60到100个点每个点平均检查3到4个方向合计不到1000次像素访问比全图扫描少了一个数量级。这就是为什么八邻域在MCU上能跑得动而全图连通域标记会卡顿。5.2 用查表和行偏移替代重复计算嵌入式代码写多了就会发现性能瓶颈往往不在算法复杂度而在一些看起来很不起眼的乘法。row * width col每次都要算乘法如果循环体里执行几百次累积下来的耗时并不少。我的做法是预计算每一行的起始地址放到一个全局数组里static uint16_t rowOffset[MAX_H]; for (uint16_t r 0; r MAX_H; r) { rowOffset[r] r * W; }之后所有取像素操作都写成img[rowOffset[row] col]省掉每次的乘法。方向增量dx/dy已经查表了以TC264的流水线执行速度一次边界点搜索的开销很小。另外一个建议find_next_boundary这样的函数定义成static inline避免不必要的函数调用压栈开销。5.3 处理时序别把算法塞进中断摄像头数据进来时会有场中断和行中断新队员一激动就容易把图像处理代码全写进中断服务函数。这个坑我踩过——中断里跑八邻域搜索整个系统其他任务的实时性全毁了表现在车上是舵机偶尔抽动一下串口打印的数据忽快忽慢。合理的时序安排是场中断里只置一个标志位DMA把当前帧图像搬到内存数组后主循环检查到标志位再开始二值化和边界跟踪。如果采集分辨率较高可以准备两块缓冲区DMA往一块缓冲区写当前帧的时候主循环处理上一帧数据这就是常见的双缓冲机制。多核或者带协处理器的型号可以进一步把二值化放进并行计算单元但对大部分组别来说DMA加双缓冲已经够用。5.4 实车上最常遇到的几个疑难角落第一个是强反光导致的“白茫茫一片”。二值化后赛道和反光区域连成一体八邻域边界会沿着反光边缘绕一个大弯看起来像是边界飞线。解决思路不在跟踪算法而在二值化反光区域虽然也白但比正常赛道更亮可以设置一个上限阈值灰度值超过这个上限的像素反而判定为异常强制变黑。第二个是十字区域边界跳变。前面说过用maxTurn限制可能缓解但遇到大型十字横通道宽到让整个白色区域连成一大块时边界还是会飞。我最后采用的是“宽度突变时切换策略”一旦检测到width超过正常宽度1.5倍就临时放弃这条边界链的继续跟踪改用全图逐行扫描来提取当前行的左右边界直到宽度恢复正常再切回八邻域。这是牺牲少量算力换取稳定性实测非常有效。第三个是环岛内圈的闭环问题。八邻域跟踪“岛”形白色区域时会沿着岛边缘不停地绕圈如果没有闭环检测边界链长度会非常长后续计算全部被污染。我的教训是边界点数量超过ROI高度的1.5倍时强制停止再检查是否回到了起点附近两者满足其一就终止本次跟踪。6. 给新队员的调试顺序与参数初值6.1 先在静态图上调通别急着上赛道我见过太多队伍代码还没验证过就装车上路结果车冲出赛道分不清是算法问题还是参数问题。正确做法是先用逐飞或者自己写的图像上位机把摄像头拍的灰度图导出来离线跑二值化和八邻域搜索看看边界链是不是贴合真实赛道边界。验证通过之后再用串口或者虚拟示波器把实时处理结果发出来在电脑上观察边界点叠加在原图上的效果。判断跟踪质量有几个硬指标边界链不能有超过连续3个点的明显跳变左右边界在近处行必须完整直道上两条边界之间的中点连线应该基本是一条平滑曲线。只要静态图像这一关没过就不要上电机不要在车上找问题——车上变量太多根本定位不了。6.2 实车调试按难度分级推进静态调通后把车架起来后轮悬空先看图像输出是否正常。然后低速上赛道先跑直道确认边界链稳定再上弯道观察入弯和出弯阶段的边界是否平滑最后才上十字和环岛。每个阶段都有对应的观察重点直道看边界是否有锯齿弯道看边界是否提前丢失十字看宽度突变时是否切到了全图扫描模式环岛看弧线边界是否被完整跟踪。我当年带队的经验是一次只调一个变量。很多人把二值化阈值、maxTurn、搜索行数上限同时改一遍出了问题根本不知道是哪个改动引起的。正确做法是固定其他参数只改动一个跑一圈记录下来再改下一个。把每次调参的结果记在表格里两天下来就能摸清车子的脾气。6.3 参数初值参考下面这组参数来自我实际调车比较稳定的起点可以根据自己的赛道光照和摄像头安装角度再调整参数含义推荐初值备注ROI顶部行远处裁剪范围10光线好可适当上调压暗可下调中值滤波窗口去除孤立噪点3×3太大容易抹掉边界细节自适应阈值系数均值乘系数0.75~0.85先固定再随光照微调maxTurn每步最大转向方向数2十字用1普通弯道2连续丢点数上限边界断链判定3~5太大容易飞线太小容易误断边界链最大长度防闭环保护150约为ROI高度的1.5倍回溯步数断链后回退重试10实测超过10意义不大正常宽度系数突变判定阈值1.5超过参考值1.5倍视为异常6.4 我对八邻域算法的一句话体会把八邻域代码重写到第三遍之后我才意识到它真正的难点从来不在“找到下一个点”这个动作而在于搞清楚什么时候该找、什么时候该停、边界断了之后怎么回头。很多新队员把边界跟丢归结为“图像算法不如例程好”其实大部分情况是二值化在光照变化下不稳定或者搜索策略没有针对具体赛道元素做约束。先花时间把图像质量搞稳再回头审视八邻域的终止条件和回溯逻辑你会发现这个算法简单但极耐打磨。参数可以照抄我这篇的初值但车上的光线、轮胎、赛道胶带都不一样最终一定得按实际图像的反馈来回调。祝调车顺利少走几个我当年绕过的弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑