做机械臂视觉抓取的时候,第一件让人卡住的事情往往不是识别算法,而是一个看起来特别基础的问题:屏幕上某个像素点,在现实空间里到底处于哪个位置?尤其是用 RealSense D435i 的时候,彩色图里已经找到了物体的角点 (u, v),可机械臂要的是三维坐标,不是像素坐标。上一篇我们把 D435i 在 Ubuntu 下的驱动和 pyrealsense2 开发环境都跑通了,这篇就直接处理这个"最后一公里"——把任意一个二维像素点,转换成相机坐标系下的三维坐标 (X, Y, Z)。
为什么要单独写这一篇?因为"获取某二维像素点的三维坐标"这句话听起来简单,实际做起来牵扯到相机模型、内参、深度对齐、反投影函数等多个环节,任何一个地方出错,最后得到的坐标都会偏得离谱。我在实盘调试机械臂抓取的时候,就吃过没对齐深度图的亏,当时拿到的坐标在桌子下面十厘米,找了大半天才发现问题出在哪儿。这篇会把原理、代码、坑全部摊开讲,适合正在用 D435i 做视觉引导抓取、目标定位、或者想搞懂像素坐标和空间坐标关系的朋友。
1. 二维像素点不携带空间方位:为什么机械臂抓取一定要三维坐标
先想一个最直白的问题:你把手机拍的一张照片发给朋友,告诉他图片上第 320 列、第 240 行的那个点是"目标点",他能据此去现实世界里把那个点找出来吗?肯定不能。因为二维像素坐标只记录了光线打到了感光元件上的哪个位置,至于这个光线是从 0.5 米外、2 米外还是 5 米外射过来的,像素坐标本身完全不知道。这就是"二维像素点不携带空间方位"的本质。
所以要把像素点变成机械臂能用的坐标,必须额外引入一个信息:深度。D435i 刚好提供这个信息,它的两个红外相机通过三角测距,给每个像素一个深度值 Z。有了 (u, v, Z),理论上就能把二维点还原成三维点。这个还原过程叫做反投影(deproject),也就是从图像坐标系倒推回相机坐标系。
对机械臂抓取来说,这个转换是整条链路的第一块地基。后面要做的手眼标定、机械臂逆解、抓取姿态规划,全部建立在这一个三维点坐标之上。如果这一步算错了,后面全崩,而且崩得很难排查——因为视觉那边看起来一切正常,画面清晰、识别框正确,唯独机械臂抓歪了。我见过不止一个朋友花了两三天查运动学代码,最后发现是像素点转三维坐标那个环节的深度值取错了。
另外,标题里特意说了"某二维像素点",这意味着这个像素点可以是任何来源选出来的。最粗暴的方式是鼠标在画面上点击;稍微工程化一点,是拿 YOLO 检测出目标框后取框中心点;再高级一点,是拿图像分割掩码求质心。无论来源是什么,最终都会收敛成一个整数坐标 (u, v),然后走同一套转换流程。所以我这篇写的方案是通用的,你后面接什么目标检测算法都不影响。
2. 反向投影公式推导:已知(u,v)和Z,如何还原(X,Y,Z)
2.1 相机成像的针孔模型:三维点是怎么变成像素点的
要理解反投影,先得理解正投影。把相机简化成一个针孔模型,三维空间中的一个点 P(X, Y, Z),经过光心投影到成像平面上,形成一个像点。在不考虑畸变的情况下,这个过程的数学表达非常简洁:
u = fx * X / Z + cx
v = fy * Y / Z + cy
这里的 fx、fy 是焦距(以像素为单位),cx、cy 是主点坐标(图像中心附近的一个偏移量)。这四个参数合起来叫做相机内参,每台相机出厂时都不一样。D435i 的出厂标定数据,包括内参和畸变系数,是写进相机固件里的,我们用 SDK 直接读就能拿到。
从正投影公式能看出一个关键现象:同一个三维点,如果深度 Z 不同,投影到图像上的位置就不同;反过来,图像上同一个像素点,对应着不同深度下的无数个三维点,它们分布在一条从光心出发的射线上。所谓单目相机看不出深度,就是这个原因。深度相机本质上是帮你把这条射线上的"距离"测出来了,从而锁定唯一的三维坐标。
2.2 反投影公式:把等式倒过来
直接把正投影公式做个移项,就能得到反投影公式:
X = (u - cx) * Z / fx
Y = (v - cy) * Z / fy
Z = depth(从深度图直接读到的距离值)
这个公式的物理含义很好理解:先求像素点相对主点偏移了多少个像素,再乘以每个像素对应的实际空间尺寸(用 Z 除以焦距得到),就能换算成三维空间中的偏移量。注意单位问题——D435i 的深度值单位是米,所以算出来的 X、Y、Z 也都是米。如果你后面接机械臂,机械臂坐标系通常用毫米,记得乘 1000。
2.3 畸变:为什么尽量别自己手算完整的反投影
上面的反投影公式是理想针孔模型下的,真实相机还有镜头畸变。D435i 的彩色镜头畸变不算大,但在像素精度要求高的场景(比如抓取小零件),畸变误差不可忽略。畸变模型通常包含径向畸变和切向畸变,校正的时候要先对归一化坐标做畸变矫正,再套用针孔模型公式,过程比较繁琐。
所以我的建议是:公式一定要看懂,因为它决定了你对这个问题的理解深度;但是实际写代码时,能调用官方 SDK 的反投影函数,就不要自己从头手算。官方函数内部把内参、畸变全部处理好了,而且用的就是这台相机固件里的标定值,准确性和便利性都更好。下一节就对比两条实现路径。
3. rs2_deproject_pixel_to_point还是手写公式:两条实现路径怎么选
3.1 方案一:pyrealsense2 官方反投影函数
pyrealsense2 提供了一个专门的反投影函数rs2_deproject_pixel_to_point,签名大致是:
point = rs.rs2_deproject_pixel_to_point(intrinsics, [u, v], depth)传入三个参数:相机内参对象、像素坐标列表 [u, v]、深度值 depth,返回一个三维坐标 [x, y, z],单位是米。函数内部会自动做畸变校正。这是我最常用的方式,优点非常明显:不用自己关心畸变模型,内参直接从相机读取,代码量极小。
对应的还有投影函数rs2_project_point_to_pixel,可以把三维点投回二维像素。它最大的价值是拿来验证反投影结果——把反投影得到的三维点再投回像素,理论上应该得到原来的 (u, v),如果误差在一个像素以内,说明整条链路是通的。
3.2 方案二:OpenCV 手动计算
OpenCV 的方式稍微绕一点。可以先通过cv2.undistortPoints对像素坐标做畸变矫正,得到归一化坐标,然后再乘深度得到三维坐标。核心流程:
# 假设已经拿到内参矩阵 K 和畸变系数 dist points = np.array([[[u, v]]], dtype=np.float64) undistorted = cv2.undistortPoints(points, K, dist, P=K) # undistorted[0][0] 是矫正后的像素坐标 nx = (undistorted[0][0][0] - cx) / fx ny = (undistorted[0][0][1] - cy) / fy X = nx * depth Y = ny * depth Z = depth这套写法的好处是依赖 OpenCV,如果你本来就在用 OpenCV 做图像处理,顺手能用,不一定要引入 pyrealsense2 的坐标转换接口。但缺点也很明显:内参矩阵 K 和畸变系数 dist 你得自己从相机读取、自己维护;如果忘记做畸变矫正,或者拿错了内参,结果就偏了。
3.3 两条路径对比
| 对比维度 | pyrealsense2 官方函数 | OpenCV 手动计算 |
|---|---|---|
| 畸变处理 | 自动 | 需要手动调用 undistortPoints |
| 内参来源 | 直接从相机读取 | 自己读取并维护 K、dist |
| 代码量 | 一行 | 多行,需要自己组织流程 |
| 可移植性 | 依赖 pyrealsense2 | 依赖 OpenCV,更通用 |
| 适合场景 | D435i 配套项目、快速落地 | 已有 OpenCV 管线、学习原理 |
3.4 我的选型建议
我的习惯是:理解原理时用手写公式在笔记本上推导,项目代码里用官方rs2_deproject_pixel_to_point。原因很简单——官方函数减少了一个出错环节。机械臂抓取这种场景,稳定性和精度优先,没必要在坐标转换这种底层环节上重复造轮子。当你哪天真遇到一个奇怪 bug,怀疑是坐标转换的问题时,再用 OpenCV 手动流程做交叉验证,这样排查效率最高。
4. 完整代码与逐步走读:点击像素点,实时输出三维坐标
4.1 环境准备
我假设你已经装好了 pyrealsense2,Python 版本在 3.6 到 3.8 之间最稳妥。项目里还需要 numpy 和 opencv-python,用来处理图像显示和鼠标事件。如果你用的是 Jetson 这类 ARM 平台,pip 装不了 pyrealsense2,需要从源码编译,那个过程我会在后续文章里单独写。在 x86 的 Ubuntu 上,直接 pip 安装即可:
pip install pyrealsense2 numpy opencv-python4.2 完整可运行代码
import pyrealsense2 as rs import numpy as np import cv2 # 初始化 pipeline 和配置 pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) # 启动相机 profile = pipeline.start(config) # 等待几帧,确保传感器稳定 for _ in range(5): pipeline.wait_for_frames() # 获取彩色流的内参 color_profile = profile.get_stream(rs.stream.color) color_intrin = color_profile.as_video_stream_profile().get_intrinsics() print("内参 fx={:.3f} fy={:.3f} cx={:.3f} cy={:.3f}".format( color_intrin.fx, color_intrin.fy, color_intrin.ppx, color_intrin.ppy)) # 构建对齐对象:把深度图对齐到彩色图视角 align = rs.align(rs.stream.color) click_point = None def on_mouse(event, x, y, flags, param): global click_point if event == cv2.EVENT_LBUTTONDOWN: click_point = (x, y) cv2.namedWindow("color") cv2.setMouseCallback("color", on_mouse) try: while True: frames = pipeline.wait_for_frames() aligned_frames = align.process(frames) color_frame = aligned_frames.get_color_frame() depth_frame = aligned_frames.get_depth_frame() if not color_frame or not depth_frame: continue color_image = np.asanyarray(color_frame.get_data()) depth_image = np.asanyarray(depth_frame.get_data()) if click_point is not None: u, v = click_point # 注意参数顺序:(u, v),不是 (v, u) depth = depth_frame.get_distance(u, v) if depth > 0: # 反投影:像素坐标 + 深度 -> 相机坐标系下的三维点 camera_point = rs.rs2_deproject_pixel_to_point( color_intrin, [u, v], depth) # 闭环验证:把三维点重新投影回像素坐标 back_pixel = rs.rs2_project_point_to_pixel( color_intrin, camera_point) back_u, back_v = int(round(back_pixel[0])), int(round(back_pixel[1])) # 在画面上标注结果 cv2.drawMarker(color_image, (u, v), (0, 255, 0), cv2.MARKER_CROSS, 12, 2) cv2.putText(color_image, "pixel ({}, {}) -> back ({}, {})".format( u, v, back_u, back_v), (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.putText(color_image, "X={:.3f} Y={:.3f} Z={:.3f} m".format( camera_point[0], camera_point[1], camera_point[2]), (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) else: cv2.putText(color_image, "depth = 0, 换个点试试", (10, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imshow("color", color_image) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break finally: pipeline.stop() cv2.destroyAllWindows()4.3 关键代码逐段解读
第一段是 pipeline 初始化和配置。我开了 640x480 的彩色流和深度流,分辨率一致对齐时最省事。config.enable_stream里指定了两种流的格式和帧率,彩色流用 bgr8 是 OpenCV 默认的显示格式,深度流用 z16 是 16 位无符号整数,每个像素值就是深度值,单位毫米(注意,z16 原始像素值的单位是毫米,但get_distance返回的是米)。
获取内参的部分值得多说一句。profile.get_stream(rs.stream.color)拿到彩色流配置信息,然后转成video_stream_profile,再get_intrinsics()取内参。打印出来的 fx、fy、cx、cy 就是第二节公式里那四个参数,每台相机都不一样,你要确认读取到的确实是当前这台相机的值。常见错误是想当然地用一个"D435i 通用内参"写死在代码里,这种行为一旦换台相机,坐标直接漂移。
鼠标回调用了 OpenCV 的setMouseCallback,左键按下时记录像素坐标。注意回调函数的 x、y 参数就是图像上的像素坐标,对应公式里的 u(列)和 v(行),不要和 OpenCV 惯用的二维数组索引 (row, col) 搞混了。我的实际经验是,这个顺序问题是最容易犯的错之一——depth_frame.get_distance(u, v)第一个参数是横坐标 u,第二个是纵坐标 v,和图像数组depth_image[v, u]正好反过来。
4.4 闭环验证法:反投影之后再做一次正投影
代码里我特意加了一步"闭环验证":用rs2_project_point_to_pixel把反投影得到的三维点重新投回二维。理想情况下,back_pixel 应该和原始的 (u, v) 重合,误差在一个像素以内。这个验证手段在调试时极其好用,因为如果你不小心把内参用错了,或者对齐步骤出问题,反投影后的三维点再投回来就会明显偏移,一跑就能发现。
我第一次跑通这个闭环时,屏幕上显示的 back 坐标和原坐标完全一致,当时心里就踏实了,知道坐标转换这条链路是通的。后续再接机械臂手眼标定的时候,如果抓不准,排查范围就可以缩小到外参标定,而不是怀疑基础转换。
5. 彩色流与深度流对齐:跳过这步,取到的深度和坐标全错
5.1 为什么深度图和彩色图天然对不上
D435i 的深度是由左右两个红外摄像头计算出来的,彩色则来自独立的 RGB 摄像头。这三个摄像头在硬件上位于不同的物理位置,看到同一个物体的视角略有差异。这就导致同一个空间点,在深度图上的像素坐标和在彩色图上的像素坐标大概率不一样。换句话说,彩色图 (320, 240) 这个位置的物体,在原始深度图里可能出现在 (310, 240),也可能在 (335, 238),偏差大小取决于物体的距离和场景结构。
如果你拿到深度图后,不经过任何处理,直接get_distance(u, v)去取彩色图上某个点的深度,取到的其实是深度图上那个位置的深度,而这个位置对应的物体很可能不是你想测的那个物体。这就是"没对齐导致的坐标错误",它不会报错,只是结果悄悄偏掉,属于最讨厌的一类 bug。
5.2 对齐到底做了什么
对齐操作本质上是把深度图重新投影到彩色相机的视角下。具体来说,深度像素 z16 的每个像素值是一个深度距离,结合深度相机内参可以反投影成三维点,再把这些三维点投影到彩色相机坐标系下,就得到了一张"以彩色图为参考视角"的深度图。对齐完成后,深度图和彩色图的像素一一对应:你在彩色图上点到的 (u, v),直接去对齐后的深度图上get_distance(u, v),取到的就是该物体表面的真实深度。
代码实现很简单,就是那句:
align = rs.align(rs.stream.color) aligned_frames = align.process(frames)rs.align的参数rs.stream.color表示"要对齐到哪个流",这里我选择把深度对齐到彩色。对齐之后,aligned_frames.get_depth_frame()拿到的深度帧,才是能和彩色帧配套使用的数据。
5.3 对齐后内参怎么取:一个容易忽略的细节
很多教程会告诉你直接从profile.get_stream(rs.stream.color)拿彩色内参,这没问题,因为最终反投影用的就是彩色内参。但有一个细节要留意:对齐后的深度图的内参其实已经变成了彩色相机的内参(因为深度图被重投影到了彩色坐标系)。如果你后面要做自定义的深度图处理,需要获取对齐后深度帧的 profile 再取内参:
aligned_depth_profile = aligned_depth_frame.profile depth_intrin = aligned_depth_profile.as_video_stream_profile().get_intrinsics()这个内参和color_intrin数值上基本一致,但标准做法还是从帧上动态获取,不要把内参硬编码。我记得有一次自己图省事直接用了最开始取的 color_intrin,后来换了一台相机,内参变了,代码还在用旧的,调试时浪费了不少时间。从那以后我养成了一个习惯:所有内参都从当前帧或当前 profile 动态读取,不写死。
6. 实测绕坑笔记:深度0、边缘跳变、USB带宽与一个方向搞反
6.1 深度值返回 0:不一定是相机坏了
跑上面代码的时候,你大概率会遇到某些点返回的深度是 0。这不是代码问题,更不是相机坏了,而是这个点的深度"测不出来"。常见原因有这么几类:目标太近,进入了 D435i 的最小测量距离(大概 0.1 米左右);目标表面是强反光或纯黑的材料,红外光打上去反射信号太弱;物体是透明的,比如玻璃杯,红外光直接穿过去了;还有一个原因是物体刚好落在深度图的空洞区域。
处理办法要看场景。如果只是鼠标点着玩,换个点就行;如果是工程项目,通常用邻域滤波来补救,比如取 (u, v) 周围 3x3 或 5x5 窗口里非零深度的中值作为该点深度,或者用空间上的深度补洞算法。我的经验是:对于机械臂抓取,宁可不测也不要给一个错误深度,深度为 0 时触发重新检测,比硬凑一个值安全得多。
6.2 物体边缘的深度跳变
物体边缘的深度往往不可靠。因为深度是靠左右红外相机视差计算的,在物体边缘处,左右相机看到的遮挡关系不一样,很容易把背景的深度算到物体边界上,或者出现一个突然的跳变。所以选点的时候要尽量选在物体表面靠中间的位置,别拿鼠标精准点击边缘上那一个像素然后直接拿去抓取。如果是视觉引导抓取,通常的做法是取目标检测框中心,或者取分割掩码质心,天然避开了边缘问题。
6.3 USB 带宽导致帧率莫名下降、时间戳跳变
D435i 同时开彩色流和深度流,数据量不小,官方推荐 USB 3.0 接口。如果插在 USB 2.0 上,要么帧率上不去,要么时不时卡顿,甚至出现彩色图和深度图时间戳对不上的情况。排查这类问题有个最简单的办法:运行rs-enumerate-devices,看一下相机的连接速度和当前配置的带宽占用。我在调试现场遇到过一台工控机,前面板的 USB 口全是 2.0,后面板才有 3.0,第一次插前面板折腾了半天,换到后面板瞬间正常。
6.4 参数顺序搞反:最隐蔽的低级错误
get_distance(u, v)这个接口,第一个参数是列坐标 u,第二个是行坐标 v。因为日常写 OpenCV 代码时大家习惯用image[v, u]的 (row, col) 顺序,所以这里特别容易一个不留神就写反。写反的后果是:取的深度点偏移了几十个像素,坐标计算结果就会偏。这种 bug 不会让程序报错,只会让结果莫名其妙地偏。我的建议是,在代码里显式注释"u=列,v=行",或者把变量名直接命名为col, row,从源头杜绝。
6.5 单位换算:米和毫米的分歧点
rs2_deproject_pixel_to_point返回的三维坐标单位是米,z16 深度图的原始像素值单位是毫米,而机械臂运动学里常用的单位往往是毫米。三者混在一起用,特别容易出问题。我见过有人拿到相机坐标后直接传给机械臂,结果机械臂跑到目标点十倍远的地方。稳妥做法是,在坐标转换函数里用一个明确的单位常量,比如SCALE_TO_MM = 1000.0,并且所有对外接口都统一成毫米,内部代码里做好注释,避免单位"刺客"。
6.6 给新手的排查路径参考
如果你按照上面的代码跑完,发现坐标不对,我建议按这个顺序排查:先确认内参是不是从当前相机动态读取的;再确认深度图有没有经过 align;然后把鼠标点击的目标放在一个平坦表面上,比如桌面,看看反投影出来的三维点在 Z 方向上是否符合直觉;最后做一次闭环投影,看 back_pixel 和原像素是否一致。大多数坐标异常问题,都能在这四步里找到答案。
我在实际项目中还有一个习惯:把反投影出来的三维点打印到终端的同时,用 RealSense Viewer 手动测一下同一位置的深度值,两个数据交叉验证。这个习惯帮我至少避开了三次"代码看似没问题但实际数据不对"的陷阱。视觉这块,调试时多留一条验证路径,永远不亏。