ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32F407+OpenMV视觉循迹小车:颜色与形状识别实战方案

STM32F407+OpenMV视觉循迹小车:颜色与形状识别实战方案 简介STM32F407与OpenMV联合开发的循迹小车项目定位为嵌入式视觉识别综合训练案例面向正在学习嵌入式、机器视觉或智能小车的开发者和学生。压缩包共276个文件、约9.14MB以52个C语言源文件和55个头文件为核心代码配套Keil工程配置文件、可直接烧录的固件、Python脚本、说明文档与定时器驱动模板同时涵盖六轴姿态解算、超声波测距、PWM电机调速、OLED显示等模块代码文件结构清晰、层次分明便于按需裁剪与二次开发。已有9179人浏览学习适合作为课程设计、毕业设计或电子竞赛的参考方案。包内附带的封装模块可快速完成STM32与OpenMV的串口通信、颜色/形状识别逻辑整合减少环境搭建与底层调试时间。从底层驱动到上层视觉处理均有对应实现使用者可将该项目用于入门练习或综合竞赛快速掌握从传感器采集、视觉识别到电机控制的完整链路。 做视觉循迹小车最让人头疼的往往不是电机控制而是“看路”和“看懂路”。用普通红外传感器做循迹只能在固定黑线上跑稍微换个场地就要重新调位置更别提让小车识别出“前面有个红色圆形”这种需求了。所以这期我直接分享一套比较完整的方案用STM32F407做控制大脑OpenMV做视觉眼睛实现一台既能循迹、又能识别图形形状和颜色的视觉小车而且代码里把视觉识别和串口通信都封装成了可以直接调用的模块适合做电子竞赛、毕业设计或者纯粹想折腾智能车的朋友。这个组合的好处在于视觉算法全部交给OpenMV的MicroPython环境处理STM32只负责串口解析和电机控制两边分工非常清爽。整套系统我从通信协议设计、OpenMV识别算法、STM32端的PID控制到调试排坑完整走了一遍下面把这些经验按项目推进顺序整理出来你照着搭一遍基本就能跑起来。1. 项目整体拆解这套系统到底在做什么1.1 为什么是OpenMVSTM32F407这个组合很多新手容易犯一个错误觉得主控越强越好恨不得让STM32直接把图像处理也干了。实际上STM32F407虽然是一颗168MHz带FPU的Cortex-M4芯片但你要让它跑摄像头采集、图像二值化、blob提取这些操作帧率会非常难看而且一旦图像处理占用大量CPU电机控制就容易出现抖动和响应迟钝。反过来讲OpenMV作为视觉协处理器就很合适它上面跑的是MicroPython官方库自带find_blobs、find_circles、find_rects、find_lines这些图像处理函数开发效率极高。而STM32F407这边两路高级定时器输出PWM控制电机、串口中断收数据、跑PID控制环这些活儿它干得非常利索。注意这套方案的架构本质是“视觉协处理器运动控制器”。OpenMV看到什么通过串口告诉STM32STM32根据收到的信息决定怎么走。这个主从关系想清楚后续所有代码逻辑就都顺了。1.2 数据流与系统分工整个系统的数据流其实很简单我画了一条链路方便你理解OpenMV上电后持续采集图像先做二值化/找色块处理计算赛道偏差、目标颜色和形状OpenMV把处理结果按照约定好的协议帧通过UART发送给STM32F407STM32F407通过串口中断逐字节接收解析出循迹偏差或识别结果如果是循迹数据STM32跑差速PID控制两个电机如果是识别数据比如识别到“红色三角形”STM32可以根据需求执行停车、蜂鸣器提醒、切换模式等动作。这个分工方式带来的最大好处是OpenMV端改算法不影响STM32的控制逻辑STM32端改运动策略也不影响视觉端的识别代码。两块代码解耦之后调试起来效率高很多。2. 通信方案设计两块板子之间的“对讲机”2.1 接线方式和串口选择OpenMV和STM32之间最简单的通信方式就是串口UART。接线原则是交叉连接OpenMV的TX接STM32的RXOpenMV的RX接STM32的TX然后两块板的GND必须连在一起否则电平没有参考地数据会乱码。OpenMV端一般用默认的UART3具体引脚按板子型号查引脚图STM32端我用的是USART1。两边都配置成115200波特率、8位数据位、1位停止位、无校验也就是常说的115200 8N1。波特率选115200是性价比最高的OpenMV的MicroPython环境跑这个速率非常稳而且对线材要求不高普通杜邦线短距离传输完全够用。2.2 自定义协议帧格式既然要在一个串口上同时传“循迹数据”和“颜色形状识别结果”就必须自定义一套协议否则接收端根本不知道这一串字节是什么意思。我用的协议帧结构如下帧头1帧头2功能码数据长度数据区校验和0xAA0x551字节1字节N字节1字节功能码用来区分帧类型0x01表示循迹数据帧0x02表示颜色形状识别结果帧。校验和采用累加和方式——把“功能码数据长度数据区所有字节”加起来取低8位。以循迹数据帧为例完整的一帧是AA 55 01 05 01 00 30 87 52。拆解一下AA 55是帧头01是循迹功能码05表示后面有5个字节数据数据区里01表示检测到线0表示丢线00 30是偏差值0x0030换算成十进制是48表示偏离中心48个单位87是自定义状态字段52是校验和。这个帧结构的好处是定长、可校验、便于STM32用状态机解析。2.3 模块化封装的规划思路在写具体代码之前我建议你先想清楚模块怎么切分否则代码全堆在main函数里会很痛苦。OpenMV端的封装思路是line_tracker.py循迹模块负责计算赛道线中心偏差color_detect.py颜色识别模块返回识别到的颜色IDshape_detect.py形状识别模块返回识别到的图形形状IDprotocol.py串口协议封装提供发送循迹帧和识别结果帧的函数main.py主程序循环采集图像并调用以上模块。STM32端的封装思路是uart_protocol.c / .h串口协议解析自动识别帧头、校验、分发到回调函数pid.c / .hPID控制器motor.c / .h电机PWM和方向控制。协议一旦定死OpenMV端和STM32端各自写各自的模块最后联调的时候只要对着协议文档检查一遍就行基本不会出现“那边发的到底是不是这个意思”的问题。3. OpenMV端视觉识别循迹颜色形状三板斧3.1 循迹偏差的计算方式循迹的核心不是“看到线”而是“算出偏离了多少”。我用的方法很简单先把图像转成灰度图然后用阈值把深色赛道线从浅色背景中分离出来找最大的那个色块最后用色块中心x坐标减去图像中心x坐标得到偏差。OpenMV初始化部分有一些细节要注意import sensor, image, time from pyb import UART uart UART(3, 115200, timeout_char1000) sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 循迹模式用灰度图速度快 sensor.set_framesize(sensor.QQVGA) # 160x120帧率更高 sensor.skip_frames(30) # 跳过前30帧让自动曝光稳定 sensor.set_auto_gain(False) # 关闭自动增益避免阈值漂移 ROI (0, 40, 160, 80) # 只检测画面下半部分 BLACK_THRESHOLD (0, 60) # 灰度阈值黑线像素灰度值在0~60这里的ROI设置很关键。如果全画面检测远处的反光、阴影很容易干扰判断我只保留画面下半部分作为感兴趣区域相当于让小车只看眼前30厘米的路既减少计算量又提高抗干扰能力。循迹偏差计算的函数如下def get_line_error(img): blobs img.find_blobs([BLACK_THRESHOLD], roiROI, pixels_threshold20) if not blobs: return 0, 0 # 0表示丢线偏差为0 blob max(blobs, keylambda b: b.area()) err (blob.cx() - 80) * 10 # 图像中心x80偏差乘10扩大分辨率 return 1, int(err)blob.cx()返回色块中心x坐标减掉图像中心80就能得到“线在左边还是右边”的数值。乘以10是为了把偏差值放大因为STM32端用的是整数PID太小的偏差容易被当成0忽略掉。3.2 颜色识别的阈值调试颜色识别我推荐用LAB颜色空间而不是RGB。因为RGB三个通道之间关联性太强光照一变三个值一起漂而LAB把亮度L通道和色彩A、B通道分开了抗光照干扰能力明显好很多。OpenMV里设置颜色阈值的方法是给出一组[L_min, L_max, A_min, A_max, B_min, B_max]。我实测下来不同灯光环境下的阈值差距非常大没有一组阈值能通吃所有场地所以一定要用OpenMV IDE自带的“阈值编辑器”工具现场标定。下面这组阈值可以作为初始参考值颜色L通道A通道B通道红色20~8025~705~50绿色20~90-60~-1010~60蓝色20~80-10~30-60~-15注意这些只是参考值换了摄像头、换了灯光、换了地面颜色都必须重新标定。标定的方法很简单把目标颜色物体放到镜头前用阈值编辑器拖动滑块直到画面里只有目标物体被高亮然后导出这组阈值。颜色识别函数我建议把多种颜色放在一个列表里轮询然后取面积最大的那个作为最终结果THRESHOLDS { red: (20, 80, 25, 70, 5, 50), green: (20, 90, -60, -10, 10, 60), blue: (20, 80, -10, 30, -60, -15), } def detect_color(img): best_color 4 # 4表示未识别 best_area 0 for color, threshold in THRESHOLDS.items(): blobs img.find_blobs([threshold], pixels_threshold80) for b in blobs: if b.area() best_area: best_area b.area() best_color {red:0, green:1, blue:2}[color] return best_color, best_areapixels_threshold80这个最小像素面积限制很重要它可以过滤掉画面中的小噪点。如果设得太小一个反光点都可能被当成目标颜色误报率会很高。3.3 形状识别的几何特征判别法识别形状有多种方案我这里用的是最适合新手、也最稳定的“几何特征”方案。OpenMV在找到目标色块后blob对象自带一个关键属性density即实际像素面积除以外接矩形面积。这个density对不同形状有很强的区分度正方形/矩形像素填满了外接矩形density接近1.0圆形像素面积约等于外接矩形的π/4density接近0.785三角形像素面积约等于外接矩形的一半density接近0.5。def classify_shape(blob): aspect blob.w() / float(blob.h()) density blob.pixels() / float(blob.w() * blob.h()) if density 0.70 and abs(aspect - 1.0) 0.3: return 2 # 矩形/正方形 if 0.65 density 0.85 and abs(aspect - 1.0) 0.25: return 0 # 圆形 if 0.35 density 0.62: return 1 # 三角形 return 3 # 未知形状需要注意摄像头是斜着看目标的透视效果会让正方形的density略低于1圆形的density也会受影响所以判断阈值要留余量。我这个范围是在摄像头高度15厘米左右、俯视角度45度时调出来的你实际使用时要根据自己的安装角度微调这些数值。3.4 OpenMV主程序整合与模块调用前面几个模块分好后main.py就非常简单了。核心逻辑是每一帧图像同时做三件事算循迹偏差、识别颜色、识别形状然后打包成帧发出去。import sensor, image, time from pyb import UART from line_tracker import get_line_error from color_detect import detect_color from shape_detect import classify_shape from protocol import send_line_frame, send_detect_frame sensor.reset() sensor.set_pixformat(sensor.RGB565) # 颜色/形状识别需要RGB图 sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(30) while True: img sensor.snapshot() line_state, line_err get_line_error(img) color_id, area detect_color(img) shape_id 3 if area 0: blobs img.find_blobs([THRESHOLDS[color_id]]) blob max(blobs, keylambda b: b.area()) shape_id classify_shape(blob) send_line_frame(line_state, line_err) send_detect_frame(color_id, shape_id)有一点要提前说明循迹用灰度图更流畅但颜色和形状识别需要RGB565模式这两个需求在同一镜头下是冲突的。我的做法是统一用RGB565模式循迹阈值也基于RGB565的LAB转换后取灰度分量。实测在QQVGA分辨率下帧率仍然能跑到30帧以上对循迹小车来说完全够用。4. STM32F407端接收与控制逻辑4.1 串口中断接收与协议校验STM32端我直接用CubeMX配置USART1在NVIC里打开串口中断然后把接收逻辑做成一个简单的状态机。这里我强烈建议用“逐字节中断接收 状态机解析”的方式而不是用HAL库的HAL_UART_Receive阻塞收因为你要一边收数据一边控制电机阻塞接收会直接把控制周期拖死。解析函数的核心逻辑是uint8_t rx_buffer[64]; uint8_t rx_index 0; uint8_t parse_state 0; uint8_t frame_len 0; void uart_parse_byte(uint8_t data) { switch (parse_state) { case 0: if (data 0xAA) parse_state 1; break; case 1: if (data 0x55) { parse_state 2; rx_index 0; } else parse_state 0; break; case 2: rx_buffer[rx_index] data; if (rx_index 5) { // 功能码长度数据长度合计 frame_len rx_buffer[1]; parse_state 3; } break; case 3: rx_buffer[rx_index] data; if (rx_index frame_len 4) { parse_state 0; if (check_crc(rx_buffer, rx_index - 1)) { handle_frame(rx_buffer); } } break; } }注意状态机的思路是先找帧头AA 55再找功能码和数据长度然后按长度收完整个帧最后算校验和。校验通过了才丢给处理函数校验失败直接丢弃整个帧。这个机制能挡住绝大多数因为电磁干扰或者接插件松动导致的脏数据。4.2 差速PID控制参数整定STM32收到循迹偏差后我用经典的差速PID算法控制两个电机偏差为正说明线在右边右轮减速、左轮加速小车右转偏差为负则反之。int16_t pid_output position_pid(line_err); int32_t left_pwm BASE_SPEED pid_output; int32_t right_pwm BASE_SPEED - pid_output; left_pwm clamp(left_pwm, MIN_PWM, MAX_PWM); right_pwm clamp(right_pwm, MIN_PWM, MAX_PWM); __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, left_pwm); __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_2, right_pwm);PID参数整定我给一个实用的经验流程先把Kp设成0.3、Ki和Kd都设成0让小车跑起来看效果。如果小车左右摇摆幅度很大说明P太大减到0.15左右如果小车冲出赛道拉不回来说明P太小适当加大。等P能让小车大致沿线走之后再加Kd抑制震荡Kd先从0.5开始试。最后如果发现小车过弯后回不到中线加一点点KiKi不要超过0.05否则积分饱和会让小车在直线段画龙。丢线保护逻辑也很重要如果连续200毫秒没有收到有效的循迹帧我直接让两个电机PWM设为0原地停车等待。这是防止OpenMV那边死机或者画面突然全黑导致小车冲出赛道的关键保护机制。4.3 识别结果触发动作的设计识别到了颜色和形状之后STM32要做什么由你自己定。我的做法是预留一个动作函数收到识别结果后通过回调方式触发void handle_detect_frame(uint8_t color_id, uint8_t shape_id) { if (shape_id 0 color_id 0) { // 红色圆形停车3秒 HAL_GPIO_TogglePin(BUZZER_GPIO_Port, BUZZER_Pin); set_motor_speed(0, 0); } else if (shape_id 1 color_id 1) { // 绿色三角形原地左转180度 // 这里写转向逻辑 } }这个设计的好处是视觉端只负责“看到什么”运动端只负责“怎么办”两边互不干涉。以后想增加新的识别目标只要在颜色阈值表里加一组阈值、在形状分类里加一个ID再在STM32端加一个动作分支就行整个流程不需要大改。5. 调试实录与避坑清单5.1 高频问题排查表实际调试中我踩过不少坑这里整理成一张速查表你遇到类似问题时可以直接对号入座。现象可能原因解决办法串口收到的数据全是乱码波特率不一致或GND没共地两边检查波特率配置确认GND相连小车在直线上画龙式摇摆PID的P太大或D太小降低Kp适当增加Kd颜色识别时好时坏摄像头自动曝光/自动白平衡未关闭把set_auto_gain和set_auto_whitebal都设为False识别到大量误报色块最小像素面积阈值太低调大pixels_threshold过滤噪点循迹时丢线后小车猛打方向丢线处理策略不对丢线时发送0标记保持上一次转向不置0OpenMV画面卡顿分辨率太高或ROI没设置用QQVGA缩小ROI区域电机一转MCU就复位电源没有隔离电机驱动电源和逻辑电源分开加1000uF电容5.2 几个亲测有效的调试技巧调试这个项目的过程中有几个“没有老师告诉你但你迟早会踩”的细节我觉得值得单独拿出来说。第一个是强烈建议在OpenMV IDE里打开串行终端功能直接在IDE里打印发送出去的数据同时用另一个串口助手看STM32收到并解析出来的结果。两边数据一对比就能立刻看出是发送端的问题还是接收端的问题这是最省时间的排查方式。第二个是颜色阈值一定要写一个“标定模式”。我在工程里留了一个调试入口按住板载按键上电OpenMV会进入阈值标定循环在IDE里实时显示每个颜色通道的直方图和阈值效果。换场地的时候不用重新烧代码直接对着IDE调阈值就行。第三个是建议给STM32额外留一路空闲串口接蓝牙调试模块跑动调试的时候不用插USB线手机就能看小车的实时偏差、PID输出和帧接收率。我实际用下来蓝牙串口在115200波特率下完全够用对调试体验的提升非常明显。回到这个项目本身我觉得最有价值的其实不是复杂的识别算法而是把“视觉识别”“通信协议”“运动控制”这三个部分解耦开来的工程思路。你把这个项目做完一遍掌握的不只是一台循迹小车而是以后遇到任何“传感器主控执行器”的组合项目都能快速上手的方法。如果后面你想继续扩展可以考虑给OpenMV加TensorFlow Lite跑数字识别或者把STM32端的PID改成模糊控制升级路径都很清晰。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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