☰
单目视觉位姿估算:PnP与OI正交迭代实战解析
2026/10/13 9:00:27 网站建设 项目流程

简介:面向计算机视觉与机器人领域开发者,这份资源集中阐述单目视觉位姿估算问题,并以正交迭代(OI)算法为主线,给出可运行的MATLAB实现。算法将位姿求解视为最优化问题,通过特征检测、匹配、三角化与优化四大环节,迭代逼近相机在三维空间中的位置和姿态,适用于机器人导航、自动驾驶、增强现实等场景,也为理解PnP类问题提供了完整范例。压缩包共14个文件,全部为MATLAB的m脚本,包含主程序与test验证程序,并配有坐标变换、矩阵旋转、正交迭代核心函数等模块,整套代码仅8KB,结构轻量,便于逐段阅读和二次开发。目前已有2570人学习下载。通过该资源,读者可以直观对比不同迭代次数下的估算精度,也可将OI算法与其他位姿估计方法进行基准测试,从而掌握一种准确、稳定且适合嵌入式环境的单目视觉定位实现思路。

1. 单目视觉位姿估算:一个相机、一个PnP问题、六自由度的R和t

机械臂要抓取面前一个工件,AR要往桌面上放一尊虚拟雕塑,视觉测量要算出零件端面与相机平面的夹角。这些场景共同的问题是:只有一个普通USB相机,却要从单张图像里估计物体相对相机的旋转和平移。这就是单目视觉位姿估算的核心任务——已知物体上若干三维点及其对应的二维像素,反推出外参R和t。我第一次上手时,先是尝试训练关键点检测网络,后来发现最稳、最可迁移的还是PnP配合OI正交迭代这类经典几何算法,不依赖训练集、单帧出结果、参数透明。这篇文章适合正在给机器人加定位、用单目相机制作测量工装,或者刚接手位姿估计任务、对精度和尺度边界心里没底的工程师,我把选型依据、可运行代码和踩过的坑一次性拆开。

2. 位姿估算的数学底座:从像素到三维的坐标变换链

2.1 一个世界点如何变成像素:变换链与6自由度的由来

任何单目位姿估计算法的底层都是同一条成像链路。世界坐标系下的三维点 P_w,先经过外参(R, t)变换进入相机坐标系,再做针孔投影落到像素平面。整条链写成公式就是:

P_c = R * P_w + t

再经过相机内参 K 的透视投影,得到像素坐标 (u, v)。内参K里的 fx、fy 是焦距,cx、cy 是主点偏移,它描述的是“相机坐标系里的三维点在图像上落在哪里”;而外参(R, t)才是我最终想要的东西。R 是三阶旋转矩阵,t 是平移向量,两者合在一起共6个自由度,对应物体在空间里的位置和姿态。

这条链路里最麻烦的是那个透视除法。像素坐标本质上只保存了物体点的方向,深度信息在投影中被压缩掉了。图像里的一个像素位置,对应着无穷多个不同深度的三维点,它们分布在同一条视线上。位姿估算这个问题的本质,就是用多个2D-3D对应关系,把这些被压缩掉的信息量重新补回来。这也是为什么算法能工作的前提是你知道物体上若干特征点的三维坐标,而不是只知道一张图片。

在实际项目里,这个问题被称作PnP(Perspective-n-Point):输入n个三维点坐标和n个对应像素坐标,输出外参R、t。n至少需要4个点,严格说要4个不共面的点才能从数学上保证唯一解;点数量大于4时,问题变成带冗余的最小二乘优化,结果更稳。我一般会在目标物上取8到12个特征点,覆盖视野的不同区域,比“恰好凑够4个点”稳健得多。这里的关键认知是:2D-3D对应关系的质量直接决定了所有后续算法的上限,这一条后面会反复出现。

2.2 为什么单独讲OI正交迭代:三类PnP解法的选型对比

PnP不是一个算法,而是一族算法。代数解法中常见的是DLT(直接线性变换)和EPnP,它们不需要初值、速度快,但对噪声的鲁棒性一般,EPnP在点数较少时精度有限。另一类是迭代优化解法,典型代表是把重投影误差丢给Levenberg-Marquardt算法优化,OpenCV里solvePnP的SOLVEPNP_ITERATIVE走的就是这条路。迭代法精度高,可初值给不好时容易发散,或者收敛到局部最优点。

