ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

二维坐标转三维坐标:反投影原理、OpenCV实现与工程避坑指南

二维坐标转三维坐标:反投影原理、OpenCV实现与工程避坑指南 做视觉、机器人和测量方向的朋友应该都遇到过这个需求在图像上框选或点击一个点要拿到它在真实世界里的三维坐标。二维坐标、三维坐标之间的坐标转换也叫“反投影”或“逆投影”但它并不是简单的代数求逆。实际成像过程把一个三维点压缩成二维像素深度信息在这个过程中丢掉了所以单张图像本身提供不了唯一的三维坐标解。这篇文章我会从成像模型讲起结合平面假设、深度传感器、多视角三角化三类主流实现方案给出可直接落地的 OpenCV 示例和踩坑清单。无论你是做车道线检测、机械臂抓取还是 AR 放置物体这个原理都是绕不开的基础。1. 坐标转换的基础认知从像素到三维点的完整链路1.1 四个坐标系和一次完整透视投影我见过太多人拿到代码就套公式结果换一个场景就废。原因通常不是公式错而是坐标系没理清。先花两分钟把这几个坐标系记牢比背任何公式都有用。世界坐标系Xw, Yw, Zw我们为了描述方便指定的绝对参考系比如把标定板的一个角设为原点地面 Z0向上为 Z 轴。相机坐标系Xc, Yc, Zc原点在相机光心Zc 轴沿光轴向前Xc 向右Yc 向下OpenCV 习惯。图像物理坐标系以光轴和成像面的交点为原点单位通常是毫米xy 轴方向和像素坐标系一致。像素坐标系u, v单位是像素原点在图像左上角。一个世界坐标点 P_w 投影到像素 p 的过程用齐次坐标写下来是这样的λ [u; v; 1] K [R | t] [Xw; Yw; Zw; 1]其中 K 是内参矩阵K [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]这里的 fx、fy 不是焦距的毫米数而是焦距在像素尺度下的表示等于物理焦距除以单个像素的物理边长。cx、cy 是主点坐标反应相机光轴在图像上的像素位置。R 和 t 是外参表示世界坐标系到相机坐标系的旋转和平移所以 [R|t] 是一个 3x4 的矩阵。这个公式中间其实还藏了一步归一化三维点先变换到相机坐标 (X_c, Y_c, Z_c)然后投影成 (fx * X_c / Z_c cx, fy * Y_c / Z_c cy)。把除法写开就成了齐次形式λ 就是 Z_c代表点在相机坐标系下的深度。这个深度信息在成像瞬间被彻底丢失二维像素里没有足够信息直接还原出它。1.2 反投影为什么只能得到一条射线现在我们把上面公式反过来。已知内参 K给定像素点 p可以通过归一化坐标得到x K⁻¹ [u; v; 1]x 其实是一个三维方向向量。任何一个满足 P_c d * x 的空间点d 是任意大于 0 的标量投影后在图像上都是同一个位置。也就是说二维坐标转三维坐标未加约束时答案不是“一个点”而是“一条从相机光心出发的射线”。用人眼来打比方一只眼睛看到远处的一个物体只知道它大概在这个方向但没法判断到底是近处的小物体还是远处的大物体。只有加上深度线索比如两只眼睛的视差、遮挡关系、物体尺度假设才能确定准确距离。摄像头成像和这个道理完全一样唯一的区别是它连颜色信息都只能给出方向性无法直接感知距离。所以所有能做“二维转三维”的算法本质上都是在给这条射线补充一个额外的约束。约束可能来自三个地方目标点所在的几何模型平面、球面、传感器测到的距离深度相机/激光雷达、或者多视角间的几何关系双目/多视图三角化。这也是衡量方案优劣的通用视角——约束越可靠结果越稳定。1.3 坐标转换的两大类工程思路理解了射线模型再回看工程需求就很清晰了。二维到三维转换没有万能方案只有最匹配约束条件的方案。一类是“单视图 空间模型”。如果目标物体都在某个已知平面上比如自动驾驶里的路面、仓储机器人工作的地坪、桌面上摆放的零件那么直接把射线与平面求交即可。如果目标尺寸已知也可以让射线与已知大小的包围盒或球体求交这是很多单目物体位姿估计的做法。另一类是“多视图或直接测距”。双目相机利用左右图像匹配点三角化得到三维坐标结构光和 ToF 深度相机如 RealSense、Kinect直接返回每个像素的深度激光雷达甚至直接输出三维点云。这类方案不依赖平面假设适合抓取、三维重建、SLAM 等更通用的场景。这两类方案后面都会讲到具体实现。先端正心态二维转三维不是查一个公式就能解决的关键在于想清楚你手里的额外信息是什么。2. 几种主流方案的原理与适用场景2.1 平面假设与逆透视变换性价比最高的单目方案假设目标点全部位于某个已知平面上这是工程里最常用也最经济的方法。以自动驾驶的俯视图为例摄像头安装在车头图像里路面上的车道线、障碍物底部轮廓都位于地面平面。我们可以先标定相机外参确定相机相对地面的高度和角度然后把图像反算到平面坐标得到鸟瞰图这就是逆透视映射IPM。数学上平面约束可以写成 n · P_w d 0其中 n 是平面法向量d 是平面到世界原点的有向距离。地面通常设为 Z0所以 n [0, 0, 1]d 0。求解分三步根据内外参把像素坐标转换为相机坐标系下的射线方向 dir_c把射线方向和相机光心位置通过外参转到世界坐标系把世界坐标系下的射线与世界平面方程联立求出交点参数 t再代回射线方程。用代码表达时世界坐标系下相机光心位置是 O_w -R_c2w t_w2c其中 R_c2w 是旋转矩阵的转置t_w2c 是之前 solvePnP 求出的“世界到相机”平移向量。射线方向 dir_w R_c2w dir_c。已知光心 O_w 和方向 dir_w射线就是 O_w t * dir_w。把射线方程代入平面方程得到t -( n · O_w d ) / ( n · dir_w )分母是射线方向与平面法向量的点积如果接近零说明射线几乎平行于平面交点非常远或不存在工程里要加一个阈值判断避免计算出天文数字。从应用角度说逆透视变换不只用于显示鸟瞰图。很多低成本的自动泊车、AGV 导航系统检测目标在图像中的像素坐标后直接用这套方案换算成地面坐标再发送给运动控制模块。它的精度取决于相机标定质量和目标是否严格位于平面内——稍微凸起或凹陷的物体算出来的三维坐标就会有系统性偏差。2.2 深度传感器辅助RGB-D 相机直接给出距离如果项目预算允许加一个深度传感器整个问题就简单得多。RGB-D 相机里的深度图是一个和彩色图对齐的二维数组每个像素值代表该位置的深度。以 RealSense 和 Kinect 为代表的结构光/ToF 相机直接给出的是相机坐标系下的 Z_c 值单位通常是毫米或者由相机设置决定。已知齐次像素坐标 (u, v) 和深度 Z_c我们可以反算相机坐标X_c (u - cx) / fx * Z_cY_c (v - cy) / fy * Z_cZ_c depth这里有个容易被忽略的细节depth 给的是“沿光轴方向的深度 Z_c”而不是点到相机光心的欧氏距离。在视场中心两者差不多但在图像边缘同一个深度值对应的欧氏距离会更长。很多点云生成库内部会做一个缩放如果你自己实现要确认公式用的是 Z_c 而不是 dist sqrt(X² Y² Z²)。得到相机坐标后再乘外参的逆矩阵就能转到世界坐标P_w R_w2cᵀ * (P_c - t_w2c)或者按我之前习惯的方式 P_w R_c2w P_c O_w。注意 RGB-D 相机在使用前也要做好深度图与彩色图的对齐否则直接用彩色图上的像素坐标去取深度值会产生边缘错位。深度方案的优点是几乎不依赖几何模型室内物品、桌面抓取、人体姿态什么形状都可以。缺点是有效距离有限ToF 相机在强阳光下容易失效物体表面反光或黑色吸光材质会带来大量深度空洞。使用前最好做一次深度精度校准因为深度误差会直接线性传导到 X、Y 坐标1% 的深度误差就是 1% 的三维坐标误差。2.3 多视角三角化双目与多视图重建当单个相机无法测距、又不能加深度传感器时多视角是最可靠的手段。两个相机从不同位置观察同一个点两条视线在空间中相交交点就是三维坐标。人的双眼也是这么工作的不过工程上要处理的是相机内参、外参和匹配误差。最常用的数学方法是直接线性变换三角化DLT。一个三维点 X 投影到图像上满足 p × (P X) 0其中 P 是某一视角的 3x4 投影矩阵。叉积等于零可以拆出两个独立的线性方程两个视角一共凑出 4 个方程未知数只有 3 个X 的 xyz加上齐次尺度一共是 4 个未知量于是用 SVD 求最小二乘解即可。OpenCV 已经封装好了triangulatePoints使用前的关键输入是两个视角的投影矩阵 P1、P2以及一对已经匹配好的像素坐标点。P 矩阵由 K 和外参拼接得到P K [R | t]。这里 R、t 代表世界坐标系到该相机的变换而不是相机到世界。双目标定的输出一般就是左相机相对于右相机的 R、t直接用就行。多视角方案的精度和两个因素强相关一是相机之间的基线长度基线越长交会角越大深度精度越高二是像素匹配精度哪怕只有 0.1 像素的匹配误差远处的三维点也会产生明显抖动。在实际项目里如果要做高精度的双目三维定位建议先对两路相机做立体校正stereo rectification把匹配搜索从二维降到一维这样既能提高正确率也能让匹配点达到亚像素精度。现在方案的原理都过了一遍准备进入实操环节。3. 用 OpenCV 把二维坐标转成三维坐标3.1 标定相机拿到内参、畸变和外参没有标定的坐标转换等于瞎猜。内参和畸变只需要做一次但外参会随相机安装位置变化所以每次移动相机都要重新求外参。先说内参标定。经典做法是用棋盘格采集 10~20 张不同角度的标定板图像调用cv2.findChessboardCorners找到角点再交给cv2.calibrateCamera计算 K 和 D。D 是畸变系数向量通常包含 k1、k2、p1、p2、k3前两个是径向畸变p1、p2 是切向畸变。如果用的是鱼眼相机不能直接套这套模型要用cv2.fisheye模块单独标定。外参的获取场景多种多样。对于固定安装的相机最通用也最少出错的办法是把一张标定板平放在目标平面上用cv2.solvePnP求世界坐标到相机坐标的旋转向量和平移向量。代码是这样的import cv2 import numpy as np # 标定板上的角点三维坐标单位建议统一为米 object_points np.array([ [0, 0, 0], [0.03, 0, 0], [0.06, 0, 0], [0, 0.03, 0], # ... 根据标定板实际角点补全 ], dtypenp.float32) # 对应的图像角点坐标 image_points np.array([ [u1, v1], [u2, v2], # ... 和 object_points 一一对应 ], dtypenp.float32) # 求解世界坐标到相机坐标的旋转向量 rvec 和平移向量 tvec retval, rvec, tvec cv2.solvePnP(object_points, image_points, K, D) R_w2c, _ cv2.Rodrigues(rvec)看到没有solvePnP输出的是“世界到相机”。如果直接拿这个 R 去拼接外参矩阵最终求出来的三维点会莫名其妙地往一个方向偏。正确的做法是先做一次转置R_c2w R_w2c.T O_w -R_c2w tvecR_c2w 表示相机坐标系到世界坐标系的旋转O_w 是相机光心在世界坐标系下的位置。有了这两个量相当于在真实空间里给相机定了位后面所有坐标变换都基于它们。3.2 射线与平面求交实现一个像素到地面坐标的函数假设我们已经通过 solvePnP 拿到了 R_c2w 和 O_w目标平面是世界坐标系的 Z0 地面那么一个很实用的转换函数长这样def pixel_to_ground_z0(u, v, K, D, R_c2w, O_w): # 1. 像素坐标去畸变得到归一化坐标 pts np.array([[[u, v]]], dtypenp.float32) norm cv2.undistortPoints(pts, K, D) x, y norm[0, 0] # 2. 构造相机坐标系下的射线方向 dir_c np.array([x, y, 1.0], dtypenp.float64) # 3. 转到世界坐标系 dir_w R_c2w dir_c # 4. 射线与 Z0 平面求交 # 设 P O_w t * dir_w令 P_z 0 t -O_w[2] / dir_w[2] point_w O_w t * dir_w return point_w这里有一个极易踩的坑射线方向在相机坐标下一定可以写成 (x, y, 1)但转到世界坐标后这个向量的 z 分量未必非零。用 O_w[2] / dir_w[2] 求解 t 时如果 dir_w[2] 接近零说明射线近乎平行于地面此时目标点离相机非常远结果没有参考价值建议加一个阈值直接丢弃。还有一点cv2.undistortPoints的第二个输出参数 P 如果不指定返回的确实是归一化坐标如果传入 PK返回的就不是归一化坐标而是像素坐标了。很多老代码里为了省事直接忽略这一步在图像中心区域问题不大但广角镜头边缘的误差可以到几十个像素对应几十厘米绝对不可忽视。如果目标平面不是 Z0而是高度 h 的水平面或者带倾斜角度的墙面只需要把平面方程改写成 n · P_w d 0然后代入射线参数求解即可。我在前面已经给过通用公式def ray_plane_intersection(O_w, dir_w, n, d): denom np.dot(n, dir_w) if abs(denom) 1e-8: return None t -(np.dot(n, O_w) d) / denom return O_w t * dir_w这个函数可以作为通用工具封装起来单目相机在地面、桌面、墙面上定位物体时都用它。3.3 深度图转三维坐标从单像素到点云这里给出基于深度图的完整 Python 实现。假设 depth 是一张 16 位单通道深度图单位是毫米且已经和彩色图做过对齐def depth_to_point(u, v, depth, K, R_c2w, O_w): Z_c depth[v, u] / 1000.0 # 转换为米 if Z_c 0: return None fx K[0, 0] fy K[1, 1] cx K[0, 2] cy K[1, 2] X_c (u - cx) / fx * Z_c Y_c (v - cy) / fy * Z_c P_c np.array([X_c, Y_c, Z_c]) P_w R_c2w P_c O_w return P_w实际项目里通常不会只转一个点而是把整张深度图转成一个点云。批量操作时尽量不要用 Python 的 for 循环先通过np.meshgrid生成所有像素的 u、v 网格然后一次性做减法和乘法速度会快几百倍。我用 640x480 的深度图做过测试向量化版本大约 20 毫秒for 循环可能要十几秒差距非常大。深度图转点云的精度直接取决于深度相机本身。RealSense 系列在 1 米以内的室内误差大概几毫米3 米外就到厘米级结构光方案对黑色反光物体会产生空洞最好先用cv2.inpaint做简单深度补全或者用多帧时间滤波否则生成的点云表面会有很多黑洞。3.4 多视角三角化写一个最小可用的双目定位如果手头有两台已标定、已立体校正的相机可以把左右图像中同一个特征点的像素坐标喂给triangulatePoints。P1 K np.hstack((R1, t1)) P2 K np.hstack((R2, t2)) # 注意格式2xN 的浮点数组 left np.array([[u1, v1]], dtypenp.float64).T right np.array([[u2, v2]], dtypenp.float64).T point_4d cv2.triangulatePoints(P1, P2, left, right) point_3d_h point_4d / point_4d[3] point_3d point_3d_h[:3, 0]这里的 R1、t1 和 R2、t2 都是世界坐标系到各自相机坐标系的变换和相机内参 K 拼接后得到各自投影矩阵。triangulatePoints返回的四维齐次坐标需要除以 w 分量才是欧氏三维坐标。很多人在这一步会疑惑标定好双目后为什么把 R1、t1 设为单位阵和零向量R2、t2 设为右相机相对左相机的位姿这本质上是把左相机坐标系当作世界坐标系。这样得到的 P1 就是 KP2 就是 K [R | t]使用起来也完全正确。只要保证两个视角的投影矩阵定义在同一个世界坐标系下就行。三角化结果的质量我在实际项目中体会最深的是左右匹配点坐标。如果匹配点是手工选的一定要做到亚像素精度否则同一组点每次三角化出来的坐标会上下跳动如果是特征点匹配建议用cv2.cornerSubPix或类似亚像素细化算法先精化再三角化。4. 常见问题与工程排查4.1 内参外参单位弄错计算结果直接偏移坐标转换里最常见的翻车点就是单位不统一。标定板坐标如果用毫米深度图用米生成的点云就会差 1000 倍。外参里的 t 是和标定板坐标同一个单位所以标定板坐标用什么单位最后输出的三维坐标就是什么单位工程上最好统一成米。内参矩阵里的 fx、fy、cx、cy 单位是像素不是米也不要在代码里把它和物理焦距混淆。另一个高频错误是把solvePnP返回的旋转向量直接用 3x3 矩阵去乘没有先调用Rodrigues转换。旋转向量是一个紧凑的三维向量只有经过罗德里格斯变换才能变成旋转矩阵直接相乘会得到完全错误的结果。4.2 畸变校正做没做误差天壤之别我用广角镜头做过对比测试不校正畸变图像边缘的像素坐标反算到地面横向误差可以达到 60 厘米校正后在相同区域误差能压到 3 厘米以内。所以说无论哪个方案先把畸变这个源头处理好。注意cv2.undistort和cv2.undistortPoints是两回事。undistort是对整幅图像做处理输出一张新图而undistortPoints是对点坐标做处理输出新的点坐标。做像素点到空间点的转换必须用后者因为它返回的是归一化坐标直接用来构造射线。如果目标图像本身是已经去畸变过的代码里就不需要再调用这一步前提是你明确知道预处理流程。4.3 验证转换结果好坏的三个自检方法我每次搭好一套坐标转换流程都会做三个自检建议你也照做。第一反投影检查。把求出来的三维点重新投影回图像看像素坐标是否和原来输入的一致。如果差几个像素以内说明标定和变换基本没问题如果差几十像素说明外参或畸变参数错了。第二已知距离检查。在目标平面上放一个已知世界坐标的物体比如一把卷尺量好的标记点分别测 2 米、5 米、10 米看系统输出的坐标和真值差多少。第三稳定性检查。固定相机不动连续采集几帧图像对同一个特征点做转换看结果方差。如果每次结果都飘出几厘米以上多半是像素提取抖动、曝光变化或外参不稳要先定位是哪个环节引入的噪声。4.4 精度提升的经验清单我把项目里遇到过的精度问题整理成了速查表方便大家对照排查。现象可能原因处理建议输出坐标整体偏移外参或者平面方程错误重新用 solvePnP 标定打印 R_c2w 和 O_w 核对边缘区域误差大畸变校正被忽略使用 undistortPoints 校正后再算射线远处点抖动明显匹配/像素坐标提取精度不足做亚像素细化或改用更高分辨率图深度空洞多相机和材质问题时间滤波、多帧融合、改用主动光源三角化坐标偏斜双目外参或校正不准确重新做立体标定检查重投影误差单位差 1000 倍标定板单位和输出单位不一致统一为米代码里加注释表格只是一种总结方式实际调试时我会建议先在检测阶段就保证像素精度。像素误差每增加 1 个像素射线的角度误差会随着物距拉大而放大10 米外一个像素往往对应几十厘米。所以很多中文博客喜欢说“坐标转换精度不高”大概率不是转换公式出了问题而是前面像素级特征点的定位本身就不牢固。4.5 一个容易被忽略的坐标系正负号问题最后补充一个经常让人挠头的细节像素坐标系 v 轴向下相机坐标系 Yc 轴也通常向下而很多数学教程里的三维坐标 Y 轴朝上X 轴朝左。如果你在标定时手动构造了三维点坐标比如把标定板放在桌上Z 轴定义成垂直桌面向上那么和 OpenCV 默认的右手系可能不完全一致。解决的办法是在构造object_points里定义三维点时保证和实际物理布置一致不要依赖想象中的理想坐标。如果发现转换出来的物体左右颠倒、前后反了优先检查外参 R_w2c 的旋转矩阵是否和一个单位阵差得很远以及相机光心 O_w 是否落在了相机实际位置的另一侧。这些问题多跑一次反投影就能定位不 debug 空想永远找不到根因。我自己的体会是二维坐标转三维坐标本质不是在学一个公式而是在学“如何补深度信息”。第一次上手时不要急着跑通代码先拿一张标定板、一把卷尺把坐标系拍照、画图、写注释把每个矩阵的物理含义都标清楚再上代码实现通常一两个小时就能从“跑通”变成“懂”。最后再分享一个小技巧把所有标定参数和外参保存成 JSON 后一定要记录当时的标定板摆放方向否则第二天再开项目你根本想不起来当时世界坐标系的 X 轴朝着哪里。这个细节帮我在后续回读数据时省了太多麻烦。
返回列表