免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

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

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色彩空间而不是RGBRGB对光照变化极其敏感同一个红色球在阳光和日光灯下R、G、B三个分量能差出一倍以上。OpenMV的find_blobs默认工作在LAB空间L代表亮度A代表红绿轴B代表黄蓝轴。识别色块时只关心A、B两个通道亮度影响可以靠L值的宽范围吸收掉。在OpenMV IDE里打开工具 - 机器视觉 - 阈值编辑器选中帧缓冲区的实时画面拖动LAB六个滑块目标是让目标物体在二值化图像里呈现白色背景呈黑色。一个常见误区是想把阈值卡得死死的结果换个环境就识别不到。我一般会把A、B轴的上下限各放宽10到15L轴直接放开到全范围# 红色球阈值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过滤掉外框面积太小的对象。再有就是mergeTrue把同一目标被光照拆成几块的碎片合并起来避免一个完整色块被识别成三四个。import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240 sensor.skip_frames(time2000) sensor.set_auto_whitebal(False) RED_THRESHOLD (30, 100, 15, 127, 15, 127) uart UART(3, 115200, timeout_char100) # 对应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_threshold200, area_threshold200, mergeTrue) lost 1 cx cy area 0 if blobs: # 取面积最大的作为追踪目标避免多个同色物体跳变 b max(blobs, keylambda 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, keylambda 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) 0xFF和cx 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引脚是否标注FT5V容忍。FT引脚接5V单片机没问题非FT引脚接了5V逻辑信号长期运行有烧毁风险。OpenMV往STM32发数据时如果STM32端检测到乱码但接线正确优先怀疑TX/RX接反其次检查两边地线是否共地。共地是串口通信最常见也最容易漏的一步不接GND电平参考点悬空大概率收一帧错一帧。3.2 波特率选115200还是更高速率OpenMV和STM32都是软硬件都支持高速串口的设备但这个项目里信息量很低——每帧才12个字节20帧/秒也就240字节/秒115200波特率跑这个数据量绰绰有余。选115200的真正原因是这个速率在两边都是默认配置出问题概率最低。有些工程把波特率提到921600图像数据量大的时候有意义色块追踪场景纯粹是给自己找麻烦。OpenMV端配置了timeout_char100这个参数表示字符间超时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色块面积、lost0表示目标在视野内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定时器输出需要把系统时钟分频到计数频率再设定自动重装载值让周期落在20msvoid 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/(711)1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 19999; // 1MHz/2000050Hz 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度脉宽500us90度脉宽1500us180度脉宽2500us。角度转脉宽的公式是pulse 500 (angle / 180.0) * 2000。4.3 位置式PID云台追踪的核心色块追踪的PID输入是像素误差图像中心是期望位置OpenMV返回的cx是当前目标位置err_x cx - 160320宽的中点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输出应该快速收敛云台稳定指向目标允许有轻微振荡但不能持续抖动。具体调参顺序先设Kp0.1、Ki0、Kd0逐步增加Kp直到云台出现等幅振荡记为临界值然后取60%作为工作Kp。再加入Kd抑制超调Kd先从0.01开始。积分项Ki只在有静态误差时用——比如目标偏在画面一侧但误差不再减小。垂直轴因为重力作用需要一点积分补偿水平轴一般Ki给0.05以下就够。最后一招是用固定脉宽步进代替闭环给STM32写一个测试程序每500ms把PWM脉宽从1000us步进到2000us观察舵机是否跟随。如果这一步正常再去跑PID闭环问题定位清晰得多。开发时在Core里保留这段测试代码用宏开关切换调试模式和追踪模式比反复烧录不同版本靠谱。本文还有配套的精品资源点击获取
返回列表