
简介面向OpenMV初学者与嵌入式视觉实践者的云台自动追踪程序围绕打靶识别场景演示如何由摄像头检测目标、计算位置偏差并驱动云台完成实时跟踪。rar压缩包内共2个Python文件以MicroPython源码为主体整体约3KB体量轻巧便于直接阅读、移植与二次实验代码覆盖颜色阈值、图像预处理、目标定位、串口通信及PID调节等环节并配有逐行注释可帮助读者理解从视觉识别到云台控制的完整链路。已有4197人学习适合作为课程设计、竞赛打靶或小型云台项目的参考底稿对安防监控与无人机视觉应用也有迁移价值。读者可据此拆解识别与追踪逻辑观察参数调试与实时性取舍并在此基础上扩展目标特征、控制接口和多轴云台策略形成可复用的机器视觉实践样例。1. 从一次打靶跑偏说起OpenMV 云台自动追踪要解决的真问题去年帮一个做科创的朋友调一个打靶装置靶纸贴在五米外OpenMV 挂在二自由度云台上代码跑起来看着挺正常串口也有数据可舵机就是慢半拍弹着点总往右下角飘。后来把摄像头画面和串口数据同时打出来才发现问题根本不在 PID而是识别框在靶心边缘反复横跳一帧跳 8 个像素云台当然跟着抖。OpenMV 云台自动追踪这类项目真正难的不是「识别到目标」而是「稳定地识别到同一个目标并且把这个偏差及时、平滑地送到舵机上」。它适合三类人参加打靶/追踪类竞赛的学生、想入门机器视觉闭环控制的开发者、以及要做一个自动跟随小装置的 DIY 玩家。硬件成本不高一个 OpenMV 模块加两个舵机、一块下位机就能起步难点全在参数和链路细节里。2. 硬件与链路选型OpenMV 视觉头 二自由度云台的系统骨架2.1 视觉端与执行端怎么分工这套系统最容易被新手做错的地方是把所有活儿都塞给 OpenMV。OpenMV 跑的是 MicroPython主频和内存都有限它擅长的是采集图像、做阈值分割、算出色块或圆心的像素坐标不擅长的是高频 PWM 输出和毫秒级的闭环控制。所以合理的分工是OpenMV 只负责「看」输出归一化后的偏差值下位机STM32 或 Arduino 一类负责「动」跑 PID、出 PWM、驱动两个舵机。拆开看就是四个部分。视觉端是 OpenMV负责图像采集和目标定位执行端是二自由度云台通常用两个舵机一个管水平偏航一个管俯仰控制端是下位机接收偏差、执行 PID、输出 PWM能源端是一套能同时喂饱舵机和主控的电源。四者之间的边界清晰了后面调试才有地方下手。我一般会把云台的机械零位和摄像头的视场中心对齐也就是舵机在中位比如 90 度时摄像头光轴正对正前方。这个对齐如果偏了PID 里那个「期望偏差为 0」的前提就不成立云台会一直带着一个固定偏置转看起来像是积分项在捣乱实际是机械装歪了。2.2 供电、共地与串口电平供电是这类项目最典型的隐性坑。舵机堵转电流能到 1A 以上如果和 OpenMV 共用一路 5V图像采集会随机复位串口数据跟着断。常见做法是舵机单独一路电源OpenMV 和下位机共用另一路两路电源的 GND 必须接在一起否则串口收到的全是乱码。串口电平也要确认。OpenMV 的 UART 是 3.3V TTLSTM32 也是 3.3V直连没问题如果下位机是 5V 逻辑的 Arduino接收方向一般能识别 3.3V 高电平发送方向最好串一个电平转换或者分压别直接把 5V 灌进 OpenMV 的 RX。环节常见配置注意点视觉端OpenMVUART3115200关闭自动增益和自动白平衡通信TTL 串口8N1双向共地波特率两端一致控制端STM32/Arduino定时器 PWM50Hz0.5~2.5ms 脉宽执行端二自由度舵机云台单独供电留出机械限位电源舵机 5~6V主控 5V大电容并在舵机电源上波特率别贪高。115200 在 20~30 帧每秒的数据量下完全够用帧长控制在 6 字节以内丢帧概率比 460800 低得多也更好用逻辑分析仪抓。2.3 工程目录与文件职责源码包一般分成两块OpenMV 侧的 Python 脚本和下位机侧的 C 工程。视觉侧通常就一两个文件main.py里是主循环可能还有一个config.py存阈值和串口参数。下位机侧是标准的单片机工程核心文件是串口接收解析、PID 计算、舵机 PWM 输出这三个。openmv_gimbal/ ├── openmv/ │ ├── main.py # 主循环采集、识别、发偏差 │ └── config.py # 阈值、串口、归一化系数 ├── mcu/ │ ├── main.c # 初始化 主循环 │ ├── uart_proto.c # 帧解析状态机 │ ├── pid.c # 位置式 PID │ └── servo.c # 舵机 PWM 输出 └── README.md这个划分的好处是阈值调参不用动控制代码控制参数调整也不用重新烧 OpenMV。二次开发时最常改的就是config.py里的阈值和pid.c里的三个系数把这两处独立出来改起来不牵连。3. 靶心识别拆解find_blobs 阈值分割与圆形检测的两条路3.1 色块法LAB 阈值与面积过滤打靶识别最常用的是色块法前提是靶心有稳定的颜色特征比如红色靶心、蓝色靶环。OpenMV 的find_blobs在 LAB 色彩空间下工作比 RGB 更抗光照变化阈值形如(L_min, L_max, A_min, A_max, B_min, B_max)。L 是亮度A 是绿到红B 是蓝到黄红色靶心一般落在 A 的正区间、B 的负到中区间。import sensor, image, time from pyb import UART # LAB 阈值红色靶心的典型区间需按实际光照用 IDE 的阈值编辑器微调 THRESHOLD (10, 80, 25, 127, -10, 60) sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240帧率与精度折中 sensor.skip_frames(time1500) # 关掉自动增益和白平衡否则靶子一动画面亮度就变阈值跟着漂 sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) uart UART(3, 115200) while True: img sensor.snapshot() blobs img.find_blobs( [THRESHOLD], pixels_threshold150, # 像素数下限滤掉噪点 area_threshold150, # 外接矩形面积下限 mergeTrue # 相邻色块合并防止靶心被切成两半 ) if blobs: # 取像素数最大的块比取第一个稳能压掉背景里的零星红点 b max(blobs, keylambda x: x.pixels()) img.draw_rectangle(b.rect(), color(0, 255, 0)) img.draw_cross(b.cx(), b.cy(), color(0, 255, 0))pixels_threshold和area_threshold这两个参数是抗干扰的第一道闸。靶心在五米外只占几十个像素设太大直接找不到设太小背景噪点全进来。我的经验是先把靶子放到最远距离看 IDE 里最大色块的pixels()返回值取其一半作为下限。mergeTrue也别忽略。靶心如果有反光或者被准星遮挡会被拆成两块不合并的话中心点会跳到其中一块上云台就开始画圈。3.2 圆形靶find_circles 与 ROI 收敛如果靶面是标准的同心圆环用find_circles更直接它对颜色不敏感靠边缘梯度找圆。代价是计算量大QVGA 全图跑一次可能要几十毫秒帧率掉到 10 以下闭环就跟不上了。常见做法是两级收敛先用色块法粗定位拿到一个大致区域再把这个区域缩小成 ROI在 ROI 里跑圆检测精修中心。# 在粗定位色块的外接矩形上扩一圈作为 ROI再找圆 if blobs: b max(blobs, keylambda x: x.pixels()) r b.rect() roi (max(0, r[0] - 10), max(0, r[1] - 10), min(img.width() - r[0] 10, r[2] 20), min(img.height() - r[1] 10, r[3] 20)) circles img.find_circles( roiroi, threshold2500, # 边缘强度阈值光照差时下调 x_margin10, y_margin10, r_margin10, r_min8, r_max60 # 靶心半径的合理范围 ) if circles: c circles[0] img.draw_circle(c.x(), c.y(), c.r(), color(255, 0, 0))threshold这个参数在光照变化时最敏感白天调 3000 能滤干净傍晚就得降到 1500 左右。不想每次手动改可以让程序根据画面平均亮度动态调整但更省事的办法是把补光做稳让环境光别变。3.3 坐标归一化与目标丢失识别出来的cx, cy是像素坐标直接发给下位机不行因为换分辨率、换镜头就全乱了。要在 OpenMV 侧先归一化到[-100, 100]0 表示正中心这样下位机的 PID 参数不随分辨率变化。# 归一化偏差中心为 0边缘为 ±100 dx int((b.cx() - img.width() / 2) * 200 / img.width()) dy int((b.cy() - img.height() / 2) * 200 / img.height())目标丢失必须有明确标志位。如果没找到目标就发0,0下位机会以为目标在正中心云台保持不动看起来像「锁定了」实际上是丢了。正确做法是发一个独立的丢失帧下位机收到后让舵机保持当前角度或者按预设速度回中。if blobs: # 正常帧帧头 0xAA 0x55 dx dy 校验 frame bytearray([0xAA, 0x55, dx 0xFF, dy 0xFF]) frame.append(sum(frame) 0xFF) uart.write(frame) else: # 丢失帧用 0x7F 0x7F 作为哨兵值与正常的 0 偏差区分开 uart.write(bytearray([0xAA, 0x55, 0x7F, 0x7F, 0x00]))哨兵值这个设计是从血泪经验里来的。早期我用0,0表示丢失结果靶子被遮挡的瞬间云台以为对准了松了一下又猛地转回去整个装置像抽风一样。换成独立标志后丢失时让云台保持并轻微回中动作就顺了。4. 串口协议与 PID 闭环从像素偏差到舵机角度4.1 帧格式帧头、校验与丢失标志串口是这套系统里最脆的一环。线上跑的是裸数据没有重传一帧错位后面全乱。所以帧必须有帧头、有长度、有校验接收端用状态机解析不能指望「一次 read 就是完整一帧」。一个够用又不啰嗦的格式帧头两字节0xAA 0x55数据两字节dx dy有符号用补码表示或者加 128 偏移一字节校验和。// 串口接收状态机逐字节推进任何一步不匹配就回到找帧头 typedef enum { WAIT_H1, WAIT_H2, WAIT_DX, WAIT_DY, WAIT_SUM } rx_state_t; void UART_RxByte(uint8_t byte) { static rx_state_t st WAIT_H1; static uint8_t buf[4]; switch (st) { case WAIT_H1: if (byte 0xAA) st WAIT_H2; break; case WAIT_H2: st (byte 0x55) ? WAIT_DX : WAIT_H1; break; case WAIT_DX: buf[0] byte; st WAIT_DY; break; case WAIT_DY: buf[1] byte; st WAIT_SUM; break; case WAIT_SUM: { uint8_t sum 0xAA 0x55 buf[0] buf[1]; if (sum byte) { int8_t dx (int8_t)buf[0]; // 补码还原有符号偏差 int8_t dy (int8_t)buf[1]; if (dx 0x7F dy 0x7F) { g_target_lost 1; // 丢失标志 } else { g_target_lost 0; g_err_yaw dx; // 供 PID 使用 g_err_pitch dy; } } st WAIT_H1; break; } } }状态机的好处是错一帧只丢一帧下一个0xAA来了立刻重新同步。用「读到固定长度就当一帧」的写法一旦错位会连续错很多帧表现出来就是云台卡顿然后猛跳。4.2 下位机 PID 与 PWM 舵机驱动PID 用位置式就够了输入是归一化偏差输出是角度的增量或者直接角度。这里有个细节舵机的角度范围有限比如水平 0~180 度实际可用可能只有 30~150 度所以输出必须限幅积分项也必须限幅否则目标长时间在画面边缘时积分会一直累积等目标回来时云台冲过头再慢慢荡回来。typedef struct { float kp, ki, kd; float integral; float prev_err; float out_min, out_max; } PID_t; float PID_Calc(PID_t *pid, float err) { pid-integral err; // 积分限幅防止目标长期偏离导致积分饱和 if (pid-integral 500.0f) pid-integral 500.0f; if (pid-integral -500.0f) pid-integral -500.0f; float deriv err - pid-prev_err; pid-prev_err err; float out pid-kp * err pid-ki * pid-integral pid-kd * deriv; if (out pid-out_max) out pid-out_max; if (out pid-out_min) out pid-out_min; return out; }参数上kp起主导作用先只给 P从 0.05 开始加加到云台能跟上目标但略有等幅振荡再给一点kd压振荡kd一般在kp的 5~10 倍量级因为这里的偏差是像素级、变化快ki最后加只给很小一点用来消除静态误差给大了必然超调。舵机输出是 50Hz PWM周期 20ms0.5~2.5ms 脉宽对应 0~180 度。用定时器时把计数频率设成 1MHzARR 设 20000比较值 500~2500 直接对应脉宽微秒数省得来回换算。// 角度转 PWM 比较值500~2500 对应 0~180 度 void Servo_SetAngle(TIM_HandleTypeDef *htim, uint32_t ch, float angle) { if (angle 0.0f) angle 0.0f; if (angle 180.0f) angle 180.0f; uint16_t ccr (uint16_t)(500.0f angle * 2000.0f / 180.0f); __HAL_TIM_SET_COMPARE(htim, ch, ccr); }4.3 上电联调的顺序联调千万别一上来就闭环。我习惯分四步第一步只开 OpenMV把识别框和串口发出的偏差打在 IDE 里确认目标在画面左边时dx为负、右边为正方向别搞反第二步只开下位机用固定的偏差值手动喂给 PID看舵机转的方向和幅度对不对第三步接通串口但先把kp设成很小确认云台朝目标方向慢速移动第四步才把参数加到能用。方向搞反是新手最常见的翻车云台会朝着远离目标的方向狂奔一直撞到限位。所以第二步的静态验证不能省五分钟能省掉后面半小时的排查。5. 避坑与排查曝光、阈值漂移、舵机抖动、串口丢包5.1 视觉侧三个高频翻车现象靶心识别框忽大忽小中心点来回跳。原因自动增益和自动白平衡没关。OpenMV 默认会根据画面亮度动态调整靶子一动曝光跟着变同一块红色在画面里的 LAB 值就变了阈值边缘的像素时进时出。 解决初始化时set_auto_gain(False)、set_auto_whitebal(False)然后固定曝光时间。如果场地光线变化大宁可加个小的补光灯也别开自动。现象白天调好的阈值到了下午就找不到靶。原因LAB 阈值里的 L 分量对光照最敏感环境光一变靶心的 L 值整体平移原来的区间就框不住了。 解决把 L 的范围放宽A、B 收窄让颜色特征承担主要区分任务同时在config.py里存几组阈值按使用场景切换比现场手调快。现象背景里有红色物体识别框锁到背景上。原因只按颜色找没有做形状或位置约束。 解决加面积下限再加一个「目标应在画面中心附近」的软约束比如离画面中心超过 0.4 倍宽度的候选块直接丢弃。这招在固定场景里非常好用。5.2 控制侧抖动与超调现象云台在目标附近小幅高频抖动。原因kp过大加上kd不足或者偏差本身噪声大微分项把噪声放大了。 解决先降kp到不抖为止再给kd。如果降kp后响应太慢问题在识别噪声不在 PID回头去加pixels_threshold或者对dx dy做几帧滑动平均。现象目标移开后云台冲过头再慢慢荡回来。原因积分饱和。目标长时间在画面边缘积分项累积到限幅值目标突然回到中心时积分还没泄掉。 解决给积分限幅上面的代码里已经做了再加一个「丢失目标时清空积分」的逻辑双保险。现象云台转到某个角度会卡顿或者发出嗡嗡声。原因舵机到了机械限位还在被驱动堵转。 解决软件限幅把可用角度限制在 20~160 度别用满 0~180。5.3 通信侧丢包与错帧现象云台偶尔抽一下频率不高但很烦。原因串口偶发错帧解析出错误的偏差值。 解决确认校验和判断没被注释掉很多人调试时把校验先关了忘了开回来。再加一个「偏差变化率限幅」单帧偏差跳变超过 30 就丢弃防止一帧错误数据把云台带飞。现象OpenMV 每隔几秒重启一次。原因舵机和 OpenMV 共用电源舵机启动瞬间把电压拉低。 解决分开供电舵机电源并一个大电容470uF 以上两路电源共地。现象串口助手能看到数据下位机收不到。原因TX/RX 接反或者没共地。这两条占了接线问题的九成。 解决先量电压再短接自测回环最后才怀疑代码。6. 二次开发换目标、加预测、把调参搬到上位机这套代码的可改点集中在三个地方按投入产出比排个序。改造方向改动位置难度效果换识别目标config.py阈值 识别函数低可追踪任意高对比色块加运动预测OpenMV 侧缓存历史坐标中快速移动目标不滞后上位机调参加一路串口命令解析中免去反复烧录多目标选择find_blobs结果排序中抗干扰能力提升最值得先做的是运动预测。云台追一个快速横移的目标时总是慢半拍本质是「看到的是过去的画面算出来的是过去的偏差」。做法很简单在 OpenMV 侧保存最近三帧的中心坐标用线性外推估下一帧的位置把这个预测量发下去。代码改动不到二十行效果立竿见影。# 简易线性外推用最近两帧的位移预测下一帧中心 if prev_cx is not None: pred_cx b.cx() (b.cx() - prev_cx) * 0.6 # 0.6 是外推系数别给满 pred_cy b.cy() (b.cy() - prev_cy) * 0.6 else: pred_cx, pred_cy b.cx(), b.cy() prev_cx, prev_cy b.cx(), b.cy()外推系数一般给 0.5~0.7给到 1.0 就是完全按上一帧的速度外推目标一停就会过冲。这个系数和 PID 的kd会互相影响调完这个要回头微调kd。第二个改造是把调参搬到上位机。加几个简单的串口命令比如P 0.12改kp、D 0.8改kd、SAVE写进 Flash。这样在现场调参不用抱着电脑烧录效率差好几倍。命令解析可以复用主串口加一个前缀字节和普通偏差帧区分开。验证改造效果时别凭感觉。我的做法是固定一个测试流程靶子从画面左侧匀速移到右侧用手机慢动作录下云台动作回放看有没有超调和滞后再记录串口日志统计一百帧里丢帧和跳变帧的数量。这两个数字比「感觉跟得挺紧」靠谱得多。从我第一次调这套东西到现在最深的教训是任何一次改动都只改一个变量改完立刻用同一套测试流程跑一遍。有次我同时改了阈值和kp结果云台抖了花了两个小时才定位到是阈值放宽引入了噪点跟 PID 一点关系没有。从那以后我每次动参数前都先把当前版本备份成带时间戳的文件改完不对就回滚绝不在一个版本上连续叠改动。希望这套拆解能帮到你少走那两小时的弯路。本文还有配套的精品资源点击获取