news 2026/9/12 6:11:20

STM32+OpenMV色块追踪云台:视觉识别与PID控制全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32+OpenMV色块追踪云台:视觉识别与PID控制全解析

简介:基于STM32与OpenMV的色块追踪云台完整项目,以STM32F103C8T6为主控,实时接收OpenMV识别到的色块坐标,通过PID算法控制双舵机云台追踪目标。资源包含全套工程源码与说明文档,覆盖舵机脉冲角度化换算、串口通信协议(帧头+数据+校验+帧尾)、OpenMV色块识别与坐标发送等关键环节,适合嵌入式初学者、电子竞赛备赛者以及机器人视觉方向学习者参考。压缩包共1009个文件,以C源码(561个)、H头文件(245个)、汇编启动文件(51个)为主,同时包含Python脚本、Keil/STM32CubeMX工程配置及Hex固件,整体体积24.69MB。目前已有1110人学习下载,既能直接烧录运行,也可按需修改算法参数或移植到同类云台项目中,是理解视觉追踪与电机控制流程的实用参考资料。

1. 用STM32和OpenMV做色块追踪云台,先解决"分工"问题

把OpenMV直接接到舵机上也能转,很多入门教程就是这么演示的。但一旦目标移动稍快、背景稍微杂乱,你就会发现云台要么疯狂抖动,要么追丢后找不到目标。这套项目把视觉模块和运动控制模块拆开,OpenMV负责"看清"——识别色块、输出质心坐标和面积;STM32负责"动作"——解析串口帧、计算PID、输出两路PWM驱动舵机。两个芯片各干各的,循环频率互不拖累,调参数时也不用为了改一个PID值重新烧OpenMV固件。

网上这类项目的源代码和文档说明很多,质量差异也很大。大部分源码能跑,但阈值标定、通信协议、PID调参这三个环节,恰恰是文档里最容易含糊的地方。这篇基于我自己的工程习惯,把从图像侧到电机侧的完整链路讲清楚,照着做就能让云台稳稳咬住色块。

2. OpenMV端:LAB阈值标定与色块坐标提取,把"看到"变成"数字"

2.1 为什么选LAB色彩空间而不是RGB

RGB对光照变化极其敏感,同一个红色球在阳光和日光灯下,R、G、B三个分量能差出一倍以上。OpenMV的find_blobs默认工作在LAB空间,L代表亮度,A代表红绿轴,B代表黄蓝轴。识别色块时只关心A、B两个通道,亮度影响可以靠L值的宽范围吸收掉。

在OpenMV IDE里打开工具 -> 机器视觉 -> 阈值编辑器,选中帧缓冲区的实时画面,拖动LAB六个滑块,目标是让目标物体在二值化图像里呈现白色,背景呈黑色。一个常见误区是想把阈值卡得死死的,结果换个环境就识别不到。我一般会把A、B轴的上下限各放宽10到15,L轴直接放开到全范围:

# 红色球阈值:L放宽,A和B卡红色区域 RED_THRESHOLD = (30, 100, 15, 127, 15, 127) GREEN_THRESHOLD = (30, 100, -127, -15, 15, 127) BLUE_THRESHOLD = (30, 100, -127, -15, -50, -15)
目标颜色L范围A范围B范围说明
红色30~10015~12715~127A、B都偏正
绿色30~100-127~-1515~127A负B正
蓝色30~100-127~-15-50~-15A负B负

这组数值是启动值,不要在代码里反复改,应该在阈值编辑器里对着实际画面调好后抄回来。OpenMV的自动白平衡会漂移颜色,记住在初始化里加一句sensor.set_auto_whitebal(False),否则每次上电识别同一块颜色,阈值可能对不上。

2.2 find_blobs的核心参数:过滤噪点和合并碎片

find_blobs返回的是多个色块对象列表,每个色块有质心坐标、面积、外框等信息。直接遍历列表取第一个往往不靠谱,小噪点可能比目标更早出现。两个过滤参数必须用:pixels_threshold过滤掉像素点太少的对象,area_threshold过滤掉外框面积太小的对象。再有就是merge=True,把同一目标被光照拆成几块的碎片合并起来,避免一个完整色块被识别成三四个。