OI正交迭代(Orthogonal Iteration)属于第三类,也是这个资源里我认为最值得吃透的一类。它的目标函数非常清楚:对每个三维点,误差定义为它经过R、t变换后的位置到对应观测视线之间的垂直距离。优化这个目标采用两步轮换:先固定旋转矩阵,平移向量有闭式解;再固定平移,把参照点沿视线正交投影得到临时理想位置,再解一个加权Wahba问题更新旋转矩阵。两步交替循环,直到收敛。由于每一步都有闭式解,它不需要像LM那样算数值雅可比,收敛行为稳定得多。

方法是否需要初值噪声鲁棒性收敛行为适用场景
DLT否一般直接闭式解当宽基线粗解,作初值用
EPnP否中等闭式解,速度快初始化首选
LM迭代(ITERATIVE)是较好依赖初值,会发散OpenCV默认迭代基线
OI正交迭代是好全局收敛性有证明点数适中、精度要求高

我在实际项目里的组合方式是:先用EPnP算一版粗位姿作为初值,再交给OI迭代精修。EPnP负责把初值拉进正确的收敛域,OI负责把精度磨上去。这个组合比直接上ITERATIVE稳定,尤其是在特征点分布不均匀的情况下。DLT我几乎不单独用,它的代数误差在高精度测量场景里往往不够看,但拿它做初值是合格的。

3. 落地实现:先用OpenCV跑通基线,再手写OI核心迭代

3.1 三十行代码先跑通:solvePnP与第一版R、t

我习惯先把基线跑出来,再谈算法优化。OpenCV的solvePnP一行调用即可得到外参,先让它帮你验证点对应关系是否成立。下面这段代码用六个非共面的三维点作为模型,配一组图像像素点:

import cv2 import numpy as np # 标定过的相机内参与畸变系数(单位:像素) K = np.array([[914.3, 0, 640.0], [0, 915.1, 360.0], [0, 0, 1.0]], dtype=np.float64) dist = np.array([-0.103, 0.112, 0.001, 0.0004, 0.0], dtype=np.float64) # 物体上的三维点,世界坐标系,单位:毫米 # 故意选了非共面的六个点,避免平面退化 obj_pts = np.array([ [0.0, 0.0, 0.0], [60.0, 0.0, 0.0], [60.0, 40.0, 0.0], [0.0, 40.0, 0.0], [30.0, 20.0, -25.0], [25.0, 15.0, -40.0], ], dtype=np.float64) # 与obj_pts一一对应的图像像素坐标(由特征匹配或手动点选得到) img_pts = np.array([ [352.1, 251.0], [448.3, 247.8], [446.5, 315.2], [349.8, 318.1], [398.6, 288.4], [404.2, 302.7], ], dtype=np.float64) ok, rvec, tvec = cv2.solvePnP(obj_pts, img_pts, K, dist, flags=cv2.SOLVEPNP_ITERATIVE) R, _ = cv2.Rodrigues(rvec) # 旋转向量转旋转矩阵 print("旋转矩阵 R:\n", R) print("平移向量 t:", tvec.ravel())

这段代码有三个关键的参数细节。首先是obj_pts和img_pts的索引顺序必须一一对应,错一个点结果就是错的,没有商量的余地。其次,obj_pts里特意加入两个不共面的点,避免平面目标引起的旋转退化,这在5.2节会详细展开。第三,SOLVEPNP_ITERATIVE自带内部初值,所以这里能直接跑;如果你换成SOLVEPNP_EPNP则不需要初值,但精度稍低,它更适合当其他算法的初值来源。

跑通之后顺手做一遍重投影误差校验。把六个模型点用求出的R、t投影回图像,和img_pts做像素差,正常应在1像素以内。误差突然放大到几十像素时,基本可以断定点对里混入了错配项。这时候先别急着优化算法,回去检查特征匹配和畸变纠正,比调参更有效。

3.2 手写OI正交迭代核心循环:投影视线与Kabsch交替更新

