跳转至

第 4 章 装配与坐标变换

装配是最贴近实际工作的场景:将一个夹爪子树挂接到某个 link 之下,并使其在该 link 的坐标系中落于指定位姿。有三件事需要先说明:<freejoint> 的边界、weld 的实际语义,以及"依赖软约束缓慢拉到位"为何不可行。后半章转向朝向与坐标系的存储约定,以及如何校验自身的约定表未写反。

1. freejoint 与 joint type="free"

XML reference 给的定义是一行等价式:<freejoint/> 只有 name 与 group 两个属性,它等价于 <joint type="free" stiffness="0" damping="0" frictionloss="0" armature="0"/>,目的是确保关节的默认设置不被继承。

因此两者的差别仅在于是否继承 default class:所谓 <freejoint> 不继承 bias= 的根据正在此处——它将 stiffness/damping/frictionloss/armature 显式置为 0。在同一份 default(stiffness=5 damping=2 frictionloss=1 armature=0.3)套在同一个 body 上,只换关节写法:

写法 dof_damping dof_armature
<freejoint name="fj"/> 六个 0 六个 0
<joint type="free"/> 全 2.0 全 0.3

freejoint 的六个自由度全被钉回 0,default 一个字都不进——这就是那条机制的确切形态。

位置限制是硬性的。joint type 条目对 free 的规定是:只允许用在世界刚体的子刚体上;一个刚体里定义了自由关节就不能再定义别的关节;自由关节不能设限位(XML reference)。

四句各对应一种结果:

<!-- ① 放在二级 body 上:违反"自由关节只允许在世界刚体的直接子刚体上"这条限制 -->
<body name="shell"><body name="payload"><freejoint/> …
<!-- ② 放在 <worldbody> 的直接子 body 上 → 通过,nq=7 nv=6 -->
<!-- ③ 放在 worldbody 自己身上 → 违反限制,载入被拒 -->
<!-- ④ 同一个 body 上再加一个 hinge → 自由度超出自由关节的 6 个,载入被拒 -->

"不能设置限位"则是 schema 层面的:<freejoint> 只认 name 与 group 两个属性,写 limited="true" 会因属性名无法识别而被拒绝载入。

装配守则:外部资产包里带 <freejoint> 的子树,装到机器人上时必须删掉那个根关节。它的前提是"该零件单独仿真时自由落体",而非"装到任何父体上都保持自由"。第 3 章「attach 与 asset 子模型声明」中"attach 带根关节的子树会被拒绝"指的就是这条限制。

2. weld 的 relpose 语义

weld 的语义有三处容易误解(XML reference 的 equality/weld 条目)。第一,约束强制执行的是"建模时"的相对位姿——定义模型那一刻两个 body 的相对位置和方向就是目标。第二,relpose(real(7),默认 "0 1 0 0 0 0 0")的前三个分量是 body1 局部坐标系里的锚点坐标,后四个分量是 body2 相对 body1 的朝向;但只要四元数部分全零(正是默认值),整个属性就被忽略,目标退回 qpos0 里的参考位姿。第三,torquescale(默认 1)设成 0 会让 weld 退化成 connect,即只管位置不管朝向。

两条结论:前三个分量是锚点坐标(body1 局部系里的一个点),不是"要移动多远";四元数部分全零时整个 relpose 被忽略,weld 的目标退回建模位姿——没有误差可拉。

下面这份模型是本章后半段的共用实验台:mount 在原点,part 带 freejoint 悬在 z=0.719,part 里嵌一个 range="0 0.046" 的 slide 关节:

<mujoco model="weld_range">
  <!-- 关闭接触:本章只考察 weld 与 range,需排除碰撞对读数的干扰 -->
  <option timestep="0.002"><flag contact="disable"/></option>
  <worldbody>
    <!-- mount 固定在世界里(没有 joint),充当焊枪的另一端;mass=1 保证它不会被拖着走 -->
    <body name="mount"><geom name="mg" type="sphere" size="0.01" mass="1"
                            contype="0" conaffinity="0"/></body>

    <!-- part 带 freejoint → 6 dof 悬在空中,z=0.719 是建模时的相对位姿来源 -->
    <body name="part" pos="0 0 0.719">
      <freejoint name="fj"/>
      <geom name="pg" type="sphere" size="0.01" mass="0.05" contype="0" conaffinity="0"/>

      <!-- 被测件:0 ≤ q ≤ 46 mm 的限位slide,本章后半段所有越界数字都出自它 -->
      <body name="slider" pos="0 0 -0.03">
        <joint name="slide" type="slide" range="0 0.046" limited="true" damping="0.01"/>
        <geom name="sg" type="box" size="0.005 0.005 0.005" mass="0.01"
              contype="0" conaffinity="0"/>
      </body>
    </body>
  </worldbody>

  <!-- 不写 relpose → 四元数全零 → 目标退回 qpos0 的参考位姿(本节的核心陷阱) -->
  <equality><weld name="w" body1="mount" body2="part"/></equality>
