简介这是一套基于STM32F103的OpenMV二维舵机云台追踪PID控制程序源自电磁炮项目实战代码。OpenMV负责颜色识别并返回色块中心x、y坐标主控端根据坐标驱动二维舵机云台做闭环追踪适合学习PID控制、机器视觉与舵机驱动的嵌入式开发者。zip压缩包共455个文件大小约11.86MB以C源文件151个和头文件131个为主并含编译生成的axf、hex、map等文件、28张png图片及Keil工程配置便于还原开发环境。已有8233人学习/下载。程序重点在32端PID实现OpenMV端的颜色识别代码需自行编写二维PID联动逻辑与云台跟踪思路值得参考作者还提醒舵机精度越高越好、PID参数需根据实际硬件重新整定可作为电磁炮追踪、视觉云台等竞赛项目的起点。1. 基于32F103与OpenMV的组合这个项目在解决什么实际问题如果你手头有一块STM32F103和一块OpenMV摄像头想做一个小型的物体追踪云台最大的障碍不在于「摄像头识别」而在于「识别到之后怎么让舵机平稳地跟过去」。这套基于STM32F103的OpenMV舵机云台二维追踪PID程序解决的正是这个问题OpenMV负责识别目标物并输出坐标32F103负责接收坐标、跑PID运算、驱动云台上的两个舵机完成水平和俯仰方向的追踪。反直觉的一点是这个项目的视觉部分其实不复杂真正花时间的往往是PID调参和两板之间的通信协议。项目的适用人群很清晰手上有OpenMV和F103开发板、想做一个能实时追踪小球的低成本云台的学生开发者或者刚入门机器视觉与嵌入式控制组合开发的工程师。它把「图像识别」和「运动控制」拆成两个独立模块中间通过串口对接正好是这类项目里最值得抄的架构。2. 整体架构拆解OpenMV看32F103动串口当传令兵2.1 硬件分工与数据流向先说清楚这套系统的分工。OpenMV Cam是一块带摄像头和图像处理器的板子适合跑颜色识别、人脸检测这类视觉任务但它直接去驱动舵机并不是不行而是它的定时器资源比较紧张而且PID控制周期如果和图像处理挤在一起帧率一掉追踪就卡。F103作为主控最合适的点在于它有丰富的定时器资源可以输出多路稳定PWM且串口中断处理足够快专门用来干运动控制这件事再合适不过。我一般会把硬件连接做成这样OpenMV的P3、P4即UART3_TX、UART3_RX分别接F103的USART2_RX、USART2_TX交叉连接F103的PA0输出Pan舵机PWMPA1输出Tilt舵机PWM使用TIM2的通道1和通道2两板共地这个很关键串口通信不共地会出现随机乱码舵机电源用独立5V供电不要直接从F103的VCC口取电云台转动时电流尖峰能把单片机拉复位数据流向是一条单向链路OpenMV采集图像 → 识别目标物 → 计算目标在画面中的坐标 → 串口发送F103串口中断接收 → 解析坐标 → 与画面中心点做差得到误差 → PID运算 → 更新PWM占空比 → 舵机转动。整个链路里没有双向握手所以通信协议要做得足够健壮。2.2 串口通信协议的设计思路OpenMV往F103发数据最容易踩的坑就是发送频率和舵机响应频率不匹配。OpenMV的帧率大概30到50fps如果每帧都发一次坐标F103的PID也跟着50Hz跑普通塑料舵机会因为控制频率太高而发烫甚至堵转。所以通信协议里必须留一个频控字段让OpenMV端可以调发送间隔。我的做法是定义一帧固定5字节的数据帧帧格式: 0xAA 0x55 [CenterX] [CenterY] [Checksum]其中CenterX和CenterY的取值范围是0~254直接把OpenMV画面分辨率映射到字节范围内。Checksum是CenterX加CenterY后取低8位F103端收到后校验校验不过直接丢弃整帧。坐标值超过254就钳位到254用来表示「目标丢失」。OpenMV端发送部分的伪代码逻辑是这样的import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240 sensor.skip_frames(30) uart UART(3, 115200, timeout_char1000) # 在阈值编辑器里取的颜色阈值按实际目标物调整 threshold (30, 100, 15, 127, 15, 127) clock time.clock() while True: clock.tick() img sensor.snapshot() blobs img.find_blobs([threshold], pixels_threshold100, area_threshold100) if blobs: b max(blobs, keylambda x: x.area()) cx min(max(int(b.cx() * 254 / 320), 0), 254) cy min(max(int(b.cy() * 254 / 240), 0), 254) checksum (cx cy) 0xFF uart.write(bytes([0xAA, 0x55, cx, cy, checksum])) else: # 目标丢失发254 254 uart.write(bytes([0xAA, 0x55, 254, 254, 254])) time.sleep_ms(20) # 50Hz发送与舵机响应频率匹配这段代码里的关键点cx和cy不是直接把像素坐标发出去而是先做了一个等比映射把320x240的画面映射到0~254的字节空间。这么做的原因是F103端做PID时误差量直接就是一个字节的差值不需要再做浮点除法定点运算更快。丢失目标时发送254占位F103端收到254就知道目标丢了走归中逻辑而不是继续PID运算。2.3 F103端的初始化与主循环结构F103端标准的初始化步骤是串口2配置为115200、8N1、使能接收中断TIM2配置为PWM输出模式频率50Hz即周期20msGPIO复用为推挽输出。舵机的控制规律是20ms周期内高电平脉宽1ms对应0度1.5ms对应90度2ms对应180度。所以在代码里初始化PWM的脉宽上限和下限要留出余量。void Servo_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); // 50Hz: 72MHz / (72-1) / (20-1) 50Hz TIM_TimeBaseStructure.TIM_Period 19; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 1.5ms - 1500us - 计数值75中心位置 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_Pulse 75; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2Init(TIM2, TIM_OCInitStructure); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE); }这段初始化里最容易配错的是预分频和周期的组合。72MHz主频下预分频71分频后计数频率变成1MHz即1个计数值等于1微秒周期20ms就是20000微秒但这里是20-1再配合预分频之后实际得到50Hz计算时务必把加1的逻辑理清楚。TIM_Pulse初始化为75即1.5ms脉宽对应舵机中位上电先归中是个好习惯。3. 视觉端核心逻辑目标识别与坐标输出的完整链路3.1 LAB颜色空间与阈值标定OpenMV的find_blobs使用的是LAB颜色空间而不是RGB。原因是RGB三个通道对光照变化极其敏感同一个颜色在不同亮度下RGB值差异很大而LAB的L通道单独管亮度A通道管红绿方向B通道管黄蓝方向。实际标定时只需要固定L的范围A和B的范围稍微放宽一些就行这样能省去很多环境光的麻烦。OpenMV IDE自带的阈值编辑器可以直接拖滑条看二值化效果我用下来的经验是先把L通道上下限拉宽比如10到100再去调A和B的上下限让目标物在白背景上呈现为一块单独的白色区域即可。下面这个例子以追踪红色小球为例# 红色小球的LAB阈值按实际环境微调 red_threshold (20, 100, 15, 127, 10, 127)这段阈值的意思L通道范围20到100限制在中等亮度段A通道15到127偏向红色区域B通道10到127。如果你的目标物是绿色B通道的值就要反过来取负数段。注意L通道下限不要设成0因为黑色背景的L值接近0下限太低会把阴影区域也误识别成目标。blobs提取时要设pixels_threshold和area_threshold两个参数。前者过滤掉像素点太少的噪点区域后者是连通域的面积下限。两个都设成100对于320x240分辨率的画面比较合适太小会把画面上零散的反光点也算进来。3.2 目标坐标计算与帧率控制识别到多个色块时用max(keylambda x: x.area())取面积最大的那个这个策略比取像素点最多的更稳因为同一目标物的连通域面积和像素数是正相关的但area的计算包含了形状因子对细长噪点有天然的抑制作用。坐标映射这步很多人会直接发b.cx()和b.cy()原始像素坐标但原始像素是320和240范围一个字节存不下。F103端如果用int16去接收串口帧要多出一倍的字节数解析也复杂。映射到0~254的好处是F103端拿到的误差范围是[-127, 127]正好是signed char的表示范围PID误差变量可以直接用int8_t后续所有运算都走整数乘法关键是省掉了浮点库。def send_target(cx, cy): cx max(min(cx, 254), 0) cy max(min(cy, 254), 0) checksum (cx cy) 0xFF uart.write(bytes([0xAA, 0x55, cx, cy, checksum]))时间戳这里的逻辑time.sleep_ms(20) 放在循环末尾配合sensor.snapshot()本身的耗时实际发送频率大约在30到40Hz之间不完全等于50Hz也没关系F103端PID的输出频率由它自己的定时器决定不会跟着串口数据频率走。这是这类双芯片系统里一个很重要的解耦设计。F103端接收解析在串口中断里做用状态机的思路处理字节流不要用简单的数组缓存否则帧头会错位。最稳妥的做法是第一个0xAA进入状态1第二个0x55进入状态2连续读两个数据字节进入状态4校验通过后置标志位。主循环的PID计算只读这个标志位读到清零。这样即使偶尔丢帧也不会导致数据错乱。4. 运动控制核心位置式PID在云台上的落地实现4.1 PID参数结构与运算流程F103作为主控跑PID很多人一上来就套网上找的PID模板结果云台要么一动不动要么疯狂抖动。这里的本质问题在于舵机云台的被控对象不是电机转速而是舵机的角度位置所以采用位置式PID输出量直接是PWM脉宽值不需要积分分离之类的高级策略。typedef struct { float Kp; float Ki; float Kd; int16_t target; int16_t current; int16_t last_error; int32_t integral; int16_t output; int16_t output_min; int16_t output_max; } PID_TypeDef; int16_t PID_Calculate(PID_TypeDef *pid, int16_t target, int16_t current) { int16_t error target - current; pid-integral error; // 积分限幅防止长时间丢目标导致积分饱和 if (pid-integral 500) pid-integral 500; if (pid-integral -500) pid-integral -500; pid-output (int16_t)(pid-Kp * error pid-Ki * pid-integral pid-Kd * (error - pid-last_error)); // 输出限幅到舵机安全工作范围 if (pid-output pid-output_max) pid-output pid-output_max; if (pid-output pid-output_min) pid-output pid-output_min; pid-last_error error; return pid-output; }代码里的几个关键点注释已经标了一部分我再补充说明。积分限幅很重要当目标丢失时OpenMV会发254占位如果这时不做积分限幅积分项会在几秒内累积到几千等目标重新出现时PID输出还停留在极限值云台会猛甩过去表现就是「看到球之后突然抽搐一下」。输出限幅直接卡在舵机脉宽的安全边界上比如脉宽计数值映射后最小30最大120超出范围舵机可能进入堵转状态。4.2 坐标系映射与方向校准OpenMV的画面坐标系原点在左上角x向右增大y向下增大。而云台的Pan舵机顺时针转动会让画面里的目标往左移Tilt舵机向上转动会让目标在画面里往下移。这就导致了一个经典的方向反转问题。如果不做映射PID会形成一个正反馈回路云台会往目标相反的方向加速跑直到限位。处理方式是给y轴误差加负号x轴按实际接线决定是否取反。我的习惯是在调试时先固定一个轴不动只调另一个轴确定方向正确后再调另一轴。static void Control_Loop(void) { static PID_TypeDef pid_pan { .Kp 0.8f, .Ki 0.02f, .Kd 0.3f, .output_min 30, .output_max 120, }; static PID_TypeDef pid_tilt { .Kp 0.6f, .Ki 0.02f, .Kd 0.2f, .output_min 30, .output_max 120, }; if (target_lost) { // 目标丢失缓慢归中而不是瞬间跳回 servo_set(servo_center_pan, servo_center_tilt); pid_pan.integral 0; pid_tilt.integral 0; return; } // 注意tilt误差取负因为画面y轴向下 int16_t err_x (int16_t)rx_data[0] - 127; int16_t err_y (int16_t)rx_data[1] - 127; // 输出值叠加到中位脉宽上 int16_t pan_pulse SERVO_CENTER PID_Calculate(pid_pan, err_x, 0); int16_t tilt_pulse SERVO_CENTER PID_Calculate(pid_tilt, err_y, 0); servo_set(pan_pulse, tilt_pulse); }这里直接把「目标坐标与画面中心点之差」当作PID的targetPID的current固定为0。这是一种常见的简化写法好处是省去一个「当前误差」的反馈通道坏处是PID的各项增益含义变得不那么直观。实际上target传入的是误差值本身current恒为0PID输出的是为了让误差归零所需的控制量这种方式在云台追踪场景里完全够用。4.3 PID参数的整定规律与初始参考值调PID是这个项目里最玄学的部分但也不是完全没有规律可循。我的整定顺序是先把Ki和Kd都设为0只留Kp从小到大加直到云台出现轻微来回摆动然后退回80%作为基础Kp接着加Kd观察摆动是否明显抑制加到一个临界值后如果出现高频抖动就回退最后加Ki用于消除目标静止在画面边缘时残留的误差。我常用的一组起点参数是Pan轴Kp0.8Ki0.02Kd0.3Tilt轴Kp0.6Ki0.02Kd0.2。Tilt轴的Kp比Pan轴小原因是俯仰方向受重力影响舵机负载变化大增益太大会在目标上下快速移动时出现头部点头一样的症状。死区设置也很关键。当目标在画面中心附近几个像素内小幅晃动时舵机跟着微微调整会产生高频抖动发热严重。我在误差绝对值小于5的时候直接清零输出让云台安静下来。这条经验值得记住死区设得越小不代表追踪越精准很多时候反而会让系统在平衡点附近反复震荡。5. 避坑指南调试云台追踪系统时我的五条踩坑记录5.1 舵机疯狂抖动PID越调越乱现象云台在目标静止时持续发出吱吱声舵机明显在来回微调。原因PID控制频率与舵机自身响应频率不匹配。舵机的典型响应频率是50Hz即20ms周期但F103的PID循环如果跑在1kHz每1ms就更新一次PWM脉宽舵机根本来不及执行到目标角度就收到了新指令两路指令相互打架。解决把PID运算用定时器固定在50Hz执行串口接收只负责更新目标值主循环不要直接调用PID运算。我用TIM3的更新中断触发控制回调50ms执行一次配合20ms的超时判断目标是否丢失。5.2 目标在画面里云台却朝反方向跑现象小球在画面右侧云台却向左转追得越快脱离得越远。原因Pan舵机的转动方向导致画面里的目标向相反方向移动误差的正负号接反了形成了正反馈。这个现象在x和y轴上可能同时存在也可能只有一个轴反了。解决先断开一个轴只保留另一个轴的PID输出用串口助手打印目标坐标和PWM脉宽值观察目标偏移方向与PWM变化方向的关系确认反转后直接给对应轴加负号。我建议用固定目标物做测试不要手动拿小球晃来晃去否则你会分不清是控制器指令错了还是你手抖了。5.3 串口接收数据偶尔错乱云台跳动一下又恢复现象云台长时间运行正常但每隔一段时间会突然跳一个角度然后又恢复正常。原因OpenMV上电初始化时串口会发出无效数据或者两板之间存在地电位差导致偶发校验错误更常见的是丢帧后没有做帧同步数据从错误位置开始解析了。解决串口状态机里强制要求帧头0xAA后必须紧跟0x55才进入数据接收态任何校验失败都要回到帧头匹配状态。F103侧USART使能空闲中断超过一帧时间没有收到新数据就清空接收缓冲区防止半帧数据残留。另外两板之间的共地线必须是独立一根杜邦线直接接地不要用面包板的电源排针接地接触电阻太大会增加干扰。5.4 OpenMV颜色阈值调好了换个环境就失效现象在室内灯光下追踪良好到了窗边有阳光的地方目标识别面积突然变大或者完全丢失。原因LAB的L通道受光照强度影响显著阳光直射会让目标物的L值漂移出设定范围。解决固定L通道在30到90的中段然后把A和B的上下限放宽到127上限。同时我把曝光时间固定为自动曝光模式而不是手动固定曝光值这样环境亮度变化时摄像头先做自动适应LAB色度的漂移会小很多。如果目标物颜色特殊比如深蓝色建议把L通道上限提到120注意B通道也是蓝色主导的具体值没有玄学就是标定后一帧一帧地检查边缘像素的L值分布。5.5 第一次上电舵机猛甩到底差点坏掉现象程序烧录后云台瞬间甩到极限位置伴随咔咔声。原因GPIO初始化到TIM2输出使能之间的空窗期PA0和PA1没有复用为TIM2的AF_PP模式时处于普通推挽输出高电平直接输出了一个砢碜的宽脉冲舵机以为自己收到了满量程指令。解决GPIO初始化的顺序必须是「先配置TIM2的PWM参数再使能TIM2最后才把GPIO复用为AF_PP」。更保险的做法是在main函数的最前面对舵机供电做延时控制程序启动后先串口打印启动信息延时300ms确认TIM2和GPIO全部配置完成后再打开舵机电源用MOS管控制舵机电源。从那以后我每次烧录程序前都会确认供电顺序启动瞬间猛甩的问题一次没再出现过。6. 二次开发思路从固定追踪到闭环预测的三个改造方向6.1 改造一从位置式PID升级为串级PID当前实现的PID是单环的误差是图像中心与目标位置的偏差输出直接是舵机脉宽。这种结构的局限性在于目标物快速移动时舵机从角速度0加速到目标速度的响应偏慢追踪大角度快速运动的小球时会拖后腿。串级PID的改造思路是外层PID的输入是图像坐标误差输出是云台角速度期望值内层PID的输入是角速度期望值与实际角速度的差输出才是舵机脉宽。这样做的好处是内环可以比较快地抑制舵机负载变化带来的扰动外环只用关心位置收敛。如果想在F103上实现需要在云台上加一个MPU6050测角速度内环频率跑到200Hz以上外环保持50Hz。这套架构对后续做移动底盘云台的项目同样适用值得一次投入。6.2 改造二在OpenMV端做目标运动轨迹预测当OpenMV的帧率只有30fps时目标在两帧之间的位移可能已经超过一个死区的宽度。追踪效果看起来就会滞后一拍。常见做法是保存最近5帧的目标坐标用最小二乘拟合一条直线预测目标在下一帧的位置并发送给F103。history_x [] history_y [] def predict_next(): if len(history_x) 5: return None, None # 一阶线性拟合省略了最小二乘公式展开 # 实际用numpy的话直接np.polyfit(range(5), history_x, 1) # 返回给F103的坐标是预测值这个改造之所以值得做是因为它几乎没有增加成本。OpenMV算最小二乘的开销在几十毫秒以内不影响主循环帧率。需要注意的是预测值要钳位在画面边界内目标出画面后立刻切回丢失状态不要沿着外推方向继续跑。6.3 改造三把固定阈值改成动态阈值聚类固定阈值最大的痛点我已经在避坑章节里讲过了环境光一变就要重新标定。一个比较实用的自动标定方案是在画面中心区域开一个感兴趣区目标进入ROI时采集该区域的像素均值做一次简单的K-Means聚类把聚类中心的LAB值作为后续追踪的阈值。每次目标丢失超过3秒后重新采样一次实现自适应切换。我做这套改造时用了一个取巧的办法OpenMV的img.get_statistics函数可以直接得到ROI区域的LAB均值不需要手动实现聚类。每50帧更新一次阈值如果目标不在ROI内则不更新。这样既保留了基于阈值的速度优势又解决了环境漂移问题。回看这个项目最大的收获反而不是PID本身而是理解双芯片协同工作时「通信协议解耦控制频率」这件事。我后来做四轴定高、做小车循迹沿用的都是这套「视觉端负责感知、主控端负责控制、串口传递归一化坐标」的架构。最后说一句实在话这个工程不算难但它踩过的坑足够多你按本文的避坑清单走一遍能少熬三到五个晚上。希望帮到你。本文还有配套的精品资源点击获取