智能车竞赛做到“走马观碑”这个组别,很多人第一反应是“识别目标板然后绕过去”,但真正跑过赛道才知道,这个项目说简单可以很简单——塞一个灰度传感器、检测到黑块就绕;说难也可以非常难——要在高速入弯前稳定识别、精确判断绕行方向、还要保证绕完之后不丢线、不冲出去。这篇文章我会把我实际调试中积累的经验拆开来讲,包括目标板的图像识别思路、三种绕行策略的优缺点对比、以及可以直接抄走的嵌入式C代码,尽量让正在备赛或者准备入坑的朋友少走几周弯路。
先说清楚这个任务的核心链路。走马观碑组的本质是视觉感知 + 运动决策两件事。感知端要把“前方出现目标板”变成一个可靠的二值信号,决策端要把这个信号变成“绕左还是绕右、打多少角度、什么时候回正”的一组动作。很多队伍挂在这一步,不是因为识别不出来,而是识别率不稳定——十次里有八次能识别,剩下两次失误直接导致车队没成绩。所以这篇文章里我会花比较多篇幅讲如何把识别做到“几乎不出错”,然后再谈绕行策略的取舍。
这个内容适合正在备赛的智能车队伍、对嵌入式视觉入门感兴趣的同学、以及想了解轻量级图像处理在单片机上怎么落地的开发者。我做这个项目用的是某款常见的总钻风摄像头 + 主控单片机方案,没有用树莓派或者K210这类带系统的方案,原因后面会详细说,但可以先给结论——在竞赛场景下,算法越简单、越确定、越不带“神经网络味道”,越容易稳定出成绩。
1. 项目整体设计与思路拆解
1.1 走马观碑组的任务本质
先理解“走马观碑”这个名字的含义。它取自“走马观碑”这个典故,形容快速行进中仍然能看清碑文。放到智能车竞赛里,就是赛车在高速行驶过程中,必须识别赛道边缘或者赛道中出现的特定标识物(也就是目标板),并根据目标板的类型、位置、姿态做出对应的避让动作。
这和普通的“巡线 + 避障”有一个关键差别:避障通常只关心“有没有障碍物”,而走马观碑还要关心“目标板到底是什么状态”。第21届、第22届的规则里,目标板往往带有不同方向的特征,比如十字标记、斜线标记、或者不同颜色的底色。这就意味着你的识别不能只看“面积够不够大”,还要看“形状对不对、特征符不符”。
1.2 方案选型:为什么走纯视觉而不是传感器融合
当时我们队里出现过一次激烈讨论:要不要在车头加一排红外对管或者激光测距,辅助判断目标板与车的相对位置?最后的结论是不加。
原因有三点:
- 目标板在竞赛场地内是平面标识物,不是立体障碍物,红外对管的检测逻辑很难区分“目标板”和“赛道边界波浪纹”。
- 传感器融合需要额外的标定工作,而赛场上的光线条件、地面材质会直接影响红外反射率;视觉方案至少可以通过图像特征来过滤干扰。
- 竞赛规则内的目标板是有标准尺寸和标准颜色的,这在客观上给视觉识别提供了非常强的先验信息——不用去做通用目标检测,只需要做一个“针对特定标识的专用检测器”。
所以最终方案是:单车载摄像头 + 主控单片机跑图像处理 + 编码器辅助速度闭环。我甚至不建议在识别环节加入编码器或者陀螺仪信息,因为目标板识别本质上是图像域的任务,和车跑了多远、转了多少度没有直接关系,加进来反而会让状态判断变复杂。
1.3 核心流程拆解
整个系统从摄像头取帧到舵机打角的链路是这样的:
- 摄像头采集一帧灰度图,分辨率通常取120x160或者188x120,视主控性能而定。
- 对图像做二值化,把目标板的深色边框或特征标记从浅色背景中分离出来。
- 提取连通域(或者叫“斑块”),用面积、长宽比、填充率这些几何特征筛掉噪声。
- 对筛选出的候选区域做特征判定,判断它是不是目标板、目标板的方向状态。
- 计算目标板在图像中的横向位置,映射成“左绕 / 右绕”的决策。
- 进入绕行状态机,控制舵机完成绕行动作,结束后恢复到正常巡线状态。
这套流程的核心难点集中在第3步和第4步,因为摄像头看到的东西永远是“带噪声的”,尤其是光线变化引起的二值化阈值漂移。下面我会详细讲我用的是什么方法。
2. 目标板识别的核心实现
2.1 为什么选择灰度二值化而不是彩色识别
很多新手队伍一开始会纠结“要不要识别颜色”。这里我先泼一盆冷水:竞赛场地的光线条件远比你想象的恶劣。室内赛场顶灯频闪、窗户反光、甚至旁边队伍的车壳反光,都会让彩色图像的颜色分布发生剧烈偏移。你今天标定的红色阈值,明天上午可能就失效了。
所以我最终采用的是灰度图 + 自适应二值化方案。目标板的颜色虽然五花八门,但它的核心特征往往是“与赛道背景有足够的灰度差”。换句话说,不管你用的是蓝底白十字还是白底红十字,只要目标板的边框、内部标记与赛道灰度对比明显,灰度二值化就能把它分离出来。
灰度方案还有一个好处:处理速度快。以总钻风摄像头为例,输出的一帧灰度图在120x160分辨率下只有不到2万个像素点,即使做全图遍历二值化,在单片机上也能轻松跑满30帧/秒以上,这为后面的绕行决策留了充足的算力余量。
2.2 自适应二值化与固定阈值的取舍
我最早用的是固定阈值二值化,比如灰度大于80算白、小于80算黑。实测下来有个很典型的问题:上午十点的阳光照在赛道上,阈值80还勉强能用;下午三点反光严重,赛道白色区域的灰度能飙到200以上,但阴影里的灰度可能只有不到50,同一个阈值根本没有办法同时处理这两种情况。
我的解决办法是局部自适应阈值 + 全局平均灰度偏移补偿。最简单有效的做法是计算当前帧全图的灰度均值,然后用这个均值动态调整二值化阈值。比如:
uint8_t compute_threshold(uint8_t *image, uint16_t width, uint16_t height) { uint32_t sum = 0; uint16_t total = width * height; for (uint16_t i = 0; i < total; i++) { sum += image[i]; } uint8_t mean = sum / total; // 基准阈值 = 均值 + 固定偏移,偏移量需要实车调试 int16_t threshold = (int16_t)mean + 18; if (threshold < 0) return 0; if (threshold > 255) return 255; return (uint8_t)threshold; }这里“+18”的偏移量不是拍脑袋定的,它表示目标板的深色特征与赛道背景的最小灰度差期望值。你可以在你们场地上做一次灰度直方图统计,取“目标板深色区域灰度峰值”和“赛场背景灰度峰值”的中值,再和全局均值比较,就能算出一个适合自己场地环境的偏移量。
这是个非常实用的经验:不要追求一个万能阈值,而是要追求一个“跟随环境变化”的阈值。目标板和场地是相对固定的,它们的灰度差不会因为光线增强而消失,所以自适应阈值在理论上一定能找到目标板。
2.3 连通域提取与噪声过滤
二值化之后,图像里往往不会只有目标板一个白色斑块,地面上可能还有一些杂色、轮胎印、观众席透进来的光斑,都会形成面积不小的连通域。这时候就要用几何特征做筛选。
我最常用的筛选条件是:
- 面积限制:目标板离车有一定距离,在图像里占据的像素面积有大致范围。比如50cm外是120个像素,80cm外是50个像素,我通常设置最小面积40像素、最大面积不做硬限制,但会结合“高宽比”来过滤。
- 高宽比:竞赛标准目标板通常是正方形或者横长方形,所以连通域的外接矩形宽高比应该在0.5到2.0之间。一条长长的轮胎印在高宽比上就会被排除掉。
- 填充率:目标板是规则的几何图形,像素面积与外接矩形面积的比值一般不低。如果填充率低于某个值(比如0.4),就说明这个斑块大概率不是完整的目标板。
以下是核心的筛选函数实现:
typedef struct { uint16_t area; uint16_t min_x; uint16_t max_x; uint16_t min_y; uint16_t max_y; } blob_t; uint8_t is_target_board(blob_t *blob) { // 面积过滤:至少40像素 if (blob->area < 40) return 0; uint16_t width = blob->max_x - blob->min_x + 1; uint16_t height = blob->max_y - blob->min_y + 1; // 长宽比过滤:0.5 ~ 2.0 float ratio = (float)width / (float)height; if (ratio > 2.0f || ratio < 0.5f) return 0; // 填充率过滤 float fill_rate = (float)blob->area / (float)(width * height); if (fill_rate < 0.4f) return 0; return 1; }这段代码看起来简单,但实际调试时有两个细节非常关键。第一,连通域的扫描要用并查集或者两遍扫描算法,不要用递归深度搜索,因为嵌入式平台栈空间很小,递归深了直接硬错误。第二,float运算在主控单片机上很慢,能转成整数比较就转成整数比较。比如高宽比0.5到2.0可以变成width * 2 < height之类的方式,尽量避免浮点运算。
2.4 靶心十字特征的判定
如果只是“绕过目标板”,那识别到连通域就够用了。但走马观碑组里往往还要判断目标板内部的十字或斜线标记方向。这一步比单纯找矩形要难,因为要用到图像内部的像素分布特征。
我的方案是拿到目标板连通域的外接矩形后,在矩形中心区域做一个简单的十字扫描:
- 沿着水平中心线统计白色像素的连续段。
- 沿着垂直中心线统计白色像素的连续段。
- 如果水平方向出现1个明显贯穿的白色条带,且垂直方向也出现1个明显贯穿的白色条带,则确定为“十字形”目标板。
- 如果只有水平方向有条带而垂直方向没有,则可能是“横向标记”目标板。
这个逻辑用代码实现很简单,但要注意一个坑:目标板在图像里不一定是正向的。车在行驶过程中,目标板在图像里可能带有旋转角度,外接矩形的中心线不再和图像坐标系平行。所以我实际实现时会先对外接矩形内的图像做一次轻量级的投影统计,而不是直接取图像坐标系的水平/垂直方向。
uint8_t detect_cross(uint8_t *binary_img, uint16_t width, uint16_t height, blob_t *blob) { uint16_t center_x = (blob->min_x + blob->max_x) / 2; uint16_t center_y = (blob->min_y + blob->max_y) / 2; // 扫描水平中心线:统计连通白色段的数量 uint8_t h_segments = 0; uint8_t prev = 0; for (uint16_t x = blob->min_x; x <= blob->max_x; x++) { uint8_t pixel = binary_img[center_y * width + x]; if (pixel && !prev) h_segments++; prev = pixel; } // 扫描垂直中心线 uint8_t v_segments = 0; prev = 0; for (uint16_t y = blob->min_y; y <= blob->max_y; y++) { uint8_t pixel = binary_img[y * width + center_x]; if (pixel && !prev) v_segments++; prev = pixel; } // 十字目标板:水平和垂直各有一个明显条带 if (h_segments == 1 && v_segments == 1) return 1; return 0; }当然,这种方法的鲁棒性依赖中心点选得准。如果外接矩形内混进了干扰物,中心点偏了,判定就会失效。稳妥的做法是取多个扫描行/列做投票,比如扫描中心线附近上中下三行,每条线出一个判断,最后少数服从多数。这会增加一点计算量,但换来的稳定性非常值得。
3. 绕行策略的深入优化
3.1 三种绕行策略的对比与适用场景
识别到目标板并确定其方向后,接下来就是绕行。绕行策略的好坏直接影响车能不能在最短时间内、最稳定地绕过目标板并回到赛道中心线上。我调试过三种方案,各有各的适用场景:
| 策略 | 实现难度 | 稳定性 | 适用场景 |
|---|---|---|---|
| 固定定时器绕行 | 低 | 一般 | 目标板位置固定不变、车速比较恒定 |
| 状态机分段绕行 | 中 | 较高 | 目标板相对位置变化不大、但车速变化明显 |
| 动态PID补偿绕行 | 高 | 高 | 目标板位置、车速、曲率都存在较大变化 |
固定定时器绕行是最容易想到的方案:一旦识别到目标板,就固定打左满舵或者右满舵500毫秒,然后回正。这个方法最大的问题是它完全无视车速和当前位置。车速慢一点,500毫秒可能还没绕完;车速快一点,500毫秒已经冲过头了,很可能直接压到目标板。
状态机分段绕行是把绕行过程拆成多个阶段:向右拉方向、直行脱离、向左回正、重新寻线。每个阶段持续的时间由编码器积分距离或者图像丢失状态来触发。这个方案比固定定时器稳了不少,因为它在关键时刻是根据“实际走了多远”来判断的,而不仅仅是“过了多久”。
动态PID补偿绕行则更进一步,它把图像中目标板中心与图像中心的偏差作为PID控制器的输入,持续输出一个叠加在巡线舵机控制量上的补偿量。这样当目标板在图像里偏向左侧时,车会提前向左打一点方向,保证绕行轨迹是一条平滑的弧线而不是生硬的折线。稳定性和速度上限最高,但代码复杂度和调参工作量也最大。
3.2 状态机分段绕行的实现细节
我最终用的是“状态机分段绕行 + 图像交叉验证”的组合方案,兼顾稳定性和实现复杂度。状态机的核心定义如下:
typedef enum { RUN_NORMAL = 0, // 正常巡线 RUN_AVOID_START, // 开始绕行 RUN_AVOID_STRAIGHT, // 直线脱离阶段 RUN_AVOID_RETURN, // 回正阶段 } run_state_t;每一次状态切换都依赖不同的触发条件,具体逻辑如下:
RUN_NORMAL→RUN_AVOID_START:连续3帧图像内识别到目标板,并且目标板中心横向偏差超过设定阈值(比如40像素)。连续3帧是为了防止单帧误识别导致误绕行。RUN_AVOID_START→RUN_AVOID_STRAIGHT:持续输出转向控制量之后,目标板中心开始移出图像视野,当连续2帧找不到目标板时,进入直线脱离阶段。RUN_AVOID_STRAIGHT→RUN_AVOID_RETURN:编码器累积距离达到设定的脱离长度(比如40cm),或者重新在图像边缘找到赛道边界线。RUN_AVOID_RETURN→RUN_NORMAL:图像中赛道中心线恢复到接近图像中心位置,并且持续5帧以上稳定,说明车已经回到正常巡线状态。
这个状态机的核心优势在于,每个阶段都有明确的退出条件,不会像定时器方案那样开环运行。你在实际调试时会发现,RUN_AVOID_STRAIGHT阶段的“脱离长度”是最难调的。调短了,车还没完全离开目标板就开始回正,会蹭到目标板边缘;调长了,车会偏出去太远,回正后离赛道中心线太远,容易在下一个弯道入弯失败。
我自己的调试办法是:先在地上贴一条标记线,让车以不同速度跑过目标板,记录每次从开始绕行到目标板完全脱离视野的车轮编码器读数,取中位数作为初始值,然后在此基础上微调。
3.3 绕行方向判断与动态补偿
方向判断的优先级要非常明确。我按照以下逻辑来确定绕行方向:
- 如果目标板中心在图像偏左,那么目标板相对车的位置在偏右方,车应该向右绕行。
- 如果目标板中心在图像偏右,车应该向左绕行。
- 如果目标板中心几乎居中,则参考赛道边界的方向,向有更多空间的一侧绕行。
这个逻辑看起来简单,但如果你直接用“目标板中心减去图像中心”的差值符号来决定方向,你会发现在某些特定编码下,差值符号和实际方向是反的。这是因为摄像头安装方式不同,有的队伍是前视安装,有的队伍是斜下安装,图像左右方向和车身左右方向可能一致也可能镜像。所以我强烈建议在写绕行方向代码之前,先做一个打印实验——把目标板放在车右前方,看图像里目标板中心是在图像中心的左边还是右边,确认之后再把方向逻辑写死。
动态补偿部分的实现是通过将目标板横向偏移量映射成一个补偿转向角,叠加到基础巡线PID输出上。以我用的主控和舵机为例:
int16_t base_steer = get_line_pid_output(); // 巡线PID输出 int16_t avoid_offset = compute_target_offset(); // 目标板与图像中心的偏移 int16_t avoid_gain = 30; // 绕行补偿增益,实车调参 if (state == RUN_AVOID_START || state == RUN_AVOID_STRAIGHT) { if (avoid_direction == AVOID_RIGHT) { steer_output = base_steer - avoid_gain * avoid_offset / 100; } else { steer_output = base_steer + avoid_gain * avoid_offset / 100; } }这里要注意的是,avoid_offset要归一化到合理范围,否则图像中的横向偏差值会被直接放大成一个很大的转向角,导致车猛地一甩。我一般会把它限制在-100到+100之间,再乘一个小于1的增益系数。实际调参时,增益从20开始,每次加5,观察车在绕行过程中是否有明显的抖动或甩尾。
3.4 绕过之后如何平稳回到赛道线
很多人忽视的一个细节是:绕行成功之后,车不能“一下子”回到正常巡线状态,否则会因为巡线PID的积分饱和或者微分冲击产生严重的抖动。
我的处理方法是:在RUN_AVOID_RETURN状态下,对巡线PID的积分项做清零处理,同时把PID输出与前一帧的输出做“限幅渐变”。也就是说,绕行回正阶段允许的最大转向变化量不是立即恢复,而是每帧最多改变一定数值(比如每帧10个舵机PWM单位),这样车在回到中心线的过程中轨迹会更平滑。
还有一个实用技巧:回正状态后,强制让车保持直线行驶一小段距离(比如编码器走10cm),再交还给巡线逻辑。这能有效避免因为回正过程中赛道线位置变化太快而导致的误判和振荡。
4. 实战代码与关键函数解析
4.1 主循环与状态机整合
下面是一段可以运行在主控单片机上的精简代码框架。为了适合展示,我做了些简化,但整体结构和实际项目一致。
void main_loop(void) { while (1) { // 1. 采集图像并二值化 camera_capture(frame); uint8_t threshold = compute_threshold(frame, IMG_W, IMG_H); binary_image(frame, threshold, IMG_W, IMG_H); // 2. 提取连通域并筛选目标板 blob_t target; uint8_t found = find_target_blob(frame_binary, IMG_W, IMG_H, &target); // 3. 状态机更新 state_update(found, &target); // 4. 生成舵机PWM int16_t steer = calc_steer_output(found, &target); servo_set_pwm(steer); // 5. 速度闭环控制 speed_pid_run(); } }这段主循环非常简单,但里面有几个性能优化的关键点:
- 不要在主循环内做
printf打印调试,调试信息会严重拖慢循环频率。我都是把状态变量放进一个环形缓冲区,通过串口定时批量输出。 - 连通域的扫描算法建议用两遍扫描,第一遍标记等价对,第二遍合并。不要用深度优先填充,因为图像中白色区域较大时,递归会爆栈或者占用大量堆栈空间。
- 二值化后的图像一定要复用同一块内存,不要在每帧重新
malloc,嵌入式环境下的动态内存分配很容易产生碎片,跑几分钟后系统就挂了。
4.2 目标板寻找与状态机完整代码
目标板寻找函数find_target_blob是整个系统的核心,内部按顺序执行“扫描标记连通域、计算几何特征、过滤非目标板”三个步骤。这里给出一个可直接参考的实现思路:
uint8_t find_target_blob(uint8_t *binary, uint16_t w, uint16_t h, blob_t *out) { // 使用一个标记数组记录每个像素是否已被访问 static uint8_t visited[IMG_W * IMG_H]; // 提前分配好,避免栈开销 memset(visited, 0, w * h); for (uint16_t y = 1; y < h - 1; y++) { for (uint16_t x = 1; x < w - 1; x++) { uint16_t idx = y * w + x; if (binary[idx] && !visited[idx]) { blob_t blb; flood_fill(binary, visited, w, h, x, y, &blb); if (is_target_board(&blb)) { *out = blb; return 1; } } } } return 0; }这里我用的是标记数组而不是修改原图,这样二值图在其他地方(比如调试串口输出)还需要用到。flood_fill虽然是用栈模拟的,但相比递归版本已经安全很多。需要注意的是,static uint8_t visited[IMG_W * IMG_H]会占用一块不小的静态内存,如果你的主控SRAM比较紧张,可以考虑用位图来压缩内存占用。
状态机更新函数state_update是绕行策略的灵魂,代码实现如下:
void state_update(uint8_t found, blob_t *target) { static uint8_t found_cnt = 0; switch (current_state) { case RUN_NORMAL: if (found) { found_cnt++; if (found_cnt >= 3) { // 判断绕行方向 uint16_t img_center = IMG_W / 2; if (target->min_x + (target->max_x - target->min_x) / 2 < img_center) { avoid_direction = AVOID_RIGHT; // 目标偏左 -> 向右绕 } else { avoid_direction = AVOID_LEFT; } current_state = RUN_AVOID_START; found_cnt = 0; } } else { found_cnt = 0; } break; case RUN_AVOID_START: if (!found) { distance_cnt = 0; current_state = RUN_AVOID_STRAIGHT; } break; case RUN_AVOID_STRAIGHT: distance_cnt += encoder_read_delta(); if (distance_cnt >= avoid_straight_length) { pid_reset(); current_state = RUN_AVOID_RETURN; } break; case RUN_AVOID_RETURN: if (is_line_centered() && stable_cnt >= 5) { current_state = RUN_NORMAL; } else { stable_cnt = (is_line_centered()) ? stable_cnt + 1 : 0; } break; } }这个状态机有一个隐含的好处:它在绕行过程中不是一直依赖“目标板是否可见”这一个条件,而是通过“发现目标板后的图像丢失”来确认已经接近甚至经过目标板,随后利用距离积分来精确控制绕行距离,最后让视觉巡线逻辑确认回归。每一步的触发条件都有冗余和交叉验证,所以即使某帧图像出现误识别,也不会立刻让状态机乱跳。
4.3 巡线PID与绕行的融合控制
这里有一个容易出错的地方,就是巡线PID的输出和绕行控制量的融合顺序。千万不要简单地在巡线PID输出后面加上一个固定偏置,那样会导致绕行过程中仍然在按照赛道线做修正,最终绕行轨迹变成波浪形。
我的做法是:在绕行状态下,暂停巡线PID的积分更新,仅保留一个很弱的比例项用于感知赛道大致方向,同时以更大的权重叠加绕行控制量。回归正常巡线状态后,再逐步恢复PID的完整输出。
int16_t calc_steer_output(uint8_t found, blob_t *target) { int16_t line_steer = get_line_pid_output(); int16_t avoid_steer = 0; if (current_state == RUN_AVOID_START || current_state == RUN_AVOID_STRAIGHT) { // 目标板偏移量越大,补偿转角越大,但要限制上限 int16_t offset_px = compute_offset_in_pixels(target); avoid_steer = avoid_gain * offset_px / 100; if (avoid_steer > AVOID_STEER_MAX) avoid_steer = AVOID_STEER_MAX; if (avoid_steer < -AVOID_STEER_MAX) avoid_steer = -AVOID_STEER_MAX; pid_free_integral(); // 暂停积分 return avoid_steer + line_steer * 0.3f; // 弱化巡线项 } if (current_state == RUN_AVOID_RETURN) { pid_reset_integral(); return line_steer * 0.8f; // 逐渐恢复巡线权重 } return line_steer; }注意代码里的line_steer * 0.3f这个权重系数,实际使用中不建议直接用浮点,可以改成(line_steer * 3) / 10。在嵌入式平台上,浮点运算会占用额外的CPU时间,虽然单次影响不大,但放在控制主循环里,每一帧都算一下,累计起来会让主循环频率下降不少。
另外,这个融合方式有个前提:目标板出现的时候,赛道线仍然在图像视野中。如果目标板太大、太近,把整个视野都挡住了,那line_steer本身就会失真,这时候你就需要进入一个“纯盲绕”模式,完全依靠编码器距离来走完绕行轨迹。这也是为什么状态机里RUN_AVOID_STRAIGHT阶段设计了“图像完全看不到目标板和赛道线”的处理逻辑。
5. 常见问题与排查技巧实录
5.1 目标板识别率不稳定怎么办
我在调试中遇到过最典型的现象是:目标板识别率在上午测试时还有95%,到了下午直接降到60%。排查了很久才发现,问题不在算法,而在摄像头曝光时间。
总钻风摄像头默认的曝光时间是自动调节的,光照变化时,画面的亮度会上下浮动。当你刚好在阳光照射较强、曝光时间被迫缩短的时候,目标板暗色区域的灰度值会被压得很低,甚至和二值化背景的灰度区分不开。
解决方法是:把摄像头设为手动曝光,固定一个中等偏低的曝光时间,然后通过自适应二值化来吸收剩余的光照变化。这个操作听起来简单,但很多人第一反应是去调图像处理的阈值,而不是调摄像头本身的曝光参数,导致浪费时间。
还有一次,我们发现目标板识别率在过弯时会突然下降。最后定位到原因是:车在高速过弯时,摄像头安装架发生微小形变,导致图像中心偏移。这个问题的排查思路是:录制一段过弯时的图像数据,逐帧看目标板在画面中的位置变化,而不是只看识别结果。因为图像中心偏移一两行像素,在肉眼看来不明显,但对连通域搜索范围影响很大。
5.2 绕行距离调不准的两个隐藏因素
绕行距离(avoid_straight_length)是我调得最痛苦的一个参数。它受两个隐藏因素影响:
第一,编码器的分辨率误差。我们的车轮直径和编码器轮直径不完全一致,它俩之间有一个比例系数。如果直接用编码器读数当距离,车速越快,轮胎打滑越明显,编码器读数和实际行驶距离的偏差就越大。解决方法是做一个多速度下的标定实验,把“编码器读数”和“实际距离”的关系拟合成一条分段线性曲线。
第二,舵机响应延迟带来的轨迹偏差。从状态机进入绕行状态到舵机真正转到目标角度,中间有大约20-40毫秒的延迟。车速3m/s时,20毫秒车已经跑了6cm,绕行轨迹的起点已经比预期晚了6cm。这也是为什么状态机里RUN_AVOID_START阶段不宜设置得太短,要给舵机响应留出时间。
这两个因素叠加起来,会导致你在地面贴了标记线精心调试好的距离参数,到了赛场上因为轮胎磨损、地面摩擦系数不同又失效。我最后的应对方法是:赛前至少留出一个下午,用新轮胎、场地实际地面重新标定一次距离参数,不要抱着“这周已经调好了”的想法。
5.3 回正后车身倾斜严重
这个问题出现得非常多,原因也很简单:绕行状态退出后,车往往不在赛道中心线上,而巡线PID的积分项还残留着绕行时的大误差,导致回正阶段出现严重的超调。
我处理这个问题用的是“软回正”策略:在RUN_AVOID_RETURN阶段,不直接把巡线PID输出作为舵机控制量,而是先强制直行固定距离,等车头接近赛道中心线方向后,再逐渐放开巡线PID的权重。这个“逐渐放开”的过程可以在10帧左右完成,每帧巡线权重递增10%。
这样做的代价是回正过程会比直接切回巡线慢大约200毫秒,但换来的是车身姿态稳定,尤其是在连续弯道里,这个稳定性比速度更重要。
5.4 无法同时满足识别速度和稳定性
最后聊一个很多队伍都会陷入的误区:总想让识别速度更快,于是把图像分辨率降低、把算法简化,结果识别率暴跌。我的实际经验是:50ms的处理周期完全够用。换算过来就是20帧每秒的处理速度,对于最高时速3-4m/s的智能车来说,50ms内车最多前进20cm,这个控制粒度对于绕行一个20x20cm左右的目标板来说足够精确了。
所以我不建议为了追求30帧甚至60帧而牺牲识别质量。更合理的思路是:把处理周期稳定在40-50ms,多出来的时间用于做一些简单的图像预处理增强,比如中值滤波去噪点,或者对二值化图做形态学开运算去掉小的孤立白点。这些操作虽然每帧只增加不到1ms,但对识别率的提升非常明显。
6. 现场调试流程与参数调整建议
6.1 一套高效的标定顺序
很多队伍到了赛场才开始调参,现场乱成一团。我的建议是,把调试拆成以下四个阶段,每个阶段有明确的目标,不要跨阶段调参:
- 静态标定阶段:在场地固定位置放好目标板,不跑车,只验证识别算法的输出是否正确。这个阶段调的是二值化阈值、连通域过滤参数、十字特征判定的阈值。确保车停在不同距离、不同角度时都能输出正确识别结果和偏差值。
- 低速绕行阶段:车速控制在1m/s左右,只验证绕行状态机和绕行方向是否正确。这个阶段不要碰速度闭环,也不要碰图像识别参数,只看车的绕行轨迹是否合理、回正是否平稳。
- 全速绕行阶段:逐步提高车速,记录不同速度下的绕行效果。这个阶段的重点是调
avoid_straight_length和舵机补偿增益。每提升一次速度,先跑10次,确认成功率在90%以上再继续加高速度。 - 抗干扰阶段:在赛道旁边放置其他杂物、关闭部分灯光模拟不同光线条件,验证识别和绕行是否依然可靠。这个阶段如果发现误识别,优先检查图像预处理环节,而不是直接加判断条件。
这个顺序看起来慢,但每一步都有明确的目标,能让你在赛前快速定位问题,不至于赛场上瞎调。
6.2 关键参数速查表
下面是我在这个项目中实际用到的核心参数表,可以作为你调试的起点值:
| 参数 | 参考值 | 调整依据 |
|---|---|---|
| 图像分辨率 | 120x160 | 主控性能越好可适当提高 |
| 二值化偏移量 | +18 | 根据场地灰度中值差调整 |
| 目标板最小面积 | 40像素 | 根据最小识别距离调整 |
| 目标板长宽比 | 0.5 ~ 2.0 | 根据规则目标板形状调整 |
| 最小填充率 | 0.4 | 防止不规则反光斑块误判 |
| 绕行方向偏差阈值 | 40像素 | 根据赛道宽度调整 |
| 绕行补偿增益 | 30 | 从20开始逐步调大 |
| 最大补偿转角 | 60 | 防止绕行过猛 |
| 直线脱离距离 | 40cm | 通过目标板距离实测标定 |
| 回正稳定帧数 | 5帧 | 防止提前切回巡线 |
这个表里的数值不是通用答案,但它提供了一个非常合理的起点。如果你现在是一张白纸,用这些参数上车,大概率能跑起来,之后根据自己场地再做微调。
6.3 参数调整中容易犯的三个错误
第一个错误是同时调多个参数。比如你觉得绕行不稳,就同时改了补偿增益、脱离距离和舵机PD参数。结果是车从“绕行不稳”变成“完全没法跑”,因为你根本不知道是哪个参数导致的。正确做法是每次只动一个参数,跑3-5次看效果,再决定下一步调什么。
第二个错误是用记忆代替记录。我今天在这个场地调好了,觉得参数没问题,结果第二天换了场地全废。我在项目里专门写了一个参数保存功能,每次调完参都可以一键存储到EEPROM,同时打印出当前参数组编号。这样切换场地、切换赛道时,可以快速恢复对应参数,不用重新调。
第三个错误是忽略摄像头安装角度对绕行轨迹的影响。摄像头俯仰角稍微偏一点,图像中的目标板横向位置与车的实际相对位置的映射关系就会变化。这个问题不体现在目标板识别上,而是体现在绕行方向判断的边界上。比如某个角度下,目标板明明在车正前方,图像里却显示偏左,导致车绕错了方向。解决方法是定期用水平尺确认摄像头安装支架是否保持水平,尤其是在经历碰撞和运输之后。
7. 现场比赛策略与应急处理
7.1 赛前检查清单
比赛当天最容易出状况的往往不是算法,而是硬件和现场环境。我给自己列了一个检查清单,每次上场前按顺序走一遍:
- 摄像头镜头是否清洁?灰尘和指纹会严重降低图像清晰度,从而影响二值化效果。
- 摄像头曝光参数是否被误改?很多摄像头在重启后会自动恢复到默认自动曝光模式,需要重新检查。
- 电池电压是否正常?舵机和电机在低压时的响应速度差异很大,会影响绕行动作的稳定。
- 场地光线是否和调试时一致?如果赛场的灯光布局和调试场地不同,需要重新调整二值化偏移量。
这个清单看起来很简单,但每一届比赛现场都会有人在开场前慌张地改代码,最后连赛道都没跑完。先检查硬件和环境,再去动代码参数。
7.2 绕行失败的应急预案
即使你的系统调试得再充分,比赛现场也有可能因为突发的反光、目标板位置偏差导致绕行失败。我的应急预案是:在代码里增加一个“手动强制绕行”模式,当裁判宣布发车时,我可以根据现场观察到的目标板位置,通过遥控器或者按键强制设定绕行方向和绕行距离。这样即使视觉识别在某一帧出现了失误,我也可以靠人工介入保证至少有一个有效成绩。
这个方法看起来有点“土”,但在实际比赛里真的救过我一次。当时现场光线和调试场地区别太大,目标板的十字特征识别一直不稳定,三次试跑两次失败。我靠手动绕行模式拿到一个有效成绩,后续才在适应场地后慢慢调试恢复。
7.3 时间分配建议
如果你现在是赛前两周才第一次上车调试,我建议时间按以下比例分配:
- 30%的时间花在目标板识别的稳定性上,这是绕行的前提。
- 20%的时间花在绕行状态机和距离参数调校上。
- 30%的时间花在完整赛道模拟跑圈上,重点看绕行后的回正衔接。
- 20%的时间留给抗干扰测试和应急预案准备。
不要把90%的时间都花在图像识别上,因为绕行策略和完整赛道跑通的配合才是决定最终成绩的关键。识别做得再好,绕行不连贯,成绩同样不会理想。
8. 写在最后的一点心得
走马观碑组这个项目最有意思的地方在于,它不是一个纯粹的“视觉算法题”,也不是一个纯粹的“控制题”,而是两者深度耦合的工程题。目标板识别做得再好,如果绕行策略跟不上,成绩依然不理想;绕行策略做得再顺滑,如果识别时灵时不灵,整个状态机就会变成摆设。把这两个模块拆开各自调通都不算难,真正考验人的是它们之间的接口设计和异常处理逻辑。
我个人在实际调试中最深的体会是:不要追求“理论最优”的方案,要追求“现场最稳”的方案。你完全可以用深度学习模型来识别目标板,精度可能很高,但它在单片机上跑不动,或者帧率上不来,反而拖垮了绕行控制。反过来说,一个基于灰度二值化和几何特征的传统视觉方案,虽然在复杂背景下不够“智能”,但它在竞赛这个受限场景下确实够用、够稳、够快。
这篇文章里的代码和参数都是我从实际项目中提炼出来的,你完全可以照着搭一版跑起来,然后再针对自己的赛道特征逐步调整。最后再分享一个小技巧:参数调优时一定要录视频记录,不要只靠眼睛看车跑完的瞬间。绕行过程中车身姿态的变化、回正时的偏移量,肉眼很难捕捉,但慢放视频就能看得一清二楚。很多困扰我好几天的问题,最后都是靠慢放视频定位到具体是哪个环节出了偏差。