说到视觉测距,很多人第一反应是上个深度学习模型,或者搞双目摄像头。但实际做过落地项目的应该都有体会:方案越重,坑越多。单目相机想测距,靠深度学习估计深度,模型要标定、要训练、要调参,而且在弱纹理场景下说飘就飘;双目方案精度确实不错,但硬件成本、基线校准、计算资源,每一项都是实打实的开销。
我之前在一个仓储机器人项目里需要做末端避障和货架定位,精度要求毫米级,但设备端只有一个普通USB摄像头,算力还受限。折腾了一圈,最后选定的方案就是OpenCV自带的ArUco标记配合单目测距。这个方案的核心思路非常朴素:用ArUco标记的已知物理尺寸,结合相机内参,通过透视投影关系反推出标记到相机的距离。不需要训练,不需要额外硬件,单帧图像就能算,精度在合适的距离范围内相当能打。
这篇文章我会把整个方案的原理、代码、标定、误差来源全部拆开讲清楚,代码可以直接拿去改。适合正在做视觉定位、机器人避障、AR叠加、或者单纯想给单目相机加一个低成本测距功能的开发者。
1. 为什么测距选ArUco而不是普通二维码
很多人会问:普通QR Code也能检测角点,为什么非要ArUco?这个问题我在选型的时候专门对比过,差别还挺大。
1.1 检测鲁棒性的本质差异
QR Code的设计目标是承载信息,它的定位靠三个角上的“回”字形方块,内部还有密集的数据模块。OpenCV的QRCodeDetector虽然能给出四个角点,但在以下场景下会出问题:
- 近距离大角度倾斜时,透视畸变严重,数据模块解码失败率飙升
- 部分遮挡时,数据区一旦受损,整个码直接无法识别
- 反光、暗光环境下,解码器的容错机制会频繁误判
而ArUco标记的设计目标是定位与识别。它的字典编码分布在内部的二进制矩阵中,核心优势在于:
- 角点特征是几何级的——外边框是纯黑色方块,边缘锐利,角点检测远比QR Code的数据模块稳定
- 自带字典校验——每个标记的ID由预定义字典决定,检测时会计算汉明距离,天然过滤掉误检
- 部分遮挡可用——即使遮住一半,只要剩下的角点和编码信息足够,依然能识别
- 亚像素级角点提取——配合
cornerSubPix可以做亚像素优化,对测距精度影响很大
我实测过同一个场景下,A4纸打印同样尺寸的QR Code和ArUco,在30度以上倾斜角度时,QR Code的检出率明显下降,而ArUco基本不受影响。测距场景里相机和标记的相对姿态本来就是任意的,这一点非常关键。
1.2 单目测距对标记的特殊要求
单目测距的本质是P4P问题(四个共面点求解位姿)。要稳定解算,标记的四个角点必须满足:
- 精确可重复提取:每次检测角点像素坐标的抖动要小,否则距离值会剧烈跳动
- 方向唯一:ArUco标记通过内部编码区分旋转方向,四个角点能按固定顺序输出,否则PnP解算时容易陷入歧义
- 尺寸可定制:ArUco码可以用
generateImageMarker按任意像素尺寸生成,需要多大多小都能控制
普通二维码的角点顺序在旋转后需要额外判断,编码区也容易引入干扰。ArUco从设计之初就是为位姿估计服务的,这就是我最终选择它的根本原因。
1.3 ArUco和QR Code的实际测距对比
| 对比项 | ArUco | QR Code |
|---|---|---|
| 角点检测稳定性 | 高(几何特征+亚像素优化) | 中(依赖解码成功) |
| 大角度倾斜识别 | 可靠 | 容易失败 |
| 部分遮挡 | 可容忍 | 基本不可用 |
| ID识别 | 自带校验 | 需解码内容 |
| 测距适用性 | 专门为PnP设计 | 需要额外处理 |
2. 测距的几何原理:从小孔成像到P4P求解
这个方案的核心就是相机成像模型。理解了它,你才能知道误差出在哪、怎么去控制。
2.1 小孔成像模型
先看最简单的单目测距公式。假设标记的真实宽度为W(米),在图像中占的像素宽度为w(pixel),相机焦距为f(pixel),则距离D满足:
D = (W * f) / w我举个例子。ArUco码边长5cm,相机焦距700px,图像里码的宽度是70个像素:
D = 0.05 * 700 / 70 = 0.5米这个公式很简单,但它有一个前提:标记平面必须正对相机光轴。一旦有偏转角度,图像中呈现的宽度会变小,直接用这个公式算的话距离就会偏大。实际场景里标记不可能每次都正对着相机,所以需要更完整的模型。
2.2 P4P问题的数学表达
ArUco测距用的是estimatePoseSingleMarkers,底层调用的就是solvePnP,本质是一个P4P问题。已知条件:
- 标记四个角点在世界坐标系下的三维坐标(由真实物理尺寸确定)
- 四个角点在图像坐标系下的二维像素坐标(由检测得到)
- 相机内参矩阵
K和畸变系数
求解目标是旋转向量rvec和平移向量tvec。数学关系为:
s * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T其中[u, v]是像素坐标,[X, Y, Z]是世界坐标,s是尺度因子。tvec就是我们需要的平移向量,它表示标记坐标系原点在相机坐标系中的位置。
这里有个细节需要注意:estimatePoseSingleMarkers返回的tvec在OpenCV 4.x之前的版本和之后的版本坐标约定有差异,这一点我会在第5部分专门讲。
2.3 为什么实际算距离取Z分量而不是欧氏距离
tvec是一个三维向量(tx, ty, tz)。很多教程直接取tz当成距离,也有人用sqrt(tx^2 + ty^2 + tz^2)。两种做法在标记正对相机时结果几乎一样,但在斜视情况下差异明显。
我的经验是:取欧氏距离更符合“测距”的直觉语义。sqrt(tx^2 + ty^2 + tz^2)表示的是相机光心到标记坐标系原点的真实直线距离,不管标记怎么转,这个值都是准的。而tz表示的是标记中心在相机Z轴方向上的分量,标记偏离画面中心时,这个值会略小于实际距离。
2.4 像素误差如何影响距离精度
这个误差传递关系值得单独说。对公式D = (W * f) / w做微分:
dD/dw = - (W * f) / w^2代入相对误差形式:
dD/D = - dw/w也就是说,距离相对误差等于像素宽度测量值的相对误差。如果标记在图像中只占20个像素,角点检测误差0.5像素,那么相对误差就是0.5/20=2.5%。但如果标记占了200个像素,同样是0.5像素的误差,相对误差只有0.25%。
结论很简单:想让测距精度高,要么让标记在画面里占得足够大,要么把角点检测的像素精度做上去。这就是为什么我建议在实际应用里对检测到的角点再做一次亚像素细化,后面代码里会体现。
3. 环境准备与ArUco标记生成
先把环境配好,这是整个项目里第一个容易劝退新手的环节。
3.1 安装OpenCV Contrib模块
ArUco检测功能在opencv-contrib-python包里,不在最早的opencv-python包里。这里最容易踩的坑是:两个包不能同时装,否则会互相覆盖,导致cv2.aruco属性不存在。
建议直接装contrib版本:
pip install opencv-contrib-python==4.8.1.78我锁了4.8.1版本,因为4.7.0之后的cv2.arucoAPI变化比较大,网上很多旧教程的写法在4.8上已经跑不通了。装完之后验证一下:
import cv2 print(cv2.__version__) print(hasattr(cv2, 'aruco'))如果输出True,说明ArUco模块可用。其他依赖像numpy通常装OpenCV的时候会自动带上,没有就单独补一个。
3.2 生成ArUco标记
ArUco标记需要一个字典(Dictionary),字典决定了标记的尺寸和编码集。我建议用DICT_4X4_50或DICT_4X4_100,码型简单,近距离识别率最高。4x4的字典包含的模块数少,角点检测更稳,对于测距这种场景来说足够用。5x5、6x6码型包含的信息更多,但单个模块更小,同样的物理尺寸下识别距离更近。
import cv2 import numpy as np aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) def generate_aruco_marker(marker_id, size_px=300, border_bits=1): marker = np.zeros((size_px, size_px), dtype=np.uint8) marker = cv2.aruco.generateImageMarker(aruco_dict, marker_id, size_px, marker, border_bits) return marker marker = generate_aruco_marker(marker_id=0, size_px=300) cv2.imwrite('aruco_0.png', marker)打印出来之前,注意两点:
- 打印的实际物理尺寸要量准确。比如你在代码里写入边长5cm,但打印出来后实际是5.2cm,那么所有距离都会系统性偏大4%,这种误差会直接叠加到最终结果上。
- 纸张要平整、不要折。纸张褶皱在微距上会改变角点位置,对近距离测距影响尤其明显。条件允许就塑封一下,但塑封膜要选哑光的,太亮的膜在强光下会反光,导致角点提取失败。
3.3 在Marker周围留出白边
生成ArUco标记时有一个border_bits参数,默认是1,表示在标记外围额外留一圈黑色边框。如果打印后觉得识别不灵敏,可以尝试增大白边——在代码里加大size_px的整体留白,或者打印时在四边额外加2~3cm的白边。
实际原因在于OpenCV的检测管线是先找外围四边形轮廓,再根据内部的黑白网格解码。外边框和背景的对比度越强,轮廓提取越容易。如果标记贴在一个同样是白色或深色的物体上,很容易出现检测失败。
4. 相机标定:高精度的前提
这一步很多人会跳过去,觉得“用默认参数先跑起来再说”。但如果你想做的不是演示而是真正有精度要求的测距,标定这一步不能省。
4.1 不标定会有什么问题
相机的镜头存在径向畸变和切向畸变。未矫正的画面里,画面边缘的直线是弯的,角点的像素位置会发生偏移。这个偏移在中长焦镜头下还能接受,但在广角镜头下非常明显,画面边角的角点位置可能偏好几个像素,按照前面分析的误差传递,测距精度会直接劣化。
另外,焦距参数f必须准确。如果你不标定直接用图像宽度的近似值或者网上查的参数,焦距可能偏差百分之几到百分之十几,测距结果也会成比例偏。
4.2 棋盘格标定完整代码
标定通常用棋盘格,因为角点检测稳定且特征点数量可控。用A4纸打印一张标准棋盘格(建议12x9或9x6,格子大小20mm左右),贴在硬纸板上,从不同角度拍20~30张照片。
import cv2 import numpy as np import glob CHESSBOARD_SIZE = (9, 6) SQUARE_SIZE = 0.025 # 每个格子的边长,单位:米 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) objp = np.zeros((CHESSBOARD_SIZE[0] * CHESSBOARD_SIZE[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD_SIZE[0], 0:CHESSBOARD_SIZE[1]].T.reshape(-1, 2) objp *= SQUARE_SIZE obj_points = [] img_points = [] images = glob.glob('calib_images/*.jpg') for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, CHESSBOARD_SIZE, None) if ret: obj_points.append(objp) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) img_points.append(corners2) cv2.drawChessboardCorners(img, CHESSBOARD_SIZE, corners2, ret) ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(obj_points, img_points, gray.shape[::-1], None, None) print("内参矩阵:\n", mtx) print("畸变系数:\n", dist) np.savez('camera_calib.npz', mtx=mtx, dist=dist)和测距代码无关,标定采集图像时要记住几个关键点:
- 棋盘格占画面面积要大:每张照片里棋盘格要占整个画面的1/3以上,否则有效角点太少,标定结果不稳
- 角度要丰富:前后倾斜、左右旋转、上下翻转都要拍,不要只在一个固定姿态下平移
- 必须使用cornerSubPix细化:直接使用
findChessboardCorners返回的角点,误差一般在半像素量级,细化后能到0.1像素以内 - 拍摄距离要和实际测距距离接近:标定时的物距范围应该覆盖实际使用的距离区间,这样畸变和焦距参数在该区间内才是最优的
标定完成后可以把mtx和dist保存成文件,测距程序启动时直接加载。
5. 实时测距完整代码实现
到了最核心的部分。这里给的是可以直接复制运行的完整代码,关键位置都加了注释。
5.1 主程序:检测ArUco并计算距离
import cv2 import numpy as np # 标记真实边长,单位:米(根据你打印的实际尺寸修改!) MARKER_LENGTH = 0.05 # 加载标定结果 calib_data = np.load('camera_calib.npz') camera_matrix = calib_data['mtx'] dist_coeffs = calib_data['dist'] # 使用4x4字典,ID范围0~49 aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) parameters = cv2.aruco.DetectorParameters() # OpenCV 4.7+ 推荐的新API detector = cv2.aruco.ArucoDetector(aruco_dict, parameters) cap = cv2.VideoCapture(0) if not cap.isOpened(): print("无法打开摄像头") exit() while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 检测ArUco标记 corners, ids, rejected = detector.detectMarkers(gray) if ids is not None: # 对所有检测到的标记计算位姿 rvecs, tvecs, _ = cv2.aruco.estimatePoseSingleMarkers(corners, MARKER_LENGTH, camera_matrix, dist_coeffs) for i in range(len(ids)): # 提取角点坐标 corner = corners[i][0] # 四个角点,顺序:左上、右上、右下、左下 # 计算中心点 center_x = int(np.mean(corner[:, 0])) center_y = int(np.mean(corner[:, 1])) # 平移向量:相机坐标系的水平、垂直、深度分量 tx = tvecs[i][0][0] ty = tvecs[i][0][1] tz = tvecs[i][0][2] # 欧氏距离 distance = np.sqrt(tx**2 + ty**2 + tz**2) # 可视化 cv2.aruco.drawDetectedMarkers(frame, corners, ids) cv2.drawFrameAxes(frame, camera_matrix, dist_coeffs, rvecs[i], tvecs[i], 0.03) label = f'ID:{ids[i][0]} D:{distance*100:.1f}cm' cv2.putText(frame, label, (center_x - 60, center_y - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow('ArUco Distance Measurement', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()代码里有一个细节,estimatePoseSingleMarkers返回的tvecs形状是(N, 1, 3),所以取第i个标记的平移向量要用tvecs[i][0]。同样rvecs[i]的形状是(1, 3),传入drawFrameAxes时要保持这个维度。
5.2 对距离做一个简单的稳定性滤波
原始距离值在动态场景下会有抖动,即使相机静止,角点检测的微小波动也会让距离值在±1cm左右浮动。加上一个滑动平均滤波,效果会很直观:
class DistanceFilter: def __init__(self, window_size=10): self.window = [] def update(self, value): self.window.append(value) if len(self.window) > 10: self.window.pop(0) return np.mean(self.window) filters = {} # 在主循环中: distance = np.sqrt(tx**2 + ty**2 + tz**2) if ids[i][0] not in filters: filters[ids[i][0]] = DistanceFilter() filtered_distance = filters[ids[i][0]].update(distance)这里要注意:如果ArUco标记在画面中消失再出现,滤波器的历史数据可能是旧的,会造成瞬时的滞后。可以记录每个标记的最近检测时间,超过一定阈值(比如0.5秒)就重置滤波器。
5.3 关键API版本差异说明
这条专门写给那些按旧教程写代码然后报错的人。OpenCV 4.7.0之后,cv2.aruco模块的API做了重构:
| 旧API(<4.7) | 新API(≥4.7) |
|---|---|
cv2.aruco.Dictionary_get(cv2.aruco.DICT_4X4_50) | cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) |
cv2.aruco.DetectorParameters_create() | cv2.aruco.DetectorParameters() |
cv2.aruco.detectMarkers(gray, dictionary, parameters=parameters) | cv2.aruco.ArucoDetector(dictionary, parameters).detectMarkers(gray) |
cv2.aruco.estimatePoseSingleMarkers(corners, ...) | 未变,但内部已标注Deprecated,建议用cv2.solvePnP |
这些API在不删除旧函数名的同时做了重构,但很多函数签名变了,参数类型检查也更严格。如果你遇到AttributeError: module 'cv2.aruco' has no attribute 'Dictionary_get',说明你用了旧写法而安装的是新版本,直接换成新API就行。
6. 测距误差实测与分析
代码跑起来之后,一定要做精度验证。这个环节能帮你发现很多理论上看不到的问题。
6.1 实测一:固定距离重复测量稳定性
把ArUco码固定在三脚架上,相机距离标记1米,记录连续100次的测量结果。正常情况下:
- 原始值的标准差应该在5mm以内
- 滑动平均后的值可以稳定在±2mm以内
如果标准差过大,优先检查曝光、对焦是否在波动,其次检查标记是否平整。这里有个容易忽略的因素:自动曝光。相机面对不同亮度场景时,曝光时间变化会导致图像亮度波动,间接影响角点检测。有条件的话,最好手动固定曝光参数,或者使用工业相机的固定曝光模式。
6.2 实测二:从0.3米到3米的测量误差分布
| 实际距离(米) | 测量距离(米) | 绝对误差(厘米) | 相对误差 |
|---|---|---|---|
| 0.30 | 0.298 | 0.2 | 0.67% |
| 0.50 | 0.502 | 0.2 | 0.40% |
| 1.00 | 0.994 | 0.6 | 0.60% |
| 1.50 | 1.487 | 1.3 | 0.87% |
| 2.00 | 1.961 | 3.9 | 1.95% |
| 3.00 | 2.872 | 12.8 | 4.27% |
可以看出,距离越远,误差增长越快。这里面有两个原因:
- 标记在图像中的像素尺寸变小,角点检测误差占比上升
- 远距离时PnP求解的姿态退化问题:同样的像素误差在远处对应的物理误差更大
所以这个方案在2米以内是可靠的“高精度”,超过2米精度就开始快速恶化。如果你必须在远距离测距,要么把标记做得更大,要么换更高分辨率的相机,要么就只能考虑双目方案了。
6.3 误差来源拆解
整理一下整个测量链路上的误差来源:
| 误差源 | 影响量级 | 如何抑制 |
|---|---|---|
| 标记物理尺寸测量误差 | 系统偏差,等比例影响距离 | 用卡尺精确测量,取多次平均 |
| 相机焦距不准确 | 系统偏差 | 严格标定 |
| 镜头畸变残留 | 画面边缘更明显 | 多角度大量采集标定图,重投影误差控制 |
| 角点检测量化误差 | 随机误差 | 亚像素细化、多帧平均 |
| 标记平面倾斜 | 非对称误差 | 尽量让标记正对相机,或补偿姿态 |
| 运动模糊 | 角点偏位 | 提高快门速度,避免运动场景下测距 |
7. 踩坑记录与环境问题排查
最后把我在实际跑这个方案时踩过的坑列出来,每一个都真实影响过我的进度。
7.1 opencv-python和opencv-contrib-python冲突
这个问题在搜索热词里都出现了好多次。很多人在环境里先装了opencv-python,再装opencv-contrib-python,或者反过来,导致cv2.aruco时有时无。
正确的做法是:先卸载所有OpenCV包,再安装contrib版。
pip uninstall opencv-python opencv-contrib-python opencv-python-headless pip install opencv-contrib-python有虚拟环境的话,建议每种项目单独建环境,别用系统全局环境,避免包之间互相污染。
7.2 No module named 'cv2'排查
如果你遇到ModuleNotFoundError: No module named 'cv2',先确认在哪个解释器环境里运行:
which python pip list | grep opencv很多时候是IDE或终端用了不同的Python解释器,比如Anaconda的base环境和虚拟环境的site-packages是隔离的。在命令行里装了包,但PyCharm里用的是另一个解释器,就会报找不到模块。检查并切换到装好包的解释器即可。
7.3 检测不到ArUco标记的排查思路
如果运行后始终没有识别到标记,按下面顺序排查:
- 打印质量:标记是否清晰、白边是否够、纸张是否有反光
- 画面尺寸:标记在画面里是否太小,建议至少占60x60像素
- 曝光:画面是否过曝,过曝会让白色部分变成一片白,黑色边框和白色底之间的对比度降低
- 字典ID:确认生成的标记和检测用的字典一致;有的码生成用了
DICT_5X5_100,检测时用了DICT_4X4_50,当然识别不到 - 限制检测范围:
DetectorParameters里可以设置minMarkerPerimeterRate和maxMarkerPerimeterRate,如果画面里有很多其他干扰物体,适度缩小检测范围能提高稳定性
7.4 坐标轴箭头乱飘问题
用cv2.drawFrameAxes画坐标轴时,如果距离较远,坐标轴可能会“飘”到奇怪的位置。这是因为solvePnP远距离时的旋转向量解算精度下降,特别是标记接近画面边缘时更明显。这个现象不影响距离值的可用性(距离用平移向量,平移的估计比旋转更稳定),但不建议在超过2米时把坐标轴可视化当作位姿参考。
写在最后
这个ArUco单目测距方案,我在实际项目里用了大半年,最大的体会是:精度上限其实取决于你愿不愿意把每一步做扎实。Mark键的物理尺寸精确到毫米级、相机标定重投影误差控制在0.1像素以内、角点检测做亚像素细化、测距结果做滑动平均,这一整套流程做完,1米以内的测量误差做到5mm以下完全没问题。很多人在网上复现这个方案觉得不准,十有八九是跳过了标定,或者打印尺寸没仔细量。
如果你要在自己的项目里用,建议先把第5部分的代码跑通,然后用第6部分的误差测试方法量化一下自己设备上的精度水平,再决定要不要做滤波、要不要优化曝光。如果要做多标记测距或者测距之外还有位姿估计的需求,ArUco同样能扩展到那些场景里,后面有机会我再单独写一篇多标记位姿融合的内容。