import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240 sensor.skip_frames(time=2000) sensor.set_auto_whitebal(False) RED_THRESHOLD = (30, 100, 15, 127, 15, 127) uart = UART(3, 115200, timeout_char=100) # 对应P4(TX), P5(RX) clock = time.clock() last_send = time.ticks_ms() while True: clock.tick() img = sensor.snapshot() blobs = img.find_blobs([RED_THRESHOLD], pixels_threshold=200, area_threshold=200, merge=True) lost = 1 cx = cy = area = 0 if blobs: # 取面积最大的作为追踪目标,避免多个同色物体跳变 b = max(blobs, key=lambda x: x.area()) cx, cy = b.cx(), b.cy() area = b.area() lost = 0 img.draw_rectangle(b.rect()) img.draw_cross(cx, cy) # 40~50ms发一帧足够,太快STM32解析不过来反而丢数据 if time.ticks_diff(time.ticks_ms(), last_send) >= 50: # 发送逻辑见2.3 last_send = time.ticks_ms()

max(blobs, key=lambda x: x.area())这一步很关键。场景里出现多个同色物体时,面积最大的通常是离镜头最近的那个,优先追踪它符合直觉。发送频率控制在20帧/秒,OpenMV端别直接uart.write裸发,一定要让出时间给图像采集循环。用time.ticks_ms()做非阻塞计时,比time.sleep(50)优雅得多,后者会把帧率拖垮。

2.3 发送帧结构:把坐标、面积和丢失标志打包

STM32解析数据需要固定的帧格式,我这里用经典的自定义协议:帧头固定0xAA 0x55,后面跟命令字、数据长度、数据体和累加校验和。坐标拆成高字节和低字节发送。

def send_frame(cx, cy, area, lost): # 帧结构: AA 55 | cmd(0x01) | len(4) | cx_h cx_l | cy_h cy_l | area_h area_l | lost | checksum data = bytearray([0xAA, 0x55, 0x01, 0x04, (cx >> 8) & 0xFF, cx & 0xFF, (cy >> 8) & 0xFF, cy & 0xFF, (area >> 8) & 0xFF, area & 0xFF, lost]) cs = 0 for b in data: cs += b data.append(cs & 0xFF) uart.write(data)

累计校验和的做法是把帧头到最后一个数据字节全部累加,取低8位放进帧尾。STM32接收时做同样的累加,结果跟收到的校验字节比对,不一致直接丢弃整帧。坐标用的是(cx >> 8) & 0xFFcx & 0xFF分解高低位,STM32端重组方式是cx = (buf[4] << 8) | buf[5]。320x240分辨率下坐标范围是0到319,一个字节装得下,但拆成两字节是为后续换高分辨率传感器留余地,协议改动成本最低。

3. 串口通信:STM32与OpenMV的协议设计与电平匹配

3.1 接线和电平关系

OpenMV的串口是3.3V TTL电平,绝大多数STM32开发板(F103、F407系列)的供电和IO也是3.3V,可以直接对接:OpenMV P4(TX) -> STM32 PA10(RX),OpenMV P5(RX) -> STM32 PA9(TX)。注意串口要交叉连接,同端TX接对端RX。

如果不用PA9/PA10这两个默认引脚,换到其他USART引脚时留意STM32引脚是否标注FT(5V容忍)。FT引脚接5V单片机没问题,非FT引脚接了5V逻辑信号长期运行有烧毁风险。OpenMV往STM32发数据时,如果STM32端检测到乱码但接线正确,优先怀疑TX/RX接反,其次检查两边地线是否共地。共地是串口通信最常见也最容易漏的一步,不接GND,电平参考点悬空,大概率收一帧错一帧。

3.2 波特率选115200还是更高速率

OpenMV和STM32都是软硬件都支持高速串口的设备,但这个项目里信息量很低——每帧才12个字节,20帧/秒也就240字节/秒,115200波特率跑这个数据量绰绰有余。选115200的真正原因是这个速率在两边都是默认配置,出问题概率最低。有些工程把波特率提到921600,图像数据量大的时候有意义,色块追踪场景纯粹是给自己找麻烦。

OpenMV端配置了timeout_char=100,这个参数表示字符间超时100毫秒,超过就当链路异常。STM32端初始化时也要注意,使能串口中断前先清一次标志位,否则上电瞬间可能误触发一次接收中断。

3.3 STM32接收与解析:用状态机而不是逐字节傻等

STM32解析一帧数据,新手最容易写成的样子是:中断里把字节存进数组,主循环里检测帧头。这种写法在中断里做大量判断,主循环时序一乱就丢字节。我一般用状态机解析:每个收到的新字节推进状态,帧头、命令字、长度、数据体、校验逐级过关。

