跳到主要内容

3. 逆运动学

对应 CS123 · Lab 3 "Copy Cat / Romeo and Juliet" + Lecture 4 "Inverse Kinematics"

正运动学是单向的:关节角 → 末端。逆运动学是我们真正想要的方向:给一个末端目标,求出关节角。这一章我们用两种方式实现 IK,并把它接到第 1 章的 PD 控制器上,做出第一个"到点控制" demo。

本章目标

  • 理解 IK 的多解性、奇异性、可达性
  • 能对 3-DoF 平面臂手推解析解
  • 能用雅可比伪逆法 / damped least squares 写数值 IK
  • 在 MuJoCo 里让机械臂末端追踪一个移动目标

前置阅读

先建立 FK ↔ IK 直觉

直接看解析式更快:

  • FK(正向):给定 ,末端位置是 写成关节角的显式函数,一次计算得出。
  • IK(反向):给定 ,求 。这是一个多解、可能无解的非线性方程组——同一个末端目标经常对应多组关节角("肘上 / 肘下"),也有目标在工作空间外时根本解不出。
  • 数值 IK 的通用写法是 Damped Least Squares (DLS): 其中 , 是阻尼系数。奇异点附近用 防止伪逆爆炸,是数值 IK 能稳定收敛的关键。

3.1 IK 是什么问题:约束、解空间、局部最优

FK 是一个干净的前向函数,IK 则是"解一个欠定/过定非线性方程组",至少有三种和 FK 完全不同的麻烦:

  1. 多解。平面 2-DoF 臂的一个目标点,通常对应"肘朝上"和"肘朝下"两组关节角;6-DoF 机械臂经常有 8 组甚至 16 组。选哪组不是数学问题,而是工程约束(自碰撞、关节限位、能量最小、上一步最近)。
  2. 无解。目标点落在工作空间外面——这是第 2 章那张"甜甜圈"图之所以重要的原因。严肃的 IK 实现会先做一次"目标是否可达"的保护,再进正式迭代。
  3. 可达但到不了。目标在工作空间里,但当前构型被关节限位障碍物挡住,从当前 出发的数值法只能收敛到一个局部最优。一些控制框架(MoveIt2、TRAC-IK)会并行多个 random seed 来缓解这个问题。

所以第 3 章的关注点不是"有没有 IK 算法能跑",而是在 1/2/3 上踩哪些坑、如何绕过

3.2 解析法:3-DoF 平面臂的几何推导

先退到最干净的情形——3-DoF 平面臂,三节长度 ,关节在 z 轴转。

常见的简化做法是把"末端朝向"也作为输入(),这样前两个关节退化成经典的 2-DoF 几何问题,第三个关节由朝向约束直接读出。

令:

由余弦定理:

  • :目标不可达,直接返回"不行"。
  • 否则 ——正负号就是"肘上/肘下",一般按期望构型固定选一个。

由"指向 的总角度 − 贡献的那一部分"给出:

由朝向一句话补齐:

翻译成代码:

import numpy as np

def ik_analytic_planar(xy, phi, L=(0.3, 0.25, 0.15), elbow_up=True):
X, Y = xy
L1, L2, L3 = L

xp = X - L3 * np.cos(phi)
yp = Y - L3 * np.sin(phi)
r2 = xp * xp + yp * yp

cos_t2 = (r2 - L1**2 - L2**2) / (2 * L1 * L2)
if abs(cos_t2) > 1.0:
return None # 不可达,调用方要能处理 None

t2 = np.arccos(cos_t2) * (1.0 if elbow_up else -1.0)
t1 = np.arctan2(yp, xp) - np.arctan2(L2 * np.sin(t2), L1 + L2 * np.cos(t2))
t3 = phi - t1 - t2
return np.array([t1, t2, t3])

解析解的价值不是"省 CPU",而是做数值解的参考答案:下一节的数值 IK 跑完后,在平面工况下和解析解比一下,能第一时间抓到"我的雅可比写错了"这类 bug。

3.3 数值法:雅可比伪逆 + Damped Least Squares

一旦到 6-DoF 一般机械臂、人形机器人,解析解要么不存在、要么写出来比数值法还长。业内的通用解法是迭代线性化 + 最小二乘

从当前构型 出发,线性近似一步:

最朴素的"雅可比伪逆法"是直接 ,但这在奇异附近会炸—— 某个奇异值趋近 0,伪逆对应的那一行会放大到几百几千。

Damped Least Squares (DLS) 把它改造成一个带正则的最小二乘:

闭式解:

相当于给"往哪个方向动得最多"加了一个代价:远离奇异时和伪逆几乎一致,奇异附近 保住了矩阵的可逆性——不再"瞬间飞出去"。

代码:

def ik_dls(fk_fn, jac_fn, theta0, p_target,
lam=0.05, step=0.3, tol=1e-4, max_iter=200):
theta = theta0.copy()
for k in range(max_iter):
p = fk_fn(theta)
e = p_target - p
if np.linalg.norm(e) < tol:
return theta, k

