news 2026/9/16 6:52:55

基于STM32与OpenMV的视觉云台追踪小车设计详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于STM32与OpenMV的视觉云台追踪小车设计详解

简介:基于STM32与OpenMV的视觉云台追踪小车完整工程资料,适合嵌入式、自动化、电子信息等专业学生用于课程设计、毕业设计或项目初期验证。整体围绕纯色物体识别与AprilTag标签识别,讲解了从边缘检测、四边形筛选到标签解码的原理,以及从图像坐标到二自由度云台角度换算的完整思路,并给出可直接烧录的固件与源码工程。压缩包共246个文件,主要包括.c/.h源码、Keil工程配置文件(.uvprojx)、编译生成的.hex/.axf固件、中间文件(.o/.d/.crf)及README说明文档,整体约9.42MB,目录结构清晰,便于按模块对照学习。资源已有685人学习下载。通过源码可学习OpenMV视觉定位与STM32控制逻辑的串口通信方法,掌握目标坐标到舵机角度的转换设计,适合在现有代码基础上二次开发,实现更多巡线、抓取等视觉追踪功能。

1. 视觉云台追踪小车:为什么必须用双芯片方案

第一次看到“基于stm32以及openmv的视觉云台追踪小车”这个题目时,很多人的第一反应是“OpenMV不就能直接输出坐标吗,为什么还要加一块STM32?”这个疑问恰恰是项目的核心。OpenMV的OpenMV Cam系列虽然集成了摄像头、处理器和MicroPython解释器,但它本质上是视觉前端,它的GPIO资源有限、定时器不够精准、PWM输出路数少,直接驱动双路直流电机加两路舵机时,会出现帧率波动导致PWM周期抖动、串口被图像处理占用导致控制指令延迟、电机反电动势干扰复位等一连串问题。而STM32F103系列在电机控制上经历了大量工业验证,定时器输出比较模式配合DMA可以做到微秒级稳定PWM,独立看门狗和硬件串口又能保证系统不出现死循环。所以常规做法是:OpenMV负责“看”,STM32负责“动”,两者用串口通信建立一条低延迟指令通道。

这套方案适合三类人:做毕业设计或电赛的学生,需要快速出效果但不想把整个视觉和控制逻辑揉在一个MCU上;刚接触嵌入式视觉的工程师,想理解图像坐标系到机械执行机构的完整映射流程;以及想给现有STM32小车加视觉跟踪能力的开发者,本文的串口协议和PID骨架可以直接复用。你不用先精通OpenMV的神经网络,也不必研究复杂的图像处理算法,只要掌握颜色识别和云台角度映射这两个基本功,就能跑通一个“目标走到哪,云台和小车跟到哪”的完整闭环。


2. 系统架构与通信协议:先让两个芯片“说上话”

2.1 拆解硬件选型:用一个最小可复现的物料清单

常见做法是选STM32F103C8T6核心板,OpenMV Cam H7 Plus作为视觉模块,云台用两路SG90舵机组成二自由度结构,底盘用两路带编码器的直流减速电机加TB6612驱动。电源必须分开:OpenMV瞬时功耗可以达到250mA以上,舵机启动瞬间电流接近500mA,如果和电机共用一路供电,摄像头会频繁花屏。我一般会准备两片18650锂电池组,一组为STM32和OpenMV供电,另一组专门给电机驱动,两路电源只共地不共电。

通信引脚接线时,OpenMV的UART3_TX(P4)接STM32的PA3(USART2_RX),OpenMV的UART3_RX(P5)接STM32的PA2(USART2_TX),波特率设置为115200。STM32端选择USART2而不用USART1的原因是把USART1留着接蓝牙或上位机,方便后期调试。关键的一个细节是两根通信线尽量短且远离电机驱动板的输出端,电机的PWM斩波会产生强烈的电磁干扰,在线材缠绕时不要把串口线和电机线扎在一起,否则经常出现帧头校验错误。

2.2 定义一帧指令:不要让坐标数据“裸奔”

OpenMV向STM32发送的数据需要包含三个信息:目标在画面中的x坐标、y坐标、目标是否丢失。常见的做法是封装成七个字节的帧:帧头(0xAA)、数据长度(0x04)、x高八位、x低八位、y高八位、y低八位、校验和。校验和取前六个字节的累加和低八位,STM32收到后先验证帧头,再算校验,避免把噪声数据当成有效指令。