// 串口接收状态机,放在USART1中断服务函数中调用 uint8_t rx_buf[12]; uint8_t rx_cnt = 0; uint8_t rx_state = 0; uint8_t rx_len = 0; uint8_t rx_checksum = 0; void UART1_RX_StateMachine(uint8_t data) { switch (rx_state) { case 0: // 等待帧头第1字节 0xAA if (data == 0xAA) rx_state = 1; break; case 1: // 等待帧头第2字节 0x55 if (data == 0x55) { rx_state = 2; rx_cnt = 0; rx_checksum = 0xAA + 0x55; // 预累计帧头 } else { rx_state = 0; // 帧头错误,重新开始 } break; case 2: // 命令字,本项目固定0x01 if (data == 0x01) { rx_state = 3; rx_checksum += data; } else { rx_state = 0; } break; case 3: // 数据长度,必须小于等于8 rx_len = data; rx_checksum += data; if (rx_len > 0 && rx_len <= 8) { rx_state = 4; } else { rx_state = 0; } break; case 4: // 接收数据体 rx_buf[rx_cnt++] = data; rx_checksum += data; if (rx_cnt >= rx_len) rx_state = 5; break; case 5: // 校验字节 if (data == (rx_checksum & 0xFF)) { ProcessFrame(rx_buf, rx_len); // 校验通过,处理整帧 } rx_state = 0; break; default: rx_state = 0; break; } }

状态机的好处是每个字节只做一次状态转移判断,中断服务函数里不会阻塞太久。rx_checksum在状态1预填了帧头的两个字节,后续每收一个字节就累加一次,最后校验时直接与收到的校验字节比对。注意rx_len的合法性判断不能省——长度字段被干扰成255,数组会越界。ProcessFrame里在解坐标前先判断rx_len == 4,这样即使协议被干扰,也不会把错误长度的数据当目标坐标。

3.4 帧解析后的数据含义

OpenMV发来的四个数据段含义分别为:cx(质心X坐标)、cy(质心Y坐标)、area(色块面积)、lost(0表示目标在视野内,1表示丢失)。STM32拿到这三个数据后应该先做一次合理性过滤:cx应当在0到319之间,cy应当在0到239之间。超出这个范围说明数据帧出错或者OpenMV异常上电,不能直接拿去给PID当误差输入。

4. STM32端:舵机PWM与位置式PID,云台"咬住"目标

4.1 云台结构、舵机选型和供电注意

两轴云台自由度分工明确:水平轴(Pan)控制左右旋转,垂直轴(Tilt)控制俯仰。舵机选型上,轻量摄像头用SG90足够,但注意SG90的扭矩在垂直轴上偏弱,镜头稍微重点就会低头。我一般水平轴用SG90,垂直轴用MG996R或MG90S,多花几块钱换稳定不抖。

舵机型号扭矩适用轴供电建议
SG901.8kg·cm水平Pan轴独立5V稳压
MG90S2.2kg·cm水平或轻载垂直独立5V稳压
MG996R9.4kg·cm垂直Tilt轴独立5V/2A以上

舵机供电一定要跟STM32单片机供电隔开。舵机启动瞬间电流能到几百毫安,直接从单片机3.3V取电,一启动就复位。常见的做法是5V稳压模块单独给舵机供电,STM32通过自己的LDO供电,两边共地。上电瞬间舵机会猛冲到中位,这是舵机接收不到PWM信号时的默认行为,解决办法是STM32先输出中位脉宽(1.5ms),延时200ms再初始化舵机控制逻辑。

4.2 TIM输出PWM:高频时钟分频出50Hz

舵机控制PWM频率固定50Hz,周期20ms,脉宽0.5ms到2.5ms对应0到180度,1.5ms是中位。用STM32定时器输出,需要把系统时钟分频到计数频率,再设定自动重装载值让周期落在20ms:

