简介:面向ROS2与机器人视觉开发者,这份资源围绕OpenArm双臂机器人,提供从深度相机集成、手眼标定到环境建模与视觉引导抓取的完整工程实现,并在MuJoCo仿真环境中完成系统验证。包体共1055个文件,约32.26MB,核心包括C++/Python源码、yaml/yml参数配置、urdf/xacro机器人模型、srv/msg自定义接口以及ROS2 launch启动文件,可支撑二次开发与模块替换。已有283人学习下载。资料价值在于:不仅给出可运行的抓取决策与标定算法代码,还包含环境建模相关脚本、仿真场景搭建资源及完整目录结构,便于读者梳理双臂机器人视觉系统的数据流与模块边界。适合作为课程设计、毕业设计或科研预研的直接参考,尤其适合希望快速上手真实机器人视觉抓取项目的工程师。 做双臂机器人视觉抓取的同学,应该都经历过这种尴尬:模型在URDF里跑得好好的,深度相机的图像也能看到目标,但“相机坐标”和“机器人坐标”就是一对谁也不服谁的冤家。目标物体明明在点云里,机械臂却伸到完全不是那么回事的位置。前阵子我基于ROS2平台+Python+OpenArm机器人模型,把双臂机器人的深度相机集成、手眼标定、环境建模和视觉引导抓取整条链路走了一遍,最后在MuJoCo仿真环境里完整跑通了全套功能。这篇文章就是那次实践的复盘,希望能帮你少踩点坑,尤其适合正在做机械臂视觉抓取、手眼标定,或者在ROS2+MuJoCo仿真环境里搭类似系统的同学。
1. 为什么选这套组合:ROS2、OpenArm、MuJoCo能解决什么
先说选型。ROS1时代很多老代码还跑得欢,但新项目我坚决用ROS2,原因很简单:节点间的通信基于DDS,自带服务质量策略,数据丢了、迟了都能感知,不像ROS1里Topic可能悄悄丢帧;另外ROS2支持多机通信、进程隔离,后续如果要把感知和控制拆到不同机器上,不需要改架构。Ubuntu 24.04上对应的是ROS2 Jazzy,直接按官方apt源装就行,这里不赘述。
OpenArm是双臂机器人里很友好的开源模型,关节配置清楚、结构对称,特别适合做双臂协同验证。而且它支持标准URDF描述,这意味着能直接被MuJoCo、MoveIt2、RViz2这些工具链消费。相比自己搭一个双臂模型,省掉了大量调DH参数和碰撞网格的时间。
MuJoCo这边,我选它不是因为别的,就是快和稳。它用软接触模型,对抓取这类需要反复试接触的仿真来说,不容易像Gazebo那样动不动弹飞;而且Python接口很直接,加载模型、读取传感器、设置控制量都是几行代码的事。我们可以把MuJoCo当成一个“物理真实但零成本”的机器人环境,所有视觉、控制算法都先在仿真里跑通,再考虑搬到真机。
整套链路的核心逻辑是:深度相机负责看,手眼标定负责把视觉坐标和机器人坐标对齐,环境建模负责让机器人知道周围的空间占用,视觉引导抓取则把目标识别、位姿估计、运动规划串起来,最终让双臂机器人像人一样“看到什么拿什么”。每一环单独拎出来都不难,难的是串起来之后,坐标系、时间戳、消息频率这些细节会不会把你卡死。
2. 搭建双胞胎仿真环境:OpenArm模型与MuJoCo的融合
2.1 环境准备:Ubuntu 24.04 + ROS2 Jazzy + MuJoCo
我不建议在Windows里硬刚MuJoCo,哪怕现在有Windows原生版本。最省心的路线是在Ubuntu 24.04下装ROS2 Jazzy,再用Python的mujoco包,这样后期和ROS2节点通信、调RViz2都顺。
MuJoCo的Python安装很简单:
pip install mujoco python -c "import mujoco; print(mujoco.__version__)"能打印版本号就算通。如果你要在WSL里跑,记得装好WSLg保证GUI显示,否则mujoco.viewer打不开。
ROS2这边,Ubuntu 24.04对应Jazzy,安装完成后记得source环境:
source /opt/ros/jazzy/setup.bash为了后续把MuJoCo和ROS2接起来,建议装rclpy、sensor_msgs、geometry_msgs、std_msgs这组Python接口。
2.2 把OpenArm的URDF转换成MuJoCo可用的MJCF
MuJoCo原生支持MJCF格式,也更高效,但OpenArm给的是URDF。不建议手动重写模型文件,直接用MuJoCo提供的URDF插件转换:
from mujoco import MjModel import mujoco.viewer # MuJoCo 3.x 的 Python 绑定支持直接加载 URDF(需要模型名称唯一) model = MjModel.from_xml_path("openarm.urdf") data = mj_data = mujoco.MjData(model) with mujoco.viewer.launch_passive(model, data) as viewer: for _ in range(1000): mujoco.mj_step(model, data) viewer.sync()这里有个经验:URDF里如果包含大量网格文件,转换后MuJoCo的求解速度会下降。我的做法是先导出简化的几何体(box、cylinder),只保留视觉网格做显示,碰撞检测用简化网格。仿真里不会有人趴到屏幕上数齿轮,但物理引擎会诚实地把碰撞网格的三角形数量换算成计算时间。
2.3 让ROS2和MuJoCo之间“对话”
MuJoCo本身没有ROS2接口,所以我在二者之间写了一个小桥接节点。逻辑很简单:一个节点订阅关节指令,把目标角度写进MuJoCo的data.qpos,然后mj_step推进仿真,再把data.qpos、data.sensordata包装成ROS2消息发布出去。
控制频率我建议固定在100Hz左右,太低机械臂会有明显顿挫感,太高则DDS消息包太多,反而不稳定。仿真实时性不要苛求,MuJoCo跑起来远超实时是正常的,但发布消息的频率要用手动节流控制。
import rclpy from rclpy.node import Node from sensor_msgs.msg import JointState class MujocoBridge(Node): def __init__(self, model, data): super().__init__('mujoco_bridge') self.model = model self.data = data self.pub = self.create_publisher(JointState, '/joint_states', 10) self.sub = self.create_subscription(JointState, '/joint_commands', 10, self.cb) self.timer = self.create_timer(1.0 / 100, self.step_and_publish) def cb(self, msg): for i, name in enumerate(msg.name): if name in self.model.joint_names: idx = self.model.joint_names.index(name) self.data.qpos[idx] = msg.position[i] def step_and_publish(self): mujoco.mj_step(self.model, self.data) js = JointState() js.header.stamp = self.get_clock().now().to_msg() js.name = list(self.model.joint_names) js.position = self.data.qpos.tolist() self.pub.publish(js)这样做的好处是:后续MoveIt2或者自定义运动规划节点只要往/joint_commands发目标角位置,就会等价于控制仿真机械臂,跟控制真机的接口几乎一致。
3. 深度相机集成与手眼标定:坐标系“对齐”这件事
3.1 深度相机在双臂感知中的角色
彩色图能告诉你“目标是什么”,深度图能告诉你“目标在相机坐标系下的三维位置”。对于抓取任务,深度信息是刚需。我用的是RealSense D435的仿真模型,在MuJoCo里可以通过把相机传感器设置成深度渲染模式来模拟:
<camera name="cam_left" pos="0 0 0" xyaxes="1 0 0 0 1 0" fovy="60"/> <depth name="depth_left" width="640" height="480" mode="planar"/> <equip name="cam_sensor_left" camera="cam_left" sensor="depth_left"/>仿真里生成深度图并不难,难的是标定——你要让仿真相机和视觉感知代码里的相机内参完全一致。MuJoCo的fovy是垂直视野角,要换算成针孔模型的内参矩阵,直接按图像高度、焦距换算:
focal_length_y = (height / 2) / math.tan(math.radians(fovy / 2)) focal_length_x = focal_length_y # 如果像素是正方形把这两个值写进相机内参,后面做手眼标定才不会被“看起来差一点”的内参折磨。
3.2 手眼标定的两种模式:Eye-in-Hand与Eye-to-Hand
OpenArm有两条臂,我通常这样分配:一条臂的末端装一个小相机,做近距离目标精定位,对应的是Eye-in-Hand模式;工作台上方固定另一个相机,负责整体环境和粗略目标搜索,对应Eye-to-Hand模式。两条原理一样,但求解模型略有区别。
手眼标定的数学本质都是求解刚体变换方程:Eye-in-Hand求的是相机相对机械臂末端的固定变换X,每次移动机械臂末端,相机跟着动,方程形如:
AX = XB
其中A是相邻两次机械臂末端坐标系之间的相对变换,B是相邻两次相机坐标系之间的相对变换。仿真的好处是你可以得到“理想”的相机位姿,但实机标定就必须依赖标定板。OpenCV的calibrateHandEye函数可以直接吃T1, T2, R_cam2base这类参数,不需要自己实现SVD求解:
import cv2 import numpy as np # 假设我们从 /tf 和 /odom/vision 采集了一系列变换 rvecs_base, tvecs_base = collect_base_transforms() # 机械臂基座到末端的旋转、平移 rvecs_cam, tvecs_cam = collect_cam_transforms() # 相机到标定板的旋转、平移 R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( rvecs_gripper2base, tvecs_gripper2base, rvecs_target2cam, tvecs_target2cam, method=cv2.CALIB_HAND_EYE_TSAI )这里最容易出错的不是算法,而是数据采集顺序。一定要让机械臂做大幅度的姿态变化,比如先平移,再旋转,再平移,别只在一个小范围内微调。我踩过的坑是:采集了30组数据,标定结果却飘到几十厘米外,原因就是机械臂末端变换A几乎为零,把AX=XB退化成了单位矩阵问题。
3.3 标定实操中容易被忽略的细节
标定板方面,我强烈建议用不对称圆点板,而不是棋盘格。不对称圆点板的方向性更强,即使机械臂末端转得比较猛,OpenCV的findCirclesGrid也不容易搞混角点顺序。而且仿真里生成圆点板非常简单,直接用OpenCV画一个,再放到MuJoCo世界里当作固定物体。
另一个大坑是时间戳。ROS2里面tf2讲究时间同步,如果你拿着一个0.1秒之前的机械臂末端位姿和0.5秒之前的相机位姿去求手眼变换,结果必然废。我后来在采集节点里加了一个简单条件:只有当帧时间戳和/joint_states时间戳相差小于5ms时才记录数据。
4. 环境建模:从深度点云到八叉树地图
4.1 环境建模到底要建什么
很多人一听到“环境建模”就觉得要做SLAM,其实在双臂抓取场景里,我们更关心的是“物体周围是否有障碍物”“机械臂移动到目标点的通道是否畅通”。因此,八叉树地图(OctoMap)比存储全部点云更实用。八叉树能以不同分辨率表示空间占用情况,而且可以增量更新,非常适合动态场景。
在仿真里,我们通过两个深度相机获取点云,经过坐标变换统一到机器人基座坐标系,再构建八叉树。MuJoCo仿真的优势是深度图非常干净,没有真实相机那种高斯噪声和边缘跳变,所以建模算法可以先用干净数据跑通,再加噪声模拟。
4.2 点云获取与预处理流程
深度图本身不是点云。要从深度图生成点云,需要用到相机内参。我在ROS2里写了一个节点,读取Image类型的深度图,然后:
def depth_to_pointcloud(depth_img, intrinsics): h, w = depth_img.shape fx, fy = intrinsics['fx'], intrinsics['fy'] cx, cy = intrinsics['cx'], intrinsics['cy'] xs, ys = np.meshgrid(np.arange(w), np.arange(h)) z = depth_img.astype(np.float64) / 1000.0 # 如果深度单位是毫米 x = (xs - cx) * z / fx y = (ys - cy) * z / fy points = np.stack([x, y, z], axis=-1) # 去掉无效深度 return points[z > 0.2]这一步看起来简单,但坐标系非常容易搞反。常用的顺序是,把相机坐标系下的点云先转到机械臂末端坐标系,再转到机械臂基座坐标系,最后转到一个world或base_link公共坐标系。如果你前面手眼标定拿到了相机相对末端的变换T_cam2gripper,再配合机器人正运动学给出的末端相对基座变换T_gripper2base,就可以:
points_world = (T_base @ T_gripper2base @ T_cam2gripper @ points_cam.T).T注意矩阵维度和齐次坐标,我调这个变换的bug调了一下午,最后发现是忘了把点云末尾补一列1。
预处理方面,我一般先用体素滤波把点云降采样到1cm分辨率,再用统计滤波去掉离群点。真实相机的深度图边缘经常有飞点,仿真里没有,但保留这一步能为后续接实机省事。
4.3 构建八叉树地图并在RViz2中可视化
ROS2里直接用octomap_msgs和octomap_server社区包虽然方便,但我建议自己基于octomap-python写一个更轻量的节点,便于按需控制更新逻辑和分辨率:
from octomap_python import OctoMap octree = OctoMap(0.02) # 2cm分辨率 for point in points_world: octree.updateNode(point, True) # 标记为占用然后通过octomap_msgs/Octomap消息发布出去,RViz2里就能看到类似体素的地图。这里有一个性能建议:建图频率不要跟相机帧率同步,相机30Hz,建图实时做会拖垮CPU;我通常把点云累积一段时间,每0.5秒更新一次八叉树,这样RViz2和规划端的负担都小很多。
八叉树地图不只用来给人看,还要喂给运动规划做避障。后续我直接把octomap的占用信息转成costmap,供MoveIt2或自定义规划器用。这个转换要稍微注意坐标分辨率一致,否则机械臂规划出来的路径会“穿透”障碍物。
5. 视觉引导抓取:目标识别、三维定位与双臂协调
5.1 目标检测与抓取点估计
视觉引导抓取可以拆成三件事:找到目标、算出目标在机械臂基座坐标系的位姿、生成一个能让夹爪张开到目标位置的关节轨迹。目标检测我用了最简单的颜色分割+轮廓提取,因为仿真目标通常是纯色立方体或圆柱:
def detect_object(rgb_img, depth_img): hsv = cv2.cvtColor(rgb_img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, lower_color, upper_color) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 取最大轮廓 cnt = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(cnt) # 取深度图中该包围盒中心附近的深度值 z_center = np.median(depth_img[y:y+h, x:x+w]) return (x, y, z_center)这里我踩过的一个坑是:直接用深度图像中心像素的深度值,结果因为目标边缘有噪声,深度值忽高忽低。后来我改成取包围盒内所有有效深度的中位数,稳定性一下子提升很多。
有了二维像素坐标和深度值,再用相机内参反投影到相机坐标,最后经过前面标定的变换转到机器人基座坐标系,就得到目标的三维位置。抓取方向可以简单取目标包围盒的最小主成分方向,或者对仿真已知模型直接使用预设姿态。对于双目双臂场景,我会让两条臂分头去抓两个目标,或者一条臂负责把目标推到另一条臂的可抓范围。
5.2 逆运动学与轨迹规划
双臂机器人控制的核心是逆运动学解算。OpenArm自由度足够,但解析解并不好找,我直接用数值IK库ikpy,配合ROS2发布目标关节位置:
import ikpy from ikpy.chain import Chain arm_chain = Chain.from_urdf_file("openarm_left.urdf") target_position = [0.4, 0.2, 0.3] target_orientation = [1, 0, 0, 0] ik = arm_chain.inverse_kinematics( target_position, target_orientation, orientation_mode="all" )然后用一个简单的五次样条插值,把当前关节角平滑过渡到目标关节角。如果你需要更花哨的避障规划,可以接MoveIt2,但OpenArm双臂模型在MoveIt里设置自碰撞检测矩阵比较繁琐,我前期直接用自定义规划器跑通功能,再升级到MoveIt2。
双臂协调方面,最重要的问题是两个臂的规划不能互相碰撞。我的做法是:左臂规划时,把右臂当前姿态做成一个临时障碍物加进规划场景;右臂规划时同理。仿真里这一步看似多余,但真机如果没做互斥,两条臂很容易在目标附近撞在一起。
5.3 抓取闭环与失败重试
执行抓取时,不要只发一次指令就当成功。在MuJoCo里,我通过data.contact检测夹爪与目标物体是否发生接触,如果接触力达到阈值,就认为抓取成功;如果夹爪已经闭合但目标还在掉落,说明位姿估计可能有偏差,我会让机械臂退回观察点重新检测。
for contact in data.contact: geom1, geom2 = contact.geom1, contact.geom2 # 检查是否包含夹爪和目标物体 if is_gripper_geom(geom1) and is_object_geom(geom2): force = contact.frame[0] # 接触点法向 if norm(force) > threshold: grasp_success = True这个闭环至少能帮你发现两类问题:一类是目标识别框偏了,另一类是手眼标定矩阵有微小偏移。真机开发最怕的就是“程序看起来在跑,但结果永远差一点”,有了接触检测这个闭环,至少能自动判断失败并重试。
6. 联调实录与踩坑记录:把整条流程跑通之前,你可能先遇到这些问题
6.1 消息时间戳不同步
这条我必须先说。ROS2里每个消息都带时间戳,但你订阅相机深度图和一个机械臂关节状态消息时,很难保证它们对应同一物理时刻。如果时间戳错位严重,手眼标定和三维定位都会完全失真。我最后统一用相机图像时间戳作为同步基准,只把时间戳误差在5ms以内的关节状态消息和图像配对。这个设定看起来简单,但效果立竿见影。
6.2 MuJoCo深度图内参与算法内参不一致
MuJoCo的fovy是指垂直视场角,而RealSense相机的内参通常以焦距形式给出,二者单位不同。如果你不换算,直接用一个估计内参去做三维定位,会发现目标位置在仿真里漂几十厘米。最稳的做法是:从MuJoCo图像尺寸和fovy反推fx/fy,再写进算法,别靠猜。
6.3 RViz2里看不到模型或点云
这大概率是TF树不完整。我用ROS2之后发现,RViz2对TF的要求比RViz1严格得多,它会因为缺少某个frame的TF而直接不显示相关数据。解决方案是确保你的桥接节点持续发布完整的tf2_msgs/TFMessage,把base_link、left_arm_base、camera_link等坐标系的关系都维护好,每个坐标系的父子关系不要搞混。
6.4 双臂规划时的自碰撞
我前面提到双臂互斥,这里再补充一个教训:OpenArm的左右臂如果配置不仔细,规划算法会把两条臂的初始姿态当成可以任意穿越的通道,结果生成一条直接穿胸而过的轨迹。处理方式是在规划器里显式添加自碰撞矩阵,至少把两条臂的碰撞几何体对互相排除。如果你也遇到“单臂正常、双臂必撞”的问题,多半就是这里没配置好。
结尾
最后分享一个我在这个项目里最深的体会:仿真项目能不能顺利落地,关键不是看某一块算法有多精妙,而是看坐标系、时间戳、消息频率这些基础环节是否严谨。MuJoCo给了我们一个可以无限重置的试错环境,但千万别因此养成“随便调参,失误就重置”的习惯——把每个环节当成真机来要求,后面换到真机才能少受点罪。
你如果也想自己跑一遍这套流程,建议按“仿真环境搭建 -> 手眼标定 -> 环境建模 -> 视党引导抓取”的顺序推进,不要跳跃。每一步都在RViz2里可视化验证通过后,再进下一步。这个过程里你会遇到许多看起来莫名其妙的问题,但绝大多数都出在坐标系和时间戳上,耐心排查就好。
本文还有配套的精品资源,点击获取