智能车八邻域图像算法把赛道边界“描”出来在智能车竞赛的摄像头组里图像算法一直是最重要也最折腾人的一环。无论是21届还是22届的全国大学生智能车竞赛摄像头组的核心任务都没变把摄像头拍到的画面变成小车能理解的赛道信息。而在这个环节里八邻域图像算法是很多队伍绕不开的经典方案。它本质上做的事很简单——把二值化图像里的赛道边界点一个个“描”出来形成左右两条边界线再算中线、算偏差、算曲率喂给转向环和速度环。这篇就把八邻域从原理到落地拆开讲透适合正在备赛、准备入门智能车图像处理的同学也适合那些觉得“用了但没完全懂”的选手。先解释一下我为什么专门写八邻域。智能车图像处理中提取边界的方法不少有直接逐行扫描跳变点的有用固定搜索窗口的也有用Sobel边缘检测的但在竞赛这种低算力、高实时性、光照不稳定的环境下八邻域边界跟踪有着独特的优势它不依赖一条条行扫描的独立性而是把边界看成一条连续曲线去跟踪天然带了“连续性”这个先验信息。这就让它在十字、环岛、断路这些复杂元素的识别上有天然扩展空间。下面我会从原理、预处理、代码实现到调参踩坑一条线讲清楚。1. 为什么偏偏是八邻域图像处理方案选型逻辑1.1 摄像头组图像处理到底要解决什么问题先把需求端说清楚。智能车摄像头组拿到的原始图像一般是灰度图以最常见的MT9V03X系列摄像头为例输出分辨率常见的是188×120也有用160×120或者更高分辨率的。但不管分辨率多少处理目标是一致的在有限的算力里尽最大可能实时、稳定地提取出赛道的左右边界。很多刚入门的同学会直接把整幅图像丢给算法逐像素去遍历这在PC上无所谓但在TC264、TC377这类单片机上就是灾难。TC264主频高一些但跑完整幅图的浮点计算依然奢侈如果用的是入门级的K60或者STM32F4那更要精打细算。所以在竞赛圈图像处理有个不成文的习惯不需要处理整幅图只需要处理“感兴趣区域”。而八邻域算法恰好可以把“逐行遍历全图”压缩成“只遍历边界点”把二维的问题压缩成一维的边界曲线问题算力消耗瞬间降下来。还有一个核心需求是“稳定”。我见过不少队伍用逐行扫描跳变点的方式找边界这种方法在光线均匀的室内赛道很完美但一到强光、逆光或者反光路面跳变点会被噪声干扰得七零八落。八邻域因为是基于连续跟踪的思路对单点噪声天然有一定抵抗力配合上开运算和中值滤波稳定性会好很多。1.2 几种边界提取方案的血泪对比我在备赛过程中陆续试过几种主流方案这里直接做一个横向对比帮助大家理解为什么最后会落在八邻域上。逐行扫描跳变点是新手最容易上手的方法从图像底部往上对每一行从左往右扫像素从白变黑的那个点就是左边界从黑变白的点是右边界。它的优点是代码简单、逻辑直白二值化做得好的时候效果也不错。但缺点也很明显——它假定每行有且仅有两个跳变点一旦遇到十字路口、环岛入口这种多边界同时存在的场景就不知道该选哪一组跳变了。另外它对每一行的处理是独立的单行噪声会导致边界点在行与行之间来回跳中线抖得厉害。固定窗口搜索法是进阶一点的方案在上一行找到的边界点左右一定范围内搜索下一行的边界这样边界曲线会比较平滑也能避免跳到别的边界上去。它的弱点是搜索窗口宽度不好定太大失去意义太小又跟不上弯道。而八邻域算法本质上是“边缘跟踪”也叫“边界跟踪”。它从赛道底部找一组起始点种子点然后沿着边界逐点往上走每一步都在当前点的八个相邻像素里找下一个边界点。这种方式天然把边界限制成一条连续曲线既能处理弯道也不会被无关区域干扰而且输出是一串有序点列后面做中线拟合、斜率计算、元素识别都方便。当然八邻域不是万能的。如果二值化之后赛道边缘碎裂严重或者图像里有大量离散噪点八邻域跟踪很容易跟丢。所以它很依赖前级图像预处理质量。这也是我后面花一整节讲预处理的原因。1.3 八邻域适合的赛道元素与应用场景八邻域最大的价值在于它可以作为“地基”算法来用。边界点提取出来之后不只是算中线你还可以基于左右边界点做很多判断算中线的水平坐标差得到横向偏差对左右边界点做最小二乘拟合得到赛道斜率判断直道/弯道统计当前行左右边界宽度突然变宽可能进了十字突然变窄可能是环岛出口如果某一侧边界连续缺失可能是有断路或者坡道触发特殊应对逻辑。所以八邻域不是一个孤立算法它是整个图像处理链路的“数据源头”。这也是为什么很多有经验的队伍会把八邻域边界提取写得很稳健——它一旦崩了后面全崩。2. 八邻域算法的工作原理解析2.1 核心概念种子点、邻域搜索方向、跟踪循环在讲代码之前我用通俗的方式把八邻域的机制说清楚。假设我们已经把图像二值化赛道是黑色的0背景是白色的255或者在有些队伍里反过来都可以关键是前后统一。八邻域算法要做的是从图像底部开始找到黑白色块交界处的一个“起点”然后沿着交界边缘一格一格往上爬。这里有几个概念要先建立种子点Seed Point边界跟踪的起始点。一般从图像底部行开始从中间往两侧扫描遇到的第一个黑色像素与白色像素的交界点就是左右边界的种子点。八邻域8-Neighborhood以当前边界点为中心周围8个像素上、下、左、右、左上、右上、左下、右下。搜索方向优先级为了让跟踪不回头、不乱跳需要定义一个固定的搜索顺序。比如从“右上”开始顺时针搜一遍或者按“上-左-右-下-左上-右上-左下-右下”的优先级搜具体顺序影响很大后面展开讲。跟踪终止条件当搜索点回到种子点附近、越出图像边界、或者连续跟踪点数超出预设上限时结束循环。理解八邻域最简单的类比是你用手指在等高线地形图上沿着一条等高线画线你不需要看整张地图只看手指当前位置周围的一小块决定下一步往哪里挪然后一直挪下去就能描完一整条线。八邻域算法做的就是这件事。2.2 搜索方向的优先级为什么最重要这是八邻域算法里最容易被忽略也最容易出问题的细节。一个简单的想法是从当前点出发把周围8个点全部检查一遍哪一个是黑色像素就走哪。但实际运行时会发现这样做的后果是算法在边界上左右横跳甚至原地打转。原因很简单边界不是一个像素宽的细线二值化后的边界其实是一个有宽度、有毛刺的区域当前点的8个邻居里可能有多个都是黑色像素到底选哪个如果选一个“回头方向”的邻居就会沿着边界往回走形成死循环——你踩过这个坑就知道有多痛苦。所以必须定死搜索顺序。实践中最稳妥的方式是确定当前点的“来向”然后从“来向的前方”开始按顺时针或逆时针搜索。举个例子如果上一个边界点在当前点的左下方那么下一步搜索应从“上”开始顺时针转一圈优先寻找“上前方”的黑色像素。这种策略保证了算法一直朝一个方向前进不会回头。代码实现上可以定义一个方向数组比如// 八方向偏移量上、右上、右、右下、下、左下、左、左上 const int dx[8] {0, 1, 1, 1, 0, -1, -1, -1}; const int dy[8] {-1, -1, 0, 1, 1, 1, 0, -1};然后根据上一个点的移动方向决定从哪个下标开始搜索循环遍历一圈。这里要特别注意的是起点周围如果同时存在“前方”和“侧方”两个黑色像素一定要优先选“前方”否则边界会在同一个地方绕一个圈导致提取的边界线“多出一个小疙瘩”后面算中线时会产生高频抖动。2.3 八邻域提取的伪代码与退出条件八邻域边界跟踪的伪逻辑可以这样写// 输入二值图像 image[height][width] // 输出左边界 left_border[row]、右边界 right_border[row] 1. 从底部中间行 row height - 1 开始 自中间向左扫描找到第一个“白→黑”转变点记为 leftSeed 自中间向右扫描找到第一个“黑→白”转变点记为 rightSeed 2. 以 leftSeed 为起点调用 trackEdge(image, leftSeed, step, direction) 2.1 初始化 cur leftSeedprev 某个方向通常是 leftSeed 的下方点 2.2 while (未越界 未回到起点附近 搜索次数 maxSteps): 根据 prev 与 cur 的相对关系确定搜索起始方向 遍历 8 个方向找到第一个满足条件的黑色像素作为 next if (next 存在): prev cur cur next 记录 cur 到边界数组 else: 结束跟踪边界断裂 2.3 返回边界数组 3. 以 rightSeed 为起点用同样的 trackEdge 逻辑提取右边界关于退出条件这里有一个容易踩的细节如果一直不设置最大步数在某些异常图像上算法可能无限循环导致整个控制程序卡死。所以我强烈建议每个边界跟踪都加上步数上限最稳妥的上限是“图像行数×2”或者“图像总像素数”并且在使用前把边界数组清零。实际竞赛中边界正常情况下的点数不会超过图像行数所以设置成2倍行数已经留了足够余量。3. 实战链路从摄像头图像到八邻域边界3.1 图像预处理二值化、开运算与ROI裁剪八邻域算法输入的是二值图所以二值化的效果直接决定了八邻域的上限。这一步值得花大力气去磨很多队伍在八邻域上反复出bug回头一看根因其实是二值化出来的图太脏了。我常用的流程是灰度图先做一次中值滤波3×3或5×5去掉摄像头噪点。有的队伍为了省时间会跳过这一层但如果你的图像上有一粒一粒的椒盐噪声中值滤波会极大降低后续八邻域跟丢的概率。二值化用大津法OTSU动态阈值不要用固定阈值。因为赛道的光照是不稳定的上午和下午、晴天和阴天、室内和室外灰度分布差很多固定阈值调一次崩一次。大津法的原理是按灰度直方图把像素分成两类使类间方差最大化这样能自适应地找到分割阈值。计算量也不大在TC264上跑完全可行。实在想省算力的可以只在ROI区域计算阈值。对二值图做开运算先腐蚀后膨胀这一步非常关键。开运算是把图像中细小的高亮噪点腐蚀掉然后再膨胀回来能有效断开细小的噪声连接避免八邻域跟到噪点上去。核大小一般取3×3就够了5×5会损失边界细节。最后裁剪ROI。一般把图像顶部无用的天空、场地背景直接裁掉只保留赛道路面区域这样一方面减少干扰另一方面降低计算量。这里多提醒一句做完这些预处理之后记得把二值化结果存成一份调试图像通过无线模块或者SD卡发到上位机看。不要凭感觉调参图像算法永远是“眼见为实”。3.2 主函数实现找到种子点并跟踪左右边界下面给一段可以直接参考的C语言实现框架。这段代码我是在TC264上写的但逻辑完全通用换到K60、STM32H7上只需要改一下底层图像数组的获取方式。#define IMG_W 188 #define IMG_H 120 #define SEARCH_OFFSET 15 // 底部中心往两侧搜索的起始偏移 uint8_t binary_image[IMG_H][IMG_W]; // 二值化后的图像255为白色0为黑色 int left_border[IMG_H]; int right_border[IMG_H]; typedef struct { int x; int y; } Point; // 方向偏移数组上、右上、右、右下、下、左下、左、左上 const int dx8[8] {0, 1, 1, 1, 0, -1, -1, -1}; const int dy8[8] {-1, -1, 0, 1, 1, 1, 0, -1}; int is_black(int x, int y) { if (x 0 || x IMG_W || y 0 || y IMG_H) return 0; return (binary_image[y][x] 0); } Point track_edge(Point seed, int start_dir) { Point cur seed; Point prev; prev.x seed.x - dx8[start_dir]; prev.y seed.y - dy8[start_dir]; int steps 0; int max_steps IMG_H * 2; // 记录起点 left_border[cur.y] cur.x; while (steps max_steps) { // 根据上一个点相对当前点的方向决定搜索起始索引 int rel_dir -1; int delta_x cur.x - prev.x; int delta_y cur.y - prev.y; for (int i 0; i 8; i) { if (dx8[i] delta_x dy8[i] delta_y) { rel_dir i; break; } } // 从当前方向的下一个方向开始逆时针搜索一圈 int start_idx (rel_dir 1) % 8; int found 0; for (int k 0; k 8; k) { int idx (start_idx k) % 8; int nx cur.x dx8[idx]; int ny cur.y dy8[idx]; if (is_black(nx, ny)) { prev cur; cur.x nx; cur.y ny; if (cur.y IMG_H cur.y 0) { left_border[cur.y] cur.x; } found 1; break; } } if (!found) break; // 边界断裂 // 如果回到起点附近结束 if (abs(cur.x - seed.x) 1 abs(cur.y - seed.y) 1 steps 5) { break; } steps; } return cur; }这段代码的核心思想是每次都根据“上一个点相对当前点的方向”来动态计算搜索起始方向。为什么不用固定的起始索引举个例子如果上一个点在当前点的上方意味着我们正在从下往上跟踪边界那下一步就应该优先搜索上方其次是左上、右上而不应该回头搜下方。但如果上一个点在当前点的左侧说明我们正在横向通过某个区域搜索优先级就应该从“右上”开始而不是从“上方”开始。这样一个动态优先级的处理能有效避免来回打转。种子点的获取也不复杂。我在最底部一行从图像中心点往左右找跳变点int mid_x IMG_W / 2; Point left_seed, right_seed; left_seed.y right_seed.y IMG_H - 1; // 找左边界种子点从中间往左找第一个白→黑跳变 left_seed.x -1; for (int x mid_x; x 0; x--) { if (binary_image[IMG_H - 1][x] 0) { left_seed.x x; break; } } // 找右边界种子点从中间往右找第一个黑→白跳变 right_seed.x -1; for (int x mid_x; x IMG_W; x) { if (binary_image[IMG_H - 1][x] 0) { right_seed.x x; break; } } if (left_seed.x 0) { track_edge(left_seed, 4); // 以“下方向”为起始 } if (right_seed.x 0) { track_edge(right_seed, 4); }种子点找不到怎么办大概率是二值化异常或者图像底部已经没有赛道了此时应该做异常处理要么沿用上一帧的边界数据要么把转向控制量设成一个安全值。千万不要让种子点非法还继续往下跑。3.3 从边界到中线转向控制的数据基础拿到左右边界之后最朴素也最实用的做法是逐行计算中点int center_line[IMG_H]; for (int row 0; row IMG_H; row) { if (left_border[row] 0 right_border[row] 0) { center_line[row] (left_border[row] right_border[row]) / 2; } else if (left_border[row] 0) { center_line[row] left_border[row] TRACK_WIDTH_HALF; // 用经验赛道宽度补偿 } else if (right_border[row] 0) { center_line[row] right_border[row] - TRACK_WIDTH_HALF; } }注意这里用了一个经验值TRACK_WIDTH_HALF。当某一侧边界缺失时用另一侧边界加或减半个赛道宽度来估算中线。这个经验值需要根据实际赛道宽度标定一般可以在调试界面上量一下赛道像素宽度取一个平均值。有些队伍会动态估算赛道宽度——统计最近若干行的左右边界宽度取中值或均值来避免赛道宽度变化导致估算偏差。中线的用途就非常直白了取底部若干行的中线平均值减去图像中心点得到横向偏差errorerror经过PID或者纯跟踪算法转换成舵机转角中线连续多行近似直线说明是直道可以加速中线曲率大说明进弯需要减速。说到底八邻域只是把边界信息高质量地提取出来真正让车跑得稳的是后面这一整套控制逻辑。但边界提取质量越高控制逻辑就越省心。我在调车时经常说如果中线总是抖先别急着调PID先回头看看八邻域提出的边界是不是平滑的。4. 光看不练假把式冠军队都在偷偷做的几件事4.1 调试上位机与图像回传这是我认为整个调试过程中投入产出比最高的一步。不要只在单片机里烧代码然后看小车跑出来的效果去猜算法哪里有问题那样效率太低。我一般会在代码里留一个调试模式把二值化图像、边界点、中线点通过串口或者无线模块传回电脑上位机实时显示出来。这样你能一眼看出八邻域跟丢的具体位置是在十字路口还是环岛是光线反光还是赛道断裂排查问题的速度快好几倍。上位机可以是自己写的小工具也可以用现成的串口屏调试助手。关键是通信协议要稳定最好加上帧头、帧尾和校验避免图像数据错位。在调试阶段哪怕每秒只传5帧图都够用了重点是看得清楚不是跑得快。4.2 参数标定与阈值自适应的工程化处理很多队伍在实验室调好的参数一到比赛现场就崩根因就是固定阈值没有自适应能力。我强烈建议至少做两件事第一二值化阈值用大津法。大津法的计算非常简单统计灰度直方图遍历0~255每个灰度值计算类间方差取方差最大的那个灰度值作为阈值。在188×120的图像上算一次的时间开销可以忽略不计。如果嫌大津法在某些特殊光照下效果不好可以再加一个“灰度直方图双峰检验”如果直方图没有明显的双峰分布就退回固定阈值。第二边界宽度异常检测。正常赛道的左右边界宽度应该在一个合理区间内。当八邻域提取出的某一侧边界连续多行宽度异常时说明极大概率是跟踪出错了此时宁可丢弃这部分数据、用上一帧的数据插值补齐也不要让错误的边界数据直接进入PID控制器。我给每帧图像加了一个“边界健康分数”健康分数低于阈值就直接沿用上一帧的控制量小车的稳定性会显著提升。4.3 性能优化从全图遍历到关键行计算八邻域已经把算力消耗降了很多但还有优化的空间。我自己的习惯是不做全图二值化。只在ROI区域做ROI以外直接当作背景处理。边界跟踪不需要从底部一直跟到顶部当某一行的左右边界宽度小于阈值时就认为前方是直道尽头或者赛道消失点可以提前终止跟踪。中线拟合用最小二乘直线或者二次曲线时不需要用全部行取底部连续12~20行的中线点就够算了。距离越近的数据对控制的实时性越好太远的数据反而会因为透视关系带来不稳定的扰动。这些优化做完之后整个图像处理链路在TC264上可以跑在每帧5毫秒以内给后续的控制算法留出了充足的裕量。5. 常见问题与排查技巧实录5.1 八邻域很吃预处理这些坑我先替你踩了我整理了一张八邻域调试排查速查表你在现场改代码的时候可以直接对照问题现象可能原因排查方法解决方案边界跟踪死循环程序卡死搜索方向优先级设置不当边界点反复回头上位机观察边界点是否反复在相邻点之间横跳在track_edge里加最大步数限制利用上一节点方向动态确定搜索起始方向边界一到弯道就丢失弯道处二值化边界断裂或者曲率过大单步搜索跟不上在弯道图像上打印搜索路径加大搜索邻域范围到16邻域或对断裂处做缺口桥接处理边界线在直道上抖动严重二值化阈值固定光照变化导致边界位置跳动观察同一位置不同帧的二值化结果改用大津法动态阈值并对二值图做开运算一侧边界完全没提取出来ROI底部没有找到种子点或种子点被噪点带偏检查底部行的播种输出打印种子点坐标底部播种前先做一行连续性校验只有连续N个黑色像素才认为是赛道边界点“穿”进白色背景邻域搜索时没有限制搜索距离跑到噪点上去了打印每一步搜索的点序列限制搜索步长超过预设距离直接终止元素识别时左右边界串了十字或环岛处出现了多个边界候选跟踪没有锁住原边界查看十字处边界点的x坐标是否有跳变在track_edge中限制左右边界搜索范围禁止交叉越过中线5.2 排查实例边界“串线”的完整定位过程这里分享一个我印象特别深的案例。有一阵子我的车每次进环岛就疯狂摆头输出中线剧烈抖动。我先怀疑是PID参数问题调了一下午没效果最后把图像回传打开一看发现进环岛前右侧边界点突然跳到了赛道中间的斑马线上——八邻域把右边界跟丢了跟到了环岛中心图标的外沿上。根因是我在右边界跟踪时搜索方向没有限制在“右侧一定范围内”而环岛中心区域恰好有大片黑色像素把跟踪点“勾引”过去了。解决办法是在track_edge里加一个判断如果下一跳的x坐标已经越过图像中线强制放弃这个候选点。等于给左右边界各划了“势力范围”不允许越界。这个限制加上之后环岛处边界就稳定了。这个案例说明一个道理八邻域不是拿到就能用的算法它得和你的赛道场景、图像特征结合起来改。遇到问题不要靠猜把图像可视化一步一步追踪边界点很快就能定位。5.3 从八邻域到完整元素识别的过渡最后聊一下八邻域做完之后怎么往元素识别上走。当你拥有稳定的左右边界数组之后元素识别就不是从零开始了而是变成了对边界数组做特征分析十字路口左右边界在某一区域同时消失再恢复且消失区域的宽度明显大于正常赛道宽度。环岛一侧边界连续多行与外圈边界重合另一侧边界出现内圈边界点。断路某一侧边界连续丢失多行随后在更高处恢复。坡道边界在图像中的相对位置整体下移且赛道宽度基本不变。这些都是我在实践中验证过的判断思路。八邻域输出的有序边界点让这些特征变得很容易统计和判断这也是为什么很多人说八邻域是智能车图像算法中最值得吃透的基础算法。它可能不是最快的可能不是最炫的但它是你在复杂赛道上保持稳定的一根定海神针。我自己用八邻域调了整整两届车从最初只能跑简单直道到后来能稳定处理十字、环岛、断路回头看最值钱的经验就三条种子点要稳方向优先级要严谨退出条件要兜底。把这三条逻辑理清楚八邻域这个底座就算打牢了后面加什么花样都好办。如果你正在被边界提取折腾得焦头烂额不妨回到这个原点把每一帧图像的边界点一个一个打出来看很快就能找到问题所在。