</mujoco>

步进 5000 步(每步记录一次 qpos,观察 slide 关节是否越界、part 的最终位置),四种写法的结果:

<weld> 写法 slide 最大 qpos 越界 首次越界 part 的最终 z
relpose 留空(默认) 0.000000 未越界 — 0.718590
relpose="0 0 -0.719 1 0 0 0" 0.175210 +0.129210(+280.9%) 第 2 步 −0.719410
同上 + <flag autoreset="disable"/> 0.175210 与上一行逐位相同 第 2 步 −0.719410
relpose="0 0 -0.719 0 0 0 0"(四元数全零) 0.000000 未越界 — 0.718590

第一行的"未见任何变化"正是正确行为:约束在 t=0 即已满足,求解器无需作用。第三行说明这件事与 autoreset 无关(第 5 章「事故复盘:NaN 命令下的停摆」用另一条不含 weld 的模型测 NaN,两条路径别混)。第四行是第一行的孪生:位移部分明明写了 719 mm,却因为四元数全零被整个忽略——relpose 的前三个分量单独存在是没有意义的。这个反例值得反复提醒:先确认自己的场景真的触发了要测的机制。

3. 软约束与 range 越界

第二行那 129 mm 的越界不是数值事故,而是设计如此。computation 章的约束模型一节说限位时只有一句:

文档对限位只有一句:限制被求解器视为无摩擦接触来处理。

即 range 属于软约束:限位仅施加速度相关的恢复力,可被更大的力克服。越界幅度随参数的变化在该模型上可直接测出(每行仅改动一个量,步进 5000 步后取 qpos 最大值):

改动 越界幅度 相对默认
默认 solreflimit="0.02 1" solimplimit="0.9 0.95 …" +0.129210 m +280.9%
solreflimit="0.005 1"(限位变硬) +0.031369 m +68.2%
solimplimit="0 0.1 …"(阻抗更软) +0.208715 m +453.7%
solreflimit="-2000 -20"(直接刚度格式) +0.290841 m +632.3%
iterations 20 / 100 / 200 +0.129210 m 不变
integrator Euler / implicitfast +0.129210 m 不变

三点读法:调参只能减轻症状不能消除它——越界发生在第 2 步的瞬态里,与求解器迭代数、积分器都无关;限位变硬(时间常数 0.02→0.005)确实能把幅度压到 1/4,但 0.002 那一档已低于文档建议下限("timeconst 参数应至少比仿真时间步长高出两倍",timestep=0.002 ⇒ 下限 0.004),只能看趋势不能当推荐值抄;阻抗参数 solimplimit 与越界幅度同向——把 d0/dwidth 从 0.9/0.95 改软到 0/0.1,越界反而涨到 +453.7%。

⇒ range 不能当硬限位用。

4. 装配的几种做法

按代价从低到高:

  1. 零 DOF 嵌套(首选):把子树直接放进目标 body 下,pos/quat 一次写对,不产生任何约束行(nefc 不涨)。
  2. 资产复用走 <attach>(第 3 章):位姿由包裹它的 <frame> 或父 body 决定,一次声明可以装左右两份。
  3. 需要可拆卸表达时用 weld + t=0 精确对齐:先按目标位姿摆放 body 初值(或用 keyframe 给 qpos0),让约束在初始就成立。
  4. 只在"确实想让它被拽过去"的任务里(如抓取后对齐)用软约束,并给足时间常数(≥ 2×timestep)与合理的 solimplimit。
  5. 校验靠数字,不靠画面:加载后看 d.nefc(约束行数)、d.efc_state(哪些约束在激活)、以及每个关节 qpos 是否还在 range 内。上面那张表就是这套校验跑出来的——nefc=7、越界 0.129210 m,都是肉眼在画面里看不出来的。

