第 2 章 模型描述与逆解¶
规划器不认识“机器人”,只认识两份描述:URDF 给出几何与关节的硬约束,SRDF(Semantic Robot Description Format,语义机器人描述格式)给出“哪一段链路算一个组、末端在哪、哪些关节不参与规划、哪些 link 允许互碰”。逆运动学(inverse kinematics, IK)则把任务位姿翻译成关节值,是这两份描述被第一次消费的地方。下面的逐项说明与全部读数,均取自运行环境 ROS 2 Humble + MoveIt 2 2.5.10 的 moveit_resources panda 资产。
两份描述文件与逆解插件的分工:
flowchart LR
urdf["URDF:几何与关节"] --> srdf["SRDF:规划组 / 末端 / 禁用碰撞对"]
srdf --> ik["IK 插件<br/>KDL / IKFast / pick_ik"]
ik --> goal["目标位形或位姿"]
1. 规划组与末端执行器¶
规划组(planning group)是 SRDF 里最基本的单位:一次规划请求的 group_name 只能指向一个组,组决定了参与求解的关节集合与末端 link。panda 的三行声明覆盖了三种定义方式:
<!-- SRDF 片段(panda.srdf,节选):三种组定义方式与末端声明 -->
<group name="panda_arm">
<!-- 链式定义:从基座 link 到末端 link 的全部关节与 link 自动入组 -->
<chain base_link="panda_link0" tip_link="panda_link8"/>
</group>
<group name="hand">
<link name="panda_hand"/>
<link name="panda_leftfinger"/>
<link name="panda_rightfinger"/>
<joint name="panda_finger_joint1"/>
<passive_joint name="panda_finger_joint2"/> <!-- 见「关节耦合的两处表达」 -->
</group>
<group name="panda_arm_hand">
<group name="panda_arm"/> <!-- 复合组:引用已有组而非重新枚举 -->
<group name="hand"/>
</group>
<end_effector name="hand" parent_link="panda_link8" group="hand" parent_group="panda_arm"/>
三种定义方式对应三种用途:<chain> 适合串联臂,改动链路端点即整个组随之变;逐 link/joint 列举适合并联或带分支的手;<group name="..."> 嵌套适合“臂 + 手”这种既要单独规划、也要一起规划的场合。<end_effector> 的价值在于把“工具挂在哪个 link 上”写成结构化信息:抓取时 attach 的默认挂点、以及 panda_arm 组请求里附带末端语义,都由这一行提供。
SRDF 里还能存具名组态(named state),即“一组关节值 + 一个名字”。panda 声明了三组:ready(0, −0.785, 0, −2.356, 0, 1.571, 0.785)、extended、transport,第 1 章的规划读数即以 ready → extended 为目标。它们的用途是作为可复现的中间位姿:规划请求可直接指定 configuration_name,避免裸关节值散落在脚本里。
2. 关节耦合的两处表达¶
panda 的两根手指由一个电机经机械耦合驱动,另一根是从动的。这类耦合(mimic)在样本资产里分三层表达,消费方各不相同:
<!-- ros2_control 段(嵌在 URDF xacro 内):执行侧把 finger2 声明为 finger1 的镜像 -->
<joint name="panda_finger_joint2">
<param name="mimic">panda_finger_joint1</param> <!-- 控制器复制指令 -->
<param name="multiplier">1</param>
<command_interface name="position" />
</joint>
<!-- SRDF 侧:finger2 标为被动关节 -->
<group name="hand">
<joint name="panda_finger_joint1"/>
<passive_joint name="panda_finger_joint2"/> <!-- 规划侧:不进决策变量 -->
</group>
漏掉 ros2_control 段的 mimic,两指会各走各的指令,开合不对称;漏掉 SRDF 侧的 <passive_joint>,finger2 会成为第 9 个决策变量,采样空间维数上升,且规划器会给出“两指张开量不同”这种物理上做不到的解。判据是:耦合必须在“谁执行”与“谁规划”两处各表一次,一处表了另一处不会自动继承;
URDF 关节上的 <mimic joint="panda_finger_joint1"/> 供描述层消费者(robot_state_publisher 一类的状态换算)使用,不替代这两处。真实硬件上还有第三种表达——驱动器内部的电子齿轮或主从同步(见 fieldbus-note 第 5 章 DS402),那种情况下软件侧只保留一个关节即可。
3. 浮动基座与虚拟关节¶
panda 是固定式机械臂,但 URDF 的树必须有一个根。SRDF 用虚拟关节表达这件事:
<virtual_joint name="virtual_joint" type="floating" parent_frame="world" child_link="panda_link0"/>
type 的可选值决定了基座的自由度:fixed(基座固定)、planar(基座可在一个平面内移动加绕法线转动)、floating(六个自由度全放开)。这里写的是 floating,但 panda_arm 组的链从 panda_link0 起算,虚拟关节不在组内,因此它不进入规划——基座实际按固定处理,world 坐标系靠 TF 提供一个静态变换即可。浮动基座真正派上用场是移动操作(底盘 + 臂):把虚拟关节纳入规划组后,位姿解算会同时决定车停在哪、臂怎么伸,代价是维度显著上升(底盘与导航的耦合归 ROS2-note 第 10 章)。
一个直接的推论:base_frame 不是唯一的。第 1 章用 TF 查到的 panda_link0 → panda_link8 距离,与任何以 world 或相机坐标系为基准的测量之间,差的是这个虚拟关节的变换。手眼标定的任务就是把这条变换换成可测的外参(camera-note 第 1 章)。
4. 逆运动学求解器与配置项¶
求解器选择在 kinematics.yaml 里,按组配置:
## kinematics.yaml:panda_arm 组的 IK 求解器与两项关键参数
panda_arm:
kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin
kinematics_solver_search_resolution: 0.005 # 沿零空间搜索候选解的步长(rad)
kinematics_solver_timeout: 0.05 # 单次求解的内部超时(s)
发行形态不对称:KDL 随 moveit_kinematics 一起装好;ikfast 需要为目标臂离线生成代码包,没有预编译发行版;trac-ik 在 Humble 无发行版包、须源码编译。因此本章只覆盖 KDL,其余求解器的差异待补齐后另述。
另一类实现不在 MoveIt 插件体系内:把 IK 直接写成带约束 QP(mink、PlaCo 一类)。以 mink 为例(pip install mink):
import mink
configuration = mink.Configuration(model) # model 提供关节配置与限位(MuJoCo/Pinocchio 模型有现成加载器)
task = mink.FrameTask("ee", "body", position_cost=1.0, orientation_cost=0.5)
task.set_target(target_pose)
limits = [mink.ConfigurationLimit(model)]
for _ in range(10): # 迭代到收敛
vel = mink.solve_ik(configuration, [task], 0.01, "daqp", limits=limits)
configuration.integrate_inplace(vel, 0.01)
它是增量式的:每拍解一个 QP 得速度,再积分更新配置;限位、速度与碰撞线性化约束显式进 QP,解不出时给的是“冲突方向”而不是空值。适合在规划链外单跑(采样/优化前的候选筛选),不替代 kinematics.yaml 的求解器。
两个生态的分工:pink 建在 Pinocchio 上(URDF 直读,\(J\) 与 \(M\) 来自 rnea/crba),mink 建在 MuJoCo 模型上;QP 思路相同,选哪个取决于规划链其余部分用哪套模型。
调用形态是一个同步服务:/compute_ik,消息类型 moveit_msgs/srv/GetPositionIK。请求里的关键字段决定了结果形状:
from moveit_msgs.srv import GetPositionIK
from moveit_msgs.msg import RobotState
from sensor_msgs.msg import JointState
req = GetPositionIK.Request()
q = req.ik_request
q.group_name = "panda_arm" # 组决定参与求解的关节集合
q.ik_link_name = "panda_link8" # 末端 link:位姿的度量点
q.pose_stamped = goal_in_link0_frame
q.avoid_collisions = False # False:只在几何上求解,不看环境与自碰撞
q.timeout = Duration(sec=1.0) # 服务侧总超时;
无解时会把这个预算烧满
## 种子状态:迭代式求解器(KDL)沿它附近搜索,直接决定落在哪个解分支
q.robot_state = RobotState(joint_state=JointState(name=JOINT_NAMES, position=current))
robot_state 是种子而不是约束——这是 KDL 这类数值求解器的行为,也是下一节多解现象的来源。
5. 同一目标位姿的多解¶
7 自由度臂对同一个 6 维位姿一般有无穷多解(1 维零空间),加上肩/肘/腕的分支组合,离散分支也不止一个。对当前状态下的 panda_link8 位姿(即已知有解的目标)换 14 个种子分别请求,运行环境的读数是:
14 个不同种子: 成功 14/14,去重后 12 组不同的解
分支1: seed=j1-1.2 往返=2.2 ms 解=[-0.74, -0.91, 0.44, -2.34, 0.35, 1.48, 0.36]
分支2: seed=j1+1.2 往返=2.0 ms 解=[ 0.74, -0.91, -0.44, -2.34, -0.35, 1.48, 1.21]
分支5: seed=j3-1.2 往返=2.0 ms 解=[-1.19, -1.13, 0.62, -2.30, 0.58, 1.30, 0.06]
解之间的最大单关节跳变 = 2.230 rad(127.8°)
同一目标位姿有 12 组本质不同的解、组间最大单关节差 2.23 rad,这两条数字合起来说明一件事:“给一个位姿求一组关节角”这个问题本身是不适定的,必须由使用方补上选择准则。可用的准则按代价从低到高:
| 准则 | 做法 | 代价与边界 |
|---|---|---|
| 就近 | 以当前状态为种子,取第一组解 | 代价最低;但目标附近无解时容易掉到另一分支,产生大跳变 |
| 可操作度优先 | 对多组候选解算 \(w=\sqrt{\det(JJ^{\top})}\) 取大者 | 需多解枚举;判据定义见 robotics-note 第 4 章 |
| 零空间优化 | 在主任务解集里再优化第二目标(避障、关节裕度) | 需要求解器支持任务优先级或冗余解析,KDL 不暴露该层 |
零空间(self-motion manifold)是 7 自由度臂相对 6 维位姿任务多出的那一维:沿它移动末端位姿不变。任务侧最常用它来避开限位与障碍,这属于控制侧的任务优先级处理,此处只点一名目。
6. 连续解的数值卫生¶
三类读数需要在写代码时防住。
超时烧满。 目标不可达(把 panda_link8 抬高 0.9 m)时:
不可达目标(z +0.9 m): code=-31 往返=1001.0 ms
超出工作空间(x=2.0 m): code=-31 往返=1000.9 ms
-31 是 moveit_msgs/MoveItErrorCodes 里的 NO_IK_SOLUTION。要点在于耗时:1 s 的请求预算被完整烧掉,而不是立刻返回失败。把 IK 放进高频回路(如笛卡尔伺服)时,“无解”必须当作慢路径设计,否则一次误检的位姿就能把控制周期堵死。
分支跳变。 迭代求解器按种子局部收敛。相邻两帧的目标位姿只差 1 mm,解却可能落到另一分支,关节值跳 2 rad 级——执行侧表现为整臂突然甩动。防线有两层:一是始终以上一帧的解作种子;二是在接受解之前比较新旧关节值,超过阈值就丢弃这组解或退回上一位姿。判据本身要写成数值条件(例如单关节增量阈值),不依赖“看起来还行”。
解与限位的关系。 KDL 的解可能贴着限位。第 5 节的分支里就出现过 panda_joint1 = -0.74 到 -1.19 的跨度,而样本资产中 panda_joint4 的区间只有 \([-3.1416, 0.0873]\)——非对称区间的关节更容易在分支切换时被判无解。逆解返回“成功”不等于这组值在下一帧仍然可用:位姿容差、限位覆写(第 1 章的 joint_limits.yaml)任一变化都会改变可行集。
碰撞开关的两副面孔。 avoid_collisions=True 让求解器在候选解上做一次碰撞过滤,第 3 章会给出对照:障碍附近它常常直接返回 -31 并把超时烧满,而 avoid_collisions=False 毫秒级出解、但解可能同时与环境和自身相交。把 IK 当作“绕障碍的手段”是分层误用——绕障是规划器的职责(第 4 章)。
IK 有解不等于任务能做成:同一目标位姿的解既可能贴着限位、可能与障碍相交、也可能与上一帧的解差 2 rad。逆解只回答“这组关节值能让末端到达位姿”,可达性之外的三件事——连续、合法、安全——分别由位姿序列设计、限位与碰撞检查、规划器负责。
7. 配置生成的落点文件¶
moveit_setup_assistant 已随发行版安装(2.5.10),它的产出不是新格式而是上一节各项的文件化。样本配置包 moveit_resources_panda_moveit_config/config/ 的目录内容即一次配置的完整落点:
panda.urdf.xacro 模型入口(含 ros2_control 硬件声明)
panda.srdf 组、末端、被动关节、虚拟关节、组态、ACM
kinematics.yaml 各组 IK 求解器与参数
joint_limits.yaml 速度与加速度上限覆写
initial_positions.yaml 假硬件的起始关节值
ompl_planning.yaml OMPL 管线:插件、请求适配器、各规划器配置
chomp_planning.yaml (其余管线同构:pilz / trajopt / lerp)
pilz_cartesian_limits.yaml 笛卡尔速度/加速度上限(Pilz 侧的额外约束)
moveit_controllers.yaml 规划侧到控制器的映射(含执行容差)
gripper_moveit_controllers.yaml 带夹爪版本的同一映射
ros2_controllers.yaml controller_manager 与各控制器的实例化
sensors_kinect_pointcloud.yaml 点云 → octomap 的占据地图插件配置
Setup Assistant 的 GUI 步骤不在本章展开,只讲它产出哪些文件、每个文件归谁消费。需要注意的是 panda.srdf 与 *.xacro 的关系:SRDF 不重复几何,几何永远只在 URDF 侧;SRDF 里引用不存在的 link 名不会在编辑期报错,而是在加载模型时失败,这一类“名字对不上”是操作侧最常见的第一次失败。
8. 要点¶
- SRDF 用四类声明把 URDF 变成“可规划的描述”:组(链式/列举/复合)、末端执行器、具名组态、允许碰撞矩阵;几何永远只在 URDF 侧。
- 关节耦合要在两处各表一次:
ros2_control的mimic管执行侧镜像,SRDF 的<passive_joint>管规划侧不进决策变量;漏一处会导致不对称或物理上做不到的解。 - 虚拟关节的
type决定基座自由度;floating只有把虚拟关节纳入规划组才真正生效,代价是维度上升。 - IK 的调用形态是同步服务
/compute_ik(moveit_msgs/srv/GetPositionIK),robot_state是种子不是约束,这直接决定落在哪个解分支。 - 同一位姿得到 12 组本质不同的解,组间最大单关节差 2.230 rad:问题不适定,选择准则(就近 / 可操作度 / 零空间)必须由使用方补上。
- 无解时把超时烧满(1.0 s 预算对应约 1001 ms 往返),高频回路里必须把“无解”设计成慢路径;失败码
-31对应NO_IK_SOLUTION。 - 解可能贴限位、可能与障碍相交、可能与上一帧跳变 2 rad;连续、合法、安全分别由位姿序列、限位与碰撞检查、规划器保证。