1. 坐标转换的整体设计:像素坐标为什么会变成三维坐标
做机器人抓取、三维重建或者AR应用时,几乎都会碰到同一个问题:相机图像上某个点的像素坐标是(u, v),它对应的那个物体在真实空间里到底在哪个位置?D435i深度相机给了一个很直接的答案——每个像素都带深度值,有了深度值,像素坐标就能换算成三维坐标。但这个换算过程背后不是简单乘个系数,而是一条完整的几何转换链。
1.1 四个坐标系,一条转换链
要从像素坐标得到三维空间坐标,先要搞清楚图像上的一个点经历了哪些“坐标系变换”。整条链路涉及四个坐标系:
- 像素坐标系:以图像左上角为原点,单位是像素,就是我们平时说的(u, v)。
- 图像坐标系:以光轴与成像平面的交点为原点,单位是毫米,坐标轴与像素坐标系平行。
- 相机坐标系:以相机光心为原点,Z轴指向相机正前方,X轴向右,Y轴向下,单位是毫米或米。三维空间坐标就是在这个坐标系下表示的。
- 世界坐标系:你自己定义的一个基准坐标系,机械臂场景里通常以机器人基座为原点。
像素坐标转三维坐标,实际操作上就是把像素坐标一步步还原到相机坐标系里。先由像素坐标(u, v)得到图像坐标,再由图像坐标结合深度值得到相机坐标系下的三维坐标。如果还要放到机械臂或者导航地图里,就再加一步外参变换,把相机坐标转到世界坐标。
这条链路里最关键的一个认知是:像素坐标到三维坐标不是“查表”,而是“反投影”。相机成像过程是把三维物体投影到二维图像平面上,这个过程天然丢失了深度信息。深度相机的作用就是把丢失的深度信息补回来,有了深度,反投影就能完整还原出三维坐标。
1.2 内外参:相机的“视力”和“姿势”
整个转换过程依赖两组参数:内参和外参。
内参是相机本身的属性,包括焦距(fx, fy)、主点坐标(cx, cy)和畸变系数。它描述的是“三维空间中的点是如何被投射到像素平面上的”。同一个相机,内参是固定的,不受安装位置影响。D435i出厂时已经标定好内参,存在相机固件里,调用SDK可以直接读出来。
外参描述的是相机坐标系相对于某个参考坐标系的旋转和平移,也就是相机在空间中的“姿势”。相机装在机械臂末端,外参就是相机坐标系到机械臂末端坐标系的变换矩阵;相机固定在天花板上,外参就是相机坐标系到机器人基座坐标系的变换矩阵。外参不是固定的,每次重新安装相机都要重新标定。
如果把内参理解成“这双眼睛近视多少度、有没有散光”,那外参就是“这双眼睛长在脑袋的什么位置、脑袋朝向哪里”。两者缺一不可。
1.3 深度值在这个链条里的作用
没有深度值的普通RGB相机,像素坐标(u, v)只能给出一条射线,物体可能出现在射线上的任意位置,距离未知。D435i用主动立体视觉方案,通过红外投影仪投射不可见的红外纹理,再用左右两个红外相机拍摄,通过视差计算每个像素的深度。输出的深度图里,每个像素的值就是该点到相机平面的距离,单位通常是毫米。
这里有个容易误解的点:深度值代表的是“点到相机平面”的距离,不是“点到相机光心”的直线距离。以D435i为例,深度值z对应的是相机坐标系中该点的Z轴分量。所以计算三维坐标时,直接用z作为Z方向分量,而不是把它当作欧氏距离再分解。这一点在做坐标转换时特别容易踩坑,后面代码部分会专门演示。
有了深度值z,像素坐标(u, v)就能通过内参反投影公式还原成相机坐标系下的三维坐标(x, y, z)。这是整个转换的核心公式,下一节详细拆解。
2. 核心细节解析:内参、对齐与畸变
2.1 内参矩阵到底怎么读
D435i的内参通常用3x3矩阵表示:
K = [fx, 0, cx, 0, fy, cy, 0, 0, 1]其中fx和fy是焦距,单位是像素。注意,这里的焦距不是物理焦距(毫米),而是物理焦距除以像元尺寸后得到的“像素焦距”。cx和cy是主点坐标,理论上应该位于图像中心,但由于制造装配误差,实际位置会偏移几个像素。
读取D435i内参的代码非常简单:
import pyrealsense2 as rs pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) profile = pipeline.start(config) color_profile = profile.get_stream(rs.stream.color) intr = color_profile.as_video_stream_profile().get_intrinsics() print(intr.fx, intr.fy, intr.ppx, intr.ppy)打印出来的fx、fy、ppx、ppy就是内参矩阵里的fx、fy和cx、cy。D435i的640x480彩色流,典型值大概是fx≈615,fy≈615,cx≈320,fy≈240,但每一台相机都有细微差别,必须以实际读取为准。
2.2 深度图与彩色图对齐:先统一坐标系
D435i有两个成像传感器:彩色摄像头和红外深度传感器。它们的物理位置不同,视野范围也不同,所以同一时刻彩色图和深度图里的像素并不一一对应。如果直接拿彩色图像上的像素坐标去查深度图,得到的深度值可能是错的,偏差在边缘区域尤其明显。
解决办法是做“深度对齐”(align),把深度图映射到彩色图的坐标系下,让彩色图的每个像素都有一个对应的深度值。实现方式:
align = rs.align(rs.stream.color) frames = pipeline.wait_for_frames() aligned_frames = align.process(frames) aligned_depth_frame = aligned_frames.get_depth_frame() color_frame = aligned_frames.get_color_frame()对齐后,彩色图上的像素(u, v)对应的深度值,可以直接从aligned_depth_frame里取。这一步看似简单,但很多初学者会跳过,结果做出来的三维坐标误差很大,还以为是标定问题。实际上一大半情况是对齐没做对。
2.3 畸变模型与去畸变
任何镜头都存在畸变,D435i的彩色镜头是广角镜头,边缘畸变更明显。畸变分为径向畸变和切向畸变:
- 径向畸变(k1, k2, k3):光线经过透镜时弯曲程度不一致,导致直线变弯。桶形畸变是典型的径向畸变。
- 切向畸变(p1, p2):透镜与成像平面不平行导致的偏移。
D435i的出厂内参里包含畸变系数,SDK读取内参时会一并读出。如果直接使用OpenCV的cv2.undistort做去畸变,可以这样:
import numpy as np import cv2 K = np.array([[intr.fx, 0, intr.ppx], [0, intr.fy, intr.ppy], [0, 0, 1]]) dist = np.array(intr.coeffs) undistorted = cv2.undistort(color_image, K, dist)但要注意一点,在D435i的SDK流程里,深度对齐操作内部已经做了畸变校正。也就是说,从aligned_frames拿到的彩色图和深度图,已经对应到同一个畸变校正后的坐标系,不需要额外再调一次undistort。只有当你直接取raw帧自己处理时才需要显式去畸变。这里曾经有朋友问为什么对齐后还要undistort,结果两重操作叠加,图像边缘反而出现了重影,问题就出在这个流程上。
2.4 从像素到三维空间:完整公式拆解
这一节是整个博文的核心,把公式一步步拆开讲透。
假设深度图与彩色图已对齐,深度图上某个像素坐标是(u, v),对应的深度值是depth(单位:毫米)。相机内参为fx, fy, cx, cy。那么该点在相机坐标系下的三维坐标计算如下:
z = depth / 1000.0 # 转成米 x = (u - cx) * z / fx y = (v - cy) * z / fy为什么要减cx和cy?因为像素坐标的原点在图像左上角,而相机坐标系的原点在光轴与图像平面的交点。减去主点坐标,就把像素坐标转换成了以光轴投影点为原点的图像坐标。除以fx相当于把像素单位的横向距离换算成归一化平面坐标,再乘以深度z,就得到了相机坐标系下的真实横向偏移。
举个实际例子。假设内参fx=615.0,fy=615.0,cx=320.0,cy=240.0。像素坐标是(400, 300),深度值是1200mm。计算过程:
z = 1.2 米 x = (400 - 320) * 1.2 / 615 = 80 * 1.2 / 615 ≈ 0.1561 米 y = (300 - 240) * 1.2 / 615 = 60 * 1.2 / 615 ≈ 0.1171 米所以该点在相机坐标系下的坐标是(0.1561, 0.1171, 1.2)。这个结果意味着该点位于相机右方约15.6厘米、下方约11.7厘米、正前方1.2米处。把物体放在这个位置用公式算一遍,你会发现结果完全吻合,这就是反投影的基本几何原理。
一个常见的疑问是:为什么y方向是正的?因为在相机坐标系里,Y轴指向下方,所以物体在图像中心下方时,y坐标为正。很多第一次接触的人会习惯性地以为y应该向上为正,实际上相机坐标系和常规的“右手系”在纸上画出来方向不同,但D435i遵循的就是“X向右、Y向下、Z向前”这个约定。这个约定在机械臂场景里尤其重要,后面做坐标变换时如果符号搞反,抓取位置会偏得离谱。
3. 实操实现:用Python把像素坐标变成三维坐标
3.1 环境准备与依赖
先交代环境。我用的Python版本是3.8,操作系统是Ubuntu 20.04。需要安装三个核心库:
pip install pyrealsense2 opencv-python numpypyrealsense2是Intel官方SDK的Python封装,负责读取相机数据。opencv-python用于图像处理。numpy负责向量化计算。如果你用的是Windows,安装方式一样,SDK会自动匹配对应平台的驱动。
3.2 单点像素坐标转三维坐标
需求场景:用鼠标在彩色图像上点一个点,实时显示这个点的三维坐标。这个功能在做目标检测后处理时很常用——检测框中心点转到三维坐标,供机械臂抓取。
完整代码如下:
import pyrealsense2 as rs import numpy as np import cv2 pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) align = rs.align(rs.stream.color) pipeline.start(config) try: while True: frames = pipeline.wait_for_frames() aligned_frames = align.process(frames) aligned_depth = aligned_frames.get_depth_frame() color_frame = aligned_frames.get_color_frame() if not aligned_depth or not color_frame: continue depth_image = np.asanyarray(aligned_depth.get_data()) color_image = np.asanyarray(color_frame.get_data()) # 定义要转换的像素坐标(这里以图像中心点为例) u, v = 320, 240 depth_value = depth_image[v, u] # 注意:索引顺序是v在前(行),u在后(列) if depth_value == 0: print("该点无有效深度值") continue # 读取内参 intr = color_frame.profile.as_video_stream_profile().get_intrinsics() fx, fy = intr.fx, intr.fy cx, cy = intr.ppx, intr.ppy # 反投影公式 z = depth_value / 1000.0 x = (u - cx) * z / fx y = (v - cy) * z / fy print(f"像素坐标: ({u}, {v}), 深度: {depth_value}mm") print(f"三维坐标: ({x:.3f}, {y:.3f}, {z:.3f}) 米") cv2.circle(color_image, (u, v), 5, (0, 0, 255), -1) cv2.imshow("Color", color_image) if cv2.waitKey(1) & 0xFF == ord('q'): break finally: pipeline.stop()这段代码有几个容易踩坑的地方,我逐一说明。
第一个是数组索引顺序。depth_image是numpy数组,形状是(H, W)即(height, width),所以访问第v行第u列的元素,要写depth_image[v, u],而不是depth_image[u, v]。写反了也不会报错,但取到的深度值是另一个点的,坐标就全错了。这个问题排查起来很隐蔽,因为程序能正常跑,只是数值不对。
第二个是对齐的作用。加了rs.align(rs.stream.color)之后,深度图和彩色图的坐标系已经对齐,彩色图上的(u, v)点才能直接去深度图里查深度。没有对齐直接查,边缘位置的深度值会偏差几十毫米甚至更多。
第三个是深度值为0的情况。深度0通常表示该点无法测量,可能是因为物体太近、反光太强或者处于视野边缘。做工程时一定要做这个判断,否则计算出的坐标可能是一个巨大的异常值。
3.3 整幅图生成点云:向量化计算
单点转换适用于目标检测场景。但如果要做三维重建或者点云处理,需要一次性把整张深度图转换成三维坐标。用for循环逐像素计算会非常慢,正确做法是用numpy向量化。
import pyrealsense2 as rs import numpy as np pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) pipeline.start(config) frames = pipeline.wait_for_frames() depth_frame = frames.get_depth_frame() depth_image = np.asanyarray(depth_frame.get_data()) height, width = depth_image.shape intr = depth_frame.profile.as_video_stream_profile().get_intrinsics() fx, fy = intr.fx, intr.fy cx, cy = intr.ppx, intr.ppy # 生成像素坐标网格 u_map, v_map = np.meshgrid(np.arange(width), np.arange(height)) # 向量化反投影 z_map = depth_image.astype(np.float32) / 1000.0 x_map = (u_map - cx) * z_map / fx y_map = (v_map - cy) * z_map / fy points = np.stack((x_map, y_map, z_map), axis=-1) # 形状: (H, W, 3)numpy的meshgrid生成所有像素坐标的网格,然后直接用矩阵运算得到每个像素对应的三维坐标。640x480的图像有30万个点,向量化计算耗时在毫秒级,而for循环可能需要几十秒。这在实际项目中是必须做的优化。
生成的points数组可以直接喂给Open3D做可视化或者点云处理。如果你用rs.save_to_ply函数,SDK也有内置的点云导出能力,但自己做的好处是能拿到原始三维坐标数据,方便后续跟机械臂、导航模块对接。
3.4 验证转换准确性:把三维坐标投影回像素
写完了转换,怎么确认算得对不对?最直观的验证方法是做一次“往返测试”:先把像素坐标转成三维坐标,再用相机投影公式把三维坐标投影回像素坐标,看是否回到原点。
x, y, z = 0.1561, 0.1171, 1.2 # 上一节计算得到的三维坐标 u_reproj = int(x * fx / z + cx) v_reproj = int(y * fy / z + cy) print(u_reproj, v_reproj) # 应该输出接近(400, 300)的结果如果代码逻辑正确,投影回来的像素坐标与原始像素坐标误差应该在一个像素以内。这个测试的好处是不需要任何外部设备,一把尺子都不用,纯数学检验逻辑是否正确。
更贴近实际场景的验证方法是放一个已知尺寸的物体。比如一个边长为10厘米的方块,放在相机正前方1米处,检测方块边缘的像素坐标,转换成三维坐标后测量边长,看是否接近10厘米。这个验证能同时检验深度精度和内参是否正确,是对整个坐标转换链路的一次端到端测试。
4. 进阶实战:把三维坐标放进机器人坐标系
4.1 手眼标定:eye-in-hand还是eye-to-hand
像素坐标到三维坐标解决的是“物体在相机坐标系下的位置”,但机械臂需要的是“物体在机器人坐标系下的位置”。从相机坐标到机器人坐标,需要一次刚体变换,由旋转矩阵R和平移向量t组成。求解R和t的过程就是手眼标定。
根据相机安装方式不同,手眼标定分为两种:
- eye-in-hand:相机装在机械臂末端,跟着机械臂一起动。标定目标是求相机坐标系到机械臂末端坐标系的变换矩阵。
- eye-to-hand:相机固定在外部支架上,机械臂在相机视野内运动。标定目标是求相机坐标系到机械臂基座坐标系的变换矩阵。
两种方式的标定原理相同,都是通过机械臂带着标定板运动,记录多个位置的机械臂位姿和标定板在相机坐标系下的位姿,联立方程组求解AX=XB。OpenCV提供了cv2.calibrateHandEye函数,输入机械臂末端相对于基座的位姿序列和标定板相对于相机的位姿序列,输出相机到末端的变换矩阵。
4.2 从像素坐标到机械臂抓取坐标的完整链路
假设完成了eye-in-hand标定,得到了相机坐标系到机械臂末端坐标系的变换矩阵T_cam_to_end,再结合机械臂的正运动学得到末端到基座的变换矩阵T_end_to_base,那么相机坐标系下的点P_cam转换到基座坐标系下的P_base:
P_end = T_cam_to_end * P_cam P_base = T_end_to_base * P_end用齐次坐标表示就是:
import numpy as np # T_cam_to_end: 4x4齐次变换矩阵,来自手眼标定 # T_end_to_base: 4x4齐次变换矩阵,来自机械臂正运动学 def pixel_to_base(u, v, depth_value, intr, T_cam_to_end, T_end_to_base): z = depth_value / 1000.0 x = (u - intr.ppx) * z / intr.fx y = (v - intr.ppy) * z / intr.fy P_cam = np.array([x, y, z, 1.0]) P_end = T_cam_to_end @ P_cam P_base = T_end_to_base @ P_end return P_base[:3]这里有一个工程上的重要经验:不要忽略齐次坐标的w分量。很多人在做矩阵变换时只取前三个分量,忘记把第四维设为1,结果平移量完全算错。原因是旋转变换只影响方向,平移变换依赖齐次坐标的第四维才能正确叠加。这个错误非常隐蔽,因为代码跑起来不报错,但机械臂抓取位置永远偏一个常数。
在机械臂实战中,我建议把整个坐标转换链路封装成一个独立的模块,输入是像素坐标和深度值,输出是基座坐标系下的三维坐标。这样上层逻辑只需要关心目标位置,不需要关心传感器细节。后续如果要换相机或者调整安装位置,只需要改模块内部的标定参数,上层代码一行都不用动。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
实际操作中会遇到不少问题,下面这个表格是我自己踩过坑和帮朋友排查过的问题汇总,按频率从高到低排列。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 三维坐标整体偏移,比如目标明明在相机正前方,算出来x方向偏了10厘米 | 忘记做深度对齐,或者对齐后仍在使用未对齐的深度图 | 使用rs.align(rs.stream.color),并从aligned_depth_frame取深度值 |
| 坐标值跳变剧烈,同一位置相邻两帧计算结果相差很大 | 深度值本身有噪声,尤其是边缘和反光区域 | 对深度值做时间滤波(取5帧中位数),或者对坐标结果做低通滤波 |
| 目标物体在图像上可见,但深度值为0 | 物体太近(低于最小深度范围约0.2米)、表面反光强、或被遮挡 | 调整相机角度,避免强反光;确认物体在0.28米到3米范围内 |
| 图像中心点的三维坐标不是(0, 0, z),而是有偏移 | 主点坐标cx, cy不是精确的图像中心,这是正常的 | 不要手工假设主点在中心,必须读取内参中的实际值 |
| 深度图边缘有黑色空洞 | 左右红外相机在物体边缘存在遮挡盲区 | 如果目标物体在边缘区域,移动相机让目标靠近视野中心 |
| 使用OpenCV去畸变后图像边缘变形 | 对齐流程已经做了畸变校正,重复去畸变导致二次失真 | 在alignment流程下不要额外调用undistort |
5.2 几个排查技巧
第一个技巧:打印内参。拿到一台新的D435i,第一件事就是打印fx、fy、cx、cy和畸变系数。我遇到过一台设备的cx比标称值偏了4个像素,这种个体差异不影响SDK内部转换,但如果你手工硬编码内参做转换,误差就会直接体现到三维坐标上。
第二个技巧:可视化深度误差。写一个脚本,在深度图上用伪彩色显示深度值,然后用一个已知距离的物体(比如把标定板放在1米处)核对深度值是否准确。D435i的深度误差通常在1%以内,如果偏差超过3%,需要检查是否使用了错误的深度流或对齐方式。
第三个技巧:利用rs-enumerate-devices命令查看相机出厂标定信息。在终端执行rs-enumerate-devices,可以看到相机的硬件信息、各条数据流的内参,以及固件版本。如果怀疑相机标定数据异常,先来这里核对。
第四个技巧:对深度值做中值滤波而不是均值滤波。深度图里的噪点通常是离群的极值(比如0值或非常大的值),均值滤波会被这些极值拉偏,而中值滤波能有效剔除离群点。实现上可以用OpenCV的cv2.medianBlur,核大小选3或5就够。核太大反而会抹掉物体边缘细节,影响坐标精度。
第五个技巧:在开发阶段把三维坐标以点云形式可视化出来。用Open3D载入points数组,如果你的转换公式正确,点云应该呈现出清晰的物体轮廓,地面是一个平坦的平面。如果点云看起来“扭曲”或者“整体倾斜”,通常是内参读错或者对齐没做对,这个视觉反馈比任何数值调试都直观。
写在最后
D435i的像素坐标到三维坐标转换,初学者觉得复杂,是因为中间隔着内参、对齐、畸变、外参好几层概念;真正啃下来之后,你会发现本质就是一条几何变换链。我在实际项目中最大的体会是,这类传感器融合的问题,80%的坑出在坐标系约定上——谁的方向是正、谁的单位是毫米谁是米、哪个索引在前哪个在后。只要把这些约定搞清楚、写进代码注释里,整个工程都会顺畅很多。
如果你接下来要做机械臂抓取,建议先别急着上手抓,把坐标转换模块单独写好,用标定板在不同位置验证几组坐标,误差控制在厘米级后再往下走。坐标转换是上层所有应用的地基,这一层稳了,后面的事都是时间问题。