ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenMV+STM32视觉巡线小车完整工程实践与调试指南

OpenMV+STM32视觉巡线小车完整工程实践与调试指南 简介一套基于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 为什么是OpenMVSTM32而不是其他组合视觉巡线说起来简单选型却是第一道坎。常见的方案有这么几档纯STM32加灰度传感器成本最低但只能贴地巡线遇到陡坡、反光、复杂底色基本白给树莓派加摄像头加STM32性能强但体积大、启动慢、供电复杂做课设有点杀鸡用牛刀K210这类AI芯片方案性价比高但资料少、上手门槛高最后就是OpenMV加STM32这个组合——硬件成本适中、资料极其丰富、MicroPython开发效率高STM32这边又有着庞大的生态两者通过串口通信配合正好能覆盖“视觉识别运动控制”的全部需求。我最终选这个组合还有一个现实原因OpenMV本身也自带电机驱动引脚理论上可以独立完成巡线但它的IO翻转速度、PWM分辨率和实时性远不如STM32车速一快就容易失控。把底层控制交给STM32OpenMV只干它最擅长的事——图像分析这样任务划分清晰、调试效率高后期想扩展超声波避障、蓝牙遥控也方便。1.2 系统架构与数据流设计整个系统的数据流是一套典型的“感知-决策-执行”闭环。OpenMV摄像头采集图像后在内部完成二值化、寻找色块、计算偏差值然后通过UART串口把偏差值和赛道状态发送给STM32STM32作为主控核心接收数据后运行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)做形态学处理我实测下来经过一次腐蚀加一次膨胀噪点基本都能消除干净。关键代码片段是这样的MicroPythonimport 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_threshold100) # 后续处理...这里有个细节值得注意pixels_threshold参数我建议根据自己的实际图像尺寸调整。如果设得太小半条噪点线都会被误判成目标设得太大线窄的地方或弯道拐角处的色块会被整体过滤掉。对于160×120的分辨率经验值是80~150之间你可以用OpenMV IDE的“阈值编辑器”边调边看效果。2.2 偏差计算与赛场状态判断光找到色块还不够还得算清楚小车当前相对于线的偏移量。这一步我用的方法是找出图像中最大的目标色块然后计算它的质心横坐标blob.cx()与图像中心横坐标160/280的差值这个差值就是“横向偏差”。偏差范围大致在-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.01D设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只有在完整收到帧头和帧尾时才认为这一帧有效。具体格式AAdeviation1字节有符号- state1字节 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画面卡顿或延迟严重画面卡顿会直接导致小车反应慢半拍。首先检查分辨率设置如果你用了VGA640×480分辨率OpenMV的处理速度会大幅下降帧率可能只剩个位数这时候就算控制算法再好也白搭。巡线这种应用QQVGA160×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输出曲线对比着看。这样一眼就能看出是视觉端抖还是控制端抖极大减少瞎猜的时间。希望这篇工程笔记能帮你少走几条弯路把你的小车从“勉强能跑”调成“稳稳跑完”。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进