
做视觉巡线小车是很多嵌入式爱好者入手机器视觉的第一个完整项目。STM32负责“动”OpenMV负责“看”两者通过串口把视觉信息和运动控制串起来整套系统麻雀虽小五脏俱全。项目做完我最大的感受是视觉巡线的难点不在于某个单一模块而在于“看”和“动”的配合。OpenMV看到的偏差怎么变成电机的转速差、弯道怎么不冲出赛道、十字路口怎么不迷路这里面每一步都有讲究。这篇文章把我做这个项目的过程、代码思路、调参经历和踩过的坑完整记录下来给准备做或者正在做类似项目的朋友一个参考。1. 项目概览与整体方案选型1.1 项目目标与功能拆解先明确我要做的东西是什么。视觉巡线小车最朴素的场景就是一辆两轮差速小车车头装一个摄像头在白色底板上沿着黑色赛道自动行驶能过弯道、能走直线、能处理十字路口和断线区域。听起来简单但仔细拆解下来整个系统要解决四个问题第一是“看得到”摄像头要能稳定识别出赛道线第二是“算得准”要把赛道线的位置转换成准确的偏差数据第三是“传得快”视觉数据和运动控制之间的通信不能拖后腿第四是“控得住”电机要根据偏差实时调整转速让小车不偏不倚地跑下去。从功能模块上分这套系统由三大部分组成OpenMV视觉模块负责图像采集、赛道识别和偏差计算STM32主控模块负责接收视觉数据、运行控制算法、输出PWM驱动电机底盘动力模块包含电池、电机驱动板和两个直流减速电机。三个模块各司其职靠一条串口线和几条电源线连在一起。我选择的是最简单也最经典的两轮差速底盘方案两个驱动轮加一个万向轮控制灵活而且算法直观。1.2 核心器件选型为什么是STM32OpenMV选型这块我一开始其实纠结过一段时间。视觉模块在市面上有很多选择树莓派加摄像头、K210、OpenMV甚至用手机加蓝牙。每种方案都有各自的长处但结合“嵌入式学习”和“视觉巡线”这两个核心需求OpenMV是最合适的。原因有三点。第一OpenMV是基于MicroPython的写起来比C语言快太多。图像处理算法在OpenMV IDE里调试非常直观可以实时看画面、看阈值效果、看色块识别结果这对前期开发效率帮助巨大。相比之下树莓派虽然性能强但Linux环境的启动速度、图像处理管线的复杂度对一个巡线项目来说有点杀鸡用牛刀。K210的话性能也够但生态和资料相对OpenMV少一些。第二OpenMV自带摄像头接口、SD卡槽、LED补光灯和完整的例程库尤其是find_blobs和regression这两个图像算法函数就是为色块追踪和线段检测量身定做的不需要自己从零写图像处理算法。第三OpenMV的UART、I2C、GPIO都引出来了和STM32通信非常方便。STM32这边我用的是一块STM32F103C8T6的最小系统板。这颗芯片虽然是一颗老将但用在巡线小车上绰绰有余。72MHz主频、3个定时器、3个USART刚好覆盖PWM输出、串口通信和逻辑控制的需求。Keil5写代码、ST-Link下载调试这套开发流程在校园和工业嵌入式领域都非常通用学到的经验也具备迁移价值。1.3 系统架构与数据流解析把整体架构画出来就清晰了。传感器端是OpenMV摄像头拍摄前方地面画面每帧图像经过算法处理后计算出赛道线和画面中线的偏差值然后通过串口把偏差值按固定格式发送给STM32。STM32收到数据后做两层处理第一层是解析校验确认数据没有丢帧错位第二层是执行控制算法也就是PD/PID计算把偏差值映射成左右两个电机的转速差最终通过定时器PWM通道输出到电机驱动模块驱动电机转动。整个数据流是单向的OpenMV负责“感知”STM32负责“决策和执行”。这种架构的好处很直接——视觉部分的运算量不会占用主控的资源主控可以专心地跑控制逻辑。另一层好处是模块化程度高OpenMV那边的算法和STM32这边的控制算法可以独立开发、独立调试最后再联调通信协议。我在做的时候就是先分别调好了视觉模块和电机模块才把它们接在一起的。2. 视觉端OpenMV巡线算法与实现细节2.1 图像预处理与LAB阈值设定OpenMV上的巡线实现核心是色块追踪。黑色赛道线的RGB值几乎接近0而白色底板的RGB值比较高两者在LAB色空间里区分度很明显。我是在OpenMV IDE里打开阈值编辑器实时调节L通道的上下限把画面里赛道线单独抠出来。阈值这块看着简单其实有个最容易被忽略的问题光照。OpenMV的镜头有一个自动曝光和自动白平衡机制环境光忽亮忽暗的时候图像亮度会自动调整这会导致原本调好的阈值在某些时刻失效。我的处理方式是把auto_exposure和auto_whitebal这两个参数固定住手动设置曝光时间和增益值。在正常室内灯光下我最后用的阈值大概是(20, 60, -20, 20, -20, 20)这样的一个范围但每个人实际环境不同还是要在现场重新取。这里有个小技巧调阈值的时候不要只看一帧画面要拿着小车在赛道的不同位置、不同角度多动一动观察日照变化、反光对色块提取的影响。图像分辨率我设置成QQVGA也就是160x120。为什么不用更高的分辨率因为巡线小车只需要知道赛道线的横向位置偏差不需要看清赛道纹理。分辨率每提高一倍图像处理的耗时大约会增加一倍帧率就直接腰斩。160x120在OpenMV上跑find_blobs帧率可以稳定在50FPS左右这个刷新率对电机控制来说已经绰绰有余了。2.2 偏差值计算的两种方法对比OpenMV官方例程里提供了两种巡线思路一种是find_blobs找色块取色块的中心X坐标作为赛道位置另一种是find_lines或regression直线回归取回归直线的中心位置。这两种我都试过各自有适用的场景。find_blobs的思路是把黑色赛道线看成一个连通的色块然后取这个色块的外接矩形矩形的中心点就是赛道位置。这种方法在赛道线比较宽、比较连续的时候效果很好计算快稳定性高。但对于弯道比较大的情况色块的中心可能偏向弯道内侧导致小车转向不足。regression直线回归的思路是提取所有黑色像素点用最小二乘法拟合出一条直线直线的方向和位置代表赛道方向。它的优势在于弯道预判——回归直线带有角度信息OpenMV甚至可以直接返回直线的角度和中心坐标。我最后采用的是“色块提取回归直线”的组合先用find_blobs找到赛道线区域再在区域内做回归这样既保留了色块的稳定性又拿到了角度信息作为转向参考。不过为了控制逻辑简单最终发给STM32的核心数据依然是“偏差值”。2.3 串口数据打包与发送格式设计OpenMV算出了偏差接下来就是把它打包发出去。串口通信的格式我折腾过好几版最初图省事直接发ASCII字符串像dev:25\n这样。这样做的优点是调试方便串口助手直接能看明白内容。但问题也很明显发送效率低一个整型偏差值要占用好几个字节而且STM32那边解析字符串需要处理字符到数字的转换稍微麻烦一些。后面我改成了二进制报文格式。一帧数据由5个字节组成帧头0xAA、0x55接着是数据字节偏差值第五个字节是校验和。整个发送逻辑用MicroPython写起来非常简洁关键代码如下import ustruct from pyb import UART uart UART(3, 115200) # 每一帧帧头0xAA 0x55 偏差值1字节 校验1字节 def send_deviation(dev): dev max(-128, min(127, int(dev))) buf bytearray([0xAA, 0x55, dev 0xFF, (0xAA 0x55 dev) 0xFF]) uart.write(buf)把偏差值限制在-128到127之间是为了方便用一个字节表示。校验和用来在STM32端验证数据正确性防止通信干扰导致的错误帧。这个协议虽然简单但实际用下来非常可靠。3. STM32端控制核心从串口解析到PWM输出3.1 串口接收与解析的中断处理STM32端的核心任务是实时、准确地拿到OpenMV发来的偏差值。我使用的是USART1配置为115200波特率、8位数据位、1位停止位无校验。接收方式用的是串口中断加状态机解析。为什么不用轮询方式因为主循环里还要跑PID算法和PWM更新如果串口数据到了但循环没有及时去读数据就会覆盖丢失。用中断接收的好处是数据一到就立刻存入环形缓冲区主循环只需要从缓冲区里取数据解析即可两边互不干扰。解析状态机也比较经典核心逻辑是先找帧头0xAA 0x55找到后接收第3个字节作为偏差值再接收第4个字节作为校验值最后判断校验和是否正确。说得直白一点就是“先对暗号再收货”。我贴一段简化后的C代码uint8_t rx_buffer[4]; uint8_t rx_index 0; int16_t deviation 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t data USART_ReceiveData(USART1); if (rx_index 0 data ! 0xAA) return; // 等待帧头 rx_buffer[rx_index] data; if (rx_index 4) { if ((rx_buffer[0] rx_buffer[1] rx_buffer[2]) rx_buffer[3]) { deviation (int8_t)rx_buffer[2]; } rx_index 0; } } }这段代码的核心是“非0xAA的数据直接丢弃”这种做法能快速跳出乱帧重新对齐数据流。如果校验失败直接把索引清零重新开始找帧头不会卡死在半截数据上。3.2 定时器PWM配置与电机驱动细节STM32驱动直流电机本质上是输出PWM信号控制电机两端电压的平均值从而控制转速。我用TIM2的两个通道输出PWM分别控制左右两个电机的PWM输入脚。还有一个方向引脚用普通GPIO控制因为直流减速电机驱动模块通常需要两路信号一路PWM调速一路高低电平换向。电机驱动模块我选的是TB6612FNG相比L298N它体积小、压降小、发热低而且逻辑电平兼容3.3V可以直接和STM32对接不需要额外的电平转换。PWM频率我设置为10kHz这个频率高于人耳听觉范围不会听到电机高频啸叫同时对于直流电机的电感特性来说也能获得比较平滑的电流波形。关键的接线逻辑大致是这样TIM2_CH1也就是PA0引脚输出左电机PWMTIM2_CH2也就是PA1引脚输出右电机PWMPB0和PB1控制左电机方向PB10和PB11控制右电机方向。电源方面电池7.4V直接接TB6612的VM引脚它内部稳压后给逻辑部分供电STM32那边单独用一个AMS1117-3.3模块降压供电。别忘了全部模块必须共地这个问题我在调试初期踩过一次后面会详细说。3.3 两轮差速模型与转向控制逻辑小车转弯靠的是左右轮速不一样这就是两轮差速的核心思想。差速模型写起来很直白一个基础速度base_speed负责让小车前进偏差值再叠加到两个轮子上产生差速。我的映射规则是偏差为正说明赛道线在中线的右侧小车需要向右转这时右轮减速、左轮加速偏差为负则相反左轮减速、右轮加速。在代码里体现为int16_t speed_left base_speed - kp * deviation; int16_t speed_right base_speed kp * deviation;这个公式是纯比例控制如果kp设置不合适小车要么反应迟钝要么左右剧烈震荡。实际使用中我很快就加了微分项也就是PD控制用来抑制转向过冲这个我们下一节详细展开。差速模型本身也有限制如果偏差值太大比如赛道线几乎在画面边缘差速导致一侧电机反转小车会原地打转。为了避免这种情况我给左右电机的PWM值分别做了限幅保持在最大值为1000、最小值为50归一化占空比对应值的范围内。4. PID控制器设计与现场调参实战4.1 为什么巡线小车离不开PID从纯比例控制起步当我第一次把kp调大想让小车反应更灵敏时发现小车在直道上开始左右画龙高速时甚至摇摆幅度越来越大直到冲出赛道。原因很简单比例控制只根据当前偏差输出纠偏量没考虑小车的惯性。小车以一定速度向前冲你给它一个瞬间的大转向它会因为惯性冲过头超过中线后又反向纠偏结果就是来回震荡。这就是PID控制中“D”项要解决的问题。D项根据偏差的变化率来输出一个相反方向的修正力相当于一个阻尼器。偏差变化越快阻尼力越大从而抑制振荡。对于巡线小车这种典型的“一阶惯性延迟”系统PD控制通常就够用了。I项积分一般可以不给因为巡线任务本身是持续动态的目标线一直在变过大的I项反而容易引起超调。4.2 PD控制器代码实现与参数调优我最后实现的PD控制算法如下#define BASE_SPEED 400 #define KP 32 #define KD 18 float last_deviation 0; void control_loop(void) { if (rx_updated) { float P KP * deviation; float D KD * (deviation - last_deviation); int16_t output (int16_t)(P D); // 限幅 if (output 300) output 300; if (output -300) output -300; int16_t speed_left BASE_SPEED - output; int16_t speed_right BASE_SPEED output; set_motor_speed(LEFT, speed_left); set_motor_speed(RIGHT, speed_right); last_deviation deviation; } }参数整定我采用了一个比较实用的流程先把KD设成0只用KP从小到大慢慢加直到小车能在直道上基本稳定行走但又开始出现轻微摆动时记下这个KP值然后逐渐增加KD观察摆动是否被抑制住。我的经验是KP大约在30左右KD在15到20之间小车就能跑得比较稳了。当然不同电机的响应速度、不同底盘的摩擦力都会影响这个数值建议在自己的平台上重新整定。另外BASE_SPEED也不要一开始就给满我调试时先给300跑顺了再逐步提到400、500。4.3 转向延迟与丢线处理策略调试过程中最烦的一个问题是小车在连续弯道中OpenMV偶尔会一瞬间丢失赛道线。摄像头画面对着一片白色find_blobs找不到任何色块。如果这时直接发一个偏差0给STM32小车会以为自己在直线按基础速度猛冲结果就是冲出赛道。针对这个问题我在OpenMV端加了一个“丢线保持”逻辑如果连续几帧都找不到色块就根据上一次的偏差方向给出一个修正值让OpenMV主动朝丢失方向搜索。同时STM32端也做了类似的保护如果串口数据超过200毫秒没更新就判定为通信超时电机直接停止防止小车失控。丢线处理这块还有另一种思路就是OpenMV把图像搜索区域改大或者把阈值调宽一些让系统对轻微反光、阴影变化不那么敏感但这样做有可能把赛道附近的杂物也识别成赛道线属于“饮鸩止渴”。我最后选择的是“丢线保持超时停车”的双保险策略。5. 硬件设计与接线避坑指南5.1 电源系统分配与稳压方案电源是整个项目里最容易被低估、也最容易出问题的一环。我的小车使用2节18650锂电池串联标称电压7.4V。这个电压直接给电机驱动模块供电是没问题的但不能直接给STM32供电——STM32的额定电压是3.3V。我用了两条降压路径7.4V经过一个LM2596降压模块降到5V再通过AMS1117-3.3降到3.3V给STM32供电。OpenMV则直接使用5V供电。这里有一个非常关键的经验电机是系统里最大的干扰源。电机启动和堵转瞬间电流变化剧烈会在电源线上产生很大的纹波噪声。如果直接用同一个5V给OpenMV和STM32供电图像处理时OpenMV的模拟电路很容易被干扰表现为画面闪烁、阈值漂移甚至花屏。解决办法是让电机电源和逻辑电源在物理上分开走线或者在主电源处加一个大容量的电解电容和一个高频陶瓷电容做滤波。我实测下来在电池两端并联一个470uF电解电容和0.1uF陶瓷电容后干扰问题基本消失。5.2 电机驱动接线与方向电平逻辑TB6612的接线逻辑并不复杂但有一个特别容易出错的点AIN1、AIN2和PWMA这三个引脚决定了一个电机的全部行为。AIN1和AIN2控制方向PWMA控制速度这三者必须配合使用。比如要让左电机正转需要设置AIN11、AIN20然后给PWMA一个PWM占空比要让左电机反转则设置AIN10、AIN21。如果两个方向引脚都是0或者都是1电机就会停止刹车或滑行取决于驱动芯片内部逻辑。我在代码里封装了这样一个函数void set_motor_speed(uint8_t motor, int16_t speed) { if (speed 0) { // 正转 if (motor LEFT) { GPIO_WriteBit(GPIOB, GPIO_Pin_0, Bit_SET); GPIO_WriteBit(GPIOB, GPIO_Pin_1, Bit_RESET); TIM_SetCompare1(TIM2, speed); } else { GPIO_WriteBit(GPIOB, GPIO_Pin_10, Bit_SET); GPIO_WriteBit(GPIOB, GPIO_Pin_11, Bit_RESET); TIM_SetCompare2(TIM2, speed); } } else { // 反转 speed -speed; // 对应的方向引脚反过来设置 } }注意这里如果speed给了一个负值不仅要反转方向还要对速度值取绝对值否则TIM_SetCompare收到负数会导致PWM输出异常。我在调试时因为这个问题折腾了很久小车一会儿往前冲一会儿突然停住后来才发现是负值没有取绝对值。5.3 摄像头安装角度与镜头调焦摄像头安装的位置和角度对巡线效果影响非常大。我一开始把OpenMV固定在底盘正前方镜头几乎水平朝向正前方结果发现OpenMV看到的是远处的赛道而不是车正前方的赛道巡线响应非常迟钝弯道处总是“看到得太晚”。后来把安装支架抬高让镜头向下倾斜大约45度这样画面上看到的区域就是车前方20到40厘米左右的一段赛道线。需要注意另一个细节是镜头调焦。OpenMV标配的镜头是可变焦的出厂时通常对焦在较远的距离。如果安装高度只有十几厘米看到的图像有可能是模糊的。我的解决办法是把小车放在赛道上OpenMV连接到IDE打开实时画面看赛道线是否清晰然后用手慢慢旋转镜头底座直到画面中的赛道线边缘出现清晰的锐利边界。这个过程需要耐心最好用一个小把手去旋镜头不然手指容易弄脏镜片。6. 常见问题排查与调测经验汇总6.1 串口通信乱码和数据错位的根治方法串口乱码是联调阶段最容易遇到的问题。我遇到过的乱码原因包括波特率不一致、收发引脚接反、没有共地、干扰导致数据帧错位。逐一排查的思路是这样的先用USB转TTL模块把OpenMV连接到电脑用串口助手查看OpenMV发的原始数据确认OpenMV端数据格式正确然后再把STM32端的串口通过另一个USB转TTL模块也接到电脑由电脑模拟OpenMV发送数据确认STM32端解析正确。两边分开测试都通过之后再把两个设备对接。这个“分而治之”的排查方法帮我快速定位到问题出在那一端。对于共地问题有个很简单直观的判断方法如果OpenMV和STM32各自单独接电源时通信正常但一起供电后通信就乱大概率是共地问题。解决办法就是在两个模块的GND引脚之间额外接一根粗导线。6.2 小车左右画龙与震荡的分析小车的“画龙”现象我之前提到过是PD参数配合不好的典型症状。但“左右震荡”这个症状其实还得分几种情况来讨论。第一种是P过大引起的持续震荡特点是小幅高频的左右摆动解决方法是降低KP第二种是D过小引起的过冲型震荡特点是振幅较大且逐渐衰减解决方法是增大KD第三种是机械结构问题比如两个驱动轮转速不完全一致、万向轮摩擦力过大、底盘重心不稳这种情况即使PID参数调得再好也没用。我在调车时发现把万向轮换成一颗更顺滑的滚珠后直道稳定性提升了一个档次这个细节很容易被忽略。6.3 OpenMV识别不稳定的几个隐藏原因OpenMV识别不稳定并不总是算法的问题。我总结出了三个隐藏原因都在实际项目中遇到过。第一是镜头焦距松了小车跑着跑着画面变模糊重新对焦就好。第二是OpenMV内部的LDO稳压芯片过热长时间运行后图像出现条纹这和环境温度以及供电电压都有关系我的解决方法是给OpenMV的散热片贴了一片小的铝散热片。第三是最隐蔽的LED补光灯引起的反光。如果OpenMV自带的白色LED补光灯亮度太高在某些光洁的赛道表面会产生反光把原本黑色的赛道线照成浅色导致阈值提取失败。后来我在OpenMV代码里直接把LED(2).off()关掉了补光灯改用室内自然光识别反而更稳定。6.4 电机响应迟滞与PWM频率调整还有一个容易被忽略的是PWM频率和电机响应速度的关系。早期我把PWM频率配置成200Hz电机在低占空比时会出现明显的“咔咔”声和步进式的转动这是PWM频率过低导致的电流不连续。提高到10kHz后电机运行平滑多了。实际上直流电机的PWM频率一般推荐在10kHz到20kHz之间太低了噪声大太高了驱动芯片的开关损耗会增加。我最终用的是10kHz实测下来是最平衡的。另外电机驱动板的使能引脚也要检查。TB6612的STBY引脚如果接低电平整个芯片就会进入待机状态电机完全不动。我的代码里初始化时就直接把STBY拉高到3.3V否则会陷入“PWM明明有输出但电机就是不动”的假象中。6.5 常见问题速查表现象可能原因排查与解决方法串口收到乱码波特率不一致 / 收发接反 / 未共地分端排查统一波特率GND相连小车左右画龙KP过大 / KD过小 / 机械不对称降低KP增大KD检查轮子与万向轮OpenMV画面花屏供电不稳 / 镜头松动改善电源滤波重新对焦并固定镜头电机不转使能脚未拉高 / PWM频率过低 / 方向引脚错误拉高STBY提高PWM频率检查方向逻辑小车丢线冲出赛道丢线处理缺失 / 弯道太急增加丢线保持逻辑降低BASE_SPEEDOpenMV帧率低分辨率太高 / 自动曝光耗时降到QQVGA固定曝光和增益舵机或电机死机电源噪声干扰逻辑电路加电解电容滤波动力与逻辑电源分开走线7. 项目调试心得与可扩展方向整个项目做下来我最深的体会是嵌入式项目里“稳定性”比“花哨”重要得多。代码写得再漂亮、算法看起来再高级如果小车跑三圈就冲出赛道一次那依然不能算一个合格的项目。所以我把大量时间花在了数据校验、超时保护、丢线处理和电源滤波这些“看不见”的地方。这些细节不直接体现在功能上但恰恰决定了整个系统能不能长时间稳定运行。另外一点感触是调参要有方法论。不要靠“瞎猜参数-跑一下-再看效果”的循环那是碰运气。先让每个模块独立工作正常再联调调PID时先P后D每次只改一个变量记录每一组参数对应的小车表现形成数据表你才能逐渐找到规律。这个方法不仅适用于巡线小车往后的飞控、小车、机械臂项目我都沿用了同一套流程。这个项目的可扩展空间其实很大。比如在OpenMV端加入对赛道分支的判断可以实现巡线小车在三岔路口自主转向在STM32端加入蓝牙模块或者无线模块可以把遥测数据发到电脑上画实时曲线再加上光电编码器测速把开环控制升级成闭环PID速度控制小车过弯的稳定性还会有明显提升。每一个方向都足够展开做一个新的项目但基础的“视觉主控驱动”这套架构已经通过这个巡线小车全部打通了。