void Servo_PWM_Init(void) { // 假设系统时钟72MHz, 使用TIM3_CH3(PB0)和TIM3_CH4(PB1) GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, 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(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz/(71+1)=1MHz TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 19999; // 1MHz/20000=50Hz TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 1500; // 初始中位1.5ms TIM_OC1Init(TIM3, &TIM_OCInitStructure); // 注意通道号 TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }

预分频71把72MHz降到1MHz,意味着计数器每计数一次就是1微秒。自动重装载值19999,一个周期20000微秒即20ms,频率刚好50Hz。这样CCR值直接就是微秒数——1500代表脉宽1.5ms,角度换算很直观:0度脉宽500us,90度脉宽1500us,180度脉宽2500us。角度转脉宽的公式是pulse = 500 + (angle / 180.0) * 2000

4.3 位置式PID:云台追踪的核心

色块追踪的PID输入是像素误差:图像中心是期望位置,OpenMV返回的cx是当前目标位置,err_x = cx - 160(320宽的中点),err_y = cy - 120。注意图像Y轴向下为正,OpenMV里cy越大目标越靠下,而垂直舵机向下转需要增加脉宽还是减少脉宽,以实际接线为准,调试时如果方向反了把误差取负即可。

位置式PID适合输出直接对应舵机脉宽的场景:

typedef struct { float kp, ki, kd; float integral, prev_err; float out_min, out_max; } Pid_t; float Pid_Update(Pid_t *pid, float err, float dt) { pid->integral += err * dt; // 积分限幅,防止长时间追丢导致积分饱和 if (pid->integral > 100.0f) pid->integral = 100.0f; if (pid->integral < -100.0f) pid->integral = -100.0f; float derivative = (err - pid->prev_err) / dt; float out = pid->kp * err + pid->ki * pid->integral + pid->kd * derivative; pid->prev_err = err; if (out > pid->out_max) out = pid->out_max; if (out < pid->out_min) out = pid->out_min; return out; }

误差单位是像素,但最终要叠加到脉宽上,两者量纲不一致。一种做法是PID输出直接当作脉宽增量叠加到当前脉宽上,Kp设为0.3以下,让增量平缓;另一种做法是先把像素误差乘一个比例系数转成角度差,再进PID。我常用前者,代码里少一层换算,调Kp时也更直接——Kp等于0.5时,50像素误差产生25us脉宽增量,也就是约2.25度的舵机转动量。

4.4 目标丢失策略:回中还是保持

OpenMV的lost标志置1时,PID的误差输入会变成离谱的随机值(因为cx、cy被清零了),这时绝不能继续跑PID。我给两个方案:回中,目标丢了云台慢慢转回90度中位,适合重新扫描场景;保持,输出不变,云台停在最后位置,目标很可能还在视野边缘,移动不大就能重新捕获。

保持方案实现时要小心:误差不再更新,但PID的积分项还在累计,目标重新出现时会猛冲一下。所以在lost为1时把积分项清零,只让云台保持最后的输出脉宽。重新捕获目标后,误差从当前实际位置开始计算,追赶过程更自然。

5. 联合调试技巧:用双串口助手指纹验证链路,用固定误差辨识PID

手工调PID最痛苦的是不知道问题出在视觉链路还是电机链路。先做一次链路验证:用USB转TTL模块把OpenMV的串口同时接电脑和STM32(注意TXD/RXD交叉和共地),电脑上用一个串口助手监听OpenMV发出来的原始数据。再在STM32收到有效帧后,把解析出的cx、cy原样从调试串口回传,用另一个串口助手看回传值。两条链路的数值一致,说明通信协议没问题,后面只管调PID。

PID调参有个可复现的测试方法:固定OpenMV让一个静止色块出现在画面上,通过改变色块距离模拟稳定误差。此时PID输出应该快速收敛,云台稳定指向目标,允许有轻微振荡但不能持续抖动。

具体调参顺序:先设Kp=0.1、Ki=0、Kd=0,逐步增加Kp直到云台出现等幅振荡,记为临界值,然后取60%作为工作Kp。再加入Kd抑制超调,Kd先从0.01开始。积分项Ki只在有静态误差时用——比如目标偏在画面一侧但误差不再减小。垂直轴因为重力作用需要一点积分补偿,水平轴一般Ki给0.05以下就够。

最后一招是用固定脉宽步进代替闭环:给STM32写一个测试程序,每500ms把PWM脉宽从1000us步进到2000us,观察舵机是否跟随。如果这一步正常,再去跑PID闭环,问题定位清晰得多。开发时在Core里保留这段测试代码,用宏开关切换调试模式和追踪模式,比反复烧录不同版本靠谱。

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

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

2025年AI编程助手横评:Cursor、Claude Code、Copilot等五款实测体验

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

作者头像 李华
网站建设 2026/9/12 6:10:41

电力变压器多物理场耦合仿真与降噪优化实践

1. 多物理场耦合仿真模型概述在电力变压器设计与优化领域&#xff0c;传统单物理场仿真已无法满足高精度分析需求。我最近完成的一个工业级变压器仿真项目&#xff0c;就涉及磁场-结构-声学的多场耦合问题。这种耦合模型能完整呈现变压器从电磁激励到最终噪声辐射的全链条物理过…

作者头像 李华
网站建设 2026/9/12 6:09:26

VTK数据文件格式在CFD仿真中的高效应用与优化

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

作者头像 李华
网站建设 2026/9/12 6:06:04

从EasyExcel到Apache Fesod:Java复杂表头与POI冲突的解决实践

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

作者头像 李华