
做机器人的朋友应该都有过这种经历想测个距离拿起超声波及红外一量方向稍稍偏一点就偏得离谱想定位吧好一点的激光雷达价格劝退普通摄像头又不知道怎么从图像里把“物体在哪儿、离我多远”给算出来。这篇文章想分享的是一套我用了很长时间、在多个小项目里反复打磨过的方案——基于 ArUco 标签的视觉定位、测距与三维位置估算核心工具就是 OpenCV。它可以做到识别画面里贴好的 ArUco 码输出它的 ID、中心坐标、相对相机的距离甚至给出标签在相机坐标系下的完整 6 自由度位姿X、Y、Z 位置以及绕三个轴的旋转角。整套方法不需要昂贵的硬件一个普通 USB 摄像头、一张打印出来的标签纸、再加一段不算复杂的 Python 代码就能跑起来。适合谁来参考如果你在做小车避障、机械臂抓取、无人机定点降落、AR 叠加渲染或者只是想给大学课程设计、毕业设计找一个靠谱的视觉定位方案这篇文章的内容基本可以照抄。就算你之前没接触过相机标定和位姿解算我也会点差布尽量讲清楚背后的原理和每一步的要坑。1. 为什么选 ArUco 做视觉定位原理与方案选型1.1 ArUco 标签的构成与识别逻辑ArUco 标签本质上是二进制方形标记跟二维码有点像但结构简单得多。它的内部是一个由黑白小块组成的网格不同的网格图案对应不同的 ID外圈围着一圈黑色边框这个边框不是为了好看而是为了让检测算法能快速通过“黑色方块”找到候选区域。OpenCV 的 aruco 模块检测标签时大致走的是这样一条链路先把图像转成灰度图再做自适应阈值处理把图像变成黑白分明的二值图接着用轮廓提取找出所有闭合的黑白区域再用四边形逼近筛选出那些“看起来像正方形”的候选块然后对候选块的内部网格进行透视校正和采样得到一个二进制矩阵最后拿这个矩阵去和当前使用的字典做匹配匹配上就返回 ID匹配不上就丢弃。整个过程在普通 CPU 上处理一帧 640x480 的图像耗时基本在 10 毫秒以内性能完全不是问题。这里有个容易理解错的地方ArUco 标签本身不携带任何实际物理尺寸信息。它能告诉你“我检测到了 ID 为 3 的标签”但如果你不告诉程序这张标签实际打印出来有多宽程序是无法算出距离的。所以使用 ArUco 做测距有一个必要的前置条件——你要知道自己打印的标签纸的边长是多少以米为单位这个参数叫 marker length。1.2 为什么不用二维码、棋盘格或特征点匹配有人会问直接用 OpenCV 的二维码检测不也能定位吗确实能但二维码的检测模块设计上更偏重信息量存储大量的字符串内部结构复杂在光照变化剧烈、视角倾斜严重时识别率明显下降。ArUco 字典更稀疏、容错更好、抗遮挡能力更强对实时定位场景更友好。棋盘格常用于相机标定但它必须整块可见才能解算出有效的角点部分遮挡时就无从下手。ArUco 只要有一个角点被遮住靠另外三个角点仍然能解算出位姿。至于传统 ORB、SIFT 特征点匹配在没有先验纹理特征的场景下几乎无法工作更不用说 ArUco 的 ID 天然自带“身份识别”能力这是普通特征点完全做不到的。如果项目需要更高精度和更强的抗遮挡能力可以考虑 AprilTag它与 ArUco 类似但编码和检错机制不太一样在特定机器人和无人机领域用得很多。不过 AprilTag 在 OpenCV 主仓库里没有直接集成需要额外编译或安装第三方库而对于绝大多数需求来说OpenCV 内置的 ArUco 已经足够这也是我选择它作为主方案的原因。1.3 字典概念为什么是 DICT_6X6_250ArUco 提供了很多种预定义字典常见的包括 DICT_4X4_50、DICT_5X5_100、DICT_6X6_250、DICT_ARUCO_ORIGINAL 等。命名里的第一个数字表示内部网格尺寸比如 6x6 表示内部是 6 乘 6 的格子第二个数字表示该字典最多可以表示的 ID 数量如 250 表示可以编码 0 到 249 共 250 个不同标签。我默认会选择 DICT_6X6_250原因是它在识别稳定性、视觉特征丰富度和 ID 容量之间找到了一个比较好的平衡点。4x4 的标签内部信息太少稍微模糊一点就很容易误判7x7 或更大的标签识别率确实高但打印出来占用的面积也大而且近距离观看时占据的像素更多给画面中的其他目标留出的空间就少了。6x6 的标签在 1 米外、旋转 30 度的条件下用普通 720p 摄像头实测依然能保持较高的识别成功率。2. 环境准备与相机标定2.1 OpenCV 的版本选择和 aruco 模块的坑很多人在环境搭建这一步就卡住了。ArUco 模块在主 OpenCV 仓库里不存在它在 opencv-contrib-python 这个扩展包里。也就是说如果只安装普通的 opencv-python你会得到一个“module ‘cv2’ has no attribute ‘aruco’”的报错。正确做法是卸载掉原来的 opencv-python然后安装 opencv-contrib-python。要注意的一点是opencv-contrib-python 和 opencv-python 不能同时安装否则会因为命名空间冲突导致各种诡异问题。安装命令很简单pip uninstall opencv-python opencv-contrib-python pip install opencv-contrib-python如果你用的是 4.x 以上版本直接import cv2然后cv2.aruco就能用了。还有一个小坑不同版本的 aruco API 有些变化。比如 OpenCV 4.7 之前获取字典的写法是cv2.aruco.Dictionary_get(cv2.aruco.DICT_6X6_250)但在 OpenCV 4.7 之后推荐改成cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250)。两个写法在新版本里都能用但老写法会有一条 DeprecationWarning。本文后面展示的代码我会用兼容性更好的写法方便直接复制运行。2.2 相机内参与畸变系数的标定方法前面说过ArUco 测距的前提是知道标签实际物理尺寸还有一个同样重要的前提是相机的内参矩阵和畸变系数。内参矩阵主要包含焦距和光心坐标畸变系数描述镜头成像时产生的桶形或枕形畸变。如果你不做标定直接用默认内参去解算位姿结果会像戴着一副度数不对的眼镜看世界——距离和角度都会存在不可忽略的系统误差。实验室里最好用的方案是打印一张棋盘格标定板用 OpenCV 自带的样例脚本去标定。你可以把这个过程理解成让相机从不同角度拍下几十张棋盘格照片程序通过已知的棋盘格角点世界坐标和图像坐标之间的对应关系反向解算出相机内参。这里给出一个最简化的相机标定流程用打印机输出一张棋盘格推荐 9x6 的格子数量贴在一个平整硬纸板上。用待标定的相机从多个角度、多个距离拍摄 20 到 30 张照片保证棋盘格在画面中清晰完整。用 OpenCV 的 findChessboardCorners 提取每张图中的角点坐标。调用 calibrateCamera 计算内参矩阵和畸变系数。把结果保存到一个 numpy 文件里后续程序直接加载。代码方面我在第 3 部分里会再细化。如果你觉得标定麻烦也可以用 OpenCV 提供的网络摄像头标定交互工具但那个工具我在实际使用中感觉容易漏帧还是自己写脚本更可控。2.3 标定过程中的几个要点标定棋盘格时有一个经常踩的坑不要把标定板放在画面正中间拍一堆照片就结束了。因为照片如果都是同样的角度、同样的位置解算出来的内参矩阵会很“偏科”对靠近画面边缘的区域误差极大。正确做法是让标定板尽量出现在相机的各个角落并且倾斜角度要有变化。距离也要拉开从近到远都拍一些。另一个要点是粘贴标定板的底板要绝对平整稍微凹凸不平都会导致角点位置和理想坐标模型有偏差。我试过把棋盘格贴在快递纸箱上结果标定出来焦距值偏得离谱换了玻璃板和双面胶之后才恢复正常。最后保存内参时建议单独存成文件不要每次跑代码都重新标定一次。相机出厂后内参在相当长时间内是稳定的除非拧过镜头焦距否则一次标定可以用很久。3. 核心代码实现检测、测距、三维位置估算3.1 单目测距和三维定位的几何原理单目测距的本质是根据“已知物体尺寸 物体在图像中的像素尺寸”来反推深度这是针孔相机模型下的一个经典计算。设标签真实边长为 L 米在图像中检测到的像素边长是 p 像素相机焦距为 f 像素那么标签中心到相机的垂直深度 Z 约等于Z f * L / p这里得到的 Z 其实是沿相机光轴方向的距离而不是“标签到相机镜头中心的直线距离”。如果标签正对着相机两个值差不多如果标签偏到了画面边缘或者与相机光轴有大角度倾斜直接用这个方法算出来的距离就会有偏差。更严谨的做法是用 solvePnP 解出完整位姿后直接取平移向量的长度作为直线距离。solvePnP 的中文是“解算 n 点透视问题”。它干的事情是已知标签上四个角点在标签自身坐标系里的三维坐标比如左上角为原点(0,0,0)、(L,0,0)、(L,L,0)、(0,L,0)再已知这四个角点在图像上的二维像素坐标就能求出相机相对标签的平移向量 tvec 和旋转向量 rvec。平移向量 tvec 就是标签在相机坐标系下的三维位置这正是我们要的“三维位置估算”。3.2 图像采集与 ArUco 标签检测接下来直接上代码。先写一个最基础的 ArUco 检测与 ID 识别脚本import cv2 import numpy as np # 兼容 OpenCV 4.7 前后版本 try: dictionary cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250) except AttributeError: dictionary cv2.aruco.Dictionary_get(cv2.aruco.DICT_6X6_250) # 打开摄像头0 表示默认摄像头 cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) corners, ids, rejected cv2.aruco.detectMarkers(gray, dictionary) if ids is not None: # 可视化标签边框和 ID cv2.aruco.drawDetectedMarkers(frame, corners, ids) cv2.putText(frame, fMarkers: {len(ids)}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(ArUco Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMarkers 函数的返回结构是corners 里是每个检测到的标签的四个角点坐标排序顺序是左上、右上、右下、左下ids 是与 corners 一一对应的 ID 数组。实际开发时不需要自己写角点排序这个顺序 OpenCV 已经固定了。3.3 姿态估计与三维坐标计算上面的代码只完成了“检测”还没有做“位姿”。要输出三维位置和测距结果需要把内参矩阵和畸变系数加载进来然后调用 estimatePoseSingleMarkers。完整的示例代码如下import cv2 import numpy as np # 1. 加载标定结果 camera_data np.load(camera_params.npz) camera_matrix camera_data[mtx] dist_coeffs camera_data[dist] # 2. 定义字典和真实边长 try: dictionary cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250) except AttributeError: dictionary cv2.aruco.Dictionary_get(cv2.aruco.DICT_6X6_250) marker_length 0.05 # 标签边长单位米 cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) corners, ids, rejected cv2.aruco.detectMarkers(gray, dictionary) if ids is not None: # 位姿估计 rvecs, tvecs, _ cv2.aruco.estimatePoseSingleMarkers( corners, marker_length, camera_matrix, dist_coeffs) for i in range(len(ids)): rvec rvecs[i] tvec tvecs[i].flatten() # 画坐标轴长度0.03表示画3厘米的轴 cv2.drawFrameAxes(frame, camera_matrix, dist_coeffs, rvec, tvec, 0.03) # 三维位置 x, y, z tvec distance_m np.linalg.norm(tvec) # 计算旋转角 rmat, _ cv2.Rodrigues(rvec) euler cv2.RQDecomp3x3(rmat)[0] yaw, pitch, roll euler info fID:{ids[i][0]} X:{x:.2f} Y:{y:.2f} Z:{z:.2f} dist:{distance_m:.2f}m cv2.putText(frame, info, (10, 30 i * 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow(ArUco Pose Estimation, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码输出的是 tvec 的三个分量和距离。tvec 的含义是“标签坐标系原点在相机坐标系下的三维坐标”具体说就是以相机光心为原点X 轴朝右Y 轴朝下Z 轴朝向相机正前方。所以如果我们把标签贴在物体正前方tvec 的 Z 分量就是标签相对于相机的深度距离dist 则是包含 XY 偏移在内的直线距离。3.4 距离计算时该取 Z 还是取欧氏距离这里有一个在实际工程里需要想清楚的问题到底拿 tvec 的 Z 值当作距离还是用 sqrt(X^2 Y^2 Z^2) 的欧氏距离如果标签基本在画面中央两个值差距不大如果标签跑到画面边缘欧氏距离会比 Z 值大几个百分点。在机器人避障场景里通常关心的是相机坐标系下 Z 轴的“前向距离”此时取 Z 更合理因为真正影响碰撞的是物体在运动方向上的投影距离。在机械臂抓取场景里需要的是标签相对于机械臂末端的完整空间坐标那就直接用 tvec 的 XYZ不做降维处理。在需要做“两点间空间距离”判断时才应该用欧氏距离。所以在使用上不用死板先想清楚应用场景要什么再决定取哪个值。3.5 批量生成 ArUco 标签ArUco 标签必须打印出来贴在物体上。写一个批量生成标签的脚本很实用import cv2 import os try: dictionary cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250) except AttributeError: dictionary cv2.aruco.Dictionary_get(cv2.aruco.DICT_6X6_250) os.makedirs(aruco_tags, exist_okTrue) tag_size_px 300 margin_size_px 20 for tag_id in range(20): tag_image cv2.aruco.generateImageMarker(dictionary, tag_id, tag_size_px, marginSizemargin_size_px) cv2.imwrite(faruco_tags/tag_{tag_id}.png, tag_image) print(fGenerated tag_{tag_id}.png)实际打印时要注意把图片设置为“实际大小”不要缩放否则打印出来的物理尺寸与 marker_length 不一致测距结果就会偏。我一般会在打印后用尺子量一下黑色边框的外边缘以实际测量值为准填入 marker_length这一步能消除很多不必要的误差。4. 实测效果与精度提升手段4.1 一台普通摄像头能跑到什么程度我用一个普通 720p 办公摄像头做过一组简单实验。标签打印成 5cm 宽贴在纸箱上相机从 0.5 米到 2.5 米每隔 0.1 米测一次。结果是这样的1 米以内距离误差基本在 1 到 2 厘米相当准1 到 2 米误差在 3 到 5 厘米还可以接受2.5 米以上时标签在画面里只占 30 到 40 个像素宽角点检测精度开始下降误差偶尔到 10 厘米以上。这个结果说明 ArUco 单目测距的精度取决于标签在图像中的像素占比。如果想让 3 米外的目标也有较高测距精度有三条路一是换更高分辨率的摄像头二是用更大尺寸的标签三是让相机靠近目标。标签在图像中的像素宽度越大角点定位的亚像素精度对最终位姿的影响就越小。还测过标签旋转对结果的影响。标签正对相机时误差最小倾斜超过 45 度后识别率明显下降解算出来的角度误差也会变大。这和 ArUco 的采样网格有关标签越倾斜透视变形越严重内部格子的二值化结果就越容易出问题。如果项目需要大角度检测建议多个标签配合使用或者尽量保证相机视角与标签平面夹角不要太大。4.2 提高精度的五个具体方向单目视觉的精度上限不低但能不能达到上限取决于你有没有处理下面这五件事。第一亚像素角点优化。ArUco 检测到的角点是整数像素级别的直接用这个结果做 solvePnP精度还有提升空间。可以用 cornerSubPix 对角点坐标做亚像素精细化输入检测到的原始角点再加上终止条件让角点位置收敛到亚像素精度。这个操作在近距离测距时能明显减小抖动。第二多帧滤波。单帧解算出来的距离会有些抖动这在手持摄像头场景很常见。工程上最简单的办法是滑动窗口平均或者用卡尔曼滤波做平滑。如果你的应用是机器人后面接一个扩展卡尔曼滤波是非常自然的做法能把距离波动控制在很小的范围。第三多标签融合。一个物体上贴多个 ArUco 标签同时检测到多个标签后把各自算出的位姿求平均或者做加权融合可以抵消单个标签因为角度偏差带来的系统性误差。这个在运动平台上的效果尤其明显。第四高分辨率图像与镜头选择。如果项目允许的话尽量用 1280x720 甚至 1920x1080 的采集分辨率同时在检测前做一次适当的图像预处理比如高斯模糊去掉噪点、对比度增强让边缘更锐利。第五精确测量标签尺寸。这一步看似简单但很多人随手在打印设置里填了个数字就开始用。正确的做法是打印后用游标卡尺量标签黑色边框的外边距因为不同打印机的缩放比例不同设置和实际尺寸常有零点几毫米的偏差这几毫米的偏差在远距离测距时会被放大很多倍。4.3 结合 RGB-D 相机做一个补充如果手上只有普通单目相机上面的方案就够用了。但如果项目有深度相机比如 Intel RealSense 或奥比中光可以把 ArUco 检测的结果和深度图对齐。具体做法是先用 ArUco 求出标签中心的像素坐标然后在深度图对应位置取深度值这样能拿到更稳的绝对距离。这种方案的好处是可以用深度传感器修正 ArUco 解算的 Z 值同时又用 ArUco 给深度图一个语义身份标签两者形成互补。不过这里有个需要注意的地方彩色图和深度图之间存在视差必须先用 SDK 做对齐否则从彩色图上得到的像素坐标直接去索引深度图位置会偏差几十个像素。这一步在不同相机 SDK 里的叫法不一样有的叫 align有的叫 registration本质是同一回事。5. 踩坑记录ArUco 使用中的常见问题与排查5.1 检测不到标签或者识别率极低遇到这种情况先不要怀疑代码按下面的顺序逐一排查标签打印模糊、墨迹扩散导致黑白边界不清晰。解决方案是换打印机或把标签尺寸调大。标签在画面里太小。经验值是标签宽度至少占图像宽度的 3% 到 5%比如 1280 像素宽的图像里标签至少要有 40 到 60 像素宽。光线太强或太暗。ArUco 依赖二值化阈值强反光或阴影都会破坏内部网格结构。这不意味着要在无影灯下使用但需要注意让标签表面受光均匀。摄像头自动曝光和自动白平衡造成画面过曝。在固定光照环境下建议手动固定相机的曝光和增益参数。另外一个容易忽略的问题误用了不匹配的字典。用 DICT_4X4_50 生成的标签用 DICT_5X5_100 的字典去检测肯定识别不出来。代码里生成和检测必须用同一个字典。5.2 距离和位置数据一直抖动如果标签静止不动但屏幕上的距离数据一直在小幅跳动这通常是角点检测的亚像素噪声导致的。可以先做滑窗平均from collections import deque import numpy as np distance_history deque(maxlen10) # 每次得到新的 distance 后 distance_history.append(distance_m) smoothed_distance np.mean(distance_history)注意滑窗大小不要过大否则系统响应会变得很迟钝。对运动速度较快的目标建议滑窗长度 5 到 10对静止目标可以放到 20 到 30。另一个思路是用卡尔曼滤波代码会稍微复杂一点但效果更好。它的核心思想是把上一帧的预测值和当前帧的观测值做一个权衡权重由系统噪声和测量噪声决定这样既平滑又不会过度滞后。5.3 多个标签同时出现在画面里怎么处理detectMarkers 天然支持一帧里检测多个标签ids 数组的长度就是检测到标签的数量。工程上可以建立一个字典把 ID 映射到具体的物理含义。比如 ID 0 表示“充电桩”ID 1 表示“货架”ID 2 表示“目标抓取点”。需要在代码里建立一个映射关系marker_map { 0: charging_station, 1: shelf, 2: grasp_point, }这样程序里每个标签检测结果都能直接对应到业务语义后续做路径规划或者决策处理就直观多了。当画面中同时出现多个标签时可以加上去重逻辑同一个 ID 在一帧里只处理一次。实际使用中很少发生一个 ID 被检测成另一个的情况因为 ArUco 的字典带有校验能力但如果标签太模糊偶尔会出现误检通过限制最小角点距离或使用更高精度字典可以减少这个问题。5.4 标签 ID 混乱或者读错读错 ID 的情况比较少见但一旦出现就要警惕字典的纠错能力。ArUco 字典采用了类似汉明码的机制允许一定数量的比特错误纠正。但如果标签本身模糊到内部网格都看不清纠错也救不回来。此时建议降低检测距离、增大标签尺寸、优化光照并且可以在代码里统计每个 ID 的检测频率对连续多帧出现且稳定的 ID 才认为有效单帧偶发出现的 ID 直接丢弃。5.5 运行环境下无法安装 OpenCV 的替代思路这个坑主要出现在离线环境或特殊嵌入式平台上。如果没法安装 opencv-contrib-python可以考虑用 ROS 环境下现成的 aruco 功能包或者编译 OpenCV 时手动勾选 contrib 模块。不过无论哪种方式本质上用的都是同一套底层算法。对于 Jetson Nano 这类 ARM 设备pip 直接安装 opencv-contrib-python 通常也是可行的只是需要注意选择匹配 Python 版本和系统架构的 wheel 包。6. 延伸方向从单站定位到更广的应用场景走到这一步你已经能拿到标签在相机坐标系下的三维位置和姿态了。这个信息可以直接用于很多有意思的项目如果你在做小车或无人机定位可以把标签贴在固定位置作为“着陆点”或“充电桩”相机识别之后通过 tvec 引导运动控制实现自动对准和降落。此时要注意位姿解算的坐标系定义tvec 是相机相对标签的位姿如果要控制机器人移动需要转换成机器人基座坐标系中间通常还要做一次坐标变换。如果你在做增强现实ArUco 标签可以作为虚拟物体锚点用 rvec 和 tvec 作为 OpenGL 或 Unity 的外参直接在标签位置渲染 3D 模型。很多 AR 入门教程用二维码做但其实 ArUco 更适合因为它识别更快姿态解算也更稳定。如果你在做多传感器融合可以把 ArUco 位姿作为视觉观测配合轮式里程计或 IMU 做卡尔曼滤波融合。这样既解决了里程计漂移问题又不会被视觉单帧遮挡彻底打断。这是我在实际项目里觉得最有价值的一个方向——视觉不是万能的但当一个低成本的绝对定位源它的性价比非常高。我个人在实际操作中的体会是ArUco 方案的学习曲线不算陡峭真正花时间的反而是相机标定、坐标系转换和参数调试这些细节。这篇文章里给出的代码和参数我都在真实环境里跑过如果你照着执行仍然遇到问题不要犹豫先从打印标签尺寸和相机内参入手检查因为九成以上的异常最终都出在这两个地方。希望这套方案能帮你少走几步弯路。