ARTICLE DETAIL

资讯详情

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

RealSense D435i深度相机:从像素到机械臂抓取的三维坐标转换实战

RealSense D435i深度相机:从像素到机械臂抓取的三维坐标转换实战 深度相机玩过不少从早期的结构光方案到现在的主动双目RealSense D435i算是性价比和易用性平衡得相当好的一款。但很多刚上手的朋友会卡在同一个地方拿到了深度图也知道某个像素的深度值可怎么把它变成机械臂能用的三维坐标这中间差的那一步其实就是从像素坐标系到相机坐标系再到世界坐标系的完整映射链路。这篇内容就是把这套链路拆开揉碎用Python一步步实现让你看完就能在自己的项目里跑通。1. 先搞清楚D435i到底给了你什么数据1.1 三个数据流的分工与坑点D435i插上电脑后SDK默认会给你三样东西深度流、彩色流、IMU流。很多人一上来就同时开三个流结果发现帧率掉得厉害或者深度图和彩色图对不齐。这里面的门道在于深度流和彩色流来自两个不同的物理传感器它们之间有固定的外参关系但如果你不主动调用对齐操作拿到的就是各自独立视角的画面。深度流的分辨率常见有640x480、848x480、1280x720几种。分辨率越高视场角内的细节越丰富但深度计算的噪声也会相应增大。我实测下来640x480在1米以内的近距离场景下最稳848x480适合中等距离的抓取任务1280x720除非你对细节有极致要求否则不太推荐因为帧率会降到15fps左右对动态场景不友好。彩色流的分辨率选择更多但要注意一个细节D435i的彩色传感器是卷帘快门不是全局快门。这意味着如果目标物体在快速运动彩色图会有果冻效应而深度图用的是主动红外双目不受这个影响。所以在做运动物体跟踪时深度图的时间戳对齐比彩色图更可靠。IMU流是D435i区别于D435的关键升级它包含加速度计和陀螺仪。这个数据在纯视觉抓取任务里用得不多但如果你要做手持扫描或者移动机器人上的建图IMU和视觉的融合就是必须的。不过IMU和深度图之间的时间同步需要额外处理SDK提供的回调时间戳只能精确到毫秒级更高精度的同步得自己写硬件触发。1.2 深度值的物理含义与无效值处理深度图里每个像素的值是uint16类型单位是毫米。但这里有个大坑值为0的像素不代表距离为0而是表示该点深度计算失败。失败的原因有很多种比如物体表面反光太强、距离超出量程、纹理太弱导致双目匹配失败等。我在做桌面抓取时遇到过一种情况黑色哑光物体在深度图里全是0。一开始以为是相机坏了后来才发现是主动红外投射到黑色表面后被吸收了双目匹配找不到特征点。解决办法是在场景里加一些辅助纹理或者换用结构光方案的相机。D435i的量程标称是0.3米到3米但实际有效范围受环境光影响很大。室内日光灯下1.5米以外的深度噪声明显增大室外阳光下红外投射被淹没基本只能靠被动双目量程会缩到1米以内。处理无效值的策略取决于你的应用。如果是做点云可视化直接过滤掉0值就行如果是做抓取规划需要对无效区域做插值或者标记为不可抓取区域。我通常会在Python里先把深度图转成float32然后把0值替换成NaN这样后续的numpy运算会自动忽略这些点。import numpy as np import pyrealsense2 as rs 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) profile pipeline.start(config) depth_sensor profile.get_device().first_depth_sensor() depth_scale depth_sensor.get_depth_scale() print(f深度比例因子: {depth_scale}) align_to rs.stream.color align rs.align(align_to) try: while True: frames pipeline.wait_for_frames() aligned_frames align.process(frames) depth_frame aligned_frames.get_depth_frame() color_frame aligned_frames.get_color_frame() if not depth_frame or not color_frame: continue depth_image np.asanyarray(depth_frame.get_data()) color_image np.asanyarray(color_frame.get_data()) depth_float depth_image.astype(np.float32) * depth_scale depth_float[depth_image 0] np.nan print(f有效深度范围: {np.nanmin(depth_float):.3f}m - {np.nanmax(depth_float):.3f}m) finally: pipeline.stop()这段代码里有个关键操作align.process(frames)。它把深度图重投影到彩色图的视角下这样两个图像的像素就是一一对应的。不做这一步的话你拿到的深度图像素坐标和彩色图像素坐标是两套独立的坐标系后续做颜色映射或者目标检测时会完全对不上。2. 内参矩阵像素到相机坐标系的桥梁2.1 内参的获取与验证D435i出厂时已经做了标定内参直接存在相机里通过SDK可以读出来。但出厂标定是在特定温度下做的实际使用中如果环境温度变化大内参会漂移。我遇到过夏天在没空调的车间里深度图边缘出现明显畸变的情况重新标定后就好了。获取内参的代码很简单intrinsics depth_frame.profile.as_video_stream_profile().intrinsics print(ffx: {intrinsics.fx}, fy: {intrinsics.fy}) print(fppx: {intrinsics.ppx}, ppy: {intrinsics.ppy}) print(f畸变模型: {intrinsics.model}) print(f畸变系数: {intrinsics.coeffs})fx和fy是焦距单位是像素ppx和ppy是主点坐标理论上应该在图像中心附近但实际会有几个像素的偏移。畸变模型D435i用的是Brown-Conrady模型系数有5个k1, k2, p1, p2, k3。对于深度图来说SDK输出的深度图已经做了去畸变处理所以你可以直接用针孔模型做反投影。但彩色图如果没开去畸变就需要自己处理。验证内参是否准确有个简单方法把相机对着一个已知尺寸的平面物体比如A4纸测量它在图像中的像素宽度然后反推距离。公式是距离 (实际宽度 × fx) / 像素宽度。如果算出来的距离和卷尺量的差在几毫米以内说明内参没问题。2.2 反投影的数学推导与代码实现从像素坐标(u, v)和深度值Z到相机坐标系下的三维点(X, Y, Z)公式是X (u - ppx) × Z / fx Y (v - ppy) × Z / fy Z Z这个公式看起来简单但有几个细节容易出错。第一u和v是整数像素坐标但实际计算时应该用浮点数因为像素中心在(u0.5, v0.5)的位置。第二深度值Z是相机坐标系下的Z轴距离不是欧氏距离。对于D435i这种视场角较大的相机图像边缘的像素对应的欧氏距离会比Z值大不少。def pixel_to_camera(u, v, depth_value, intrinsics): X (u - intrinsics.ppx) * depth_value / intrinsics.fx Y (v - intrinsics.ppy) * depth_value / intrinsics.fy Z depth_value return np.array([X, Y, Z]) point_camera pixel_to_camera(320, 240, 0.5, intrinsics) print(f相机坐标系下的点: {point_camera})如果你要批量处理整张深度图用numpy的向量化操作会比循环快几十倍def depth_to_pointcloud(depth_image, intrinsics, depth_scale): height, width depth_image.shape u np.arange(width) v np.arange(height) uu, vv np.meshgrid(u, v) Z depth_image.astype(np.float32) * depth_scale valid Z 0 X np.zeros_like(Z) Y np.zeros_like(Z) X[valid] (uu[valid] - intrinsics.ppx) * Z[valid] / intrinsics.fx Y[valid] (vv[valid] - intrinsics.ppy) * Z[valid] / intrinsics.fy points np.stack([X, Y, Z], axis-1) return points[valid]这里有个性能优化的点np.meshgrid生成的坐标矩阵是固定的如果相机内参不变可以预先计算好缓存起来每帧只需要做乘法和减法。我在做实时点云显示时这个优化能把处理时间从15ms降到3ms左右。3. 外参标定从相机坐标系到机械臂基座3.1 手眼标定的两种模式如果你只是想把点云显示出来到相机坐标系就够了。但要做机械臂抓取必须知道相机坐标系和机械臂基坐标系之间的关系这就是手眼标定要解决的问题。手眼标定分两种模式eye-in-hand和eye-to-hand。前者是相机装在机械臂末端跟着一起动后者是相机固定在支架上看着机械臂工作。D435i因为体积小、重量轻两种模式都适用。eye-in-hand的优点是相机跟着末端走视野灵活但标定过程复杂需要机械臂走多个位姿eye-to-hand的优点是标定简单一次标定完就不用动了但视野固定有遮挡就没办法。我两种都做过个人建议如果是固定工位的抓取任务优先用eye-to-hand标定一次能用很久如果是大范围移动抓取必须用eye-in-hand但标定频率要高一些因为机械臂运动时的振动可能导致相机松动。3.2 基于棋盘格的标定实操标定板用标准的棋盘格就行我常用的是9x6的棋盘方格边长25mm。标定时把棋盘格放在相机视野内用机械臂末端带着标定针去触碰棋盘格的角点记录下机械臂的关节角度同时从图像里提取角点的像素坐标。这里有个关键点机械臂末端触碰的是棋盘格角点的物理位置而图像里提取的是角点的像素位置两者之间的对应关系就是我们要标定的变换矩阵。至少需要采集10组以上的数据而且棋盘格要覆盖相机视野的各个区域不能只集中在中心。import cv2 def find_chessboard_corners(image, pattern_size(9, 6)): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern_size, None) if ret: criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_refined cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) return corners_refined return None采集完数据后用OpenCV的calibrateHandEye函数求解。这个函数支持多种算法我一般用CALIB_HAND_EYE_TSAI速度快精度也够用。求解出来的结果是一个4x4的变换矩阵包含旋转和平移。标定精度的验证方法用标定结果把图像里的某个点投影到机械臂基坐标系然后让机械臂末端去触碰那个位置看误差有多大。我实测下来D435i在1米距离上标定误差可以做到2mm以内足够应付大多数抓取任务。3.3 标定中的常见问题与解决标定过程中最容易出现的问题是数据采集不充分。有些人只采集了5组数据就急着求解结果误差大得离谱。我的经验是至少15组而且要让棋盘格在图像里呈现不同的角度和位置。另外机械臂的运动范围要覆盖实际工作区域不能只在某个小角落里标定。还有一个坑是棋盘格的平整度。打印出来的棋盘格如果贴在不平的表面上角点的物理位置就会有偏差。我一般会把棋盘格打印在哑光相纸上然后贴在一块磨光的铝板上这样平整度能控制在0.1mm以内。温度变化也会影响标定结果。D435i的铝合金外壳热膨胀系数不小从20度到40度基线长度会有微米级的变化。对于高精度应用建议在相机预热30分钟后再标定而且工作环境温度要尽量稳定。4. 点云生成与三维可视化4.1 从深度图到彩色点云有了内参和外参就可以生成带颜色的点云了。点云的每个点包含XYZ坐标和RGB颜色这样在可视化时能直观地看到物体的形状和纹理。def create_colored_pointcloud(depth_image, color_image, intrinsics, depth_scale): points depth_to_pointcloud(depth_image, intrinsics, depth_scale) height, width depth_image.shape u np.arange(width) v np.arange(height) uu, vv np.meshgrid(u, v) valid depth_image 0 colors color_image[valid] return points, colors这里要注意颜色和点的对应关系。因为深度图已经对齐到彩色图了所以直接用相同的像素坐标索引就行。如果没做对齐就需要用外参把深度图的点投影到彩色图上再取颜色那样会麻烦很多。点云的数据量很大640x480的深度图最多能生成30万个点。直接全部显示会卡顿我一般会做降采样每隔2个像素取一个点这样点数降到7.5万视觉效果几乎没差别但帧率能翻倍。4.2 用Open3D做实时可视化Open3D是Python里做点云可视化最顺手的库安装简单API也直观。创建一个点云窗口只需要几行代码import open3d as o3d vis o3d.visualization.Visualizer() vis.create_window(window_nameD435i PointCloud, width1280, height720) pcd o3d.geometry.PointCloud() first_frame True while True: frames pipeline.wait_for_frames() aligned_frames align.process(frames) depth_frame aligned_frames.get_depth_frame() color_frame aligned_frames.get_color_frame() depth_image np.asanyarray(depth_frame.get_data()) color_image np.asanyarray(color_frame.get_data()) points, colors create_colored_pointcloud(depth_image, color_image, intrinsics, depth_scale) pcd.points o3d.utility.Vector3dVector(points) pcd.colors o3d.utility.Vector3dVector(colors[:, ::-1] / 255.0) if first_frame: vis.add_geometry(pcd) first_frame False else: vis.update_geometry(pcd) vis.poll_events() vis.update_renderer()Open3D默认的坐标系是Y轴向上而D435i的相机坐标系是Y轴向下所以显示出来的点云是倒着的。可以在可视化时加一个旋转变换或者直接在生成点云时把Y轴取反。我习惯在可视化阶段处理这样原始数据保持相机坐标系不变方便后续做算法处理。4.3 点云滤波与降噪原始点云里有很多离群点主要来自深度计算的噪声。常用的滤波方法有统计滤波和半径滤波。统计滤波是计算每个点到最近K个点的平均距离如果超过阈值就认为是离群点半径滤波是看每个点周围一定半径内的点数太少就剔除。pcd_filtered, ind pcd.remove_statistical_outlier(nb_neighbors20, std_ratio2.0)这两个参数需要根据实际场景调。nb_neighbors设20std_ratio设2.0是个比较通用的起点。如果场景里物体比较小nb_neighbors要调小否则会把物体本身的点也滤掉。还有一种情况是深度图边缘的飞点这些点通常出现在物体和背景的交界处深度值跳变导致的。处理方法是检查每个点周围3x3邻域的深度值如果方差太大就标记为无效。这个操作在深度图上做比在点云上做更高效。5. 与机械臂联动的实战要点5.1 坐标系转换的完整链路从像素到机械臂末端执行器完整的坐标变换链路是像素坐标 → 相机坐标系 → 机械臂基坐标系 → 末端执行器坐标系。每一步都是一个4x4的变换矩阵乘起来就是最终的变换。这里最容易出错的是旋转矩阵的表示方式。OpenCV用的是旋转向量Open3D用的是旋转矩阵ROS用的是四元数。我在项目里统一用4x4的齐次变换矩阵需要的时候再转成其他格式。转换函数要写清楚不然很容易搞混。def transform_point(point, transform_matrix): point_homo np.append(point, 1.0) transformed transform_matrix point_homo return transformed[:3]5.2 抓取点的选取策略从点云里选抓取点最简单的方法是取目标物体的质心。但质心不一定是最佳抓取点比如一个杯子质心在杯子中间但机械臂应该抓杯壁。更实用的方法是先做平面分割找到桌面然后在桌面上方做聚类把目标物体分离出来再在物体表面找抓取点。plane_model, inliers pcd.segment_plane(distance_threshold0.01, ransac_n3, num_iterations1000)distance_threshold设1cm适合大多数桌面场景。分割出平面后剩下的点就是桌面上的物体。然后用DBSCAN做聚类把不同物体分开。每个聚类计算一个包围盒抓取点就选在包围盒的顶部中心。5.3 实时性与稳定性的平衡整个链路跑下来从采集到输出抓取点耗时主要在深度图处理、点云生成和聚类这三步。在Intel i7的工控机上640x480分辨率下单帧处理时间大约30ms能跑到30fps。如果换成1280x720处理时间会涨到80ms左右帧率降到12fps。对于抓取任务12fps其实也够用因为机械臂的运动本身就不快。但如果要做动态跟踪就必须用低分辨率加降采样。我的做法是深度图用640x480点云降采样到1/4这样处理时间能控制在15ms以内。稳定性方面深度图的噪声会导致抓取点抖动。解决方法是对多帧的抓取点做滑动平均或者用卡尔曼滤波。我一般用简单的指数移动平均alpha设0.3既能平滑抖动又不会引入太大延迟。6. 几个让我踩过坑的细节6.1 USB供电不足导致的掉线D435i通过USB Type-C供电如果主板USB口供电不足相机会频繁掉线。我遇到过在工控机上跑得好好的换到笔记本上就每隔几分钟断一次。后来查出来是笔记本USB口只能提供500mA而D435i峰值需要1A以上。解决办法是用带外部供电的USB Hub或者直接插在支持USB PD的接口上。6.2 红外投射器与多相机干扰D435i的红外投射器在多个相机同时工作时会互相干扰。我做过一个项目用两台D435i从不同角度拍同一个场景结果深度图里全是噪点。后来把其中一台的投射器关掉只用被动双目虽然量程短了点但至少不互相干扰了。如果必须用主动投射两台相机要分时复用交替开启投射器。6.3 温度漂移对深度精度的影响前面提过温度会影响标定其实温度对深度计算本身也有影响。相机刚开机时深度图比较准连续工作半小时后深度值会整体偏移几毫米。对于高精度应用要么等相机热平衡后再开始工作要么定期做在线标定。我一般会在程序启动时先让相机空转5分钟等温度稳定了再开始采集。6.4 彩色图与深度图的曝光同步D435i的彩色图和深度图是独立曝光的在光照变化快的场景里彩色图可能过曝而深度图正常或者反过来。SDK提供了曝光同步的选项但需要手动开启。在rs.config()里设置config.enable_stream之后可以通过depth_sensor.set_option(rs.option.enable_auto_exposure, 1)来开启自动曝光但同步功能需要更底层的设置。我的做法是固定曝光时间根据场景光照手动调一次这样虽然不够智能但至少稳定。7. 从点云到抓取一个完整的代码框架把前面所有内容串起来一个完整的抓取流程大概是这样的import numpy as np import pyrealsense2 as rs import open3d as o3d import cv2 class D435iGraspPipeline: def __init__(self): self.pipeline rs.pipeline() self.config rs.config() self.config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) self.config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) self.profile self.pipeline.start(self.config) self.align rs.align(rs.stream.color) depth_sensor self.profile.get_device().first_depth_sensor() self.depth_scale depth_sensor.get_depth_scale() self.intrinsics None self.hand_eye_matrix np.eye(4) def get_frames(self): frames self.pipeline.wait_for_frames() aligned self.align.process(frames) depth_frame aligned.get_depth_frame() color_frame aligned.get_color_frame() if not self.intrinsics: self.intrinsics depth_frame.profile.as_video_stream_profile().intrinsics depth_image np.asanyarray(depth_frame.get_data()) color_image np.asanyarray(color_frame.get_data()) return depth_image, color_image def generate_pointcloud(self, depth_image, color_image): height, width depth_image.shape u np.arange(width) v np.arange(height) uu, vv np.meshgrid(u, v) Z depth_image.astype(np.float32) * self.depth_scale valid Z 0 X np.zeros_like(Z) Y np.zeros_like(Z) X[valid] (uu[valid] - self.intrinsics.ppx) * Z[valid] / self.intrinsics.fx Y[valid] (vv[valid] - self.intrinsics.ppy) * Z[valid] / self.intrinsics.fy points np.stack([X[valid], Y[valid], Z[valid]], axis-1) colors color_image[valid][:, ::-1] / 255.0 pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) pcd.colors o3d.utility.Vector3dVector(colors) return pcd def find_grasp_point(self, pcd): plane_model, inliers pcd.segment_plane( distance_threshold0.01, ransac_n3, num_iterations1000 ) objects_pcd pcd.select_by_index(inliers, invertTrue) if len(objects_pcd.points) 100: return None labels np.array(objects_pcd.cluster_dbscan(eps0.02, min_points10)) if len(labels) 0 or labels.max() 0: return None largest_cluster np.argmax(np.bincount(labels[labels 0])) cluster_pcd objects_pcd.select_by_index(np.where(labels largest_cluster)[0]) aabb cluster_pcd.get_axis_aligned_bounding_box() grasp_point_camera aabb.get_center() grasp_point_camera[2] aabb.max_bound[2] return np.asarray(grasp_point_camera) def camera_to_base(self, point_camera): point_homo np.append(point_camera, 1.0) point_base self.hand_eye_matrix point_homo return point_base[:3] def run(self): try: while True: depth_image, color_image self.get_frames() pcd self.generate_pointcloud(depth_image, color_image) grasp_point_camera self.find_grasp_point(pcd) if grasp_point_camera is not None: grasp_point_base self.camera_to_base(grasp_point_camera) print(f抓取点(基坐标系): {grasp_point_base}) o3d.visualization.draw_geometries([pcd]) finally: self.pipeline.stop() if __name__ __main__: pipeline D435iGraspPipeline() pipeline.run()这个框架里hand_eye_matrix需要你根据实际标定结果填入。DBSCAN的eps参数设0.02米适合大多数桌面物体。如果物体比较小eps要调小到0.01如果物体比较大可以调到0.03。实际跑的时候draw_geometries会阻塞主线程所以点云显示和抓取计算不能同时进行。我的做法是把点云显示放在单独的线程里主线程只做计算这样能保证实时性。或者用Open3D的Visualizer类手动控制渲染循环就像前面4.2节那样。8. 关于精度和鲁棒性的一些个人体会D435i的深度精度在1米距离上大约是量程的1%到2%也就是1到2厘米。这个精度对于抓取大物体够用但抓小零件就有点勉强。如果项目对精度要求高要么换更高精度的相机要么在算法上做补偿比如用多帧平均来降噪。鲁棒性方面最大的敌人是光照。强光直射会让深度图大面积失效弱光环境下彩色图又看不清。我的经验是给相机加一个遮光罩避免直射光进入镜头同时用主动红外投射来补光。如果场景里有反光物体可以在物体表面喷一层哑光显像剂或者用偏振片来消除镜面反射。还有一个容易被忽略的点是相机的安装角度。D435i的最佳工作角度是水平向下倾斜15到30度这样既能覆盖桌面又能避免正对光源。如果完全水平安装天花板上的灯会在深度图里形成大面积噪声如果垂直向下又容易拍到自己的影子。最后说一个调试技巧在程序里加一个深度图的有效像素比例统计如果低于60%说明当前场景不适合深度计算需要调整光照或者相机位置。这个指标比看深度图直观多了能快速判断数据质量。
返回列表