这个话题我几乎每周都会遇到一次:标定板上的角点怎么投到三维空间,语义分割图里某个像素落在机器人坐标系下的哪个位置,AR 应用里屏幕点击坐标怎么换算成地面上可站立的点,或者干脆想把一张 2D 海报贴到 3D 楼房模型的墙面上。做视觉、仿真、三维重建的工程师应该都有过这种困惑——把 2D 图像或 2D 点“投影”到 3D,听起来像是补全缺失的深度信息,实际上是把相机模型、坐标系变换、几何约束串起来的一整条工程链路。这篇文章不堆数学推导,重点讲清底层的针孔模型、投影前要准备哪些参数、几种能直接落地的单目投影方案和对应代码,以及我在真实项目里踩过的坑。适合刚接触三维视觉、想在 AR、机器人、仿真或三维重建中做“屏幕点转空间点”的同学。
1. 先搞清楚一个前提:2D 到 3D 没有唯一解
很多人第一次做反向投影时,会下意识认为这是一个“已知像素坐标,求三维坐标”的方程求解问题。实际上,从单个 2D 点反推 3D 坐标在数学上是一个欠定问题,先把这个概念理顺,后面所有代码才不会写歪。
1.1 针孔相机模型到底在算什么
相机把三维世界变成二维图像,靠的是小孔成像。一个 3D 点经过光心投影到成像平面上,得到像素坐标。整个过程可以用一个非常简单的式子表达:
u = fx * X_c / Z_c + cx v = fy * Y_c / Z_c + cy其中 (X_c, Y_c, Z_c) 是 3D 点在相机坐标系下的坐标,(u, v) 是像素坐标,fx、fy 是焦距(以像素为单位),cx、cy 是主点坐标。这个式子直观说明了一件事:投影过程中,点的深度 Z_c 被除法吃掉了。也就是说,二维图像里只保留了“方向”信息,丢掉了“距离”信息。
打个生活化的比方:拿起激光笔照向一面墙,墙上出现一个光点。你只知道光点在某条直线方向上,但不知道它具体落在多远。除非你知道墙面在哪里,或者光是从多远发出的,否则这条直线上的每一个点都可能对应这个光点。2D 到 3D 的转换,本质上就是在找回这条直线上的正确位置。
1.2 齐次坐标和投影方程
为了写代码方便,视觉里普遍用齐次坐标把“旋转+平移+透视除法”统一成矩阵乘法。世界坐标 X_w 先通过外参变换到相机坐标:
X_c = R * X_w + t再通过内参投影到像素:
[u, v, 1]^T ~ K * X_c其中 K 是内参矩阵:
K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1 ]]把这两个式子合并,就是经典的投影矩阵 P = K * [R | t],一个 3D 点 X_w 的像素坐标可以写成齐次形式:
[u, v, 1]^T ~ P * [X_w, 1]^T反过来,如果已知像素坐标 (u, v),可以先恢复相机坐标系下的归一化坐标:
x_norm = (u - cx) / fx y_norm = (v - cy) / fy z_norm = 1这个归一化坐标就是一条射线的方向(未归一化)。给它乘上任意的深度值 s,都能得到相机坐标系下的一个三维点。所以从数学上讲,一个 2D 像素点对应的是三维空间里整整一条射线的无数个点。
1.3 为什么必须加约束
既然单目单帧的 2D 到 3D 解不唯一,实际工程里就必须给这个问题加约束条件。常见的约束有这么几种:
- 深度已知或可假设:比如深度相机输出的深度图,或者物体放在已知距离的平面上。
- 平面约束:比如地面、桌面、墙面的平面方程已知,把射线与平面求交。
- 多视图约束:同一 3D 点被两幅或更多图像看到,利用多视角几何三角化出唯一交点。
- 尺度约束:知道物体的实际物理尺寸,用来消除单目视觉的尺度模糊。
后续所有方案,本质都是在回答“我该选哪种约束”。理解这一点,就不会被各种看似不同的投影方法搞晕。
2. 投影前必须准备齐全的三件套:内参、外参、去畸变
不管用哪种方案,跑通 2D 到 3D 之前,手里必须有一套可信的相机参数。参数不行,后面所有几何计算都是空中楼阁。
2.1 内参矩阵 K 里每一项到底代表什么
内参矩阵里的 fx、fy 不是物理焦距的毫米值,而是“焦距 / 像素物理尺寸”得到的像素焦距。它描述的是三维点在相机坐标系下的坐标值,经过透视除法后,能被缩放成多少像素偏移。cx、cy 是光轴与成像平面交点的像素坐标,正常情况下接近图像中心,但相机模组装配误差会让它偏移几十个像素,必须标定出来。
内参通常通过棋盘格标定得到。用常见的开源标定工具,拍十几张不同角度的棋盘格照片,提取角点,拟合内参和畸变系数,最后看重投影误差是否小于 0.1 像素量级。这里有个容易被忽略的问题:标定得到的 fx、fy 是在“去畸变图像”上有效的内参,这一点和图像分辨率强相关。如果标定时用的是 1920x1080 的原图,后来程序里把图像缩放成了 960x540,那么 fx、fy、cx、cy 必须同步除以 2,否则投影结果会整体偏移。
2.2 外参决定了“相机在世界哪里”
外参由旋转矩阵 R 和平移向量 t 组成,作用是把世界坐标系下的点转换到相机坐标系:
X_c = R * X_w + t做反向投影时,通常要把相机坐标系下的点恢复到世界坐标系,用逆变换:
X_w = R^T * (X_c - t)这里要注意,t 不是相机在世界坐标系下的位置。相机光心在世界坐标系下的坐标是:
C_w = -R^T * t我见过不少人在这一步把符号搞反,导致投影点全部落在错误象限。可以自己推导一遍:光心满足 R * C_w + t = 0,所以 C_w = -R^T * t,这个关系在射线求交时非常重要。
如果你用到的是免标定的仿真环境,比如直接从渲染引擎的相机节点里读出位姿,也要先确认引擎的坐标轴约定。很多引擎是左手系,视觉里用的是右手系,直接套公式会导致 z 轴方向反了,投影点前后颠倒。
2.3 畸变不处理,投影会歪
针孔模型是理想模型,真实镜头还有径向畸变和切向畸变。径向畸变让直线变弯,切向畸变让成像平面和镜头不完全平行。这意味着直接把原始图像上的像素坐标丢进反投影公式是不对的,必须先做畸变校正。
常用的做法有两种。一种是在管线里用去畸变后的图像,所有像素坐标都来自已校正图;另一种是保留原始图像,但用undistortPoints这类函数把特征点坐标从畸变图转换到去畸变后的归一化坐标系,再参与投影计算。
注意:如果某个视觉库配置了“畸变校正参数”,但你实际没有开启去畸变功能,那么基于这些参数做反投影就等于瞎猜。先确认整条链路的图像到底是不是同一种坐标系,再谈投影精度。
3. 实操:三种把 2D 点投影到 3D 的可落地办法
下面给出我在项目里最常用的三种方案,每一种都对应一个明确的物理场景。代码用 Python + NumPy 写,核心是投影函数本身,业务逻辑可以根据需求替换。
3.1 固定深度:假设 z 等于某个值
适用场景:深度相机输出深度图、已知目标在相机前方固定距离、或者在做点云生成时每一个像素的深度都已知。
算法非常直接:已知像素 (u, v)、内参 K、相机坐标系下的深度 Z_c,直接反投影:
import numpy as np def pixel_to_point_at_depth(u, v, depth, K, R, t): # 从像素坐标恢复归一化相机坐标 x_norm = (u - K[0, 2]) / K[0, 0] y_norm = (v - K[1, 2]) / K[1, 1] z_norm = 1.0 # 相机坐标系下的三维点 X_cam = np.array([x_norm * depth, y_norm * depth, depth]) # 转换到世界坐标系 X_world = R.T @ (X_cam - t.reshape(3, 1)).ravel() return X_world这里有一个容易混淆的点:depth指的是相机坐标系下的 Z 坐标,不是点到相机光心的欧氏距离。如果某个深度传感器给的是欧氏距离 distance,你需要先换算:depth = distance / sqrt(x_norm^2 + y_norm^2 + 1),否则点云会整体往相机方向收缩。
用这种方法生成整个图像的点云,其实就是深度图转点云的标准流程。逐像素计算时注意用 NumPy 向量化,不要写 Python for 循环,否则几百万元素能把 CPU 跑穿。
3.2 地面平面约束:像素落在地面上
适用场景:机器人导航、AR 物体摆放、监控场景中把图像上的目标位置放到地面坐标系。约束条件是“目标点在地面平面上”,平面方程已知。
处理思路是构造一条从相机光心出发的射线,然后求射线与平面交点。射线方向来自归一化坐标,光心位置来自外参。
def pixel_to_ray(u, v, K, R, t): # 归一化坐标,作为相机坐标系下的方向 x_norm = (u - K[0, 2]) / K[0, 0] y_norm = (v - K[1, 2]) / K[1, 1] # 方向从相机系转到世界系,单位化 dir_cam = np.array([x_norm, y_norm, 1.0]) dir_world = R.T @ dir_cam dir_world = dir_world / np.linalg.norm(dir_world) # 相机光心在世界系下的坐标 C_world = -R.T @ t.reshape(3, 1).ravel() return C_world, dir_world def pixel_to_plane(u, v, K, R, t, plane): # plane: [a, b, c, d],表示 a*x + b*y + c*z + d = 0 C, direction = pixel_to_ray(u, v, K, R, t) denominator = plane[0] * direction[0] + plane[1] * direction[1] + plane[2] * direction[2] if abs(denominator) < 1e-8: return None # 射线与平面平行 lam = -(plane[0] * C[0] + plane[1] * C[1] + plane[2] * C[2] + plane[3]) / denominator if lam < 0: return None # 交点在相机后方 point = C + lam * direction return point这个方案的精度几乎完全取决于平面方程准不准。在某个户外项目里,我先把地面近似成 z=0 平面,投影出来的目标位置和实测位置差了半米,原因是地面本身有坡度,而且世界坐标系原点没校准。后来用多点拟合真实地面平面,误差立刻降到厘米级。所以如果方案不达标,先检查约束面,而不是怀疑内参。
3.3 用射线做 AR 拾取和场景点选
适用场景:在 3D 引擎或仿真环境里点击屏幕选物体、AR 中把虚拟物体放到真实场景中。这类需求往往不需要直接求世界坐标,而是拿到射线后和场景中的三角网格做碰撞检测。
射线方向的计算跟前面一样。拿到C_world和direction之后,交给引擎的 Raycast 接口即可。在自研几何引擎里,则要对每个三角形做射线求交。求交时要留意射线与三角形平行的情况,以及交点落在三角形外侧的情况,这些都会造成点击无反应。
有一点经验值得分享:不要把 ray 的 direction 做归一化之前先传给引擎。很多引擎的 Raycast 默认接受单位方向向量,如果直接传未归一化的向量,交点距离会被错误缩放,导致拾取位置偏移。我在无数个项目里反复确认过这一点,最后干脆把单位化写死在pixel_to_ray里,从源头杜绝这类问题。
3.4 另一层含义:把整张 2D 图像投影到 3D 表面
标题里“2D 图像投影到 3D”还有另一层常见含义:把一张图片作为纹理贴到 3D 模型上。比如给楼房模型贴外立面贴图、给地面贴卫星图、或者做投影仪效果。这里有两个分支。
第一种是常规 UV 纹理映射。3D 模型每个顶点都带有一个 UV 坐标,纹理图像按 UV 坐标采样颜色。这本质上是从 3D 到 2D 的映射,但在建模软件里操作时,确实就是“把 2D 图像贴到 3D 表面”的直观体验。需要注意 UV 坐标的原点通常在左上角,而很多渲染 API 的纹理坐标原点在左下角,上下颠倒会导致贴图翻转。
第二种是相机投影贴图,也叫投影纹理。思路是把 3D 场景中的每个点,通过一个虚拟相机的投影矩阵,映射回一张 2D 图像上取色。这时的投影矩阵就是标准的 P = K * [R | t],3D 点先投影成像素坐标,再采样图像颜色。适合做路边广告牌动态投影、汽车大灯灯光贴图、或美术预览中的“电影放映机”效果。实际渲染中要注意投影相机必须把视锥体范围、远近裁剪面配置好,否则贴图会在错误位置拉伸。
4. 多视图:用两幅 2D 点还原 3D 点坐标
如果场景里有两台相机,或者同一台相机移动后拍了两个位置,就可以放弃平面假设,直接用三角化把匹配的 2D 点还原成真正的 3D 点。这是结构光、双目标定、SLAM 建图的基础。
4.1 三角化原理:从一条线变成两条线相交
前面说过,单目单帧的 2D 点对应一条射线。如果同一个 3D 点在另一张图像里也有匹配点,那就多了一条射线。两条射线理论上交于同一个点,这个点就是我们要恢复的 3D 坐标。由于噪声存在,两条射线往往不会严格相交,而是形成一条微小的“错位”,三角化算法就是在最小化这个重投影误差。
最朴素的做法是直接构建线性方程组,把齐次坐标形式代入:
x1 ~ P1 * X x2 ~ P2 * XP1、P2 是两个相机的 3x4 投影矩阵,x1、x2 是匹配点的齐次像素坐标。用 DLT 方法求最小二乘解即可。实际工程里可以直接用开源视觉库的triangulatePoints函数,省去自己解矩阵:
# points1, points2 是两帧匹配点坐标,形状为 Nx2 points4 = cv2.triangulatePoints(P1, P2, points1.T, points2.T) points3d = (points4[:3] / points4[3]).T这里的P1、P2不是内参矩阵 K,而是包含内外参的 3x4 投影矩阵。如果手里只有两帧的 R、t 和内参 K,需要自己组合一下。
4.2 完整流程和注意事项
要做三角化,光有两幅图的匹配点还不够,必须先恢复两帧之间的相对位姿。常规流程是:
- 提取特征点并匹配。
- 用基础矩阵或本质矩阵估计两帧相对运动。
- 分解出 R、t,结合内参得到 P1、P2。
- 对匹配点执行三角化。
- 用光束平差优化所有点的 3D 坐标和位姿。
这个流程里最容易出问题的有三件事。一是匹配点里混入外点,二是在退化场景下解基础矩阵,三是相对尺度没有锚定。退化场景包括两帧之间几乎只有纯旋转、所有 3D 点共面、相机中心距离过近等情况,此时三角化结果极不稳定。尺度问题则意味着恢复出来的 3D 坐标是相对尺度,没有真实米制单位,需要已知某个物理距离来恢复绝对尺度。
经验:单目两帧三角化得到的点云看起来很漂亮,但绝对尺度是错的。做展示可以,做测量必须引入标定尺、已知距离或深度传感器。
5. 避坑指南:我从项目里总结的常见问题与排查方法
写再多理论,最终都要落实到项目里不出错。下面这份避坑清单,是我反复踩过之后沉淀下来的,基本可以当作排查手册用。
5.1 坐标系手性和轴方向不一致
视觉惯用右手系,相机坐标 z 轴指向前方,x 轴向右,y 轴向下。但很多 3D 引擎、机器人平台把 z 轴定义成“向上”,y 轴向前。直接套用相机模型会导致投影点上下颠倒或前后翻转。
我的习惯是进入项目第一步,先打印一个已知位置的 3D 点,投影到图像上看看是否和标注一致;再用一个已知像素点做反向投影,看射线方向是否指向预期的 3D 位置。这个“双向对拍”能最快暴露坐标系问题。
5.2 像素坐标来源不同
同一个图像,在原始畸变图上提取角点,和在去畸变图上提取角点,得到的像素坐标是不同的。如果标定和投影不是同一套图源,结果会差不少。
建议在整个视觉管线中统一约定:所有像素坐标都来自“已去畸变图像”。如果有环节必须在原始图上处理,要先用畸变校正函数把点坐标变换到去畸变图像坐标系,再参与投影。
5.3 数值精度问题
反投影公式涉及矩阵逆运算和除法,用 float32 会在距离较远时产生明显误差。特别是相机光心到目标的距离达到几十米时,float32 的精度损失可能让投影点偏移几个像素。核心几何运算全部用 float64,只在最后传给渲染引擎或显示时才转回 float32。
另外,单位制一定要统一。K 矩阵里的焦距是像素单位,R、t 里的平移是米制单位,深度图的值可能是毫米、也可能是 16 位整数按某个比例缩放。混用单位是投影结果“看起来差不多但数值全错”的最常见原因。
5.4 常见症状速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 投影点前后颠倒 | 相机坐标 z 与场景坐标 z 约定不一致 | 统一右手/左手系,检查 z 轴方向 |
| 点云整体绕原点旋转 | 外参 R 的旋转约定不对 | 用已知 3D 点做投影对拍验证 |
| 近距离正确、远距离偏移大 | 未校正畸变,或 float32 精度不足 | 做去畸变,核心运算用 float64 |
| 点落在前后两个镜像位置 | 齐次坐标转换时符号丢失 | 检查除法和符号,确认 t 的向量形状 |
| 射线和平面无交点 | 射线方向与平面平行,或交点在相机后方 | 检查平面法线和射线方向点积,过滤 lam < 0 |
| 三角化结果尺度不对 | 单目绝对尺度缺失 | 引入已知距离或深度先验 |
这份避坑清单看起来零散,但每一项都来自真实调试现场。我个人的体会是,2D 投影到 3D 本身并不难,难的是把坐标系、畸变、尺度这些隐性问题一次性处理好。写代码之前,先在白纸上把“像素 -> 相机系 -> 世界系”的链路画一遍,再开始写函数,能省下大量调试时间。
最后再分享一个小技巧:我会把所有反投影逻辑封装成pixel_to_ray和ray_to_plane两个基础函数,业务代码里只负责选择约束条件,比如“今天用地面平面,明天用固定深度,后天做 raycast”。这样几何逻辑和业务逻辑彻底分离,出问题时只需要检查一个函数,排查效率高很多。希望这份经验能让你少走几趟弯路。