简介:基于OpenMV的云台自动追踪程序是一套面向机器视觉初学者的嵌入式实战代码,结合打靶识别场景,解决摄像头识别目标后驱动二轴云台自动跟踪的问题,适用于课设、竞赛及安防巡检、无人机视觉等方向。资源共2个文件,均为py脚本,压缩包仅3KB,代码精简、注释详细,便于直接阅读和动手移植;源码采用MicroPython编写,一份实现目标图像采集、识别与主流程调度,另一份侧重PID闭环控制逻辑,清晰展示了视觉模块与控制模块的协作方式。已有4186人学习下载。通过学习,可掌握OpenMV颜色阈值定位、MicroPython编程习惯、PID参数整定思路及云台角度控制方法,理解“感知—决策—执行”的完整自动追踪链路;这份轻量级样例也适合作为毕业设计、智能小车视觉追踪等项目的参考起点。
1. 基于 OpenMV 的云台自动追踪:先把“识别得准”和“瞄得住”拆开做
“OpenMV 打靶识别”这个需求,表面上是在摄像头里找一个靶标,实际上要连环解决三段问题:图像里有没有靶、靶心在画面中偏了多少、云台该往哪转才能把准星移过去。真正做得稳的项目,都不是把识别代码和舵机控制堆在一个循环里就跑通的。这套基于 OpenMV 的云台自动追踪程序,先解算靶标在画面中的像素偏移,再用位置环 PID 把偏移量换算成两轴云台的角度增量,最后通过串口把角度指令交给执行端。它适合做打靶模拟、自动瞄准演示、视觉伺服入门验证,也适合作为课程设计里“有完整闭环”的素材。前提是:你得先理解每一步为什么这么接,而不是急着抄代码。
2. 靶标识别与坐标解算:从色块到 AprilTag 的选型
2.1 为什么打靶场景优先选 AprilTag 而不是纯色块
很多人一看到“打靶识别”就想到红色靶心、颜色阈值、膨胀腐蚀那一套。色块方案在固定光照下确实能用,但它有三个硬伤:第一,靶纸上的颜色如果和背景里其他物体重叠,误识别率立刻上来;第二,光照一变,LAB 阈值就要重新调,现场调参的时间往往比写代码还长;第三,色块只能给一个重心,没法告诉你这个靶是不是你要打的那个靶,也没法提供稳定的角点信息。
AprilTag 是另一条更稳的路。它是一种用黑白方块编码的视觉基准系统,OpenMV 官方固件里就内嵌了find_apriltags(),不需要额外移植。打靶场景里,把编码标签直接贴在靶纸中心,程序就能同时拿到标签的 ID、四个角点、中心和旋转角度。这意味着你可以做 ID 过滤:画面里出现十张标签,只锁定 ID 为 3 的靶;还能根据角点算距离,避免靶离得很远时云台盲目乱动。
当然,我不是说色块完全没用。如果靶标本身就是单色圆环,而且场景里没有同色干扰物,用img.find_blobs()加area过滤也能跑。但做自动追踪时,AprilTag 的鲁棒性和信息量远好于色块。一套成熟方案通常这样组合:远距离用 AprilTag 确认目标,目标进入近景后用色块找靶心亚像素位置。下面先从 AprilTag 讲起。
2.2 靶标识别代码:检测、ID 过滤与坐标归一化
我常用的识别主循环是这样写的:
import sensor, image, time, math sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) # 160x120,帧率优先 sensor.set_vflip(True) sensor.set_hmirror(True) sensor.skip_frames(time=2000) sensor.set_auto_gain(False) # 关自动增益,防止曝光跳动 sensor.set_auto_whitebal(False) # 灰度模式下也顺手关掉 clock = time.clock() TARGET_ID = 3 # 只追踪 ID=3 的靶 def find_target(img): tags = img.find_apriltags(families=image.TAG36H11) for t in tags: if t.id() != TARGET_ID: continue cx = t.cx() cy = t.cy() img.draw_cross(cx, cy, color=0, size=10) img.draw_string(cx, cy - 15, "ID:%d" % t.id(), color=0) return cx, cy, t.w() * t.h() return None while True: clock.tick() img = sensor.snapshot() res = find_target(img) if res: cx, cy, area = res dx = cx / img.width() - 0.5 dy = cy / img.height() - 0.5 print("dx=%.3f dy=%.3f area=%d fps=%.1f" % (dx, dy, area, clock.fps()))这段代码的关键点不在于调用了find_apriltags(),而在于我把图像归一化了。dx = cx / img.width() - 0.5把像素坐标转换成 -0.5 到 0.5 的比值,左右的正负方向是一致且确定的。这样后面的 PID 不依赖具体分辨率,从 QQVGA 换成 VGA 也不需要改控制参数。area用t.w() * t.h()表示,它的用途有两个:一是判断目标远近,二是做丢失保护阈值,面积小于某个值就认为目标太远,云台不动作。
families=image.TAG36H11是标签家族参数。TAG36H11 有 5 位码距、支持 587 个 ID,抗误检能力比 TAG25H9 强,适合现场有文字、网格这类容易造成误检的物体。如果靶纸很小、对距离要求高,可以换 TAG25H9,它的码距小、密度高,但误检率相对高。实际项目里我倾向于固定用 TAG36H11,省去现场换家族的时间。
2.3 坐标解算:把像素偏移换算成云台需要的偏移量
拿到dx、dy之后,不能直接把它当初增量发给舵机。这里有一个常见理解偏差:云台转动的角度和像素偏移不是线性关系,焦距和图像宽度决定了对应关系。OpenMV 的镜头视场角在广角和标准镜头之间差别很大,如果忽略焦距,近距离时云台会快速震荡。
我的做法是先给角度增量做一个比例映射:
FOV_H = 60.0 # 水平视场角,按你镜头实测值填 FOV_V = 45.0 def pixel_to_angle(dx, dy, fov_h=FOV_H, fov_v=FOV_V): # dx,dy 是归一化后的相对坐标 angle_x = dx * fov_h angle_y = dy * fov_v return angle_x, angle_y这里的逻辑是:当靶标从画面正中心移到最后边,像素归一化坐标约等于 0.5,对应云台需要补偿的极限角度就是视场角的一半。这是一种粗略的等比例映射,虽然没考虑镜头畸变,但作为位置反馈控制的输入足够了。真正的偏差由后续 PID 稳态误差吸收,不需要精确知道每个像素对应多少度。
如果要更精确,可以在固定距离下摆两个已知间隔的标签,用math.atan()反推每个像素的角度分辨率。但对打靶追踪这种闭环系统来说,线性映射已经能让云台准确压到靶心,省下的算力可以留给识别帧率。需要强调的是:angle_x和angle_y是“期望的云台角度增量”,不是“云台当前位置”。PID 控制的也是这个增量随时间收敛的过程。
2.4 参数对照:分辨率、曝光、增益怎么取
图像参数配置是一个权衡表格,我按不同场景给出一组参考值:
| 参数 | 远距离狙击 | 近距快速追踪 |
|---|---|---|
| 分辨率 | QQVGA 160x120 | QVGA 320x240 |
| 像素格式 | 灰度 | 灰度 |
| 曝光 | 手动固定 20000us | 手动固定 15000us |
| 增益 | 固定低增益 | 固定中增益 |
| 目标帧率 | 40-50fps | 25-30fps |
| 标签家族 | TAG36H11 | TAG36H11 |
| 面积阈值 | 200px | 800px |
手动固定曝光这件事值得多说两句。OpenMV 默认自动曝光,室内灯光如果带 50Hz 频闪,自动曝光会在每帧之间跳动,导致标签边缘亮度忽高忽低,find_apriltags()偶尔丢帧。最省事的做法是在固定场景下跑 20 秒自动曝光,记录sensor.get_exposure()的稳定值,然后写死。增益同理,手动固定后,识别率和一致性都会有明显提升。
另一个容易被忽略的点是翻转参数。云台安装方向不同,set_vflip()和set_hmirror()要按实际朝向设置。如果装了之后发现云台往左打,靶心反而更偏右,那大概率不是 PID 算错,而是图像方向和控制方向不一致。
3. 云台跟踪控制:位置环 PID 与串口指令下发
3.1 两轴云台的自由度分配与执行器选型
识别做完之后,追踪系统的核心就变成“如何让云台平滑跟随”。这里我建议把云台拆成两个独立的自由度:Yaw(水平旋转)和 Pitch(俯仰旋转)。打靶场景里 Yaw 的负载变化比较大,因为云台顶部一边挂摄像头,一边挂发射装置,重心未必落在旋转轴上。Pitch 则主要对抗重力,需要更大保持力矩。
执行器有三种常见选择:普通舵机、数字舵机、步进电机加减速箱。普通舵机便宜,但死区明显,PID 输出很小时它根本不动,导致靶心在小范围内来回抖;数字舵机响应快,适合追踪移动靶;步进电机精度最高,但需要驱动器,重量也上去了。我的建议是:课程演示用 9g 数字舵机,重量轻、响应够;竞赛场景用带金属齿轮的数字舵机,防止长时间往返摆动扫坏齿轮。
舵机的角度范围也必须在代码里限制。标准舵机名义上是 0 到 180 度,实际机械限位往往只有 170 度左右。PID 累积误差如果无限制,会让舵机一直往一个方向加角度,最后顶到机械限位发出嗡嗡声。这个现象不是程序跑飞了,是输出没有限幅。
3.2 PID 参数计算:把过冲打掉
追踪云台最怕两个表现:一是响应慢,靶都移到另一边了云台还在慢慢爬;二是过冲大,云台冲到目标位置后弹回来,来回震荡。纯比例控制能让响应变快,但一定会带稳态误差和过冲。打靶场景对稳态精度要求高,所以至少要加积分项消除末端误差,加微分项抑制过冲。
我习惯用位置式 PID,周期固定在 20ms:
class PositionPID: def __init__(self, kp, ki, kd, out_min=-30.0, out_max=30.0): self.kp = kp self.ki = ki self.kd = kd self.out_min = out_min self.out_max = out_max self.last_err = 0.0 self.integral = 0.0 def update(self, err, dt=0.02): self.integral += err * dt # 积分限幅,防止长时间丢失目标时积分饱和 self.integral = max(-5.0, min(5.0, self.integral)) derivative = (err - self.last_err) / dt output = self.kp * err + self.ki * self.integral + self.kd * derivative self.last_err = err return max(self.out_min, min(self.out_max, output))初始参数给kp=1.2, ki=0.05, kd=0.15,但必须强调:这组参数是给 QQVGA + 50Hz 控制周期用的,换了分辨率或控制频率就得重新整定。整定的顺序是先只留 P,P 从 0.3 开始加,直到云台出现约 20% 的过冲,然后加 D 把过冲压下去,最后加一点 I 消除静止时与靶心的残差。如果你发现云台总是在靶心两侧抖,多半是 D 太小或者控制频率太低,dt用错也会让微分项的数值大得离谱。
积分限幅是必须的。目标丢失时,误差会持续累积,如果积分不限幅,目标重新出现时云台会先猛冲一大段,看起来像“抽搐”。我把积分限幅放在 ±5,对应最大积分补偿角度,实际调的时候看输出曲线再缩窄。
3.3 串口协议与角度限位:OpenMV 到主控的通信格式
OpenMV 负责视觉,舵机通常由主控板(比如某单片机开发板)驱动,也有直接把舵机接在 OpenMV 上的做法。前者更接近真实打靶系统,因为发射机构、安全互锁都要主控统一管。OpenMV 和主控之间用串口通信,帧格式必须稳定,我用的结构如下:
import ustruct def pack_cmd(pan_angle, tilt_angle): # 协议: 0xA5 0x5A CMD LEN PAN_H PAN_L TILT_H TILT_L CRC cmd = 0x01 pan_int = int(pan_angle * 10) tilt_int = int(tilt_angle * 10) data = bytearray([0xA5, 0x5A, cmd, 4]) data += ustruct.pack('<hh', pan_int, tilt_int) crc = 0 for b in data: crc ^= b data.append(crc) return data uart = pyb.UART(3, 115200, timeout_char=1000)我把角度放大 10 倍再转成整数,避免串口传浮点带来的解析误差。CRC 校验只用了最简单的异或和,现场通信距离短、干扰不大,异或足够发现错误帧;如果环境里电机干扰严重,可以换成 CRC16。注意PAN_H、PAN_L是高低位,接收端拼回时也要按小端序处理,这个细节很容易两边的工程师各写各的,最后联调时对不上。
串口通信还要考虑一个问题:OpenMV 的主循环如果加上拍照、识别、画框,串口发送可能会被延迟几毫秒。主控一般用定时中断每个 20ms 去读串口,OpenMV 这边则每个控制周期只发一帧。不要在while True里连续刷几十帧,主控缓冲区的旧角度还没处理完,新的又来了,云台会明显变肉。
3.4 追踪主循环与丢失重捕获
控制主循环要写成一个状态机,至少分成“跟踪”和“搜索”两个状态。丢失目标时不发送旧误差算出的角度,而是让云台按预设轨迹扫描。这个逻辑比硬在 PID 里清零积分更可靠。
STATE_TRACK = 1 STATE_SEARCH = 2 state = STATE_SEARCH search_yaw = -60.0 search_dir = 1 while True: clock.tick() img = sensor.snapshot() res = find_target(img) if res and res[2] > 200: # 面积超过阈值才认为有效 dx, dy = pixel_to_angle(res[0] / img.width() - 0.5, res[1] / img.height() - 0.5) pid_yaw.update(dx) pid_pitch.update(dy) yaw_out = pid_yaw.output pitch_out = pid_pitch.output state = STATE_TRACK else: yaw_out = 0.0 pitch_out = 0.0 state = STATE_SEARCH if state == STATE_SEARCH: search_yaw += search_dir * 2.0 if search_yaw > 60.0: search_yaw = 60.0 search_dir = -1 elif search_yaw < -60.0: search_yaw = -60.0 search_dir = 1 uart.write(pack_cmd(search_yaw, 0.0)) else: uart.write(pack_cmd(pan_base + yaw_out, tilt_base + pitch_out))这里有一个隐藏前提:云台的角度是相对某个初始零位的。pan_base、tilt_base是机械零位对应的角度,PID 输出是叠加增量,不是绝对角度。搜索状态下,我让 Yaw 从 -60 到 60 度来回扫描,Pitch 先固定在 0 度,命中后切回跟踪状态。状态切换的瞬间要注意把pid_yaw.last_err和积分清零,否则上次残留的误差会让云台先跳一下。
4. 整机联调与验证:先静态后动态,先瞄准后射击
4.1 联调顺序:不要直接跑完整程序
我这边的血泪经验是:不要第一次通电就上完整程序。调试必须分层:先验证识别层——把 OpenMV 接电脑,只用 IDE 看画面,确认标签能被稳定框住;再验证通信层——用串口调试助手发固定角度,确认云台能转到位;然后验证闭环——让靶标静止,看云台能不能在 1 秒内收敛;最后才做动态追踪和发射联动。
每次只改一个变量,改完记录结果,这是把玄学问题变成工程问题的第一步。静态测试时靶标固定在离摄像头 1.5 米处,左右各移动 20cm,观察终态误差。如果在静态下云台仍然来回晃,不要先调 PID,先检查机械结构:舵机座有没有锁紧、摄像头有没有晃动、标签是不是贴歪了。很多追踪不稳其实是结构刚度不够,不是算法问题。
联调时建议打开差分日志。OpenMV IDE 的串行终端会打印dx dy area fps,把这些数据复制到表格里,能看到丢帧的节奏。如果area在 20 到 800 之间剧烈跳变,说明标签边缘反光导致角点提取不稳定,优先处理光照而不是 PID。
4.2 标定发射器与相机光轴偏差
打靶和单纯追踪有一个本质区别:追踪只要让靶心在画面中心还不够,必须让发射装置的弹道和摄像头光轴在目标距离上交汇。摄像头装在云台正上方,发射管装在下方,两者相差几厘米,近距离时会产生明显偏差。这个偏差叫视差,不消除的话,云台看着已经对准了,打出去却偏左下。
解决办法是“弹道偏移标定”。把靶标放在 3 米、5 米、8 米三个距离,手动把云台对准靶心后,各发射一次,记录实际弹着点相对画面中心的像素偏移:
| 距离 | 水平偏移 | 垂直偏移 |
|---|---|---|
| 3m | 左 8px | 下 12px |
| 5m | 左 5px | 下 7px |
| 8m | 左 3px | 下 4px |
偏移量随距离增大而减小的趋势,正好符合视差模型。程序里建立距离到偏移量的插值表,在计算 PID 误差前,先给期望位置加上这个偏移。比如 3 米时,画面中的靶心虽然已经在正中心,程序仍认为它偏左下,继续调整云台,直到靶心落在中心的右下方向预设处。这种做法在模拟打靶系统里非常常见,只靠“把目标移到画面中心”是没法打准的。
4.3 场景参数清单与测试轨迹
调参过程必须有一套可复现的测试轨迹。把靶标放在一个可移动的小车上,分别做匀速直线、突然停止、正弦摆动三组运动。匀速直线看稳态误差,突然停止看过冲,正弦摆动看相位滞后。记录每组运动下云台的最大误差和收敛时间。
三组测试对应的参数敏感性完全不同。匀速直线最容易暴露积分不足导致的“跟慢”;突然停止最容易暴露微分过强导致的反弹;正弦摆动则考验整体响应带宽。如果三组都过,这套参数基本能应付实际比赛场景。
测试时还要固定环境变量:光照强度、背景复杂度、靶标到摄像头的距离范围。OpenMV 在同一个光照下测出来的参数,搬到户外太阳底下基本要重调,这不是代码问题,是曝光和增益的适应性问题。所以场景参数清单里必须写三条:曝光、增益、标签尺寸,这三项变了,其他参数都失去意义。
5. 常见问题与排查:五个实测踩坑记录
5.1 标签贴歪了,云台怎么都追踪不准
现象:靶心已经在画面正中心,代码也打印出dx=0.000 dy=0.000,但发射端的落点明显偏移。排查发现摄像头画面里的标签看起来是正的,实际上标签中心比靶心偏了 1 厘米。
原因:AprilTag 检测到的t.cx()是标签几何中心,不是靶纸的环心。标签贴的位置和靶心不重合,识别越准,偏差反而越固定。
解决:标定标签中心与靶心的像素偏移。最直接的方法是把靶标放到固定距离,手动云台对准靶心,记录t.cx()与画面中心的差值,把它作为补偿量叠加到dx、dy上。从那以后我每次贴标签都先用尺子量,贴完再拍一张图验证中心偏差,不再凭肉眼。
5.2 云台高频抖动,伴随嗡嗡声
现象:目标静止时,云台在 ±1 度范围内高频抖动,舵机发出持续嗡嗡声,画面也跟着轻微晃动。静态测试时尤其明显。
原因:控制频率和舵机响应频率不匹配。我的 PID 以 20ms 周期计算输出,但舵机本身的响应带宽很低,微小的输出变化它跟不上,反而形成“指令-位置”之间的相位震荡。另外,微分项对像素噪声非常敏感,标签角点提取的亚像素抖动会被 D 项放大。
解决:先把 D 项调到接近 0,确认抖动消失后再小幅加回;再把控制周期从 20ms 改成 50ms,给舵机足够时间响应。多数情况下,问题出在像素噪声而不是 PID 参数本身,可以在计算导数前对dx做一次滑动平均滤波,窗口取 3 帧即可,窗口太大会引入明显滞后。
5.3 目标丢失后云台猛冲,重新出现时剧烈震荡
现象:靶标被遮挡 2 秒后重新出现,云台先向一个方向猛冲,然后反向弹回,来回好几次才稳定。
原因:这是积分饱和的典型表现。目标丢失期间,PID 的err一直保持较大值,积分项持续累积到限幅边缘,目标重新出现时,输出被饱和值主导。
解决:在目标丢失的每一帧都调用pid.integral = 0,同时把last_err也清零。更稳妥的做法是让追踪状态机只在STATE_TRACK下更新 PID 参数,STATE_SEARCH模式下完全冻结 PID 内部状态。我后来把积分限幅从 ±5 缩到 ±2,发现目标重新出现后的恢复时间缩短了一半。
5.4 帧率只有十几帧,整个追踪像慢动作
现象:代码在 IDE 里单步跑正常,但实机运行时帧率掉到 15fps 以下,云台反应迟钝。
原因:分辨率或像素格式选得过高,或者在同一帧里重复执行了多次耗时操作。有人在主循环里先find_blobs再find_apriltags,检查代码发现每帧做了两次完整扫描。另一个隐蔽因素是 IDE 打开的同时占用了部分 USB 带宽。
解决:确认sensor.set_framesize()是否真的设成了 QQVGA。OpenMV 在切换分辨率后要重新skip_frames,否则传感器可能仍然工作在上一次分辨率。帧率测试要断开 IDE 单独跑,实测 QQVGA 灰度下稳定在 40fps 左右,这个帧率对舵机控制已经足够。识别和解算代码别在draw_*方法上浪费太多时间,可视化只在调试时打开。
5.5 暗光下标签识别率骤降
现象:室内光线正常时识别率 100%,傍晚拉上窗帘后开始间歇性丢帧,靶标稍微一晃就搜不到。
原因:曝光时间不够,图像整体偏暗,AprilTag 的黑白对比度低于检测阈值。自动增益虽然会自动升高,但会带来明显噪点,噪点让四角点定位崩掉。
解决:手动把曝光时间从 15000us 提到 30000us,牺牲一点点帧率换对比度。同时把增益固定在一个偏高的值,再配合一块补光板。如果必须在低照度下工作,优先提高曝光而不是增益,因为增益的人为噪点会让find_apriltags的抗噪声能力迅速下降。我也试过把灯源从正前方改到侧上方 45 度打光,标签表面反光减少后,识别率明显回升。
6. 进阶:把每次追踪都变成一份可回放的日志
调参最怕“感觉这版好了”又说不清好在哪里。我后来给这套程序加了一个最小化的日志模块,不依赖外部文件系统,直接把关键数据从串口终端导出:
def log_state(tag_id, dx, dy, yaw_out, pitch_out, fps, state): log_line = "%d,%.3f,%.3f,%.2f,%.2f,%.1f,%d" % ( tag_id, dx, dy, yaw_out, pitch_out, fps, state) print(log_line)每帧打印一行,字段间用逗号分隔,IDE 的串口终端可以直接全选复制到 CSV。之后用任意表格软件把dx、dy、yaw_out画成曲线,就能看到云台对阶跃输入的响应过程。过冲、振荡周期、稳态误差全在曲线里,不需要靠耳朵听舵机声猜。
验证参数是否可用的标准动作是:连续跑三组同样的正弦轨迹,取三组稳态误差的平均值和最大超调量。超调量超过 15% 直接降 P,收敛时间超过 1 秒就加 D。这套方法比凭感觉调参快很多,也让每次改动都有数据支撑。从那以后,我每次调云台都强制走一遍“标定偏移、记录日志、曲线回放”的流程,再没出现过上午调好下午又崩的情况。希望这个流程也能帮到你少走一段弯路。
本文还有配套的精品资源,点击获取