以下是OpenMV端的发送代码:

import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) uart = UART(3, 115200, timeout_char=1000) red_threshold = (30, 100, 15, 127, 15, 127) def pack_data(cx, cy, lost_flag): x = int(cx) y = int(cy) payload = bytearray([0xAA, 0x04, (x >> 8) & 0xFF, x & 0xFF, (y >> 8) & 0xFF, y & 0xFF, 0x00]) checksum = sum(payload[1:6]) & 0xFF payload[6] = checksum return payload while True: img = sensor.snapshot() blobs = img.find_blobs([red_threshold], pixels_threshold=200, area_threshold=200) if blobs: largest_blob = max(blobs, key=lambda b: b.area()) cx = largest_blob.cx() cy = largest_blob.cy() img.draw_cross(cx, cy, color=(0, 255, 0)) uart.write(pack_data(cx, cy, 0)) else: uart.write(pack_data(160, 120, 1)) time.sleep(20)

这段代码里,find_blobs的第一个参数是LAB色彩空间的阈值元组,不同光照环境下同一个红色物体的阈值变化很大,调试时用OpenMV IDE的“阈值编辑器”工具拖动滑块找到实际环境的区间。pixels_threshold是用像素个数过滤掉画面中的噪点,如果设成1,摄像头的噪声点会让云台在小范围内高频抖动;设得太大又可能漏掉远距离的小目标。time.sleep(20)控制发送帧率大约50Hz,对于视觉云台来说,这个频率刚好匹配舵机的响应速度,不需要更快,快了舵机也跟不上,反而占用更多串口带宽。

2.3 STM32端串口解析:中断加状态机,不要用阻塞延时

STM32端如果写成在主循环里死等串口数据,会有两个问题:一是在执行电机PID计算时,串口数据可能已经覆盖缓冲区,导致丢帧;二是如果通信线松动,程序会卡死在等待循环里,系统性崩溃。正确的做法是开启USART2接收中断,在中断回调里按字节填充一个环形缓冲区,主循环再解析帧。

// 串口中断回调,存入环形缓冲区 void USART2_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { data = USART_ReceiveData(USART2); ring_buffer[ring_write_idx] = data; ring_write_idx = (ring_write_idx + 1) % RING_BUFFER_SIZE; } } // 主循环中调用,返回1表示解析到一帧完整指令 uint8_t parse_vision_frame(uint8_t *x_out, uint8_t *y_out, uint8_t *lost_out) { static uint8_t state = 0; static uint8_t frame[7]; static uint8_t frame_idx = 0; while (ring_read_idx != ring_write_idx) { uint8_t byte = ring_buffer[ring_read_idx]; ring_read_idx = (ring_read_idx + 1) % RING_BUFFER_SIZE; switch (state) { case 0: if (byte == 0xAA) { frame_idx = 0; frame[frame_idx] = byte; state = 1; } break; case 1: frame[++frame_idx] = byte; if (byte == 0x04) { state = 2; } else { state = 0; } break; case 2: frame[++frame_idx] = byte; if (frame_idx >= 6) { uint8_t checksum = 0; for (uint8_t i = 1; i < 6; i++) checksum += frame[i]; if (checksum == frame[6]) { *x_out = (frame[2] << 8) | frame[3]; *y_out = (frame[4] << 8) | frame[5]; *lost_out = (frame[4] == 0 && frame[5] == 0) ? 1 : 0; state = 0; return 1; } state = 0; } break; } } return 0; }

状态机解析的优点是每一帧数据的一个字节出错时,不会影响下一帧数据的同步。判断丢目标的标准不是单独依赖lost_out标志,而是同时检查x和y是否同时为0,这样对异常数据有双重容错。如果帧头0xAA出现在数据段内部怎么办?状态机的设计会直接丢弃当前帧并重新同步,不产生虚假控制指令。


3. 视觉处理与坐标映射:从像素坐标到舵机角度

3.1 OpenMV的色块识别——先理解LAB阈值分离的原理

OpenMV默认的帧尺寸是QVGA(320x240),图像的坐标系原点在左上角,x轴向右,y轴向下。很多新手在写跟踪代码时,直接用blob.cx()的原始值作为舵机目标值,结果会发现云台“反着转”。原因是云台的云台转向舵机装在车体上,镜头朝前,当目标物向画面右侧移动时,云台应该向右转,但如果直接反向映射,就会越追越偏。