5. 引擎的存储约定

这一章后半段的主题是朝向:引擎会告诉你它按右手系、单位四元数存一切,但它不会告诉你你的约定表是不是镜像。

modeling 章的 frame orientations 一节:

"若干模型元素具有与其相关联的右手空间坐标系(spatial frame)。" "围绕由单位向量 (x, y, z) 给出的轴旋转角度 a 的 3D 旋转对应于四元数 (cos(a/2), sin(a/2)·(x, y, z))" "请记住旋转是右手的;如果向量 (x, y, z) 的方向反转,将导致相反的旋转。" "无论用户选择哪种机制,坐标系朝向在内部始终会转换为单位四元数。"

programming 章的数据布局一节:

"MuJoCo 使用单位四元数——即排列为 q = (w, x, y, z) 的 4D 单位向量……对应于零旋转的四元数是 (1, 0, 0, 0)。"

工程后果是切片位置。qpos 里 freejoint 的分量顺序是"三个平移自由度,后跟三个旋转自由度"(joint type 条目),四元数落在这段旋转分量的前四个位置、第 0 个是 w。一个只含 <freejoint> 的 body 编译出来是 nq=7 nv=6:位置层 7 个、速度层 6 个——速度层存的是角速度(3 维)而不是四元数增量(4 维)。任何"把 qvel 当四元数速度用"的代码,从维度上就已经错了。"切片取错(qpos[0:4] 还是 qpos[3:7])"这个经典 bug,本质就是把 (w,x,y,z) 当成了 (x,y,z,w)。两种层次的分量摆位是:

flowchart LR
    fj["freejoint<br/>6 个自由度"] --> qp["位置层 qpos<br/>7 个数"]
    fj --> qv["速度层 qvel<br/>6 个数"]
    qp --> tq["qpos[0:3]<br/>平移"]
    qp --> rq["qpos[3:7]<br/>单位四元数 (w, x, y, z)"]
    qv --> lv["qvel[0:3]<br/>线速度"]
    qv --> av["qvel[3:6]<br/>角速度(非四元数增量)"]

坑:qvel 不是四元数速度

freejoint 的 qpos[3:7] 才是 (w,x,y,z),qvel 的旋转段是 3 维角速度——把 qvel 当四元数增量用,从维度上就已经错了。

6. 换基检查与转身不变量

6.1 换基矩阵与 det 检查

事故经过:代码里声明了一张 CAM_TO_BASE_AXES——"相机 x 右 → 机体左、相机 y 上 → 机体上、相机 z 出屏 → 机体后"。三条合起来构成的矩阵 det = −1,是镜像。而相机系(x 右 y 上 z 出屏,由 OpenCV 系绕 X 转 180° 得来)与机体系(x 前 y 左 z 上)都是右手系,两个右手系之间的换基只能是旋转,det 必为 +1。

把那张表照抄成矩阵,问题一行就能看出来:

import numpy as np
## 每行是相机某根轴在机体系 (x 前, y 左, z 上) 里的像
BAD = np.array([[0, 1, 0],     # 相机 x 右 → 机体左
                [0, 0, 1],     # 相机 y 上 → 机体上
                [-1, 0, 0]])   # 相机 z 出屏 → 机体后(三条全写)
np.linalg.det(BAD)                       # → -1.0
np.cross(BAD[0], BAD[1])                 # → [1, 0, 0],与第三行 (-1,0,0) 反号

后果不止于"某个通道符号相反":Rz·Ry·Rx · A 无论三个角取什么值都是镜像矩阵,因此没有任何一组安装角能表示真实的相机;而标定解算器(Kabsch/Procrustes)不报错——它强制输出 det=+1,于是给出的欧拉角是一副任意角。同一批完美数据下,把 det 修对之后残差从 905 mm 回到 0.0 mm:反过来看,残差爆掉只是 det 检查缺位的下游症状。

三条守则:

  1. 三条"轴对应"只能取两条,第三条被叉积强制(机体系里 x_b × y_b = z_b)。写表时故意留一条不写,让它由叉积推出来。
  2. 写完任何"帧 A 三轴 → 帧 B 三轴"的表,第一件事算 det,第二件事把它钉进代码:assert np.linalg.det(A) > 0。
  3. 换轴表与换序表分开放、各测一次。下游若还要把分量重排(例如返回 (侧移, 前后, 升降) 而不是 (x,y,z)),那个换序本身也是 det=−1,会把第 2 条检查掩盖掉。

