【玩转VLA具身智能机械臂】(三):视觉避障——从 RGB-D 点云到 MoveIt octomap 的完整落地

前言


文章目录

    • 前言
    • [1 Gazebo与机械臂](#1 Gazebo与机械臂)
        • [1-1 介绍](#1-1 介绍)
        • [1-2 panda 的 urdf 转 sdf](#1-2 panda 的 urdf 转 sdf)
        • [1-3 包目录结构](#1-3 包目录结构)
        • [1-4 关键文件源码](#1-4 关键文件源码)
          • [1-4-1 主 URDF](#1-4-1 主 URDF)
          • [1-4-2 主臂 ros2_control 宏](#1-4-2 主臂 ros2_control 宏)
          • [1-4-3 夹爪 ros2_control 宏](#1-4-3 夹爪 ros2_control 宏)
          • [1-4-4 控制器配置](#1-4-4 控制器配置)
          • [1-4-5 初始关节角](#1-4-5 初始关节角)
          • [1-4-6 构建配置 CMakeLists](#1-4-6 构建配置 CMakeLists)
        • [1-5 launch](#1-5 launch)
        • [1-6 一键启动](#1-6 一键启动)
    • [2 编写自己的节点arm_control_demo.cpp](#2 编写自己的节点arm_control_demo.cpp)
        • [2-1 流程](#2-1 流程)
        • [2-2 功能包创建](#2-2 功能包创建)
        • [2-3 思路](#2-3 思路)
        • [2-4 源码](#2-4 源码)
        • [2-5 启动与发点脚本](#2-5 启动与发点脚本)
    • [3 深度相机插件与机械臂](#3 深度相机插件与机械臂)
        • [3-1 末端执行器的深度相机](#3-1 末端执行器的深度相机)
        • [3-2 手眼标定](#3-2 手眼标定)
        • [3-3 插件配置以及注意事项](#3-3 插件配置以及注意事项)
        • [3-4 对接moveit2的深度图像输入](#3-4 对接moveit2的深度图像输入)
          • [3-4-1 QoS 对齐](#3-4-1 QoS 对齐)
          • [3-4-2 最小测量范围](#3-4-2 最小测量范围)
        • [3-5 launch启动文件](#3-5 launch启动文件)
        • [3-6 添加桌子](#3-6 添加桌子)
        • [3-7 测试](#3-7 测试)
    • 总结

1 Gazebo与机械臂

1-1 介绍
  • 先认识一下 Gazebo:一个开源物理仿真器,负责给机器人提供一个"真实世界"------重力、碰撞、接触、传感器噪声都在这层模拟
  • 它和 MoveIt2 是怎么对接的?拆开看就是三块各司其职:
    • Gazebo:提供物理世界(重力、碰撞、接触),也是模型最终的执行环境
    • gazebo_ros2_control :插件跑在 gzserver 进程内,把 URDF 里声明的 ros2_control 描述翻译成 controller_manager,加载并激活控制器
    • MoveItmove_group 规划出轨迹,通过控制器下发关节位置,再从 /joint_states 读回真实反馈
  • 一句话总结:MoveIt 负责"想",Gazebo 负责"动",ros2_control 负责"传话"

说人话:Gazebo 当机械臂的"身体"、ros2_control 当"神经"、MoveIt 当"大脑"------上一期只有大脑在空想(RViz 回放动画),本期让三者连起来,机械臂才真正动起来

1-2 panda 的 urdf 转 sdf
  • URDF 是 MoveIt 全家的语言(描述 link/joint/惯性/碰撞),而 Gazebo classic 原生只认 SDF
  • 两个格式的关系:SDF 是"整个世界"的格式(除了机器人本体,还包含灯光、传感器、物理引擎参数),URDF 是它的一个子集、偏机器人本体
  • 所以 panda 想进 Gazebo,先得把 URDF "翻译"成 SDF,官方给了两条路:
    • 在线转换:浏览器打开 gazebosim.org 的 URDF 转换页面,传 URDF 下载 SDF
    • 命令行:gz sdf -p 你的.urdf 直接输出转换结果
  • 拿本项目用的官方精简版 panda.urdf 实测一下------注意它一个 <inertial> 都没有:
bash 复制代码
# 确认一下:12 个 link 全部没有惯量
grep -c "<inertial" /opt/ros/humble/share/moveit_resources_panda_description/urdf/panda.urdf   # 0

# 命令行转换,结果打印到终端
gz sdf -p /opt/ros/humble/share/moveit_resources_panda_description/urdf/panda.urdf
# 输出只有 <sdf><model name='panda'/></sdf> ------ 12 个 link 全被丢弃,一个不剩
  • 这就是坑 1 的现场:缺惯量的 link 在转换时被整体丢弃,一个不剩
  • 本项目其实不手动转spawn_entity 喂给 gzserver 的 URDF,会在加载时由 sdformat 自动转成 SDF
  • 但这条"自动翻译"路上埋了三个坑,正是后面 gazebo.launch.py 里几段修复要处理的:
    • 没有 <inertial> 的 link 会被整体丢弃(连带它的关节一起消失)
    • 网格路径用 package:// 时,Gazebo classic 不解析
    • URDF 里的 .dae 网格在 OGRE 渲染下是黑的(材质丢失)

说人话:URDF 是机器人的"简历",SDF 是"世界户口本"。Gazebo 只认户口本,简历虽然能自动翻译,但翻译老容易漏字,得人工兜底

1-3 包目录结构
  • 本节我们将构建功能包 panda_gazebo_bringup ,目录结构如下(编译产物 __pycache__ 等已省略):
bash 复制代码
src/panda_gazebo_bringup/
├── CMakeLists.txt                    # 构建配置:把 config/launch 安装进 share 目录
├── package.xml                       # 包元信息与依赖声明
├── config/
│   ├── initial_positions.yaml        # 机械臂初始关节角(一进 Gazebo 就摆好的姿势)
│   ├── panda_gazebo.urdf.xacro       # 主 URDF:复用系统 panda.urdf + 自建 ros2_control
│   ├── panda.ros2_control.xacro      # 主臂 7 关节的 ros2_control 描述(mock/isaac/gazebo 三分支)
│   ├── panda_hand.ros2_control.xacro # 夹爪 1 关节的 ros2_control 描述
│   └── ros2_controllers.yaml         # controller_manager + 臂/夹爪/状态广播三个控制器
└── launch/
    └── gazebo.launch.py              # 基线启动:无相机、无 octomap(本章主角)
  • 对号入座,和前面章节对上:
    • 1-2 挖的那三个坑(丢 link、package://、渲染黑),就是 1-5 里 _fix_* 后处理要填的,逻辑都在 gazebo.launch.py
    • 1-4 的 ros2_controllers.yaml 是控制器的"总装配表",spawner 按它声明的名字加载并激活
    • initial_positions.yaml 决定机械臂进 Gazebo 后摆什么姿势
  • 相机、场景相关的文件(gazebo_camera.launch.pypick_place.worldmoveit_camera.rviz)和第三章绑定,等讲到深度相机时再逐个展开

说人话:这个包像一本菜谱------launch 是"做菜步骤",xacro/yaml 是"食材配方"。本章先把"机械臂 + 控制器"这道基础菜端上来,相机和场景是第三章的加菜

1-4 关键文件源码
  • 六份核心配置按依赖顺序拆成 1-4-1~1-4-6 逐份展开:先是主 URDF,再是它 include 的两个 ros2_control 宏,接着是插件和宏引用的两个 yaml,最后是构建规则 CMakeLists
1-4-1 主 URDF
  • 文件:config/panda_gazebo.urdf.xacro。它是整条链路的入口,1-5 的 launch 直接加载它,做三件事:复用系统 panda.urdf(几何/惯量/关节/网格)、include 两个 ros2_control 宏并实例化、声明 gazebo_ros2_control 插件块
  • 三个 xacro:arg 参数含义:
    • initial_positions_file:初始关节角文件路径,默认 initial_positions.yaml(1-4-5)
    • ros2_control_hardware_type:硬件后端类型,默认 gazebo;宏里还预留 mock_components / isaac,本章只用 gazebo
    • enable_camera:是否挂腕部 RGBD 相机,默认 false;相机块整段是 <xacro:if> 包着的,和第三章 3-3 绑定,这里先留空位
  • 完整源码:
xml 复制代码
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="panda">
    <xacro:arg name="initial_positions_file" default="initial_positions.yaml" />
    <xacro:arg name="ros2_control_hardware_type" default="gazebo" />
    <xacro:arg name="enable_camera" default="false" />

    <!-- 1) 复用系统包基础 URDF(几何/惯量/关节/网格),不修改系统文件 -->
    <xacro:include filename="$(find moveit_resources_panda_description)/urdf/panda.urdf" />

    <!-- 2) 自建 ros2_control 描述:在系统版本基础上新增 gazebo 硬件分支 -->
    <xacro:include filename="panda.ros2_control.xacro" />
    <xacro:include filename="panda_hand.ros2_control.xacro" />

    <xacro:panda_ros2_control name="PandaGazeboSystem"
        initial_positions_file="$(arg initial_positions_file)"
        ros2_control_hardware_type="$(arg ros2_control_hardware_type)"/>
    <xacro:panda_hand_ros2_control name="PandaHandGazeboSystem"
        ros2_control_hardware_type="$(arg ros2_control_hardware_type)"/>

    <!-- 3) gazebo_ros2_control 插件块:模型被 spawn 后插件加载,
         controller_manager 直接运行在 gzserver 进程内(无需独立 ros2_control_node)。
         Humble 的插件完整支持 parameters 标签(会注入为参数文件) -->
    <gazebo>
        <plugin name="gazebo_ros2_control" filename="libgazebo_ros2_control.so">
            <parameters>$(find panda_gazebo_bringup)/config/ros2_controllers.yaml</parameters>
        </plugin>
    </gazebo>

    <!-- 4) 可选 RGBD 深度相机(眼在手)块:enable_camera=true 时挂腕部,
         是第三章 3-3 的内容,讲到时再补全 -->
</robot>
1-4-2 主臂 ros2_control 宏
  • 文件:config/panda.ros2_control.xacro。声明 panda_ros2_control 宏,1-4-1 include 后实例化为 PandaGazeboSystem,描述主臂 7 个关节的硬件接口
  • 宏参数含义:
    • name<ros2_control name="${name}"> 的实例名,插件按它区分硬件块
    • initial_positions_file:初始关节角文件,宏里用 ${xacro.load_yaml(initial_positions_file)['initial_positions']} 读成 XML 属性,供各关节取 initial_value
    • ros2_control_hardware_type:选 <hardware> 分支------mock_components / isaac / gazebo,本章用 gazebo 对应 gazebo_ros2_control/GazeboSystem
  • 每个关节的接口含义:
    • command_interface name="position":控制器下发的命令接口(位置模式)
    • state_interface(position / velocity):插件回传的状态反馈接口;position 的 initial_value 取 yaml 里的初始角,velocity 置 0
  • 完整源码:
xml 复制代码
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">

    <xacro:macro name="panda_ros2_control" params="name initial_positions_file ros2_control_hardware_type">
        <xacro:property name="initial_positions" value="${xacro.load_yaml(initial_positions_file)['initial_positions']}"/>

        <ros2_control name="${name}" type="system">
            <hardware>
                <xacro:if value="${ros2_control_hardware_type == 'mock_components'}">
                    <plugin>mock_components/GenericSystem</plugin>
                </xacro:if>
                <xacro:if value="${ros2_control_hardware_type == 'isaac'}">
                    <plugin>topic_based_ros2_control/TopicBasedSystem</plugin>
                    <param name="joint_commands_topic">/isaac_joint_commands</param>
                    <param name="joint_states_topic">/isaac_joint_states</param>
                </xacro:if>
                <xacro:if value="${ros2_control_hardware_type == 'gazebo'}">
                    <plugin>gazebo_ros2_control/GazeboSystem</plugin>
                </xacro:if>
            </hardware>
            <joint name="panda_joint1">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint1']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint2">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint2']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint3">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint3']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint4">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint4']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint5">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint5']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint6">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint6']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_joint7">
                <command_interface name="position"/>
                <state_interface name="position">
                  <param name="initial_value">${initial_positions['panda_joint7']}</param>
                </state_interface>
                <state_interface name="velocity">
                  <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
        </ros2_control>
    </xacro:macro>
</robot>
1-4-3 夹爪 ros2_control 宏
  • 文件:config/panda_hand.ros2_control.xacro。声明 panda_hand_ros2_control 宏,实例化为 PandaHandGazeboSystem,只描述夹爪的 1 个关节 panda_finger_joint1
  • 为什么只声明 1 个:系统包的 panda_finger_joint2<param name="mimic"> 联动,会让插件以不存在的假关节名 panda_finger_joint2_mimic 上报、MoveIt 状态监视器每周期刷 "Joint not found",所以去掉(代价是夹爪不再联动,本 demo 不用夹爪)
  • 完整源码:
xml 复制代码
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">

    <xacro:macro name="panda_hand_ros2_control" params="name ros2_control_hardware_type">
        <ros2_control name="${name}" type="system">
            <hardware>
                <xacro:if value="${ros2_control_hardware_type == 'mock_components'}">
                    <plugin>mock_components/GenericSystem</plugin>
                </xacro:if>
                <xacro:if value="${ros2_control_hardware_type == 'isaac'}">
                    <plugin>topic_based_ros2_control/TopicBasedSystem</plugin>
                    <param name="joint_commands_topic">/isaac_joint_commands</param>
                    <param name="joint_states_topic">/isaac_joint_states</param>
                </xacro:if>
                <xacro:if value="${ros2_control_hardware_type == 'gazebo'}">
                    <plugin>gazebo_ros2_control/GazeboSystem</plugin>
                </xacro:if>
            </hardware>
            <joint name="panda_finger_joint1">
                <command_interface name="position" />
                <state_interface name="position">
                  <param name="initial_value">0.0</param>
                </state_interface>
                <state_interface name="velocity">
                    <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
            <joint name="panda_finger_joint2">
                <!-- 原系统包有 <param name="mimic">panda_finger_joint1</param>,会让
                     gazebo_ros2_control 以 panda_finger_joint2_mimic 这个名字发布
                     接口 -> /joint_states 里出现模型里不存在的假关节 -> MoveIt 状态
                     监视器每周期刷 "Joint not found"。去掉后它用自己的本名上报,
                     与模型一致、无噪音(代价:夹爪不再联动,本 demo 不用夹爪)。 -->
                <command_interface name="position" />
                <state_interface name="position">
                  <param name="initial_value">0.0</param>
                </state_interface>
                <state_interface name="velocity">
                    <param name="initial_value">0.0</param>
                </state_interface>
            </joint>
        </ros2_control>
    </xacro:macro>

</robot>
1-4-4 控制器配置
  • 文件:config/ros2_controllers.yaml。控制器的"总装配表":插件启动 controller_manager 后按它加载控制器,spawner 再按名字激活
  • 参数含义:
    • update_rate: 100:controller_manager 的更新频率(Hz)
    • panda_arm_controller:主臂控制器,类型 joint_trajectory_controller/JointTrajectoryControllerjoints 列出 7 个关节,命令接口 position、状态接口 position+velocity
    • panda_hand_controller:夹爪控制器,类型 position_controllers/GripperActionControllerjoint 指定 panda_finger_joint1
    • joint_state_broadcaster:关节状态广播器,把状态发布到 /joint_states
  • 完整源码:
yaml 复制代码
# This config file is used by ros2_control
controller_manager:
  ros__parameters:
    update_rate: 100  # Hz

    panda_arm_controller:
      type: joint_trajectory_controller/JointTrajectoryController

    panda_hand_controller:
      type: position_controllers/GripperActionController

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster


panda_arm_controller:
  ros__parameters:
    command_interfaces:
      - position
    state_interfaces:
      - position
      - velocity
    joints:
      - panda_joint1
      - panda_joint2
      - panda_joint3
      - panda_joint4
      - panda_joint5
      - panda_joint6
      - panda_joint7

panda_hand_controller:
  ros__parameters:
    joint: panda_finger_joint1
1-4-5 初始关节角
  • 文件:config/initial_positions.yaml。机械臂一进 Gazebo 就摆好的姿势,被 1-4-2 宏的 ${xacro.load_yaml} 读取
  • 数值选在 ready 位姿附近:末端大致朝下、桌面落在相机视野里,方便第三章把相机照向桌面
  • 完整源码:
yaml 复制代码
# Default initial positions for the panda arm's ros2_control fake system
initial_positions:
  panda_joint1: 0.0
  panda_joint2: -0.785
  panda_joint3: 0.0
  panda_joint4: -2.356
  panda_joint5: 0.0
  panda_joint6: 1.571
  panda_joint7: 0.785
1-4-6 构建配置 CMakeLists
  • 文件:CMakeLists.txt。纯数据包:不编译任何代码,只把 config/launch/ 两个目录安装进 share/${PROJECT_NAME}------运行时 launch 里的 $(find panda_gazebo_bringup) 就是从这里定位文件的
  • 不能省略、也不能只当 ros2 pkg create 的默认模板用:默认模板只声明 ament_cmake不会安装子目录 ,缺了下面这段 install(DIRECTORY ...),xacro/yaml/launch 根本装不进 share,ros2 launch 直接报找不到包内容
  • 完整源码:
cmake 复制代码
cmake_minimum_required(VERSION 3.8)
project(panda_gazebo_bringup)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)

# 纯数据包:只安装目录,全部用相对路径(规避中文路径风险)
install(DIRECTORY config launch
  DESTINATION share/${PROJECT_NAME}
)

ament_package()
  • 六份文件加上 1-5 的 gazebo.launch.py,链路就齐了:launch 加载主 xacro → xacro include 两个宏、读 initial_positions.yaml → 插件按 ros2_controllers.yaml 在 gzserver 内拉起 controller_manager

说人话:这六份是"食材配方",1-5 的 launch 是"做菜步骤"。先把配方逐份抄好,下一步看步骤怎么做菜

1-5 launch
  • 上面说了一堆坑,现在看怎么在 launch 里一次性兜底。整个文件其实就做三件事:
    • MoveItConfigsBuilder 在进程内 eager 生成 robot_description(URDF 字符串),再经过一串 _fix_* 后处理把坑填平
    • move_grouprobot_state_publisher、RViz,并把 panda 用 spawn_entity 插进 Gazebo
    • 等 spawn 成功退出(=controller_manager 已建立),再用 spawner 加载三个控制器
  • 完整代码:
python 复制代码
import os
import re
import shutil

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, RegisterEventHandler
from launch.event_handlers import OnProcessExit
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node
from moveit_configs_utils import MoveItConfigsBuilder


def _fix_package_mesh_uris(urdf: str) -> str:
    """Gazebo classic 不解析 package:// 网格 URI(会当成 model:// 找),
    替换成 file:// 绝对路径,RViz / MoveIt 同样可用。"""
    panda_desc = get_package_share_directory('moveit_resources_panda_description')
    return urdf.replace('package://moveit_resources_panda_description', 'file://' + panda_desc)


# 真实 panda/FR3 惯量(kg*m^2,主对角线 + 质心偏移,取 7 自由度臂同架构量级)。
# 之前注入扁平默认值 mass=1.0 / inertia=0.01 比真实值轻太多,位置控制器(默认
# Kp=100)在轻惯量下形成极限环振荡 -> 关节速度发散 -> 位置变 NaN -> Gazebo 里
# 机械臂消失(RViz 用模型显示所以还在)。换成真实量级后物理稳定。
REAL_INERTIA = {
    'panda_link0': (2.3966, '0 0 0', '0.0090', '0.0115', '0.0085'),
    'panda_link1': (2.9275, '0 -0.0181 -0.0386', '0.0239', '0.0225', '0.0064'),
    'panda_link2': (2.9355, '0.0032 -0.0743 0.0088', '0.0419', '0.0251', '0.0617'),
    'panda_link3': (2.2449, '0.0407 -0.0048 -0.0290', '0.0241', '0.0197', '0.0190'),
    'panda_link4': (2.6156, '-0.0459 0.0630 -0.0085', '0.0345', '0.0289', '0.0413'),
    'panda_link5': (2.3271, '-0.0016 0.0293 -0.0973', '0.0516', '0.0479', '0.0164'),
    'panda_link6': (1.8170, '0.0597 -0.0410 -0.0102', '0.0054', '0.0141', '0.0161'),
    'panda_link7': (0.6271, '0.0045 0.0086 -0.0162', '0.0002', '0.0002', '0.0001'),
    'panda_link8': (0.0724, '0 0 0', '0.0001', '0.0001', '0.0001'),
    'panda_hand': (0.73, '0 0 0', '0.001', '0.001', '0.001'),
    'panda_leftfinger': (0.03, '0 0 0', '0.0001', '0.0001', '0.0001'),
    'panda_rightfinger': (0.03, '0 0 0', '0.0001', '0.0001', '0.0001'),
}


def _add_inertials(urdf: str) -> str:
    """moveit_resources 的 panda.urdf 是 MoveIt 精简版,link 没有 <inertial>。
    sdformat 的 URDF->SDF 转换会把无 inertial 的 link 整个丢弃(连带其 joint),
    导致 spawn 出来的模型只有 gazebo 插件块、没有任何 link/joint ------ 表现为
    gazebo_ros2_control 日志里所有关节 "Skipping joint ... not in the gazebo model"。
    给每个缺 inertial 的 link 注入 REAL_INERTIA 表里的真实惯量(比扁平默认值重,
    与控制器默认增益匹配,物理稳定不发散)。"""
    fmt = ('<inertial><mass value="{m}"/><origin xyz="{o}" rpy="0 0 0"/>'
           '<inertia ixx="{ixx}" ixy="0" ixz="0" iyy="{iyy}" iyz="0" '
           'izz="{izz}"/></inertial>')

    def _add(m):
        tag = m.group(0)
        nm = re.search(r'name="([^"]+)"', tag)
        m_, o, ixx, iyy, izz = REAL_INERTIA.get(nm.group(1),
                                                (1.0, '0 0 0', '0.01', '0.01', '0.01'))
        block = fmt.format(m=m_, o=o, ixx=ixx, iyy=iyy, izz=izz)
        # 自闭合 link(如 panda_link8):<link name="x"/> -> <link name="x">...</link>
        if tag.rstrip().endswith('/>'):
            return tag[:-2] + '>' + block + '</link>'
        return tag + block

    return re.sub(r'<link\b[^>]*>', _add, urdf)


def _strip_mimic(urdf: str) -> str:
    """URDF 里 panda_finger_joint2 的 <mimic> 让 MoveIt robot model 在状态更新时
    反复找内部名 'panda_finger_joint2_mimic' -> 每 10ms 刷一条 "Joint not found in model"。
    去掉 <mimic>:MoveIt 把它当普通被动关节(SRDF 已声明 passive_joint);
    夹爪联动改由 ros2_control 的 mimic 参数负责(Gazebo 端不受影响)。"""
    return re.sub(r'<mimic\b[^>]*/>', '', urdf)


def _swap_visual_meshes(urdf: str) -> str:
    """moveit_resources 的 panda .dae 网格在 Gazebo classic 的 OGRE 下渲染成
    白色盒子/黑色(RViz 用同一文件能渲染,是 Gazebo 特有的问题)。换成官方
    franka_description 的 FR3 网格(同为 7 自由度、文件名一致、OGRE 兼容),
    Gazebo 与 RViz 都能正确显示。碰撞网格(STL)不换。"""
    pnd = 'file:///opt/ros/humble/share/moveit_resources_panda_description/meshes/visual'
    fr3 = 'file:///opt/ros/humble/share/franka_description/meshes/robot_arms/fr3/visual'
    hand = 'file:///opt/ros/humble/share/franka_description/meshes/robot_ee/franka_hand_white/visual'
    urdf = urdf.replace(pnd, fr3)
    urdf = urdf.replace(fr3 + '/hand.dae', hand + '/hand.dae')
    urdf = urdf.replace(fr3 + '/finger.dae', hand + '/finger.dae')
    return urdf


def _fix_base(urdf: str) -> str:
    """panda.urdf 根 link 是 panda_link0、没有任何 world 固定关节(MoveIt 用 SRDF
    虚拟关节代替)。但在 Gazebo 里这意味着底座是自由体------只能靠碰撞网格搁在
    地面上,位置控制器在漂移的底座上打极限环 -> 关节抖动/偶发 NaN -> 机械臂
    抽搐/消失。官方 Gazebo 配置都会给底座加 world 固定关节,这里补上。"""
    base = ('<link name="world"/>'
            '<joint name="panda_link0_joint" type="fixed">'
            '<parent link="world"/><child link="panda_link0"/></joint>')
    # 插在 <robot ...> 之后(要在 _add_inertials/_add_materials 之后调用,
    # 这样 world link 不会被注入惯量/材质)
    return re.sub(r'(<robot[^>]*>)', r'\1' + base, urdf, count=1)


def _add_materials(urdf: str) -> str:
    """panda.urdf 的 <visual> 只有 mesh、没有 <material> 颜色,Gazebo classic
    会 fallback 到 .dae 自带材质,而这些 COLLADA 文件在 OGRE 下渲染成黑色 ->
    机械臂看起来黑/看不见。给每个 visual 注入浅灰材质即可(MoveIt/RViz 同样可用)。"""
    material = ('<material name="panda_light_grey">'
                '<color rgba="0.83 0.83 0.83 1"/></material>')

    def _add(m):
        tag = m.group(0)
        if tag.rstrip().endswith('/>'):
            return tag[:-2] + '>' + material + '</visual>'
        return tag + material

    return re.sub(r'<visual\b[^>]*>', _add, urdf)


def _fix_controller_yaml_path(urdf: str) -> str:
    """gazebo_ros2_control 把 <parameters> 当作 --params-file 交给 rcl 解析,
    workspace 路径含中文(非 ASCII)时 rcl_parse_arguments 会失败 -> 插件 Load
    失败 -> gzserver 崩溃。把 ros2_controllers.yaml 复制到 ASCII 路径 /tmp 并替换。"""
    src = os.path.join(
        get_package_share_directory('panda_gazebo_bringup'),
        'config', 'ros2_controllers.yaml')
    dst = '/tmp/panda_ros2_controllers.yaml'
    shutil.copy2(src, dst)
    return urdf.replace(src, dst)


def generate_launch_description():
    panda_bringup_dir = get_package_share_directory('panda_gazebo_bringup')

    # ---- 1) robot_description:自建 gazebo xacro(eager 生成 URDF 字符串)----
    #   mappings 全为 str -> 进程内立即 xacro.process_file,返回可修改的 URDF 字符串
    moveit_config = (
        MoveItConfigsBuilder('moveit_resources_panda')
        .robot_description(
            file_path=os.path.join(panda_bringup_dir, 'config', 'panda_gazebo.urdf.xacro'),
            mappings={'ros2_control_hardware_type': 'gazebo'},
        )
        .robot_description_semantic(file_path='config/panda.srdf')
        .trajectory_execution(file_path='config/gripper_moveit_controllers.yaml')
        .planning_pipelines(pipelines=['ompl', 'chomp', 'pilz_industrial_motion_planner'])
        .to_moveit_configs()
    )
    # 关键修复(缺一不可,否则 gzserver 崩溃/模型不可见):
    #   1) 网格 URI package:// -> file:// 绝对路径(Gazebo classic 不解析 package://)
    #   2) 参数文件复制到 ASCII 路径(中文 workspace 路径会让 rcl 解析 --params-file 失败)
    #   3) 给无 inertial 的 link 注入默认惯性(sdformat 会丢弃无 inertial 的 link 连带关节)
    #   4) 给 visual 注入浅灰材质(.dae 自带材质在 OGRE 下渲染成黑色,机械臂看不见)
    #   5) spawn 直接从文件读完整 URDF:-topic 走 /robot_description 时,10KB+ 的大消息
    #      在 FastDDS 下偶发只投递末尾片段 -> 模型无关节(关节全被插件跳过)。
    fixed_urdf = _add_materials(
        _fix_base(
            _add_inertials(
                _fix_controller_yaml_path(
                    _swap_visual_meshes(
                        _fix_package_mesh_uris(
                            _strip_mimic(
                                moveit_config.robot_description['robot_description'])))))))
    moveit_config.robot_description = {'robot_description': fixed_urdf}
    robot_urdf_file = '/tmp/panda_full.urdf'
    with open(robot_urdf_file, 'w') as f:
        f.write(fixed_urdf)

    # ---- 2) Gazebo 空世界(gazebo_ros 默认空 world,带 GUI)----
    gazebo = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(get_package_share_directory('gazebo_ros'),
                         'launch', 'gazebo.launch.py')
        ),
    )

    # ---- 3) 发布 /robot_description + 关节 TF(spawn_entity 从这里取 URDF)----
    robot_state_publisher = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        name='robot_state_publisher',
        parameters=[moveit_config.robot_description, {'use_sim_time': True}],
        output='screen',
    )

    # ---- 4) world -> panda_link0 静态 TF(MoveIt 虚拟关节规划帧是 world)----
    static_tf = Node(
        package='tf2_ros',
        executable='static_transform_publisher',
        name='static_transform_publisher',
        arguments=['0', '0', '0', '0', '0', '0', 'world', 'panda_link0'],
        output='log',
    )

    # ---- 5) move_group ----
    move_group_node = Node(
        package='moveit_ros_move_group',
        executable='move_group',
        output='screen',
        parameters=[moveit_config.to_dict(), {'use_sim_time': True}],
        arguments=['--ros-args', '--log-level', 'info'],
    )

    # ---- 6) RViz(复用系统 moveit.rviz,Fixed Frame = panda_link0)----
    rviz_config = os.path.join(
        get_package_share_directory('moveit_resources_panda_moveit_config'),
        'launch', 'moveit.rviz',
    )
    rviz_node = Node(
        package='rviz2',
        executable='rviz2',
        name='rviz2',
        output='log',
        arguments=['-d', rviz_config],
        parameters=[
            moveit_config.robot_description,
            moveit_config.robot_description_semantic,
            moveit_config.planning_pipelines,
            moveit_config.robot_description_kinematics,
            {'use_sim_time': True},
        ],
    )

    # ---- 7) 把 panda spawn 进 Gazebo ----
    #   spawn_entity.py 默认只等 30s 服务就绪,Gazebo 首次启动/DDS 发现偏慢时不够,
    #   显式传 -timeout 60 放宽。
    #   模型插入 -> gazebo_ros2_control 插件加载 -> controller_manager 起在 gzserver 内
    #   从文件读 URDF(而非 -topic):见上方 fixed_urdf 的注释。
    spawn_entity = Node(
        package='gazebo_ros',
        executable='spawn_entity.py',
        arguments=['-file', robot_urdf_file, '-entity', 'panda',
                   '-timeout', '60'],
        output='screen',
    )

    # ---- 8) spawn 成功退出 = 模型已插入 = controller_manager 已建立 ----
    #   用 spawner(带 controller-manager-timeout)加载并激活三个控制器
    load_joint_state_broadcaster = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_state_broadcaster', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )
    load_arm_controller = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['panda_arm_controller', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )
    load_hand_controller = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['panda_hand_controller', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )

    return LaunchDescription([
        gazebo,
        robot_state_publisher,
        static_tf,
        move_group_node,
        rviz_node,
        spawn_entity,
        RegisterEventHandler(
            event_handler=OnProcessExit(
                target_action=spawn_entity,
                on_exit=[load_joint_state_broadcaster, load_arm_controller,
                         load_hand_controller],
            )
        ),
    ])
  • 这串 _fix_* 后处理,每一步都对应一个真实的坑,缺一个都可能让 gzserver 崩溃或模型不可见:
修复函数 解决的坑
_fix_package_mesh_uris Gazebo 不解析 package:// 网格,换成 file:// 绝对路径
_add_inertials <inertial> 的 link 被 sdformat 丢弃(连带关节),注入真实惯量
_fix_base panda 根 link 没固定 world 关节、底座是自由体,补 world 固定关节
_add_materials .dae 网格在 OGRE 下渲染成黑色,注入浅灰材质
_fix_controller_yaml_path 中文 workspace 路径让 rcl 解析 --params-file 失败,复制到 /tmp
_swap_visual_meshes moveit_resources.dae 在 OGRE 下渲染成白盒/黑盒,换成 FR3 网格
1-6 一键启动
  • 上面的 launch 是 gazebo.launch.py------无相机、无 octomap 的基线版 ,一键脚本 1_gazebo_baseline.sh 把它包一层:先杀干净残留进程,再 source 环境、启动(第三章的相机增强版是另一个脚本 3_depth_gazebo.sh,3-5 再展开)
  • 那段进程清理很关键------ros2 launch 的 Ctrl+C 在部分环境会卡死进程(信号 fd 被提前关闭),脚本用 pkill + ps 精确匹配兜底 kill -9
bash 复制代码
#!/bin/bash
# 基线启动(无相机、无 octomap):gazebo.launch.py 原版
#   主流程用 ./3_depth_gazebo.sh(= 本脚本的超集,还带腕部 RGBD 相机 + octomap)
#   本脚本仅在需要无相机基线做对照/排障时用;之后仍用 ./2_arm_control.sh 跑 C++ 控制节点
set -e

# ---- 启动前清理:杀掉所有残留进程,确保从干净状态启动 ----
echo "[cleanup] 清理残留进程 (gzserver / gzclient / move_group / rviz2 / controllers ...)"
for p in gzserver gzclient gazebo; do
  pkill -x "$p" 2>/dev/null || true
done
# 进程名超过 15 字符时 pkill -x 匹配不到(如 robot_state_publisher),改用 ps 精确匹配;
# 过滤掉 grep 自身和 "bash -c",避免误杀当前 shell / 上层包装(历史教训:曾把自己杀掉)
ps aux | grep -E "ros2 launch|spawn_entity|spawner|move_group|robot_state_publisher|static_transform_publisher|controller_manager|rviz2|arm_control_demo" \
  | grep -v grep | grep -v "bash -c" \
  | awk '{print $2}' | xargs -r kill -9 2>/dev/null || true
sleep 2


source /opt/ros/humble/setup.bash
source "$(dirname "$0")/install/setup.bash"
export LANG=C.UTF-8 LC_ALL=C.UTF-8

ros2 launch panda_gazebo_bringup gazebo.launch.py
  • 启动后就能看到 rviz2 和 Gazebo 两个窗口

2 编写自己的节点arm_control_demo.cpp

2-1 流程
  • 节点要做的事非常聚焦:订阅一个"目标位姿",每收到一次就规划一次、执行一次,然后回到等待
  • 完整流程:
    • 启动:launch 把 robot_description 等参数注入,节点 init() 里建 MoveGroupInterface(构造函数会阻塞等 move_group 的 action server 就绪)
    • 等待:主循环 cv_.wait() 挂起,直到收到 /joint_states 和第一个 /target_pose
    • 触发:订阅回调把目标位姿缓存进共享变量,notify_one() 唤醒主循环
    • 规划:用 /joint_states 最新值显式构造起点,setPoseTarget 设目标,plan() 出轨迹
    • 执行:execute() 把轨迹发给 move_group,后者经 ros2_control 下发给 Gazebo 物理驱动
    • 反馈:/joint_states 订阅一直刷新,为下一次规划提供真实起点
2-2 功能包创建
  • 节点放在独立包 arm_control_demo 里,创建命令:
bash 复制代码
cd src
ros2 pkg create arm_control_demo --build-type ament_cmake \
  --dependencies rclcpp moveit_ros_planning_interface \
  geometry_msgs sensor_msgs moveit_msgs
  • 创建完补上 launch/ 目录、把源码替换成 2-4 的完整实现后,最终包结构如下(编译产物已省略):
bash 复制代码
arm_control_demo/
├── CMakeLists.txt                    # 构建配置:编译 src/arm_control_demo.cpp,安装 launch
├── package.xml                       # 包元信息与依赖声明
├── launch/
│   └── arm_control.launch.py         # 包装 launch:注入 robot_description 等参数
└── src/
    └── arm_control_demo.cpp          # 主节点:订阅 /target_pose,规划+执行(源码见 2-4)
  • 先看 CMakeLists.txt------不能只当 pkg create 的模板用:默认模板不编译 src/ 里的源码、也不安装 launch/ 目录。缺了 add_executable + ament_target_dependencies + install(TARGETS ...) + install(DIRECTORY launch ...) 这几段,ros2 runros2 launch 都找不到东西。pkg create--dependencies 只是写进 package.xml,CMake 里还必须 find_package 才能链接:
cmake 复制代码
cmake_minimum_required(VERSION 3.8)
project(arm_control_demo)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(moveit_msgs REQUIRED)
find_package(moveit_ros_planning_interface REQUIRED)

add_executable(arm_control_demo src/arm_control_demo.cpp)
ament_target_dependencies(arm_control_demo
  rclcpp
  sensor_msgs
  geometry_msgs
  moveit_msgs
  moveit_ros_planning_interface
)

install(TARGETS arm_control_demo
  DESTINATION lib/${PROJECT_NAME})

# 全部用相对路径(规避中文路径风险)
install(DIRECTORY launch
  DESTINATION share/${PROJECT_NAME})

ament_package()
  • 因为 MoveGroupInterface 需要 robot_description、规划管线等参数(Humble 没有 topic 回退),所以源码编译成可执行目标后,用 arm_control.launch.py 包一层:
python 复制代码
import os
import shutil

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node
from moveit_configs_utils import MoveItConfigsBuilder


def _fix_package_mesh_uris(urdf: str) -> str:
    panda_desc = get_package_share_directory('moveit_resources_panda_description')
    return urdf.replace('package://moveit_resources_panda_description', 'file://' + panda_desc)


def _fix_controller_yaml_path(urdf: str) -> str:
    src = os.path.join(
        get_package_share_directory('panda_gazebo_bringup'),
        'config', 'ros2_controllers.yaml')
    dst = '/tmp/panda_ros2_controllers.yaml'
    shutil.copy2(src, dst)
    return urdf.replace(src, dst)


def generate_launch_description():
    panda_bringup_dir = get_package_share_directory('panda_gazebo_bringup')

    # 与 gazebo.launch.py 完全一致的 MoveIt 配置构造(URDF 用自建 gazebo xacro)
    moveit_config = (
        MoveItConfigsBuilder('moveit_resources_panda')
        .robot_description(
            file_path=os.path.join(panda_bringup_dir, 'config', 'panda_gazebo.urdf.xacro'),
            mappings={'ros2_control_hardware_type': 'gazebo'},
        )
        .robot_description_semantic(file_path='config/panda.srdf')
        .trajectory_execution(file_path='config/gripper_moveit_controllers.yaml')
        .planning_pipelines(pipelines=['ompl', 'chomp', 'pilz_industrial_motion_planner'])
        .to_moveit_configs()
    )
    moveit_config.robot_description = {
        'robot_description': _fix_controller_yaml_path(
            _fix_package_mesh_uris(
                moveit_config.robot_description['robot_description']))
    }

    # C++ 节点必须由 launch 提供 robot_description 等参数(Humble 无 topic 回退)
    return LaunchDescription([
        Node(
            package='arm_control_demo',
            executable='arm_control_demo',
            parameters=[moveit_config.to_dict(), {'use_sim_time': True}],
            output='screen',
        )
    ])
  • launch 里用和 gazebo.launch.py 完全一致的 MoveItConfigsBuilder 构造,保证规划参数两边一致,并注入 use_sim_time;启动命令由 2-5 的 ./2_arm_control.sh 包一层
  • 这里声明了 OMPL / CHOMP / Pilz 三条规划管线,规划时默认走 OMPL(每种算法的默认参数,即 planner_configs 里那几百行 range/goal_bias 等,在 moveit_resources_panda_moveit_config/config/ompl_planning.yaml,由 MoveItConfigsBuilder 自动加载,无需手写)------暂不展开,规划器对比与调参留到后面专题
2-3 思路
  • 整体思路就是:订阅 /target_pose,收到一个目标就规划+执行一次,流程上就是"订阅 -> Plan -> Execute"循环

#mermaid-svg-Slgu1PQexEr5Tvcp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Slgu1PQexEr5Tvcp .error-icon{fill:#552222;}#mermaid-svg-Slgu1PQexEr5Tvcp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Slgu1PQexEr5Tvcp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Slgu1PQexEr5Tvcp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Slgu1PQexEr5Tvcp .marker.cross{stroke:#333333;}#mermaid-svg-Slgu1PQexEr5Tvcp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Slgu1PQexEr5Tvcp p{margin:0;}#mermaid-svg-Slgu1PQexEr5Tvcp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster-label text{fill:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster-label span{color:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster-label span p{background-color:transparent;}#mermaid-svg-Slgu1PQexEr5Tvcp .label text,#mermaid-svg-Slgu1PQexEr5Tvcp span{fill:#333;color:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp .node rect,#mermaid-svg-Slgu1PQexEr5Tvcp .node circle,#mermaid-svg-Slgu1PQexEr5Tvcp .node ellipse,#mermaid-svg-Slgu1PQexEr5Tvcp .node polygon,#mermaid-svg-Slgu1PQexEr5Tvcp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Slgu1PQexEr5Tvcp .rough-node .label text,#mermaid-svg-Slgu1PQexEr5Tvcp .node .label text,#mermaid-svg-Slgu1PQexEr5Tvcp .image-shape .label,#mermaid-svg-Slgu1PQexEr5Tvcp .icon-shape .label{text-anchor:middle;}#mermaid-svg-Slgu1PQexEr5Tvcp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Slgu1PQexEr5Tvcp .rough-node .label,#mermaid-svg-Slgu1PQexEr5Tvcp .node .label,#mermaid-svg-Slgu1PQexEr5Tvcp .image-shape .label,#mermaid-svg-Slgu1PQexEr5Tvcp .icon-shape .label{text-align:center;}#mermaid-svg-Slgu1PQexEr5Tvcp .node.clickable{cursor:pointer;}#mermaid-svg-Slgu1PQexEr5Tvcp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Slgu1PQexEr5Tvcp .arrowheadPath{fill:#333333;}#mermaid-svg-Slgu1PQexEr5Tvcp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Slgu1PQexEr5Tvcp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Slgu1PQexEr5Tvcp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Slgu1PQexEr5Tvcp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Slgu1PQexEr5Tvcp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Slgu1PQexEr5Tvcp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster text{fill:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp .cluster span{color:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Slgu1PQexEr5Tvcp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Slgu1PQexEr5Tvcp rect.text{fill:none;stroke-width:0;}#mermaid-svg-Slgu1PQexEr5Tvcp .icon-shape,#mermaid-svg-Slgu1PQexEr5Tvcp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Slgu1PQexEr5Tvcp .icon-shape p,#mermaid-svg-Slgu1PQexEr5Tvcp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Slgu1PQexEr5Tvcp .icon-shape .label rect,#mermaid-svg-Slgu1PQexEr5Tvcp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Slgu1PQexEr5Tvcp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Slgu1PQexEr5Tvcp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Slgu1PQexEr5Tvcp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



等待 /joint_states 到位
等待 /target_pose
收到目标?
用 /joint_states 显式构造规划起点
setPoseTarget 设目标
plan 规划轨迹
规划成功?
打印 plan failed
execute 执行

  • 这里我们使用默认的 ompl 规划管线,只需配好速度/加速度缩放和规划时间上限,Gazebo 物理下才跑得稳:
yaml 复制代码
# 对应 arm_control_demo.cpp 里的常量(为对齐 yaml 写法列出)
velocity_scaling_factor: 0.3      # kVelocityScale,轨迹速度放慢到 30%
acceleration_scaling_factor: 0.3  # kAccelerationScale,加速度放慢到 30%
planning_time: 10.0               # kPlanningTime,单次规划时间上限(秒)
2-4 源码
  • 三个地方值得注意,都是实际踩过的坑:
    • MoveGroupInterface 不能在构造函数里创建------shared_from_this() 依赖 make_shared 初始化,构造函数里调用会抛 std::bad_weak_ptr,所以挪到 init(),由 main 建完节点后调用
    • 规划起点每次用 /joint_states 最新值显式构造:MoveGroupInterface 客户端缓存的起点在 execute 后不刷新,直接规划会用旧起点,导致轨迹起点和实际关节状态不一致
    • 起点只取 7 个真实臂关节,排除 /joint_states 里的 SRDF 假关节 panda_finger_joint2_mimic,否则 move_group 应用起点会抛 "Catastrophic failure"
  • 完整源码:
cpp 复制代码
// arm_control_demo.cpp
// 第三期:目标位姿驱动的控制节点
// 订阅 /target_pose (geometry_msgs/Pose):每收到一个目标点,就规划+执行过去。
// 不内置固定轨迹、不归位 ------ 节点启动后挂起等待,发点即动。
// 结构:ArmControlDemo 继承 rclcpp::Node,main() 只负责建节点、起后台 executor、调用 run()。
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

#include <rclcpp/rclcpp.hpp>
#include <geometry_msgs/msg/pose.hpp>
#include <sensor_msgs/msg/joint_state.hpp>

#include <moveit/move_group_interface/move_group_interface.h>
#include <moveit/utils/moveit_error_code.h>
#include <moveit_msgs/msg/robot_state.hpp>

class ArmControlDemo : public rclcpp::Node
{
public:
  explicit ArmControlDemo(const rclcpp::NodeOptions & options = rclcpp::NodeOptions())
  : Node("arm_control_demo", options)
  {
    // 反馈:订阅 Gazebo/ros2_control 发布的真实关节状态(规划起点的真实来源)
    joint_states_sub_ = create_subscription<sensor_msgs::msg::JointState>(
      "/joint_states", rclcpp::SensorDataQoS(),
      [this](const sensor_msgs::msg::JointState::ConstSharedPtr msg) {
        std::lock_guard<std::mutex> lock(mutex_);
        joint_states_ = *msg;
        got_joint_states_ = true;
      });

    // 指令:订阅目标位姿,收到一个就驱动一次(回调只缓存,处理放 run() 主循环)
    target_pose_sub_ = create_subscription<geometry_msgs::msg::Pose>(
      "/target_pose", rclcpp::QoS(10),
      [this](const geometry_msgs::msg::Pose::ConstSharedPtr target) {
        {
          std::lock_guard<std::mutex> lock(mutex_);
          target_pose_ = *target;
          has_target_ = true;
        }
        cv_.notify_one();
      });

    // 关键:MoveGroupInterface 不能在这里创建!
    //   shared_from_this() 依赖 make_shared 初始化的 enable_shared_from_this
    //   的 weak_this_,本环境 libstdc++ 要到 make_shared 返回后才初始化它,
    //   在构造函数里调用会抛 std::bad_weak_ptr。挪到 init(),由 main 建完节点后调用。
    // 也不要在构造函数里调 getCurrentPose():此时后台 executor 还没起,
    // /joint_states 订阅回调没跑过,current_state_monitor 是空的,会取不到状态。
    // 启动日志放到 run()(executor 已转起来)再打。
  }

  // 建完节点后调用:创建 MoveGroupInterface(构造即阻塞等待 move_group action server)
  void init()
  {
    move_group_ = std::make_shared<moveit::planning_interface::MoveGroupInterface>(
      shared_from_this(), kPlanningGroup);
    // Gazebo 物理下放慢一点,更稳
    move_group_->setMaxVelocityScalingFactor(kVelocityScale);
    move_group_->setMaxAccelerationScalingFactor(kAccelerationScale);
    move_group_->setPlanningTime(kPlanningTime);
  }

  // 主循环:等目标点 -> 规划 -> 执行 -> 再等
  void run()
  {
    // 等第一个 /joint_states 到位(后台 executor 此刻已在转),再取当前末端位姿
    std::unique_lock<std::mutex> lock(mutex_);
    cv_.wait_for(lock, std::chrono::seconds(10), [this] { return got_joint_states_; });
    lock.unlock();
    geometry_msgs::msg::Pose current = currentEndEffectorPose();
    RCLCPP_INFO(get_logger(), "current EE pose: [%.3f, %.3f, %.3f], waiting for /target_pose ...",
                current.position.x, current.position.y, current.position.z);

    while (rclcpp::ok()) {
      std::unique_lock<std::mutex> lock(mutex_);
      cv_.wait(lock, [this] { return has_target_ || !rclcpp::ok(); });
      if (!rclcpp::ok()) {
        break;
      }
      geometry_msgs::msg::Pose target = target_pose_;
      has_target_ = false;
      lock.unlock();
      moveToTarget(target);
    }
  }

private:
  using Plan = moveit::planning_interface::MoveGroupInterface::Plan;
  using ErrorCode = moveit::core::MoveItErrorCode;

  static constexpr char kPlanningGroup[] = "panda_arm";
  static constexpr double kVelocityScale = 0.3;
  static constexpr double kAccelerationScale = 0.3;
  static constexpr double kPlanningTime = 10.0;

  std::mutex mutex_;
  std::condition_variable cv_;
  sensor_msgs::msg::JointState joint_states_;
  bool got_joint_states_ = false;
  geometry_msgs::msg::Pose target_pose_;
  bool has_target_ = false;
  rclcpp::Subscription<sensor_msgs::msg::JointState>::SharedPtr joint_states_sub_;
  rclcpp::Subscription<geometry_msgs::msg::Pose>::SharedPtr target_pose_sub_;
  std::shared_ptr<moveit::planning_interface::MoveGroupInterface> move_group_;

  geometry_msgs::msg::Pose currentEndEffectorPose()
  {
    return move_group_->getCurrentPose("panda_link8").pose;
  }

  // 规划到目标位姿并执行
  void moveToTarget(const geometry_msgs::msg::Pose & target)
  {
    RCLCPP_INFO(get_logger(), "target: [%.3f, %.3f, %.3f]",
                target.position.x, target.position.y, target.position.z);
    setStartFromJointStates();
    Plan plan;
    move_group_->setPoseTarget(target, "panda_link8");
    ErrorCode plan_ok = move_group_->plan(plan);
    if (plan_ok) {
      ErrorCode exec_ok = move_group_->execute(plan);
      RCLCPP_INFO(get_logger(), "execute result: %s",
                  moveit::core::error_code_to_string(exec_ok).c_str());
    } else {
      RCLCPP_ERROR(get_logger(), "plan failed: %s",
                   moveit::core::error_code_to_string(plan_ok).c_str());
    }
  }

  // 每次规划前用 /joint_states 最新值显式构造起点:
  // MoveGroupInterface 客户端缓存的起点状态在 execute 后不刷新,setStartStateToCurrentState()
  // 拿到的还是旧值,导致规划轨迹起点与实际关节状态不一致。只取 7 个真实臂关节,
  // 排除 /joint_states 里 SRDF 假关节 panda_finger_joint2_mimic(不在 URDF 模型里,
  // 塞进起点会让 move_group 应用起点时抛 "Catastrophic failure")。
  void setStartFromJointStates()
  {
    std::lock_guard<std::mutex> lock(mutex_);
    if (!got_joint_states_) {
      move_group_->setStartStateToCurrentState();
      return;
    }
    const std::vector<std::string> arm_joints = {
      "panda_joint1", "panda_joint2", "panda_joint3", "panda_joint4",
      "panda_joint5", "panda_joint6", "panda_joint7"};
    moveit_msgs::msg::RobotState start;
    for (const auto & name : arm_joints) {
      auto it = std::find(joint_states_.name.begin(), joint_states_.name.end(), name);
      if (it != joint_states_.name.end()) {
        size_t idx = std::distance(joint_states_.name.begin(), it);
        start.joint_state.name.push_back(name);
        start.joint_state.position.push_back(joint_states_.position[idx]);
      }
    }
    move_group_->setStartState(start);
  }
};

int main(int argc, char ** argv)
{
  rclcpp::init(argc, argv);
  // robot_description / planning_pipelines 等参数必须由 launch 传入
  auto node = std::make_shared<ArmControlDemo>(
    rclcpp::NodeOptions().automatically_declare_parameters_from_overrides(true));
  // make_shared 已返回,shared_from_this() 可用;再建 MoveGroupInterface
  node->init();

  // 后台 executor:驱动两个订阅回调(use_sim_time 由 launch 注入)
  rclcpp::executors::SingleThreadedExecutor executor;
  executor.add_node(node);
  std::thread spin_thread([&executor]() { executor.spin(); });

  node->run();

  rclcpp::shutdown();
  spin_thread.join();
  return 0;
}
2-5 启动与发点脚本
  • 节点编译完(colcon build),先用 2_arm_control.sh 把它跑起来------它就是 ros2 launch arm_control_demo arm_control.launch.py 的包装,前置条件:1_gazebo_baseline.sh3_depth_gazebo.sh 已在跑(gazebo + move_group 都在):
bash 复制代码
#!/bin/bash
# 第三期:运行 C++ 控制节点(arm_control_demo 订阅 /target_pose,收点即规划+执行)
# 前提:先跑 1_gazebo_baseline.sh 或 3_depth_gazebo.sh(gazebo + move_group 都在跑)

source /opt/ros/humble/setup.bash
source "$(dirname "$0")/install/setup.bash"
export LANG=C.UTF-8 LC_ALL=C.UTF-8

ros2 launch arm_control_demo arm_control.launch.py
  • 再写一个发点脚本 4_send_target.sh,配合上面的节点:支持命令行单发(可选四元数)和交互模式,不发姿态时自动沿用 TF 里的当前末端姿态(只动位置、不转姿态)
bash 复制代码
#!/bin/bash
# 发布目标位姿给 ./2_arm_control.sh(arm_control_demo 订阅 /target_pose,收点即规划+执行)
# 前提:./3_depth_gazebo.sh 在跑(提供 /tf 和 move_group),然后 ./2_arm_control.sh 也在跑
#
# 用法:
#   ./4_send_target.sh                          # 交互模式:逐行输入 x y z,回车即发;q 退出
#   ./4_send_target.sh <x> <y> <z>              # 单发:保持当前末端姿态,只改位置
#   ./4_send_target.sh <x> <y> <z> <qx> <qy> <qz> <qw>   # 单发:完整指定姿态(四元数)
#
# 注意:坐标是 move_group 规划帧(world)下的 panda_link8 末端位姿;
#       不发姿态时自动沿用 tf 里当前的末端姿态(只动位置、不转姿态)。
set -e

# 与 ./1 ./2 ./3 相同的环境准备:退出 conda,避免 anaconda python 劫持 rclpy
source /home/lzh/anaconda3/etc/profile.d/conda.sh 2>/dev/null || true
conda deactivate 2>/dev/null || true
source /opt/ros/humble/setup.bash
source "$(dirname "$0")/install/setup.bash"
export LANG=C.UTF-8 LC_ALL=C.UTF-8

# 参数原样转发给内嵌 python("/target_pose" 发布 + world->panda_link8 姿态查询)
python3 - "$@" <<'PY'
import sys
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile
from rclpy.parameter import Parameter
from geometry_msgs.msg import Pose
from tf2_ros import Buffer, TransformListener, TransformException

PARENT, CHILD = 'world', 'panda_link8'


def wait_transform(buf, node):
    """等 world->panda_link8 的 TF(move_group 规划帧下的末端姿态)。"""
    for _ in range(100):  # 最多 ~20s
        try:
            return buf.lookup_transform(PARENT, CHILD, rclpy.time.Time())
        except TransformException:
            rclpy.spin_once(node, timeout_sec=0.2)
    raise RuntimeError(f'{PARENT} -> {CHILD} TF 20s 内不可用')


def current_orientation(buf, node):
    t = wait_transform(buf, node)
    q = t.transform.rotation
    return q.x, q.y, q.z, q.w


def build_pose(args, buf, node):
    vals = [float(a) for a in args]
    p = Pose()
    p.position.x, p.position.y, p.position.z = vals[0], vals[1], vals[2]
    if len(vals) >= 7:
        p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w = vals[3:7]
    else:
        p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w = \
            current_orientation(buf, node)
    return p


def send(pub, node, p, tag=''):
    pub.publish(p)
    rclpy.spin_once(node, timeout_sec=0.1)
    node.get_logger().info(
        f'sent{tag}: pos [{p.position.x:.3f}, {p.position.y:.3f}, {p.position.z:.3f}]  '
        f'quat [{p.orientation.x:.3f}, {p.orientation.y:.3f}, {p.orientation.z:.3f}, '
        f'{p.orientation.w:.3f}]')


def main():
    rclpy.init()
    # use_sim_time 与整套仿真一致,TF 缓存判定才可靠
    node = Node('target_pose_sender',
                parameter_overrides=[Parameter('use_sim_time', value=True)])
    buf = Buffer()
    TransformListener(buf, node)
    # arm_control_demo 用 rclcpp::QoS(10) 订阅(reliable),这里发布端也 reliable
    pub = node.create_publisher(Pose, '/target_pose', QoSProfile(depth=10))

    args = sys.argv[1:]
    if not args:
        # 交互模式:先打出当前末端位姿,再逐行收 x y z
        qx, qy, qz, qw = current_orientation(buf, node)
        node.get_logger().info(
            f'当前末端姿态(world 下 panda_link8):'
            f'quat [{qx:.3f}, {qy:.3f}, {qz:.3f}, {qw:.3f}],'
            f'输入 q 退出')
        try:
            while True:
                line = input('xyz> ').strip()
                if not line:
                    continue
                if line.lower() in ('q', 'quit', 'exit'):
                    break
                try:
                    vals = [float(v) for v in line.split()]
                    if len(vals) != 3:
                        raise ValueError('需要 3 个数')
                except ValueError as e:
                    node.get_logger().error(f'输入解析失败({e}),需要 "x y z",q 退出')
                    continue
                p = build_pose(vals, buf, node)
                send(pub, node, p)
        except (EOFError, KeyboardInterrupt):
            pass
    else:
        try:
            p = build_pose(args, buf, node)
        except (ValueError, IndexError):
            sys.exit('参数错误:需要 <x> <y> <z> [qx qy qz qw],四元数可省略')
        send(pub, node, p)

    rclpy.shutdown()


if __name__ == '__main__':
    main()
PY
  • 使用起来很简单,只需一条命令:
bash 复制代码
./4_send_target.sh 0.28 -0.2 0.5
  • rviz2 里就能看到效果:机械臂接收到目标后,plan() 先规划出一段轨迹残影,紧接着实体机械臂跟着残影一起动起来
  • 同时打开 Gazebo,会发现它的动作和 rviz2 里规划的一致

  • 需要注意的是,当你发布的目标点不可达 (超出机械臂工作空间、与给定姿态组合起来 IK 无解,或目标位姿和机械臂自身/环境发生碰撞)时,plan() 会直接失败,机械臂原地不动,终端里会看到这样的报错:

bash 复制代码
[arm_control_demo-1] [INFO] [1787986315.394702429] [move_group_interface]: MoveGroup action client/server ready
[arm_control_demo-1] [INFO] [1787986315.395037482] [move_group_interface]: Planning request accepted
[arm_control_demo-1] [INFO] [1787986325.592517226] [move_group_interface]: Planning request aborted
[arm_control_demo-1] [ERROR] [1787986325.592662114] [move_group_interface]: MoveGroupInterface::plan() failed or timeout reached
[arm_control_demo-1] [ERROR] [1787986325.592708880] [arm_control_demo]: plan failed: GOAL_STATE_INVALID
  • 关键在最后一行:GOAL_STATE_INVALID 表示 MoveIt 在规划前就把目标位姿判定为非法,根本没进规划器。换一个可达的目标点再发即可,比如 ./4_send_target.sh 0.4 0.0 0.5(落在桌面工作区正上方)

3 深度相机插件与机械臂

3-1 末端执行器的深度相机
  • 深度相机(RGBD,一图两用:RGB + 点云)装在哪,直接决定它能"看"到什么,主要有三种:
    • 固定外部相机:相机固定在世界某处,视角不变、覆盖范围大,但机械臂动起来容易遮挡目标
    • 眼外安装(eye-to-hand):相机装在机械臂外的支架上,能看到整个工作空间,标定一次即可,但和目标之间有遮挡风险
    • 眼在手上(eye-in-hand):相机固定在末端执行器上,跟着手走
  • 为什么眼在手最具普适性:抓取/操作任务里,目标往往离手很近,只有相机在手上才能保证"手伸到哪、目标就在视野中央",而且越靠近目标测量越准、误差越小
  • 本项目采用眼在手:相机挂在 panda_hand 腕部,做视觉伺服、抓取前的目标定位都很自然

说人话:眼睛装在手上,才能"盯住"手里的活。VLA 里那句"看手在干嘛"也是这个道理

3-2 手眼标定
  • 相机输出的是"目标在相机坐标系 里离我多远",机械臂执行的是"往机械臂坐标系 的哪个位姿伸"------两套坐标系之间隔着一座桥,这座桥就是手眼标定要解的固定变换
  • 标哪个变换,看相机装在哪(和 3-1 的两种装法一一对应):
    • 眼在手(eye-in-hand) :相机随末端一起动,要解的是 相机与末端(gripper) 的固定变换
    • 眼在手外(eye-to-hand) :相机固定不动,要解的是 相机与机械臂底座(base) 的固定变换
  • 数学本质是解 AX=XB :机械臂摆两个不同姿态,末端位姿变化 A(正运动学直接给),相机对着同一块标定板,相机位姿变化 B(棋盘格角点估计给),未知数 X 就是手眼变换。摆十几组姿态堆起来就能解出 X,常用 Tsai-Lenz / Park-Martin / Daniilidis 等算法,OpenCV 的 cv::calibrateHandEye() 直接封装好了
    • 实操流程:末端挂标定板(或相机对着地面棋盘格)→ 摆多组姿态、逐组记录末端位姿和棋盘格角点 → 解方程得 X → 把 X 写进 TF 或标定参数
    • 但仿真可以整体跳过这一步camera_linkpanda_hand 之间的变换就写在 URDF 的 camera_joint 里(后文 3-3 相机块里那个 rpy="3.14159 -1.5708 0"),仿真里这个值精确且已知------标定求的正是它,再标一遍只是自欺欺人
  • 所以本章后续直接信任 URDF 里的安装变换;将来上真机,手眼标定是必做项,标定误差会 1:1 变成抓取误差

说人话:手眼标定就是给"眼睛"和"手"对表------相机说目标在它面前 30cm,机械臂得换算成自己该往哪个方向伸。真机装上去总有装配误差,必须标;仿真里这个"对表值"是我们亲手写进 URDF 的标准答案,所以直接跳过

3-3 插件配置以及注意事项
  • Gazebo 的深度相机插件我们用 libgazebo_ros_camera:标准深度相机插件,一个 sensor type="depth" 同时出 RGB 图像、深度图、点云 三路话题(外加各自的 camera_info),MoveIt 的 octomap 要吃的点云,就是其中 /camera/depth/points 这一路(3-4 会把它接进规划场景)
    • 但有个坑:libgazebo_ros_camera 输出的点云是 z-forward 光学约定 (深度沿 +z、+x 向右、+y 向下),而 frame_name 指向的 camera_linkx-forward 物理系(相机沿 +x 看)。两套轴系差 90°,深度被标在了与视轴垂直的方向上 → 转成 world 后,本应水平的桌面被画成一面竖直的墙(中心点落到了相机同高、前方约 0.41m 处,而不是正下方)
    • 所以需要多引入一个纯 TF 子帧 camera_optical_frame(无碰撞、无几何,就是多一个坐标系):origin 全 0、只转姿态 rpy="-1.5708 0 -1.5708",把光学 +z 转到与 camera_link 的 +x(视轴)重合、+x/+y 对齐画面右/下------点云标到这个帧上就是标准的 z-forward 光学系,最后 frame_name 指向它即可
  • src/panda_gazebo_bringup/config/panda_gazebo.urdf.xacro 里直接补上下面这一整块(放在 </robot> 之前):
xml 复制代码
    <!-- 4) 可选 RGBD 深度相机(眼在手):gazebo_camera.launch.py 用 enable_camera=true 打开。
         纯被动固定 link(无碰撞),挂 panda_hand 腕部;
         libgazebo_ros_camera.so 标准深度相机:RGB + 点云(+深度图)三路话题 -->
    <xacro:if value="$(arg enable_camera)">
        <link name="camera_link">
            <visual><geometry><box size="0.03 0.025 0.02"/></geometry></visual>
        </link>
        <joint name="camera_joint" type="fixed">
            <!-- 直接挂在 panda_hand(工具系)上 -> panda_hand->camera_link 为纯平移、yaw=0,
                 相机轴与末端执行器完全对齐(此前挂 link8 会因 hand 自带 yaw-45° 而相对手掌错开45°)。
                 位置取 hand 系 (0.078,0,0):只沿 hand 的 x 侧挂、y=0,让开手掌包络(x±32mm)。
                 rpy="3.14159 -1.5708 0":libgazebo_ros_camera 深度相机默认沿传感器系 +x 拍摄、
                 +z 朝上,pitch -90° 把 +x 转到工具轴 +z 方向、拍摄方向水平朝前;
                 roll 180° 绕拍摄轴旋转,修正画面左右颠倒 -->
            <parent link="panda_hand"/>
            <child link="camera_link"/>
            <origin xyz="0.078 0 0" rpy="3.14159 -1.5708 0"/>
        </joint>

        <!-- 光学帧:libgazebo_ros_camera 输出的点云是 ROS 光学约定(z-forward:
             深度沿 +z、+x 向右、+y 向下),frame_name 必须指向一个 z-forward 的帧。
             camera_link 是 x-forward 的物理系(相机沿 +x 看),直接当光学帧用会导致
             深度标在与视轴垂直的方向上 -> 桌面被渲染成竖直的墙。
             加一个纯 TF 子帧,rpy="-1.5708 0 -1.5708" 使光学 +z 与 camera_link +x(视轴)重合,
             同时光学 +x/+y 与画面右/下对齐,RGB 与点云方向一致。 -->
        <link name="camera_optical_frame"/>
        <joint name="camera_optical_joint" type="fixed">
            <parent link="camera_link"/>
            <child link="camera_optical_frame"/>
            <origin xyz="0 0 0" rpy="-1.5708 0 -1.5708"/>
        </joint>

        <gazebo reference="camera_link">
            <sensor type="depth" name="camera">
                <update_rate>10</update_rate>
                <camera>
                    <horizontal_fov>1.3962634</horizontal_fov>
                    <image>
                        <width>640</width><height>480</height><format>R8G8B8</format>
                    </image>
                    <clip><near>0.02</near><far>3</far></clip>
                    <depth_camera><output_type>points</output_type></depth_camera>
                </camera>
                <plugin name="camera_controller" filename="libgazebo_ros_camera.so">
                    <!-- 插件最小测量距离(必须写 <plugin> 内,Load 的 _sdf 指向 plugin):
                         min_depth 默认 0.4m,而 ready 位姿下相机垂直朝下、离桌面仅 0.39m
                         -> 桌面中心/物体全被裁成 -inf 丢点云,octomap 建不出场景。
                         缩到 0.02m 才能测近处。<clip><near> 只是 OGRE 渲染裁剪,
                         不参与点云过滤,也一并缩到 0.02 保持一致。 -->
                    <min_depth>0.02</min_depth>
                    <max_depth>3</max_depth>
                    <ros>
                        <!-- 不加 <namespace>:camera_name='camera' 已给话题加 camera/ 前缀,
                             作为顶层 /camera/;remap 键必须带前缀才匹配得上 -->
                        <!-- QoS:MoveIt 的 PointCloudOctomapUpdater(Humble) 用 SensorDataQoS()
                             订阅点云,即硬编码 BEST_EFFORT+KEEP_LAST(5),参数不可配;
                             相机默认 RELIABLE,两边不兼容 -> octomap 永远收不到点云。
                             这里把相机全部话题统一降级成 best_effort 对齐。
                             副作用:RViz 的 RGB/点云显示默认订阅 RELIABLE 会收不到,
                             需在显示属性里把 QoS 改成 Best Effort(或加转发节点)。 -->
                        <qos>
                            <reliability>best_effort</reliability>
                        </qos>
                        <remapping>camera/image_raw:=/camera/color/image_raw</remapping>
                        <remapping>camera/camera_info:=/camera/color/camera_info</remapping>
                        <remapping>camera/depth/image_raw:=/camera/depth/image_raw</remapping>
                        <remapping>camera/depth/camera_info:=/camera/depth/camera_info</remapping>
                        <remapping>camera/points:=/camera/depth/points</remapping>
                    </ros>
                    <camera_name>camera</camera_name>
                    <!-- 输出标到光学帧(z-forward)上,与插件点云约定一致 -->
                    <frame_name>camera_optical_frame</frame_name>
                </plugin>
            </sensor>
        </gazebo>
    </xacro:if>

说人话:camera_link 是"相机脸朝哪"(沿 +x 看),光学约定是"点云坐标怎么标"(深度沿 +z)。两者对不上,深度就会被画到与视线垂直的方向上------水平的桌面看起来就像一面竖直的墙。多插一个 camera_optical_frame,就是专门给点云一个正确的坐标系

3-4 对接moveit2的深度图像输入
  • 点云接进 MoveIt 走的是 octomapmove_groupPointCloudOctomapUpdater 订阅点云,把障碍物更新进 planning scene,规划时自动避障
3-4-1 QoS 对齐
  • 第一个坑在 QoS。更新器订阅点云用的是 rclcpp::SensorDataQoS(),也就是硬编码 BEST_EFFORT + KEEP_LAST(5) ,参数不可配;而相机插件默认 RELIABLE------两边不兼容,octomap 永远收不到点云
  • 解决办法:把相机所有话题统一降级成 best_effort 对齐
xml 复制代码
<!-- 相机 QoS 降级:对齐 moveit octomap 的 SensorDataQoS()(best_effort) -->
<qos>
    <reliability>best_effort</reliability>
</qos>
<remapping>camera/points:=/camera/depth/points</remapping>
  • 然后在 move_group 参数里把数据源挂到 /camera/depth/points(这个路径就是上面 remap 出来的):
python 复制代码
# gazebo_camera.launch.py 里 move_group 的 octomap 参数
'octomap_frame': 'world',
'octomap_resolution': 0.02,
'sensors': ['camera'],
'camera': {
    'sensor_plugin': 'occupancy_map_monitor/PointCloudOctomapUpdater',
    'point_cloud_topic': '/camera/depth/points',
    'max_range': 3.0,
    'padding_offset': 0.01,
    'max_update_rate': 5.0,
    'filtered_cloud_topic': 'filtered_cloud',
},
  • 副作用:RViz 的 RGB/点云显示默认订阅 RELIABLE 也收不到,所以 moveit_camera.rviz 里新增的两个显示都要把 Reliability Policy 改成 Best Effort
3-4-2 最小测量范围
  • 点云接上了,但很快会发现一个怪现象:桌面中心、物体总是建不出来,"越近越看不见"
  • 原因藏在插件源码里------点云过滤用的是插件自己的 min_depth 参数,默认 0.4m ,注意它不是 <clip><near>(那只是 OGRE 渲染裁剪,不参与点云过滤)
  • 逻辑是:深度小于 min_depth 的点,整个设成 -inf 丢弃
cpp 复制代码
// libgazebo_ros_camera.cpp 里的逻辑
impl_->min_depth_ = _sdf->Get<double>("min_depth", 0.4);  // 默认 0.4m!
if (depth > impl_->min_depth_ && depth < impl_->max_depth_) {
  /* 有效点 */
} else if (depth <= impl_->min_depth_) {
  /* 整点设 -inf 丢弃 */
}
  • 而我们 ready 位姿下相机垂直朝下:离桌面 0.39m 高,到桌面上的物体也只有约 0.38~0.41m------大片桌面和物体表面落在 0.4m 之内,于是"近处的桌面和物体被自己的相机裁掉了"
  • 注意这个参数必须写在 <plugin> :插件 Load 收到的 _sdf 指向 <plugin> 元素(camera_nameframe_name 都在这一层)。
xml 复制代码
<plugin name="camera_controller" filename="libgazebo_ros_camera.so">
    <min_depth>0.02</min_depth>
    <max_depth>3</max_depth>
</plugin>

3-5 launch启动文件
  • 把相机、octomap、QoS、光学帧都串起来,就是相机增强版 gazebo_camera.launch.py。它和 1-5 的 gazebo.launch.py 只有两处不同:xacro 映射多了 enable_camera: 'true'(3-3 的相机块才会被展开)、move_group 参数里多了 octomap 那一段(3-4-1 的数据源)。完整源码如下(大部分和 1-5 相同,只有开头映射、move_group 参数、结尾 world 声明三处是新增的):
python 复制代码
# gazebo_camera.launch.py ------ gazebo.launch.py 的相机增强版(第三期补章)
# 仅两处与 gazebo.launch.py 不同:
#   1) xacro mapping enable_camera=true -> 腕部(panda_hand)挂 RGBD 深度相机(眼在手)
#   2) move_group 增加 octomap 参数(PointCloudOctomapUpdater 吃 /camera/depth/points)
# 老 gazebo.launch.py 保持无相机、无 octomap 的原版基线。
import os
import re
import shutil

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, RegisterEventHandler
from launch.event_handlers import OnProcessExit
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from moveit_configs_utils import MoveItConfigsBuilder


def _fix_package_mesh_uris(urdf: str) -> str:
    """Gazebo classic 不解析 package:// 网格 URI(会当成 model:// 找),
    替换成 file:// 绝对路径,RViz / MoveIt 同样可用。"""
    panda_desc = get_package_share_directory('moveit_resources_panda_description')
    return urdf.replace('package://moveit_resources_panda_description', 'file://' + panda_desc)


# 真实 panda/FR3 惯量(kg*m^2,主对角线 + 质心偏移,取 7 自由度臂同架构量级)。
# 之前注入扁平默认值 mass=1.0 / inertia=0.01 比真实值轻太多,位置控制器(默认
# Kp=100)在轻惯量下形成极限环振荡 -> 关节速度发散 -> 位置变 NaN -> Gazebo 里
# 机械臂消失(RViz 用模型显示所以还在)。换成真实量级后物理稳定。
REAL_INERTIA = {
    'panda_link0': (2.3966, '0 0 0', '0.0090', '0.0115', '0.0085'),
    'panda_link1': (2.9275, '0 -0.0181 -0.0386', '0.0239', '0.0225', '0.0064'),
    'panda_link2': (2.9355, '0.0032 -0.0743 0.0088', '0.0419', '0.0251', '0.0617'),
    'panda_link3': (2.2449, '0.0407 -0.0048 -0.0290', '0.0241', '0.0197', '0.0190'),
    'panda_link4': (2.6156, '-0.0459 0.0630 -0.0085', '0.0345', '0.0289', '0.0413'),
    'panda_link5': (2.3271, '-0.0016 0.0293 -0.0973', '0.0516', '0.0479', '0.0164'),
    'panda_link6': (1.8170, '0.0597 -0.0410 -0.0102', '0.0054', '0.0141', '0.0161'),
    'panda_link7': (0.6271, '0.0045 0.0086 -0.0162', '0.0002', '0.0002', '0.0001'),
    'panda_link8': (0.0724, '0 0 0', '0.0001', '0.0001', '0.0001'),
    'panda_hand': (0.73, '0 0 0', '0.001', '0.001', '0.001'),
    'panda_leftfinger': (0.03, '0 0 0', '0.0001', '0.0001', '0.0001'),
    'panda_rightfinger': (0.03, '0 0 0', '0.0001', '0.0001', '0.0001'),
}


def _add_inertials(urdf: str) -> str:
    """moveit_resources 的 panda.urdf 是 MoveIt 精简版,link 没有 <inertial>。
    sdformat 的 URDF->SDF 转换会把无 inertial 的 link 整个丢弃(连带其 joint),
    导致 spawn 出来的模型只有 gazebo 插件块、没有任何 link/joint ------ 表现为
    gazebo_ros2_control 日志里所有关节 "Skipping joint ... not in the gazebo model"。
    给每个缺 inertial 的 link 注入 REAL_INERTIA 表里的真实惯量(比扁平默认值重,
    与控制器默认增益匹配,物理稳定不发散)。"""
    fmt = ('<inertial><mass value="{m}"/><origin xyz="{o}" rpy="0 0 0"/>'
           '<inertia ixx="{ixx}" ixy="0" ixz="0" iyy="{iyy}" iyz="0" '
           'izz="{izz}"/></inertial>')

    def _add(m):
        tag = m.group(0)
        nm = re.search(r'name="([^"]+)"', tag)
        m_, o, ixx, iyy, izz = REAL_INERTIA.get(nm.group(1),
                                                (1.0, '0 0 0', '0.01', '0.01', '0.01'))
        block = fmt.format(m=m_, o=o, ixx=ixx, iyy=iyy, izz=izz)
        # 自闭合 link(如 panda_link8):<link name="x"/> -> <link name="x">...</link>
        if tag.rstrip().endswith('/>'):
            return tag[:-2] + '>' + block + '</link>'
        return tag + block

    return re.sub(r'<link\b[^>]*>', _add, urdf)


def _strip_mimic(urdf: str) -> str:
    """URDF 里 panda_finger_joint2 的 <mimic> 让 MoveIt robot model 在状态更新时
    反复找内部名 'panda_finger_joint2_mimic' -> 每 10ms 刷一条 "Joint not found in model"。
    去掉 <mimic>:MoveIt 把它当普通被动关节(SRDF 已声明 passive_joint);
    夹爪联动改由 ros2_control 的 mimic 参数负责(Gazebo 端不受影响)。"""
    return re.sub(r'<mimic\b[^>]*/>', '', urdf)


def _swap_visual_meshes(urdf: str) -> str:
    """moveit_resources 的 panda .dae 网格在 Gazebo classic 的 OGRE 下渲染成
    白色盒子/黑色(RViz 用同一文件能渲染,是 Gazebo 特有的问题)。换成官方
    franka_description 的 FR3 网格(同为 7 自由度、文件名一致、OGRE 兼容),
    Gazebo 与 RViz 都能正确显示。碰撞网格(STL)不换。"""
    pnd = 'file:///opt/ros/humble/share/moveit_resources_panda_description/meshes/visual'
    fr3 = 'file:///opt/ros/humble/share/franka_description/meshes/robot_arms/fr3/visual'
    hand = 'file:///opt/ros/humble/share/franka_description/meshes/robot_ee/franka_hand_white/visual'
    urdf = urdf.replace(pnd, fr3)
    urdf = urdf.replace(fr3 + '/hand.dae', hand + '/hand.dae')
    urdf = urdf.replace(fr3 + '/finger.dae', hand + '/finger.dae')
    return urdf


def _fix_base(urdf: str) -> str:
    """panda.urdf 根 link 是 panda_link0、没有任何 world 固定关节(MoveIt 用 SRDF
    虚拟关节代替)。但在 Gazebo 里这意味着底座是自由体------只能靠碰撞网格搁在
    地面上,位置控制器在漂移的底座上打极限环 -> 关节抖动/偶发 NaN -> 机械臂
    抽搐/消失。官方 Gazebo 配置都会给底座加 world 固定关节,这里补上。"""
    base = ('<link name="world"/>'
            '<joint name="panda_link0_joint" type="fixed">'
            '<parent link="world"/><child link="panda_link0"/></joint>')
    # 插在 <robot ...> 之后(要在 _add_inertials/_add_materials 之后调用,
    # 这样 world link 不会被注入惯量/材质)
    return re.sub(r'(<robot[^>]*>)', r'\1' + base, urdf, count=1)


def _add_materials(urdf: str) -> str:
    """panda.urdf 的 <visual> 只有 mesh、没有 <material> 颜色,Gazebo classic
    会 fallback 到 .dae 自带材质,而这些 COLLADA 文件在 OGRE 下渲染成黑色 ->
    机械臂看起来黑/看不见。给每个 visual 注入浅灰材质即可(MoveIt/RViz 同样可用)。"""
    material = ('<material name="panda_light_grey">'
                '<color rgba="0.83 0.83 0.83 1"/></material>')

    def _add(m):
        tag = m.group(0)
        if tag.rstrip().endswith('/>'):
            return tag[:-2] + '>' + material + '</visual>'
        return tag + material

    return re.sub(r'<visual\b[^>]*>', _add, urdf)


def _fix_controller_yaml_path(urdf: str) -> str:
    """gazebo_ros2_control 把 <parameters> 当作 --params-file 交给 rcl 解析,
    workspace 路径含中文(非 ASCII)时 rcl_parse_arguments 会失败 -> 插件 Load
    失败 -> gzserver 崩溃。把 ros2_controllers.yaml 复制到 ASCII 路径 /tmp 并替换。"""
    src = os.path.join(
        get_package_share_directory('panda_gazebo_bringup'),
        'config', 'ros2_controllers.yaml')
    dst = '/tmp/panda_ros2_controllers.yaml'
    shutil.copy2(src, dst)
    return urdf.replace(src, dst)


def generate_launch_description():
    panda_bringup_dir = get_package_share_directory('panda_gazebo_bringup')

    # ---- 1) robot_description:自建 gazebo xacro(eager 生成 URDF 字符串)----
    #   mappings 全为 str -> 进程内立即 xacro.process_file,返回可修改的 URDF 字符串
    moveit_config = (
        MoveItConfigsBuilder('moveit_resources_panda')
        .robot_description(
            file_path=os.path.join(panda_bringup_dir, 'config', 'panda_gazebo.urdf.xacro'),
            mappings={'ros2_control_hardware_type': 'gazebo', 'enable_camera': 'true'},
        )
        .robot_description_semantic(file_path='config/panda.srdf')
        .trajectory_execution(file_path='config/gripper_moveit_controllers.yaml')
        .planning_pipelines(pipelines=['ompl', 'chomp', 'pilz_industrial_motion_planner'])
        .to_moveit_configs()
    )
    # 关键修复(缺一不可,否则 gzserver 崩溃/模型不可见):
    #   1) 网格 URI package:// -> file:// 绝对路径(Gazebo classic 不解析 package://)
    #   2) 参数文件复制到 ASCII 路径(中文 workspace 路径会让 rcl 解析 --params-file 失败)
    #   3) 给无 inertial 的 link 注入默认惯性(sdformat 会丢弃无 inertial 的 link 连带关节)
    #   4) 给 visual 注入浅灰材质(.dae 自带材质在 OGRE 下渲染成黑色,机械臂看不见)
    #   5) spawn 直接从文件读完整 URDF:-topic 走 /robot_description 时,10KB+ 的大消息
    #      在 FastDDS 下偶发只投递末尾片段 -> 模型无关节(关节全被插件跳过)。
    fixed_urdf = _add_materials(
        _fix_base(
            _add_inertials(
                _fix_controller_yaml_path(
                    _swap_visual_meshes(
                        _fix_package_mesh_uris(
                            _strip_mimic(
                                moveit_config.robot_description['robot_description'])))))))
    moveit_config.robot_description = {'robot_description': fixed_urdf}
    robot_urdf_file = '/tmp/panda_full.urdf'
    with open(robot_urdf_file, 'w') as f:
        f.write(fixed_urdf)

    # ---- 2) Gazebo 抓取场景(默认 pick_place.world,可 world:= 覆盖)----
    #   默认桌高/摆放按 ready 位姿 FK 定(末端 (0.307,0,0.59)、相机垂直朝下):
    #   桌面顶 z=0.20 才能让相机照到整片桌面;基线 gazebo.launch.py 仍是空世界。
    #   换场景:ros2 launch ... gazebo_camera.launch.py world:=/path/to/xxx.world
    #   (./3_depth_gazebo.sh 已支持透传该参数)
    default_world = os.path.join(panda_bringup_dir, 'config', 'pick_place.world')
    gazebo = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(get_package_share_directory('gazebo_ros'),
                         'launch', 'gazebo.launch.py')
        ),
        launch_arguments={'world': LaunchConfiguration('world')}.items(),
    )

    # ---- 3) 发布 /robot_description + 关节 TF(spawn_entity 从这里取 URDF)----
    robot_state_publisher = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        name='robot_state_publisher',
        parameters=[moveit_config.robot_description, {'use_sim_time': True}],
        output='screen',
    )

    # ---- 4) world -> panda_link0 静态 TF(MoveIt 虚拟关节规划帧是 world)----
    static_tf = Node(
        package='tf2_ros',
        executable='static_transform_publisher',
        name='static_transform_publisher',
        arguments=['0', '0', '0', '0', '0', '0', 'world', 'panda_link0'],
        output='log',
    )

    # ---- 5) move_group ----
    move_group_node = Node(
        package='moveit_ros_move_group',
        executable='move_group',
        output='screen',
        parameters=[
            moveit_config.to_dict(),
            {
                'use_sim_time': True,
                'octomap_frame': 'world',
                'octomap_resolution': 0.02,
                # MoveIt 2(Humble) octomap 是扁平结构:'sensors' 是传感器名列表,
                # 每个传感器名作前缀给 PointCloudOctomapUpdater 用。
                'sensors': ['camera'],
                'camera': {
                    'sensor_plugin': 'occupancy_map_monitor/PointCloudOctomapUpdater',
                    'point_cloud_topic': '/camera/depth/points',
                    'max_range': 3.0,
                    'point_subsample': 1,
                    'padding_offset': 0.01,
                    'padding_scale': 1.0,
                    'max_update_rate': 5.0,
                    'filtered_cloud_topic': 'filtered_cloud',
                },
            },
        ],
        arguments=['--ros-args', '--log-level', 'info'],
    )

    # ---- 6) RViz(本包 moveit_camera.rviz = 系统 moveit.rviz + RGB图像/点云显示)----
    #   相机话题是 best_effort(为 moveit octomap 设的),新增两个显示都在
    #   Topic 里把 Reliability Policy 设成了 Best Effort,否则 RViz 默认 Reliable 收不到。
    rviz_config = os.path.join(
        get_package_share_directory('panda_gazebo_bringup'),
        'config', 'moveit_camera.rviz',
    )
    rviz_node = Node(
        package='rviz2',
        executable='rviz2',
        name='rviz2',
        output='log',
        arguments=['-d', rviz_config],
        parameters=[
            moveit_config.robot_description,
            moveit_config.robot_description_semantic,
            moveit_config.planning_pipelines,
            moveit_config.robot_description_kinematics,
            {'use_sim_time': True},
        ],
    )

    # ---- 7) 把 panda spawn 进 Gazebo ----
    #   spawn_entity.py 默认只等 30s 服务就绪,Gazebo 首次启动/DDS 发现偏慢时不够,
    #   显式传 -timeout 60 放宽。
    #   模型插入 -> gazebo_ros2_control 插件加载 -> controller_manager 起在 gzserver 内
    #   从文件读 URDF(而非 -topic):见上方 fixed_urdf 的注释。
    spawn_entity = Node(
        package='gazebo_ros',
        executable='spawn_entity.py',
        arguments=['-file', robot_urdf_file, '-entity', 'panda',
                   '-timeout', '60'],
        output='screen',
    )

    # ---- 8) spawn 成功退出 = 模型已插入 = controller_manager 已建立 ----
    #   用 spawner(带 controller-manager-timeout)加载并激活三个控制器
    load_joint_state_broadcaster = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_state_broadcaster', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )
    load_arm_controller = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['panda_arm_controller', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )
    load_hand_controller = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['panda_hand_controller', '-c', '/controller_manager',
                   '--controller-manager-timeout', '30'],
        output='screen',
    )

    return LaunchDescription([
        DeclareLaunchArgument(
            'world', default_value=default_world,
            description='Gazebo world 文件;默认 pick_place.world(抓取场景),'
                        '可用 world:= 覆盖'),
        gazebo,
        robot_state_publisher,
        static_tf,
        move_group_node,
        rviz_node,
        spawn_entity,
        RegisterEventHandler(
            event_handler=OnProcessExit(
                target_action=spawn_entity,
                on_exit=[load_joint_state_broadcaster, load_arm_controller,
                         load_hand_controller],
            )
        ),
    ])
  • 上面的 launch 和 1-5 的 gazebo.launch.py 是同一副骨架,_fix_* 后处理、spawn、spawner 那段逐字相同。读者可以直接拿 1-5 的基线 launch 改:只加三处即可(enable_camera 映射、octomap 参数、world:= 声明)
  • 显示配置 moveit_camera.rviz(本包 config/ 内):在系统 moveit.rviz 基础上新增两个显示------RGB 图像和深度点云。因为相机 QoS 已降级成 best_effort(3-4-1),两个显示在 Topic 里都把 Reliability Policy 设成 Best Effort 才收得到。这里给精简版(网格 + 轨迹 + octomap 场景 + RGB/点云,够用;完整版在工作区 config/moveit_camera.rviz):
yaml 复制代码
Panels:
  - Class: rviz_common/Displays
    Name: Displays
  - Class: rviz_common/Views
    Name: Views
Visualization Manager:
  Class: ""
  Displays:
    - Class: rviz_default_plugins/Grid
      Enabled: true
      Name: Grid
      Reference Frame: <Fixed Frame>
    - Class: moveit_rviz_plugin/Trajectory
      Enabled: true
      Name: Trajectory
      Robot Description: robot_description
      Trajectory Topic: /display_planned_path
    - Class: moveit_rviz_plugin/PlanningScene
      Enabled: true
      Name: PlanningScene
      Planning Scene Topic: /monitored_planning_scene
      Robot Description: robot_description
    - Class: rviz_default_plugins/Image
      Enabled: true
      Name: Camera RGB
      Topic:
        Reliability Policy: Best Effort
        Value: /camera/color/image_raw
    - Class: rviz_default_plugins/PointCloud2
      Enabled: true
      Name: Camera PointCloud
      Topic:
        Reliability Policy: Best Effort
        Value: /camera/depth/points
      Use Fixed Frame: true
  Enabled: true
  Global Options:
    Background Color: 48; 48; 48
    Fixed Frame: panda_link0
    Frame Rate: 30
  Name: root
  Tools:
    - Class: rviz_default_plugins/MoveCamera
    - Class: rviz_default_plugins/Select
    - Class: rviz_default_plugins/FocusCamera
  Transformation:
    Current:
      Class: rviz_default_plugins/TF
  Views:
    Current:
      Class: rviz_default_plugins/Orbit
      Distance: 1.6
      Focal Point:
        X: 0
        Y: 0
        Z: 0
      Name: Current View
      Pitch: 0.4
      Target Frame: <Fixed Frame>
      Value: Orbit (rviz)
      Yaw: 0.8
    Saved: ~
Window Geometry:
  Camera RGB:
    collapsed: false
  Displays:
    collapsed: false
  Height: 900
  Width: 1400
  X: 0
  Y: 0
  • 一键脚本 3_depth_gazebo.sh 把 launch 包一层:先杀干净残留进程、source 环境,还支持可选 world 参数透传:
bash 复制代码
#!/bin/bash
# 第三期补章:一键启动 Gazebo + MoveIt + 腕部 RGBD 相机(gazebo_camera.launch.py)
#   gazebo.launch.py 的相机增强版:眼在手(panda_hand 挂 RGBD),move_group 带 octomap
#   之后再用 ./2_arm_control.sh 跑 C++ 控制节点
# 用法:
#   ./3_depth_gazebo.sh                      # 默认抓取场景(pick_place.world)
#   ./3_depth_gazebo.sh 场景.world            # 换自定义 world(相对/绝对路径均可)
set -e

# ---- 启动前清理:杀掉所有残留进程,确保从干净状态启动 ----
echo "[cleanup] 清理残留进程 (gzserver / gzclient / move_group / rviz2 / controllers ...)"
for p in gzserver gzclient gazebo; do
  pkill -x "$p" 2>/dev/null || true
done
# 进程名超过 15 字符时 pkill -x 匹配不到(如 robot_state_publisher),改用 ps 精确匹配;
# 过滤掉 grep 自身和 "bash -c",避免误杀当前 shell / 上层包装(历史教训:曾把自己杀掉)
ps aux | grep -E "ros2 launch|spawn_entity|spawner|move_group|robot_state_publisher|static_transform_publisher|controller_manager|rviz2|arm_control_demo" \
  | grep -v grep | grep -v "bash -c" \
  | awk '{print $2}' | xargs -r kill -9 2>/dev/null || true
sleep 2

# source /home/lzh/anaconda3/etc/profile.d/conda.sh
# conda deactivate
source /opt/ros/humble/setup.bash
source "$(dirname "$0")/install/setup.bash"
export LANG=C.UTF-8 LC_ALL=C.UTF-8

# 可选 world 参数透传给 launch 的 world:=(launch 默认 pick_place.world)
WORLD="${1:-}"
if [ -n "$WORLD" ]; then
  WORLD="$(realpath -m "$WORLD")"
  echo "[world] 使用自定义场景: $WORLD"
  ros2 launch panda_gazebo_bringup gazebo_camera.launch.py "world:=$WORLD"
else
  echo "[world] 使用默认抓取场景 (pick_place.world)"
  ros2 launch panda_gazebo_bringup gazebo_camera.launch.py
fi
  • 启动后打开 rviz2,可以看到左下角是 RGB 图像、深度点云,以及 octomap 八叉树

3-6 添加桌子
  • 空世界里只有地面,抓取无从谈起。我们自建一个 pick_place.world:一张矮桌 + 三个彩色目标物体,通过 launch 的 world:= 参数加载
  • 桌高不是随便定的------ready 位姿下相机垂直朝下,离目标面越近视野越小(视野半径 = 距离 * tan(40 度)),标准桌高会让桌面落在相机视野之外,所以用顶面 z=0.20 的矮桌,相机离桌面 0.39m,视野半径约 0.33m,刚好覆盖桌子表面
world 复制代码
<?xml version="1.0" ?>
<!-- pick_place.world ------ 抓取演示场景(./3_depth_gazebo.sh 用)
     设计约束来自 ready 位姿 FK:
       panda 底座在 (0,0,0),末端 panda_link8 在 (0.307,0,0.590),相机垂直朝下
       相机在 (0.385,0,0.590),垂直朝下时离目标面越高视野越小(半径 = 距离*tan(40°))
     -> 桌子必须很矮(顶面 z=0.20),否则相机照不到整片桌面、octomap 建不出场景。
     桌子放 (0.5,0):相机离桌面 0.39m,视野半径 ~0.33m,能覆盖桌子表面大部分。 -->
<sdf version="1.6">
  <world name="default">
    <!-- 太阳 + 地面(同 gazebo_ros 的 empty.world) -->
    <include><uri>model://sun</uri></include>
    <include><uri>model://ground_plane</uri></include>

    <!-- 矮桌:0.55(x) x 0.40(y) x 0.20(z),顶面 z=0.20,木色,static 防被撞动 -->
    <model name="table">
      <static>true</static>
      <pose>0.5 0 0.10 0 0 0</pose>
      <link name="table_link">
        <collision name="collision">
          <geometry><box><size>0.55 0.40 0.20</size></box></geometry>
          <surface><friction><ode><mu>1.0</mu><mu2>1.0</mu2></ode></friction></surface>
        </collision>
        <visual name="visual">
          <geometry><box><size>0.55 0.40 0.20</size></box></geometry>
          <material>
            <ambient>0.55 0.35 0.15 1</ambient>
            <diffuse>0.55 0.35 0.15 1</diffuse>
          </material>
        </visual>
      </link>
    </model>

    <!-- 目标 1:红色方块 4cm,底面略高于桌面(0.20)让它落稳 -->
    <model name="red_cube">
      <pose>0.46 -0.10 0.205 0 0 0</pose>
      <link name="link">
        <inertial>
          <mass>0.05</mass>
          <inertia ixx="1.3e-5" ixy="0" ixz="0" iyy="1.3e-5" iyz="0" izz="1.3e-5"/>
        </inertial>
        <collision name="collision">
          <geometry><box><size>0.04 0.04 0.04</size></box></geometry>
          <surface><friction><ode><mu>0.8</mu><mu2>0.8</mu2></ode></friction></surface>
        </collision>
        <visual name="visual">
          <geometry><box><size>0.04 0.04 0.04</size></box></geometry>
          <material>
            <ambient>0.85 0.15 0.15 1</ambient>
            <diffuse>0.85 0.15 0.15 1</diffuse>
          </material>
        </visual>
      </link>
    </model>

    <!-- 目标 2:绿色圆柱 r=3cm h=8cm -->
    <model name="green_cylinder">
      <pose>0.52 0.09 0.245 0 0 0</pose>
      <link name="link">
        <inertial>
          <mass>0.05</mass>
          <inertia ixx="3.8e-5" ixy="0" ixz="0" iyy="2.2e-5" iyz="0" izz="3.8e-5"/>
        </inertial>
        <collision name="collision">
          <geometry><cylinder><radius>0.03</radius><length>0.08</length></cylinder></geometry>
          <surface><friction><ode><mu>0.8</mu><mu2>0.8</mu2></ode></friction></surface>
        </collision>
        <visual name="visual">
          <geometry><cylinder><radius>0.03</radius><length>0.08</length></cylinder></geometry>
          <material>
            <ambient>0.2 0.7 0.2 1</ambient>
            <diffuse>0.2 0.7 0.2 1</diffuse>
          </material>
        </visual>
      </link>
    </model>

    <!-- 目标 3:蓝色方块 5cm -->
    <model name="blue_cube">
      <pose>0.56 -0.03 0.225 0 0 0</pose>
      <link name="link">
        <inertial>
          <mass>0.08</mass>
          <inertia ixx="3.3e-5" ixy="0" ixz="0" iyy="3.3e-5" iyz="0" izz="3.3e-5"/>
        </inertial>
        <collision name="collision">
          <geometry><box><size>0.05 0.05 0.05</size></box></geometry>
          <surface><friction><ode><mu>0.8</mu><mu2>0.8</mu2></ode></friction></surface>
        </collision>
        <visual name="visual">
          <geometry><box><size>0.05 0.05 0.05</size></box></geometry>
          <material>
            <ambient>0.15 0.4 0.8 1</ambient>
            <diffuse>0.15 0.4 0.8 1</diffuse>
          </material>
        </visual>
      </link>
    </model>
  </world>
</sdf>
  • 然后让 launch 支持 world:= 参数(默认 pick_place.world),一键脚本把场景路径透传过去:
python 复制代码
# gazebo_camera.launch.py 里 Gazebo 场景部分
default_world = os.path.join(panda_bringup_dir, 'config', 'pick_place.world')
DeclareLaunchArgument(
    'world', default_value=default_world,
    description='Gazebo world 文件;默认 pick_place.world,可用 world:= 覆盖')
gazebo = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(...),  # gazebo_ros/launch/gazebo.launch.py
    launch_arguments={'world': LaunchConfiguration('world')}.items(),
)
  • 一键脚本就是 3-5 里的 3_depth_gazebo.sh:本身不写死场景,只把参数透传给 launch 的 world:=。想换自定义场景就 ./3_depth_gazebo.sh 你的.world;想回空世界就传绝对路径 ./3_depth_gazebo.sh /opt/ros/humble/share/gazebo_ros/worlds/empty.world(脚本里 realpath 会先转成绝对路径,所以不能只写包内相对名 gazebo_ros/worlds/empty.world------那会被拼成当前目录下并不存在的路径)
3-7 测试
  • 场景就绪,依次执行三步,机械臂就会绕开桌子和物体规划过去,octomap 实时更新
bash 复制代码
./3_depth_gazebo.sh
./2_arm_control.sh
./4_send_target.sh 0.46 -0.10 0.40 1 0 0 0 # 红方块上方
  • 移动机器人,发现避开障碍物,octomap 也在实时更新

总结

  • 本文从 Gazebo 对接 MoveIt2 讲起,到 ros2_control 真正驱动机械臂、编写目标位姿控制节点,最后给腕部接上 RGBD 深度相机,把点云接进 octomap 并搭出抓取场景,整套"规划-避障-抓取"链路完整跑通
  • 核心要点回顾:
    • Gazebo 对接 MoveIt2gazebo_ros2_control 插件 + controller_manager,MoveIt 规划、Gazebo 执行
    • URDF 转 SDF 的三个坑 :无惯量丢 link、package:// 不解析、OGRE 渲染黑色,靠 launch 后处理兜底
    • 眼在手深度相机 :挂在 panda_hand 腕部,补 camera_optical_frame 解决 z-forward 点云被标到 x-forward 帧上的"竖直墙"问题
    • octomap 对接 :相机 QoS 降级 best_effort 对齐 SensorDataQoS(),数据源指到 /camera/depth/points
    • 最小测量距离 :插件的 min_depth 默认 0.4m,必须写在 <plugin> 内,缩到 0.02m 才能测到近处的桌面和物体
    • 抓取场景pick_place.world 矮桌 + 三个目标,world:= 参数可随时换场景
  • 如有错误,欢迎指出!
  • 感谢观看!
相关推荐
2601_956121971 小时前
线性DP(入门)
c++·算法·动态规划
彷徨而立1 小时前
【C/C++】多线程读写普通 int 变量的一些问题
java·c语言·c++
蒸蒸yyyyzwd2 小时前
cpp web server 面试可能问题总结
c++·笔记·八股
啊啊啊啊啊!!!!2 小时前
【c++】map和set的使用
开发语言·c++
小灰灰搞电子2 小时前
分享自己写的一个通信协议源码,支持C++和C,双包头+不定长
c语言·开发语言·c++
Brilliantwxx2 小时前
【C++】初入嵌入式C++复习-----经典面试题100道
开发语言·c++·算法·面试·职场和发展
汉克老师2 小时前
CSP-J 初赛(以满分为目标):第十六课 《STL容器与常见数据结构——C++自带的“数据结构工具箱”》
c++·csp-j·小学生·学c++编程
YQ_012 小时前
ROS 2 Humble Nav2 生命周期教程:启动流程、状态监控、超时诊断与自愈设计
linux·机器人·ros2·nav2
luj_17682 小时前
断舍离中的项目管理智慧
c语言·开发语言·c++·经验分享·算法