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次邻域访问等于每找一个点最多要做8x864次数组访问这个开销在单片机上已经很可观了。如果你发现八邻域追踪一帧要跑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到21容易受噪点干扰2对边界连续性要求更高但会漏掉部分斜边界这三个参数没有固定值要配合你的赛道材质、摄像头高度、镜头畸变一起调。我的建议是先把MAX_EDGE_POINTS设大调通逻辑后再逐步收紧。5.2 一个实用的调试顺序如果你想在自己的车上试通这套算法按这个顺序来先不管八邻域纯用固定范围扫描把左右边线提取出来确保二值化图像没问题。在固定范围扫描提取到底部起点的基础上接上trace_boundary打印追踪出的边界坐标和固定扫描的结果做对比重点看弯道处。加上has_black_neighbor的边界判定观察追踪是否还会跑进赛道内部。加入visited标记和多次启动逻辑处理整幅图像的所有边界段。最后再加十字、断路、环岛等特定场景的连通性判断。每步都验证通过再走下一步能省下大量联调时“不知道到底是哪一层出了问题”的烦恼。5.3 我用八邻域这么久最想提醒你的一件事八邻域不是万能的补线魔法。它的本质是“利用局部信息做连续性推断”所以当出现大面积丢线、曝光过度、或者镜头被水滴糊住时邻域里全是错误信息算法再强也白搭。所以真正决定算法上限的永远是你前面那步二值化做得好不好。光照不均匀的赛道先把二值化阈值处理好比花一晚上调八邻域参数更有效。我见过不少队伍八邻域追踪写得很漂亮结果赛前测试时发现远处边界根本搜不到最后发现是二值化阈值在逆光路段整体偏了搜线代码再怎么优化也救不回来。入门阶段八邻域更像是一个帮你“看到赛道结构”的工具。先把边界追踪、连通性判断玩明白再去碰补线逻辑你会发现自己对赛道的理解会比同龄人清楚得多。