6.2 独立对照与转身不变量

自己写的四元数→矩阵,不能拿自己写的反函数来验。两条独立对照:

  • mju_quat2Mat / mju_mat2Quat 是引擎现成实现,自己写的必须与它逐位一致,往返还应回到原值([0.939693, 0.091409, 0.182817, 0.274226] 走一圈不变)。
  • 这两个函数的矩阵参数都必须是 flat (9,) 的连续数组——传 3×3 会直接报类型错,reshape 出来的二维视图不兼容。

更狠的行为级判据是转身不变量:把自由基座绕任意轴转一个角度,让引擎重算 site_xpos,再把观测点变换回机体系——刚附在机体上的点在机体系里必须原地不动。它能同时抓住三种典型错法:

def quat2mat(q):                      # 用引擎实现做独立对照
    r = np.zeros(9); mujoco.mju_quat2Mat(r, q); return r.reshape(3, 3)

def to_body(site_xpos, body_pos, body_quat):
    """全局点 → 机体系:先减原点,再用机体姿态阵转回去。"""
    # 顺序不能颠倒:R 是线性的,但对平移不线性——先减后转才对
    return quat2mat(body_quat).T @ (site_xpos - body_pos)

机体绕 (1,2,3) 转 40°、同时平移 0.05 m 后,正确实现给出残差 1.39e-17 m(浮点噪声),而三种错法立刻现形:

错法 与正确结果的偏差
只减原点、不转向 0.0494 m
旋转乘反(用 R 而不是 Rᵀ) 0.0817 m
四元数顺序按 (x,y,z,w) 取 0.1538 m

想让某条错法逃过去也容易:把平移去掉再跑,"只减原点"这一路就恰好也是零残差——所以判据必须配一个会动的机体。"拿自己的函数验自己的函数"三种错法一个都抓不住。第 6 章给出与本节配套的位移对照表(雅可比列范数 vs 实际位移,同样是两条独立路径)。

7. 参考系 site 的可视化分组

在 site/geom 上挂参考系时注意 group(第 7 章):默认只画 0/1/2 组,group=4 的坐标系 site 会静默不显示,表现成"viewer 里没有查看法兰坐标系的选项"。朝向排查时这是最常见的"看不到 ≠ 不存在"。

8. 要点

  • <freejoint/> = <joint type="free"> 且强制不继承(四个偏置参数被钉成 0);只能挂在世界刚体的直接子 body 上,放二级 body 报 free joint can only be used on top level,同 body 再加别的关节报 more than 6 dofs。
  • weld 的默认目标就是建模位姿 ⇒ 不设 relpose 就没有拉力;relpose 前三分量是 body1 局部锚点而非位移,且四元数部分全零时整个属性被忽略。
  • 关节 range 是软约束("视为无摩擦接触处理"),被顶穿是正常行为;幅度由 solreflimit/solimplimit 决定,与迭代数、积分器无关。
  • 装配靠 t=0 对齐、零 DOF 嵌套或 attach,而不是靠约束瞬态;校验用 nefc/efc_state/qpos 越界量,不以画面目视为准。
  • 内部一律单位四元数 (w,x,y,z)、零旋转 (1,0,0,0);qpos 里 freejoint 是 3 平移 + 4 四元数(nq=7),qvel 只有 3 个角速度(nv=6)。
  • 右手系 ↔ 右手系的换基只能是旋转:det 必须 +1,而标定解算器不会替你检查。轴对应表只写两条,第三条由叉积定;换轴表与换序表分开测。
  • 独立对照用 mju_quat2Mat/mju_mat2Quat(矩阵参数要 flat (9,));行为级判据用转身不变量,并确认机体真的动了、测试伤得到被测对象。
  • 参考系 site 的 group 落在可视化默认显示组之外会静默消失。

参考文献与延伸阅读

  • Todorov, E., Erez, T., & Tassa, Y. MuJoCo: A Physics Engine for Model-Based Control. IROS, 2012. DOI
  • MuJoCo. 文档入口(body、site 与坐标约定). 文档
  • MuJoCo. 源码仓库. 仓库