正确的做法是归一化处理。把cx除以320得到一个0到1之间的比值,再乘以舵机控制量范围。SG90舵机的PWM占空比控制范围一般是500us到2500us,对应0到180度,但在实际机械结构中,云台左右转动的机械限位可能是-90度到90度,超过这个范围舵机会堵转。Limit值通常要留5度到10度的余量,避免舵机顶着机械限位持续出力而烧毁电机。

3.2 云台PID控制公式:位置式PID为什么够用

云台舵机不需要增量式PID,因为舵机本身自带位置反馈(电位器),它的控制量是目标角度,误差直接作用于输出,位置式PID即可满足。公式为:

target_angle_x = pid_x.update(center_x - blob.cx()) # 其中 center_x = 160,是画面中心点

这里有一个容易被忽略的点:目标角度不是直接等于blob.cx(),而是等于画面中心与目标的偏差乘以比例系数。如果目标在画面正中心,偏差为零,此时云台应该保持当前角度不转。如果把blob.cx()直接当角度,目标在正中时云台反而会偏转一个固定角度。

我常用的一个经验值是:水平方向的P系数取0.2到0.4,垂直方向取0.15到0.25。因为云台的垂直舵机受到重力影响,如果P值太大,目标稍微一动,云台就会“点头”振荡。调试时先在OpenMV IDE里打开“直方图”窗口,观察目标物体的色块面积,如果目标面积少于50像素,就应该停止跟踪,此时PID会因为噪声输入而产生抖动。加一个面积判断,比任何滤波算法都有效。

3.3 通过串口调试助手可视化PID曲线

不确定PID参数是否合适时,不要把OpenMV的数据直接发给STM32,而是先在电脑上跑一遍纯Python仿真。OpenMV IDE自带串口终端,把调试数据通过USB虚拟串口打印出来,在Excel里画一条曲线,观察超调量和稳态误差。这个习惯能节省大量联调时间。


4. STM32端电机控制:从PWM配置到双闭环

4.1 定时器配置:TIM1产生20ms周期,TIM2产生PWM

路舵机需要三路PWM信号(左右转向、俯仰),直流电机需要两路PWM(左轮、右轮)。SG90舵机的控制信号是50Hz周期,即20ms周期,脉宽500us对应0度,2500us对应180度。STM32的定时器可以分频配置,不需要每路都占一个定时器,只要频率相同,可以共用同一个TIM的多个通道。

关键点是:舵机PWM和电机PWM的频率不同,舵机是50Hz,直流电机一般是10kHz到20kHz。10kHz以上的PWM频率听不到电机啸叫,而且电流纹波小,电机运行更平滑。两者必须用不同的定时器。我一般这样做:

// 电机PWM初始化,频率为10kHz void Motor_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, 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 = 1000 - 1; // 10kHz TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 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); }

电机PWM占空比写死是不行的,需要根据视觉目标的位置动态调整左右轮的差速。追踪小车在目标偏离画面中心时,两轮速度应该相反,这样车子原地旋转,车头始终指向目标。比如目标在画面右侧50像素,就让左轮正转、右轮反转,速度与偏差成正比。

4.2 电机速度闭环:编码器只用来做“速度反馈”,不做什么传感器融合

底盘直流电机如果连编码器都不加,用开环PWM驱动,会出现一个明显问题:电池电量充足时,PWM 50%的占空比可以产生较快的转速;电池电压降低后,同样的占空比转速明显下降。视觉追踪中,转速的绝对值不关键,关键是左右两轮的转速差,开环时即使发了相同的PWM值,两个电机因为机械磨损和负载差异,实际转速也有差别。所以必须加编码器闭环。

常见的做法是STM32的定时器编码器接口模式读取电机AB相脉冲,每10ms读取一次计数值,通过M法测速得到转速。速度环PID的输出是PWM占空比。光有速度环还不够,如果车子的重心偏了或前轮因为摩擦阻力转向,速度环无法保证直线行驶,这时候需要再加一个“线速度、角速度”的转换关系,但这个项目里追踪逻辑足够简单,不需要过分复杂的运动学解算。

