1. 八邻域算法在智能车图像处理里的真实定位
1.1 为什么处理完图像还要做“八邻域”
很多刚开始玩智能车摄像头的同学,第一次接触“八邻域”这个词,是在看开源代码或者往届学长留下来的工程时。注释里写着“八邻域搜线”“八邻域补线”,但翻遍整个工程也没找到哪个函数名叫eight_neighbor。这很正常,因为八邻域在智能车竞赛里不是一个独立算法,而是一类“基于周围像素关系做判断”的方法的统称。
我习惯把它拆成三句话来理解:
- 对图像中某个像素点,它的上、下、左、右、左上、右上、左下、右下这8个方向,就是这个点的八邻域。
- 通过判断这8个点是否满足某种条件(比如是否为白色、是否为黑色),可以对当前点做分类:它是边界点、孤立点、内部点,还是骨架点。
- 在智能车场景里,八邻域最常见的两个用途,一是沿着已经找到的边界继续往远处“追线”,二是对断裂的边界做修补和判断。
说白了,前面做二值化、大津法、固定阈值,是把灰度图变成黑白图;而八邻域是在黑白图的基础上做“关系推断”。没有这一步,你只能拿到一堆散的点,有了这一步,你才能拿到底线、边线、中线这些真正能给转向环用的连续曲线。
1.2 先搞清楚方向编号,后面所有代码都好写
八邻域的8个方向,标准写法是:
// 8邻域方向偏移 // dx, dy 分别代表列方向和行方向的偏移 // x是列方向(对应摄像头图像的横轴),y是行方向(对应纵轴,y越大越靠图像底部) const int dx[8] = {1, 1, 0, -1, -1, -1, 0, 1}; const int dy[8] = {0, 1, 1, 1, 0, -1, -1, -1};方向编号从0到7,从右边开始逆时针转一圈:
- 0:右
- 1:右下
- 2:下
- 3:左下
- 4:左
- 5:左上
- 6:上
- 7:右上
注意:这里“上”“下”指的是图像坐标里的行号变化。摄像头图像一般行号0在顶部,行号最大在底部,所以
dy为正值是往下走。这个坐标约定不统一的话,代码很容易越界,务必在工程里固定下来。
这套方向偏移表是八邻域所有衍生算法的地基。边界追踪、区域生长、断路修补,本质都是在查这张表。后面给的所有代码,我都会用这套偏移。
1.3 八邻域搜索和“固定窗口扫描”有什么本质区别
在讲代码之前,有必要把概念先掰扯清楚,否则你会在调车时被“怎么这里没搜到边”这种问题折磨。
常见的搜线方式是“由下往上逐行固定范围扫描”,比如:从第120行开始,每一行在上一行边界点左右各20个像素的范围内找黑白跳变点。这种方法简单、速度快,但有一个硬伤:弯道越急,上一行边界在下一行可能已经偏出这个范围了,一旦某一行没找到边界,后面所有行都跟着错位,严重的会整片丢线。
八邻域边界追踪的思路不一样。它不限制“每一行必须从某个范围里找”,而是把已经找到的边界点当成一个起点,沿着边界本身的走向一步一步往前走。每一步只判断当前点周围的8个邻域,找出下一个边界点,更新当前位置,再继续往前走。
打个比方,固定窗口扫描像是一个人在一条固定宽度的走廊里找墙;八邻域追踪像是你把手贴在墙上,闭着眼睛沿着墙一直走,墙往哪拐你就往哪拐。后者天然能应对各种曲率的弯道,甚至在边界断裂一小段时,还能利用邻域信息“摸着墙根”继续走。
所以,智能车图像处理里,八邻域的核心价值是“连续性”和“拓扑关系”——它不仅能告诉你哪里有边界,还能告诉你边界是怎么连通的。这对后续的赛道类型判断、十字判定、断路补线都至关重要。
2. 代码落地:用八邻域做边界提取与断线修补
2.1 数据结构:先明确你手里的是什么图
智能车摄像头组常用的二值化结果,是下面这种二维数组:
#define IMG_ROWS 120 // 图像行数 #define IMG_COLS 188 // 图像列数 // 黑白图,白的是赛道,黑的是背景(这里按常见开源约定) // 看你们组的赛道颜色,如果是深色赛道配浅色背景就反过来 uint8_t binary_image[IMG_ROWS][IMG_COLS];我习惯把白点记为1,黑点记为0。边界点定义为:当前点是白色,且八邻域里至少有一个点是黑色。这个定义在智能车赛道上非常直观——边界就是赛道和背景的交界处。
数组越界是新手最常见的崩溃原因。摄像头图像边缘的行和列,在做八邻域访问时天然缺邻居。常见做法是给图像四周各留一圈0填充,或者在做邻域访问时先判断坐标范围。我建议在工程里统一封装一个宏:
#define PIXEL_IN_BOUNDS(x, y) ((x) >= 0 && (x) < IMG_COLS && (y) >= 0 && (y) < IMG_ROWS) #define IS_WHITE(x, y) (PIXEL_IN_BOUNDS(x, y) && binary_image[y][x] == 1)先判边界再取值,写起来麻烦一点,但能省掉大量排查越界的时间。
2.2 核心函数:八邻域边界追踪的完整实现
下面这段代码是八邻域边界追踪的核心逻辑,作用是:给定一个起点(图像底部的边界点),沿着边界一路向上追踪,把每一行的边界坐标记录下来。
#include <stdint.h> #include "image.h" // 你自己的图像相关头文件 #define MAX_EDGE_POINTS 150 // 最多追踪150个点,防止死循环 const int dx[8] = {1, 1, 0, -1, -1, -1, 0, 1}; const int dy[8] = {0, 1, 1, 1, 0, -1, -1, -1}; // 查找当前点附近的下一个边界点 // cur_x, cur_y: 当前坐标 // last_dir: 上一个边界点到当前点的方向,用于限制搜索范围 // dir_out: 输出找到的方向 // 返回值:1表示找到,0表示没找到 uint8_t find_next_boundary(int cur_x, int cur_y, int last_dir, int8_t *dir_out) { int start_dir; int i, idx, nx, ny; // 从last_dir的逆时针方向开始搜索,避免回头 // 常用技巧:从 (last_dir + 5) % 8 开始,连续搜索8个方向 // 这样可以保持边界连续性,不容易跳到对面去 start_dir = (last_dir + 5) % 8; for (i = 0; i < 8; i++) { idx = (start_dir + i) % 8; nx = cur_x + dx[idx]; ny = cur_y + dy[idx]; // 目标点是白色,且它旁边有黑色点,那它就是边界点 if (IS_WHITE(nx, ny) && has_black_neighbor(nx, ny)) { *dir_out = idx; return 1; } } return 0; } // 检查某个白点周围是否有黑点 uint8_t has_black_neighbor(int x, int y) { int i, nx, ny; for (i = 0; i < 8; i++) { nx = x + dx[i]; ny = y + dy[i]; if (PIXEL_IN_BOUNDS(nx, ny) && binary_image[ny][nx] == 0) { return 1; } } return 0; } // 从底部向上追踪一条完整边界 // start_x 是底部起始列坐标,假设在图像最底行是有效边界点 // edge_x[] 和 edge_y[] 是输出数组,返回追踪到的点数 int trace_boundary(int start_x, int start_y, int *edge_x, int *edge_y) { int cur_x, cur_y; int last_dir; // 上一个点到当前点的方向 int8_t dir_out; int point_count = 0; cur_x = start_x; cur_y = start_y; last_dir = 6; // 初始默认方向为“上”,也就是从底部往上走 while (point_count < MAX_EDGE_POINTS) { // 记录当前点 edge_x[point_count] = cur_x; edge_y[point_count] = cur_y; point_count++; // 如果已经到了图像顶部附近,停止 if (cur_y <= 1) { break; } // 找下一个边界点 if (!find_next_boundary(cur_x, cur_y, last_dir, &dir_out)) { // 找不到,说明边界断了或者丢了 break; } // 更新位置 last_dir = dir_out; cur_x += dx[dir_out]; cur_y += dy[dir_out]; // 防死循环:如果回到起点附近,说明出现了小闭环 if (cur_x == start_x && cur_y == start_y) { break; } } return point_count; }几点说明:
last_dir是核心状态量。它的作用是让搜索只朝“边界前进的方向”找,而不是每次从0到7全部扫一遍。从(last_dir + 5) % 8开始搜,是因为8个方向里,与来向相反的方向是(last_dir + 4) % 8,往旁边偏一格再开始找才不会漏掉连续边界。has_black_neighbor这层判断,保证我们追踪的是“边界”而不是“区域内部的白点”。如果去掉这个判断,函数很容易钻进赛道内部的白色区域里,沿着一条完全没意义的路跑。实际工程里,
MAX_EDGE_POINTS要根据你的图像行数设置。图像一共120行,理论最多也就120个边界点,设150是留余量,但如果你做的是“同一条边界往回绕”的复杂追踪(比如环岛补线),上限需要单独评估。
2.3 把八邻域和前面的搜线函数串起来
追踪函数不会自己找到底部起点。实际使用中,第一步仍然需要用常规的逐行扫描方法,在图像最底部几行找到一个可靠的边界点,然后才交给八邻域追踪。
我常用的组合方式是:
// 先用固定范围扫描找底部边界 int bottom_edge_x = find_bottom_edge(); // 自己实现:在图像底部3到5行内找边界 if (bottom_edge_x < 0) { // 底部都没找到边界,说明车已经冲出赛道或者图像异常 // 此时进入丢线处理逻辑 handle_lost_edge(); } else { // 找到起点后,用八邻域追踪整条边界 int cnt = trace_boundary(bottom_edge_x, IMG_ROWS - 1, left_edge_x, left_edge_y); // 同理追踪右边线 }这里有个经验之谈:不要只跑一次八邻域追踪就完事。赛道的十字、环岛、断路这些元素,会把边界切成好几段。更稳的做法是,在整幅图像上做“多次启动”的追踪——从左到右扫描,遇到一个未被访问过的起点,就启动一次八邻域追踪,追踪完给它打上“已访问”标记,再继续扫描。这样你能拿到赛道里所有连通的边界段,而不是只拿一条。
static uint8_t visited[IMG_ROWS][IMG_COLS]; // 完整逻辑:遍历图像中的白点,从未访问过的边界点启动追踪 void extract_all_boundaries(void) { int x, y, cnt; int edge_x[MAX_EDGE_POINTS], edge_y[MAX_EDGE_POINTS]; memset(visited, 0, sizeof(visited)); for (y = IMG_ROWS - 1; y >= 0; y--) { for (x = 0; x < IMG_COLS; x++) { if (binary_image[y][x] == 1 && !visited[y][x] && has_black_neighbor(x, y)) { cnt = trace_boundary_with_visited(x, y, edge_x, edge_y, visited); // 对这条边界段做后续处理... } } } }用这种方式,你拿到的其实是赛道的“边界拓扑结构”:有几条边界段、每段从哪到哪、彼此之间是否靠近。这些信息是判断十字、环岛的关键素材。
2.4 在TC264这类单片机上跑,性能怎么控制
智能车竞赛常用的TC264、TC377、以及部分用H743/RT1170的队伍,性能差距不小,但八邻域追踪本身就是一种很省时间的算法,因为它的计算量是 O(边界长度),而不是 O(行数 x 列数)。
不过有几个细节会影响实际帧率:
PIXEL_IN_BOUNDS宏里的&&是短路判断,这很关键。IS_WHITE(x, y)只有在坐标合法时才会访问数组,否则直接返回0。少了这层保护,越界访问大概率会读到内存里的随机值,边界直接变花。方向表的8次循环体里,不推荐做浮点运算或者复杂函数调用。
has_black_neighbor本身就是8次邻域访问,等于每找一个点最多要做8x8=64次数组访问,这个开销在单片机上已经很可观了。如果你发现八邻域追踪一帧要跑2到3毫秒,先检查是不是在循环体里加了多余的计算,比如求距离、算角度、或者调了sqrt。实际工程优化技巧:可以把二值化图像做成“压缩位图”,一个字节存8个像素,八邻域判断用位运算来做。速度能提升一大截,但代码可读性会下降,我一般只在比赛前冲刺提速时才做这层优化。新手建议先用数组版本跑通逻辑。
3. 八邻域的进阶应用:十字、断路、环岛的修补与判断
3.1 十字路口:利用八邻域补线,避免车辆乱拐
十字是摄像头组最容易翻车的地方之一,核心难点在于:车在十字里看到的图像,四条边界线会突然“断开”,如果不处理,中线计算会直接炸掉。
八邻域在十字处理里的经典用法,是做“区域连通性判断”:
// 判断以(start_x, start_y)为起点,能否在限制步数内到达(target_x, target_y) // 这本质上就是用八邻域做的BFS/DFS连通性检测 uint8_t is_connected(int start_x, int start_y, int target_x, int target_y, int max_steps) { int i, nx, ny; int queue_x[MAX_EDGE_POINTS], queue_y[MAX_EDGE_POINTS]; int head = 0, tail = 0; int steps = 0; // 简单BFS,借助visited数组防止回头 queue_x[tail] = start_x; queue_y[tail] = start_y; tail++; while (head < tail && steps < max_steps) { int cx = queue_x[head]; int cy = queue_y[head]; head++; steps++; if (cx == target_x && cy == target_y) { return 1; } for (i = 0; i < 8; i++) { nx = cx + dx[i]; ny = cy + dy[i]; if (IS_WHITE(nx, ny) && !visited[ny][nx]) { visited[ny][nx] = 1; queue_x[tail] = nx; queue_y[tail] = ny; tail++; if (tail >= MAX_EDGE_POINTS) { return 0; } } } } return 0; }实际处理十字时,我会先通过左右边界的中线找到十字的“中心区域”,然后在中心区域上下左右各选几个点,用is_connected判断左右赛道是否通过十字中心连通。如果连通,说明当前确实是十字,这时就可以在图像里人为补一条横线连接左右边界,让中线的计算结果平滑。
注意:十字的判断一定要结合“突变的形状”和“连通性”两个条件。只靠边界突变的形状,容易把斜入十字、断路误判成十字;只靠连通性,又容易在普通弯道处误触发。两个条件都满足再补线才稳。
3.2 断路(斑马线/虚线):用八邻域修补断点
断路是近年很多赛区新增的赛道元素,图像上表现为赛道中间有一小段白色变为黑色,打断左右边界的连续性。
八邻域处理断路的做法,是在边界追踪时发现“方向突变”或“追踪中断”后,以断点为中心,在上下左右一定范围内搜索白色区域。如果断口上下的白色区域能被识别为“同一条赛道”,就通过邻域搜索把两个断点连上。
具体来说:
- 追踪左边线时,在第80行突然找不到边界点(
find_next_boundary返回0)。 - 记录断点位置
(断点x, 断点y)。 - 在断点附近的上下若干行、左右若干列范围内,搜索白色区域。
- 如果找到白色区域,且这个白色区域能通过八邻域连通到另一侧断掉的边界,就认为这是断路,直接从断点把边界线插值到白色区域边缘。
这里最容易踩的坑是阈值范围。断路开口距离如果太大,直接补线容易把旁边的对手赛道或者背景补进来。我的经验是:断路补线的搜索范围,纵向不超过图像总行数的三分之一,横向不超过二分之一赛道宽度,宁可少补一些,也不要误补。
3.3 环岛检测:八邻域帮你找到“洞口”和“圆环”
环岛的图像特征比十字更复杂,因为有圆环、有斜入弯道,还经常出现边界分叉(左边线走一段会分成两条:一条是环岛的圆环边,一条是外侧赛道边)。
八邻域在这种分叉场景下的优势很明显:当边界追踪遇到一个白点,它周围有两个相邻的白点都属于边界点时,就出现了“岔路”。你可以利用这个信息,把两条分支都追踪出来,然后根据每条分支的长度和走向判断哪条是环岛边,哪条是赛道边。
// 追踪分叉边界的关键:在find_next_boundary里允许返回多个候选 // 通常实现是,在last_dir附近找到所有满足边界条件的点,逐个尝试 // 优先选择“与原方向夹角最小”的点,夹角大的作为备用分支 // 在环岛入口处,较陡的备用分支往往就是环岛圆环的边这套逻辑不需要额外的硬件,完全靠八邻域的拓扑搜索就能做到。调车时我会在图像上叠加显示所有追踪出来的边界段,用不同颜色区分主边界和分支边界,肉眼观察哪个分支在入口处是持续向内弯的,那就是环岛的圆环边。
3.4 用八邻域做简单的形态学腐蚀/膨胀
除了追踪和连通性判断,八邻域还可以直接实现图像形态学操作,在日常调试里非常有用。
- 膨胀:如果当前点八邻域内有白点,则当前点置为白。
- 腐蚀:当前点和八邻域全为白点,当前点才保持为白。
void dilate_image(void) { int x, y, i, nx, ny; uint8_t temp[IMG_ROWS][IMG_COLS]; memcpy(temp, binary_image, sizeof(binary_image)); for (y = 1; y < IMG_ROWS - 1; y++) { for (x = 1; x < IMG_COLS - 1; x++) { if (temp[y][x] == 0) { for (i = 0; i < 8; i++) { nx = x + dx[i]; ny = y + dy[i]; if (temp[ny][nx] == 1) { binary_image[y][x] = 1; break; } } } } } }这个功能用在处理图像噪点上挺好用的。摄像头在某些光照条件下,二值化后会出现很多零星白点或黑点,这些噪点会干扰八邻域追踪的方向判断。在跑追踪之前先做一次“去孤立点”的操作,效果立竿见影:检查每个白点,如果它周围8个邻域全是黑的,直接把它变成黑点。
去孤立点和腐蚀还不一样:腐蚀会整体缩小白色区域,去孤立点只处理完全孤立的单点,不会影响正常赛道边界。这个操作在比赛前整理图像时非常值得加。
4. 常见问题与排查技巧实录
4.1 边界追踪进入死循环,出炉一发不可收拾
这是我见过最多的bug,表现形式是:串口打印边界点,发现点数没完没了,或者车辆发疯一样朝一个方向猛打。
原因通常是两种:
last_dir更新错误,导致搜索方向反复横跳。比如,相邻两个点之间方向应该是连续的,如果你每次都从方向0开始全扫,且不加任何规则限制,追踪到的“边界”很可能沿着赛道内部的噪点来回绕圈。visited标记没有打上,导致已经走过的点被再次访问。
排查方法:在追踪循环里打印(cur_x, cur_y, last_dir, dir_out),一帧图像的数据量也不大,用上位机把轨迹画出来,一两分钟就能看出来是哪一步跳回去的。
4.2 凹口、斜入十字时的误判
八邻域边界追踪对凹形边界特别敏感。比如赛道边缘有个缺口,追踪到缺口时会沿着缺口的内侧拐进去,把边界拉出一个很大的凹陷,进而导致中线偏移。
处理办法是在边界点的判定上加点约束:一个白点即使有黑邻居,如果它的“上方”和“斜上方”中至少有一个也是白点,才认为它是正常边界点。这样可以让追踪更倾向于保持“向上”的主方向,减少凹口干扰。这个方法对斜入十字尤其有效。
4.3 数组越界导致画面花掉
代码里用了IS_WHITE(x, y)但宏定义里没有加边界判断,或者加了判断但是||写成了&&,都会导致越界访问。
之前我带过的一个队伍,图像追踪到第119行时,cur_y + dy[3]会变成120,数组下标直接越界。运气好的时候读到的是上一个变量的值,运气不好的时候直接把MCU跑死。这个问题用硬fault断言或者MPU(内存保护单元)能查出来,但对新手来说最直接的办法,就是把所有数组访问都过一遍PIXEL_IN_BOUNDS。
我自己调试时习惯开一个“安全模式”,在IS_WHITE宏里一旦发现越界就置一个全局错误标志,同时把当前的(x, y)存下来。这样跟踪现场问题比纯肉眼盯屏幕快得多。
4.4 追踪方向在弯道里“回流”
入弯时,边界追踪的方向不再“一直向上”,而会变成“向上再向左/右”。如果你的last_dir初始值固定为6(上),在一些急弯入口,边界会突然横向走几个点,然后继续向上。如果方向搜索限制得太死,可能直接从边界上掉下来,追进赛道内部。
我的做法是:追踪开始前,先在底部几行用普通扫描法把边界的“初始走向”测出来,把last_dir初始化为这个走向对应的方向。这样进入弯道时,方向搜索范围天然覆盖弯道走向,不容易脱离边界。
5. 参数调节与调车经验总结
5.1 影响八邻域效果的三个关键参数
| 参数 | 推荐范围 | 影响 |
|---|---|---|
| MAX_EDGE_POINTS | 图像行数的1.2到1.5倍 | 太小会截断长边界,太大会在异常时浪费处理时间 |
| 方向搜索起始偏移量 | (last_dir + 5) % 8到(last_dir + 6) % 8 | 决定追踪对急弯的敏感程度 |
| 边界点判定时需要的黑邻居数 | 1到2 | 1容易受噪点干扰,2对边界连续性要求更高,但会漏掉部分斜边界 |
这三个参数没有固定值,要配合你的赛道材质、摄像头高度、镜头畸变一起调。我的建议是先把MAX_EDGE_POINTS设大,调通逻辑后再逐步收紧。
5.2 一个实用的调试顺序
如果你想在自己的车上试通这套算法,按这个顺序来:
- 先不管八邻域,纯用固定范围扫描把左右边线提取出来,确保二值化图像没问题。
- 在固定范围扫描提取到底部起点的基础上,接上
trace_boundary,打印追踪出的边界坐标,和固定扫描的结果做对比,重点看弯道处。 - 加上
has_black_neighbor的边界判定,观察追踪是否还会跑进赛道内部。 - 加入
visited标记和多次启动逻辑,处理整幅图像的所有边界段。 - 最后再加十字、断路、环岛等特定场景的连通性判断。
每步都验证通过再走下一步,能省下大量联调时“不知道到底是哪一层出了问题”的烦恼。
5.3 我用八邻域这么久,最想提醒你的一件事
八邻域不是万能的补线魔法。它的本质是“利用局部信息做连续性推断”,所以当出现大面积丢线、曝光过度、或者镜头被水滴糊住时,邻域里全是错误信息,算法再强也白搭。
所以,真正决定算法上限的,永远是你前面那步二值化做得好不好。光照不均匀的赛道,先把二值化阈值处理好,比花一晚上调八邻域参数更有效。我见过不少队伍,八邻域追踪写得很漂亮,结果赛前测试时发现远处边界根本搜不到,最后发现是二值化阈值在逆光路段整体偏了,搜线代码再怎么优化也救不回来。
入门阶段,八邻域更像是一个帮你“看到赛道结构”的工具。先把边界追踪、连通性判断玩明白,再去碰补线逻辑,你会发现自己对赛道的理解会比同龄人清楚得多。