多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

单目相机如何实现三维定位?PNP算法从标定到机器人抓取全解析

单目相机如何实现三维定位?PNP算法从标定到机器人抓取全解析 单目相机拍一张照片怎么让机器人知道目标物体在三维空间里的确切位置这个问题在工业分拣、机械臂抓取、移动机器人对接充电桩这些场景里反复出现。我第一次接触这个需求时直觉是一个摄像头没有深度信息怎么可能算出距离——后来才明白PNP算法Perspective-n-Point解决的正是这件事已知若干三维空间点及其在图像上的二维投影反推出相机相对于这些点的位姿。它不需要双目、不需要深度相机一个普通的USB摄像头就能跑。这篇内容面向已经会用OpenCV做基本图像处理、但还没把视觉定位真正落到机器人系统里的开发者把从标定、点对选取、solvePnP调用到结果滤波的完整链路拆开讲清楚中间会穿插我自己在机械臂抓取项目里踩过的坑。1. 先搞清楚PNP到底在解什么方程1.1 从针孔模型说起相机成像的本质是一个投影过程。三维世界里的一个点 P (X, Y, Z)经过相机外参旋转 R 和平移 t变换到相机坐标系再经过内参矩阵 K 投影到像素平面得到 (u, v)。写成齐次形式就是s * [u, v, 1]^T K * [R | t] * [X, Y, Z, 1]^T其中 s 是尺度因子K 是内参矩阵包含焦距 fx、fy 和主点 cx、cy。PNP要解的就是 [R | t]也就是相机在世界坐标系或者物体坐标系中的位姿。已知量是 K标定得到、若干组 (X,Y,Z) 和对应的 (u,v)未知量是 R3个自由度和 t3个自由度一共6个自由度。理论上3个点就能给出6个约束但3点情况会出现多解经典的P3P问题最多有4个解所以实践中通常用4个以上的点配合迭代或者EPnP这类非迭代方法求解。OpenCV的solvePnP默认走的是迭代法SOLVEPNP_ITERATIVE底层是Levenberg-Marquardt优化重投影误差。1.2 为什么机器人导航偏爱PNP而不是深度相机这里要解释一个选型逻辑。深度相机结构光、ToF确实能直接给出深度但它有几个现实问题室外强光下红外方案基本失效测量距离有限一般0.5到5米而且多机同时工作会互相干扰。单目加PNP的方案硬件成本极低作用距离只受限于目标点能否被清晰识别在远距离对接、大场景定位里反而更稳。代价是你必须提前知道目标上那几个点的三维坐标。这就是为什么工业场景里经常在目标物上贴ArUco码或者AprilTag——这些标记的角点三维坐标是已知的检测到角点像素坐标后直接喂给solvePnP位姿就出来了。我在一个AGV对接充电桩的项目里就是这么做的桩上贴一个边长160mm的ArUco2米外就能稳定解出位姿角度误差控制在1度以内。1.3 重投影误差才是你真正要盯的指标很多人调solvePnP只看返回的rvec和tvec忽略了它同时返回的reprojection error。这个值代表把解出来的位姿重新投影回图像后和实际检测到的像素点之间的平均距离单位是像素。经验上如果这个值超过2到3个像素说明点对质量有问题——要么是角点检测精度不够要么是三维坐标标定有误要么是点对匹配错了。我见过有人拿着reprojection error 8个像素的结果去做抓取机械臂直接撞在工件上。提示solvePnP的返回值里第一个是成功标志后面依次是rvec、tvec但重投影误差需要自己用projectPoints算别指望它直接给你。2. 标定环节埋的雷后面全要还2.1 内参标定不是走个流程就完事OpenCV的calibrateCamera用棋盘格标定流程网上一搜一大把但真正影响PNP精度的是几个容易被忽略的细节。第一标定板的平整度。打印出来的棋盘格贴在亚克力板上如果板子本身有弯曲标定出来的内参会有系统性偏差。我建议直接买印刷精度高的铝基标定板几十块钱的事比省这点钱后面调半天划算。第二标定时的姿态覆盖。很多人拍十几张图全是从正面稍微偏一点的角度这样标出来的焦距和畸变系数在图像边缘区域根本不准。正确做法是让标定板覆盖图像的四角和中心倾斜角度至少覆盖正负30度。我一般拍25到30张其中至少8张是明显倾斜的。第三标定完成后一定要看重投影误差。calibrateCamera返回的总体RMS误差好的结果应该在0.2像素以下。如果超过0.5别犹豫重新拍。2.2 畸变校正的时机很关键这里有个反直觉的点做PNP之前要不要先undistort图像答案是看情况。如果你用的是solvePnP配合已经undistort过的图像那检测到的角点坐标就是校正后的直接代入即可。但如果你在原始畸变图像上检测角点就必须用畸变系数参与求解——OpenCV的solvePnP本身不处理畸变你得先把角点用undistortPoints转换到归一化平面或者干脆先undistort整张图。我个人的习惯是分辨率不高1080p以下、畸变不大的镜头直接undistort整张图再检测代码简单不易错。但如果是广角镜头、畸变明显undistort会损失边缘视场这时候更推荐在原始图上检测再用undistortPoints单独校正角点。这个选择在远距离小目标场景下影响很大因为目标往往落在图像边缘。2.3 三维点坐标的获取方式决定上限PNP的输入里三维点坐标 (X,Y,Z) 的精度直接决定位姿精度。获取方式主要有三种方式精度适用场景注意事项已知标记物尺寸高ArUco、AprilTag打印尺寸必须精确测量误差直接传递CAD模型导出高已知工件注意坐标系定义要和实际一致手动测量中临时场景用游标卡尺别用卷尺我在一个项目里吃过亏ArUco码打印出来是160mm但实际用尺子量是159.2mm差了0.8mm。在1.5米距离上这个误差导致解出的Z轴距离偏了将近1厘米机械臂抓取时刚好差那么一点没夹住。后来换成激光切割的亚克力标记尺寸精度到0.05mm问题消失。3. solvePnP的几种解法什么时候用哪个3.1 迭代法、EPnP、P3P的适用边界OpenCV的solvePnP支持多种flag常用的有这几个SOLVEPNP_ITERATIVE默认选项基于LM优化需要至少6个点实际上4个点也能跑但稳定性差。精度最高但速度慢适合点数不多、对精度要求高的场景。SOLVEPNP_EPnP非迭代O(n)复杂度速度快4个点就能解。适合点数多、实时性要求高的场景比如视觉伺服。SOLVEPNP_P3P3个点求解会返回最多4个解需要额外筛选。一般只在点数极少时用。SOLVEPNP_SQPNP较新的方法精度和EPnP相当但更稳定OpenCV 4.x支持。实测数据在i5-8250U上20个点的情况下ITERATIVE大约0.8msEPnP大约0.15ms。如果你的控制循环是100HzEPnP的耗时完全可以忽略ITERATIVE就要掂量一下了。3.2 平面目标要用IPPE如果你的目标点是共面的比如贴在平面上的ArUco码一定要用SOLVEPNP_IPPE或SOLVEPNP_IPPE_SQUARE。这两个是专门为平面目标设计的比通用方法精度高一个档次。IPPE_SQUARE专门针对正方形标记直接输入4个角点返回的位姿在平面法向方向上的稳定性明显更好。我做过对比同一个ArUco码用ITERATIVE解出来的姿态角抖动在正负2度换成IPPE_SQUARE后抖动降到正负0.5度以内。这个差别在机械臂末端执行器需要精确对准时是决定性的。3.3 用RANSAC剔除错误点对当你的点对里混有误匹配比如ArUco检测到了但角点定位偏了或者背景里有相似图案被误识别直接用solvePnP会被带偏。这时候用solvePnPRansac它会随机采样若干点求解统计内点迭代出最优解。关键参数是reprojectionError内点阈值一般设2到3像素和iterationsCount迭代次数100次起步。RANSAC的代价是耗时增加但在点对质量不可控的场景下它是保命的。我在一个户外场景里光照变化导致ArUco偶尔误检加了RANSAC之后位姿跳变基本消失。import cv2 import numpy as np # 假设 object_points 是 Nx3 的已知三维点 # image_points 是 Nx2 的检测到的像素点 # camera_matrix, dist_coeffs 来自标定 success, rvec, tvec, inliers cv2.solvePnPRansac( object_points, image_points, camera_matrix, dist_coeffs, reprojectionError3.0, iterationsCount200, flagscv2.SOLVEPNP_ITERATIVE ) if success: # 用内点重新精解一次提升精度 rvec, tvec cv2.solvePnPRefineLM( object_points[inliers.flatten()], image_points[inliers.flatten()], camera_matrix, dist_coeffs, rvec, tvec )注意最后那步solvePnPRefineLM用RANSAC筛出的内点再做一次LM精解这一步能把精度再提一截很多人漏掉。4. 从位姿到机器人可用的坐标中间还差几步4.1 坐标系转换别搞混solvePnP解出来的rvec和tvec描述的是世界坐标系到相机坐标系的变换。也就是说tvec是目标原点在相机坐标系下的位置。但机器人需要的是目标在机器人基坐标系下的位置这中间要经过相机坐标系 → 机器人末端坐标系手眼标定得到→ 机器人基坐标系正运动学得到。手眼标定是另一个大话题这里只说和PNP相关的部分手眼标定的精度直接叠加到PNP结果上。如果你的手眼标定误差有5mm那PNP再准最终抓取位置也偏5mm。所以标定顺序上先把相机内参标准再做手眼标定最后才是PNP调优。4.2 旋转向量转旋转矩阵的坑rvec是旋转向量Rodrigues形式模长代表旋转角度方向代表旋转轴。要转成旋转矩阵用cv2.Rodrigues。这里有个常见错误有人直接把rvec当成欧拉角用结果姿态完全不对。记住rvec不是欧拉角三个分量也不是绕XYZ轴的旋转角。R, _ cv2.Rodrigues(rvec) # R 是 3x3 旋转矩阵 # tvec 是 3x1 平移向量 # 组合成 4x4 齐次变换矩阵 T np.eye(4) T[:3, :3] R T[:3, 3] tvec.flatten()这个T就是目标相对于相机的位姿。如果要做抓取还需要求逆得到相机相对于目标的位姿再乘以手眼矩阵。4.3 位姿滤波别让抖动毁掉控制单帧PNP解出的位姿一定有抖动来源包括角点检测的像素级噪声、光照变化、运动模糊。直接把这个位姿喂给机器人控制器机械臂会抖得像帕金森。必须滤波。简单场景用一阶低通滤波alpha 0.3 # 滤波系数越小越平滑但延迟越大 tvec_filtered alpha * tvec_new (1 - alpha) * tvec_old但旋转不能直接线性插值要用四元数球面插值SLERP。OpenCV的cv2.Rodrigues转出旋转矩阵后再用scipy的Rotation转四元数做SLERP。更讲究的做法是用卡尔曼滤波把位姿和速度都作为状态量。我在一个移动机器人对接项目里用了EKF状态量是位置和速度共6维观测是PNP解出的位置效果比低通滤波好很多尤其在目标短暂遮挡后恢复时EKF能平滑过渡低通滤波会跳变。注意滤波会引入延迟。如果你的机器人运动速度快滤波系数不能太小否则跟踪跟不上。这个需要根据实际速度调没有万能参数。5. 实战中那些文档不会告诉你的问题5.1 角点检测精度比算法选择更重要我做过一组对比实验同样的场景用cv2.cornerSubPix做亚像素精化 vs 不做PNP解出的Z轴距离误差差了将近3倍。亚像素精化的原理是在角点附近拟合梯度找到真正的亚像素位置能把定位精度从1像素提升到0.1像素级别。# ArUco检测后对角点做亚像素精化 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria)窗口大小 (5,5) 是常用的但如果图像模糊可以适当放大到 (7,7) 或 (9,9)。代价是计算量增加但在现代CPU上这点开销可以忽略。5.2 目标太小或太偏时的处理当目标在图像中只占几十个像素或者靠近图像边缘时PNP精度会急剧下降。原因是角点检测的绝对误差虽然还是0.1像素但相对于目标尺寸的比例变大了。解决办法有两个一是用长焦镜头让目标在图像中占更大比例二是用更高分辨率的相机。我在一个远距离对接场景里目标在3米外只占80个像素宽PNP解出的距离误差有5厘米。换成200万像素、6mm焦距的镜头后目标占200像素宽误差降到1.5厘米。这个改善不是算法能弥补的是物理层面的信息量问题。5.3 动态场景下的运动模糊机器人运动时拍照图像会有运动模糊角点定位精度下降。如果曝光时间能压到1ms以内一般速度下模糊可以忽略。但工业相机调短曝光后图像会变暗需要补光。我通常用LED环形光源配合全局快门相机基本消除运动模糊影响。卷帘快门相机在运动场景下会有果冻效应角点位置会随运动方向偏移这种偏移是系统性的PNP解出的位姿会有规律性误差。如果预算允许视觉定位场景一律用全局快门。5.4 多标记融合提升稳定性单个ArUco码在部分遮挡时会失效。解决方案是在目标上贴多个标记分别解PNP然后融合。融合方法可以是简单平均也可以根据每个标记的reprojection error加权。我在一个工件抓取项目里贴了4个标记在工件四角即使有一个被遮挡剩下三个也能稳定解出位姿。融合时注意多个标记的三维坐标要统一到同一个物体坐标系下这需要在标定时就把它们之间的相对位置测准。6. 一个完整的可复现流程6.1 环境准备与依赖Ubuntu 20.04 OpenCV 4.5以上Python 3.8。安装OpenCV用pip即可pip install opencv-python opencv-contrib-python numpy scipy注意opencv-contrib-python才有ArUco模块。如果你要用CUDA加速需要源码编译但PNP本身计算量很小CPU足够没必要折腾CUDA。6.2 标定与验证先用棋盘格标定相机保存内参和畸变系数到npz文件。然后打印一个已知尺寸的ArUco码放在已知距离上验证PNP解出的距离是否准确。这一步是 sanity check如果这里就不准后面全白搭。6.3 完整代码骨架import cv2 import numpy as np # 1. 加载标定参数 data np.load(calib.npz) camera_matrix data[mtx] dist_coeffs data[dist] # 2. ArUco设置 aruco_dict cv2.aruco.Dictionary_get(cv2.aruco.DICT_4X4_50) parameters cv2.aruco.DetectorParameters_create() # 3. 定义标记的三维坐标以标记中心为原点Z0平面 marker_length 0.16 # 米 half marker_length / 2 obj_points np.array([ [-half, half, 0], [ half, half, 0], [ half, -half, 0], [-half, -half, 0] ], dtypenp.float32) cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) corners, ids, rejected cv2.aruco.detectMarkers( gray, aruco_dict, parametersparameters ) if ids is not None: for i in range(len(ids)): # 亚像素精化 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) cv2.cornerSubPix(gray, corners[i], (5,5), (-1,-1), criteria) img_points corners[i].reshape(-1, 2).astype(np.float32) # 平面目标用IPPE_SQUARE success, rvec, tvec cv2.solvePnP( obj_points, img_points, camera_matrix, dist_coeffs, flagscv2.SOLVEPNP_IPPE_SQUARE ) if success: # 计算重投影误差 proj, _ cv2.projectPoints(obj_points, rvec, tvec, camera_matrix, dist_coeffs) error cv2.norm(img_points, proj.reshape(-1,2), cv2.NORM_L2) / len(proj) # 绘制坐标轴 cv2.drawFrameAxes(frame, camera_matrix, dist_coeffs, rvec, tvec, 0.1) # 输出距离 distance np.linalg.norm(tvec) cv2.putText(frame, fDist: {distance:.3f}m Err: {error:.2f}px, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2) cv2.imshow(PNP, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()这段代码跑通后你会看到标记上叠加了坐标轴屏幕上显示距离和重投影误差。如果误差稳定在1像素以内距离读数和你用尺子量的值差在几毫米内说明整条链路是通的。6.4 从验证到部署的检查清单在把PNP用到实际机器人之前逐项确认内参标定RMS误差小于0.3像素标记尺寸用卡尺实测误差小于0.1mm静态下重投影误差小于1.5像素距离1米处Z轴误差小于5mm距离2米处Z轴误差小于15mm姿态角抖动小于1度静止时手眼标定误差小于2mm滤波后的位姿延迟小于控制周期的一半任何一项不达标先解决再往下走。我见过太多项目在PNP精度不够的情况下硬上最后在抓取环节反复失败回头查发现是标定板不平这种低级问题。7. 几个容易被问到的细节为什么我的solvePnP返回false最常见的原因是点对数量不够ITERATIVE至少需要4对实际建议6对以上或者点对里有NaN。检查输入数组的dtype必须是float32或float64int类型会报错。为什么解出的距离总是偏大或偏小检查标记的实际尺寸是否和代码里写的一致。另一个可能是畸变系数没传对或者图像做了缩放但内参没跟着调整。如果图像从1920x1080缩放到960x540fx、fy、cx、cy都要除以2。姿态角跳变怎么办先确认是不是角点检测在跳。把检测到的角点画出来看如果角点本身在抖那是图像质量问题光照、模糊。如果角点稳定但姿态跳可能是平面目标用了ITERATIVE而不是IPPE换IPPE_SQUARE试试。能不能用深度学习检测的特征点做PNP可以但精度通常不如几何标记。深度学习特征点的定位精度在几个像素级别而ArUco角点配合亚像素精化能到0.1像素。如果场景不允许贴标记可以考虑用已知三维结构的物体做特征匹配但那是另一个话题了。我在实际项目里最深的体会是PNP算法本身很成熟OpenCV的实现也很稳定真正决定成败的是标定质量、点对精度和坐标系转换这三个环节。算法调参能带来的提升有限把物理层面的误差控制住比换什么高级解法都管用。
返回列表