速度环PID的参数整定有一个通用规律:比例项加大可以快速消除误差,但超过临界值会震荡;积分项的主要作用是消除静差,但积分饱和在车辆系统中非常危险,一个常见的坑是视觉目标丢失时,PID的积分项还在累积,导致目标重新出现时小车猛冲。解决办法是加积分限幅,或者目标丢失时直接清空积分值。

下面是一个简化版的增量式PID实现,适用于电机速度环:

int16_t Speed_PID_Calc(int16_t target_speed, int16_t current_speed) { static int16_t previous_error = 0; static int16_t integral = 0; int16_t error = target_speed - current_speed; integral += error; if (integral > 500) integral = 500; if (integral < -500) integral = -500; int16_t output = 8 * error + integral / 20 + 15 * (error - previous_error); previous_error = error; return output; }

这里的参数是根据典型小型直流电机经验预设的,P是8、I是0.05(积分分离写法)、D是15。注意D项的符号:增量式PID中D系数带符号,不要抄错。整定时先令I和D为0,只调P,观察电机是否出现周期性的音调变化,那个音调频率就是系统震荡频率,把P减小30%再继续调。

4.3 目标丢失时的行为策略:原地搜寻模式

追踪小车必须有目标丢失的处理逻辑。常见做法是目标丢失后云台自动执行一个“扇形扫描”动作,云台从-60度扫描到60度,STM32在扫描过程中持续接收OpenMV的数据,一旦收到有效坐标立即停止扫描并切换到追踪模式。这一策略的坑在于:如果扫描速度过快,目标从画面边缘掠过时OpenMV来不及识别,就会错过了;如果太慢,目标可能已经从视野外的区域走掉。可接受的扫描速度是每50ms更新一次角度,每次增加3度,这样一次完整扫描链约2秒。


5. 联调与排错:按这个顺序上电,问题最少

5.1 分模块调通的顺序

联调最忌讳的是把OpenMV和STM32同时上电然后发现问题不知道查哪边。我习惯的顺序是:第一,单独给OpenMV供电,打开IDE的帧缓冲区窗口,确认色块识别框能稳定框住目标;第二,OpenMV连接电脑USB串口,发坐标数据到串口助手,确认数据包格式正确;第三,STM32通过串口接USB转TTL工具,用电脑假装OpenMV发固定坐标如(160, 120),看云台是否回正、电机是否停转;第四,最后再把两者用杜邦线连接。每一步花的时间不超过十分钟,但能把问题的责任边界划清楚。

5.2 常见故障现象与修复手段

串口接好但STM32无响应时,先用示波器看PA2和PA3引脚有没有波形。如果没有波形,八成是杜邦线接触不良;有波形但STM32不动作,检查波特率是否与OpenMV端一致。如果云台角度对不上,检查定时器重载值:STM32的定时器计数频率是72MHz/(预分频+1),SG90周期20ms要求计数值是20ms/(1/计数频率),这里有一个很多人踩过的坑——预分频设为71,计数频率为1MHz,但TIM_Period如果比20000 - 1小的话,周期就小于20ms,舵机会发热抖动。如果电机不转,先量驱动板的VM和VCC电压,再量STM32输出的PWM信号。如果电机会转但速度忽快忽慢,多半是速度环的PID参数没有收敛,把I项先清零,P从小的值往上加。

5.3 用逻辑分析仪验证PWM输出质量

有条件时把逻辑分析仪的通道夹在舵机信号线和电机PWM输出上,测量实际频率和脉宽。舵机信号线如果出现不规则的毛刺,说明干扰源是电机PWM,解决方法是给两组电源单独走线并共地,另外在STM32的电源引脚上加100uF电解电容和0.1uF陶瓷电容去耦。


6. 进阶玩法:让追踪小车脱离“玩具”的四个改进

追踪小车跑通之后的下一步,常见的做法不是换更强的处理器,而是优化数据和控制的四个细节。

第一个是改善目标识别算法find_blobs在颜色接近目标肤色的复杂背景下,会把很多非目标的色块也框出来。改用Lab色彩空间的find_blobs其实是低配版方案,上电环境变化后阈值需要重新整定。进阶的一种做法是用OpenMV的find_lines结合颜色阈值做边缘检测,或者训练一个简单的CNN分类器(OpenMV H7支持加载TensorFlow Lite模型),但注意这会让帧率下降。若目标固定是特定颜色物块,还有一种低成本方案:调整镜头的自动增益和自动白平衡关闭时机,让颜色阈值在一天内不同时段都精准。