OpenCV的ITERATIVE本质是Levenberg-Marquardt优化,并不是OI。真正的正交迭代把误差定义成“三维点到观测视线的垂直距离”,然后用两步轮换求解。我在工程中最常用的落地形式分成三步:先用当前位姿把模型点搬到相机系,再沿视线正交投影出临时理想点,最后把模型点和理想点看作3D-3D对应,用Kabsch求解新的R、t,迭代至收敛。

import numpy as np def pixel_to_ray(uv, K): """像素坐标转相机系单位视线方向,K是3x3内参""" p = np.array([uv[0], uv[1], 1.0]) c = np.linalg.solve(K, p) # 等价于 inv(K) @ p return c / np.linalg.norm(c) # 归一化成单位方向 def oi_pose(pts3d, rays, R0, t0, max_iter=60, tol=1e-8): """ pts3d: (N,3) 世界系三维点 rays: (N,3) 每个点对应的相机系单位视线方向 R0, t0: 初始位姿,建议用EPnP结果 返回精修后的 R (3,3) 与 t (3,) """ R, t = R0.copy(), t0.copy() R_prev, t_prev = R0.copy(), t0.copy() for k in range(max_iter): # 第一步:当前位姿把模型点搬到相机坐标系 P_cam = pts3d @ R.T + t # (N,3) # 第二步:沿视线方向做正交投影,得到理想落点 proj = rays * np.sum(rays * P_cam, axis=1, keepdims=True) # 第三步:Kabsch求解 R,t,使 R p_i + t ≈ proj_i mu_p = pts3d.mean(axis=0) mu_q = proj.mean(axis=0) X = pts3d - mu_p Y = proj - mu_q H = Y.T @ X U, _, Vt = np.linalg.svd(H) d = np.linalg.det(U @ Vt) U[:, -1] *= np.sign(d) # 保证旋转矩阵行列式为+1 R = U @ Vt t = mu_q - mu_p @ R.T # 收敛判据:旋转和平移变化都足够小时停止 delta = np.linalg.norm(R - R_prev) + np.linalg.norm(t - t_prev) R_prev, t_prev = R.copy(), t.copy() if delta < tol: break return R, t

这段代码有四个位置需要单独讲清楚。第一,pixel_to_ray里用np.linalg.solve(K, p)把像素坐标还原成相机系方向向量再归一化,这一步决定视线方向,错一个符号后面全错。第二,第二步的proj是每个相机系点在自己视线上的最近点,这正是“正交”二字的几何含义。第三,Kabsch解里那一行U[:, -1] *= np.sign(d)是用来防止SVD解出行列式为-1的镜像旋转,在第五章会看到它在平面目标场景下的具体作用。第四,tol我给的是1e-8,工程上调到1e-6就能省下大量迭代次数,最终结果差异通常可以忽略。

初值方面,OI对初值虽然不像LM那么挑剔,但也不能用随机数。我一般先跑cv2.solvePnP的SOLVEPNP_EPNP拿到一版粗位姿,当作R0、t0,再传入oi_pose精修。EPnP的结果即便偏差一两度也没关系,OI的收敛域能兜住。想验证手写版是否正确,可以对同一组数据分别跑OpenCV基线和oi_pose,两者R、t在小数点后几位内一致,就说明实现没有大问题。

4. 数据准备与参数设置:喂给OI之前必须做对的四件事

4.1 内参标定是精度上限:棋盘格采集与畸变参数

OI的视线方向由像素坐标反算,而反算依赖内参K。K一旦不准,视线方向系统性地偏移,后面无论迭代多少次都救不回来。这就是为什么我不建议直接采用相机出厂标称焦距——同一型号两台相机的fx可能差两三个像素,在远距离目标上会被放大成厘米级的位姿误差。内参标定是所有步骤里性价比最高的一步。

标定工作流我用OpenCV棋盘格。采集15到20张不同角度、不同距离的棋盘格图像,角点做亚像素精化后再送入calibrateCamera:

criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) objp = np.zeros((6 * 9, 3), np.float32) objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2) objpoints, imgpoints = [], [] # 对每帧:findChessboardCorners 找到角点 # corners, _ = cv2.findChessboardCorners(gray, (9, 6), None) # corners = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) # objpoints.append(objp); imgpoints.append(corners) ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None)

标定结果我只关注两个指标。重投影RMS要小于0.3像素,最好在0.2附近;采集时棋盘格必须出现在画面边缘和中央,只拍正中间那几帧得到的畸变参数会在画面边缘露馅。dist数组的顺序是k1、k2、p1、p2、k3,后续在pixel_to_ray之前必须用cv2.undistort或对坐标做去畸变,直接用带畸变的像素坐标会引入几个像素的偏差,这在OI的视线计算里是致命的。

4.2 2D-3D点对应从哪来:ArUco、标定板角点与人工选点

位姿估算的输入是点对应,获取方式决定了整个系统的使用手感。工程里最常见的是三种:ArUco码角点、标定板角点、深度学习模型检出的关键点。ArUco是上手最快的方案,一个码的四个角点就是四对2D-3D对应。把码贴在目标物体上,世界系下四个角坐标已知,图像里检测到角点即可直接送入solvePnP或oi_pose:

aruco_dict = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) params = cv2.aruco.DetectorParameters() corners, ids, _ = cv2.aruco.detectMarkers(img, aruco_dict, parameters=params) # corners[i][0]是第i个码的四个角点,按顺序对应码的四个世界坐标

这里需要留意OpenCV aruco模块的API版本差异,新版本的DetectorParameters和旧版本的调用方式不同,工程里建议锁定环境版本再开发。ArUco单码只有四个角点,共面退化问题不可避免,我的做法是在目标物上贴两个码,凑出八个点,同时引入非共面约束。至于人工选点,只适合做算法验证,不适合做产品——人眼选点的重复性太差,选错一个就是典型的离群点事故,代价在5.5节单独讲。

4.3 坐标系约定与单目尺度补全:t的模长到底是多少米

坐标系约定看起来很基础,但一半以上的翻车都跟它有关。我统一采用OpenCV和机器人领域最常见的约定:世界坐标系是物体自身坐标系,R、t把世界系变换到相机系;图像坐标原点在左上角,u向右,v向下,相机系z轴指向镜头前方。这套约定在写pixel_to_ray和Kabsch时能少踩一半坑,重要的是整个项目里不要混用两套约定。

尺度问题是单目视觉特有的坑。obj_pts用毫米建模,tvec的单位就是毫米;用米建模,tvec就是米。真正的麻烦在于,如果你不知道物体的真实尺寸,只拿到一组相对坐标,那么t的模长没有物理意义,它只是“物体在相机前方几个模型单位”。补齐尺度的方法很直接:取模型上两个已知真实距离的点,真实距离除以模型距离得到比例因子,把t和后续输出的点位全部乘上去。我一般在目标附近固定一个已知长度的标尺,每帧自动算一次比例,避免温度变化或装配误差带来的比例漂移。

5. 避坑清单:单目位姿估算的五个翻车现场

5.1 重投影误差很小,但位姿明显错得离谱

现象是重投影误差中位数不到1像素,把计算出的坐标系画回图像,却明显偏转几十度,看起来完全不沾边。这个反直觉的现象最容易让人怀疑算法实现,其实原因通常不在算法上。

常见原因第一是内参K或畸变参数用了出厂标称值而不是实测值,视线方向整体偏移;第二是点对应里混进错配点,少数的坏点被多数正确点平均掉了,导致平均误差很小但位姿被带偏。解决办法是先按4.1的流程拿到实测K并做去畸变,然后把单个点的重投影误差列出来看分布,误差特别大的那几对就是嫌疑点,剔除或重新匹配后再跑一次。

5.2 平面目标上迭代抖动,甚至收敛到镜像旋转

现象是目标是一个平面板,四个角点全部共面,OI迭代多次R仍在变化,有时输出一个左右翻转的结果。原因是共面点提供的信息量只有8个自由度,而位姿本身有6个自由度,加上单目投影的二义性,平面构型天生约束不足。Kabsch里那个行列式修正会把结果掰向两个对称解之一,于是出现镜像。

