机器视觉循迹小车实战:图像处理与控制调参全解析 📅 发布时间:2026/9/8 7:15:56 👁 浏览次数: 机器视觉循迹小车这个题目我前前后后折腾了大概两个月才跑得稳。中间踩过的坑、推翻重来的方案、调试到半夜才发现的问题都挺值得拿出来聊一聊。这篇文章我不讲那种“照着抄就能跑”的教程式内容而是把从需求分析、硬件选型、图像处理到PID调参的完整链路拆开来讲谈谈每个环节为什么这么做以及在真实项目里你会遇到哪些绕不开的坎。如果你正在做毕业设计、竞赛小车或者单纯想入门机器视觉在实际场景中的应用这篇文章应该能帮你省下不少弯路。1. 为什么做机器视觉循迹小车需求分析与方案对比1.1 传统循迹方案的痛点循迹小车这玩意儿最早接触的版本基本都是红外对管方案。一排红外传感器装在小车底盘前面通过检测黑白反光差异来判断赛道位置。这个方案的好处是简单、便宜、响应快51单片机就能搞定。但用下来你会发现几个很现实的问题第一红外对管的检测距离非常受限正常也就1到3厘米。这意味着小车必须贴地走稍微把传感器抬高一点点或者赛场地面反光特性不一致数据就开始不稳定。第二对管数量决定了检测分辨率一般就是5路、8路说白了就是一条离散化的粗采样。遇到急弯或者赛道交叉线判断逻辑很容易懵。第三这个方案在户外的适应性很差太阳光里的红外分量会直接干扰传感器读数。电磁循迹方案是另一个常见选择用线圈感应赛道中线埋设的交替导线信号来定位鲁棒性确实比红外强很多但它的赛前准备工作量大需要在跑道上铺设载流导线而且硬件链路包含信号调理电路、检波电路这些模拟环节调试起来门槛不低。1.2 机器视觉方案的思路与优势机器视觉循迹小车的核心思路是用摄像头替代传统的离散传感器把“看赛道”这件事变成连续的信息获取。摄像头采集到图像之后经过一系列图像处理算法提取出赛道边缘、中心线或者特征点再换算成小车相对于赛道的横向偏差和角度偏差最后交给控制算法输出转向和速度指令。这个方案的优势非常明显。首先是前瞻性大幅提升摄像头能看到车前几十厘米甚至更远的赛道信息这在高速过弯的时候至关重要。简单说红外方案是“到了弯道口才反应过来”机器视觉方案是“提前看到一个弯已经在规划怎么过了”。其次赛道信息的连续性带来了控制精度的质变你拿到的偏差是一个浮点数而不是“左偏1格”这种离散量配合PID控制跑起来那个顺滑程度完全不一样。再者摄像头方案天然具备可扩展性识别红绿灯、标志牌、障碍物都是在这个基础上叠加算法。1.3 整体技术路径选型当时我在技术选型上纠结过几个方向一是直接用OpenMV这类集成机器视觉模块二是用树莓派跑完整Linux系统三是单片机配合摄像头加独立图像处理。OpenMV的优点是集成度高、上手快MicroPython编程也算友好但它的处理器性能比较有限跑个简单的阈值分割没问题一旦涉及稍复杂的透视变换或更高级的算法帧率就掉得厉害。树莓派性能强、生态好但体积和功耗偏大而且启动时间是个问题赛场上一旦断电重启那几十秒的等待非常尴尬。我最后选的方案是单片机摄像头的组合。具体来说主控用STM32F407系列摄像头用OV2640实时图像数据经过FIFO缓冲传入单片机图像处理算法在单片机上直接跑。这样做的好处是响应实时性好系统启动快整车的体积和功耗都能控制住而且整个图像处理链路是你自己一行行代码写出来的对里面每个参数的理解会非常深。对于想真正吃透机器视觉原理的开发者来说这个方案的学习收益是最大的。2. 整套系统的硬件选型与搭建2.1 底盘与电机驱动底盘我用的是常见的四轮小车底盘两个直流减速电机加后轮驱动前轮用万向轮。你也可以用三轮结构或者麦克纳姆轮但对循迹这个任务来说两驱加万向轮的配置在成本和稳定性上是最平衡的。电机驱动芯片选的是TB6612FNG相比老牌的L298N它的内阻小、发热低而且体积小很多。这里的选型逻辑是小车的电池电压一般是7.4V或者11.1V电机额定电压通常在6到12V之间TB6612可以承受这个电压范围。驱动板的电源输入和逻辑电源需要分别处理千万别拿逻辑电源去驱动电机否则电流一旦拉高单片机立刻复位。2.2 摄像头选型的几个关键参数摄像头是整个视觉系统的眼睛选型上这几个参数直接决定后续算法的复杂度分辨率不是越高越好。我一开始用过OV5640的500万像素发现在单片机上做处理时帧率惨不忍睹。后来切到OV2640跑320x240分辨率时帧率能做到40到50帧这个分辨率对于赛道识别来说完全够用而且处理速度快留给控制算法的响应时间更充裕。帧率很关键。循迹小车速度快起来之后一帧图像对应的物理距离会很大。比如车速是2m/s30帧的摄像头平均每帧要走6.7厘米如果算法再跑个几十毫秒整个控制滞后会非常明显。所以选摄像头时不要只看像素帧率一定要重点考虑。镜头视场角需要匹配赛道宽度。视场角太小近处看不到赛道边缘视场角太大畸变严重远处信息被压缩。我用的2.8mm镜头在车前60厘米处大约能看到40厘米宽的视野配合赛道宽度25厘米刚好合适。2.3 主控平台的算力评估说到STM32F407它是一颗Cortex-M4内核、主频168MHz的芯片带FPU浮点运算单元这个浮点运算能力对于PID控制来说绰绰有余。它的DCMI接口可以接数字摄像头DMA通道可以把图像数据直接搬到内存不占用CPU核心时间。不过说实话在单片机上做图像处理计算资源始终是紧张的。一张320x240的灰度图有76800个像素每个像素要做阈值判断的话就需要7万多次比较运算。为了这个程序里所有图像处理函数的循环都要做优化能用查表法解决的绝不做复杂计算能提前计算好的查表全部预生成。这个在后面代码部分详细讲。2.4 供电与硬件连接注意事项供电是整个系统里最容易出问题但最容易被忽视的环节。电机启动瞬间的电流可以到1到2安培如果把电机电源和单片机电源混在一起电机一转单片机电压就被拉低直接重启。正确的供电方案是电池出来后先分两路。一路经过大电流稳压模块降到5V给电机驱动逻辑端和舵机供电另一路经过高精度LDO降到3.3V给单片机、摄像头、FIFO供电。注意两路的GND要单点共地这样既能保证参考电位一致又能最大限度隔离电机带来的干扰。另外摄像头的数据线和时钟线尽量短不要在洞洞板上绕太远。DCMI接口的时序对信号完整性是有一定要求的线太长或者和电机线靠太近图像上就会出现莫名其妙的条纹噪声。2.5 调试环境准备单靠串口打印调试图像处理算法是非常痛苦的因为你看到的是打印出来的坐标值想象不出画面长什么样。我搭了一个通过蓝牙模块传图的调试通道摄像头采集图像后在单片机上跑完处理流程把处理结果和中间过程的二值图通过蓝牙模块传给电脑端自定义的C#上位机上位机实时显示画面同时叠加显示识别出来的赛道中心线。这样单片机里发生的每个处理步骤你都可以在电脑上直观地看到调起参来效率高很多。3. 图像处理管线的核心实现3.1 色彩空间转换与预处理摄像头直接输出的RGB565数据对光照变化特别敏感。为了降低这层敏感性第一步是转到灰度空间。RGB转灰度的公式是Gray 0.299R 0.587G 0.114B。但在单片机上一行一行算浮点乘法不现实处理一帧要很久。这里的技巧是查表法RGB每个分量都是8位整数三个分量一共256的立方种组合这表也太大了根本存不下。所以要么直接存一个从RGB565映射到灰度的二级制查找表大小是65536个字节64KB对于F407来说完全没有压力要么先用移位近似Gray (R * 76 G * 150 B * 30) 8这个近似公式把浮点系数变成了整数乘法再移位计算量小了一个数量级效果差距肉眼几乎分辨不出来。我用的是查表法一次查询结果精确而且给后续的阈值判断打好了基础。3.2 阈值分割二值化的不同策略灰度图像素值范围是0到255赛道是白色的背景是深色的要找赛道就要把这个图像变成“是赛道/不是赛道”的0和1。最简单粗暴的方式是固定阈值二值化if (gray_pixel threshold) binary_pixel 1; else binary_pixel 0;这个方案在室内灯光稳定的环境下能用但光照稍微一变就拉胯。上午阳光斜射和中午直射时的灰度分布差异非常大。更稳的方案有大津法Otsu和自适应阈值。大津法通过最大化类间方差来自动计算分割阈值它不需要人工设定适应性比固定阈值好很多。自适应阈值则是每一个像素点和它周围一个小邻域的平均值比较对光照不均强的场景非常有效但计算量也大在单片机上跑一帧320x240的图像大约需要增加几十毫秒的时间。我在实际项目中采用了大津法。它既不需要人为调节参数计算量也在可接受范围内实测在不同光照环境下都能稳定分割出赛道区域。成本是大约3到5毫秒的处理时间对整体帧率影响不大。3.3 开闭运算的参数原理与踩坑记录二值化之后的图像不会很干净白赛道内部会有小黑点噪声赛道边缘会有毛刺赛道外也可能有零星噪点。这时就要用到形态学操作。开运算是先腐蚀再膨胀作用是去除小的白色噪点同时保持白色区域整体面积不变化太大。闭运算是先膨胀再腐蚀作用是填充白色区域内部的小黑洞。核的大小和迭代次数是两个核心参数。核太小去噪效果不明显核太大会把细赛道直接腐蚀断。我在项目中用的是3x3的核做一次开运算和一次闭运算。这里有个我踩过的坑一开始我把迭代次数设成了2结果在过急弯的时候因为弯道处的赛道在图像里呈一个很窄的斜条3x3核跑两次腐蚀之后赛道直接断了。这个教训让我意识到形态学的参数不能只看静止帧效果一定要结合动态场景去测试。最后我改用了一次开运算加一次闭运算并且把核缩小到3x3效果才稳定下来。另一个经验是开闭运算的处理顺序有讲究。如果你的噪声主要是背景中的噪点先做开运算如果噪声主要是赛道内部的空洞先做闭运算。实际赛道上两种噪声都有所以先开后闭是比较标准的做法。3.4 透视变换与局部ROI摄像头安装在小车上是有俯仰角的所以拿到的图像是透视视角。远处的赛道在图像中是一个窄窄的梯形近处的赛道则很宽。如果直接把整幅图像的赛道中心线换算成偏差远处的中心线和近处的中心线作用不同混在一起会让控制变得很奇怪。这里我做了三件事第一是裁剪ROI区域。整幅图像底部大约1/3是离车最近、最可靠的画面顶部是远处的天空或者赛场外的场景对循迹没有帮助直接裁掉既减少计算量又减少干扰。第二是做透视变换。选取图像中赛道区域的四个角点映射到一个俯视视角的矩形这样整个赛道在图像中就变成了近似等宽的平行带中心线计算变得更准确。第三是对处理完的图像从下往上扫描每一行提取每一行赛道的左右边界计算中心点然后用这些点拟合出一条赛道中心线。拟合可以采用最小二乘法直线拟合的斜率就代表了车头相对于赛道的偏角。3.5 赛道中心线提取与偏差计算中心线提取这一步我是在每一行上扫描从左右两端向中间找第一个发生黑白跳变的点分别记为left_edge和right_edge。算中心点的公式center_x (left_edge right_edge) / 2如果这一行只有一侧检测到了边界比如赛道出了画面一侧则需要特殊处理如果left_edge找到了而right_edge没找到说明赛道偏右取left_edge加上估计的赛道半宽作为中心点估计值。所有行的center_x计算完之后用线性拟合得到一条中心线。这一步我用的是最小二乘法核心公式是k (n * Σxy - Σx * Σy) / (n * Σx² - (Σx)²) b (Σy - k * Σx) / n得到斜率k和截距b之后偏差就可以这样计算yaw_error atan(k) // 航向角偏差 offset_error (b - image_width/2) / (image_width/2) // 横向位置偏差归一化到-1到1这两个偏差值在后续的PID控制里分别对应角度环和位置环的输入是整台车“大脑”的核心输入。4. 循迹控制算法设计与调参4.1 PID控制器的整体架构视觉系统给出了偏差接下来就是控制算法。传统做法是双闭环串级PID内环是角速度环转向外环是位置环横向偏差。我在实际项目中用的是简化方案方向控制用单级PID输入是综合偏差。综合偏差的公式在这里很关键steering_error offset_error * K_offset yaw_error * K_yaw这样做的好处是既考虑了车当前离开赛道中心多远又考虑了车头朝向和赛道方向的夹角。K_offset和K_yaw是权重系数需要根据实际响应调整。4.2 转向PID的调参流程转向PID采用PD控制就够了u Kp * error Kd * derivative为什么不需要积分项因为循迹系统中期望值是不断变化的赛道中心线不是在跟踪一个固定目标。如果加了积分项系统遇到连续弯道时积分量会累积导致转向过度甚至震荡。调参顺序是先把Kd设为0从小到大加Kp观察小车在直线赛道上的表现。当Kp加到小车开始出现轻微的S形摆动时回退20%左右作为基准值。然后开始加Kd观察小车在入弯时的表现Kd适中会让车头平滑地转进弯道Kd太大会让转向反应过度生硬甚至直接冲出赛道。Kd给的是“阻尼感”它的本质是预测偏差变化的趋势提前抑制过冲。4.3 速度控制和出弯策略速度控制方面我用了比较简单的分级策略。根据当前帧的赛道弯曲程度拟合直线的斜率k绝对值来判断赛道是直道还是弯道直道|k|小于阈值1全速前进我设的是PWM 70%占空比。中弯|k|在阈值1和阈值2之间巡航速度PWM 50%。急弯|k|大于阈值2减速通过PWM 30%。这个分级策略的效果上限是死的当你想要更快过弯时就需要用到更细的速度规划。最简单有效的方法是根据PID控制器的输出幅度来调整速度。PID输出越大说明转向需求越剧烈PWM占空比就按比例压低。这段代码的基本思路是这样uint16_t base_speed 70; // 占空比百分比 uint8_t speed base_speed - (uint8_t)(abs(steering_output) * 0.5f); if (speed 20) speed 20;这是非常朴素的原理车子在直线时全速冲刺在弯道时自动减速。跑下来圈速反而比恒定高速更快因为弯中不会因为失控而走更大的半径。4.4 启动与停车策略小车刚上电的时候如果直接全速跑视野里的图像因为运动模糊会变得没法处理。所以我在程序里做了一个两段式启动先以10%占空比缓慢前进等图像连续10帧都能稳定提取到赛道中心线之后再切到高速模式。停车策略则是让小车记住上次赛道消失的位置以最慢速在当前位置打转搜索超过2秒找不到赛道才完全停下。这些细节看着不显眼但在比赛场景里都是救命功能。有一次测试时小车跑出赛道直奔墙壁我手忙脚乱地拔电池从那以后我把速度控制的上下限和启动逻辑写的非常保守反正安全第一。5. 完整代码框架与联调流程5.1 主程序架构整个STM32程序按模块拆分为三个部分图像采集模块DCMIDMAFIFO、图像处理模块灰度化、二值化、形态学、特征提取和控制模块PID速度规划。主循环的结构基本如下int main(void) { // 外设初始化、摄像头初始化、PID参数初始化 Hardware_Init(); Camera_Init(); PID_Init(); while (1) { if (frame_ready_flag 1) { frame_ready_flag 0; // 运行完整图像处理管线 Image_Processing_Pipeline(); // 计算中心线和偏差 Extract_Center_Line(); Calculate_Deviation(); // 运行PID控制 Compute_PID(); Set_Motor_Speed(); } } }图像采集用DMA自动搬运处理完后在DMA传输完成中断里把frame_ready_flag置1。主循环里不断轮询这个标志位。这种方式的好处是图像采集和图像处理并行不会互相阻塞。5.2 图像处理关键函数实现与优化思路灰度化和二值化可以合并直接在像素读取时完成灰度转化存进一个临时缓冲区。二值化使用大津法大致思路是统计灰度直方图然后从阈值1到254遍历计算每个阈值下的类间方差取最大值对应的阈值作为分割阈值。核心代码如下int otsu_threshold(uint8_t *gray_data, int size) { int histogram[256] {0}; for (int i 0; i size; i) { histogram[gray_data[i]]; } int total_pixels size; long sum_total 0; for (int i 0; i 256; i) { sum_total i * histogram[i]; } double sum_back 0; int weight_back 0; double max_variance 0; int threshold 0; for (int i 0; i 256; i) { weight_back histogram[i]; if (weight_back 0) continue; int weight_fore total_pixels - weight_back; if (weight_fore 0) break; sum_back i * histogram[i]; double mean_back sum_back / weight_back; double mean_fore (sum_total - sum_back) / weight_fore; double diff mean_back - mean_fore; double variance diff * diff * weight_back * weight_fore; if (variance max_variance) { max_variance variance; threshold i; } } return threshold; }注意代码里用了累加方式而不是在每个阈值循环里重新计算权重和这样时间复杂度从O(256*n降到了O(256n对单片机的性能非常友好。形态学操作则直接对二值图像素数组操作void dilate(uint8_t *binary, int width, int height) { static uint8_t temp[307200]; // 320*240*4分配大点的缓冲区 memset(temp, 0, width * height); for (int y 1; y height - 1; y) { for (int x 1; x width - 1; x) { if (binary[y * width x] 1 || binary[(y - 1) * width x] 1 || binary[(y 1) * width x] 1 || binary[y * width (x - 1)] 1 || binary[y * width (x 1)] 1) { temp[y * width x] 1; } } } memcpy(binary, temp, width * height); }这个实现里只检查了上下左右四个邻居比检查全部8个邻居速度快很多。我试过9点全检查的方式效果差异很小但耗时增加将近一倍所以最终优化成4点。5.3 上板联调的关键流程代码写完之后联调阶段我摸索出一个顺序第一步是验证图像采集链路。在单片机上运行拍摄功能通过蓝牙传图到上位机确认画面清晰、颜色正常、没有干扰条纹。第二步是验证图像处理效果。在电脑上把同一个场景的静态图片跑一遍算法流程反复调整参数直到二值图形态完美、中心线贴合。第三步是离线上板测试。把小车架起来让轮子悬空跑一遍完整程序通过上位机观察处理后的图像和偏差值确认控制指令在实时变化。第四步是低速实测。把车放地上用10%占空比慢速跑观察在低速下的表现尤其是启动、急弯、丢失赛道等边界情况。第五步才是全速测试。一个参数一个参数地调每调一次跑一圈记录圈速和问题迭代优化。5.4 笔记本上位机的角色上位机我用了C# WinForm来做主要不是为了炫技而是因为C#做串口通信和图像显示非常快。上位机接收单片机的调试信息格式是自定义协议帧帧头加图像数据索引加各种参数值加帧尾。上位机里定时器每100毫秒刷新一次显示同时把关键数值用曲线绘制出来。这个工具让我在调PID的时候能看到误差曲线的变化形态比如过冲和震荡在曲线上一眼就能看出来比看小车跑圈快多了。6. 常见问题与排查技巧实录6.1 图像相关问题速查表现象可能原因解决方案图像出现横条纹信号线太长或供电不稳缩短DCMI接线摄像头电源加10uF电容二值图在光照变化时质量差固定阈值改用大津法赛道上出现大量空洞闭运算参数过小增大闭运算核或迭代次数形态学处理后赛道断裂开运算过强减小核或迭代次数图像右侧比左侧亮摄像头安装角度导致反光用自适应光照校正算法6.2 控制相关问题的排查小车在直线赛道上来回摆动的现象通常是因为Kp太大。直观上小车越偏回正力越猛来回打靶。这时你会发现不管怎么减Kd都压不住是因为横向偏差的变化率本身就很剧烈微分项在前馈放大时把噪声也放大了。解决办法是把目标位置做成平滑过渡不对当前偏差直接做比例控制而是对偏差做一阶低通滤波让小车对赛道位置的响应变得“迟钝”一点。小车在入弯时冲出去通常是Kd太小或者速度太快。Kd的作用是在车头还没转过来时提前减小转向力度防止转向到位后还保持过大过弯角度。调参时可以先降低车速Kd从Kp的十分之一开始往上涨观察转弯曲率是否稳定。6.3 硬件安全与维护经验STM32的ADC采集电池电压是可以做的用来做低压保护。锂电池电压低于3.6V/节时如果还继续跑电池寿命会大打折扣。我写了个低压减速逻辑低于阈值就强制降速并闪烁LED提示换电池。电机和驱动模块的发热也要注意。长时间高速运行后测试一下TB6612的温度如果太烫手就需要加装散热片或者调整PWM频率。我用的是20kHz的PWM频率比淘宝教程常用的1kHz高很多这样电机啸叫声音小而且输出更平滑。6.4 一个非常容易踩的坑图像帧率的实际测量程序里你可以用定时器做一个帧率计数器实打实地测一下在不同处理逻辑下的实际帧率。我见过很多人标称“30帧”但实际跑起来因为算法耗时太大只有15帧还找不出原因。图像处理的时间开销大头是二值化和形态学操作。如果帧率不达标优先优化这两个环节。比如二值化时不用大津法而用固定阈值形态学操作只做一个小核的行处理而不是遍历全图收益非常明显。我最后优化到320x240分辨率下单帧处理耗时约7毫秒加上采集时间实际帧率稳定在40帧左右。6.5 从循迹到进阶还能做什么基础循迹跑通之后这个平台还有很多可以加的东西。我在后续版本里加了行人检测。原理也很朴素在图像中划定特定的区域检测肤色像素的分布密度超过阈值就判定为行人触发停车避让。这就是用最简单的“特征检测”思路去接近“目标检测”的目标。后来还尝试过加入交通标志识别方法是提取ROI区域的HOG特征配合一个简单的线性分类器能识别出“停止”和“转弯”标志。在STM32上跑这个分类器大约需要20毫秒对整体帧率有一定影响但识别准确率能到90%以上。这些后续功能的本质都是在既有图像采集和图像处理管线之上叠加新的特征提取和模式识别算法。跑通了基础循迹相当于已经掌握了整个机器视觉系统的核心链路后面的扩展都是在这个基础上做增量。7. 写在最后个人经验与建议整个项目做下来我最大的感悟是机器视觉循迹小车不是一个“算法题”而是一个“系统工程题”。图像处理算法只是链条上的一环硬件是否可靠、供电是否稳定、控制参数是否匹配、调试工具是否顺手每一个环节都能决定最终能不能跑起来。如果只盯着算法而忽略其他环节大概率会在实际场地测试时栽跟头。如果你正在做类似项目我建议你从第一天开始就准备一个可靠的图像调试通道串口传图或者蓝牙传图都可以。千万别等到小车搭完了再想做调试工具那就太晚了。还有就是控制面积的代码里一定要加上最低速度限制和越界保护逻辑否则小车失控的时候你真的只能眼睁睁看着它冲墙。最后分享一个我保存至今的小技巧在摄像头的镜头前贴一片偏振片可以极大减少地面反光造成的亮度不均问题这是一个赛场上老手告诉我的实测效果立竿见影成本只要几块钱。机器视觉这条路上很多坑都可以用这类小聪明绕过去关键是动手去做踩过、改过、跑通了才是真正学会。