简介:面向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 TTL,STM32 也是 3.3V,直连没问题;如果下位机是 5V 逻辑的 Arduino,接收方向一般能识别 3.3V 高电平,发送方向最好串一个电平转换或者分压,别直接把 5V 灌进 OpenMV 的 RX。
| 环节 | 常见配置 | 注意点 |
|---|---|---|
| 视觉端 | OpenMV,UART3,115200 | 关闭自动增益和自动白平衡 |
| 通信 | TTL 串口,8N1 | 双向共地,波特率两端一致 |
| 控制端 | STM32/Arduino,定时器 PWM | 50Hz,0.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(time=1500) # 关掉自动增益和白平衡,否则靶子一动画面亮度就变,阈值跟着漂 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_threshold=150, # 像素数下限,滤掉噪点 area_threshold=150, # 外接矩形面积下限 merge=True # 相邻色块合并,防止靶心被切成两半 ) if blobs: # 取像素数最大的块,比取第一个稳,能压掉背景里的零星红点 b = max(blobs, key=lambda 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()返回值,取其一半作为下限。
merge=True也别忽略。靶心如果有反光或者被准星遮挡,会被拆成两块,不合并的话中心点会跳到其中一块上,云台就开始画圈。
3.2 圆形靶:find_circles 与 ROI 收敛
如果靶面是标准的同心圆环,用find_circles更直接,它对颜色不敏感,靠边缘梯度找圆。代价是计算量大,QVGA 全图跑一次可能要几十毫秒,帧率掉到 10 以下,闭环就跟不上了。
常见做法是两级收敛:先用色块法粗定位,拿到一个大致区域,再把这个区域缩小成 ROI,在 ROI 里跑圆检测精修中心。
# 在粗定位色块的外接矩形上扩一圈作为 ROI,再找圆 if blobs: b = max(blobs, key=lambda 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( roi=roi, threshold=2500, # 边缘强度阈值,光照差时下调 x_margin=10, y_margin=10, r_margin=10, r_min=8, r_max=60 # 靶心半径的合理范围 ) 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,周期 20ms,0.5~2.5ms 脉宽对应 0~180 度。用定时器时把计数频率设成 1MHz,ARR 设 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 一点关系没有。从那以后我每次动参数前都先把当前版本备份成带时间戳的文件,改完不对就回滚,绝不在一个版本上连续叠改动。希望这套拆解能帮到你,少走那两小时的弯路。
本文还有配套的精品资源,点击获取