资讯动态

智能车图像处理:八邻域边界提取原理与实现

发布时间:2026/9/30 6:32:25 来源:尧图企业网站定制
最近总有人问我智能车图像处理做到第六步——边界提取到底是继续用逐行扫描还是老老实实切到八邻域。我当年准备全国大学生智能车竞赛的时候也在这个岔路口纠结过。八邻域听起来像个数学概念但在赛道边界跟踪、中线提取、元素识别这些环节里它几乎是绕不开的底座。这篇就把八邻域掰开揉碎讲清楚重点解决三个问题它为什么比逐行扫描稳、代码怎么落地、实际调车时会踩哪些坑。如果你正在写摄像头组的图像处理代码或者二值化已经跑通了、正准备提取赛道边界这篇应该能帮你省掉不少弯路也欢迎带着问题继续往下看。1. 先搞清楚八邻域在智能车图像处理里到底干什么1.1 二值化之后赛道图像变成了什么智能车摄像头采集回来的原图是灰度图车跑起来以后每一帧都要先做预处理。以最常见的情况来说赛道是深色背景、两边是白色引导线灰度图上白色线的数值明显偏高。对大津法或固定阈值二值化之后白线变成255深色赛道和赛道外区域变成0。于是整幅图像就成了一块只有黑白像素的网格后处理的所有算法都在这张网格上做文章。先把坐标系统一说清楚。很多初学者在代码里把行列搞反后面全乱。一般我们用x表示列方向向右增大y表示行方向向下增大。图像的row0是视野最远处rowIMG_H-1是靠近车头的最底行摄像头俯视下去底行的赛道信息最可信。后面所有边界提取都是基于这个坐标系来讲的。1.2 全局逐行扫描太脆四个场景直接暴露问题很多人一开始写边界提取脑中的第一反应就是逐行扫描每一行从左扫到右碰到的第一个白点就是左边界从右扫到左碰到的第一个白点就是右边界。这个写法很简单但实际跑起来会有一堆问题。第一个问题是弯道相切丢线。弯道比较大的时候白色边界线在图像里是倾斜的某一行恰好与边界线“擦着边”过去这一行可能白点很少甚至没有全局扫描就会漏掉边界。丢线一多边界看起来就是一截一截的锯齿控制程序拿到的偏差自然也是跳变的。第二个问题是噪声干扰。赛道反光、光线波动、现场灯牌闪烁都会在二值图里留下孤立白点。全局扫描没有连续性概念这些噪点一旦被当成边界那一行就会突然跳出去很远整个边界的形状就坏了。第三个问题是计算资源浪费。188乘120这么小的图逐行全扫一遍确实只有两万多次判断在单片机上看不太出成本。可智能车主控上同时要跑控制环、编码器读取、无线调试、图像采集时间预算相当紧。每帧图像处理多花1毫秒控制频率就可能掉一截。第四个问题是缺连续性信息。逐行扫描得到的是互不相关的点它不知道上一行边界在哪、下一行边界会往哪走。边界是连续的这一先验信息完全没有被用到。而八邻域搜索最大的价值就是把这个先验用起来了边界的下一跳大概率就在当前边界点的周围8个像素里。2. 打地基八邻域搜索规则与方向编码2.1 方向编码表与坐标系约定八邻域的定义很简单以某个像素为中心它周围的8个相邻像素合在一起就构成了这个像素的八邻域。这8个位置分别是左上、上、右上、左、右、左下、下、右下。处理时通常给每个方向编一个码方便在代码里通过数组索引快速查询。我习惯按顺时针给方向编码在智能车图像坐标系下对应的偏移如下方向码方向名dxdy0右101右上1-12上0-13左上-1-14左-105左下-116下017右下11这个方向表是整个八邻域系列的基础。不管后面写完整边界跟踪还是写简化的邻域窗口搜索方向码都用得特别频繁。新手最容易踩的坑就是把dy的正负搞反本来想往上搜结果跑到下面去了。建议在一开始就把方向表打印出来用画点工具在图像上验证一遍。2.2 为什么搜索顺序比方向本身更重要真正写边界跟踪的时候光知道8个邻点在哪还不够搜索的顺序才决定边界会不会跟丢。想象一下你蒙着眼睛在迷宫里沿着墙走你的手摸着墙如果每一步都从正前方开始试探遇到墙再向右转一点继续试探最终能一直贴着墙走下去。如果无规律地乱试可能走两步就离墙越来越远。在代码里find_next函数会从某个起始方向开始按顺序遍历8个方向找到第一个白点就作为下一个边界点。起始方向通常由上一次成功搜索的方向决定而不是每次固定从0开始。这样做的好处有两个一是尽可能沿着边界的走势前进不会突然掉头二是让边界保持在追踪方向的同一侧避免从一条边界线跳到另一条边界线。起始方向的偏移量可以调。比如上次方向是last_dir下一次从(last_dir 8 - 2) % 8开始搜索就相当于优先看“上方偏右”的位置适合从下往上追左边界。不同图像极性、不同赛道布局这个偏移量可能要反过来。我写代码的时候会把这个偏移量定义成一个宏调车时通过上位机实时改不用每改一次都重新烧录。3. 从理论到代码八邻域边界跟踪完整实现3.1 起始点选取从最近一行入手完整八邻域跟踪的第一步是先找到一个可靠的起始边界点。智能车场景下我一般从图像最底部一行开始找。这一行离车最近透视变形最小边界线看得最清楚即使光线波动底部的投影面积也大不容易完全丢线。找法很简单从左往右扫遇到第一个白点就是左边界起始点从右往左扫遇到第一个白点就是右边界起始点。如果最底一行恰好因为反光完全找不到白点就往上找下一行直到找到为止。你要是做的是完整轮廓提取比如从整幅图里抠出一个赛道元素的轮廓那通常会在全图范围内用遍历方式找第一个白点那种情况扫描范围就不局限于底部了。3.2 核心搜索函数实现与逐行解释完整八邻域边界跟踪的核心是一个“找下一个边界点”的函数。基于前面的方向码表这个函数其实很短小#define IMG_H 120 #define IMG_W 188 const int dx[8] {1, 1, 0, -1, -1, -1, 0, 1}; const int dy[8] {0, -1, -1, -1, 0, 1, 1, 1}; unsigned char img[IMG_H][IMG_W]; int in_bounds(int x, int y) { return (x 0 x IMG_W y 0 y IMG_H); } int is_boundary(int x, int y) { return in_bounds(x, y) (img[y][x] 255); } int find_next(int cur_x, int cur_y, int last_dir, int *out_x, int *out_y) { int start_dir (last_dir 8 - 2) % 8; for (int i 0; i 8; i) { int nd (start_dir i) % 8; int nx cur_x dx[nd]; int ny cur_y dy[nd]; if (is_boundary(nx, ny)) { *out_x nx; *out_y ny; return nd; } } return -1; }这段代码做了几件事。第一通过in_bounds做越界检查避免在图像边界访问数组越界。智能车图像处理时不时就会因为越界触发硬件异常有的主控直接卡死这个问题一定要在函数入口防住。第二is_boundary判断这个点是不是白点也就是边界候选点。第三从start_dir开始按顺序遍历8个方向只要找到白点就更新坐标并返回新方向。有了这个函数完整跟踪流程就非常简单了从起始点出发反复调用find_next把当前点往前移直到回到起点、走到图像边界、或者搜索步数超限。主循环代码大概长这样int start_x 50, start_y IMG_H - 1; int cur_x start_x, cur_y start_y; int last_dir 0; int step 0; int max_step 500; while (step max_step) { int nx, ny; int nd find_next(cur_x, cur_y, last_dir, nx, ny); if (nd 0) { break; } cur_x nx; cur_y ny; last_dir nd; step; if (cur_x start_x cur_y start_y) { break; } }这里设置max_step是个好习惯。如果图像里存在孤岛噪点或二值化出的白区形状怪异跟踪可能永远回不到起点。设一个上限超了就终止避免死循环把主控卡死。放在车上调试时这种保护能节省大量踩坑时间。3.3 实际跑车更常用的邻域窗口搜索法上面这种完整八邻域闭环跟踪适合做轮廓提取、识别环岛形状这类任务。但如果是每帧实时跑边界提取它的步数偏多而且一旦中途某个点跟丢整个轮廓就断了恢复起来也比较麻烦。于是在智能车实际代码里大家更常把八邻域思想“降维”一下用邻域窗口搜索来做左右边界提取。邻域窗口搜索的核心逻辑是赛道边界在相邻行之间不会突变。假设上一行左边界在last_left这个列位置那么当前行的左边界大概率不会跑出last_left - P到last_left P这个范围。我们只需要在这个局部窗口里搜索白点而不是整行从头扫到尾。这个P就是搜索半径本质上是把八邻域的“8个像素范围”扩展成了一个2P加1的窗口。我最早跑车时用的代码是这样#define SEARCH_RANGE 6 int left_boundary[IMG_H]; int right_boundary[IMG_H]; void extract_boundaries(unsigned char bimg[][IMG_W], int left[], int right[]) { int last_left -1, last_right -1; for (int row IMG_H - 1; row 0; row--) { if (last_left 0) { for (int col 0; col IMG_W; col) { if (bimg[row][col] 255) { last_left col; break; } } } else { int s last_left - SEARCH_RANGE; int e last_left SEARCH_RANGE; if (s 0) s 0; if (e IMG_W) e IMG_W - 1; last_left -1; for (int col s; col e; col) { if (bimg[row][col] 255) { last_left col; break; } } } left[row] last_left; if (last_right 0) { for (int col IMG_W - 1; col 0; col--) { if (bimg[row][col] 255) { last_right col; break; } } } else { int s last_right - SEARCH_RANGE; int e last_right SEARCH_RANGE; if (s 0) s 0; if (e IMG_W) e IMG_W - 1; last_right -1; for (int col e; col s; col--) { if (bimg[row][col] 255) { last_right col; break; } } } right[row] last_right; } }这段代码把八邻域的连续性思想落到了逐行边界提取上比完整八邻域跟踪实时性好很多也更容易做丢线恢复。当某一行在窗口里找不到白点last_left就会被置成-1下一行再进来时检测到参考点失效就会自动切回整行全局扫描去重新建立边界。这个状态切换很重要后面调试问题那一节我还要重点讲。3.4 从边界到中线偏差计算与控制量边界提出来不是终点最终目的是给控制模块一个偏差。常规做法是拿每一行的左右边界取中点再用这个中点跟图像中线做差int ctrl_error 0; int n 0; for (int row IMG_H - 1; row IMG_H - 5; row--) { if (left[row] 0 right[row] 0) { ctrl_error (left[row] right[row]) / 2 - IMG_W / 2; n; } } if (n) ctrl_error / n;这里不是用全图中线求平均而是只取最底部的几行。原因很简单靠近车头的图像透视误差小赛道实际偏差更真实而图像顶部的赛道距离车太远投影压缩严重边界位置反而不可靠。把远处误差加进来只会让转向抖动更厉害。如果某一行只有单边边界比如右边丢了常见做法是用标准赛道宽度来估算另一边。先统计最近几行边界宽度求个中值作为赛道宽度估计然后用已知的一边加减宽度得到另一边。这种单边补线逻辑不复杂但注意异常时别乱补补错了比丢线更可怕。4. 调试实录八邻域最常踩的四个坑4.1 丢线了怎么办从局部恢复到全局重启在真正跑赛道之前我一度以为只要参数调好边界就不会丢。后来发现太天真了。逆光、过曝、高光反射随便一个情况都能让二值图里的一段白线变成黑线。八邻域窗口搜索在这种场景下的表现是上一行还在col100这一行在col94到106范围内找不到白点于是丢线。丢线一多边界数组里就出现大段-1。控制程序拿到这种数据轻则抖动重则整个车冲出赛道。我的处理思路是分级恢复。第一级丢线数量少比如只有1到3行直接用上下行边界点做线性插值补齐不让控制量出现断崖。第二级丢线超过3行但不超过10行说明窗口搜索已经失效不再沿用上一行参考而是从当前行重新全局扫描左右边界以新的边界点为基准继续向上跟踪。第三级如果一段边界连续丢线超过10行那大概率是遇到特殊元素或者图像出了问题这时候要交给元素状态机处理而不是让图像算法自己硬猜。每一个级别的切换最好都在上位机上打一个标志点。调试的时候把边界点和标志点叠在二值图上一起看很快就能定位是哪个环节出了问题。4.2 边界被“吸”到另一侧搜索窗口如何设防这个坑很隐蔽也很致命。现象是弯道里或者十字路口附近左边界突然跳到了右边去边界线像是被右边那条白线“吸”过去了一样。原因其实很简单搜索窗口开得太大窗口范围跨过了赛道中线一下子罩住了右侧边界线。窗口太小的确会跟不上弯道但窗口大了不代表一定更好。我的习惯是给边界搜索窗口加两个硬约束。第一左边界点不允许越过图像中线右边界点不允许越过中线。这个约束在规则赛道里非常有效因为赛道宽度再大左右边界也不会同时跑到图像同一侧。第二左右边界在同一行的距离不能小于某个最小值也不能大于某个最大值。如果在搜索结束后发现左右边界间距异常直接判定这一行提取失败按丢线处理。这两个约束本质上是给八邻域搜索加上了“语义先验”让算法不只依赖像素连续性还依赖赛道宽度的一致性。弯道中边界突然跳变的问题靠这两个约束能解决一大半。4.3 十字、环岛、坡道状态机与八邻域的配合常规赛道段八邻域窗口搜索已经足够稳。但到了十字路口、环岛、坡道这些元素边界连续性的假设会被打破不能硬着头皮用同一套参数跑。十字路口最典型的问题是四条白线交叉边界点不再是简单的左右两条而是四个方向都有白区。邻域窗口很容易从一条边界跟到另一条边界去。我一般在进入十字前根据赛道宽度突然变宽、左右边界同时外扩的特征提前切到“十字模式”关闭窗口搜索改成在预设的ROI内做全局扫描并把左右边界间距限制放宽。出了十字再切回来。环岛处理的难点在入口、出口和环内绕行。入口处赛道分叉八邻域容易跟到导流区需要识别特定宽度变化特征后把搜索参考点切换到环岛内侧边界。绕环过程中赛道曲率很大固定搜索半径可能不够需要动态放大窗口。出口处还要防丢线一般是提前判断环岛出口特征恢复普通模式。坡道则是因为透视关系远处赛道在图像顶部越来越窄边界间距快速缩小。如果搜索半径还是用平地那套很容易扫过窄赛道区域跳到对面边界上。坡道内我习惯把搜索半径缩小并且只信任靠近车头的下半幅图像。4.4 反光毛刺预处理兜底不能省有一段时间我的车白天在室外测试老出现边界点上下抖动一排点打出来像心电图。排查了很久才发现不是八邻域搜索的问题而是二值化之后的毛刺。赛道上有水渍、油光或者边界线本身磨损二值图里就多出一簇一簇的孤立白点。八邻域搜索能抗掉部分孤立噪点但一旦噪点正好落在搜索窗口里还是会误判。最直接的办法是在预处理阶段加形态学滤波比如先腐蚀再膨胀的开运算把细小的白点噪点清理掉。注意腐蚀膨胀的核不能太大3乘3就够了太大容易把白线边界本身磨掉反而影响连续性。另外一个很实用的技巧是边界提取完成后对边界数组做一次中值滤波。拿当前行的左边界值、上一行的左边界值、下一行的左边界值取中值替代当前行的值。这个操作只需要少量比较但对消除单点毛刺跳变效果明显。算下来每帧图像多花的时间可以忽略不计但是边界的平滑度完全是两个级别。5. 把八邻域用得再稳一点参数调优与预测搜索5.1 搜索半径怎么调才不僵搜索半径SEARCH_RANGE是八邻域系列里最值得反复调的参数。半径太小弯道跟不上半径太大对面边界和噪点都被收进来误判率上升。我最早用固定半径6直道上问题不大但只要遇到半径较大的回头弯边界就频繁丢线车在弯道里走得歪歪扭扭。后来我改成动态半径根据当前识别到的赛道类型来切换。直道上用4普通弯道用6到8急弯或环岛用10到12。判定弯道急不急可以用最近几行边界点的横向变化量来估计变化量大说明曲率大。这个变化量本身在边界提取时顺手就能算出来不增加额外计算量。动态半径还有一个作用让控制模块提前知道前方是不是急弯。边界点在相邻行横向跳得越厉害说明弯道越急转向PD里的D项可以打得更高。这种信息是逐行全局扫描很难提供的也是八邻域搜索方向信息的附加价值。5.2 用上一行趋势预测搜索中心固定中心是上一行的边界点在直道和缓弯里没问题但到了S弯或连续折线赛道边界走势变化很快以上一行点为搜索中心有点“滞后”。我后来加了一个小改进用最近三行边界点的变化趋势预测当前行边界点的中心位置然后以预测位置为中心开窗口搜索。具体做法是记录上一行边界位置last_left上上行边界位置prev_left预测当前行边界位置pred_left last_left (last_left - prev_left) / 2。然后在pred_left附近搜索而不是last_left附近。这个预测式本质上是在做一阶差分外推代码就两三行但效果非常直观S弯里的边界跟得更紧偶尔还能提前捕捉到边界拐点。当然预测也不是万能的如果赛道里有突然的元素切换预测中心可能偏得离谱。我的兜底方式是如果按预测中心窗口搜索失败立即退回以上一行边界点为窗口中心再搜一次。两次都失败才判定丢线进入恢复流程。这么设计之后我跑各种组合弯道的成功率明显提升而且丢线恢复的触发次数大大减少。5.3 最后再分享一个调试习惯八邻域这类图像算法最怕的就是闷头调参不看图像。我在车上调试时一定会同时开上位机图像显示把二值图、左右边界点、搜索窗口范围、丢线标志全部叠在一张画面上。算法跑得对不对一眼就能看出来。很多时候你以为的“八邻域算法有问题”其实换个角度看完图像数据才发现是二值化阈值该换了。另外每改一次参数不要直接在赛道上全速试。先在低速下把赛道慢跑一遍重点看边界点有没有跳变、丢线恢复有没有触发确认稳定了再往上加码速。图像处理这种事稳是第一位的快是后面慢慢磨出来的。我自己在这个环节吃过不少亏现在养成的习惯就是“先看图像再谈速度”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