J = jac_fn(theta) # (3, n) 或 (6, n)
JJt = J @ J.T
dtheta = J.T @ np.linalg.solve(
JJt + (lam ** 2) * np.eye(JJt.shape[0]), e)
theta = theta + step * dtheta
return theta, max_iter # 没收敛也要返回,调用方决定怎么办

几个调试时会反复遇到的现象:

  • step 取 1.0 很容易震荡、越走越远:减到 0.2~0.3 往往就稳了。
  • lam = 0 效果最"贪心",但一进奇异就蹦;lam 太大则慢到收敛不动——经验值 0.01~0.1。
  • 迭代 500 次还没收敛,往往不是数值问题,而是目标本来就不可达

3.4 奇异点处理:为什么纯伪逆会爆

奇异点(singular configuration)在几何上对应"机械臂瞬间少了一个自由度"——最经典的例子是6-DoF 机械臂完全伸直:第 2、3 关节共线,腕关节的某些方向再怎么转,末端也出不去。

数学上 ,伪逆 的某些元素 。后果:关节速度指令瞬间飙到机械极限之外。

三种处理思路,从软到硬:

  1. DLS 阻尼(上一节的默认做法)。
  2. 奇异值截断(Truncated SVD):把小于阈值的奇异值强行置 0,等价于放弃那个方向的修正。
  3. 路径规划层面提前绕开:把奇异区在工作空间里框出来,IK 之前就不让目标落进去。

实战上,DLS + 关节限位 + 起步 seed 多样化是性价比最高的组合。到第 4、5 章的四足 / 末端执行器时还会再碰到它。

3.5 把 IK 结果喂给 PD:串行控制回路

IK 给出的是关节目标角,不是力矩。把它喂进第 1 章那条 PD 回路,就构成了机器人学里最基础的Task-space → Joint-space → Torque 三层串联:

p_target ──▶ [IK] ──▶ θ_target ──▶ [PD] ──▶ τ ──▶ MuJoCo 物理

│ θ, θ̇
└────── data.qpos / qvel

一个能跑起来的最小控制循环:

import mujoco

model = mujoco.MjModel.from_xml_path('planar_3dof.xml')
data = mujoco.MjData(model)

Kp = np.array([40.0, 40.0, 20.0])
Kd = np.array([ 4.0, 4.0, 2.0])

theta = np.zeros(3) # IK 的热启动值
for _ in range(5000):
p_target = np.array([0.4, 0.1]) # 或者第 3.6 节里让它画圆
sol = ik_dls(fk_fn, jac_fn, theta, p_target)
if sol is not None:
theta, _ = sol # IK 结果作为 PD 的目标

q, dq = data.qpos[:3], data.qvel[:3]
data.ctrl[:3] = Kp * (theta - q) + Kd * (-dq)

mujoco.mj_step(model, data)

两条别忽略的工程点:

  1. 把上一步的 theta 作为下一步 IK 的热启动——DLS 是局部方法,起点离得越近迭代越少、越不容易跳到"肘上/肘下"的另一解。
  2. IK 的频率可以低于 PD。典型值 IK 200 Hz、PD 1 kHz,这也是真机上大多数控制栈的默认配置。

:::tip 想立刻看效果? 不想装 MuJoCo 可以直接打开 IK Playground,鼠标拖目标就能看 DLS 的收敛过程——尤其是拖到机械臂"伸直"那条奇异线附近,能直观感受 在做什么。页面里跑的就是上面这个 ik_dls,只是搬到浏览器里、不挂物理引擎。 :::

3.6 实验:末端画一个圆

用一个参数化目标轨迹,就能看到 IK + PD 是不是真的"连成一条线":

import numpy as np

center = np.array([0.45, 0.0])
R, T = 0.08, 4.0 # 半径 8 cm,周期 4 s

while data.time < 20.0:
t = data.time
p_target = center + R * np.array([np.cos(2*np.pi*t/T),
np.sin(2*np.pi*t/T)])
sol = ik_dls(fk_fn, jac_fn, theta, p_target)
if sol is not None:
theta, _ = sol

q, dq = data.qpos[:3], data.qvel[:3]
data.ctrl[:3] = Kp * (theta - q) + Kd * (-dq)
mujoco.mj_step(model, data)

观察重点:

  • 圆画得圆不圆:不圆往往是 PD 跟不上,加大 或降低圆速度。
  • 半径 加大到工作空间边缘时,圆会突然变成一段弧——那是 IK 开始返回 None,末端走不出去。
  • 圆心 center 设到 这种绝对不可达的位置,可以看到 IK 直接退化成"朝目标方向伸直"。DLS 在这里是优雅失败,不崩不炸,而是尽力靠近。

这段脚本录一段 15 秒的视频,配上 实际轨迹图,是本章组队学习任务的标准交付物。

组队学习任务

  • ik_analytic.py:3-DoF 平面臂解析解
  • ik_numeric.py:DLS 数值解,支持 6-DoF
  • 录一段 15 秒的"末端画圆" MuJoCo 视频

参考资料

  • CS123 Lab 3 & Lecture 4(IK 章节)
  • Buss, "Introduction to Inverse Kinematics with Jacobian Transpose, Pseudoinverse and Damped Least Squares methods"