24 实践课-设备控制节点开发
设备控制节点开发
关联:索引
- 推荐系统:Ubuntu 22.04 + ROS2 Humble。
- 依赖(示例口径):
- MoveIt2:
ros-humble-moveit(或等价安装方式) - ros2_control:
ros-humble-ros2-control、ros-humble-ros2-controllers(或等价安装方式) - MoveIt2 配置向导:
moveit_setup_assistant(通常随 MoveIt2 安装,但必须用ros2 pkg list给证据) - 基础:
geometry_msgs、std_msgs
兼容说明:
- 本以 ROS2 Humble 为准;若你使用其他发行版,请自行替换文中包名前缀,并以
ros2 pkg list的输出作为证据。
0)快速自检:先确认 MoveIt2 包是否存在(证据优先)
source /opt/ros/humble/setup.bash
ros2 pkg list | grep -E "moveit" | head
逐行解释:
ros2 pkg list:列出当前环境可见的 ROS2 包。grep -E "moveit":筛出 MoveIt2 相关包名,作为“安装完成”的证据之一。head:避免输出过长,保留证据即可。
如果查不到(常见于未安装),按需安装(示例):
sudo apt update
sudo apt install -y ros-humble-moveit
逐行解释:
apt update:更新软件源索引。apt install -y ros-humble-moveit:安装 MoveIt2 元包(以你机器实际可安装包为准)。
0.1)快速自检:确认 ros2_control 与控制器是否存在(要“真执行”就必须有)
说明:
- 如果你只做“决策 + 证据闭环”(例如控制节点 dry-run,用
sleep模拟运动),可以不依赖 ros2_control。
先用证据确认 ros2_control 相关包是否存在:
source /opt/ros/humble/setup.bash
ros2 pkg list | grep -E "ros2_control|controller_manager|ros2_controllers" | head
逐行解释:
ros2 pkg list:列出当前环境可见的 ROS2 包。grep -E ...:筛选 ros2_control 与 controller_manager 相关包名,作为“已安装”的证据。
如果查不到(常见于未安装),按需安装(示例):
sudo apt update
sudo apt install -y ros-humble-ros2-control ros-humble-ros2-controllers ros-humble-controller-manager
逐行解释:
ros-humble-ros2-control:ros2_control 框架本体(controller_manager、硬件接口框架)。ros-humble-ros2-controllers:常用控制器集合(例如JointTrajectoryController、JointStateBroadcaster等)。ros-humble-controller-manager:控制器管理与 spawner 工具(有的系统会被依赖拉入,但建议显式安装,减少环境差异)。
安装后你还需要在“运行阶段”给出控制链路证据(后文会做):ros2 control list_controllers、ros2 topic echo --once /joint_states、以及 MoveIt2 轨迹执行动作是否存在(例如 FollowJointTrajectory)。
系统架构与接口约定(必读)
1)节点分工(推荐最小架构)
- 视觉检测节点(已有/教师提供):输出苹果品质结果(含
grade)。 - MoveIt2 bringup(由配置包启动):提供
move_group(规划)+ Planning Scene(碰撞)+ 轨迹执行能力。 - ros2_control(随 bringup 启动或单独启动):提供关节状态与轨迹控制器(对接真机或仿真)。
- 视觉分拣控制节点(本课实现):接收检测结果,完成抓取放置序列,并发布状态与失败原因。
2)接口约定(建议口径,可根据你现有视觉节点调整)
视觉输出(与 YOLOv8 视觉检测口径对齐,建议直接复用):
/sorting/perception/apple_quality:apple_quality_msgs/msg/AppleQualityArray- 每个
AppleQuality内包含grade(例如A/B/C),以及可选的diameter_mm/red_ratio/defect_score等业务字段。
控制节点输出:
-
/sorting/arm/state:std_msgs/msg/String(JSON 字符串,至少包含:seq、request_id、ts、phase、ok、message、grade) -
本讲“抓取目标方块”的抓取位姿是预置的、固定的(base_link 下的已知 Pose),不从视觉估计 3D 位姿。
-
视觉节点只提供“等级(grade)”,用于决定把方块放到哪个分拣箱(箱位 Pose 也是预置的、固定的)。
分拣箱位(预置):
- 以参数方式提供每个箱位的放置位姿(base_link 下的 Pose),例如
bins.A.pose、bins.B.pose。
- MoveIt2 demo/bringup 能启动(终端日志无关键报错)。
- RViz2 中能看到机器人模型与 Planning Scene(截图)。
- 你能证明“规划链路确实存在且可观察”(动作列表/日志/控制器反馈其一即可,但必须可复现)。
1)MoveIt2 在做什么(用一句话串起来)
MoveIt2 = “给定目标(位姿/关节角)+ 约束(姿态/路径/速度/碰撞)→ 规划器算出可行轨迹 → 轨迹交给控制器执行 → 执行结果/状态反馈用于闭环”。
你必须能说清以下四个关键词的关系:
- Planning Scene:场景几何与碰撞信息(决定“能不能走”)。
- Planning Pipeline:规划器与请求适配(决定“怎么算”)。
- Trajectory Execution:轨迹执行与控制器交互(决定“能不能动起来”)。
- TF/Frames:坐标系一致性(决定“目标在哪里、姿态朝哪”)。
- 目标表达是否正确:目标坐标系是什么?目标是否在可达空间?姿态是否奇异?
- 场景是否合理:是否发生自碰撞/环境碰撞?附加物体是否误判为碰撞?
- 规划配置是否匹配:planning group 名称是否正确?关节限制/速度缩放是否过小?
- 执行链路是否通:控制器是否启动?
FollowJointTrajectory是否可用?
0)复用上学期机械臂 URDF(课程资源)
本复用课程资料中的机械臂模型(上一学期已验证通过;URDF 与关节命名不依赖发行版,ROS2 Humble 可直接复用):
- URDF 文件:
机械臂定点抓取/3.机械臂URDF建模/urdf/robot_arm.urdf - 关节命名(后续 ros2_control 与 MoveIt2 配置必须一致):
joint1..joint6、hand_right_joint、hand_left_joint(其中hand_left_joint为 mimic 关节) - 基座 link:
base_link;夹爪基座 link:gripper_base_link
建议做法:创建一个“机器人描述包”并放入该 URDF,保证后续工具能通过 package:// 路径找到模型。
source /opt/ros/humble/setup.bash
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python --license MIT robot_arm_description
mkdir -p robot_arm_description/urdf
mkdir -p robot_arm_description/config
逐行解释:
robot_arm_description:只负责存放 URDF/mesh 等描述资源,不承载控制逻辑。urdf/:放robot_arm.urdf;若未来加入网格文件,可再建meshes/。
把课程资料中的 robot_arm.urdf 复制到 robot_arm_description/urdf/robot_arm.urdf 后,编译使其在环境中可见:
cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
工程规范补全(否则后续 launch/配置文件可能找不到):
- 如果你把
urdf/、config/、launch/放在robot_arm_description包内,需要在robot_arm_description/setup.py的data_files中把这些目录安装到share/<pkg>/。
示例(片段,按你包的 package_name 与 imports 补齐即可):
data_files=[
# 注册资源索引:让 `ros2 pkg` 能识别这个包
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
# 安装 package.xml:ament/colcon 运行时需要它来解析包信息
('share/' + package_name, ['package.xml']),
# 安装 URDF:让 launch 里能通过 get_package_share_directory() 找到模型文件
# 说明:glob 来自 `from glob import glob`
('share/' + package_name + '/urdf', glob('urdf/*.urdf')),
# 安装 ros2_control 控制器配置:controller_manager 读取 YAML
('share/' + package_name + '/config', glob('config/*.yaml')),
# 安装 launch:让你能 `ros2 launch <pkg> <file>.launch.py`
('share/' + package_name + '/launch', glob('launch/*.py')),
]
1)用 ros2_control 跑通“能执行到控制器”的最小链路(推荐先做)
本步骤目标是得到两条证据:控制器 active、FollowJointTrajectory 接口存在。
1)控制器配置文件(示例,参考课程资料的标准写法):
arm_controllers.yaml(放到你的描述包或 bringup 包的 config/ 下):
controller_manager:
ros__parameters:
update_rate: 20 # 控制循环频率(Hz),过低会影响跟踪质量,过高对 CPU 要求更高
arm_controller: # 控制器实例名(后续 spawner/ros2 control 都用这个名字)
type: joint_trajectory_controller/JointTrajectoryController # 轨迹控制器:对外提供 FollowJointTrajectory
hand_controller:
type: forward_command_controller/ForwardCommandController # 前向命令控制器:直接写某个接口(这里用 position)
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster # 把关节状态发布到 /joint_states(RViz/MoveIt2 常用)
arm_controller:
ros__parameters:
joints:
# 关节名必须与 URDF 完全一致(拼写/大小写必须一致),否则控制器会报错无法激活
- joint1
- joint2
- joint3
- joint4
- joint5
- joint6
command_interfaces:
- position # 发送给硬件/模拟硬件的“命令接口”(这里以位置控制为例)
state_interfaces:
- position # 从硬件读取的“状态接口”(至少要有 position 才能做状态反馈)
- velocity # velocity 可选,但很多控制器/可视化会用到
allow_nonzero_velocity_at_trajectory_end: true
# 说明:true 表示允许轨迹结束时速度不严格回到 0(更容易让轨迹顺利结束,但可能带来轻微残余速度)
hand_controller:
ros__parameters:
joints:
- hand_right_joint
interface_name: position # hand_controller 将写 hand_right_joint 的 position 接口
把 robot_arm.urdf 复制为 robot_arm_ros2_control.urdf,在 </robot> 前添加:
<!--
ros2_control 配置块(FakeSystem):
- 用 mock_components/GenericSystem 模拟硬件,便于在 RViz/课堂环境先把链路跑通
- 真机时需要替换为对应硬件驱动插件(HardwareInterface)
-->
<ros2_control name="FakeSystem" type="system">
<hardware>
<plugin>mock_components/GenericSystem</plugin>
</hardware>
<!-- 每个关节都要声明 command/state 接口;控制器激活时会检查接口是否匹配 -->
<joint name="joint1">
<command_interface name="position" />
<!-- initial_value 用于模拟硬件的初始关节位置 -->
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="joint2">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="joint3">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="joint4">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="joint5">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="joint6">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<joint name="hand_right_joint">
<command_interface name="position" />
<state_interface name="position"><param name="initial_value">0</param></state_interface>
<state_interface name="velocity" />
</joint>
<!-- 说明:hand_left_joint 在 URDF 里是 mimic 关节,通常不作为独立驱动关节,因此这里不单独声明 -->
</ros2_control>
关键解释:
mock_components/GenericSystem:模拟硬件,便于先把 MoveIt2→控制器链路打通;真机需要替换为硬件驱动插件。
3)准备一个最小启动文件(bringup)
你需要同时启动 robot_state_publisher 与 ros2_control 的 controller_manager,并生成控制器。
- 方式 B:单独创建
robot_arm_bringup包,把 launch 放到robot_arm_bringup/launch/(工程更规范)
示例启动文件 robot_arm_ros2_control.launch.py(放到你选择的包的 launch/ 目录):
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
# 通过 ament 索引找到包的 share 目录(要求 setup.py 已把 urdf/config 安装到 share/<pkg>/)
pkg_share = get_package_share_directory("robot_arm_description")
urdf_path = f"{pkg_share}/urdf/robot_arm_ros2_control.urdf"
controllers_path = f"{pkg_share}/config/arm_controllers.yaml"
# robot_description 是 URDF 文本;robot_state_publisher、ros2_control_node、MoveIt2 都依赖它
robot_description = open(urdf_path, encoding="utf-8").read()
# 发布 TF:基于 robot_description + /joint_states 生成 TF 树
# 注意:/joint_states 由 joint_state_broadcaster 发布(需要控制器激活)
robot_state_publisher = Node(
package="robot_state_publisher",
executable="robot_state_publisher",
name="robot_state_publisher",
output="both",
parameters=[{"robot_description": robot_description}],
)
# controller_manager 节点:负责加载硬件(FakeSystem)并运行控制循环
# 关键:parameters 同时传 robot_description 与 controllers.yaml(否则控制器无法按 YAML 配置加载)
ros2_control_node = Node(
package="controller_manager",
executable="ros2_control_node",
output="both",
parameters=[{"robot_description": robot_description}, controllers_path],
)
# spawner:通过 controller_manager 的服务加载并激活控制器
# 顺序:通常先 joint_state_broadcaster,再 arm_controller/hand_controller(便于先有 /joint_states)
spawner = Node(
package="controller_manager",
executable="spawner",
arguments=["joint_state_broadcaster", "arm_controller", "hand_controller"],
output="screen",
)
# RViz2:可视化与 MoveIt2 交互(本 launch 不加载固定 rviz 配置,课堂可按需补充 -d)
rviz2 = Node(
package="rviz2",
executable="rviz2",
output="log",
)
return LaunchDescription([robot_state_publisher, ros2_control_node, spawner, rviz2])
工程规范提示(避免“文件找不到/不被安装”):
- 若你把
arm_controllers.yaml与robot_arm_ros2_control.urdf放在robot_arm_description包内,需要在setup.py的data_files中把config/与urdf/安装到share/<pkg>/,否则get_package_share_directory()找不到。
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
# 方式 A:launch 放在 robot_arm_description 包内
ros2 launch robot_arm_description robot_arm_ros2_control.launch.py
# 或方式 B:launch 放在 robot_arm_bringup 包内
# ros2 launch robot_arm_bringup robot_arm_ros2_control.launch.py
ros2 control list_controllers
ros2 action list -t | grep FollowJointTrajectory
ros2 topic echo --once /joint_states
2)用 moveit_setup_assistant 生成 MoveIt2 配置包(推荐做法)
适用情况:
- 你只有机械臂 URDF/xacro(或机器人描述包),但没有现成的 MoveIt2 配置包。
- 你希望把 ros2_control 控制器与 MoveIt2 的轨迹执行链路一次性对齐(少走弯路)。
快捷复用(可选):
- 课程资料中提供了已生成的配置包压缩包:
机械臂定点抓取/6.MoveIt2 Asistant配置机械臂/robot_arm_config.zip - 你可以将其解压到
~/ros2_ws/src/,再colcon build --symlink-install后直接启动demo.launch.py,用于对照验证你的配置结果是否一致。
先确认 setup assistant 是否存在(证据):
source /opt/ros/humble/setup.bash
ros2 pkg executables moveit_setup_assistant
逐行解释:
ros2 pkg executables moveit_setup_assistant:列出该包的可执行程序名称。若输出为空,说明未安装或环境未 source。
启动 setup assistant(课程资料口径):
ros2 launch moveit_setup_assistant setup_assistant.launch.py
逐行解释:
- setup assistant 是 GUI 工具,典型流程是:导入
robot_arm.urdf→ 生成自碰撞矩阵 → 添加虚拟关节(world→base_link,fixed)→ 添加规划组arm(joint1..joint6)与hand(hand joints)→ 标记末端执行器 → 配置 ros2_control 与 controllers → 生成一个配置包(建议命名robot_arm_config或robot_arm_moveit_config)。
重要提醒(避免走弯路):
- 导入 URDF 时优先使用原始
robot_arm.urdf(未添加 ros2_control 的版本);在 setup assistant 的“ros2_control URDF 修改”步骤中再自动添加接口配置。 - 在 “ros2_controllers / MoveIt Controllers” 页面中,点进每个控制器后必须点 Save 再退出,否则生成的 YAML 可能不生效。
生成配置包后,你需要重点核对 3 件事(否则“能规划但不能动”):
- SRDF 的 planning group 与实际关节一致(名称完全匹配)。
- TF/robot_state_publisher 正常发布(RViz2 能看到完整模型与关节状态变化)。
1)启动 demo(示例口径)
source /opt/ros/humble/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 launch robot_arm_config demo.launch.py
逐行解释:
source /opt/ros/humble/setup.bash:加载 ROS2 Humble 环境。source ~/ros2_ws/install/setup.bash:加载工作空间 overlay,让系统能找到你生成的配置包。demo.launch.py:典型会启动 robot_state_publisher、move_group、RViz2,以及(如果你启用)ros2_control 控制器相关节点。
1.1)demo.launch.py 与 ros2_control bringup 的关系(必须说清)
结论先行:
robot_arm_config/demo.launch.py的职责是“MoveIt2 demo + RViz2 演示”。- 是否同时启动
controller_manager(ros2_control_node)与控制器(spawner),取决于你生成配置包时的控制器配置是否完整,以及 demo.launch.py 是否包含相关节点。
你必须用证据判断 demo.launch.py 是否已经负责启动 ros2_control:
# 证据 1:是否存在 controller_manager 节点
ros2 node list | grep -E "controller_manager|ros2_control"
# 证据 2:是否能看到控制器 active
ros2 control list_controllers
- 如果
ros2 control list_controllers能看到joint_state_broadcaster、arm_controller(或同类轨迹控制器)为 active:说明 demo.launch.py 已经把 ros2_control 也启动起来了,你不需要再单独跑“最小 ros2_control bringup”,避免重复启动 controller_manager。 - 如果
ros2 control list_controllers报错/为空、或 node list 中没有 controller_manager:说明 demo.launch.py 没有启动 ros2_control(或启动失败),需要你把“最小 ros2_control bringup”与 demo.launch.py 组合起来。
(可选)直接检查 demo.launch.py 是否包含 ros2_control 相关节点(证据更直观):
pkg_prefix=$(ros2 pkg prefix robot_arm_config)
launch_dir="$pkg_prefix/share/robot_arm_config/launch"
grep -R -n -E "ros2_control_node|controller_manager|spawner" "$launch_dir" | head
逐行解释:
- 通过
ros2 pkg prefix找到已安装的配置包路径,再在launch/里检索关键字。 - 若能搜到
ros2_control_node或spawner,基本可以断定 demo.launch.py 在管 ros2_control bringup。
1.2)两种组合方式(demo 是否带 ros2_control,分别怎么做)
方式 A(推荐):demo.launch.py 已经带 ros2_control
- 只运行
ros2 launch robot_arm_config demo.launch.py
方式 B:demo.launch.py 不带 ros2_control(或带但启动失败)
- 目标:把“最小 ros2_control bringup”与 “MoveIt2 demo”合并成一次启动,且只存在一个 controller_manager
- 做法:新建一个“组合 launch”(放到你自建的 bringup 包里),用 Include 把 demo.launch.py 拉进来,同时补上 ros2_control_node 与 spawner
组合 launch 示例 robot_arm_moveit_with_control.launch.py(示例片段,按你包名调整):
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node
def generate_launch_description():
demo_pkg = get_package_share_directory("robot_arm_config")
demo_launch = PythonLaunchDescriptionSource(f"{demo_pkg}/launch/demo.launch.py")
desc_pkg = get_package_share_directory("robot_arm_description")
urdf_path = f"{desc_pkg}/urdf/robot_arm_ros2_control.urdf"
controllers_path = f"{desc_pkg}/config/arm_controllers.yaml"
robot_description = open(urdf_path, encoding="utf-8").read()
ros2_control_node = Node(
package="controller_manager",
executable="ros2_control_node",
output="both",
parameters=[{"robot_description": robot_description}, controllers_path],
)
spawner = Node(
package="controller_manager",
executable="spawner",
arguments=["joint_state_broadcaster", "arm_controller", "hand_controller"],
output="screen",
)
return LaunchDescription(
[
IncludeLaunchDescription(demo_launch),
ros2_control_node,
spawner,
]
)
组合方式的关键点(必须满足,否则会“规划能跑但执行失败/或系统冲突”):
-
只允许一个 controller_manager(不要重复启动
ros2_control_node)。 -
demo.launch.py 若已经启动了 controller_manager,则不要再用方式 B;否则会出现控制器冲突或报错。
-
robot_state_publisher / RViz2 也尽量避免重复启动;重复启动虽不一定报错,但会导致 TF、显示与控制链路排查变复杂。
-
robot_description必须一致:controller_manager 与 move_group/RViz2 使用的 URDF/SRDF 若不是同一套,会出现“能规划但执行关节名不匹配/控制器接口找不到”等问题。 -
rviz2中能看到机械臂模型与规划交互面板。 -
MoveIt2 相关节点可见:
ros2 node list | grep -E "move_group|rviz"(截图或复制关键行即可)。
2)用 CLI 给出“规划链路存在”的证据(推荐做法)
ros2 action list | grep -E "follow_joint_trajectory|move_group|execute"
逐行解释:
ros2 action list:列出当前系统中的动作接口(设备控制栈通常大量使用 action)。grep ...:筛选轨迹执行/MoveGroup 相关动作名(不同 bringup 命名可能不同,以你的输出为准)。
你要验证两件事:控制器是否在跑、MoveIt2 的轨迹是否能“落到控制器”。
1)控制器状态(证据):
ros2 control list_controllers
逐行解释:
- 正常情况下应至少看到
joint_state_broadcaster(active)以及某个轨迹控制器(例如joint_trajectory_controller,active)。 - 如果没有轨迹控制器,MoveIt2 可能能规划但无法执行。
2)关节状态是否发布(证据):
ros2 topic list -t | grep joint_states
ros2 topic echo --once /joint_states
逐行解释:
/joint_states是很多可视化与规划模块的基础输入,没有它 RViz2 会“卡死在初始姿态”或 TF 异常。
3)轨迹执行接口是否存在(证据):
ros2 action list -t | grep -E "FollowJointTrajectory|ExecuteTrajectory"
逐行解释:
FollowJointTrajectory常见于 ros2_control 的轨迹控制器。ExecuteTrajectory常见于 MoveIt2 的轨迹执行链路。- 名称与是否存在以你的 bringup 输出为证据。
- 写出你 demo 的 planning group 名称与 end-effector link 名称(用 RViz2 / 参数 / 文档查证据,不允许拍脑袋)。
- 让机械臂做一次“关节空间目标”规划并截图(只要规划成功即可,是否真实执行以你环境为准)。
1)抓取位姿的三段式(建议统一口径)
- 预抓取(pre-grasp):距离物体一定偏移,姿态已对齐,便于安全接近。
- 抓取(grasp):末端到达夹爪闭合点(或吸盘接触点)。
- 撤退(retreat):夹住物体后沿安全方向退回,避免碰撞。
2)坐标系与姿态:你必须能回答的三个问题
- 预置方块的抓取位姿在哪个坐标系下定义?(建议统一为
base_link) - 末端执行器目标姿态用哪个 link 表达?(ee_link / tool0,以你的 MoveIt2 配置为证据)
- 抓取位姿偏移(pregrasp/retreat)是沿哪个方向定义的?(建议沿末端坐标系的接近轴,而不是 world 固定轴)
输入:
- 物体位姿
T_base_object(base_link 下的 object pose) - 夹爪期望朝向规则(例如:夹爪 z 轴朝向物体表面法向;夹爪 x 轴指向接近方向)
- 偏移量:预抓取距离
d_pre、撤退距离d_retreat
输出:
T_base_pregrasp、T_base_grasp、T_base_retreat
示例:用“沿物体法向方向”做预抓取偏移(概念示意)
T_base_pregrasp = T_base_grasp ⊕ translate(-d_pre, 0, 0) (在夹爪自身坐标系下做偏移)
T_base_retreat = T_base_grasp ⊕ translate(+d_retreat, 0, 0)
解释要点:
- 偏移最好在“末端/夹爪坐标系”下做,而不是直接在 base_link 下沿固定轴偏移,否则容易出现“方向不对”。
- 如果你的物体位姿来自相机,需要先做
T_base_object = T_base_camera ⊕ T_camera_object,其中两者都必须能通过 TF 查到证据。
(可选拓展)如果你未来把“抓取目标位姿”也交给视觉估计(从 2D 检测升级到 3D 位姿),才需要引入 TF2 把相机坐标系下的位姿变换到base_link。本讲为降低变量,抓取位姿采用预置固定点。
4)姿态规划难点清单(遇到就按证据排查)
-
可达性:目标姿态在几何上可达,但关节限制导致不可达。
-
奇异位姿:接近奇异点导致轨迹抖动或规划失败。
-
碰撞约束:夹爪/手腕与桌面/箱体发生碰撞(Planning Scene 未正确建模也会导致误判)。
-
多解选择:同一末端位姿可能有多组关节解,规划器选择不稳定导致成功率波动。
-
能根据
grade(A/B/C)选择箱位,并让机械臂至少完成一次“去抓取点→闭合夹爪→去箱位→张开夹爪”的定点抓取闭环。 -
路线 B(扩展:与《11. 机械臂物品抓取仿真》一致):沿用 MoveIt2 Python API(MoveItPy)的用法,用 MoveIt2 对同一组“预置关节角目标”做 plan+execute,再由 ros2_control 控制器执行轨迹;这样既保持目标简单(不是 pose IK),又能让学生看到 MoveIt2 在控制链路中的真实作用。
1)接口建议(与前序“服务/动作选型”衔接)
- 抓取放置任务是长时任务:工程上建议用 Action(可反馈/可取消)。
本采用“品质 Topic 输入 + 对外 PickPlace Action(核心任务接口)+ 状态 Topic 输出 + 可选使能服务”的口径:
-
视觉输入:
/sorting/perception/apple_quality(apple_quality_msgs/msg/AppleQualityArray) -
可选使能服务:
/sorting/arm/enable(std_srvs/srv/SetBool) -
抓取放置动作(对外):
/sorting/arm/pick_place(sorting_arm_interfaces/action/PickPlace) -
状态话题:
/sorting/arm/state(std_msgs/msg/String,建议输出 JSON,字段统一且可追溯) -
视觉 Topic 默认用于“自动触发任务”:收到新鲜
grade后自动发送/sorting/arm/pick_place动作目标(可用参数关闭,改为手动发 action goal)。 -
之所以保留 Topic 输入而不强制“视觉也用 Action”,是为了与 YOLOv8 输出保持最小耦合;对外统一用 Action 承载长时任务语义(feedback/cancel/result)。
2)创建控制包(Python 版本,最小可跑通骨架)
source /opt/ros/humble/setup.bash
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create sorting_arm_interfaces --build-type ament_cmake
ros2 pkg create sorting_arm_control --build-type ament_python --dependencies rclpy std_srvs std_msgs apple_quality_msgs control_msgs trajectory_msgs sorting_arm_interfaces
逐行解释:
sorting_arm_interfaces:专门存放 action/msg/srv 定义,工程规范更清晰,避免实现包被迫依赖 rosidl。ros2 pkg create ... --build-type ament_python:创建 Python 功能包,便于快速迭代。--dependencies ...:声明基础依赖(服务、状态话题、品质结果消息、轨迹控制 action、以及你自己的 task action)。
在 sorting_arm_interfaces/action/ 下创建动作文件 PickPlace.action(示例):
string grade
---
bool success
int32 request_id
string message
---
int32 request_id
string phase
string message
补齐接口包的构建配置(否则 action 不会生成):
1)sorting_arm_interfaces/package.xml 关键依赖(示例):
<buildtool_depend>ament_cmake</buildtool_depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
2)sorting_arm_interfaces/CMakeLists.txt 生成 action(示例):
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"action/PickPlace.action"
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()
构建后你应能看到(证据):
ros2 interface show sorting_arm_interfaces/action/PickPlace
ros2 action list -t | grep pick_place
建议文件路径(你需要把代码放到这里,ros2 run 才能找到):
~/ros2_ws/src/sorting_arm_control/sorting_arm_control/vision_pick_place_controller.py
入口点配置(~/ros2_ws/src/sorting_arm_control/setup.py,把可执行名与 Python 模块绑定):
entry_points={
'console_scripts': [
'vision_pick_place_controller = sorting_arm_control.vision_pick_place_controller:main',
],
},
逐段解释:
vision_pick_place_controller:你希望在命令行运行的可执行名(对应ros2 run sorting_arm_control vision_pick_place_controller)。sorting_arm_control.vision_pick_place_controller:main:Python 模块路径与入口函数,要求文件名/函数名完全一致,否则会出现“找不到入口点”的报错。
3)视觉分拣控制节点骨架(重点看“状态闭环”和“证据输出”)
说明:下面这份骨架对应上文“路线 A(保底、变量最少)”——直接调用 ros2_control 控制器接口完成定点抓取闭环;如果你要按《11. 机械臂物品抓取仿真》的思路把 MoveIt2 引入执行,请看后面的“3.2)用 MoveIt2 执行预置关节目标”。
import json
import time
import rclpy
from rclpy.action import ActionClient, ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup, ReentrantCallbackGroup
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from std_srvs.srv import SetBool
from std_msgs.msg import String, Float64MultiArray
from apple_quality_msgs.msg import AppleQualityArray
from control_msgs.action import FollowJointTrajectory
from trajectory_msgs.msg import JointTrajectoryPoint
from sorting_arm_interfaces.action import PickPlace
class VisionPickPlaceController(Node):
def __init__(self):
super().__init__('vision_pick_place_controller')
self._cb_reentrant = ReentrantCallbackGroup()
self._cb_enable_service = MutuallyExclusiveCallbackGroup()
self._cb_auto_client = MutuallyExclusiveCallbackGroup()
# 使能开关(急停/防误触发):data=false 时不启动新任务
self._enable_srv = self.create_service(SetBool, '/sorting/arm/enable', self.on_enable, callback_group=self._cb_enable_service)
# 订阅视觉品质结果(来自 YOLOv8 示例中的 /sorting/perception/apple_quality)
# QoS 这里用默认 depth=10(默认够用);工程里可根据频率/可靠性调整 QoS
self._quality_sub = self.create_subscription(AppleQualityArray, '/sorting/perception/apple_quality', self.on_quality, 10)
# 状态发布:用 JSON 字符串承载结构化字段(工程更推荐自定义 msg)
self._state_pub = self.create_publisher(String, '/sorting/arm/state', 10)
# 对外任务动作:抓取放置任务应采用 Action(可反馈/可取消/可复盘)
self.declare_parameter('pick_place_action_name', '/sorting/arm/pick_place')
self.declare_parameter('auto_goal_from_vision', True)
self._pick_place_action_name = str(self.get_parameter('pick_place_action_name').value)
self._auto_goal_from_vision = bool(self.get_parameter('auto_goal_from_vision').value)
# 运行状态
self._enabled = True
self._busy = False
# 证据字段:seq 用于全局递增序号,request_id 用于串联同一次任务的多条状态
self._seq = 0
self._request_id = 0
# 最新视觉等级(grade)缓存:课堂用“取第一颗苹果”简化
self._latest_grade = None
# 过期窗口:如果视觉消息太旧,就不触发(避免网络抖动/旧结果导致误动作)
self._stale_sec = 1.0
self._last_quality_time = time.time()
# 运动执行接口(ros2_control)
# 说明:JointTrajectoryController 默认提供 action:/<controller_name>/follow_joint_trajectory
self._arm_action = ActionClient(self, FollowJointTrajectory, '/arm_controller/follow_joint_trajectory', callback_group=self._cb_reentrant)
self._hand_pub = self.create_publisher(Float64MultiArray, '/hand_controller/commands', 10)
self._pick_place_server = ActionServer(
self,
PickPlace,
self._pick_place_action_name,
execute_callback=self.execute_pick_place,
goal_callback=self.on_goal,
cancel_callback=self.on_cancel,
callback_group=self._cb_reentrant,
)
self._auto_goal_client = ActionClient(self, PickPlace, self._pick_place_action_name, callback_group=self._cb_auto_client)
# 定点抓取参数(课堂要求:至少做到“按 grade 去不同箱位”的定点抓取)
# 关节顺序必须与控制器 joints 配置一致(示例为 joint1..joint6)
self._arm_joint_names = ['joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6']
# 你需要把这些关节角替换为你自己的“抓取点/箱位点”(从 RViz 执行后读取 /joint_states 获取证据)
self._ready_joints = [0.0, -0.3643, -0.6419, -0.8154, -0.9541, -1.6828]
self._pick_joints = [0.0, -0.35, -0.70, -0.70, -1.10, 1.55]
self._bin_joints_by_grade = {
"A": [0.4, -0.2, -0.8, -0.6, -1.1, 1.55],
"B": [0.0, -0.2, -0.8, -0.6, -1.1, 1.55],
"C": [-0.4, -0.2, -0.8, -0.6, -1.1, 1.55],
}
self._arm_move_time_sec = 2.0
self._gripper_open_pos = 0.0
self._gripper_close_pos = 0.05
def publish_state(self, request_id: int, phase: str, ok: bool, message: str, grade: str | None = None):
# 统一状态输出口径:seq/request_id/ts/phase/ok/message/grade
# 其中 phase 用于“状态机阶段”,ok 用于快速判断成功/失败
self._seq += 1
payload = {
"seq": self._seq,
"request_id": request_id,
"ts": time.time(),
"phase": phase,
"ok": ok,
"message": message,
"grade": grade,
}
msg = String()
msg.data = json.dumps(payload, ensure_ascii=False)
self._state_pub.publish(msg)
self.get_logger().info(msg.data)
def publish_feedback(self, goal_handle, request_id: int, phase: str, message: str):
fb = PickPlace.Feedback()
fb.request_id = int(request_id)
fb.phase = phase
fb.message = message
goal_handle.publish_feedback(fb)
def on_goal(self, goal_request) -> GoalResponse:
grade = str(goal_request.grade).strip().upper()
if not grade:
return GoalResponse.REJECT
if not self._enabled or self._busy:
return GoalResponse.REJECT
return GoalResponse.ACCEPT
def on_cancel(self, goal_handle) -> CancelResponse:
return CancelResponse.ACCEPT
def send_auto_goal(self, grade: str):
if not self._auto_goal_client.wait_for_server(timeout_sec=1.0):
return
goal = PickPlace.Goal()
goal.grade = grade
self._auto_goal_client.send_goal_async(goal)
def send_arm_joint_goal(self, request_id: int, joints: list[float], phase: str, grade: str):
if len(joints) != len(self._arm_joint_names):
raise ValueError("joint length mismatch")
if not self._arm_action.wait_for_server(timeout_sec=2.0):
raise RuntimeError("arm_controller action server not available")
goal = FollowJointTrajectory.Goal()
goal.trajectory.joint_names = list(self._arm_joint_names)
point = JointTrajectoryPoint()
point.positions = list(joints)
point.time_from_start = Duration(seconds=float(self._arm_move_time_sec)).to_msg()
goal.trajectory.points = [point]
self.publish_state(request_id, phase, True, f"send FollowJointTrajectory: {joints}", grade=grade)
send_future = self._arm_action.send_goal_async(goal)
t0 = time.time()
while rclpy.ok() and not send_future.done():
if (time.time() - t0) > 3.0:
raise TimeoutError("trajectory goal response timeout")
time.sleep(0.01)
arm_goal_handle = send_future.result()
if not arm_goal_handle.accepted:
raise RuntimeError("trajectory goal rejected")
result_future = arm_goal_handle.get_result_async()
t1 = time.time()
while rclpy.ok() and not result_future.done():
if (time.time() - t1) > 10.0:
raise TimeoutError("trajectory result timeout")
time.sleep(0.02)
result = result_future.result()
status = getattr(result, "status", None)
self.publish_state(request_id, phase, True, f"trajectory done status={status}", grade=grade)
def set_gripper(self, request_id: int, position: float, phase: str, grade: str):
msg = Float64MultiArray()
msg.data = [float(position)]
self._hand_pub.publish(msg)
self.publish_state(request_id, phase, True, f"hand_controller command={position}", grade=grade)
def on_enable(self, request, response):
# SetBool.srv:request.data 为 bool
self._enabled = bool(request.data)
response.success = True
response.message = "enabled" if self._enabled else "disabled"
# 注意:这里用当前 request_id 输出 enable 事件,仅作为证据;不代表启动/结束任务
self.publish_state(self._request_id, "enable", True, response.message)
return response
def on_quality(self, msg: AppleQualityArray):
# 视觉消息里可能包含多个苹果;课堂先取第一个作为决策输入
if not msg.apples:
return
grade = msg.apples[0].grade.strip().upper()
if not grade:
return
self._latest_grade = grade
self._last_quality_time = time.time()
if self._auto_goal_from_vision:
self.maybe_start_job()
def maybe_start_job(self):
# 触发条件:已使能、当前不忙、grade 非空、视觉数据未过期
if not self._enabled or self._busy:
return
if not self._latest_grade:
return
if (time.time() - self._last_quality_time) > self._stale_sec:
return
self.send_auto_goal(self._latest_grade)
def execute_pick_place(self, goal_handle):
grade = str(goal_handle.request.grade).strip().upper()
self._busy = True
self._request_id += 1
request_id = self._request_id
result = PickPlace.Result()
result.request_id = int(request_id)
try:
self.publish_state(request_id, "start", True, "job started", grade=grade)
self.publish_feedback(goal_handle, request_id, "start", "job started")
if grade not in self._bin_joints_by_grade:
raise ValueError(f"unknown grade: {grade}")
self.publish_feedback(goal_handle, request_id, "ready", "move to ready")
self.send_arm_joint_goal(request_id, self._ready_joints, "ready", grade)
self.set_gripper(request_id, self._gripper_open_pos, "open_gripper", grade)
self.publish_feedback(goal_handle, request_id, "grasp", "move to pick")
self.send_arm_joint_goal(request_id, self._pick_joints, "grasp", grade)
self.set_gripper(request_id, self._gripper_close_pos, "close_gripper", grade)
self.publish_feedback(goal_handle, request_id, "place", f"move to bin {grade}")
place_joints = self._bin_joints_by_grade[grade]
self.send_arm_joint_goal(request_id, place_joints, "place", grade)
self.set_gripper(request_id, self._gripper_open_pos, "release", grade)
self.send_arm_joint_goal(request_id, self._ready_joints, "done", grade)
goal_handle.succeed()
result.success = True
result.message = "ok"
return result
except Exception as e:
msg = f"{type(e).__name__}: {e}"
self.publish_state(request_id, "error", False, msg, grade=grade)
self.publish_feedback(goal_handle, request_id, "error", msg)
goal_handle.abort()
result.success = False
result.message = msg
return result
finally:
self._busy = False
def main(args=None):
rclpy.init(args=args)
node = VisionPickPlaceController()
executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(node)
executor.spin()
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
逐段解释(你要能口头讲清):
- 对外任务接口采用 Action:
/sorting/arm/pick_place(sorting_arm_interfaces/action/PickPlace),具备 feedback/result/cancel 语义,符合“抓取放置=长时任务”的工程规范。 - 视觉消息默认用于自动触发:收到新鲜
grade后,节点会自动发送 action goal(可通过参数关闭自动触发,改为手动ros2 action send_goal)。 request_id:每次任务自增,用于把“同一次抓取放置过程”的多条状态串起来,便于追溯与复盘。- 本代码用
MultiThreadedExecutor:保证 action server、action client(自动触发)与控制器动作客户端不会互相阻塞。 - 本代码直接驱动 ros2_control 的
arm_controller/follow_joint_trajectory(Action)与hand_controller/commands(Topic),因此能做到“根据 grade → 去不同箱位”的定点抓取闭环。 - 你需要把
ready/pick/bin(A/B/C)的关节角替换为你自己的“定点抓取/定点放置”数据:建议统一放到参数文件config/vision_pick_place_controller.yaml中修改(见下文“如何获取关节角证据”)。
重要提醒:
思路:用 MoveIt2 demo(RViz2)把机械臂执行到目标姿态,然后把 /joint_states 中的关节角记录下来作为你的 pick_joints / bins_joints.(A|B|C)。
1)启动 demo(示例):
source /opt/ros/humble/setup.bash
ros2 launch robot_arm_config demo.launch.py
2)在 RViz2 中执行到你想要的“抓取点/箱位点”后,读取一次关节状态(证据):
ros2 topic echo --once /joint_states
3)按控制器关节顺序整理:
arm_controller的关节顺序以控制器 YAML 为证据(arm_controller.ros__parameters.joints)。- 你必须确保
vision_pick_place_controller里self._arm_joint_names的顺序与控制器一致,否则会“关节值错位”导致危险动作。
3.2)用 MoveIt2 执行“预置关节目标”(推荐实现,与《11. 机械臂物品抓取仿真》一致)
前置条件(证据化):
- 建议先启动
robot_arm_config demo.launch.py,确保move_group与控制器链路正常(否则 MoveIt2 规划/执行会缺失关键依赖)。 - 证据命令示例:
ros2 node list | grep move_group
ros2 control list_controllers
环境证据(能 import 到 MoveItPy 才算安装与 Python API 可用):
source /opt/ros/humble/setup.bash
python3 -c "from moveit.planning import MoveItPy; print('MoveItPy OK')"
核心思路(示例片段,仅展示关键 API 形态):
from moveit.core.robot_state import RobotState
from moveit.planning import MoveItPy
robot = MoveItPy(node_name="sorting_moveit_py")
arm = robot.get_planning_component("arm")
hand = robot.get_planning_component("hand")
def plan_and_execute_joint_goal(joints: list[float]):
robot_state = RobotState(robot.get_robot_model())
robot_state.set_joint_group_positions("arm", joints)
arm.set_start_state_to_current_state()
arm.set_goal_state(robot_state=robot_state)
plan_result = arm.plan()
if not plan_result:
raise RuntimeError("arm plan failed")
robot.execute(plan_result.trajectory, controllers=[])
def plan_and_execute_named_hand_state(name: str):
hand.set_start_state_to_current_state()
hand.set_goal_state(name) # 例如 "open"/"close"
plan_result = hand.plan()
if not plan_result:
raise RuntimeError("hand plan failed")
robot.execute(plan_result.trajectory, controllers=[])
把它接到本讲的“视觉分拣”闭环里:
grade只负责选择bins_joints.A/B/C(预置关节角);抓取点pick_joints仍是预置关节角。- 对外接口仍是
/sorting/arm/pick_place(PickPlace Action),execute callback 里的每个 phase 只需把_send_arm_joint_goal(...)替换成plan_and_execute_joint_goal(...)。 - 对夹爪:若你的 SRDF 里配置了
hand组的命名姿态(如open/close),优先用plan_and_execute_named_hand_state("open");否则再退回到直接发布hand_controller/commands(路线 A)。
把“预置关节目标”升级为 MoveIt2 pose 目标真规划执行(进阶,按证据对齐接口):
1)先查你当前 bringup 暴露了哪些接口(不要凭感觉写死):
ros2 node list | grep move
ros2 action list -t | grep -E "MoveGroup|ExecuteTrajectory|FollowJointTrajectory"
ros2 service list | grep -E "plan|compute|ik|fk"
逐行解释:
- 第 1 条确认
move_group是否在跑。 - 第 2 条确认“规划/执行/控制器”关键 action 是否存在(不同配置命名可能不同,以输出为准)。
- 第 3 条确认是否存在规划/IK/FK 类服务(用于你选择的实现方式)。
2)推荐的工程落地方式(更稳定、对学生更友好):
-
运动规划与执行用 C++
MoveGroupInterface(MoveIt2 官方主流用法),你把“视觉接入 + 状态机 + 任务编排”保留在 Python 节点中,或把两者统一到 C++(视工程复杂度而定)。 -
轨迹执行一定要落到 ros2_control 控制器(
joint_trajectory_controller),否则会出现“能规划、不能动”。 -
视觉:品质 topic 有数据(
ros2 topic echo --once /sorting/perception/apple_quality)。 -
控制器:
ros2 control list_controllers显示轨迹控制器 active。 -
执行:MoveIt2/控制器日志能证明“轨迹被执行”(至少保留关键日志行或 action 结果)。
编译与启动:
cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
ros2 run sorting_arm_control vision_pick_place_controller \
--ros-args --params-file ~/ros2_ws/src/sorting_arm_control/config/vision_pick_place_controller.yaml
逐行解释:
colcon build --symlink-install:构建并启用软链接,便于改 Python 后快速生效。source install/setup.bash:让ros2 run能找到新节点入口点。
状态订阅(反馈证据):
ros2 topic echo /sorting/arm/state
动作接口证据(对外 Action):
ros2 action list -t | grep pick_place
ros2 interface show sorting_arm_interfaces/action/PickPlace
手动触发一次抓取放置(不依赖视觉 topic 自动触发):
ros2 action send_goal /sorting/arm/pick_place sorting_arm_interfaces/action/PickPlace "{grade: 'B'}" --feedback
取消任务(证据,Action 具备 cancel 语义):
ros2 action cancel /sorting/arm/pick_place
发布测试检测结果(用你自己的手发一条“视觉检测”来验证闭环):
ros2 topic pub --once /sorting/perception/apple_quality apple_quality_msgs/msg/AppleQualityArray "{
header: {frame_id: 'camera_link'},
apples: [
{
grade: 'A',
diameter_mm: 75.0,
red_ratio: 0.85,
defect_score: 0.10
}
]
}"
逐段解释:
- 该命令模拟发布 1 个苹果的品质结果(重点是
grade字段)。 - 其它字段(果径/着色/缺陷)用于丰富业务解释,不影响本讲“按等级分拣”的最小闭环。
- 如果你已运行 YOLOv8 视觉节点,就不需要手动 pub;用
ros2 topic echo --once /sorting/perception/apple_quality获取真实输出作为证据。
可选:禁用/启用控制节点(用于急停/防误触发):
ros2 service call /sorting/arm/enable std_srvs/srv/SetBool "{data: false}"
ros2 service call /sorting/arm/enable std_srvs/srv/SetBool "{data: true}"
逐行解释:
data: false:禁用后即使视觉继续发布,也不会启动新任务。data: true:恢复使能。
作业
1)提交 MoveIt2 环境与 bringup 运行证据:终端关键输出 + RViz2 截图(包含机器人模型与 Planning Scene)。
2)提交“规划与执行链路存在”的证据(至少包含两类):
- 规划侧:
ros2 node list | grep move或ros2 action list -t | grep -E "MoveGroup|ExecuteTrajectory"的关键输出。 - 控制器侧:
ros2 control list_controllers的关键输出(能看出轨迹控制器 active)。
并用 120 字左右说明:为什么仅有“能规划”不等于“能执行到硬件/仿真”。
3)提交视觉分拣控制节点代码(最小闭环版):
- 订阅
/sorting/perception/apple_quality(apple_quality_msgs/msg/AppleQualityArray),并能从中取出grade。 - 发布
/sorting/arm/state,状态字段至少包含seq、request_id、ts、phase、ok、message、grade。 - 可选:提供
/sorting/arm/enable(std_srvs/srv/SetBool)用于使能/禁用任务启动。
4)提交 1 份视觉品质证据与说明:
- 提交
ros2 topic echo /sorting/perception/apple_quality --once的关键输出截图/文本,并用 120 字说明:你如何从消息里取到grade,以及你如何把grade映射到分拣箱位(A/B/C 各对应哪个箱)。
5)提交一次失败排查记录(200–300 字):按“现象→证据→定位→修复→复测”结构。问题可选:
- TF 变换失败(frame 不存在/时间戳问题)
- MoveIt2 能规划但不能执行(控制器未 active/接口名不匹配)
- 规划失败(碰撞/不可达/姿态约束过强)
必须包含证据输出或截图(日志/ros2 CLI/RViz2 任一即可)。