第二个是加入环形缓冲区的时间戳机制。现在STM32只是简单解析串口数据,并没有记录每帧数据到达的时间。真实物理环境里,OpenMV发来的数据帧与STM32执行指令之间存在一个可变的延迟。如果这个延迟超过50ms,云台就会出现明显的滞后感。在数据帧中增加一字节的帧序列号,STM32端记录当前系统滴答计时,用两次连续帧之间的时间差来估计链路延迟,再将PID系数乘以一个动态比例因子补偿。这里贴一个简单的延迟补偿思路:

uint32_t current_tick = HAL_GetTick(); uint32_t link_delay = current_tick - last_frame_tick; last_frame_tick = current_tick; if (link_delay > 60) // 超过60ms认为链路拥堵 { // 降低PID的D项系数,防止振荡 speed_pid_d_factor = 5; }

第三个是云台角度平滑。OpenMV的坐标输出在目标稳定时也会有1到2个像素的抖动,如果直接换算成舵机角度,云台会不断小幅修正。最简单有效的平滑是低通滤波:new_angle = old_angle + 0.3 * (target_angle - old_angle)。这个一阶惯性滤波器的截止频率大约在3Hz左右,目标是去掉高频抖动而不引入明显延迟。

第四个是用板载LED做状态指示。这是一个调试技巧:STM32的一个GPIO连接LED,不同闪烁频率表示不同状态——快闪表示目标丢失扫描中,慢闪表示正常追踪,常亮表示串口通信异常。十几行代码换来的排障效率提升非常明显,不用每次出了问题都接串口打日志。

最后说一个容易忽略但很重要的细节:calc 一个较大的红色面积阈值。OpenMV官方的示例代码里pixels_threshold=200是给近距离目标设计的,但项目要求是在一米外追踪,目标的像素面积会大幅缩小。改成pixels_threshold=50并配合area_threshold=50,能明显推迟丢失目标的临界距离。要把追踪范围做大,不要只改色块的“颜色”,要同时调整色块的面积阈值、合并相邻色块的参数,以及图像缩放的预处理逻辑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 6:52:04

OpenMontage:面向AI智能体协同的轻量级工作流编排框架

1. 项目概述&#xff1a;OpenMontage不是视频剪辑软件&#xff0c;而是一套面向AI原生工作流的智能体协同编排框架OpenMontage这个名字容易让人联想到影视后期中的“蒙太奇”&#xff08;montage&#xff09;&#xff0c;但实际它和Premiere、DaVinci Resolve这类传统视频生产工…

作者头像 李华
网站建设 2026/9/16 6:51:53

手写STM32链接脚本:从复位向量到main的启动机制

写嵌入式的人大概都有过这个经历&#xff1a;在 Keil 里点一下 Build 就下载跑起来&#xff0c;从来没想过编译器背后把代码“放”到了哪里&#xff1b;直到某一天换成 GCC 工具链&#xff0c;第一次打开.ld文件&#xff0c;满屏的 MEMORY、SECTIONS、ALIGN 看得一头雾水。我第…

作者头像 李华
网站建设 2026/9/16 6:51:37

Linux开机卡在fsck exited with status code 4:原因定位与安全修复指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 6:50:31

大模型训练四阶段详解:从基础到RLHF实战

1. 大模型训练四阶段全景图上周在调试Llama 3时突然意识到&#xff0c;很多同行对大模型训练阶段的理解还停留在"预训练微调"的二分法。这就像把汽车制造简单分为"造零件"和"装整车"一样粗糙。经过与Anthropic和Meta工程师的多次交流&#xff0c…

作者头像 李华
网站建设 2026/9/16 6:48:55

基于TMP144与外部晶振的温度监测信号传递方案设计

1. 为什么是 TMP144 晶振的组合&#xff1a;系统架构与选型思路1.1 先从“信号传递”这个需求倒推系统架构提到温度监测&#xff0c;大多数人第一反应是 DHT22、DS18B20 这类现成模块。但如果仔细读一下标题里的需求——“监测和信号传递温度变化”&#xff0c;会发现这里面的…

作者头像 李华