简介:智能车校赛摄像头循迹代码参考,是一份面向高校智能车竞赛参赛者的完整工程资料包,覆盖图像采集、预处理、边缘检测、轨迹识别、PID控制等摄像头循迹核心环节,适合有一定C语言和单片机基础、正在备赛或调试循迹方案的读者。压缩包共1639个文件,包含436个h头文件、356个c源文件、258个o目标文件和258个d调试映射文件,以及253个crf编译信息文件,同时提供工程配置文件、链接脚本、批处理脚本等,基本对应一套完整的嵌入式工程,便于直接查看代码结构和重新编译。资源包大小143MB,目前已有3705人学习下载。通过研读这份代码,可深入理解从图像处理到控制输出的完整链路,学习RT106X平台下的工程组织方式、摄像头循迹算法实现和调试排错方法;即使不参加校赛,也可作为单片机视觉控制项目的实战参考。 每年智能车校赛前,总有学弟学妹拿着一堆报错截图和乱飘的代码来找我:“学长,摄像头图像出来了,但车就是不走线”“二值化阈值怎么调都不对”“一到弯道就冲出去”。说实话,摄像头循迹这个方向,代码量不大,难的是把图像处理、控制逻辑和硬件特性串成一条线,任何一个环节断了,车都跑不起来。
这篇文章就把我调校赛摄像头车时用过的代码框架、参数整定思路和踩坑记录完整写出来,覆盖从图像采集到转向控制的全链路。内容不搞高深算法,全部以“能稳定跑完校赛赛道”为目标。适合正在准备校赛、刚接触摄像头组的同学参照:你可以直接复用代码结构,也可以按文中的思路逐步调参,把一套能跑通的底盘逻辑搭起来,再根据自己赛道的实际情况做裁剪。
1. 校赛摄像头循迹的整体设计思路
1.1 摄像头循迹的核心链路
摄像头循迹的本质,就一句话:让小车通过摄像头“看见”前面的赛道,计算出赛道中线相对车身中心的位置偏差,再把这个偏差换算成舵机打角和电机转速,完成循迹。
完整的数据流是:摄像头采集图像 → 图像预处理(灰度/二值化)→ 提取赛道中线 → 计算偏差 → 控制舵机转向、控制电机调速。
这个过程看起来简单,但每个环节都有坑。比如图像二值化阈值选不好,赛道边缘全是锯齿;中线提取时遇到十字路口和丢线,没做补线处理车就懵了;偏差算出来后没有限幅,弯道里舵机直接打死,车就甩尾出去了。后面所有调试都是在跟这些细节搏斗。
1.2 为什么校赛阶段首选摄像头方案
校赛常见的巡线方案有三种:五路红外循迹、电磁循迹和摄像头循迹。
五路红外循迹是最容易上手的,本质就是几个开关量拼出“当前车头下赛道在哪边”,逻辑简单、成本低。但它的局限也很明显:传感器装在车头正下方,只能看到眼前一小片区域,车速稍微提起来就会冲出赛道。电磁循迹靠电感感应赛道中心铺的导线,前瞻距离能做长,但对赛道环境的电磁噪声敏感,摆放和标定麻烦,适合追求高速的组别,校赛阶段用的人反而不多。
摄像头方案的好处是“看得远、看得多”。一个几十块钱的灰度摄像头就能看到车前方一两米甚至更远的赛道,提前判断弯道方向和曲率,控制策略可以从“被动纠偏”变成“预瞄转向”,这是校赛拿名次的关键差距。同时摄像头图像更接近人眼感知,出问题时很好排查——你直接看一帧图就知道是曝光问题还是算法问题。
1.3 校赛阶段的目标定位
校赛不需要把车调成全国总决赛那种“全速过弯”的状态。在校赛里,赛道就是普通KT板喷绘的赛道,宽度大约45到60厘米,包含直道、弯道、十字和环岛,大部分学校还不允许电磁干扰。所以目标就三个:稳定识别赛道、不丢线不冲出去、在直道上尽量提速。
我的建议是,先保证“慢但不失误”,再逐步提速。一辆能在90度弯道稳定过弯、在十字路口不迷路、速度大约1.5到2米每秒的车,在校赛已经足够进复赛。这篇文章给的代码和控制策略,也全部按这个目标来设计。
2. 硬件选型与环境搭建
2.1 摄像头模块选型
校赛摄像头组的“标准答案”,这么多年基本没变过:灰度摄像头 + 单片机DVP接口,性价比最高的是杭州数码科技的总钻风MT9V034。这摄像头是128x188分辨率(宽x高,实际常按188x120或188x128读),输出8位灰度图,全局快门,动态范围大,抗反光能力比普通卷帘快门的手机摄像头强很多。
另一种常见选项是OV7725,同样是灰度摄像头,但输出是RGB565或YUV,需要做颜色转换,图像分辨率也更高,对单片机的DMA带宽和处理速度要求更高。很多新手选OV7725后卡在处理不过来上,反而是MT9V034更省心。
树莓派的OV5647摄像头模块也能采集图像,但主控从单片机换成了树莓派,整套代码逻辑也完全不同,偏向“人工智能完全模型”那类组别。校赛摄像头组不建议走这条路,调试成本高、功耗大,对校赛来说属于过度设计。
| 参数项 | 总钻风 MT9V034 | OV7725 | OV5647(树莓派) |
|---|---|---|---|
| 输出格式 | 8位灰度(Y通道) | RGB565/YUV | Raw Bayer/JPEG |
| 典型分辨率 | 188x120 | 320x240 | 2592x1944(可裁剪) |
| 快门类型 | 全局快门 | 卷帘快门(低分辨率下影响较小) | 卷帘快门 |
| 主控接口 | DVP/8bit并行 | DVP/8bit并行 | CSI |
| 单片机处理负担 | 低 | 中高 | 高(需Linux) |
| 校赛推荐度 | 强烈推荐 | 可用,但需注意代码量 | 不推荐校赛用 |
2.2 主控平台与开发环境
主控我建议优先用逐飞的TC264或TC212开发板,配合逐飞库来写。逐飞库把底层寄存器封装得比较完整,摄像头接口、DMA采集、PWM、编码器读取都有现成函数,你主要精力可以放在图像处理和控制逻辑上。
如果学校指定了STC32等单片机,也能做,但DVP平台差异大,代码没法直接套,你得更早动手改底层。开发环境方面,TC264用AURIX Development Studio,STC32用Keil C251。建议拿到板子先跑通官方例程的摄像头图像显示,再开始后面的工作。
2.3 摄像头安装与接线
摄像头建议装在车头前方横杆上,高度15到25厘米,俯角15到30度,能看到车头前方1米到1.5米的范围。不要装太高,否则赛道边缘在远处缩成一个点,反而丢失近处的转向参考;也不建议太低,近处视野太窄,弯道容易切内线出去。
接线这块,不同板子引脚不同,但原则是:摄像头电源(5V/3.3V)要独立供电或加稳压电容,DVP数据线按引脚定义接,PCLK提供像素时钟,VSYNC/HSYNC分别提供帧同步和行同步。逐飞库的例程里有现成的引脚配置表,照着接就行,坏处是很多同学看都不看直接插,结果PCLK接错引脚,图像全乱码。
提示:摄像头模块的电源和电机驱动电源必须分开,或者至少共地后加一个大电容滤波。我调车时遇到图像水波纹,排查到最后是电源被电机干扰,加了1000uF电容后才稳定。
3. 核心代码框架与图像处理解析
3.1 摄像头初始化与图像采集
摄像头组的代码框架,我按下面这种方式组织,分层清晰,调试时不容易乱。
#include "zf_common_headfile.h" camera_t camera; // 摄像头对象 uint8_t image_binary[IMAGE_H][IMAGE_W]; // 二值化后的图像 uint8_t image_gray[IMAGE_H][IMAGE_W]; // 灰度图 volatile uint8_t frame_ready = 0; // 帧完成标志 // 摄像头采集回调:图像传输完成后自动触发 void camera_callback(camera_t *camera) { memcpy(image_gray, camera->image, IMAGE_H * IMAGE_W); frame_ready = 1; } void setup_camera(void) { // 以总钻风摄像头、灰度图模式为例 camera_init(&camera, CAMERA_MT9V034, IMAGE_W, IMAGE_H, IMAGE_GRAY); camera_set_callback(&camera, camera_callback); // 注册DMA完成回调 camera_start(&camera); } void image_process(void) { // 1. 二值化 otsu_threshold(image_gray, image_binary); // 2. 提取中线 // 3. 计算偏差 }这个框架里,摄像头图像采集靠DMA传输,传输完成后进中断(回调函数)把整帧拷贝到全局数组。拷贝是为了避免在处理图像时DMA仍在往同一个缓冲区写,产生“撕裂图像”。校赛阶段的处理量不大,拷贝开销可以接受。
每次调度时,主循环里检测frame_ready标志,置位了就处理一帧图像。比如50Hz帧率,处理周期就是20ms,控制频率不用太高,够用。
3.2 二值化:从灰度到黑白分割
MT9V034输出的是灰度图,接下来要把赛道从背景里分离出来。最简单的思路是固定阈值:像素值大于阈值认定为白色赛道,小于阈值认定为黑色背景。这个逻辑在光线稳定的室内确实好用,但校赛场馆灯光复杂、KT板反光强,固定阈值很容易失效。
我的做法是用大津法(Otsu)自动计算阈值。大津法的原理简单说就是遍历所有可能的灰度值,找一个能把像素分成“前景”和“背景”两组、且两组之间方差最大的阈值。整个计算只需要一次直方图统计,代码量不长,在188x120分辨率下运行耗时不到1毫秒。
uint8_t otsu_threshold(uint8_t *gray, uint8_t *binary) { uint32_t hist[256] = {0}; uint32_t total = IMAGE_W * IMAGE_H; uint32_t sum_all = 0; uint8_t threshold = 128; // 统计灰度直方图 for (int i = 0; i < total; i++) hist[gray[i]]++; for (int i = 0; i < 256; i++) sum_all += i * hist[i]; uint32_t sum_bg = 0, w_bg = 0; long double max_var = 0; // 遍历所有灰度级,找类间方差最大的阈值 for (int t = 0; t < 256; t++) { w_bg += hist[t]; if (w_bg == 0) continue; uint32_t w_fg = total - w_bg; if (w_fg == 0) break; sum_bg += t * hist[t]; long double u_bg = (long double)sum_bg / w_bg; long double u_fg = (long double)(sum_all - sum_bg) / w_fg; long double var = (long double)w_bg * w_fg * (u_bg - u_fg) * (u_bg - u_fg); if (var > max_var) { max_var = var; threshold = (uint8_t)t; } } // 生成二值图:白色赛道为255,黑色背景为0 for (int i = 0; i < total; i++) binary[i] = (gray[i] > threshold) ? 255 : 0; return threshold; }大津法虽然是自适应阈值,但也有个前提:图像里“赛道”和“背景”的面积要相对接近,才能分得准。如果赛道只占图像很小一块,或者大片阴影让灰度分布变成多峰,大津法也会翻车。我在实际调车时,加了一道保险:先裁掉图像最上面10行最下面5行再做直方图统计,这部分通常是远方的赛道尽头或者车头杂物,对阈值干扰大。
3.3 赛道中线提取与丢线补线
拿到二值图后,接下来的活是逐行扫描每一行像素,找到左右两个赛道边缘,然后算中点坐标,连起来就是赛道中线。
逐行扫描的逻辑很简单:从左往右扫描这一行,第一次遇到“黑色到白色”的跳变点就是左边线;继续往右扫,第一次遇到“白色到黑色”的跳变点就是右边线。两个边线的中点就是这一行的中线。
但实际赛道上有很多特殊情况,最常见的是出图像边界:比如在急弯里,赛道左边界可能已经超出图像左边界,你根本扫描不到左边线。这时候不能把这一行直接丢给控制,否则中线会突然跳变,舵机猛打一下就甩出去了。
我的补线策略是:当某一行只找到一个边线时,用上一行的赛道宽度来推测本行的缺失边线位置。再极端一点,两侧边界都找不到(进入纯色区域或者被大面积反光糊住),就直接沿用上一行中线的偏移量,并把这一行的“信任度”调低,让控制环对中线跳变的响应减弱。
typedef struct { uint8_t left_edge; // 左边线列坐标,无边界设为0xFF uint8_t right_edge; // 右边线列坐标,无边界设为0xFF uint8_t mid; // 中线列坐标 uint8_t valid; // 本行是否有效 } TrackLine; void extract_track_midline(uint8_t (*binary)[IMAGE_W], TrackLine *line) { int previous_width = 40; // 默认赛道宽度,像素 for (int row = 0; row < IMAGE_H; row++) { line[row].left_edge = 0xFF; line[row].right_edge = 0xFF; line[row].valid = 0; // 从左往右找左侧第一个白色像素 for (int col = 0; col < IMAGE_W; col++) { if (binary[row][col] == 255) { line[row].left_edge = col; break; } } // 从右往左找右侧第一个白色像素 for (int col = IMAGE_W - 1; col >= 0; col--) { if (binary[row][col] == 255) { line[row].right_edge = col; break; } } int valid_edges = (line[row].left_edge != 0xFF) + (line[row].right_edge != 0xFF); if (valid_edges == 2) { line[row].mid = (line[row].left_edge + line[row].right_edge) / 2; line[row].valid = 1; previous_width = line[row].right_edge - line[row].left_edge; } else if (valid_edges == 1) { if (line[row].left_edge != 0xFF) line[row].mid = line[row].left_edge + previous_width / 2; else line[row].mid = line[row].right_edge - previous_width / 2; if (line[row].mid >= IMAGE_W) line[row].mid = IMAGE_W - 1; if (line[row].mid < 0) line[row].mid = 0; line[row].valid = 1; } else { // 左右都没有检测到:用上一行结果延续 if (row > 0) { line[row].mid = line[row - 1].mid; line[row].valid = 1; } } } }这里还有个很重要的视角处理:摄像头是装在车头前方俯视赛道的,所以远处的赛道在图像上方、近处赛道在图像下方。近处的赛道图像畸变小、宽度大,适合做转向控制参考;远处的赛道畸变大、容易跳变,只能做趋势参考。我在实际计算偏差时,只取图像下部三分之一到二分之一的有效中线行做加权平均,这样转向控制既稳定又跟手。
3.4 控制量计算:PD转向与PID速度
中线提取出来后,控制就变成纯数学问题了。先算偏差:
float error = 0; float weighted_sum = 0, weight_total = 0; // 取图像下半部分的有效行,加权平均计算平均偏差 for (int row = IMAGE_H * 1 / 2; row < IMAGE_H; row++) { if (track_line[row].valid) { float weight = (float)(row - IMAGE_H / 2); weighted_sum += (float)(track_line[row].mid - (IMAGE_W / 2)) * weight; weight_total += weight; } } if (weight_total != 0) error = weighted_sum / weight_total;这个加权平均,权重就是“行号离底部有多近”——越靠近车头,权重越大。实际效果等同于“优先盯着眼前的路,远处只做微调”。
转向采用位置式PD控制,因为转向控制追求的是响应速度和稳定性,单独的P会产生稳态误差,D则能提供阻尼,抑制中线跳变带来的抖动。
#define STEER_KP 0.45f #define STEER_KD 0.8f #define STEER_MID 7500 // 舵机中值PWM #define STEER_MAX 1500 // 最大打角范围 static float last_error = 0; float steer_pd_get(float error) { float diff = error - last_error; last_error = error; float steer = STEER_KP * error + STEER_KD * diff; if (steer > STEER_MAX) steer = STEER_MAX; if (steer < -STEER_MAX) steer = -STEER_MAX; return steer; } // 主循环中调用 float steer_duty = STEER_MID + steer_pd_get(error); pwm_set_duty(PWM_STEER, steer_duty);舵机中值和打角范围,必须在车上实际测量。我一般是把舵机从最左转到最右,记录PWM极小值和极大值,中值取两者平均。PD参数则是先给一个保守的P(舵机基本能跟手),再逐步调大D压住过弯前的中线跳变。注意D不能太大,否则直道上中线轻微抖动会被放大成左右猛摆。
速度控制方面,校赛阶段可以先不开速度PID,给电机一个固定PWM,直道弯道统一速度,先把转向的稳定性调出来。之后再加串级PID:外环用编码器测速,内环控制PWM输出。
// 给定目标速度,编码器测速,PID输出PWM float speed_speed_pid_calc(float target_speed, float actual_speed) { static float integral = 0; static float last_error = 0; float kp = 0.2f, ki = 0.01f, kd = 0.05f; float error = target_speed - actual_speed; // 单位:rpm或m/s integral += error; float output = kp * error + ki * integral + kd * (error - last_error); last_error = error; if (output > MOTOR_MAX) output = MOTOR_MAX; if (output < MOTOR_MIN) output = MOTOR_MIN; return output; }注意:速度PID的Ki不要给大,积分项在起步时会积累很大的持续误差,导致电机猛冲。我给Ki通常只有Kp的二十分之一左右,而且积分要上限幅,否则停车后再次起步容易“抽搐”。
4. 调试实录与常见问题排查
4.1 图像过曝与反光
校赛最常遇到的问题是KT板赛道在灯光下反光,灰度图上一大片全是白色,赛道边缘直接消失。处理手段有几种:
第一,调摄像头的曝光时间和增益。MT9V034的寄存器可以分别设置曝光行数和增益,逐飞库里有摄像头配置接口,把曝光压低一些,让白色赛道在灰度直方图里依然和背景有区分度。
第二,在二值化之前先做图像滤波。最简单的是3x3中值滤波,把孤立的高亮噪点干掉。代价是每帧多花几十微秒,校赛完全顶得住。
第三,如果反光严重到赛道根本搜不到,那就得调整安装角度,让摄像头视角避开灯光直射方向,或者增加遮光罩。这个比改代码管用。
4.2 急弯丢线与冲出赛道
丢线在急弯里几乎不可避免,正确的处理方式是“丢线补线”加“限幅降速”。我在前面的代码里已经写了补线逻辑,但还有一个配速的关键:当有效行很少时,说明当前视野里赛道信息缺失,车处在高风险状态。这时候应该主动降低目标速度,保证可控。
配置一个“丢线率”变量,统计下半部分有效中线行数占总行数的比例。当这个比例低于40%时,把目标速度砍掉一半;低于20%时再降三成。实测下来,这比单纯靠转向硬生生拉回赛道要稳得多。
4.3 舵机抖动与响应迟钝
舵机抖动通常来自几方面:中线提取抖动、PD参数D过小或P过大、摄像头帧率太低。优先检查图像:把处理后的二值图通过蓝牙或串口发给上位机看,如果中线一会儿左一会儿右,控制输出自然就抖。图像没问题再看P和D,P过小反应慢,P过大过冲明显;D能在过冲边缘把它拉回来,但D太大会放大噪声。
舵机响应迟钝,最常见的原因是PWM频率太低或舵机供电不足。总钻风摄像头搭配的模拟舵机,PWM频率一般50Hz到300Hz都能工作,但要查舵机手册确认适合频率。供电尽量独立稳压,舵机大电流猛摆时瞬时掉电是常见顽疾。
4.4 故障速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 图像全黑 | 摄像头没初始化成功、排线接触不良、PCLK没接对 | 检查接线,用官方例程直接测试 |
| 图像全白 | 曝光过度、摄像头朝向无光照区域 | 调低曝光/增益,检查安装角度 |
| 图像有横纹 | 摄像头电源波纹大、DMA传输不稳定 | 加电容滤波,检查电源线和信号线是否捆绑过近 |
| 直线跑歪 | 中线提取偏差、转向PWM中值不准 | 校准舵机中值,检查图像安装是否倾斜 |
| 弯道切内线 | 前瞻不够远、转向D不足 | 调高摄像头仰角,提高D参数 |
| 行驶时突然猛甩 | 丢线补线没做好、中线跳变 | 检查丢线补线逻辑,加限幅 |
| 电机不转 | 电机驱动使能脚没拉高、PWM通道没初始化 | 检查驱动板供电,确认PID输出不为零 |
5. 提速迭代的进阶思路
跑通基础代码之后,如果你想在校赛拿更好的名次,还有几个性价比高的优化方向。
弯道降速和直道提速的切换。建议根据当前有效中线计算曲率估算值:中线上相邻两行的横向位移差越大,说明弯道越急,此时目标速度就应越低。这个策略不需要额外的传感器,只用已有数据,实现成本低,效果立竿见影。
再由纯PD转向扩展到“预测转向”。在校赛的90度弯和S弯中,可以保存最近几帧的中线变化历史,利用差分趋势预测下一帧的中线位置,提前打角。这个算锦上添花,校赛阶段PD调好足够用。
图像处理的底噪清理值得花时间。我在写完基础版后加了个3x3中值滤波,虽然帧率从大约180帧掉到150帧左右,但控制稳定性提升了不少。4轮车比赛,稳定比帧率更重要。
最后还有一个容易被忽略的点:代码不是调一次就完事的,比赛场地灯光明暗、赛道颜色深浅都会影响二值化效果。建议在代码里保留一个开关,方便现场快速切换“手动阈值”和“大津法”,并留两个备用阈值,以防大津法在现场突然失效。
我自己的深切感受是:摄像头循迹这玩意,赶早不赶晚。第一周把图像调出来,第二周把基本循迹跑通,第三周再开始提速度、加细节。不要第一周就想做全速度PID和动态曲率,那不现实。先把这文章里前面四章的代码跑通,让车稳稳按线走,你已经超过了半个实验室的进度了。
本文还有配套的精品资源,点击获取