简介:一套基于STM32与OpenMV的视觉巡线小车完整工程源码,面向智能车竞赛、课程设计及嵌入式视觉初学者,解决从底层电机驱动、编码器测速到图像识别与PID闭环控制的整套实现问题。工程涵盖直流减速电机TB6612驱动、定时器PWM/正交编码/中断、串口通信、OpenMV图像二值化与线性回归,以及速度环、转向环和串级PID算法,并配有串口数据解析代码。支持二次开发:STM32CubeMX生成的Keil工程可重新配置外设,OpenMV端代码可自行调整,另附配置调试流程,整体采用模块化设计,电机驱动、编码器反馈、定时器接口和图像算法均可独立修改,便于应对不同赛道条件。包体共一千零一十七个文件,其中C/C++源文件八百一十三个,另有汇编启动文件、Keil工程配置、CubeMX配置、编译产物、数学库及Python脚本等,压缩包30.71MB,结构清晰。目前已有两千二百七十六人学习下载,适合希望完整参考巡线小车工程并在此基础上二次开发的开发者。 直接说结论:视觉巡线小车这个项目,属于典型的“老选题、新做法”。很多人在课设、电赛、毕业设计里都绕过它,但真正能把“视觉”和“控制”打通、做成一套完整工程的,其实不多。大部分作品要么是OpenMV跟着线走但车身抖动明显,要么是STM32控制很稳但全靠灰度传感器贴地跑,根本谈不上视觉前瞻。而我这次要讲的这套工程,核心思路是:OpenMV负责“看路”,STM32负责“走路”,两者通过串口通信高效协作,最终实现了在复杂赛道上的稳定循迹。
这篇博文适合正在做智能车课设的本科生、准备电赛的嵌入式爱好者,以及所有想搞明白“OpenMV和STM32到底怎么配合”的开发者。我先把完整工程的框架、图像端处理逻辑、控制端PID闭环、通信协议设计依次拆开讲,最后把我在调试中踩过的几个大坑一并列出。相信看完你就能照着搭出一台能稳定跑完赛道的小车。
1. 项目整体设计与方案选型
1.1 为什么是OpenMV+STM32,而不是其他组合
视觉巡线说起来简单,选型却是第一道坎。常见的方案有这么几档:纯STM32加灰度传感器,成本最低但只能贴地巡线,遇到陡坡、反光、复杂底色基本白给;树莓派加摄像头加STM32,性能强但体积大、启动慢、供电复杂,做课设有点杀鸡用牛刀;K210这类AI芯片方案性价比高但资料少、上手门槛高;最后就是OpenMV加STM32这个组合——硬件成本适中、资料极其丰富、MicroPython开发效率高,STM32这边又有着庞大的生态,两者通过串口通信配合,正好能覆盖“视觉识别+运动控制”的全部需求。
我最终选这个组合还有一个现实原因:OpenMV本身也自带电机驱动引脚,理论上可以独立完成巡线,但它的IO翻转速度、PWM分辨率和实时性远不如STM32,车速一快就容易失控。把底层控制交给STM32,OpenMV只干它最擅长的事——图像分析,这样任务划分清晰、调试效率高,后期想扩展超声波避障、蓝牙遥控也方便。
1.2 系统架构与数据流设计
整个系统的数据流是一套典型的“感知-决策-执行”闭环。OpenMV摄像头采集图像后,在内部完成二值化、寻找色块、计算偏差值,然后通过UART串口把偏差值和赛道状态发送给STM32;STM32作为主控核心,接收数据后运行PID控制器,计算左右电机的PWM占空比,通过TB6612电机驱动模块控制两路直流减速电机,同时配合编码器完成速度闭环。
这里要特别强调一点:很多新手会把视觉处理和运动控制串行执行,也就是STM32先等OpenMV发完数据再算PID,这样每一帧的间隔会被拉长,小车跑快了就会一顿一顿的。我在设计时让OpenMV以固定帧率(约30fps)持续发送数据,STM32的定时器中断则独立触发PID运算,两者异步工作、用数据帧里的帧头做同步标记,这样即便OpenMV偶尔丢一帧,控制端也不会产生明显抖动,实测下来稳定性提升非常明显。
1.3 硬件清单与选型理由
按照简洁可复现的原则,我用的都是常见且便宜的模块。核心清单如下:
| 模块 | 型号/规格 | 作用 | 选型理由 |
|---|---|---|---|
| 主控 | STM32F103C8T6(蓝丸核心板) | 运动控制、PID运算、通信 | 资料多、够用、价格低 |
| 视觉 | OpenMV4 H7 Plus / OpenMV Cam | 图像采集与处理 | Python编程、API成熟 |
| 电机 | 带霍尔编码器的N20减速电机×2 | 驱动车轮并反馈转速 | 体积小、自带编码器方便闭环 |
| 驱动 | TB6612FNG | 电机驱动与调速 | 压降小、带死区保护,比L298N轻便 |
| 底盘 | 两轮差速小车底盘+万向轮 | 承载与转向 | 结构简单,差速转向灵活 |
| 电源 | 7.4V/18650×2 锂电池 | 整车供电 | 电压适配电机,经降压给MCU供电 |
| 通信 | UART串口(TTL电平) | OpenMV与STM32通信 | 实现简单、稳定可靠 |
关于N20电机多说一句:如果你预算充足,可以换带AB相编码器的版本,做速度闭环会更顺滑;但哪怕只有单相编码器,配合测频法也能满足巡线需求,只是加速时的顿挫感会明显一点。
2. 图像端核心实现:OpenMV怎么“看懂”赛道
2.1 图像预处理与色块识别
OpenMV的巡线逻辑说穿了并不神秘,核心就是找色块。因为我采用的是浅色背景(如白纸或浅灰地面)配黑色引导线,所以步骤非常明确:先把RGB图像转为灰度图,再设定一个合适的灰度阈值进行二值化,黑色线变成白色(或反过来),背景变成纯黑或纯白,这样图像就只剩下两种像素值了。
然后我会用OpenMV自带的滤镜功能来去除噪点——这一步很多教程不会提,但实际非常关键。如果不用中值滤波或开运算,画面里那些细小的反光点、灰尘、线头都会在二值化后变成孤立的白色噪点,直接影响后续寻线算法对车道线的判断。代码层面可以用img.erode(1)或img.dilate(1)做形态学处理,我实测下来,经过一次腐蚀加一次膨胀,噪点基本都能消除干净。
关键代码片段是这样的(MicroPython):
import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 灰度图处理更快 sensor.set_framesize(sensor.QQVGA) # 160x120,够用且帧率高 sensor.skip_frames(30) sensor.set_auto_gain(False) # 关闭自动增益,防止亮度抖动 sensor.set_auto_whitebal(False) # 灰度阈值可根据实际环境用IDE调整 BLACK_THRESHOLD = (0, 45) # 黑色引导线阈值 while True: img = sensor.snapshot() img.erode(1) # 去噪处理 blobs = img.find_blobs([BLACK_THRESHOLD], pixels_threshold=100) # 后续处理...这里有个细节值得注意:pixels_threshold参数我建议根据自己的实际图像尺寸调整。如果设得太小,半条噪点线都会被误判成目标;设得太大,线窄的地方或弯道拐角处的色块会被整体过滤掉。对于160×120的分辨率,经验值是80~150之间,你可以用OpenMV IDE的“阈值编辑器”边调边看效果。
2.2 偏差计算与赛场状态判断
光找到色块还不够,还得算清楚小车当前相对于线的偏移量。这一步我用的方法是:找出图像中最大的目标色块,然后计算它的质心横坐标blob.cx()与图像中心横坐标160/2=80的差值,这个差值就是“横向偏差”。偏差范围大致在-80到+80之间,正值表示线偏右,负值表示线偏左,发给STM32后做PID运算即可。
但只有偏差值还不够,赛道总有十字路口、直角弯甚至断路,所以我额外设计了状态标志位。判断逻辑如下:如果目标色块的宽度占据画面宽度的80%以上,说明小车正骑在长直线上或处于十字路口,此时偏差值趋向于0,应该让小车保持直行;如果色块面积突然变得很小且位置靠近图像边缘,很可能进入了急转弯,需要加大转向力度。
这样我把发送数据设计成了一帧自定义格式:帧头(0xAA)+ 偏差值(有符号整数)+ 状态标志(1字节)+ 帧尾(0x55)。STM32接收到后再按协议解析,逻辑清晰、扩展性好。我用OpenMV的uart.write发送,格式大概是这样的:
def send_data(deviation, state): data = bytearray([0xAA, deviation & 0xFF, state, 0x55]) uart.write(data)2.3 不同光照环境下的阈值适配经验
在这一节必须说一个OpenMV最常见的坑:光照一变,阈值就废。教室上午下午光线完全不同,色温一变,同一个黑色线的灰度值可能从40变成60,你原来写的(0, 45)阈值直接失效。处理这个问题我用了两种办法。
第一个办法是关闭自动增益和自动白平衡后再做固定阈值,这是最直接的做法,配合固定的补光灯(如OpenMV板上自带的LED)能大幅降低环境干扰,实测下来从窗户边挪到室内中央都基本稳定。
第二个办法是写程序时做动态阈值自适应。大概思路是:开机时先拍一帧,用img.get_statistics()统计整幅图像灰度极值,再根据“背景灰度均值”动态偏移出目标阈值。这样做虽然理论上有风险(如果画面里正好没有线,统计结果会异常),但加上一个“等待2秒、让小车先上赛道”的初始化逻辑后,实际效果非常棒,基本不需要手动改参数。
3. 控制端核心实现:STM32怎么“走稳”赛道
3.1 电机控制与PWM调速基础
STM32这边首先要解决的是电机驱动。N20电机自带编码器,但驱动依然要靠TB6612模块——它的逻辑很简单:AIN1/AIN2控制左电机正反转,PWMA引脚输入PWM信号控制左电机速度;右侧同理。我用的STM32F103C8T6定时器资源充足,设置TIM2产生两路PWM(通道1和通道2)来分别调速,频率设为10kHz,这样电机运行起来几乎没有啸叫声,低于人耳可闻范围。
初始化PWM的代码我以标准库为例(很多人用HAL库,思路一致,只是函数名不同):
void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 7199; // 10kHz TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC2Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); }需要特别提醒的是一个新手常犯的错误:STM32F103C8T6的PA0和PA1默认是JTAG调试引脚的一部分,如果你不用这两脚做PWM输出,代码能正常跑;但一旦把它们配置为复用推挽输出,就会发现JTAG被禁用,下载器可能连不上芯片。解决方法是先调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)禁用JTAG,只保留SWD调试口,就能正常烧录和调试了。
3.2 编码器测速与速度闭环
光有PWM调速只能做开环控制,载重变化或电池电压下降时车速会明显波动,所以必须引入编码器测速。N20电机的霍尔编码器一般输出两路方波信号(A相和B相),通过判断相位差还能得到旋转方向。我的接法是:左电机编码器接STM32的TIM4的通道1/2,右电机接TIM3的通道1/2,都配置为编码器模式。
编码器模式的初始化(标准库)核心配置如下:
void Encoder_Init_TIM4(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM4, ENABLE); }测速的思路是:每隔固定时间(比如20ms)读取一次定时器计数器的值,减去上一次的值,得到这段时间的脉冲增量,再除以时间就得到速度。这里要注意TIM的计数器是16位的,持续累加会溢出,所以每次读完要立即清零或记录上次值做差值计算。我自己用的是差值法,简单可靠。
在得到真实车速后,我给左右电机各加了一个增量式PID控制器,目标速度由上层视觉偏差决定。例如:线在左边,就让左轮降速、右轮升速,小车自然左转。PID参数初值我建议P从0.5开始调,I设0.01,D设0,先跑直线看稳定性,再逐步加D抑制超调。这个调参顺序很重要,我见过太多人一上来就抄网上的PID参数,结果车疯狂振荡,最后还怪硬件有问题。
3.3 两轮差速转向模型与转弯逻辑
差速转向的原理很简单:左右轮速度不同,小车就会画弧线转向。具体到巡线场景,我采用的是“基础速度+修正量”的策略:设定一个基础速度base_speed(比如每20ms计数200),然后根据视觉偏差计算修正量correction,左轮速度 =base_speed - correction,右轮速度 =base_speed + correction。
这样设计的好处是直道时修正量接近0,小车匀速直行;弯道时修正量自动增大,差速加大、转弯半径减小。为了不让小车在急弯处直接冲出去,我还设置了修正量的上限(比如不超过base_speed的60%),保证转弯时内侧轮不会反转,避免出现原地打转的狼狈场面。
十字路口的处理则用到了前面提到的状态标志。当检测到“直行状态”时,我会让小车在接下来几百毫秒内忽略偏差、强制以相等速度直行,防止它在十字处因为两条线交叠而左右摇摆。这套逻辑虽然朴素,但实测下来非常管用,稳定性比单纯跑PID好很多。
4. 通信协议与系统联调
4.1 UART通信协议设计:从裸数据到完整帧
OpenMV和STM32之间如果直接发一个裸的偏差值,虽然简单,但实际使用中极容易出问题。比如万一数据线接触不良产生了一个乱码字节,STM32会把这个乱码当成偏差值执行,小车瞬间乱打方向。所以我的协议设计为:一帧数据固定4字节,包括帧头、偏差值、状态标志、帧尾,STM32只有在完整收到帧头和帧尾时才认为这一帧有效。
具体格式:AA+deviation(1字节,有符号)- +state(1字节) +55。因为偏差范围是-80到+80,用有符号数表示完全够用。STM32侧用串口接收中断加状态机解析,伪代码如下:
uint8_t rx_buffer[4]; uint8_t rx_index = 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[3] == 0x55) { int8_t deviation = (int8_t)rx_buffer[1]; uint8_t state = rx_buffer[2]; // 更新全局变量供PID任务使用 } rx_index = 0; } } }串口参数我是这样配的:波特率115200、8位数据、无校验、1位停止位。115200对于160×120图像中一个色块算出的几十字节数据来说完全足够,而且波特率太高在杜邦线下容易受干扰,没必要冒险。如果你用无线模块传数据,建议降到57600降低误码率。
4.2 联调步骤:先静态后动态
联调是整个项目里最容易让人崩溃的阶段,很多问题单独看都没事,一组合就出幺蛾子。我的经验是严格按“三步走”来:
第一步是静态联调。把小车架空(轮子离地),给OpenMV和STM32都上电,用OpenMV IDE的串口终端直接观察有没有数据输出,同时用ST-Link的调试模式看STM32的接收变量有没有在更新。这一步能完全排除硬件接线的低级错误。
第二步是半动态联调。用手拿着OpenMV在小车上方模拟从线的一侧移到另一侧,同时观察STM32收到的偏差值和PID输出值是否按照预期方向变化。比如OpenMV左移时,偏差应为负,左轮应减速。如果方向反了,先检查电机接线是否交叉,再检查偏差正负号是否取反。
第三步才让车下地跑。下地前一定要把车放在赛道直道段,先跑一小段距离看是否直线,再逐步提高基础速度。我前几次下地时车子直接冲出赛道,查到最后竟然是左右轮的PWM极性没对齐,一个轮子的速度随占空比增大而增大,另一个轮子则相反,两轮一较劲车就往一边拐,这类问题在架空测试时很难发现,因为轮子没有负载受力。
4.3 系统供电与抗干扰处理
最后一点联调经验是关于供电的。我之前犯过一个低级错误:直接把给STM32供电的3.3V和给OpenMV供电的5V用同一个稳压模块输出,导致电机启动瞬间电压被拉低,STM32频繁复位。后来改成独立双路供电:7.4V电池直接进TB6612的VM引脚给电机供电,同时从电池引一路到5V稳压降压模块给OpenMV和STM32供电,共地处理但各路功率独立,问题立刻解决。
另外,电机是巨大的电磁干扰源。如果发现串口偶尔收到乱码、OpenMV画面出现水波纹,大概率是电机电刷产生的干扰串进了电源或信号线。我的处理办法是三件套:一是在电机两端并联104陶瓷电容吸收尖峰;二是所有信号线尽量远离电机线,实在避不开就交叉走线而不是平行走线;三是给STM32和OpenMV的通信线用短杜邦线或者屏蔽线,实测效果立竿见影。
5. 常见问题与排查技巧实录
5.1 小车走不直或画龙
这是巡线小车最经典的故障。如果你地面是水平的,确认了PID没问题,那大概率是机械或硬件不对称导致的。我排查顺序是:先看两个轮子是否都牢固连接电机轴,有没有打滑;再看左右两个编码器是否都能正常读数,如果一边码盘松动或者霍尔元件脱落,速度反馈就会失真,导致闭环控制误判;最后看电池电压,如果两节18650电压不一致,电机获得的实际电压就会差异很大。
如果这些都正常但依然画龙,那问题可能出在PID参数上。我给出的经验值只能作为起点,最终还是要在实车上调参。一个简单有效的判断标准是:如果车快速左右摆动,说明P过大或D过小;如果车偏离中线后很久才修正回来,说明P过小或I不足;如果车过弯后有明显回摆振荡,可能D过大。建议每次只改一个参数,改完原地观察两秒再决定下一步,别一口气把三个参数全改了,否则你完全不知道是谁的锅。
5.2 OpenMV画面卡顿或延迟严重
画面卡顿会直接导致小车反应慢半拍。首先检查分辨率设置,如果你用了VGA(640×480)分辨率,OpenMV的处理速度会大幅下降,帧率可能只剩个位数,这时候就算控制算法再好也白搭。巡线这种应用,QQVGA(160×120)配合合适的镜头视角已经绰绰有余,没必要追求高分辨率。
其次,避免在循环里频繁进行大范围ROI扫描。如果你设置了img.find_blobs在全图范围内找色块,计算量自然大。实际优化思路是:用上一帧找到的色块中心坐标作为参考,只在这个中心点附近的一个小矩形区域(比如宽100、高80)内搜索新色块,这样搜索范围小、速度快,而且天然具备了一定的“预测性”,对断线赛道非常友好。
5.3 UART通信偶尔乱码或丢帧
乱码问题大概率可以从这几个方向排查:波特率不匹配、接线松动、地线未共地、电磁干扰。其中地线未共地是最容易被忽视的,OpenMV的GND和STM32的GND必须接在一起,否则串口参考电平不一致,收发必然出错。
丢帧问题则在通信设计层面解决。我在协议里加的帧头帧尾校验就能过滤大部分错误帧,另外STM32侧的接收缓冲区要留够余量,不要在中断里做太多耗时操作,否则下一次数据来的时候中断来不及响应,就会直接丢数据。如果你在中断里顺便打印了很多调试信息,那丢帧几乎是必然的,先把调试信息挪到主循环再测。
5.4 小车在十字路口和急弯处失控
十字路口的失控一般有两大类原因:一类是状态机判断逻辑不够健壮,比如把长长的直线误判成了十字;另一类是车轮经过十字时由于惯性速度太快,视觉滞后导致根不上。我采用的“直行状态忽略偏差”策略能解决大部分情况,但具体忽略的时长需要根据基础速度调整。速度越快,这个忽略窗口应该越短,否则车过了十字线还在闷头直行,遇到紧接着的弯道就直接飞出去。
急弯失控则往往是因为摄像头视角太窄,看不到足够远的前方。解决办法是把OpenMV的镜头略微上扬,让它能“看”得更远。这个调整对弯道表现影响极大,上扬角度我只改了大概10度,弯道的过弯成功率就提升了一半以上,建议你在固定支架时留出调整余量。
6. 写在最后的体会
整套工程做下来,我最大的感触是:视觉巡线小车表面上是“图像处理+控制算法”的堆叠,但真正决定上限的反而是一些不起眼的细节。比如OpenMV镜头的安装位置是否在车体中轴线上,比如两组轮子打滑率是否一致,比如PID的运算周期是否和图像帧率匹配。这些问题不实际调过几轮,光看教程永远意识不到。
最后再分享一个小技巧:我在调试PID时会把OpenMV的偏差数据用串口实时绘成曲线,然后和电机PWM输出曲线对比着看。这样一眼就能看出是视觉端抖还是控制端抖,极大减少瞎猜的时间。希望这篇工程笔记能帮你少走几条弯路,把你的小车从“勉强能跑”调成“稳稳跑完”。
本文还有配套的精品资源,点击获取