去年做自动对接小车的时候,我在测距方案上折腾了大半个月。超声波雷达在3米外就有点飘,双目摄像头标定一次要半天而且光线一变就要重来,最后同事建议我试试Apriltag加单应矩阵的思路——结果这一试就停不下来了。
当时网上关于Apriltag的教程,绝大多数都在讲怎么识别和解码,讲到"识别出来了然后呢"就没了。真正要把标签坐标换算成实际距离,让小车能靠它完成停靠,中间还隔着一层单应矩阵的数学关系和一堆工程细节。这篇文章就把我踩过的坑和最终跑通的完整方案整理出来,从矩阵原理讲到像素坐标怎么一步步变成厘米级的实际距离,适合正在做单目测距、AGV停靠、无人机降落这类项目的朋友参考。
1. 为什么测距我最后选了Apriltag:几套单目方案的实际对比
先说结论:在室内近距离(0.3到5米)这个区间,Apriltag配合单目相机,是性价比和稳定性平衡得最好的方案之一。它不需要额外硬件,一个普通USB摄像头加一张打印纸就能开工,而且精度完全够大多数机器人应用使用。
1.1 和ArUco、二维码相比,Apriltag赢在哪
网上很多人拿Apriltag和ArUco对比。两者原理上非常像,都是通过检测黑色四边形角点来定位,但实际用下来Apriltag有几个明显优势:
- 角点检测精度更高。Apriltag库内置了边缘细化(refine edges)步骤,可以在整数像素基础上做亚像素级别的角点修正,这对后续解算距离影响非常大。
- 字典设计更讲究。tag36h11这个家族里面的每一个tag,汉明距离设计得足够大,误检率极低。我在强反光地板和贴满贴纸的货架上测试过,误检概率比ArUco低不少。
- 遮挡鲁棒性更好。标签边缘被遮住一部分时,Apriltag的检测器仍然能通过四边形轮廓拟合出角点位置。
二维码和普通二维码标签不是不能做,但它们的设计目标是"存信息",不是"精确测位"。QR码的定位图案虽然也能给出角点,但那个角点精度远不如专门为定位设计的Apriltag。加上QR码decode需要图像足够清晰,距离一远或稍微模糊一点就整个读不出来,而Apriltag即使解码失败,只要四个角点还在就能完成测距。
1.2 为什么不直接用solvePnP配合任意特征点
有朋友会问:既然最后还是用solvePnP解位姿,那我随便贴几张带特征点的图不就行了?
理论上是可以,实际工程里没人这么干。原因很简单:solvePnP的精度非常依赖"世界坐标系下的3D点"和"图像坐标系下的2D点"之间的对应关系是否准确。Apriltag的价值在于它提供了一套端到端的管线——检测、识别、角点精确定位一气呵成,而且角点在世界坐标系下的坐标是已知的(以标签中心为原点,四个角点坐标固定)。你不需要自己去做特征点匹配、去除误匹配、处理旋转模糊,这些恰恰是视觉里最容易翻车的环节。
另外Apriltag从设计上就解决了"这个四边形到底是不是我要找的标签"的问题。它内部通过编码区域识别出标签的ID,并且有校验机制排除掉类似黑色边框、门缝这类假阳性四边形。用特征点匹配的话,你得自己处理场景中重复纹理导致的匹配错误。
1.3 精度预期先摆在这里
我用自己的项目实测数据说话:tag36h11家族,标签边长9厘米,相机分辨率1280x720,普通广角镜头(焦距约3mm),在0.3米到3米范围内,距离误差基本能控制在1%以内。也就是1米处误差在1厘米上下,3米处误差在3厘米上下。3米开外误差会加速增大,到5米时大概有10厘米左右的偏差,具体取决于镜头解析力和标签打印质量。
这个精度在自动化停靠、定点抓取这类场景是完全够用的。但如果你想要毫米级精度,单靠Apriltag加普通摄像头不太现实,那是激光雷达或者高精度双目系统的主场。
2. 单应矩阵到底在算什么:从像素坐标到位姿的数学链条
很多教程直接甩出cv2.solvePnP然后告诉你"传参就能用",但一旦遇到结果不对、距离偏大偏小这类问题,不懂原理的人根本无从排查。这里把数学链路拆开讲清楚,后面调参的时候你就知道自己在干什么。
2.1 相机投影模型这一条链
三维世界里一个点,是怎么变成图像上一个像素的?整个过程分两步:
第一步是外参变换,把世界坐标系下的点变换到相机坐标系下:
Pc = R * Pw + tPw是点在Apriltag坐标系下的三维坐标(标签中心为原点)Pc是同一个点在相机坐标系下的坐标R是旋转矩阵(3x3),t是平移向量(3x1)
第二步是内参投影,把相机坐标系下的点投到像素平面上:
s * [u, v, 1]^T = K * Pc这里K是内参矩阵:
K = [[fx, 0, cx], [ 0, fy, cy], [ 0, 0, 1]]把两步合并就是完整的投影方程:
s * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T这个[R | t]就是相机的外参,也就是我们要从单张图里解出来的东西——Apriltag相对于相机的位置和朝向。
2.2 为什么平面标签能用一个3x3矩阵搞定
如果标签不是一个平面,而是一个任意三维物体,那[R | t]里有6个自由度(3个旋转+3个平移),需要至少3个非共线的点才能解,而且要多视图才能稳定。
但Apriltag是平面标签,这意味着标签坐标系下的所有点都满足Z=0。投影方程就退化成了:
s * [u, v, 1]^T = K * [r1, r2, r3, t] * [X, Y, 0, 1]^T = K * [r1, r2, t] * [X, Y, 1]^Tr3那一列直接被消掉了,[r1, r2, t]拼起来是一个3x3的矩阵,这就是单应矩阵H:
H = K * [r1, r2, t]所以单应矩阵的本质是:在已知平面世界坐标的前提下,像素坐标和平面坐标之间只差一个3x3的线性变换。一个四边形标签的四个角点,刚好提供8个约束方程(每个点提供2个方程:u和v),够解出这个8自由度的H(3x3共9个元素,减去一个尺度因子)。
这个过程在OpenCV里一条cv2.findHomography就搞定了,但知道它内部在解什么,对你后面排查问题有好处。
2.3 从H到位姿:为什么要归一化和正交化
有了H,能不能直接拆出R和t?
从数学上可以:
B = K^(-1) * H = [r1, r2, t]理论上r1和r2是单位正交的,但实际估计出来的B因为噪声,r1和r2的模长不一定等于1,夹角也不一定正好90度。所以标准的做法是:
- 计算尺度因子
λ = 1 / (||B[:,0]||的平均值),把B除以这个尺度得到B' - 取
r1 = B'[:,0],r2 = B'[:,1],r3 = r1 × r2(叉积) - 用SVD对
[r1, r2, r3]做正交化修正,U * V^T就是最接近的正交矩阵 t = B'[:,2]
这套流程看起来不复杂,但实际项目里我强烈建议直接用cv2.solvePnP来做位姿解算,而不是手动分解H。原因有两个:
- 手动分解H对噪声极其敏感,角点检测稍微偏一点,解出来的姿态就抖得厉害。
- OpenCV的solvePnP内部是迭代优化算法,会同时用上所有标定的内参,对畸变也有补偿,鲁棒性好一个量级。
H在这里的正确用法是:先理解它、验证它、用它做初始值,最终位姿交给solvePnP去精确求解。
2.4 一个最容易搞错的概念:H不是距离
很多人一听到"单应矩阵测距",以为直接拿H的某个元素就能读出距离。这是个常见的误解。
H把图像坐标映射到标签平面坐标,它本身不包含相机到标签的实际物理距离信息。真正的距离信息在t(平移向量)里——也就是标签坐标系原点在相机坐标系下的位置。你需要做的是:解出R和t,然后看t的模长或者Z分量。
所以整条链路的正确顺序是:
- 检测标签角点 → 得到像素坐标
- 结合已知的标签三维角点坐标 → 用solvePnP解出R和t
- 从t里算距离
H是这条链路的底层数学基础,但它不是直接输出物。这也是为什么很多初学者看教程会懵——教程说"用单应矩阵测距",结果代码里全是solvePnP。
3. 动手前的硬准备:内参标定与标签制作的避坑要点
这块内容看起来基础,但恰恰是最容易出问题的地方。我之前帮朋友排查过好几个测距不准的case,最后发现都是内参不对或者标签实际尺寸和输入尺寸对不上。
3.1 为什么必须做内参标定
内参矩阵里的fx和fy本质上是焦距的像素表示,它的数值直接决定了单位像素角度对应的物理尺寸。打个比方,内参错了5%,你测出来的距离也会大概偏5%。1米处就是5厘米的误差,这在精确停靠场景里根本没法接受。
标定方法用OpenCV棋盘格就行,不复杂:
- 打印一张棋盘格标定板,尽量贴在完全平整的硬板子上。
- 用你要用的相机拍20到30张照片,覆盖画面的各个区域,包含各种倾角。
- 跑一遍
cv2.calibrateCamera拿到K和畸变系数。
几个容易忽略的细节:
- 标定时画面分辨率必须和实际运行分辨率一致。如果你标定用1280x720,但跑的时候用640x480,内参直接废掉。
- 标定板一定要在画面边缘多拍几张。边缘区域对畸变参数的约束最强。
- 如果镜头是可调焦的(比如有些工业相机的手动镜头),标定完之后千万不要再动焦距,否则内参全部作废。
3.2 标签生成的几个参数怎么定
Apriltag标签生成用现成库就行,Python的apriltag包里自带生成工具。我用的是tag36h11家族,它的编码密度适中,识别距离和角点精度比较均衡。
标签尺寸的选择原则很直接:工作距离越远,标签就要越大。经验公式大概是:
- 0.5米内:标签边长不小于3厘米
- 1到2米:标签边长不小于6厘米
- 2到4米:标签边长不小于10厘米
- 4米以上:标签边长最好到20厘米以上,同时对相机分辨率要求也高
标签的物理尺寸在设计阶段就要确定,因为程序里写死的三维坐标是以这个尺寸为基准的。
打印时有一个特别注意点:不要直接相信标称尺寸。打印机的缩放比例、纸张的吸湿变形都可能让实际尺寸偏个1到2毫米。解决方案是打印完后用游标卡尺实际量一下标签的物理边长,然后把这个值作为基准。1毫米的尺寸误差在短焦镜头上就会造成可见的距离误差,这个账很好算:标签实际尺寸比标称小1%,解出来的距离就会比实际距离大约1%。
3.3 相机安装固定的工程细节
这部分容易被忽略,但它直接决定你最终能拿到多少精度。相机一旦固定后,尽量不要让它产生任何位移和旋转。我第一版测试时用的塑料支架,隔几天就要重新调一次,后来换成铝合金支架加螺纹胶固定,一劳永逸。
如果你还需要知道"标签在某个世界坐标系下的位置"(而不仅仅是相机到标签的距离),那相机的位置和姿态也要标定。通常的做法是测量相机安装高度和俯仰角,然后把相机坐标系的结果投影到世界坐标系。不过如果你的场景只需要垂直距离(相机正对着标签平面),那直接把tvec的Z分量拿来用就足够了,不需要额外的外参标定。
4. 从图像到距离:完整实现与位姿分解的关键代码
到这里进入实际代码环节。我用Python的apriltag库加OpenCV,给你一个可以直接跑通的完整流程。
4.1 检测标签:参数如何影响精度
apriltag库(即dt-apriltags或apriltag包)的检测器主要参数有这几个:
quad_decimate:图像预处理降采样倍数。设为1.0表示不降采样,精度最高但速度慢;设为2.0会快很多,但小尺寸标签可能漏检。精度优先就设1.0。refine_edges:是否对角点做细化修正。我实测开启后角点精度提升明显,特别是在边缘模糊的情况下。decode_sharpening:解码锐化强度,对测距精度影响不大,保持默认即可。
一个经验:测距精度取决于角点质量,而不是解码成功率。所以即使标签内容被部分遮挡导致ID解不出来,只要四边形检测到了,角点仍然可以用。但detector默认在解码失败时会丢掉这个四边形,所以需要把decode_sharpening调低一点或者直接使用仅检测模式。apriltag库的Detection对象里有corners,这是四边形的四个角点,对应顺序是逆时针从左上角开始。
4.2 objectPoints的排列:一个最容易错的地方
调用solvePnP时,三维点objectPoints和二维点imagePoints必须一一对应。Apriltag的corners顺序是:左上、右上、右下、左下(逆时针)。所以三维坐标也要按同样的顺序:
object_points = np.array([ [-tag_size/2, -tag_size/2, 0], # 左上 [ tag_size/2, -tag_size/2, 0], # 右上 [ tag_size/2, tag_size/2, 0], # 右下 [-tag_size/2, tag_size/2, 0], # 左下 ], dtype=np.float32)这个顺序错一个,解出来的R和t就是完全错误的数值,而且不会报错——这是最坑的。
4.3 完整代码示例
import cv2 import numpy as np from apriltag import apriltag # 不同的库导入方式略有差异,根据实际安装的包调整 def detect_tag_pose(image, camera_matrix, dist_coeffs, tag_size): # 1. 转灰度并检测标签 gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) detector = apriltag("tag36h11") detections = detector.detect(gray) results = [] for det in detections: # det.corners 形状 (4, 2),类型 float image_points = det.corners.astype(np.float32) # 标签四个角点在标签坐标系下的三维坐标(单位:米) half = tag_size / 2.0 object_points = np.array([ [-half, -half, 0], [ half, -half, 0], [ half, half, 0], [-half, half, 0], ], dtype=np.float32) # 2. 使用 solvePnP 恢复位姿 # SOLVEPNP_ITERATIVE 适合平面目标,如果内参精度高也可以用 SOLVEPNP_SQPNP success, rvec, tvec = cv2.solvePnP( object_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE ) if not success: continue # 3. 计算距离 # tvec 表示标签中心在相机坐标系下的坐标 [Xc, Yc, Zc] Xc, Yc, Zc = tvec.flatten() euclidean_dist = np.linalg.norm(tvec) # 相机到标签中心的直线距离 vertical_dist = Zc # 相机到标签平面的垂直距离(假设光轴正对) # 4. 把旋转向量转成欧拉角,方便观察姿态 R, _ = cv2.Rodrigues(rvec) euler = cv2.RQDecomp3x3(R)[0] # 返回 (x, y, z) 欧拉角 results.append({ "id": det.id, "corners": det.corners, "rvec": rvec, "tvec": tvec, "euclidean_dist": euclidean_dist, "vertical_dist": vertical_dist, "euler_deg": euler, }) return results # 使用示例 # camera_matrix 和 dist_coeffs 来自内参标定结果 # tag_size 是标签的实测物理边长,单位米,比如 0.09 # results = detect_tag_pose(frame, camera_matrix, dist_coeffs, 0.09)这段代码的关键输出是tvec:它是标签中心在相机坐标系下的三维坐标。Zc代表标签平面到相机光心的垂直距离,euclidean_dist则是直线距离。在相机基本正对标签的情况下两者差别不大,但如果相机是斜着安装的,差别就会很大,下一节详细讲。
4.4 一个验证H正确性的小实验
如果说你现在只是想知道单应矩阵有没有算对,可以做一个快速验证:打印一张9x9厘米的Apriltag,用手机拍照,手动在图像上标出左上角像素坐标,然后用cv2.findHomography把它投影回标签坐标系,应该得到(-0.045, -0.045)这个坐标。
# 手动验证单应矩阵 corners_img = np.array([[...]], dtype=np.float32) # 图像上检测到的角点 corners_world = np.array([[...]], dtype=np.float32) # 对应标签坐标 H, _ = cv2.findHomography(corners_world, corners_img) # 注意:findHomography 的输入输出方向,不要反了 # 验证 test_img_point = corners_img[0] test_world_point = np.linalg.inv(H) @ np.array([test_img_point[0], test_img_point[1], 1]) test_world_point /= test_world_point[2] # 结果应该接近 (-half, -half)这个实验能帮你确认整个图像处理链路没问题,再往下调solvePnP才有意义。
5. 斜视场景下的距离换算:相机坐标Z轴不等于实际测距值
很多做机械臂抓取或者AGV对接的朋友,相机装的并不是正对着标签。这种情况下直接把tvec的Z分量当距离用,误差会很大。这里说说怎么正确处理。
5.1 先约定坐标系
相机坐标系(OpenCV约定):X轴向右,Y轴向下,Z轴指向前方(相机看的那个方向)。标签坐标系:原点在标签中心,X轴向右(沿标签长边),Y轴向下,Z轴垂直标签平面朝外(朝向相机)。
tvec给的是:标签原点在相机坐标系下的坐标(Xc, Yc, Zc)。注意这个坐标不是相机在标签坐标系下的坐标,方向是相反的,计算时别搞混。
5.2 什么情况下Zc等于距离
相机光轴正对标签中心的时候,Xc和Yc接近0,Zc约等于相机到标签的垂直距离。这是最理想的情况。
但实际安装中相机几乎不可能做到绝对正对,总有点俯仰角或偏航角。举个例子:相机光轴中心偏移了标签中心2厘米,在1米远的距离上,Xc=0.02,Zc算出来是0.9998米,影响不大。但如果相机斜着俯视(比如45度角),那Zc和实际直线距离就差的多了。
5.3 直线距离、垂直距离和水平距离的区分
实际项目里到底要哪个距离,取决于你的机器人怎么运动:
- 直线距离(欧几里得距离):
dist = sqrt(Xc^2 + Yc^2 + Zc^2)。适合无人机对准降落点这类场景。 - 垂直距离:
dist = Zc。适合相机光轴垂直标签平面,需要知道高度差的情况。 - 水平距离:
dist_h = sqrt(Xc^2 + Zc^2)。适合地面机器人,相机有俯仰角,但需要知道在地面上的投影距离。这时Yc其实就是相机和标签的安装高度差,把它去掉,剩下的就是水平距离。
我之前做AGV停靠用的就是水平距离。相机装在车头大约80厘米高度,以大约15度俯仰角往前下方看。如果直接用Zc当停靠距离,车越近误差越大。换成水平距离之后,停靠精度从5厘米提升到了1厘米左右。
5.4 滑窗滤波让距离输出更稳定
单帧solvePnP解出的距离会有随机抖动,在近距离时尤其明显。如果直接拿单帧值做控制,机器人会一顿一顿的。
我用的方案是简单的滑动窗口滤波:
from collections import deque class DistanceFilter: def __init__(self, window_size=5): self.buffer = deque(maxlen=window_size) def update(self, value): self.buffer.append(value) return sum(self.buffer) / len(self.buffer)窗口大小一般取5到10帧。太小滤波效果差,太大会导致响应迟缓。如果你的机器人移动速度比较快,可以适当减小窗口;如果基本静止,取大一点反而稳。
另外一个注意点:不要对像素坐标做滤波后再解算位姿,而是对最终的距离做滤波。因为像素坐标和位姿是非线性关系,对中间量滤波反而会引入偏差。
6. 实测精度与误差根因:影响测距结果的六个关键因素
这部分完全是踩坑出来的经验。把每个因素拆出来量化一下,你就知道在你自己的项目里该往哪个方向投入精力。
6.1 打印尺寸误差是最隐蔽的坑
标签的物理尺寸直接进入object_points。如果你的标签设计是9x9厘米,但打印机在纸张上的实际输出是8.95x8.95厘米,那么solvePnP解出来的所有距离都会统一偏大约0.55%。1米处偏5.5毫米,3米处偏1.65厘米。
这个误差方向也很固定:实际尺寸比输入小,距离就会偏大。因为相机看到的角点像素跨度是固定的,程序里假设的物理跨度越小,解出的距离就越远。
解决办法前面说过:打印后必须实测边长,然后把实测值写进程序。如果你用的是覆膜防水标签,记得量的是覆膜后的尺寸。
6.2 角点检测精度和距离误差的放大关系
这是个理解整个系统误差模型的关键公式。在近似正对的场景下,小角度近似有:
d(distance) / distance ≈ d(pixel) / (focal_length * tag_pixel_size / distance)实际翻译成人话就是:距离越远,同样的角点像素抖动造成的距离误差越大。假设焦距为800像素,标签在1米处占了200像素宽度,一个0.1像素的角点误差对应大约1米×0.1/200=0.5毫米的距离误差,影响很小。但标签在5米处只占40像素宽度,同样的0.1像素角点误差就对应5米×0.1/40=1.25厘米的距离误差。
这告诉我们一个工程上的优先级:3米以内,先保证内参和标签尺寸准确;3米以外,再抠角点精度已经意义不大,优先换更高分辨率相机或更大尺寸标签。
6.3 相机分辨率和焦距的决定性作用
相机分辨率决定了标签在图像里占多少像素,也就是角点定位的绝对精度上限。同样一个9厘米标签,在640x480分辨率下2米处可能只占60像素宽度,角点检测误差直接到0.3像素量级;换到1920x1080分辨率,同一个标签占180像素宽度,角点精度立刻提升三倍。
但分辨率不是越高越好。高分辨率意味着更慢的处理速度和更大的数据带宽。我的建议是:只要能保证工作距离最近时标签完整出现在画面里,并且最远时标签边长不小于30像素,就够用。低于30像素时,solvePnP的结果基本不可信,解码更是大概率失败。
6.4 识别距离和标签尺寸的经验关系
根据我不同项目里的实测数据,梳理了一份经验表(以1280x720、一般广角镜头为例):
| 标签边长 | 可靠识别距离 | 精度较好范围 |
|---|---|---|
| 5 cm | 0.3 - 2 m | 0.3 - 1.5 m |
| 10 cm | 0.5 - 4 m | 0.5 - 3 m |
| 20 cm | 1 - 6 m | 1 - 5 m |
这个表在不同镜头下会有差异,建议在你自己项目里实测一组数,建立自己的经验表格。
6.5 运动模糊和滚动快门是动态场景的天敌
如果你的相机装在运动的机器人上,运动模糊会直接导致角点检测偏移,而且这个偏移方向是系统性的(都朝着运动方向偏),没法靠滤波消除。常用的缓解手段:
- 提高快门速度,优先保证不糊,哪怕提高ISO引入噪声。
- 尽量让相机光轴垂直于运动方向,这样标签边缘的运动模糊不会造成单方向偏差。
- 如果场景允许,在机器人减速后再做最终测距和停靠动作。
滚动快门的问题更隐蔽。相机传感器逐行曝光,标签在画面中上下两部分的成像时间不同。如果你在移动中拍照,标签会因为滚动快门产生倾斜变形,角点位置也会跟着偏。这个误差在没有机械快门的消费级摄像头里基本没法完全消除,只能通过降低运动速度来减小影响。
6.6 相机安装角度和俯仰角对误差的影响
相机如果以较大俯仰角斜视标签,标签在图像中的变形会更剧烈,角点检测的难度加大,同时同一像素误差对应的距离误差也更大。以60度俯仰角为例,标签在图像上下边缘的尺度差异非常大,四边形的形状很不规则,solvePnP解的稳定性明显下降。
实测数据:正对时(俯仰角0度)3米处误差约2厘米,30度俯仰角时3米处误差扩大到4厘米,60度俯仰角时误差直接到10厘米以上。如果场景允许,尽量把相机装成正对小角度斜视,不要追求特别大的俯仰角。如果必须大角度斜视(比如天花板安装向下看),建议加大标签尺寸或者把标签稍微朝向相机倾斜。
下面这张表汇总了各因素的典型影响量级,方便你对照排查:
| 因素 | 典型误差量级 | 影响方向 |
|---|---|---|
| 标签尺寸误差 1% | 距离误差约1% | 系统性偏差 |
| 内参 fx 误差 1% | 距离误差约1% | 系统性偏差 |
| 角点检测误差 0.2像素(1米处) | 约1-2mm | 随机抖动 |
| 角点检测误差 0.2像素(3米处) | 约1-2cm | 随机抖动 |
| 俯仰角 30度 | 相对正对误差约增加1倍 | 系统性+随机 |
| 运动模糊 1-2像素 | 可能产生数厘米级偏差 | 系统性偏移 |
7. 一个实际案例的完整排查过程:为什么测距突然偏大了
最后分享一个我真实遇到过的排查案例。有段时间测试小车在固定停靠点的测距结果突然从稳定的1.00米变成了1.05米,而且各个方向齐齐偏大。这个问题排查了一下午,最后的原因说出来你可能觉得简单到离谱。
7.1 现象和初步判断
小车停在完全相同的物理位置,但程序输出的距离从100.2厘米变成了105.4厘米。稳定偏大,不是抖动。这说明是系统性误差,不是随机噪声。
我的排查思路是:
- 先看图像是否正常,标签是否完整清晰。
- 打印当前标签的角点坐标,看有没有异常偏移。
- 检查内参是否被意外修改(比如程序里有没有重新标定过)。
- 检查标签是否更换过或者位置有移动。
- 用另一张已知位置的固定标签做交叉验证。
图片和角点看起来都正常,内参也没被动过。我把置信区间进一步缩小:既然距离整体偏大5%,要么是标签实际尺寸比程序里的小,要么是相机到标签的真实几何关系发生了变化。
7.2 最终定位:相机支架松了
排查到一半,我无意间用手碰了一下相机支架,发现支架有点松动。用力一推,相机往下转了一点点。我先把支架拧紧,再跑一次测距,距离立刻恢复到了100.0厘米。
原因分析:相机支架松了之后,相机俯仰角变大了约2度,光轴从原来的正对标签中心变成了略微往下看。这个微小的角度变化,直接导致Zc分量变大,而程序里用的恰好是Zc作为垂直距离。在1米距离上,2度的俯仰角变化造成的Zc偏差大约是1 - cos(2°) ≈ 0.0006米,理论上只有6毫米,为什么实际有5厘米?
因为当时程序不是用Zc,而是用了tvec的欧氏距离sqrt(Xc^2+Yc^2+Zc^2)。支架松动导致相机往下偏,标签在画面中的位置从中心移到了上方,Yc直接从接近0变成了大约4.8厘米,欧氏距离一下子就被拉大了。说到底,是我用的距离公式和相机安装几何不匹配的问题——在相机正对标签时才该用欧氏距离,但当时的安装并不满足这个前提。之后我把逻辑改成了先判断标签在画面中的位置,偏离中心过远时输出水平距离而不是欧氏距离,这个问题从根上就避免了。
7.3 从这个案例里总结出的工程教训
- 固定相机的机械结构比算法更影响精度。任何轻微的松动都会让位姿解算结果漂移,尤其是相机离标签远的时候,微小角度变化都会被放大。
- 不要盲目用欧氏距离。在AGV这类地面机器人上,
水平距离 = sqrt(Xc^2 + Zc^2)比欧氏距离和垂直距离都更合理,也更能容忍相机的微小角度变化。 - 部署自检逻辑。我后来在每次启动时增加了一个自检步骤:用固定在已知位置的标签测一次距离,如果偏差超过阈值就报警提示检查机械结构。这个小改动让现场运维省了很多事。
- 记录每一次变更。标签更换、相机拆装、程序版本更新,任何变更都可能引入系统性偏差。建议在配置文件中记录标签实测尺寸、标定日期、相机安装角度,排查问题会快得多。
写在最后
AprilTag加单应矩阵这套方案,看起来代码量不大,但每个环节都有坑。从标签打印的毫米级误差到相机支架的松动,任何一个细节没做好,最后反映出来的都是几厘米甚至几十厘米的距离偏差。我现在的习惯是:拿到一个标签先量尺寸,装完相机先做一个固定距离的验证测试,程序里同时输出欧氏距离和水平距离方便对比。这套流程跑下来,测距模块基本上不怎么需要操心。
如果你也在做类似的项目,建议先按文章里的代码把最基本的链路跑通,然后拿一把卷尺实测几个不同距离点,画出误差曲线。只要误差是线性的,说明代码逻辑没问题,剩下的事情就是调整系统偏差了。