简介:这是一份基于OpenMV的云台自动追踪程序,面向学习嵌入式机器视觉、打靶识别与云台控制的初学者及项目实践者,帮助其理解从目标检测到角度调整的完整闭环。压缩包共2个文件,均为py脚本,体积约3KB,核心代码使用MicroPython编写,围绕颜色或形状识别、图像预处理、目标定位、位置偏差计算及PID控制展开,便于读者对照注释梳理OpenMV与云台电机之间的串口通信和实时追踪逻辑。资源已有4197人学习,说明其在OpenMV打靶与云台追踪入门场景中具有一定参考价值。读者可从中获得可运行的追踪程序框架、关键参数调节思路以及将视觉识别结果转化为云台控制指令的实现参考,适合用于课程实验、竞赛准备和自主项目二次开发,也能借此理解移动或静态目标跟踪中的实时性与稳定性权衡。
1. 从像素到舵机:这套 openmv 云台追踪程序要打通哪四层
调 openmv 打靶识别的开发者,大多有过同一个体验:画面里靶心框得稳稳的,云台却像喝多了似的左右点头;或者静态调好的阈值,换一盏灯就全崩。问题很少出在识别算法本身,而是从「识别出一个坐标」到「舵机转到某个角度」这条链路没人管。这套基于 OpenMV 的云台自动追踪程序,干的就是把这条链路补完整:图像采集与曝光锁定、靶标定位、像素误差到角度的换算、舵机与主控的指令下发。它适合正在做机器视觉类打靶题、或者要给小型两轴云台加自动跟随的开发者。前提是得接受它是个工程骨架而不是开箱即用的成品——阈值要自己调,增益要自己标,装反了方向还得自己改符号。
2. 识别层拆解:颜色阈值、圆检测与 ROI 的三种组合怎么选
识别层决定了这套程序的下限。靶标在画面里是什么形态,直接决定你该用 find_blobs 还是 find_circles,也决定了后面控制环能拿到多稳的坐标。很多人上来就把两种都跑一遍取平均,结果帧率掉到 15fps,云台反而更抖。这一章按「先稳住图像 → 再选检测器 → 最后抠帧率」的顺序拆。
2.1 先锁曝光再谈阈值:sensor 初始化里最容易忽略的四行
我见过太多人抱着一个阈值表调一下午,最后发现是自动增益在背后捣鬼。OpenMV 默认开着自动增益、自动白平衡、自动曝光,这三项对「人眼看着舒服」有帮助,对「阈值稳定」是灾难:同一张靶纸,镜头稍微偏一点,画面整体亮度就变,LAB 阈值立刻失效。合格的初始化应该在第一帧之前就把这三项全部关掉并锁定。
import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) # 找色块必须 RGB565;纯找圆可换 GRAYSCALE 提速 sensor.set_framesize(sensor.QVGA) # 320x240,打靶够用;换 VGA 帧率大约掉一半 sensor.set_auto_gain(False) # 关自动增益 sensor.set_auto_whitebal(False) # 关自动白平衡 sensor.set_auto_exposure(False, exposure_us=6000) # 锁曝光,单位微秒 sensor.skip_frames(time=800) # 丢掉前若干帧,等传感器稳定,别省这一步 clock = time.clock()逻辑上,reset()之后所有自动项都是打开的,必须逐条关;skip_frames(800)是给传感器和寄存器留出稳定时间,省掉它,前十几帧的曝光值是跳变的,你会以为阈值写错了。参数上最需要斟酌的是exposure_us:不要凭感觉填,先让自动曝光跑两秒,把读到的值抄下来再锁死。
# 调试期用:跑在自动曝光状态下,把这两个值抄进上面的初始化里 print("exposure_us =", sensor.get_exposure_us()) print("gain_db =", sensor.get_gain_db())读出来的曝光值会随环境光小幅波动,取一个中等偏上的值即可。太小的曝光会让画面整体偏暗,深色靶环和背景糊成一团;太大则高光溢出,白色靶纸边缘发虚。现场如果必须适应强光变化,宁可保留一档手动增益表(比如室内一套、室外一套),也不要重新打开自动项——手动切换至少是可预期的,自动项是玄学。
提示:锁定曝光后,画面亮度不再自适应。换场地时第一件事是重新确认曝光值,而不是重新调阈值。顺序搞反了,越调越偏。
2.2 find_blobs 与 find_circles 的取舍和参数表
打靶场景的靶标无非两类:靠颜色区分的色块(红色激光点、蓝色靶心标记、绿色指示区),和靠几何形状区分的同心圆靶纸。这两类对应 OpenMV 里两个完全不同的检测器,混用之前先看清代价。
| 维度 | find_blobs(颜色) | find_circles(霍夫圆) |
|---|---|---|
| 单帧耗时(QVGA 全图) | 约 6–12ms | 约 30–60ms,随 r_step 明显变化 |
| 对光照的敏感度 | 高,阈值随曝光漂移 | 中,主要受边缘对比度影响 |
| 参数调试难度 | 低,六元组有直观反馈 | 高,threshold 与半径范围互相牵制 |
| 适合的靶型 | 有色标记、激光点、色环 | 黑白同心圆、印刷靶纸 |
| 输出信息 | 外接矩形、像素质心、旋转角 | 圆心、半径、圆度幅值 |
色块方案的核心是 LAB 六元组。新手常犯的错是拿 RGB 调阈值,OpenMV 的find_blobs吃的是 LAB,L 是亮度、A 是绿红轴、B 是蓝黄轴,分开调比 RGB 直观得多。一个可用的起手式是这样:
# LAB 六元组:(Lmin, Lmax, Amin, Amax, Bmin, Bmax) # 这组是「偏暗的红色标记」示例值,必须用 IDE 的阈值编辑器按实际靶标重调 RED_TH = (20, 60, 25, 70, 10, 60) img = sensor.snapshot() blobs = img.find_blobs([RED_TH], roi=(0, 0, 320, 240), # 感兴趣区域,全图就是整个分辨率 x_stride=2, y_stride=1, # 跳行跳列,越大越快越容易漏小目标 area_threshold=80, # 色块外接矩形最小面积,滤掉噪点 pixels_threshold=80, # 实际像素数下限,比面积更能滤细碎噪点 merge=True, # 相邻色块合并,避免一个靶标裂成几块 margin=8) # 合并时允许的间隙像素 if blobs: b = max(blobs, key=lambda x: x.pixels()) # 取最大块,别直接用 blobs[0] cx, cy = b.cx(), b.cy()x_stride和y_stride是最直接的速度旋钮:调大能显著提速,代价是小于 stride 的目标可能整块漏掉。打靶的靶心通常不会太小,stride 设 2 是安全的;如果是远距离小靶,老老实实设 1。
圆检测的参数则更需要耐心。threshold是霍夫累加器的门限,值越低越容易检出、假圆也越多;r_min/r_max要卡死在实际靶纸半径的像素范围内,这一步能把误检砍掉一大半;r_step是半径搜索步长,调大提速但可能错过真实半径。
# 只在灰度图上找圆,省掉色彩转换开销 img = sensor.snapshot().to_grayscale() circles = img.find_circles(threshold=2200, # 累加器门限,先大后小逐步试 x_margin=8, y_margin=8, # 圆心合并容差 r_margin=10, # 半径合并容差 r_min=18, r_max=90, # 必须按实际成像半径收紧 r_step=2) # 半径步长,2 是速度与召回的折中 if circles: c = max(circles, key=lambda x: x.magnitude()) # 取置信度最高的圆 cx, cy, r = c.x(), c.y(), c.r()两种方案的实际组合方式,我一般是这样:如果靶标本身有明确颜色(比如红色靶心贴纸),先用颜色做粗定位,拿到一个 ROI,再在这个 ROI 里跑圆检测精修圆心。这样既避开了全图霍夫的高耗时,又比纯色块的质心稳——色块质心会被反光和阴影拉偏,而圆的几何中心对局部亮度变化不敏感。
# 两级:颜色粗定位 -> ROI 内圆检测精修 blobs = img.find_blobs([RED_TH], pixels_threshold=80, merge=True, margin=8) if blobs: b = max(blobs, key=lambda x: x.pixels()) # 按色块外扩一圈作为搜索窗,避免靶纸边缘被裁掉 rx = max(0, b.x() - 10) ry = max(0, b.y() - 10) rw = min(320 - rx, b.w() + 20) rh = min(240 - ry, b.h() + 20) circles = img.find_circles(threshold=2000, x_margin=8, y_margin=8, r_margin=10, r_min=15, r_max=min(rw, rh) // 2, r_step=2, roi=(rx, ry, rw, rh)) if circles: c = max(circles, key=lambda x: x.magnitude()) cx, cy = c.x(), c.y()2.3 ROI 裁剪与帧率:把 320x240 用出 640x480 的效果
打靶的目标在画面里是连续移动的,这意味着上一帧的位置就是这一帧最好的先验。动态 ROI 的做法是:以上一帧的质心为中心开一个固定大小的窗口,只在这个窗口里搜索。窗口面积缩到全图的四分之一,find_blobs的耗时会跟着降到四分之一左右,省下来的时间可以直接换成更高的控制频率——而控制频率才是云台不抖的根本。
ROI_W, ROI_H = 120, 90 # 搜索窗尺寸,按目标在画面里的大小定 last_cx, last_cy = 160, 120 # 初始给画面中心 def clamp_roi(cx, cy): x = max(0, min(320 - ROI_W, cx - ROI_W // 2)) y = max(0, min(240 - ROI_H, cy - ROI_H // 2)) return (x, y, ROI_W, ROI_H) roi = clamp_roi(last_cx, last_cy) blobs = img.find_blobs([RED_TH], roi=roi, pixels_threshold=60, merge=True, margin=6) if blobs: b = max(blobs, key=lambda x: x.pixels()) last_cx, last_cy = b.cx(), b.cy() else: # 窗口内没找到就退回全图搜一次,防止目标跑出窗口后彻底丢失 blobs = img.find_blobs([RED_TH], roi=(0, 0, 320, 240), pixels_threshold=60, merge=True, margin=6) if blobs: b = max(blobs, key=lambda x: x.pixels()) last_cx, last_cy = b.cx(), b.cy()clamp_roi是关键,窗口贴边时朴素地做cx - ROI_W//2会得到负坐标,find_blobs直接返回空列表,表现出来就是「目标一走到画面边缘就丢」。这个 bug 很隐蔽,因为画面中间一切正常。
另外别用sensor.set_windowing()代替 ROI 参数。set_windowing是改传感器输出的物理窗口,会改变整幅图像的坐标映射,一旦切窗口,你之前标定的像素—角度系数全部作废;而find_blobs的roi参数只是限定搜索范围,坐标仍是全图坐标系,标定值不用变。这两者名字像,行为完全不同,翻车的人不少。
3. 控制层落地:把像素误差翻译成舵机角度
识别层吐出来的是(cx, cy),舵机需要的是角度。中间这一步的换算质量,决定了云台是「稳如老狗」还是「一直点头」。控制层要解决三件事:误差怎么变成角度增量、什么时候该闭麦不动、目标丢了怎么办。
3.1 像素到角度的标定:一个焦距公式加两次实测
最朴素的换算是线性近似:水平视场角除以水平分辨率,得到每像素对应的角度。
import math HFOV = 70.0 # 镜头水平视场角,按实际镜头规格填,常见 2.8mm 镜头约 70 度 W, H = 320, 240 CX, CY = W // 2, H // 2 deg_per_px_linear = HFOV / W # 线性近似,画面中心附近够用 print("linear deg/px:", deg_per_px_linear) # 约 0.219 度/像素线性近似在画面中心误差很小,到边缘就不行了——因为真实成像遵循的是θ = arctan(x / f),角度越大,同样的像素差对应的角度差越大。更准的写法是用视场角反推等效焦距:
# 由水平视场角反推等效焦距(像素单位) f_px = (W / 2) / math.tan(math.radians(HFOV / 2)) def px_to_deg(dx, f_px=f_px): """把水平像素偏差换算成角度偏差,带符号""" return math.degrees(math.atan(dx / f_px))f_px的物理含义是「以像素为单位的焦距」,它把镜头参数和分辨率绑在一起。这个公式的好处是无论目标在画面哪个位置,换算出来的角度都是真实的。代价是要多一次atan和degrees,在 OpenMV 上单次调用开销可忽略,一帧只算两次(水平、垂直各一次)。
标定的实测部分不能省。做法是:把靶纸固定在已知距离,让云台从画面最左扫到最右,记录舵机角度和对应的目标像素横坐标,得到一组点。用这组点反算HFOV,比查镜头手册准得多——手册给的是标称值,实际装配、镜头畸变、分辨率裁剪都会让有效视场角偏小。
| 舵机角度(度) | 目标像素 x | 与中心偏差 dx | 理论角度(HFOV=70) | 实测偏差 |
|---|---|---|---|---|
| -30 | 42 | -118 | -25.8 | -4.2 |
| -15 | 101 | -59 | -12.9 | -2.1 |
| 0 | 160 | 0 | 0 | 0 |
| 15 | 219 | 59 | 12.9 | +2.1 |
| 30 | 278 | 118 | 25.8 | +4.2 |
看到「实测偏差」这一列随角度线性增长,就说明HFOV填大了,按比例缩一下重算即可。我一般会把这个表存成注释放在代码顶上,下次换镜头直接对照。
3.2 增量式 P 控制与死区:为什么你的云台一直在点头
控制律的选择上,很多人第一反应是上完整 PID。打靶这种静态或准静态目标,积分项带来的收益很小,风险却很大:目标短暂丢失时积分会持续累积,等目标回来,舵机会猛地甩过去,这就是典型的积分饱和。微分项对噪声极其敏感,像素坐标本身就有一两个像素的抖动,微分之后会被放大成剧烈的输出跳变。
我一般用增量式 P 加死区加限速,三个约束一起上:
Kp = 0.05 # 增益,度/像素,先从 0.03 试起 DEAD_PX = 6 # 死区,误差小于这个值就不动 MAX_STEP = 3.0 # 单帧最大角度变化,防止过冲 PAN_MIN, PAN_MAX = -45.0, 45.0 pan_angle = 0.0 def update_pan(err_x): global pan_angle if abs(err_x) <= DEAD_PX: return pan_angle # 死区内保持,这是不抖的关键 step = -Kp * err_x # 负号取决于舵机安装方向,装反了就改这里 step = max(-MAX_STEP, min(MAX_STEP, step)) pan_angle += step pan_angle = max(PAN_MIN, min(PAN_MAX, pan_angle)) # 机械限位,保护舵机 return pan_angle三个参数各管一件事。DEAD_PX解决的是「目标静止时云台还在微动」的问题:像素坐标天然有一两像素的跳动,没有死区,P 控制会把这点噪声放大成持续的小幅摆动,肉眼看着就是高频点头。死区设成目标尺寸的 1/5 到 1/8 比较合适,太小没效果,太大会让跟踪出现明显的「台阶感」。
MAX_STEP解决的是过冲。增益给大了,一帧就能把舵机推过目标位置,下一帧再往回推,形成极限环振荡。限速相当于给系统加了阻尼,代价是响应变慢,但对打靶这种不需要高速机动的场景完全值得。
增量式而非位置式,还有一个实际好处:目标切换或者识别短暂失效时,舵机停在当前位置,不会因为「绝对角度突然变成 0」而回中乱甩。位置式控制里这个坑很常见,尤其是用blob.cx()直接乘系数当绝对角度的写法,一旦丢帧就归零。
舵机输出建议带缓动时间,别用瞬时跳变:
from pyb import Servo servo_pan = Servo(1) servo_pan.angle(int(pan_angle), 60) # 第二个参数是毫秒,60ms 内平滑转到位第二个参数给 0 是瞬间到位,舵机会有「咯噔」一下的顿挫,长期这么用齿轮磨损快,画面也会因为反作用力抖一下。给 50–100ms 的缓动,视觉上平滑很多,代价是引入了固定延迟,所以这个值不能太大,超过控制周期就没意义了。
3.3 丢目标后的状态机:保持、搜索、回中
只用if blobs:判断目标存在与否,是打靶程序里最常见的结构缺陷。目标被手挡一下、被反光吃掉一帧,程序就立刻停发指令,舵机要么僵在原地、要么按某个默认逻辑乱走。合格的写法是一个四状态机:
S_TRACK, S_HOLD, S_SEARCH, S_HOME = 0, 1, 2, 3 state = S_TRACK lost_count = 0 LOST_HOLD = 5 # 连续丢多少帧进入保持 LOST_SEARCH = 30 # 连续丢多少帧开始搜索 SEARCH_STEP = 1.5 # 搜索时每帧转多少度 def tick(found, err_x): global state, lost_count, pan_angle if found: lost_count = 0 state = S_TRACK return update_pan(err_x) lost_count += 1 if lost_count <= LOST_HOLD: state = S_HOLD return pan_angle # 短暂丢失,原地不动,等目标回来 elif lost_count <= LOST_SEARCH: state = S_SEARCH pan_angle += SEARCH_STEP # 单向慢扫,找回来 pan_angle = max(PAN_MIN, min(PAN_MAX, pan_angle)) return pan_angle else: state = S_HOME pan_angle += (0 - pan_angle) * 0.15 # 缓慢回中,别一步到位 return pan_angleLOST_HOLD的取值和控制周期挂钩:如果一帧 25ms,5 帧就是 125ms,足够扛过大多数瞬时遮挡。LOST_SEARCH之后的单向慢扫,方向可以按最后一次目标的运动方向来定,比固定方向扫效率高。回中用的是一阶低通而不是直接赋值,避免「搜不到就猛回中」这种突兀动作。
注意:搜索阶段的扫描速度要和识别帧率匹配。扫得太快,目标从视野里划过时可能一帧都没拍到,表现为「明明在眼前却一直找不到」。
4. 通信层:OpenMV 与主控之间的定长帧协议怎么定
OpenMV 可以直接驱舵机,也可以只做「眼睛」,把坐标发给主控。两条路线各有适用面,但不管你选哪条,只要涉及串口,帧格式就是必须自己定的东西——OpenMV 的uart.write()发的是裸字节流,没有包边界,主控那头稍有不慎就会读到半包数据,解析出离谱的坐标。
4.1 串口帧格式设计:定长包加校验
我一般用 8 字节定长帧:两个帧头字节、两个 16 位坐标、一个标志字节、一个异或校验。定长的好处是解析端可以纯靠计数器推进,不需要在字节流里找分隔符。
| 字节 | 内容 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头 1 |
| 1 | 0x55 | 帧头 2,双帧头能大幅降低误同步概率 |
| 2–3 | cx | 小端 16 位,目标横坐标 |
| 4–5 | cy | 小端 16 位,目标纵坐标 |
| 6 | flags | bit0 目标有效,bit1 状态机当前处于搜索态 |
| 7 | checksum | 前 7 字节逐字节异或 |
OpenMV 端这么发:
import struct from pyb import UART uart = UART(3, 115200, timeout_char=100) # UART3 对应 P4/P5,波特率与主控一致 def send_target(cx, cy, valid, searching): flags = (1 if valid else 0) | (2 if searching else 0) pkt = struct.pack('<BBHHB', 0xAA, 0x55, cx & 0xFFFF, cy & 0xFFFF, flags) cs = 0 for b in pkt: cs ^= b uart.write(pkt + bytes([cs]))struct.pack的'<'明确指定小端,主控那边按同样字节序解,跨平台时不会因为默认字节序不同而踩坑。cx & 0xFFFF是为了防止异常值把H格式撑爆抛异常——坐标理论上不会超范围,但识别出错时什么都可能发生,多一层保护不多余。
主控端的解析要用状态机,绝不能「读到 0xAA 就开始取 8 字节」:
typedef struct { unsigned char buf[8]; unsigned char idx; } frame_parser_t; /* 逐字节喂入,返回 1 表示凑齐一帧,结果写入 out_cx/out_cy */ int frame_feed(frame_parser_t *p, unsigned char byte, unsigned short *out_cx, unsigned short *out_cy, unsigned char *out_flags) { if (p->idx == 0 && byte != 0xAA) return 0; /* 等帧头 1 */ if (p->idx == 1 && byte != 0x55) { /* 帧头 2 不对,退回重找 */ p->idx = (byte == 0xAA) ? 1 : 0; return 0; } p->buf[p->idx++] = byte; if (p->idx < 8) return 0; unsigned char cs = 0; for (int i = 0; i < 7; i++) cs ^= p->buf[i]; p->idx = 0; /* 无论校验成败都复位 */ if (cs != p->buf[7]) return 0; /* 校验失败丢弃 */ *out_cx = (unsigned short)(p->buf[2] | (p->buf[3] << 8)); *out_cy = (unsigned short)(p->buf[4] | (p->buf[5] << 8)); *out_flags = p->buf[6]; return 1; }关键在「帧头 2 不对时,如果这个字节本身是 0xAA,就要把它当新帧头 1 保留」。少了这个回溯逻辑,字节流里连续出现两个 0xAA 时会漏掉一整个帧,表现出来就是偶发的坐标跳变。这类 bug 在实验室难得复现,到现场就变成「云台偶尔抽一下」。
timeout_char=100是个容易被忽略的参数。它决定了uart.write()等待发送完成的超时时间,默认值在某些固件上偏短,长帧可能发一半就返回。设成 100ms 足够发完 8 个字节,也不会在主循环里卡太久。
4.2 主控接管还是 OpenMV 直驱舵机
这两条路线的取舍,直接决定程序结构。
| 维度 | OpenMV 直驱舵机 | OpenMV 只发坐标给主控 |
|---|---|---|
| 延迟 | 低,识别完立刻转 | 多一层串口加主控调度,通常 5–15ms |
| 云台与底盘协同 | 几乎做不了 | 方便,主控可统一调度 |
| 多传感器融合 | 困难 | 容易,主控能同时读陀螺仪、编码器 |
| 调试便利性 | 高,改完直接跑 | 中,要两边一起烧 |
| 代码复杂度 | 低,全部逻辑在 OpenMV | 高,协议和状态机要两边对齐 |
打靶这类目标固定、底盘不动的场景,直驱是更优解:延迟低,链路短,出问题的地方少。如果云台要装在移动底盘上做动态跟踪,那就必须走主控接管,否则 OpenMV 看不到底盘的运动状态,云台永远慢半拍。
真要直驱,还有一个折中方案:OpenMV 自己驱舵机,同时把状态和坐标以低频率(比如 10Hz)发给主控,主控只用来做日志记录和上位机显示。这样既能保持低延迟,又能在联调时看到 OpenMV 内部状态,排错效率高很多。
5. 避坑排查:打靶现场翻车的五个瞬间
这些坑几乎每个都踩过,写下来是为了让你少花一个通宵。
现象一:实验室调好的阈值,换个场地全崩,画面整体偏灰或者偏白。原因:set_auto_exposure没关,或者关了但没调exposure_us,传感器仍在按环境光自适应。另一个可能是自动增益没关,暗环境下增益拉满,噪点全部被当成色块。 解决:初始化里把三项自动全关并锁定,换场地时先跑一次print(sensor.get_exposure_us())确认数值合理,再动阈值。顺序永远是「先定曝光,再调阈值」。
现象二:目标静止时云台一直在小幅抖动,肉眼可见的高频点头。原因:死区为零或过小,像素坐标的一两像素跳动被 P 控制放大成持续输出;或者每帧都调servo.angle()且缓动时间设为 0,舵机在到位点附近反复微调。 解决:加DEAD_PX,取值参考目标尺寸的 1/5;舵机输出带 50–100ms 缓动;如果还抖,在误差上叠一层简单的一阶低通,err = 0.7 * err_prev + 0.3 * err_now。
现象三:目标丢失后云台猛甩到一边或者直接回中,回来时对不上。原因:没有状态机,丢帧时pan_angle被赋了默认值;或者用了位置式控制,blob.cx()为空时算出来是 0。 解决:上四状态机,短时丢失原地保持,长时丢失单向慢扫,最终缓慢回中。核心是让舵机角度在自己身上累积,而不是每帧从识别结果重新算。
现象四:串口偶发乱码,主控收到的坐标偶尔跳到 0 或者 65535。原因:解析端没做帧头回溯,遇到连续 0xAA 时错位;或者校验失败后没有复位索引,后续字节全部偏移。 解决:用上面那个frame_feed状态机,重点是校验失败也必须把idx归零。上位机联调时把原始字节打成十六进制日志,对一次就能定位。
现象五:近距离能追上,远距离就慢半拍甚至追丢。原因:ffind_circles全图搜索耗时过长,帧率掉到 10fps 以下,控制周期跟着变长;或者 ROI 窗口固定不跟随,目标走出窗口后每帧都要回退全图搜索。 解决:动态 ROI 跟随目标,窗口尺寸按目标成像大小定;圆检测的r_step从 1 调到 2;实在不行降分辨率到 QQVGA,把帧率换回来。打靶场景里,帧率比分辨率重要得多。
6. 标定验证:把「追得上」做成「打得准」
追踪稳和打得准是两件事。追踪只看质心是否在画面中心,而打靶要的是「舵机停下的位置,对应靶心」。中间差的是一个系统性偏移——镜头光轴和舵机旋转轴不重合、靶纸印刷偏心、激光指示器装歪,都会造成固定偏差。这部分只能靠实测标定。
我的做法是打一张 3×3 的标定表:把靶纸放在固定距离,云台分别在九个方向上稳定下来,记录每次的「目标像素坐标」和「实际弹着点与靶心的偏差」。
| 位置 | 目标像素 (cx, cy) | 弹着偏差 dx (mm) | 弹着偏差 dy (mm) | 补偿角度 (度) |
|---|---|---|---|---|
| 左上 | (72, 58) | -8.5 | -6.2 | (+0.62, +0.45) |
| 中上 | (160, 55) | -0.4 | -7.1 | (0, +0.52) |
| 右上 | (248, 60) | +8.1 | -5.8 | (-0.59, +0.42) |
| 左中 | (70, 120) | -9.2 | +0.3 | (+0.67, 0) |
| 中心 | (160, 120) | +0.2 | -0.2 | (0, 0) |
| 右中 | (250, 118) | +8.8 | +0.5 | (-0.64, 0) |
| 左下 | (74, 182) | -7.9 | +6.4 | (+0.58, -0.47) |
| 中下 | (160, 185) | -0.3 | +6.9 | (0, -0.50) |
| 右下 | (246, 180) | +8.4 | +6.1 | (-0.61, -0.44) |
看出规律了吗——偏差基本和像素位置成线性关系,说明主因是像素—角度的比例系数略微偏小,而不是随机的机械误差。这时候不用做九点插值,直接把f_px按比例放大百分之一二,再重测一遍就能收敛。如果偏差表呈现出明显的非线性(比如四个角偏得多、中心几乎不偏),才需要考虑分区增益或者镜头畸变校正。
补偿的落地方式是在最终角度上叠一个查表偏移:
# 按画面九宫格做分区补偿,表从上表的「补偿角度」列抄来 COMP = { (0, 0): (0.62, 0.45), (1, 0): (0.0, 0.52), (2, 0): (-0.59, 0.42), (0, 1): (0.67, 0.0), (1, 1): (0.0, 0.0), (2, 1): (-0.64, 0.0), (0, 2): (0.58, -0.47),(1, 2): (0.0, -0.50),(2, 2): (-0.61, -0.44), } def comp_offset(cx, cy): gx = 0 if cx < 107 else (1 if cx < 213 else 2) gy = 0 if cy < 80 else (1 if cy < 160 else 2) return COMP[(gx, gy)] # 用法:在 update_pan 算出 pan_angle 之后叠加 off_x, off_y = comp_offset(cx, cy) servo_pan.angle(int(pan_angle + off_x), 60)验证环节别偷懒,用十次静态打靶的落点均值做判据,而不是单次。单次命中说明不了什么,均值才能反映系统性偏差有没有消掉。我一般要求十次的平均偏差落在靶心半径的 1/3 以内,才认为这套参数可以定稿。
提示:标定表一定要写进代码注释里,注明距离、光照条件、镜头型号。换了任何一个条件,表就作废,重测比重调快。
从那以后,我每次改完控制参数,都会强制走一遍「九点标定 → 十次静态打靶 → 记录均值」这套流程,哪怕只改了一个Kp的小数点。参数之间的耦合比想象中强,凭感觉调出来的「看起来没问题」,到现场往往就是差那几毫米。希望帮到你。
本文还有配套的精品资源,点击获取