简介:面向嵌入式与机器视觉学习者,这是一份基于STM32F407与OpenMV的循迹小车完整工程,可实现矩形、圆形、三角形识别及红绿蓝颜色区分,并内置封装好的视觉与驱动模块,适合有一定单片机基础、希望快速实现视觉循迹的开发者,也可用于课程设计、电子竞赛及毕设参考。资源压缩包共276个文件,以C源码和Keil工程文件为主,包含大量.c/.h源文件、ARM编译生成的.o/.d/.crf中间文件、可直接烧录的hex/axf固件,另有Python脚本、文本说明和ini配置项,整体大小9.14MB。已有9194人学习下载。工程不仅提供视觉循迹主流程,还完整给出STM32与OpenMV的串口通信、MPU6050姿态检测、超声波避障、OLED/LCD显示和PWM电机调速的集成代码,定时器TIM2配置模板可即拿即用,器件驱动与算法模块封装清晰,便于快速移植到类似小车平台。
1. 循迹加识别为什么非要两颗芯片:先算清这笔账再动手
STMF407跑视觉识别纯属硬扛,OpenMV做闭环控制又缺实时性,这个组合的核心逻辑就是把"看"和"动"拆开。OpenMV负责用摄像头找赛道、认颜色、判别图形,然后把结果通过串口发给STM32F407,由F407算PID、出PWM、控电机。很多第一次做小车的人容易栽在"一颗芯片全包"的思路上,结果视觉处理掉帧、电机控制卡顿,两边都做不好。这套方案适合要做比赛、做课程设计、或者想把手里的F407开发板和OpenMV拼成一辆能自己跑还能认东西的小车的人,难点不在接线,在串口协议和阈值调参这两件事上。
2. 方案地基:OpenMV怎么把"看"这件事交给STM32
2.1 为什么视觉不在STM32上做:算力账和开发账
F407的主频是168MHz,跑个颜色识别算法用软件实现不是不行,但代价是CPU占用率会长期顶在80%以上,还得自己处理图像采集。OpenMV用的是MicroPython环境,内置了find_blobs、find_lines、find_circles这些现成视觉接口,换个LAB阈值就能在不同光线下干活,开发周期明显更短。
从系统稳定性的角度看,视觉处理是毫秒级甚至几十毫秒级的不定耗时,电机控制是微秒级的周期性任务。两个任务混在一个芯片里,要么视觉挤压控制的实时性,要么控制打断视觉导致识别中断。分开之后,OpenMV哪怕卡一帧,F407这边依然按照上一帧的数据继续跑控制,不会整个系统一起翻车。
/* STM32F407 电机控制定时器配置,80MHz预分频,PWM频率10kHz */ TIM_TimeBaseInitTypeDef TIM_InitStructure; TIM_InitStructure.TIM_Prescaler = 8400 - 1; /* 8400分频,得到10kHz */ TIM_InitStructure.TIM_Period = 1000 - 1; /* 周期1000,占空比精度0.1% */ TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_InitStructure);这套分工方式在项目里的含义是:延时、串口中断、电机控制全部放F407这边,图像处理全部放OpenMV那边。两边之间只传高层的语义结果,比如"目标在左边""当前图形是三角形",而不是传一帧一帧的图像。传图像的方案我在验证阶段试过,一条115200的串口,传一帧320x240的JPEG要几百毫秒,等传完小车早就冲出赛道了。
2.2 串口是唯一靠谱的桥:协议设计与波特率选择
OpenMV和F407之间硬件上就是TX接RX、RX接TX、GND接GND,但通信协议需要提前定好。我一般不用裸数据直接发,因为多字节数据之间容易互相污染,接收端很难判断哪一字节是开头。常见做法是定义一个固定帧格式:帧头、数据区、校验位。
波特率的选择需要考虑两个因素:OpenMV串口默认跑得稳的是115200,F407的USART随便支持;业务数据量很小,每帧不超过8字节。115200波特率下每毫秒大约传11.5字节,OpenMV按照30帧的处理速度发,每33毫秒发一帧数据,串口完全不会成为瓶颈。
/* F407端USART2初始化,与OpenMV通信 */ void OpenMV_UART_Init(uint32_t baud) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = baud; /* 常用115200 */ USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); USART_Cmd(USART2, ENABLE); }这里对参数的一个实际建议是:如果现场电磁环境差、线又长,可以把波特率降到38400。代价是OpenMV帧率要跟着降,否则发送缓冲会堆积。但我不建议用9600,那个速度下识别结果到达F407的延迟会明显拉高,PID回路跟着滞后,小车走起来会一顿一顿的。
2.3 "封装好的模块"不是玄学:模块边界应该怎么切
标题提到的"封装好的模块",落到工程上就是三件事:把OpenMV的识别逻辑做成一个可以调参数的类、把串口收发做成一调就用的函数、把F407端的解析做成分状态处理的结构。这三块独立出来以后,换赛道、换车型、换摄像头都不需要重写逻辑。
我见过很多人所谓的"模块"就是把代码堆一个文件里,然后说自己封装完了。真正可用、可复现的封装,接口应当满足:改颜色阈值不需要翻代码逻辑、改串口波特率不需要动解析函数、换电机方向只需要改一个宏。做到这三点,新同学拿过去也能很快把车调起来。
# OpenMV 端识别模块的接口设计示例 class Recognition: def __init__(self, uart_port=3, baud=115200): self.uart = UART(uart_port, baud, timeout_char=1000) self.threshold = None # 颜色阈值,外部注入 self.mode = 'line' # line 或 target def set_threshold(self, th): self.threshold = th # 外部修改阈值,不动内部逻辑3. STM32端串口解析与电机控制:先把数据稳接住
3.1 USART初始化:波特率、DMA还是中断,我选中断
串口接收方案有轮询、中断、DMA三种。轮询会浪费CPU,DMA适合大流量数据比如固件升级,OpenMV每帧才几个字节、而且帧间隔不稳定,中断是最省事且响应最快的方案。开启USART2的接收中断,每收到一个字节就压进缓冲区,主循环里再做帧解析。
需要特别注意的是,OpenMV上电初始化比F407慢,F407先跑起来会收到一串无意义字节。如果不做处理,解析状态机会被垃圾数据打乱。常见做法是收到数据后先判断当前是否处于等待帧头状态,不是帧头就丢弃,这样垃圾数据最多影响一个字节,不会破坏后续正常帧。
/* 串口接收中断:单字节压入环形缓冲区 */ void USART2_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { data = USART_ReceiveData(USART2); rx_buf[rx_write_index++] = data; if (rx_write_index >= RX_BUF_SIZE) rx_write_index = 0; } }中断里不要去做帧解析,只做接收。原因很简单:解析里含循环、判断,放在中断里会让中断服务时间变长,一旦下一字节到达时还没退出中断,就会丢字节。缓冲区大小建议至少256字节,按115200波特率算,这个缓冲能扛约22毫秒的数据突发,足够主循环抽空来取。
3.2 状态机解析协议:帧头帧尾校验的三段式写法
协议格式我常用的是:帧头0xAA 0x55,一个字节数据长度,N个字节数据,最后是校验和。校验和取前面所有字节之和的低8位。状态机按 找帧头->找帧头第二字节->读长度->读数据->校验 的顺序推进。
/* F407端串口帧解析状态机 */ uint8_t parse_state = 0; uint8_t frame_data[8]; uint8_t frame_len = 0; uint8_t frame_index = 0; void Parse_UART_Byte(uint8_t byte) { switch (parse_state) { case 0: if (byte == 0xAA) parse_state = 1; break; case 1: if (byte == 0x55) { parse_state = 2; } else parse_state = 0; /* 帧头错误,重新找 */ break; case 2: frame_len = byte; frame_index = 0; if (frame_len > 8) parse_state = 0; /* 长度非法保护 */ else parse_state = 3; break; case 3: frame_data[frame_index++] = byte; if (frame_index >= frame_len) parse_state = 4; break; case 4: uint8_t sum = 0; sum += 0xAA + 0x55 + frame_len; for (int i = 0; i < frame_len; i++) sum += frame_data[i]; if (sum == byte) { Process_Frame(frame_data, frame_len); } parse_state = 0; break; } }状态机的优势在于不管字节流从哪里开始,最终都会自动收敛到正确的帧边界。代码里的非法长度判断很关键,如果OpenMV端程序改坏了发过来一个超长长度值,没有这个保护的话缓冲区会被写穿,这是血泪教训。另外Process_Frame里不要做耗时操作,只更新全局变量,具体控制逻辑放主循环里处理。
3.3 控制输出:占空比映射和方向切换的一次到位
拿到OpenMV发来的偏差数据以后,F407需要把它换算成左右电机的PWM占空比。最简单的映射是偏差范围-100到+100,对应左电机占空比减少或右电机减少。但这里有个必须提前考虑的问题:电机驱动板的IN1、IN2逻辑和PWM使能组合,左右转向时如果只调占空比不换方向,会出现原地打转和失控。
/* 偏差转PWM:偏差>0目标在右侧,右轮减速,左轮保持 */ void Motor_Control(int16_t deviation, uint16_t base_speed) { int16_t left_speed = base_speed; int16_t right_speed = base_speed; if (deviation > 0) { right_speed = base_speed - deviation * 2; /* 偏差范围±100 */ } else if (deviation < 0) { left_speed = base_speed + deviation * 2; } if (left_speed < 0) left_speed = 0; if (right_speed < 0) right_speed = 0; if (left_speed > 1000) left_speed = 1000; if (right_speed > 1000) right_speed = 1000; Set_Motor_PWM(left_speed, right_speed); }这里有两个参数需要根据你的底盘实际调整:base_speed基准占空比,我一般从500(即50%)起步;deviation的缩放系数,上面代码里用了2倍放大,如果车反应过冲就降到1。另外,PWM频率对电机噪音和线性度有影响,10kHz左右比较合适,太低会听到明显的啸叫,太高的话驱动芯片的开关损耗变大。
4. OpenMV端颜色与形状识别:阈值、轮廓和封装思路
4.1 LAB阈值才是颜色识别的真相:避开RGB的坑
很多第一次接触OpenMV的人会先去找RGB阈值,然后在不同灯光下识别结果飘得厉害,这是因为RGB三个通道都随亮度变化,光照一变三个值一起变。LAB空间把亮度单独放到L通道,A和B主要表达颜色偏向,所以在同样光照条件下只需微调L通道,A和B相对稳定。
OpenMV IDE里自带阈值编辑器,打开帧缓冲区后拖L、A、B三个滑条,把目标颜色区域调成白色、背景调成黑色,最后导出的就是一个六元组阈值。实际落地时我建议把阈值放到一个单独数组里,不同场景存不同组,切换场景直接改下标,不要每次编译烧录。
# OpenMV 阈值配置:红、绿、蓝三组LAB阈值 thresholds = [ (30, 100, 30, 80, 30, 80), # 红色 (30, 100, -80, -30, 30, 80), # 绿色 (30, 100, -30, 30, -80, -30) # 蓝色 ]需要注意,阈值编辑器里调好的值在你的电脑屏幕上看没问题,但OpenMV镜头对着实际赛道时反光、阴影、远处背景都会影响效果。我一般会先把摄像头固定在一个位置,用手在画面里移动目标物,观察阈值在哪个范围会出现误检和漏检,然后取中间值。校准曝光和白平衡也很重要,把auto_whitebalance关闭、固定曝光值,阈值一次调好能在不同时段保持稳定。
4.2 找色块与判形状:find_blobs和find_polylines的组合用法
识别颜色用find_blobs,识别形状我走的是两步方案:先用find_blobs锁定目标色块的区域,再在该区域里用find_lines或find_polylines分析轮廓特征。如果直接在整幅画面里做形状查找,运算量大且容易被画面里的其他物体干扰。
# 颜色识别找目标色块 def find_color_target(img, thresholds, roi=None): blobs = img.find_blobs( thresholds, roi=roi, # 限定搜索区域,加快处理 area_threshold=50, # 过小色块忽略 merge=True # 合并相近色块 ) if blobs: largest = max(blobs, key=lambda b: b.area()) return largest return None色块锁定后,形状判断我一般用find_polylines把色块轮廓提取出来,然后数顶点数。三角形有三个顶点、矩形有四个顶点且相邻边夹角接近90度。圆形则用find_circles直接检测,半径和色块外接圆半径对得上才认。顶点数判断有个坑:图像噪点会导致轮廓上出现大量假顶点,所以必须先对轮廓做轮廓近似,把距离很近的顶点合并。
# 基于轮廓顶点数的形状判别 def judge_shape(img, blob): lines = img.find_polylines( blob.rect(), # 只在色块区域内找轮廓 threshold=30, # 线段检测阈值,太小噪声多 merge=True ) # 根据线段数量和几何关系粗判形状 return shape4.3 把识别逻辑封装成模块:输入画面、输出指令的接口设计
"封装好的模块"在OpenMV端核心是写一个类,输入是摄像头帧,输出是结构化指令。结构化指令包括:当前模式、是否有目标、目标形状、目标颜色、目标在画面中的水平偏移量。这个偏移量就是串口帧里最关键的数据,后续F407能不能走出平滑弧线全看它。
# 封装后的识别模块核心结构 class TargetDetector: SHAPE_NONE = 0 SHAPE_TRIANGLE = 1 SHAPE_RECT = 2 SHAPE_CIRCLE = 3 def __init__(self, thresholds, line_threshold=30): self.thresholds = thresholds self.line_threshold = line_threshold def detect(self, img): result = { 'shape': self.SHAPE_NONE, 'color': -1, 'offset': 0, # 目标中心相对画面中心的像素偏差 'found': False } blob = find_color_target(img, self.thresholds) if blob: result['found'] = True result['color'] = blob.code() result['offset'] = blob.cx() - img.width() // 2 result['shape'] = judge_shape(img, blob) return result这个类的设计要点是detect方法只做识别、不掺和通信。串口发送是在主循环里拿到result以后另做。这样分层的实际好处是:你想加一个二维码识别的功能,只需要加一个detector,主循环逻辑不用动;想换成WiFi通信,也只改发数据那一行,识别部分完全复用。
5. 联调避坑实录:颜色误判、串口丢帧、跑飞路线的5个翻车现场
5.1 光线一变颜色就误判:快门和白平衡在背后捣鬼
现象是上午调好的阈值,下午拿到强光下小车把红色地板当成目标,一路冲过去。原因有两个:自动曝光导致画面变亮,LAB里的L通道整体上移;自动白平衡让红色在画面里偏紫或偏橙。
解决思路是固定摄像头参数。在OpenMV初始化里把auto_exposure设为False、auto_whitebalance设为False,然后手动设一个exposure值。具体值没有标准答案,我的做法是在比赛场地固定灯光下反复试几个值,选一个目标物和背景对比最明显的。这个写在初始化里,不要留给后期。
# 固定曝光与白平衡,让阈值结果在一天内可复现 sensor.set_auto_exposure(False, exposure_us=30000) sensor.set_auto_whitebalance(False) rgb = sensor.get_rgb_gain_db() sensor.set_rgb_gain_db(rgb) # 锁定当前白平衡增益5.2 串口第一帧总是乱码:上电时序背锅
现象是F407刚开机时接收到的数据全是0xFF、0x00这类,状态机经常被卡住。原因是OpenMV的系统启动和MicroPython环境加载需要时间,F407上电后立即初始化串口并开始收,接收到的其实是OpenMV系统启动过程中的电平抖动。
解决方法是双层:硬件上在OpenMV的VIN引脚之前做一个上电延时,软件上F407端前500毫秒不处理串口数据。两个方法选一个就行,我两个都做了,后来在任何一次上电测试里都没再出过乱码。
5.3 小车跑到弯道直接冲出去:丢线处理缺失
循迹小车跑直线很稳,一进弯道就冲出去,原地转两圈才回来。一开始以为是PID没调好,后来打印OpenMV的色块数据,发现弯道处目标色块在画面边缘时,find_blobs会偶尔丢帧,一丢帧偏差就归零,小车以为自己在直道上,直接加速冲出赛道。
解决方法是给F407的偏差数据加一个"数据有效"位,OpenMV没检测到线时发一个特殊偏差值比如200,F407收到200就按最大转向执行而不是保持直行。实际测试里效果非常明显,丢一帧最多就是转向抖一下,不会冲出赛道。
# OpenMV 发送数据时明确区分"线在左边"和"没看到线" if blob: offset = blob.cx() - img.width() // 2 send_frame(1, offset) # 数据有效 else: send_frame(0, 200) # 丢线,请求急转找回5.4 OpenMV画面卡死、帧率暴跌:ROI没限制
现象是加上形状识别以后,整个系统的反应明显变慢,OpenMV在IDE里看到的帧率只有个位数。原因是find_polylines在整幅画面范围内跑,计算复杂度高,一帧处理时间能到一两百毫秒。
解决方法是先find_blobs用大ROI,找到目标后,把后续的find_polylines的搜索区域缩到目标色块附近的小矩形里。这个矩形通常是色块的rect()往外扩20到30个像素。这样形状查找的计算量直接降低一个数量级,帧率能回升到30帧上下。
5.5 电机一边转一边抖:PWM占空比的"静区"问题
现象是小车直行时右轮转左轮抖,或者启动时一个轮子延迟明显。原因是直流电机在占空比低于某个值(比如20%)时,驱动力矩不足以克服静摩擦,电机就处在抖动状态。
解决方法是把PWM映射加一个死区处理,小于死区直接输出0,否则输出映射后的值。另一个相关的参数是电机驱动板的EN引脚,有些板子用跳线选择使能方式,接错了会导致某一侧永远半速,查这个比调参更省时间。
6. 进阶:从"能跑"到"跑得稳"——PID参数与多目标策略的实用习惯
偏差直接映射PWM的方案在缓慢直道和缓弯上够用,但到了S弯和连续急弯就露馅:转向滞后、来回摆动。这一步我建议引入PID控制器,F407里只需要写一个位置式PID,输入是OpenMV传来的偏差,输出是PWM修正量。
P初始值从1.5开始——这是虚拟的一个样例起点,实际需要根据你的底盘转向响应去试。I设0.02左右消除静态误差,D设0.5抑制摆动。调参顺序永远是先P后D再I:先让P在直道上不震荡,再加D压过弯时的摆动,最后加一点I弥补弯道里的稳态误差。每个参数调整后都要跑同一条赛道对比,不要凭感觉。
多目标识别方面,如果赛道上同时存在多个图形和颜色,find_blobs返回所有色块,但小车只需要一个决策目标。我习惯用优先级排序:先按"目标形状+目标颜色"双重匹配筛一次,多个匹配时选面积最大的,也就是离摄像头最近的。偏离方向的判断用目标的cx值减去画面中心即可,数值本身经过归一化后可以直接当作PID输入。
# 多目标筛选:形状和颜色双重匹配,再取最近目标 def pick_target(img, blobs, target_shape, target_color): candidates = [] for b in blobs: if b.code() != target_color: continue shape = judge_shape(img, b) if shape != target_shape: continue candidates.append(b) return max(candidates, key=lambda b: b.area()) if candidates else None这个做法的直接收益是:小车不会被画面角落的干扰色块带偏方向,也不会停下在一个错误目标旁边。最后养成一个习惯:每次联调前先跑一遍自检脚本,把OpenMV的画面帧率、串口接收计数、阈值生效情况都打印出来,确认三项指标正常以后再让车上赛道。早些年我为了省时间跳过自检,结果上了赛道花了半小时才发现白平衡参数丢了,从那以后这个习惯一直保留着。希望帮到你。
本文还有配套的精品资源,点击获取