解决方式第一优先级是给模型加非共面点,哪怕只是把一个角点垫高10毫米,效果都天差地别。如果目标本质就是平面,则先用单应性分解给位姿一个先验初值,再用OI在附近精修,并把tol调小,避免它在两个解之间反复横跳。

5.3 像素转视线时坐标系翻车,物体上下颠倒

现象是输出的R完全不对,画出来的坐标轴像被镜像过,物体上下反转。原因多半是把图像像素坐标直接当成相机系的x、y,忘了图像原点在左上角、v轴向下,或者跳过了内参归一化和畸变校正。

解决办法是让所有像素到视线的转换统一走pixel_to_ray一个函数,不要在每个模块里各写一遍。写完立刻做正向投影自检:把世界系原点按当前R、t投影回图像,看到的位置应当落在它真实像素附近。这一步能暴露所有坐标系约定错误,我在新项目里都会先跑一次这个自检再继续往下写。

5.4 ITERATIVE初值给歪,直接发散成NaN

现象是solvePnP返回失败,rvec和tvec为空,打印出来能看到NaN或几百像素的残差。原因是SOLVEPNP_ITERATIVE本质是局部优化,非常依赖初值,初值给成零向量或者偏差太大时,雅可比迭代直接飞出相机前方,重投影误差爆炸。

解决办法是不用ITERATIVE直接求解,而是先跑SOLVEPNP_EPNP得到一版粗位姿,再把它作为初值喂给ITERATIVE,或者直接丢给oi_pose。这个两段式流程在我做过的项目里基本杜绝了发散问题,代价只是多花一两毫秒的EPnP求解。

5.5 一两个离群点混进点集,结果被拖偏一大截

现象是点对里混入1到2个错配点,肉眼看不出来,但输出位姿的t偏离正确值十几厘米。原因是OI把残差平方累加,离群点的权重被平方放大,少数坏点就能主导目标函数。这是二范数优化的通病,不是OI独有的问题。

解决办法是在迭代前先做一轮粗筛:用任意一种快速PnP解算一版中间位姿,把所有点投影回去,残差大于0.5像素的直接标记,剔除后再用干净点集跑OI精修。收敛后再看一眼残差的中位数而不是平均数,中位数对离群点天然不敏感,更适合做质量指标。

6. 进阶验证:三关自查,让位姿结果可以放心用

位姿算出来不等于能用。我每次把R、t送进机械臂控制环之前,都会强制跑三关自查,这套流程已经变成了固定习惯。

第一关是重投影误差。用求出的R、t把所有模型点投影回图像,看残差的中位数。正常项目做到0.5像素以内,说明点对应和内参都没问题;超过1像素就值得警惕,这一步能拦下一半以上的脏结果。第二关是尺度核对。在点集里找两个距离已知的点,算一下模型距离和真实距离的比例,与上一帧的比例应该几乎一致,误差超过2%说明有离群点混进来,或者目标发生了不该发生的变形。第三关是连续帧一致性。相邻两帧的旋转增量应当是小角度的,平移增量应当与相机运动趋势吻合,如果帧间t瞬间跳了几十厘米,多半是单帧解算失败。

这三关都是几行代码能完成的事,我习惯把它们封装成一个check_pose函数,在位姿输出后自动调用。如果把相机坐标轴画回原图,判断会更直观:

axis_world = np.array([ [0, 0, 0], [30, 0, 0], [0, 0, 0], [0, 30, 0], [0, 0, 0], [0, 0, 30], ], dtype=np.float64) proj_axis, _ = cv2.projectPoints(axis_world, rvec, tvec, K, dist) # 用cv2.line把proj_axis按三对坐标连成XYZ三条轴,叠加在原图上

画出来的三条彩色坐标轴会立刻告诉你位姿是真是假。这套流程看起来不起眼,但确实在项目里救过我很多次,尤其是当镜头畸变和特征点噪声同时存在时,数字指标容易骗人,视觉反馈不会。从那以后我每次跑位姿估算,不管多着急,都先走一遍重投影和尺度核对,把脏数据拦在下游之前,省下的调试时间远超这几毫秒的代价。这套单目位姿估算实践路径就分享到这里,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询